feat: Network Validator challenge loop (ADR-013 slice 4, issue #78) - #85
Merged
Conversation
Implements the last piece of the Network Validator daemon: `networkvalidator run`, a continuous loop that discovers assigned providers, challenges their Agent over mTLS, scores the response, and submits evidence/closes rounds on pallet-network-validator. Slices 1-3 (identity/lifecycle, agent-endpoint discovery, validator allowlist push) were already merged; #78 remains open, tracking dispute_round/resolve_dispute handling (ADR-013 slice 5), which is deliberately out of scope here. Protocol (protocol/proto/openinfra/agent/v1/agent.proto): - Add TYPE_NETWORK=4 and TYPE_RELIABILITY=5 to SolveChallengeRequest.Type, regenerated via make proto (generated Go committed). - provider-agent/crates/agent-api/src/lib.rs's solve_challenge handler now maps the two new variants to "network"/"reliability" resource_type strings; identical logic to the three existing variants otherwise. control-plane/internal/blockchainbridge (chain-facing primitives): - providerregistry.go: FinalizedProviderAccounts enumerates pallet-provider-registry::Providers via state_getKeysPaged (confirmed live against the running dev chain's --rpc-methods=safe allowlist; state_getPairs is rejected as unsafe, state_getKeysPaged is not). - committee.go: Committee/IsAssigned port pallet-network-validator's committee() selection bit-for-bit in Go. Verified against a real 32-byte-AccountId32 run of the actual pallet: a temporary #[test] was added to a scratch module under blockchain/pallets/network-validator/src/ (never committed -- blockchain/ is untouched in this diff, confirmed via `git diff --stat -- blockchain/`), capturing two golden vectors that are now pinned as committee_test.go's TestCommitteeMatchesRustGoldenVector. The captured Rust output was also independently cross-checked in Python before being ported to Go. - networkvalidatorregistrar.go: ScoreDimension byte-enum (Compute, Storage, Network, Availability, Reliability -- pallet declaration order), SubmitEvidence (call_index 5) and CloseRound (call_index 6), fixed-width SCALE encoding throughout (the pallet has no #[pallet::compact] fields on either call). - networkvalidatortls.go: Registrar.ClientIdentity() builds the self-signed X.509 certificate a validator presents as its mTLS client identity, reusing the same Ed25519 key already loaded for chain signing (ADR-011 §2's no-separate-validator-PKI decision). control-plane/internal/networkvalidator (new package, the loop itself): - round.go: round = finalized_block_number / round_length_blocks (default 100 blocks, ~5 minutes at this runtime's ~3s block time). Documented prominently as an off-chain convention this implementation chooses -- pallet-network-validator's `round: u64` is caller-supplied and not derived from block height by the runtime. A future multi-implementation validator ecosystem needs to standardize this; not attempted here. - endpoint.go: resolves a provider's Agent endpoint/public key via the dashboard's GET /api/v1/agent-endpoint/{provider_id} (ADR-013 slice 2). - challenge.go: dials an Agent over mTLS, sends a fresh SolveChallenge request, and verifies the response byte-for-byte against agent-api's actual signed-bytes construction (CHALLENGE_DOMAIN ++ be_u32(len) ++ challenge_id ++ be_u32(type) ++ result). Binary pass/fail scoring (10_000 bps or 0), mirroring pallets/availability's deadline philosophy rather than grading latency. - assignment.go: AssignedWork -- pure, chain-call-free local assignment selection built on Committee(), split out from the I/O loop so it is directly unit-testable. - run.go: the polling loop -- derive round, enumerate providers, compute local assignment, challenge+submit for anything newly assigned (in-memory-only `done` tracking, matching slice 3's established accepted-gap pattern: a restart's duplicate submission just fails harmlessly on-chain via DuplicateSubmission), and periodically attempt close_round with bounded retries for anything this process evidenced (most early attempts legitimately dispatch-fail with QuorumNotReached, which is invisible over RPC -- documented as "fire and let the chain reject if not ready," not a bug). Design decision called out loudly (also in code comments): the Agent's TLS server certificate is verified against an operator-supplied CA pool if AGENT_SERVER_CA_FILE is set (recommended -- a CA's public certificate is not secret, so a Control Plane operator can safely publish it for independent validators to use, matching the trust strength internal/agentmanager.NewMTLSClient already gets); if unset, the connection falls back to InsecureSkipVerify with a loud startup warning. This is a real, named security gap: this validator's own mTLS client identity stays fully verified by the Agent regardless, but without a CA file this validator cannot authenticate which server it connected to. Publishing the CA certificate through a new channel (e.g. extending the agent-endpoint response) is a reasonable follow-up, not attempted here. cmd/networkvalidator: new `run` subcommand, configured via DASHBOARD_BASE_URL (required), AGENT_SERVER_CA_FILE/ROUND_LENGTH_BLOCKS/ POLL_INTERVAL_SECONDS (optional), alongside the existing SUBSTRATE_RPC_URL/VALIDATOR_SIGNER_KEY_FILE. Verification: - make proto: clean, generated Go committed and matching. - control-plane: gofmt -l . clean, go vet ./... clean, go test ./... clean (all packages). Live-verified against SUBSTRATE_RPC_URL= http://127.0.0.1:9944 (the running local dev chain): provider enumeration cross-checked against 9 real registered providers. - provider-agent: cargo fmt --check clean, cargo clippy --workspace --all-targets -D warnings clean, cargo test --workspace clean (28 tests). - blockchain/: untouched, confirmed via git diff --stat -- blockchain/. `cargo clippy --workspace` could not be verified end-to-end in this sandbox (the node crate's rocksdb dependency needs libclang, not installed here) -- confirmed this is an environment limitation, not a code issue, by running `cargo clippy -p pallet-network-validator` (the only pallet this PR's Go code needs to stay in sync with) clean on its own. `buf lint` (protocol) is clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
flo2517
added a commit
that referenced
this pull request
Aug 7, 2026
Reads pallet-network-validator's Rounds StorageNMap directly (never read from Go before this) and surfaces it as a new dashboard endpoint and UI panel, the first concrete piece of #76's validator-facing views unblocked by #85's committee/evidence work. internal/blockchainbridge/roundresult.go: - RoundResult/RoundStatus mirror the pallet's struct/enum exactly (fixed-width SCALE, no compact encoding), verified directly against blockchain/pallets/network-validator/src/lib.rs. - roundResultStorageKey builds Rounds' 3-key StorageNMap key (Blake2_128Concat AccountId, Twox64Concat u64, Twox64Concat ScoreDimension) -- this codebase's first NMap key, everything prior only needed the single-key mapStorageKey helper. Evidence uses the identical key shape if a future caller needs it. - twox64 is twox128's 8-byte single-pass sibling (Twox64Concat's hash component). - FinalizedRoundResult reads one (provider, round, dimension) point; found=false means the round hasn't closed yet, a normal state, not an error. - ConfidenceBps turns submissions/committee_target into a display figure so a thin, low-confidence round can't be confused with a well-attested one. internal/dashboard/validatorscores.go: - GET /api/v1/validator-scores/{provider_id}: for each of the 5 score dimensions, concurrently scans the last 12 rounds back from the current round (derived via the existing internal/networkvalidator.RoundLength helper) for closed rounds, returning up to 8 per dimension. Unauthenticated and rate-limited, matching agentEndpoint's reasoning -- this is a narrower view over reputation data already public in /api/v1/overview, not a new trust boundary. Dashboard UI: a new "Historique des rounds de scoring" panel (on-demand lookup by provider_id, not folded into the periodic overview poll, since each load is real per-round chain I/O). Tested: unit tests for the storage-key construction, decode, and confidence math; live-chain integration tests (OPENINFRA_TEST_SUBSTRATE_RPC_URL) proving the whole read path against the running local dev node, including the concurrent per-dimension scan end to end via the HTTP handler. As with every pallet-network-validator interaction this session, the local dev chain's wasm predates the pallet, so only the "not found" path is live-verified, not a real closed-round decode -- documented in the test comments, not hidden. Leaves #76 open: operator views, RBAC, pagination beyond hardcoded LIMITs, accessibility review, E2E tests, and the decentralized-hosting migration doc are still outstanding. Co-authored-by: FlorianJeandenans <florian.jeandenans@skin-soft.org> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
flo2517
added a commit
that referenced
this pull request
Aug 7, 2026
) (#90) * docs: add ADR-015, independent bandwidth throughput measurement Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(protocol): add MeasureBandwidth RPC (ADR-015) Adds ProviderAgentService.MeasureBandwidth and its request/response messages exactly as specified in ADR-015 sec1. Regenerated Go bindings via make proto. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(agent-api): implement MeasureBandwidth RPC (ADR-015) Implements agent-api's measure_bandwidth handler: validates probe_id (MAX_CHALLENGE_ID) and payload/requested-size bounds (new MAX_BANDWIDTH_PROBE_BYTES = 8 MiB), generates a random (not zeroed) download_payload, computes SHA256(upload_payload), times server_processing_ms after into_inner() and before building the response, and signs a domain-separated construction consistent with solve_challenge's existing signing style: BANDWIDTH_PROBE_DOMAIN ++ be_u32(len(probe_id)) ++ probe_id ++ upload_payload_hash (32 bytes, fixed) ++ be_u32(len(download_payload)) ++ download_payload ++ be_u32(server_processing_ms) Adds a per-caller rate limiter (BandwidthRateLimiter, fixed window, 10 calls/60s) scoped to just this RPC, keyed by the caller's raw Ed25519 public key extracted from its mTLS leaf certificate via tonic::Request::peer_certs() -- real per-caller extraction, not the coarser global fallback ADR-015 allowed, mirroring agent-cli's mtls.rs allowlist verifier's identical extraction logic (duplicated, not shared, since agent-api cannot depend on agent-cli). Callers this handler cannot identify share one fallback bucket rather than bypassing the limiter. Threads a new bandwidth_rate_limiter field through AgentGrpcServer's construction in agent-cli/src/main.rs. Tests: valid probe succeeds and its hash/signature verify against a real keypair; oversized upload_payload / requested_download_bytes rejected; empty probe_id rejected; download_payload length exactly matches the request; two concurrent calls sharing one probe_id do not cross-talk; the rate limiter blocks a caller once its window budget is exhausted and a distinct caller has an independent budget; the raw- key extraction helper is verified against a real rcgen-generated self-signed certificate. cargo fmt --check, cargo clippy --workspace --all-targets -D warnings, and cargo test --workspace all pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(control-plane): wire MeasureBandwidth into the Network Validator loop (ADR-015) Adds ChallengeClient.MeasureBandwidth (internal/networkvalidator/ bandwidth.go): sends an 8 MiB random upload payload and requests an 8 MiB download payload in one round trip, verifies the Agent's signature/hash (bandwidthSignedBytes reproduces agent-api's exact signed-byte construction), estimates ingress/egress Mbps by subtracting the Agent's reported server_processing_ms from the validator's own wall-clock round trip and splitting the remainder proportionally by direction's byte count (estimateThroughputMbps -- a documented approximation, per ADR-015 sec2's own accepted coarseness), and scores pass/fail (10_000 or 0 bps) against the provider's declared ResourceCapability.Bandwidth with a 70% tolerance (bandwidthToleranceBps, ADR-015 sec5's own example value). Wires this into run.go: the Network dimension now calls MeasureBandwidth instead of the generic SolveChallenge-based Challenge() every other dimension still uses -- a deliberate, documented behavior change to already-shipped code (#85). Threads declared bandwidth capacity through: the dashboard's GET /api/v1/agent-endpoint/{provider_id} response (internal/ dashboard/agentendpoint.go) now includes bandwidth_ingress_mbps/ bandwidth_egress_mbps, read from the same Redis heartbeat cache (openinfra:heartbeat:<provider_id>) the overview endpoint's capability display already reads -- the same live directory data the scheduler uses, per ADR-015 sec5. Omitted (not zero-valued) when no fresh heartbeat capability data exists yet, so "nothing declared" is distinguishable from "declared 0 Mbps"; MeasureBandwidth's tolerance check treats both as "nothing to verify against" and passes trivially either way. AgentEndpoint (endpoint.go) carries the decoded fields through to the challenge loop. Also fixes a real bug caught by the new tests: gRPC's default 4 MiB per-message limit (both grpc-go and tonic) rejects an 8 MiB probe outright. Raises the limit on both sides: agent-cli's tonic server (ProviderAgentServiceServer::max_decoding/encoding_message_size, sized off agent-api's newly-public MAX_BANDWIDTH_PROBE_BYTES) and this package's grpc-go dial options (bandwidthMessageSizeLimit). Tests: a fake Agent (extended fakeAgentServer, shared with challenge_test.go) proves score 10_000 when measured throughput comfortably exceeds a low declared tolerance and 0 against an unreachably high declared figure; tampered upload_payload_hash and tampered signature both score 0; an unreachable Agent scores 0 with a reason, never a Go error; passesBandwidthTolerance/ estimateThroughputMbps are unit-tested directly; the end-to-end loop test (run_test.go) now asserts exactly one MeasureBandwidth call and confirms SolveChallenge is still called exactly once for each of the other four dimensions and never for TYPE_NETWORK. Dashboard tests cover the new field being omitted without a Redis client and populated from a real heartbeat cache entry (Postgres/Redis-backed, gated on OPENINFRA_TEST_DATABASE_URL/OPENINFRA_TEST_REDIS_URL per this package's existing convention). gofmt -l ., go build ./..., go vet ./..., and go test ./... all pass (both with and without the Postgres/Redis-backed integration tests enabled, against this session's running dev-stack containers). blockchain/ is untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: FlorianJeandenans <florian.jeandenans@skin-soft.org> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements ADR-013 slice 4 (docs/adr/013-network-validator-daemon.md): the
Network Validator's continuous challenge loop,
networkvalidator run. Thisis the last piece of the daemon described in ADR-013 §3 -- slices 1-3
(identity/lifecycle, agent-endpoint discovery, validator allowlist push)
were already merged in prior PRs. Issue #78 remains open, tracking
dispute_round/resolve_disputehandling (ADR-013 slice 5), which isdeliberately out of scope for this PR per the ADR's own sequencing.
Each tick the loop: derives the current round, enumerates registered
providers, computes this validator's local committee assignment (no chain
call needed beyond the active-validator-set read, per ADR-011 §1), calls
SolveChallengeover mTLS for anything newly assigned, submits evidence,and periodically attempts
close_roundwith bounded retries for rounds itevidenced.
Key design decisions
Round derivation (
internal/networkvalidator/round.go):round = finalized_block_number / round_length_blocks, withround_length_blocksdefaulting to 100 (~5 minutes at this runtime's ~3s block time) and
overridable via
ROUND_LENGTH_BLOCKS. This is loudly documented as anoff-chain convention this implementation chooses, not something
pallet-network-validatorderives or enforces -- the pallet'sround: u64is a plain caller-supplied number. A future multi-implementation validator
ecosystem needs to standardize this in its own ADR; flagged as a known gap,
not silently assumed canonical.
Agent server-certificate trust (
internal/networkvalidator/challenge.go,see
callSolveChallenge's doc comment): the Control Plane's own existingAgent client (
internal/agentmanager.NewMTLSClient) verifies an Agent'sserver certificate against a CA file the Control Plane operator already
has. An independently operated Network Validator, by ADR-013's own premise,
is a separate operator who doesn't necessarily have that file out of band.
Since a CA's public certificate isn't secret, the resolution here is: if
AGENT_SERVER_CA_FILEis set, verify fully (same strength as the ControlPlane's own client); if not set, fall back to
InsecureSkipVerifywith aloud startup warning printed to stderr and a long code comment explaining
exactly what's weakened (this validator's assurance of which server it
talked to -- its own client identity stays fully verified by the Agent
either way, and the signature check in
Challenge()is a partial,not-a-substitute mitigation). Publishing the CA cert through a new channel
(e.g. extending the agent-endpoint discovery response) is a reasonable
follow-up, not attempted here since it wasn't asked for.
committee()golden vector:blockchainbridge.Committee(new file,committee.go) portspallet-network-validator::Pallet::committeebit-for-bit. To verify it, I added a temporary
#[test]module (real32-byte
AccountId32, not the mock runtime'su64AccountIdtests.rsuses) directly inblockchain/pallets/network-validator/src/,ran it, captured two golden vectors from the actual pallet's output, then
reverted the pallet change completely --
blockchain/is untouched inthis PR's final diff (
git diff --stat -- blockchain/is empty). Thecaptured vectors were also independently cross-checked in Python before
being ported to Go, and are now pinned permanently in
committee_test.go'sTestCommitteeMatchesRustGoldenVector, assertingexact slice equality, not just "looks plausible."
Verification
make proto: clean; generated Go committed and matches (protocol/generated/go/agent/v1/agent.pb.go).control-plane/:gofmt -l .clean,go vet ./...clean,go test ./...clean across every package.Live-verified
FinalizedProviderAccounts'sstate_getKeysPagedprefix scan against the running local dev chain athttp://127.0.0.1:9944-- confirmedstate_getKeysPagedis in the node's--rpc-methods=safeallowlist (state_getPairsis rejected as unsafe), and cross-checked the decode against 9 real registered providers.provider-agent/:cargo fmt --checkclean,cargo clippy --workspace --all-targets -- -D warningsclean,cargo test --workspaceclean (28 tests, onlyagent-api'ssolve_challengetwo-variant addition touches this side).blockchain/: untouched -- confirmed viagit diff --stat -- blockchain/(empty) andgit status --porcelain -- blockchain/(empty).cargo clippy --workspacecould not be run end-to-end in this sandbox (thenodecrate's rocksdb dependency needslibclang/llvm-config, not installed here); confirmed this is a sandbox limitation and not a code issue by runningcargo clippy -p pallet-network-validator --all-targets -- -D warnings(the only pallet this PR's Go code needs to stay byte-compatible with) clean on its own.buf lint(protocol) is clean.Scope boundaries respected
dispute_round/resolve_disputehandling (ADR-013 slice 5, deliberately deferred).Evidence/RoundsNMap reads -- evidence/close-attempt tracking is in-memory-only for this process's lifetime, matching slice 3's already-established accepted-gap pattern (a restart's duplicate submission just fails harmlessly on-chain via the pallet's ownDuplicateSubmission/QuorumNotReachedchecks).blockchain/is untouched in the final diff.🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com