Skip to content

feat(blockchain): add pallet-network-validator (ADR-011 first slice) - #43

Merged
flo2517 merged 6 commits into
mainfrom
feat/issue-29-network-validator-pallet
Aug 6, 2026
Merged

feat(blockchain): add pallet-network-validator (ADR-011 first slice)#43
flo2517 merged 6 commits into
mainfrom
feat/issue-29-network-validator-pallet

Conversation

@flo2517

@flo2517 flo2517 commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Summary

First implementation slice of #29, building on ADR-011 (#40): a new pallet-network-validator that gives the chain an actual, bonded Network Validator identity/lifecycle.

  • register_validator(stake) — reserves a real stake via the runtime's existing pallet_balances (ReservableCurrency), not a self-reported number, so it's an actual Sybil deterrent per ADR-011 §1.
  • request_exit / withdraw_unbonded — bounded unbonding period; a validator stops being is_active the moment it requests exit, not just after the period completes.
  • suspend / reinstateSuspensionOrigin-gated (root for the MVP; a real dispute/governance origin is ADR-011 §5 follow-up).
  • NetworkValidatorInspector::is_active(&AccountId) -> bool — the narrow interface other pallets will check.

Wired into the runtime at pallet_index(16), Currency = Balances (already configured for pallet_balances), SuspensionOrigin = EnsureRoot.

Explicitly out of scope for this PR

Per ADR-011, this pallet only answers "is this account a bonded, active validator." It does not decide committee/challenge assignment, self-assignment exclusion, or evidence aggregation — those are separate pieces. availability/reputation's EnsureRoot origins are untouched here; moving them to signed + is_active-checked origins is a deliberately separate follow-up PR, since it changes the security model of two already-shipped pallets and deserves its own review.

Verification

Run from blockchain/ (sandbox lacks libclang for openinfra-node's rocksdb dependency, so the node itself isn't buildable here — untouched by this change regardless):

$ cargo fmt --all -- --check
$ SKIP_WASM_BUILD=1 cargo clippy --workspace --exclude openinfra-node --all-targets -- -D warnings
$ SKIP_WASM_BUILD=1 cargo test --workspace --exclude openinfra-node

11 new pallet tests (registration bounds, insufficient-balance rejection, double-registration, exit/unbonding timing, suspend/reinstate origin checks) plus every existing pallet/runtime test — including the runtime's call-index stability test — still pass.

Depends on

#40 (ADR-011) — this PR implements it; ideally reviewed together or after.

🤖 Generated with Claude Code

florianjeandenans-tech and others added 5 commits August 6, 2026 08:38
Test pallets and runtime (~9m18s) and Check node compilation (~18m28s)
were together the slowest ~28min of the Blockchain / Substrate CI job
(close to its 60min timeout) because both trigger runtime/build.rs's
substrate-wasm-builder, which cross-compiles the entire runtime +
pallet dependency graph a second time for wasm32-unknown-unknown and
runs wasm-opt over it -- for a WASM binary neither step ever uses.

WASM_BINARY is only read at runtime, in chain_spec.rs's development
chain-spec builder (called when the node actually starts), and the
two runtime crate tests only exercise genesis-preset/call-index logic
-- neither compile- nor test-time path needs the real blob. Set
SKIP_WASM_BUILD=1 for the job so wasm-builder emits a stub instead.

Verified locally: 'cargo check -p openinfra-runtime' with
SKIP_WASM_BUILD=1 still succeeds; a real wasm build is unaffected
outside CI (dev-up, cargo build --release).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Identity/stake/lifecycle registry for the Network Validator role
defined in ADR-011: register_validator (bonds a real reserved stake
via the runtime's existing pallet_balances, not a self-reported
number), request_exit/withdraw_unbonded (bounded unbonding period),
and root-gated suspend/reinstate for dispute resolution. Exposes
NetworkValidatorInspector::is_active for other pallets to check.

Deliberately scoped to just identity/stake/lifecycle -- it does not
decide committee/challenge assignment, self-assignment exclusion, or
evidence aggregation (still-to-be-implemented pieces per ADR-011).
availability/reputation origins are untouched in this change; moving
them from EnsureRoot to signed+is_active-checked is a follow-up PR,
kept separate since it touches two already-shipped pallets' security
model.

