Skip to content

feat(rewarding): fold block reward through per-delegate pool (IIP-59 PR 4')#4926

Draft
envestcc wants to merge 1 commit into
iip-59/pr3prime-distribute-voter-rewardfrom
iip-59/pr4prime-block-reward-folding
Draft

feat(rewarding): fold block reward through per-delegate pool (IIP-59 PR 4')#4926
envestcc wants to merge 1 commit into
iip-59/pr3prime-distribute-voter-rewardfrom
iip-59/pr4prime-block-reward-folding

Conversation

@envestcc

Copy link
Copy Markdown
Member

Summary

PR 4' folds the block reward side of IIP-59 §3.2 on top of PR 3' (#4924, epoch stream). For opted-in delegates, the block reward is no longer credited straight to the producer at block time — it accumulates in a new per-delegate on-chain pending pool and is drained at epoch close through the same voter-split path, using BlockCommissionBasisPoints for the block portion and EpochCommissionBasisPoints for the epoch portion. Both streams collapse into a single batched `DelegateDistributed` log per delegate per epoch.

Direct prerequisite

Upstream dependencies (already in the amended stack)

What changes

New state type — per-delegate pending pool

  • `action/protocol/rewarding/pending_block_reward.go`: `pendingBlockRewardPool { amount }` at `prefix "pbrp" || candidate identifier`, plus a sorted `pbrpx` index so end-of-epoch enumeration is canonical without a namespace scan (staking-style prefix-scan + `bytes.Compare`, no Go map iteration).
  • Proto `PendingBlockRewardPool` added, `rewardingpb.pb.go` regen.
  • Helpers: `readPendingBlockRewardPool` (missing entry = zero), `creditPendingBlockRewardPool` (nil/zero is no-op), `deletePendingBlockRewardPool` (idempotent), `refundPendingBlockRewardPool` (grows `unclaimedBalance`, never touches `totalBalance`).

Block-time — `GrantBlockReward`

  • Branches on the block producer's frozen `PollSnapshot` via `staking.PollSnapshotFor(sm, producer)`:
    • fork off, or snapshot missing, or `!VoterRewardOnchainOptIn` → legacy grant to `RewardAddress`, emit `BLOCK_REWARD` log.
    • opt-in on-chain and fork on → `updateAvailableBalance` still debits, but the base reward is credited into the pool instead of the delegate account; legacy `BLOCK_REWARD` log is suppressed (the batched log at epoch close carries both streams).
  • Priority tip stays with the producer directly in both paths — it is fee income, not voter-splittable economics.

Epoch-close — `GrantEpochReward`

  • The per-candidate loop now goes through `distributeCombinedReward`, built on an extracted `allocateAndRouteVoters` helper (PR 3's per-voter routing lifted out so both streams share it). Body: `splitCommission(block, blockBps)` + `splitCommission(epoch, epochBps)` → union voter pool → one route pass → grant `blockCommission+epochCommission` to reward address → one `DelegateDistributed` log summing both streams.
  • `handled=true` → append log, `deletePendingBlockRewardPool`. `handled=false + poolAmt > 0` (opt-out mid-epoch, or the delegate opted out after already accumulating) → legacy-grant the pool balance so nothing is stranded, then legacy epoch path. `handled=false, poolAmt=0` → pure legacy path.

Orphan drain

  • After the loop, `drainPendingBlockRewardOrphans(sm, visited)` sweeps the pool index. Any ID not in `visited` is a delegate that dropped off the poll list entirely (deactivation, unregistration).
  • Destinations, in order:
    1. Live `candidate.Reward` via `staking.ConstructBaseView.GetCandidateByOwner` — handles delegates that rotated their reward address after opting in.
    2. Fallback: refund to `fund.unclaimedBalance`. Never burn — that would violate the fund invariant.

Invariants

  • `unclaimedBalance + Σ(pool entries) ≤ totalBalance` — pool participates in `unclaimedBalance` accounting. Block-time debit stays; drain does not double-debit; orphan refund goes back to `unclaimedBalance`.
  • Deterministic enumeration — sorted-bytes index, binary-search insert, no map iteration.
  • Consistent fork gating — `!featureCtx.NoVoterRewardDistribution` at both block-time accumulate and epoch-close drain. The mid-epoch fork boundary is fine because the snapshot was frozen at `PutPollResult` and already reflects fork state (per PR 2').

Test plan

  • `gofmt` / `go vet` clean
  • `go build ./...` clean
  • `go test ./action/protocol/rewarding/... -count=1` — new pool tests (credit accumulate, sorted index, delete idempotent, refund arithmetic, orphan-refund-when-candidate-gone, visited-skip) + PR 3' suite green (34 tests total).
  • `go test ./action/protocol/staking/... -count=1` — no regression on `PollSnapshotFor` consumers.
  • `go test ./state/... -count=1` — fund state helpers still pass.
  • E2E smoke: block production healthy with fork on and one opted-in delegate; single batched log at epoch close (deferred to PR 5 harness).

Explicitly out of scope

  • Stress + e2e determinism harness (PR 5).
  • Fork activation constants (PR 6).
  • Off-chain Hermes-patch filter (governance / off-chain PR).

🤖 Generated with Claude Code

…PR 4')

Under IIP-59 §3.2 an opted-in delegate's block reward can no longer be
credited directly to its RewardAddress at block time — it must join the
epoch stream and be split by the same frozen voter snapshot. This commit
introduces the pending block-reward pool, dual-rate distribution, and the
epoch-close orphan drain.

State layout: a new `_pendingBlockRewardPoolKeyPrefix` ("pbrp") holds one
`PendingBlockRewardPool` entry per delegate (proto added, pb.go regen), plus
a sorted `pbrpx` index so end-of-epoch enumeration is canonical without a
namespace scan.

GrantBlockReward now branches on the block producer's frozen
PollSnapshot (via staking.PollSnapshotFor) — if VoterRewardOnchainOptIn is
set and the fork gate is on, the base reward is debited from
unclaimedBalance and credited into the pool instead of the delegate's
account; the priority tip stays with the producer directly since it is fee
income, not voter-splittable. Legacy BLOCK_REWARD log is suppressed on the
opt-in path.

GrantEpochReward folds both streams via a new `distributeCombinedReward`
built on the extracted `allocateAndRouteVoters` helper: block portion
splits by BlockCommissionBasisPoints, epoch portion by
EpochCommissionBasisPoints, both voter pools sum into a single per-voter
allocation, and one batched DelegateDistributed log is emitted per
delegate. When the combined path declines (opt-out mid-epoch) but a pool
balance exists, the pool balance is legacy-granted to the delegate so
nothing is stranded.

After the per-candidate loop, `drainPendingBlockRewardOrphans` sweeps any
pool entries not visited this epoch — these are delegates that dropped
out of the poll list entirely. Destinations, in order: live candidate's
current Reward address (via staking.ConstructBaseView.GetCandidateByOwner)
or, if the candidate is unregistered, refund to fund.unclaimedBalance.
Never burn — the fund invariant unclaimedBalance + Σ(pool) ≤ totalBalance
stays intact.

Test suite covers pool credit accumulation, sorted-index invariant,
idempotent delete, refund fund arithmetic, drain no-op on empty index,
orphan refund when candidate is gone, and visited-set filtering.
newVoterRewardCtx now seeds the staking base view so tests exercising the
drain path can resolve candidate lookups.

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

Copy link
Copy Markdown

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