Skip to content

IIP-59: contract-backed rates, native compound, batched log, opt-in migration - #74

Merged
envestcc merged 5 commits into
iotexproject:masterfrom
envestcc:iip-59-contract-rate-compound-optin
Jul 16, 2026
Merged

IIP-59: contract-backed rates, native compound, batched log, opt-in migration#74
envestcc merged 5 commits into
iotexproject:masterfrom
envestcc:iip-59-contract-rate-compound-optin

Conversation

@envestcc

Copy link
Copy Markdown
Member

Summary

Four amendments to the IIP-59 draft that override the earlier all-native design (superseding parts of pending PR #73). None affect the goal — protocol-native voter reward distribution replacing the centralized Hermes service — but each closes a UX regression or migration gap in the original spec.

1. Commission-rate source: reuse DelegateProfile contract

  • Removed: native SetCommissionRate action + on-chain rate storage.
  • Added: read voter-take portions from the existing DelegateProfile contract at PutPollResult via getEncodedProfile(delegate); invert to commission (10000 − portion × 100); snapshot both BlockCommissionRate and EpochCommissionRate separately.
  • Contracts: mainnet 0xfa7f50866ac45d84adf54bc767c885f92750e258, testnet 0xd19ffB48a5C18B77c541D32c1B1ac2440c287774.
  • Semantic invariant: DelegateProfile stores the analyser-format voter-take portion (analyser/common/rewards/hermes.go:60-67). The bridge MUST invert. A regression here would silently swap voter and delegate shares; guarded by unit test against an oracle Hermes distribution over 100 mainnet delegates.
  • Empty profile → legacy path (full amount to delegate's RewardAddress), matching today's Hermes behavior for unconfigured delegates.
  • Hard prerequisite: DelegateProfile contract owner is currently a single EOA (0x68c12a8c5d5f0a1fd13319ba4840301b0c93bd4f). Before the fork, ownership MUST move to a multisig / burn / delta-cap + delay scheme. Documented in §5 Step 1 and Security Considerations.

2. Native compound via existing auto-deposit contract

  • Reuses the existing compound-registration contract (mainnet io108ckwzlzpkhva7cnfceajlu7wu6ql5kq95uat9, testnet 0xB575DfA55d9Cd04841567Fe8461BFC79D068B9Cf).
  • Per-voter routing at epoch drain: if bucketId != 0 && bucket.Owner == voter && AutoStake && status == activeAddDeposit(bucketId, amount); else accrue to unclaimedBalance for pull-claim.
  • LSD (contract-staking) holders can't compound (parity with today's Hermes behavior).
  • Preserves today's passive-user compound UX. Without this, opt-in delegates would break every compounder in their voter set on flip day.

3. Batched DelegateDistributed log

Replaces per-voter VOTER_REWARD receipt events with a single log per opted-in delegate per epoch:

DelegateDistributed{
  epoch, delegate,
  voters[], amounts[],
  commissionAmount,
  snapshotHash,           // hash of frozen voter weight snapshot
  blockRewardComponent,   // pre-commission block reward pool
  epochRewardComponent,   // pre-commission epoch reward pool
}
  • Log volume: 10k-50k/epoch → ~24-100/epoch.
  • snapshotHash gives off-chain verifiers a single anchor to check the batched arrays against the frozen voter weight snapshot.
  • Auto-splits at MaxLogPayloadBytes (200 KB default) with {seq, seqTotal} for delegates that ever exceed the cap.

4. Per-delegate opt-in transition

  • New Candidate.VoterRewardOnchainOptIn: bool.
  • New SetVoterRewardOptIn(bool) action (protobuf tag 60, ioctl stake2 optin [--off]).
  • Default post-fork: opt-out — legacy Hermes service continues to distribute the full amount from the delegate's RewardAddress.
  • Delegates must send an explicit opt-in tx; effect delayed one epoch via the poll snapshot. Bidirectional.
  • Hermes off-chain service MUST filter opted-in delegates from distributeRewards at the same snapshot boundary to prevent double-spend. Documented in Security Considerations.

Rollout is now per-delegate rather than a fork-wide flip; delegates who need more time (compound registration, voter comms, sub-tooling) don't block the rest.

What the amendment file changes

  • §1 Candidate Registration Extension: adds VoterRewardOnchainOptIn, BlockCommissionRate, EpochCommissionRate to the Candidate struct.
  • §2 New Actions: replaces SetCommissionRate with SetVoterRewardOptIn.
  • §3 Modified Reward Distribution: splits effectiveTip from base block reward (§3.1), dual-rate epoch split (§3.2), synthetic *state.Candidate for orphan drain (§3.3), 3-item PutPollResult snapshotting (§3.4), NEW §3.5 DelegateProfile contract rate source, NEW §3.6 AutoDeposit compound, NEW §3.7 per-delegate opt-in gate.
  • §4 Voter Claim Flow: ASCII diagram extended with AutoDeposit routing.
  • §5 Migration Plan: 8-step, with DelegateProfile ownership handover as Step 1.
  • §6 ioctl Integration: ioctl stake2 optin [--off].
  • §7 Skeleton Implementation: handleSetVoterRewardOptIn with idempotency + pre-fork guard, dual-rate split code, newDelegateDistributedLog emitter, NEW §7.9 SnapshotCommissionRates bridge (evm.SimulateExecution + invertToCommission), NEW §7.10 compoundOrCredit bridge with 4-precondition check + graceful degradation.
  • Rationale: adds "Why Reuse DelegateProfile", "Why Native Compound", "Why a Single Batched Log"; extends Performance Impact.
  • Backwards Compatibility: rewritten as 9 items covering all new state, RPC surface, Hermes filter requirement, contract compat, iotex-hub, restart safety.
  • Test Cases: adds need an XRC-20 contract interface and ABI #12 opt-in delay, Propose IIP-10 #13 compound routing preconditions, Propose IIP-11 #14 empty-profile fallback, update IIP-10/11 #15 batched log format, iip-12: Support staking action via Web3 #16 Hermes double-spend prevention.
  • Security Considerations: adds contract-owner governance (hard prerequisite), semantic inversion invariant, opt-in coexistence, compound reentrancy, log payload bound, contract-read determinism.
  • Protocol Changes Required: dropped handleSetCommissionRate; added handler_voter_reward_optin.go, delegate_profile_bridge.go, auto_deposit_bridge.go, stake2optin.go, SetVoterRewardOptIn action registration.

Why now, before more code work

The original design (implemented across draft PRs 1–4 in iotex-core) broke Hermes UX in four places (push→pull, compound gap, service fee removal, log volume) and had no per-delegate migration ramp. Amending the IIP first — before rewriting or extending those PRs — is cheaper than shipping code that gets rolled back after review.

Test plan

  • Verify amendment renders correctly on GitHub (headings, table, code blocks).
  • Confirm all 16 test cases are unambiguously specified against §3 and §7 (no undefined identifiers).
  • Confirm §5 Migration Plan Step 1 (DelegateProfile owner governance) is called out as blocking.
  • Confirm Security Considerations flag the Hermes off-chain filter requirement as a launch-critical dependency.
  • Confirm §7.9 bridge inverts portion → commission (not raw carry-through).

Related: supersedes portions of #73.

🤖 Generated with Claude Code

envestcc and others added 5 commits July 10, 2026 09:53
…igration

Four amendments to the IIP-59 draft that override the earlier all-native
design (pending PR iotexproject#73):

1. Commission rate source. Drop native `SetCommissionRate` action. Read
   voter-take portions from the existing `DelegateProfile` contract
   (mainnet 0xfa7f5..0e258 / testnet 0xd19ff..7774) via
   `getEncodedProfile(delegate)` at `PutPollResult`, invert to
   commission (`10000 - portion*100`), and support separate
   `BlockCommissionRate` + `EpochCommissionRate`. Empty profile falls
   back to legacy full-amount-to-delegate. Reuses operator UX; requires
   contract-owner governance (multisig / burn / delta caps) as a hard
   prerequisite.

2. Native compound via existing auto-deposit contract. When a voter
   has an active bucket registered with the compound contract
   (mainnet io108ck..q95uat9 / testnet 0xB575D..0B9Cf), the on-chain
   epoch distribution calls `AddDeposit(bucketId, amount)`; otherwise
   the share accrues to `unclaimedBalance` for pull-claim. Preserves
   today's passive-user compound UX. LSD holders get pull-claim parity
   with today.

3. Batched per-delegate log. Replace per-voter `VOTER_REWARD` receipt
   events with a single `DelegateDistributed{epoch, delegate, voters[],
   amounts[], commissionAmount, snapshotHash, blockComponent,
   epochComponent}` per opted-in delegate per epoch. Cuts receipt log
   volume from 10k-50k/epoch to ~24-100/epoch and gives off-chain
   verifiers a single anchor to check the frozen snapshot against.

4. Per-delegate opt-in transition. New `Candidate.VoterRewardOnchainOptIn`
   bool + `SetVoterRewardOptIn(bool)` action. Default post-fork is
   opt-out: legacy Hermes continues to distribute the full amount from
   the delegate's `RewardAddress`. Delegates must send an explicit
   opt-in tx; effect is delayed one epoch via the poll snapshot.
   Bidirectional (delegates can opt back out). Hermes off-chain
   service must filter opted-in delegates from `distributeRewards`
   at the same snapshot boundary to prevent double-spend.

Rationale, backwards compatibility, six new test cases (opt-in delay,
compound routing, empty-profile fallback, batched log format, Hermes
double-spend prevention, contract-rate snapshot invariance), expanded
security considerations (contract-owner governance, semantic inversion
guard, opt-in coexistence, compound reentrancy, log payload bound,
contract-read determinism), and updated Protocol Changes Required
table all included.

Co-Authored-By: Chen Chen <envestcc@gmail.com>
Adds §0 Architecture Overview at the top of Specification with a
Mermaid flowchart showing the four layers (actors, reused EVM contracts,
native protocol state, off-chain Hermes coexistence) and the data flow
from setProfile/opt-in through PutPollResult snapshotting to
GrantBlockReward pool credit and GrantEpochReward distribution
(compound via AutoDeposit or unclaimedBalance credit).

Also spells out four design invariants that fall out of the diagram:
- rates + opt-in read once per epoch and frozen at PutPollResult
- effectiveTip always to producer, only base block reward is split
- AutoDeposit read per-voter at drain time, not snapshotted
- Hermes MUST filter opted-in delegates at the same boundary

Reading guide clarifies solid = writes/transfers, dashed = reads.

Co-Authored-By: Chen Chen <envestcc@gmail.com>
Redesigns §0 diagram with four numbered stages (Config → Snapshot →
Rewarding → Sinks) in strict left-to-right layout. Key changes:

- LR direction instead of TB (fewer node crossings on wide screens).
- Removes actor nodes (Delegate/Voter) — described in prose instead.
- Drops VoterWeightView node — its output (voterWeights in snapshot)
  is what matters for the flow.
- Removes the intermediate `SnapshotCommissionRates` process node;
  the read-arrows into PollSnap carry the same information.
- Moves AutoDeposit onto the drain arrow (its actual runtime role
  as a per-voter router) rather than treating it as config source
  that also gets called back into.
- Adds a "sink" color band so the three destination accounts read
  as a single group.
- Uses ①②③④ stage markers so the reading order is unambiguous.
- Prose intro rewritten as a single stage-by-stage sentence.

Co-Authored-By: Chen Chen <envestcc@gmail.com>
…n a specific node

Feedback: subgraph frames were catching arrows at the group boundary,
making it ambiguous which inner node the arrow referenced.

Fix: remove all subgraphs and split into two focused Mermaid diagrams.

- Diagram A — Snapshot pipeline: DelegateProfile + Candidate.OptIn +
  VoterWeightView → PutPollResult → PollSnapshot. Four arrows, four
  named nodes each side.

- Diagram B — Reward flow: PollSnapshot, GrantBlockReward, PendingPool,
  GrantEpochReward, AutoDeposit router, three sinks (DelReward /
  Bucket / Unclaimed), Hermes side-path. Every arrow lands on a named
  account or the AutoDeposit router — no group boundaries.

Prose reworded to point at the two diagrams and cross-ref which §
each covers.

Co-Authored-By: Chen Chen <envestcc@gmail.com>
…rsion

Per feedback, keeping the initial TB-flow overview with the four
colored bands (contracts, staking, poll, rewarding) and actor writes
shown. The two subsequent rework attempts (LR flatten and
two-diagram split) are reverted.

Co-Authored-By: Chen Chen <envestcc@gmail.com>
envestcc added a commit to iotexproject/iotex-core that referenced this pull request Jul 13, 2026
…lure to legacy path instead of halting the block (IIP-59)

Previously any single delegate's read failure (RPC hiccup, ABI mismatch,
out-of-range portion value) at PutPollResult propagated up and failed
block production. Determinism-safe (same on-chain state ⇒ same error on
every validator ⇒ no fork) but any malformed on-chain profile would
deterministically halt the chain at every subsequent epoch boundary.

Change: bridge.Snapshot now absorbs per-delegate read errors and returns
a Registered=false entry for the affected delegate, keyed the same as
successful ones. FreezePollSnapshot writes the degraded snapshot as
normal; rewarding routes that delegate via the legacy Hermes path.
Errors are logged with delegate + contract address for observability.

Only wiring bugs stay hard errors: nil ContractReader, nil delegate
address in the slice. Those indicate a caller-side mistake, not a data
issue.

Trade "deterministic reward-path fallback" for "deterministic block
production failure" — the fallback is well-defined (legacy Hermes) and
one bad DelegateProfile field never gets to hold the chain hostage.

Test churn:
- Snapshot_OutOfRangeRejected → Snapshot_OutOfRangeDegradesToUnregistered
- Snapshot_LargeBigIntRejected → Snapshot_LargeBigIntDegradesToUnregistered
- Snapshot_ReaderErrorPropagates → Snapshot_ReaderErrorDegradesToUnregistered
- New: Snapshot_PerDelegateErrorIsolated — one bad delegate does not
  poison batched successful reads.
- FreezePollSnapshot_BridgeErrorPropagates →
  FreezePollSnapshot_BridgeErrorDegradesToLegacy: snapshot IS written,
  Registered=false, opt-in still captured from live Candidate.

Refs iotexproject/iips#74

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
envestcc added a commit to iotexproject/iotex-core that referenced this pull request Jul 13, 2026
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
envestcc added a commit to iotexproject/iotex-core that referenced this pull request Jul 13, 2026
… 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>
envestcc added a commit to iotexproject/iotex-core that referenced this pull request Jul 13, 2026
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 merged commit ff1fa64 into iotexproject:master Jul 16, 2026
@envestcc
envestcc deleted the iip-59-contract-rate-compound-optin branch July 16, 2026 02:50
envestcc added a commit to iotexproject/iotex-core that referenced this pull request Jul 16, 2026
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>
envestcc added a commit to iotexproject/iotex-core that referenced this pull request Jul 16, 2026
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
envestcc added a commit to iotexproject/iotex-core that referenced this pull request Jul 16, 2026
… 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>
envestcc added a commit to iotexproject/iotex-core that referenced this pull request Jul 16, 2026
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 added a commit to iotexproject/iotex-core that referenced this pull request Jul 21, 2026
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>
envestcc added a commit to iotexproject/iotex-core that referenced this pull request Jul 21, 2026
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
envestcc added a commit to iotexproject/iotex-core that referenced this pull request Jul 21, 2026
… 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>
envestcc added a commit to iotexproject/iotex-core that referenced this pull request Jul 21, 2026
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 added a commit to iotexproject/iotex-core 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>
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