Skip to content

feat: Network Validator challenge loop (ADR-013 slice 4, issue #78) - #85

Merged
flo2517 merged 2 commits into
mainfrom
worktree-agent-a0cb021645fb75443
Aug 7, 2026
Merged

feat: Network Validator challenge loop (ADR-013 slice 4, issue #78)#85
flo2517 merged 2 commits into
mainfrom
worktree-agent-a0cb021645fb75443

Conversation

@flo2517

@flo2517 flo2517 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

Implements ADR-013 slice 4 (docs/adr/013-network-validator-daemon.md): the
Network Validator's continuous challenge loop, networkvalidator run. This
is 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_dispute handling (ADR-013 slice 5), which is
deliberately 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
SolveChallenge over mTLS for anything newly assigned, submits evidence,
and periodically attempts close_round with bounded retries for rounds it
evidenced.

Key design decisions

Round derivation (internal/networkvalidator/round.go): round = finalized_block_number / round_length_blocks, with round_length_blocks
defaulting to 100 (~5 minutes at this runtime's ~3s block time) and
overridable via ROUND_LENGTH_BLOCKS. This is loudly documented as an
off-chain convention this implementation chooses, not something
pallet-network-validator derives or enforces -- the pallet's round: u64
is 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 existing
Agent client (internal/agentmanager.NewMTLSClient) verifies an Agent's
server 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_FILE is set, verify fully (same strength as the Control
Plane's own client); if not set, fall back to InsecureSkipVerify with a
loud 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) ports pallet-network-validator::Pallet::committee
bit-for-bit. To verify it, I added a temporary #[test] module (real
32-byte AccountId32, not the mock runtime's u64 AccountId
tests.rs uses) directly in blockchain/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 in
this PR's final diff (git diff --stat -- blockchain/ is empty). The
captured vectors were also independently cross-checked in Python before
being ported to Go, and are now pinned permanently in
committee_test.go's TestCommitteeMatchesRustGoldenVector, asserting
exact 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's state_getKeysPaged prefix scan against the running local dev chain at http://127.0.0.1:9944 -- confirmed state_getKeysPaged is in the node's --rpc-methods=safe allowlist (state_getPairs is rejected as unsafe), and cross-checked the decode 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, only agent-api's solve_challenge two-variant addition touches this side).
  • blockchain/: untouched -- confirmed via git diff --stat -- blockchain/ (empty) and git status --porcelain -- blockchain/ (empty). cargo clippy --workspace could not be run end-to-end in this sandbox (the node crate's rocksdb dependency needs libclang/llvm-config, not installed here); confirmed this is a sandbox limitation and not a code issue by running cargo 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

  • No dispute_round/resolve_dispute handling (ADR-013 slice 5, deliberately deferred).
  • No on-chain Evidence/Rounds NMap 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 own DuplicateSubmission/QuorumNotReached checks).
  • blockchain/ is untouched in the final diff.

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

florianjeandenans-tech and others added 2 commits August 7, 2026 11:46
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
flo2517 merged commit 6103bd3 into main Aug 7, 2026
4 checks passed
@flo2517
flo2517 deleted the worktree-agent-a0cb021645fb75443 branch August 7, 2026 10:12
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants