K401Distributor.sol
Epoch emission. rate = R_MAX * clamp((P - 1) / (K - 1), 0, 1) — exactly zero at or below NAV.
186 lines7.0 KBSolidity
| 1 | // SPDX-License-Identifier: MIT |
| 2 | pragma solidity ^0.8.24; |
| 3 | |
| 4 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; |
| 5 | import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; |
| 6 | import {IK401, IK401Oracle, IK401Treasury, IK401Staking} from "./interfaces/IK401Interfaces.sol"; |
| 7 | |
| 8 | /** |
| 9 | * @title K401Distributor — premium-clamped emission |
| 10 | * |
| 11 | * EPOCH_LENGTH = 8 hours (3 rebases/day) |
| 12 | * R_MAX = 45 bps per epoch (0.45%) |
| 13 | * K_PREMIUM = 1.75e18 |
| 14 | * P = twap / nav |
| 15 | * rate = R_MAX * clamp((P - 1e18) / (K_PREMIUM - 1e18), 0, 1e18) |
| 16 | * |
| 17 | * `P <= 1.0` => `rate == 0`, exactly. No emission at or below backing; this is the |
| 18 | * single line that kills the OHM death spiral. The mint is additionally clamped by |
| 19 | * the treasury's RFV headroom so `rfvPerToken() >= 1e18` can never be breached. |
| 20 | * |
| 21 | * Emission is minted to `K401Staking`, never to holders. Liquid balances do not move. |
| 22 | */ |
| 23 | contract K401Distributor is Ownable, ReentrancyGuard { |
| 24 | /*////////////////////////////////////////////////////////////// |
| 25 | IMMUTABLE PARAMETERS |
| 26 | //////////////////////////////////////////////////////////////*/ |
| 27 | |
| 28 | uint256 public constant EPOCH_LENGTH = 8 hours; |
| 29 | uint256 public constant R_MAX_BPS = 45; |
| 30 | uint256 public constant K_PREMIUM = 1.75e18; |
| 31 | uint256 public constant WAD = 1e18; |
| 32 | uint256 public constant BPS = 10_000; |
| 33 | /// @notice Share of the epoch mint paid to whoever pokes `rebase()`. |
| 34 | uint256 public constant CALLER_REBATE_BPS = 50; // 0.50% |
| 35 | /// @notice Rebases per year, used only for the APY view. |
| 36 | uint256 public constant EPOCHS_PER_YEAR = 1095; |
| 37 | |
| 38 | IK401 public immutable k401; |
| 39 | IK401Oracle public immutable oracle; |
| 40 | IK401Treasury public immutable treasury; |
| 41 | IK401Staking public immutable staking; |
| 42 | |
| 43 | /*////////////////////////////////////////////////////////////// |
| 44 | STORAGE |
| 45 | //////////////////////////////////////////////////////////////*/ |
| 46 | |
| 47 | uint256 public epoch; |
| 48 | uint256 public nextEpochTs; |
| 49 | uint256 public lastMinted; |
| 50 | uint256 public lastRateWad; |
| 51 | uint256 public totalEmitted; |
| 52 | |
| 53 | event Rebase( |
| 54 | uint256 indexed epoch, |
| 55 | uint256 premiumWad, |
| 56 | uint256 rateWad, |
| 57 | uint256 minted, |
| 58 | uint256 toStaking, |
| 59 | uint256 callerRebate, |
| 60 | address indexed caller |
| 61 | ); |
| 62 | |
| 63 | error EpochNotOver(); |
| 64 | error ZeroAddress(); |
| 65 | |
| 66 | constructor(address k401_, address oracle_, address treasury_, address staking_, address owner_) Ownable(owner_) { |
| 67 | if (k401_ == address(0) || oracle_ == address(0) || treasury_ == address(0) || staking_ == address(0)) { |
| 68 | revert ZeroAddress(); |
| 69 | } |
| 70 | k401 = IK401(k401_); |
| 71 | oracle = IK401Oracle(oracle_); |
| 72 | treasury = IK401Treasury(treasury_); |
| 73 | staking = IK401Staking(staking_); |
| 74 | nextEpochTs = block.timestamp + EPOCH_LENGTH; |
| 75 | } |
| 76 | |
| 77 | /*////////////////////////////////////////////////////////////// |
| 78 | PURE FORMULA |
| 79 | //////////////////////////////////////////////////////////////*/ |
| 80 | |
| 81 | /// @notice `rate` as a fraction of total supply, 1e18 fixed point. |
| 82 | /// @dev Returns exactly 0 for premiumWad <= 1e18, and the R_MAX cap at or above K_PREMIUM. |
| 83 | function rateFor(uint256 premiumWad) public pure returns (uint256) { |
| 84 | if (premiumWad <= WAD) return 0; |
| 85 | uint256 numerator = premiumWad - WAD; |
| 86 | uint256 denominator = K_PREMIUM - WAD; |
| 87 | uint256 clamped = (numerator * WAD) / denominator; |
| 88 | if (clamped > WAD) clamped = WAD; |
| 89 | // R_MAX_BPS / BPS of supply, scaled by `clamped`. |
| 90 | return (R_MAX_BPS * clamped) / BPS; |
| 91 | } |
| 92 | |
| 93 | /// @notice Premium P = twap / nav, 1e18 fixed point. |
| 94 | function premium(uint256 twapWad, uint256 navWad) public pure returns (uint256) { |
| 95 | if (navWad == 0) return 0; |
| 96 | return (twapWad * WAD) / navWad; |
| 97 | } |
| 98 | |
| 99 | /*////////////////////////////////////////////////////////////// |
| 100 | VIEWS |
| 101 | //////////////////////////////////////////////////////////////*/ |
| 102 | |
| 103 | /// @notice Non-reverting preview of the next epoch. |
| 104 | function preview() |
| 105 | public |
| 106 | view |
| 107 | returns (uint256 premiumWad, uint256 rateWad, uint256 mintAmount, bool oracleOk) |
| 108 | { |
| 109 | (uint256 twap, bool ok) = oracle.peek(); |
| 110 | oracleOk = ok; |
| 111 | if (!ok) return (0, 0, 0, false); |
| 112 | uint256 navWad = treasury.nav(); |
| 113 | premiumWad = premium(twap, navWad); |
| 114 | rateWad = rateFor(premiumWad); |
| 115 | mintAmount = (k401.totalSupply() * rateWad) / WAD; |
| 116 | uint256 headroom = treasury.maxMintable(); |
| 117 | if (mintAmount > headroom) mintAmount = headroom; |
| 118 | if (staking.totalStaked() == 0) mintAmount = 0; |
| 119 | } |
| 120 | |
| 121 | /// @notice Compounded APY implied by the current epoch rate, 1e18 fixed point. |
| 122 | function apy() external view returns (uint256) { |
| 123 | (, uint256 rateWad,,) = preview(); |
| 124 | if (rateWad == 0) return 0; |
| 125 | uint256 base = WAD + rateWad; |
| 126 | uint256 result = WAD; |
| 127 | uint256 n = EPOCHS_PER_YEAR; |
| 128 | while (n != 0) { |
| 129 | if (n & 1 == 1) result = (result * base) / WAD; |
| 130 | base = (base * base) / WAD; |
| 131 | n >>= 1; |
| 132 | } |
| 133 | return result - WAD; |
| 134 | } |
| 135 | |
| 136 | /*////////////////////////////////////////////////////////////// |
| 137 | REBASE |
| 138 | //////////////////////////////////////////////////////////////*/ |
| 139 | |
| 140 | /** |
| 141 | * @notice Permissionless epoch roll. Fail-closed on a stale oracle. |
| 142 | * @dev Liquid ERC20 balances are untouched: the mint goes to `K401Staking` |
| 143 | * (plus a 0.5% rebate to the caller) and is distributed through the index. |
| 144 | */ |
| 145 | function rebase() external nonReentrant returns (uint256 minted, uint256 rebateToCaller) { |
| 146 | if (block.timestamp < nextEpochTs) revert EpochNotOver(); |
| 147 | |
| 148 | uint256 twap = oracle.consult(); // reverts StaleOracle if outside the valid window |
| 149 | uint256 navWad = treasury.nav(); |
| 150 | uint256 premiumWad = premium(twap, navWad); |
| 151 | uint256 rateWad = rateFor(premiumWad); |
| 152 | |
| 153 | uint256 amount = (k401.totalSupply() * rateWad) / WAD; |
| 154 | |
| 155 | // Never mint past RFV coverage. |
| 156 | uint256 headroom = treasury.maxMintable(); |
| 157 | if (amount > headroom) amount = headroom; |
| 158 | |
| 159 | // With nothing staked there is no index to grow into; skip rather than strand tokens. |
| 160 | if (staking.totalStaked() == 0) amount = 0; |
| 161 | |
| 162 | uint256 toStaking; |
| 163 | if (amount != 0) { |
| 164 | rebateToCaller = (amount * CALLER_REBATE_BPS) / BPS; |
| 165 | toStaking = amount - rebateToCaller; |
| 166 | if (toStaking != 0) { |
| 167 | k401.mint(address(staking), toStaking); |
| 168 | staking.notifyEmission(toStaking); |
| 169 | } |
| 170 | if (rebateToCaller != 0) k401.mint(msg.sender, rebateToCaller); |
| 171 | } |
| 172 | |
| 173 | minted = amount; |
| 174 | lastMinted = amount; |
| 175 | lastRateWad = rateWad; |
| 176 | totalEmitted += amount; |
| 177 | |
| 178 | unchecked { |
| 179 | epoch += 1; |
| 180 | } |
| 181 | nextEpochTs = block.timestamp + EPOCH_LENGTH; |
| 182 | |
| 183 | emit Rebase(epoch, premiumWad, rateWad, amount, toStaking, rebateToCaller, msg.sender); |
| 184 | } |
| 185 | } |
| 186 |
Click any line number to deep-link to it — the target line highlights on load.