Skip to content

feat(blockchain): assign validator committees deterministically - #48

Merged
flo2517 merged 1 commit into
mainfrom
feat/issue-29-committee-assignment
Aug 6, 2026
Merged

feat(blockchain): assign validator committees deterministically#48
flo2517 merged 1 commit into
mainfrom
feat/issue-29-committee-assignment

Conversation

@flo2517

@flo2517 flo2517 commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Summary

Fourth slice of #29 / ADR-011 §1 — closing a real gap in the scoring pipeline landed in #47.

The gap: with open submission, a Sybil cluster holding several bonded validator accounts could fill every slot in a round and control the trimmed mean despite quorum, because slots were self-selected on a first-come basis. Quorum counts submissions; it doesn't check who they came from.

The fix: slots are now assigned, not claimed.

Pallet::committee(provider, round) draws TargetCommitteeSize distinct validators from a new bounded, enumerable ActiveValidatorSet using blake2_256((provider, round, nth)) reduced modulo the remaining candidates, drawn without replacement, always filtering out the provider itself. submit_evidence rejects anyone outside that committee with NotAssignedToRound.

Set maintenance

ActiveValidatorSet is kept in step with Validators on every status transition — joined on register/reinstate, left on request_exit/suspend/withdraw_unbonded — so suspended and exiting validators stop receiving assignments.

Two details worth reviewer attention:

  • Removal is order-preserving (remove, not swap_remove): selection indexes into this set, so a swap would silently reshuffle assignments for unrelated providers.
  • Growth is bounded by MaxValidators (TooManyValidators), since committee selection iterates the set and must stay within bounded weight.

Known limitation (documented in the code, not left implicit)

The dev chain runs Aura, which offers no VRF, so this assignment is publicly computable in advance — a validator can predict which providers it will score. Security therefore rests on quorum, outlier trimming, and bonded stake rather than on assignment secrecy. That's exactly the tradeoff ADR-011 §1 already accepted and called out; unpredictable per-round entropy is tracked as follow-up rather than pretended-solved here.

Tests (25 in this pallet, up from 21)

New: committee is deterministic, exactly TargetCommitteeSize, distinct, excludes the provider even when the provider is itself a registered validator, and differs across rounds · shrinks gracefully below target when few validators exist · suspended/exiting validators leave the pool and reinstatement restores them · an unassigned-but-active validator is rejected · the active set is bounded.

Existing scoring tests now derive their submitters from the real committee instead of arbitrary accounts — so they exercise assignment rather than bypassing it.

Verification

$ 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

73 tests, full suite green, including the runtime call-index stability test.

Still open in #29

dispute_round, validator reward/penalty accrual, dashboard validator views, and the Control Plane/Agent side that actually drives challenges.

🤖 Generated with Claude Code

Fourth slice of #29/ADR-011 §1, closing a real gap in the scoring
pipeline landed in #47: with open submission, a Sybil cluster holding
several bonded validator accounts could fill every slot in a round and
control the trimmed mean despite quorum, because slots were
self-selected on a first-come basis.

Slots are now assigned. Pallet::committee(provider, round) draws
TargetCommitteeSize distinct validators from a new bounded, enumerable
ActiveValidatorSet using blake2_256((provider, round, nth)) reduced
modulo the remaining candidates, without replacement, always filtering
out the provider itself. submit_evidence rejects anyone outside that
committee with NotAssignedToRound.

ActiveValidatorSet is maintained on every status transition: joined on
register/reinstate, left on request_exit/suspend/withdraw_unbonded, so
suspended and exiting validators stop receiving assignments. Removal
is order-preserving (not swap_remove) because selection indexes into
the set -- a swap would silently reshuffle assignments for unrelated
providers. Growth is bounded by MaxValidators (TooManyValidators).

Known limitation, documented in the code rather than left implicit:
the dev chain runs Aura, which offers no VRF, so this assignment is
publicly computable in advance and a validator can predict which
providers it will score. Security rests on quorum, outlier trimming
and bonded stake rather than on assignment secrecy -- exactly the
tradeoff ADR-011 §1 already accepted. Unpredictable per-round entropy
is tracked as follow-up.

Tests: 25 in this pallet (up from 21). New coverage -- committee is
deterministic, exactly TargetCommitteeSize, distinct, excludes the
provider even when the provider is itself a registered validator, and
differs across rounds; shrinks gracefully below target when few
validators exist; suspended/exiting validators leave the pool and
reinstatement restores them; an unassigned-but-active validator is
rejected; the active set is bounded. Existing scoring tests now derive
their submitters from the real committee instead of arbitrary
accounts.

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 (73 tests, full suite green, incl. the runtime
call-index stability test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@flo2517
flo2517 merged commit 4e10d3a into main Aug 6, 2026
4 checks passed
@flo2517
flo2517 deleted the feat/issue-29-committee-assignment branch August 6, 2026 09:04
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