Wired into the runtime at pallet_index(16), Currency = Balances
(already configured), SuspensionOrigin = EnsureRoot for the MVP.

Verified locally (blockchain/):
- cargo fmt --all -- --check
- SKIP_WASM_BUILD=1 cargo clippy --workspace --exclude openinfra-node --all-targets -- -D warnings
- SKIP_WASM_BUILD=1 cargo test --workspace --exclude openinfra-node
  (11 new pallet tests + all existing pallet/runtime tests, including
  the call-index stability test, still pass)
openinfra-node itself isn't buildable in this sandbox (no libclang for
its rocksdb dependency) -- untouched by this change regardless.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This branch forked from ci/skip-wasm-build-for-checks before its fix
commit, so merging main back in re-added the already-reverted
job-level SKIP_WASM_BUILD alongside the correct step-level one --
squash merges collapse history, so main's squash diff couldn't know
the job-level block had been added then removed on the source branch.
Re-checked out ci.yml from origin/main to match exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@flo2517
flo2517 merged commit a5f0bef into main Aug 6, 2026
4 checks passed
@flo2517
flo2517 deleted the feat/issue-29-network-validator-pallet branch August 6, 2026 07:55
flo2517 added a commit that referenced this pull request Aug 6, 2026
…rk Validators (#46)

Second slice of #29/ADR-011, building on pallet-network-validator (#43).

availability::ProofOrigin and reputation::UpdateOrigin move from
frame_system::EnsureRoot (the Control Plane bridge acting alone) to a
new EnsureActiveValidator<T> origin: signed + T::ValidatorInspector::
is_active(&who), modeled on frame_system::EnsureSignedBy with a
dynamic membership check in place of a static SortedMembers set.

Each pallet redeclares its own narrow NetworkValidatorInspector trait
(same pattern already used for ProviderInspector) rather than
depending on pallet-network-validator directly, so there's no new
pallet-to-pallet Cargo dependency; the runtime's new
ActiveValidatorLookup glues both to pallet-network-validator's
is_active, mirroring RegisteredProviderInspector.

availability::ChallengeOrigin stays EnsureRoot -- the Control Plane
still issues on-chain challenges; only proof *submission* moves to
validator accounts in this slice, per ADR-011's explicit scope.
pallet-network-validator's own SuspensionOrigin also stays EnsureRoot
(emergency admin override, unrelated to routine scoring).

Adds 8 new tests (4 per pallet) exercising EnsureActiveValidator
directly: accepts a signed active validator, rejects a signed inactive
account, rejects Root (deliberately not a privilege shortcut here --
only a real registered validator may score), rejects None. All prior
tests are unaffected (each pallet's own mock Config keeps
EnsureRoot for these origins in its *test* setup; only the real
runtime's wiring changed).

Verified (blockchain/):
- cargo fmt --all -- --check
- SKIP_WASM_BUILD=1 cargo clippy --workspace --exclude openinfra-node --all-targets -- -D warnings
- SKIP_WASM_BUILD=1 cargo test --workspace --exclude openinfra-node
  (availability 11/11, reputation 11/11, runtime 4/4 incl. the
  call-index stability test, full suite green)

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 6, 2026
…tion (#47)

Third slice of #29/ADR-011, after the validator registry (#43) and
validator-gated origins (#46). This is the scoring pipeline itself.

pallet-network-validator gains two extrinsics (ADR-011 §3/§5):

- submit_evidence(provider, round, dimension, score_bps, sample_count,
  payload_hash): one attributable integer observation per validator.
  Rejects non-active validators, self-scoring (validator == provider),
  duplicate submissions for the same (provider, round, dimension),
  out-of-range scores (basis points, 0..=10_000), zero-sample claims,
  and anything arriving after the round closed. Bounded by
  MaxSubmissionsPerRound so consensus state stays bounded; only the
  integer summary is stored, with payload_hash addressing the full
  evidence off-chain.

- close_round(provider, round, dimension): requires MinQuorum
  independent submissions, aggregates with an integer trimmed mean
  (drop one lowest + one highest when >= 3 submissions), stores the
  result with submissions/committee_target/closed_at so readers can
  compute confidence and spot degraded quorum, then pushes the score
  into reputation. Idempotent: a closed round can neither re-close nor
  accept late evidence.

pallet-reputation gains set_dimension_score(), a non-extrinsic entry
point that sets one vector component from a basis-points score and
recomputes global. It keeps pallet-reputation the only writer of
ReputationVectors, still enforcing MaxScore, regardless of which
pallet triggered the update.

Neither pallet depends on the other: each declares its own dimension
enum and the runtime's new ScoringReputationUpdater maps between them,
matching the existing ProviderInspector/ActiveValidatorLookup pattern.

All arithmetic is integer and checked/saturating -- no floats, no
unbounded loops (aggregation iterates a BoundedVec).

Adds 10 scoring tests: inactive/suspended validator rejected,
self-scoring rejected, duplicate/out-of-range/zero-sample rejected,
sub-quorum round refuses to close and pushes nothing to reputation,
outliers trimmed (a 0 and a 10_000 among three 6_000s yields exactly
6_000, where a plain mean would give 5_200), minimum-quorum committee
trims to the median, closed round rejects late evidence and double
close with exactly one reputation update, per-round submission bound
enforced.

Verified (blockchain/): cargo fmt --all -- --check; SKIP_WASM_BUILD=1
cargo clippy --workspace --exclude openinfra-node --all-targets --
-D warnings; SKIP_WASM_BUILD=1 cargo test --workspace --exclude
openinfra-node (network-validator 21/21, reputation 11/11,
availability 11/11, runtime 4/4, full suite green).

Still open in #29: deterministic committee assignment (who is expected
to score whom in a round), dispute_round, validator reward/penalty
accrual, and dashboard validator views.

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 6, 2026
…#49)

Fifth slice of #29/ADR-011 §5, completing the on-chain scoring
lifecycle after the registry (#43), gated origins (#46), evidence
aggregation (#47) and committee assignment (#48).

dispute_round(provider, round, dimension): callable by the scored
provider or by any validator that sat on that round's committee,
within DisputeWindow blocks of closing. It immediately rolls the
dimension back to the value it held before the round applied -- a
contested score must not keep influencing scheduling while it is
unresolved -- and marks the round Disputed.

resolve_dispute(provider, round, dimension, uphold): SuspensionOrigin-
gated, since ADR-011 defers full on-chain adjudication. Upholding
keeps the rollback (DisputeUpheld); rejecting re-applies the round's
aggregate (DisputeRejected).

Making the rollback exact rather than approximate required knowing the
pre-round value, so close_round now captures it via a new
ReputationUpdater::dimension_score reader and stores it on the round
as previous_score_bps. pallet-reputation gains dimension_score_bps(),
inverting set_dimension_score's scaling; the round trip is exact
whenever MaxScore divides 10_000 evenly (it is 1_000 in the runtime,
a 10 bps step) and otherwise deterministically truncating -- never
node-dependent. RoundResult also gains an explicit RoundStatus
(Final/Disputed/DisputeUpheld/DisputeRejected) so a reader can never
mistake a contested score for an accepted one.

Adds 8 dispute tests: rollback restores the exact pre-round value
established by an earlier round; only the provider or a committee
member may dispute (an active-but-unassigned validator is rejected);
the window is enforced; a round cannot be disputed twice; disputing an
unknown round fails; upholding keeps the rollback; rejecting
re-applies the aggregate; resolution requires governance and an actual
dispute. 33 tests in this pallet, 81 across the workspace.

Verified (blockchain/): cargo fmt --all -- --check; SKIP_WASM_BUILD=1
cargo clippy --workspace --exclude openinfra-node --all-targets --
-D warnings; SKIP_WASM_BUILD=1 cargo test --workspace --exclude
openinfra-node.

Co-authored-by: FlorianJeandenans <florian.jeandenans@skin-soft.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
flo2517 pushed a commit that referenced this pull request Aug 6, 2026
…ssions

Sixth slice of #29/ADR-011 §5, completing the incentives bullet after
the registry (#43), gated origins (#46), aggregation (#47), committee
assignment (#48) and disputes (#49).

When a round closes, every submission that survived outlier trimming
earns PointsPerAcceptedSubmission Reward Points; the trimmed high and
low earn nothing for that round. Honest reporting is therefore the
paying strategy, and a validator that is consistently an outlier --
whether colluding or merely malfunctioning -- is paid nothing without
needing a separate detection mechanism.

Trimming had to become attributable to do this: the old trimmed_mean
sorted bare scores and lost track of who submitted what. It is now
trimmed(), returning the surviving submissions alongside their mean,
sorted by (score_bps, validator) rather than score alone -- with tied
scores that makes the choice of which entry gets trimmed
total-ordered and therefore identical on every node, which a
score-only sort did not guarantee.

pallet-rewards gains accrue_points(), a non-extrinsic entry point, so
it stays the only writer of RewardBalances and keeps its own overflow
checking regardless of caller. Validators claim through the existing
signed claim_reward path -- balances are keyed by account, so
providers and validators share it without a second store. The new
ValidatorRewards trait keeps the two pallets decoupled, wired by the
runtime's ValidatorRewardsBridge, matching the existing
ReputationUpdater/ProviderInspector pattern.

RoundClosed now reports a 'rewarded' count, so an observer can see how many
submissions actually counted without replaying the trim.

Known gap, documented at the accrual site rather than left implicit:
crediting is one-way -- an upheld dispute does not claw back points
already accrued for that round. Clawback belongs with slashing
economics, which ADR-011 explicitly leaves out of scope.

Adds 3 tests (36 in this pallet, 84 workspace-wide): only survivors of
trimming are paid and both outliers get zero; RoundClosed's rewarded
count matches the payouts; all-tied scores trim deterministically by
validator id, paying exactly the middle three.

Verified (blockchain/): cargo fmt --all -- --check; SKIP_WASM_BUILD=1
cargo clippy --workspace --exclude openinfra-node --all-targets --
-D warnings; SKIP_WASM_BUILD=1 cargo test --workspace --exclude
openinfra-node.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
flo2517 added a commit that referenced this pull request Aug 6, 2026
…ssions (#64)

Sixth slice of #29/ADR-011 §5, completing the incentives bullet after
the registry (#43), gated origins (#46), aggregation (#47), committee
assignment (#48) and disputes (#49).

When a round closes, every submission that survived outlier trimming
earns PointsPerAcceptedSubmission Reward Points; the trimmed high and
low earn nothing for that round. Honest reporting is therefore the
paying strategy, and a validator that is consistently an outlier --
whether colluding or merely malfunctioning -- is paid nothing without
needing a separate detection mechanism.

Trimming had to become attributable to do this: the old trimmed_mean
sorted bare scores and lost track of who submitted what. It is now
trimmed(), returning the surviving submissions alongside their mean,
sorted by (score_bps, validator) rather than score alone -- with tied
scores that makes the choice of which entry gets trimmed
total-ordered and therefore identical on every node, which a
score-only sort did not guarantee.

pallet-rewards gains accrue_points(), a non-extrinsic entry point, so
it stays the only writer of RewardBalances and keeps its own overflow
checking regardless of caller. Validators claim through the existing
signed claim_reward path -- balances are keyed by account, so
providers and validators share it without a second store. The new
ValidatorRewards trait keeps the two pallets decoupled, wired by the
runtime's ValidatorRewardsBridge, matching the existing
ReputationUpdater/ProviderInspector pattern.

RoundClosed now reports a 'rewarded' count, so an observer can see how many
submissions actually counted without replaying the trim.

Known gap, documented at the accrual site rather than left implicit:
crediting is one-way -- an upheld dispute does not claw back points
already accrued for that round. Clawback belongs with slashing
economics, which ADR-011 explicitly leaves out of scope.

Adds 3 tests (36 in this pallet, 84 workspace-wide): only survivors of
trimming are paid and both outliers get zero; RoundClosed's rewarded
count matches the payouts; all-tied scores trim deterministically by
validator id, paying exactly the middle three.

Verified (blockchain/): cargo fmt --all -- --check; SKIP_WASM_BUILD=1
cargo clippy --workspace --exclude openinfra-node --all-targets --
-D warnings; SKIP_WASM_BUILD=1 cargo test --workspace --exclude
openinfra-node.

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