feat(blockchain): add bounded round disputes with reputation rollback - #49
Merged
Conversation
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: 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>
flo2517
pushed a commit
that referenced
this pull request
Aug 6, 2026
Second slice of #15, on top of the delegated calls from #69. internal/blockchainbridge/resourcemarket.go: - AnnounceOfferFor/RemoveOfferFor submit announce_offer_for/ remove_offer_for, mirroring EnsureActive's proven submission shape exactly (serialize nonce use under the same mutex, sudo-wrap, sign, submit). Not live-verified: the locally running dev chain predates this pallet change and I have no way to rebuild/redeploy it in this sandbox (no libclang for openinfra-node), so the encoding is built and unit-tested against the same primitives EnsureActive already uses in production, not against a live extrinsic acceptance. - FinalizedOffer/decodeResourceOffer read pallet-resource-market's Offers map (single-key Blake2_128Concat, same shape as the reputation/validator reads from #46-#49) and decode cpu/ram/storage (fixed u32+u64+u64) plus capabilities (compact-length-prefixed bytes, the same shape decodeAccountIdVec already handles for the validator set). This is live-verified: Offers storage is unchanged by #69, so it ran against the running local dev chain during development -- correct "no offer yet" for every registered provider, no decode errors. ResourceOffer documents this bridge's own unit convention explicitly (CPU in millicores, matching workloadapi.CPUCoresToMillicores and the scheduler; RAM in MB, storage in GB, matching ResourceCapability on the wire) since the pallet's u32/u64 fields carry no unit themselves -- issue #15 asks for units to be defined, not left implicit. Adds 5 tests: fixed-field + capabilities round trip across several shapes including empty capabilities; truncated and trailing-byte inputs both rejected (a decoder that silently ignores trailing bytes would also silently accept a corrupted encoding); encodeBoundedBytes matches the existing compact decoder; storage key is deterministic and provider-distinguishing. Verified (control-plane/): go build ./...; go vet ./...; gofmt -l .; go test ./... (full suite green). Still open in #15: a reconciler that actually calls Announce/ RemoveOfferFor as provider capacity/status changes, the scheduler checking finalized on-chain offers before selecting a provider, and integer pricing (needs a proto change + consumer analysis, out of scope for a bridge-layer change). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
flo2517
added a commit
that referenced
this pull request
Aug 6, 2026
Second slice of #15, on top of the delegated calls from #69. internal/blockchainbridge/resourcemarket.go: - AnnounceOfferFor/RemoveOfferFor submit announce_offer_for/ remove_offer_for, mirroring EnsureActive's proven submission shape exactly (serialize nonce use under the same mutex, sudo-wrap, sign, submit). Not live-verified: the locally running dev chain predates this pallet change and I have no way to rebuild/redeploy it in this sandbox (no libclang for openinfra-node), so the encoding is built and unit-tested against the same primitives EnsureActive already uses in production, not against a live extrinsic acceptance. - FinalizedOffer/decodeResourceOffer read pallet-resource-market's Offers map (single-key Blake2_128Concat, same shape as the reputation/validator reads from #46-#49) and decode cpu/ram/storage (fixed u32+u64+u64) plus capabilities (compact-length-prefixed bytes, the same shape decodeAccountIdVec already handles for the validator set). This is live-verified: Offers storage is unchanged by #69, so it ran against the running local dev chain during development -- correct "no offer yet" for every registered provider, no decode errors. ResourceOffer documents this bridge's own unit convention explicitly (CPU in millicores, matching workloadapi.CPUCoresToMillicores and the scheduler; RAM in MB, storage in GB, matching ResourceCapability on the wire) since the pallet's u32/u64 fields carry no unit themselves -- issue #15 asks for units to be defined, not left implicit. Adds 5 tests: fixed-field + capabilities round trip across several shapes including empty capabilities; truncated and trailing-byte inputs both rejected (a decoder that silently ignores trailing bytes would also silently accept a corrupted encoding); encodeBoundedBytes matches the existing compact decoder; storage key is deterministic and provider-distinguishing. Verified (control-plane/): go build ./...; go vet ./...; gofmt -l .; go test ./... (full suite green). Still open in #15: a reconciler that actually calls Announce/ RemoveOfferFor as provider capacity/status changes, the scheduler checking finalized on-chain offers before selecting a provider, and integer pricing (needs a proto change + consumer analysis, out of scope for a bridge-layer change). 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
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, withinDisputeWindowblocks of closing. It immediately rolls the dimension back to its pre-round value (a contested score must not keep influencing scheduling while unresolved) and marks the roundDisputed.resolve_dispute(provider, round, dimension, uphold)—SuspensionOrigin-gated, since ADR-011 explicitly defers full on-chain adjudication. Upholding keeps the rollback (DisputeUpheld); rejecting re-applies the aggregate (DisputeRejected).Making the rollback exact
Restoring the exact prior value (rather than approximating it) meant
close_roundhas to capture it before applying. So:ReputationUpdatergains adimension_scorereader;close_roundstores the result asprevious_score_bpson the round.pallet-reputationgainsdimension_score_bps(), invertingset_dimension_score's scaling. The round trip is exact wheneverMaxScoredivides 10_000 evenly (it's 1_000 in the runtime — a 10 bps step), and otherwise deterministically truncating — never node-dependent, which is what matters for consensus.RoundResultgains an explicitRoundStatus(Final/Disputed/DisputeUpheld/DisputeRejected) so a reader can never mistake a contested score for an accepted one.Tests (8 new, 33 in this pallet, 81 workspace-wide)
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.
Verification
What's left in #29 (no longer blockchain-side)
Validator reward/penalty accrual in
pallet-rewards, dashboard validator views, and the Control Plane / Agent side that actually drives challenges over the existingSolveChallengeRPC — the piece that turns this working on-chain machinery into an exercised end-to-end flow.🤖 Generated with Claude Code