Skip to content

feat(staking,poll): freeze DelegateProfile commission rates + opt-in at PutPollResult (IIP-59) - #4915

Closed
envestcc wants to merge 5 commits into
iip-59/era-genesis-paramsfrom
iip-59/pr2-snapshot-writer
Closed

feat(staking,poll): freeze DelegateProfile commission rates + opt-in at PutPollResult (IIP-59)#4915
envestcc wants to merge 5 commits into
iip-59/era-genesis-paramsfrom
iip-59/pr2-snapshot-writer

Conversation

@envestcc

Copy link
Copy Markdown
Member

Summary

  • Adds a per-candidate CandidatePollSnapshot frozen at every PutPollResult, capturing block/epoch commission basis points from the DelegateProfile contract, a Registered bit, and the delegate's VoterRewardOnchainOptIn flag from PR feat(staking): add Candidate.VoterRewardOnchainOptIn for IIP-59 (amended) #4911.
  • Wires a config- and fork-gated call into poll.setCandidates; downstream rewarding (PR 3', follow-up) will read via staking.PollSnapshotFor instead of live sources so mid-epoch mutations do not retroactively re-split rewards.
  • Introduces Blockchain.DelegateProfileContractAddress (per-network YAML) and FeatureCtx.NoVoterRewardDistribution (bound to !g.IsToBeEnabled(height)) as the two independent gates.

Stack

Stacks on top of both:

Diff will look larger than the actual delta until those two land.

Scope explicitly deferred

  • Voter-weight source. `Entries` is written empty for now; PR 3' has a degenerate branch that treats an empty list as full-amount-to-delegate. A separate follow-up fills in the actual weight computation.
  • Rewarding consumption of the snapshot (PR 3').
  • `SetVoterRewardOptIn` native action (separate PR).

Feature-flag matrix

`NoVoterRewardDistribution` `DelegateProfileContractAddress` Behaviour
true (pre-fork) any Snapshot NOT written; `PollSnapshotFor` returns `ErrStateNotExist` for every candidate.
false (post-fork) empty Snapshot written with `Registered=false`, rates zero, `OptIn` from live Candidate. Rewarding falls back to legacy path.
false (post-fork) non-empty Bridge called; snapshot carries frozen rates + registration bit + opt-in flag.

Test plan

  • `go test ./action/protocol/staking/...` — 14 new tests in `poll_snapshot_test.go` cover serialize roundtrip, key layout, nil-bridge / happy-path / partial-profile / bridge-error / invalid-address, reader semantics, opt-in read from live Candidate.
  • `go test ./action/protocol/poll/...` — no regressions in the poll layer; setCandidates writes snapshots when the fork gate is active.
  • `go build ./...`, `go vet ./action/protocol/... ./blockchain/genesis/...`, `gofmt -l` — clean.
  • Determinism spot-check under e2e (deferred to PR 5' harness).

Refs iotexproject/iips#74

🤖 Generated with Claude Code

@envestcc

Copy link
Copy Markdown
Member Author

Force-pushed a rebase on top of the updated PR #4912.

Change: the bridge-semantic fix (absorb per-delegate read errors as Registered=false instead of erroring out) has been folded into PR #4912 where it belongs. The only remaining commit in this PR on top of the writer itself is a docstring + test rename (FreezePollSnapshot_BridgeErrorPropagatesFreezePollSnapshot_BridgeErrorDegradesToLegacy) that follows the new bridge contract.

No behavioural change relative to the prior HEAD of this branch — same three-commit diff, cleaner split of responsibilities across the stack.

envestcc and others added 5 commits July 16, 2026 11:39
Introduce a read-only bridge over the existing DelegateProfile contract
(mainnet io1lfl4ppn2c3wcft04f0rk0jy9lyn4pcjcm7638u) so that PutPollResult
can snapshot per-delegate commission rates from the amended IIP-59 design
(iotexproject/iips#74).

The bridge invokes getProfileByField twice per delegate (blockRewardPortion
and epochRewardPortion), inverts the on-chain voter-take portion into
commission basis points, and returns a per-delegate map. Empty bytes for
either field flag the delegate as unregistered so the caller falls back to
the legacy Hermes path; a partial profile is deliberately treated as
unregistered to preserve the "either fully opted-in or fully legacy"
invariant. Explicit zero voter-take remains distinguishable (single 0x00
byte) and yields a registered 100% commission split. Values exceeding
uint64 or 10000 basis points are rejected rather than silently truncated.

The package has no dependency on protocol.StateManager and is exercised by
14 unit tests using an in-process ABI-round-tripping fake reader.

Refs iotexproject/iips#74. Follow-up PR (2') will call Snapshot at
PutPollResult and freeze the returned rates into the poll snapshot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…legacy path

Snapshot originally propagated any per-delegate read error up, which at the
IIP-59 consensus entry point (PutPollResult) would deterministically halt
block production at every subsequent epoch boundary if one delegate's
DelegateProfile field was malformed or a view call transiently failed.

Same on-chain state produces the same error on every validator, so the
fork stays safe — but wedging the chain is strictly worse than routing the
one bad delegate through the well-defined legacy Hermes path. Absorb the
per-delegate error, emit Registered=false for that entry, and log for
observability. Nil-reader and nil-address stay hard errors: those are
wiring bugs, not on-chain data issues, and must surface loudly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a persistent per-delegate opt-in flag on staking.Candidate that gates
protocol-native voter reward distribution. Default false — post-fork the
legacy path (full block/epoch reward to RewardAddress, off-chain Hermes
service continues) still runs unless the delegate explicitly opts in.

Scope of this change is intentionally narrow:

- stakingpb.Candidate gains field 11 (voterRewardOnchainOptIn bool).
- staking.Candidate Go struct gains VoterRewardOnchainOptIn; Clone, Equal,
  toProto, fromProto all thread it. Default zero-value keeps existing
  candidates opting out on decode.
- TestSerWithVoterRewardOnchainOptIn covers false/true round-trip through
  proto, Equal's flag sensitivity, and Clone independence.

Deferred to follow-up PRs (per amended IIP-59, iotexproject/iips#74):

- SetVoterRewardOptIn native action + handler that mutates the flag with
  a one-epoch delay via the PutPollResult snapshot (bidirectional flip).
- BlockCommissionRate / EpochCommissionRate — these are per-epoch snapshot
  values populated from the DelegateProfile contract at PutPollResult;
  they live on the poll snapshot (PR 2') rather than the persistent
  Candidate.

Superseded PRs: #4865 / #4866 / #4880 / #4881 (all closed 2026-07-10).

Refs iotexproject/iips#74
… opt-in at PutPollResult (IIP-59)

Introduces the per-candidate poll snapshot IIP-59 needs at each epoch boundary.
Downstream rewarding (PR 3', follow-up) reads this snapshot instead of the
live DelegateProfile contract / live staking.Candidate so a mid-epoch
mutation cannot retroactively re-split rewards that have already begun
accruing.

Wiring:

- stakingpb.CandidatePollSnapshot: block/epoch commission basis points,
  registered flag, opt-in flag, per-voter entries (empty in this PR).
  stakingpb.VoterWeightEntry per-voter tuple.
- staking._candidatePollSnapshot = 5 tag byte; full key
  {tag}||candID.Bytes() under _stakingNameSpace.
- staking.FreezePollSnapshot: writer called from poll/util.setCandidates.
  Nil bridge (contract not configured) → skip rate freeze but still
  capture opt-in from live Candidate; Registered=false forces legacy
  fallback downstream. Any per-delegate error aborts the whole snapshot
  write (no partial map).
- staking.PollSnapshotFor: reader; returns ErrStateNotExist pre-fork /
  pre-write.
- staking.readLiveOptIn: degrades to false when the poll list names a
  candidate that has no staking record, rather than wedging the chain.
- poll.freezeIIP59PollSnapshot: fork + config gate. Guarded by
  fCtx.NoVoterRewardDistribution (pre-fork no-op) and
  Blockchain.DelegateProfileContractAddress (empty ⇒ nil bridge).
- poll.delegateProfileContractReader: view-call plumbing mirrors
  consortium.getContractReaderForGenesisStates verbatim
  (address.ZeroAddress caller, evm.SimulateExecution).
- genesis.Blockchain.DelegateProfileContractAddress: per-network config;
  default empty.
- protocol.FeatureCtx.NoVoterRewardDistribution: fork gate bound to
  !g.IsToBeEnabled(height); zero-value = active post-fork.

Intentionally out of scope for this PR:

- Voter-weight source. Entries is written empty; PR 3' has a degenerate
  branch (empty voter list ⇒ full amount as commission). Follow-up PR
  fills in the actual weight computation.
- Rewarding consumption of the snapshot (PR 3').
- SetVoterRewardOptIn action to mutate the field this snapshot freezes
  (separate PR).

Stacks on iip-59/pr1-candidate-schema-optin (#4911) and
iip-59/pr4.5-delegateprofile-bridge (#4912).

Refs iotexproject/iips#74

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Follows PR 4.5's change to have delegateprofile.Bridge.Snapshot degrade
per-delegate read failures to Registered=false instead of erroring out.

FreezePollSnapshot's docstring now says "on bridge error the snapshot is
still written with Registered=false; opt-in remains captured from the
live Candidate" so downstream (PR 3') has a single degradation contract
to reason about.

Test rename: FreezePollSnapshot_BridgeErrorPropagates →
FreezePollSnapshot_BridgeErrorDegradesToLegacy — asserts the snapshot
IS written and opt-in is preserved on bridge error.

Refs iotexproject/iips#74

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@envestcc
envestcc force-pushed the iip-59/pr2-snapshot-writer branch 2 times, most recently from fd1b6e7 to 1667ba9 Compare July 16, 2026 03:52
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
5.0% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@envestcc
envestcc changed the base branch from master to iip-59/era-genesis-params July 16, 2026 03:54
envestcc added a commit that referenced this pull request Jul 21, 2026
Wire the three read-side bridges landed upstream — PR 2' (frozen poll
snapshot), PR 4.6 (autodeposit compound bridge), PR 4.7 (batched
DelegateDistributed log) — into GrantEpochReward. For each opted-in
delegate, IIP-59 §3.2 now: splits the epoch pool by the frozen commission
basis points, allocates the voter pool proportionally by frozen voter
weight in canonical order, routes each per-voter share to compound
(native AddDeposit) or credit (rewarding unclaimedBalance), credits the
delegate's commission to its reward address, and emits exactly one
batched DelegateDistributed log per delegate.

Feature-flag matrix stays honoured:

  NoVoterRewardDistribution=true (pre-fork) → return (nil, false, nil);
    caller runs legacy grantToAccount unchanged.
  VoterRewardOnchainOptIn=false               → same fallback (opt-out).
  no poll snapshot yet                        → same fallback (first
    epoch after registration).
  Registered=false (bridge degraded)          → same fallback.
  autoDepositBridge nil                       → split runs; every voter
    routes to credit (compound routing inactive).

Per-item consensus fallback per feedback-consensus-fallback-vs-halt:
malformed on-chain data (bridge RPC error, bucket read error, ineligible
bucket) downgrades the affected voter to credit rather than halting the
block. Wiring errors (nil staking protocol, log-encoder failure) still
hard-fail.

Cross-protocol seam: staking.AddDepositForCompound is a package-exported
entry point for the rewarding-side compound path. Skips the
handleDepositToStake action-plumbing checks that PR 3' has already
enforced upstream (positive bucketID from AutoDeposit.bucket(voter);
IsBucketEligibleForCompound confirmed native/active/AutoStake/Owner).
Does NOT emit a staking receipt log — the batched DelegateDistributed
log is the single source of truth per delegate.

New:
- action/protocol/rewarding/voter_reward.go — distributeVoterReward
  + splitCommission (basis-points helper) + resolveAutoDepositReader.
- action/protocol/rewarding/voter_reward_test.go — 14 tests covering
  splitCommission edge cases, fork gate, nil-input guards, missing-
  snapshot fallback, bridge-nil default, and Option wiring.
- action/protocol/staking/add_deposit_compound.go — cross-protocol seam.

Modified:
- action/protocol/rewarding/reward.go — one call site + one branch
  inside GrantEpochReward's per-delegate loop. splitEpochReward now
  returns the filtered candidate list so callers see the same index
  domain as addrs/amounts.
- action/protocol/rewarding/protocol.go — Option type +
  WithAutoDepositBridge / WithAutoDepositReader; NewProtocol accepts
  opts ...Option so 11 existing call sites remain unchanged.
- chainservice/builder.go — construct autodeposit.Bridge from
  Blockchain.AutoDepositContractAddress at registerRewardingProtocol;
  empty string ⇒ nil bridge (compound routing inactive).

Stacks on:
- iip-59/pr4.7-delegatedistributed-log (#4923) — merge base.
- iip-59/pr2-snapshot-writer (#4915) — merged in via d7fe741.
- iip-59/pr4.6-autodeposit-bridge (#4922) — via #4923.
- iip-59/pr4.5-delegateprofile-bridge (#4912) — via #4915.

Deliberately out of scope: block-reward folding + orphan drain
(PR 4'); voter-weight source population (Entries currently empty
per PR 2' skeleton — downstream degenerate branch pays full amount as
commission until the weight-source follow-up lands).

Refs iotexproject/iips#74

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@envestcc

Copy link
Copy Markdown
Member Author

Superseded by #4953 (consolidated IIP-59 on-chain voter reward distribution). Contents folded into that PR wholesale; review and merge happen there.

@envestcc envestcc closed this Jul 21, 2026
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.

1 participant