Skip to content

fix: make reward share payouts non-dilutive (treasury-based, not minted) - #8

Merged
stefanobotticelli merged 2 commits into
mainfrom
kpi4/mintrewardshares-pps
Aug 8, 2026
Merged

fix: make reward share payouts non-dilutive (treasury-based, not minted)#8
stefanobotticelli merged 2 commits into
mainfrom
kpi4/mintrewardshares-pps

Conversation

@kalrashivam

Copy link
Copy Markdown
Collaborator

mintRewardShares() minted new shares against nothing on every payout, diluting PPS for all holders — a direct violation of the README's PPS monotonicity invariant. Unlike force-exit penalty and perf-fee (both capped on-chain), it had no cap at all.

Renamed to payRewardShares() and switched from _mint() to transferring from a pre-funded rewardsTreasury, so payouts are non-dilutive by construction instead of just being an accepted exception. Reverts on an unset or underfunded treasury rather than falling back to minting.

Also closes a seal-time gap this introduced: SystemSealer now requires rewardsTreasury to be set whenever rewardsPayoutManager is deployed. setRewardsTreasury() is blocked once the vault is sealed, so an unset treasury at seal time would otherwise be permanently unfixable.

mintRewardShares() minted new shares against nothing on every payout,
diluting PPS for all holders — a direct violation of the README's PPS
monotonicity invariant. Unlike force-exit penalty and perf-fee (both
capped on-chain), it had no cap at all.

Renamed to payRewardShares() and switched from _mint() to transferring
from a pre-funded rewardsTreasury, so payouts are non-dilutive by
construction instead of just being an accepted exception. Reverts on
an unset or underfunded treasury rather than falling back to minting.

Also closes a seal-time gap this introduced: SystemSealer now requires
rewardsTreasury to be set whenever rewardsPayoutManager is deployed.
setRewardsTreasury() is blocked once the vault is sealed, so an unset
treasury at seal time would otherwise be permanently unfixable.
…c seal-guard gap

setRewardsTreasury existed on AdminModule but was never registered in
SelectorLib/SelectorRegistry or IAdminModule, so the vault's delegatecall
router had no route to it — the treasury could never actually be set,
and payRewardShares() reverted "treasury=0" on every call.

Separately, SystemSealer's pre-seal check trusted the caller-supplied
config.rewardsTreasury struct field instead of the vault's real storage,
so a proposer could pass a plausible non-zero address at seal time while
the on-chain value stayed address(0) — permanently locking payRewardShares()
since setRewardsTreasury() is blocked post-seal. Added a rewardsTreasury()
getter on CoreVault and pointed both verifyAndSeal() and canSeal() at it.

The existing "both set" seal POC test only set the config field, never
the real value, so it was a false-positive proof of this exact gap; fixed
it to call the real setter and added a regression test that fails without
the getter fix.
@stefanobotticelli
stefanobotticelli merged commit 5be1221 into main Aug 8, 2026
1 check passed
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