Skip to content

feat(blockchain): add bounded round disputes with reputation rollback - #49

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

feat(blockchain): add bounded round disputes with reputation rollback#49
flo2517 merged 1 commit into
mainfrom
feat/issue-29-round-disputes

Conversation

@flo2517

@flo2517 flo2517 commented Aug 6, 2026

Copy link
Copy Markdown
Owner

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, within DisputeWindow blocks 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 round Disputed.

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_round has to capture it before applying. So:

  • ReputationUpdater gains a dimension_score reader; close_round stores the result as previous_score_bps on the round.
  • pallet-reputation gains dimension_score_bps(), inverting set_dimension_score's scaling. The round trip is exact whenever MaxScore divides 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.
  • RoundResult gains an explicit RoundStatus (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

$ 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

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 existing SolveChallenge RPC — the piece that turns this working on-chain machinery into an exercised end-to-end flow.

🤖 Generated with Claude Code

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
flo2517 merged commit 6c201cf into main Aug 6, 2026
4 checks passed
@flo2517
flo2517 deleted the feat/issue-29-round-disputes branch August 6, 2026 09:17
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>
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