K401Treasury.sol
Two-bucket reserve. Bucket A is USDG and enforces rfvPerToken() >= 1e18; Bucket B holds marked tokenized equities.
293 lines11.6 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 {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; |
| 7 | import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; |
| 8 | import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; |
| 9 | import {IK401, IERC20Like, IPriceFeed, IUniswapV2Pair} from "./interfaces/IK401Interfaces.sol"; |
| 10 | |
| 11 | /** |
| 12 | * @title K401Treasury — two-bucket reserve |
| 13 | * |
| 14 | * Bucket A · RFV : USDG (plus protocol-owned 401K/USDG LP, valued at the geometric |
| 15 | * mean so the 401K side is never marked above the 1 USDG floor). |
| 16 | * This bucket alone defends `rfvPerToken() >= 1e18`. |
| 17 | * Bucket B · EQUITY : tokenized equities marked with Chainlink-style price feeds, |
| 18 | * never with on-chain swap quotes — Robinhood equity pools are thin |
| 19 | * and a spot quote is trivially manipulable. |
| 20 | * |
| 21 | * `nav()` and `rfv()` are DIFFERENT numbers and both are load-bearing: |
| 22 | * - the Distributor prices the premium against `nav()`, |
| 23 | * - the BondDepository floors its price at `nav()`, |
| 24 | * - the Buyback bids at `rfv()` minus 150 bps — never at `nav()`. |
| 25 | */ |
| 26 | contract K401Treasury is Ownable, ReentrancyGuard { |
| 27 | using SafeERC20 for IERC20; |
| 28 | |
| 29 | uint256 public constant WAD = 1e18; |
| 30 | /// @notice The hard floor: one 401K is always redeemable for at least 1 USDG of RFV. |
| 31 | uint256 public constant FLOOR_RFV_PER_TOKEN = 1e18; |
| 32 | |
| 33 | IK401 public immutable k401; |
| 34 | IERC20 public immutable usdg; |
| 35 | uint8 public immutable usdgDecimals; |
| 36 | |
| 37 | /// @notice Reserve assets counted into Bucket A. ADD ONLY. |
| 38 | mapping(address => bool) public isReserveToken; |
| 39 | /// @notice Protocol-owned 401K/USDG LP counted into Bucket A at geometric-mean value. ADD ONLY. |
| 40 | mapping(address => bool) public isPolToken; |
| 41 | address[] public polTokens; |
| 42 | |
| 43 | /// @notice Bucket B assets. ADD ONLY. |
| 44 | mapping(address => bool) public isEquityToken; |
| 45 | address[] public equityTokens; |
| 46 | mapping(address => IPriceFeed) public priceFeed; |
| 47 | |
| 48 | /// @notice Modules allowed to call `deposit` / `mintForModule` (BondDepository). ADD ONLY. |
| 49 | mapping(address => bool) public isDepositor; |
| 50 | /// @notice Modules allowed to pull assets out (Buyback, StockDesk). ADD ONLY. |
| 51 | mapping(address => bool) public isReserveManager; |
| 52 | |
| 53 | event ReserveTokenAdded(address indexed token); |
| 54 | event PolTokenAdded(address indexed token); |
| 55 | event EquityTokenAdded(address indexed token, address indexed feed); |
| 56 | event DepositorAdded(address indexed account); |
| 57 | event ReserveManagerAdded(address indexed account); |
| 58 | event Deposit(address indexed token, uint256 amount, uint256 valueWad, uint256 minted); |
| 59 | event Managed(address indexed token, uint256 amount, address indexed to); |
| 60 | |
| 61 | error NotAuthorized(); |
| 62 | error ZeroAddress(); |
| 63 | error UnsupportedToken(); |
| 64 | error FloorBreached(); |
| 65 | error ProfitTooHigh(); |
| 66 | error StaleFeed(); |
| 67 | |
| 68 | constructor(address k401_, address usdg_, address owner_) Ownable(owner_) { |
| 69 | if (k401_ == address(0) || usdg_ == address(0)) revert ZeroAddress(); |
| 70 | k401 = IK401(k401_); |
| 71 | usdg = IERC20(usdg_); |
| 72 | usdgDecimals = IERC20Like(usdg_).decimals(); |
| 73 | isReserveToken[usdg_] = true; |
| 74 | emit ReserveTokenAdded(usdg_); |
| 75 | } |
| 76 | |
| 77 | /*////////////////////////////////////////////////////////////// |
| 78 | ADD-ONLY CONFIGURATION |
| 79 | //////////////////////////////////////////////////////////////*/ |
| 80 | |
| 81 | function addReserveToken(address token) external onlyOwner { |
| 82 | if (token == address(0)) revert ZeroAddress(); |
| 83 | isReserveToken[token] = true; |
| 84 | emit ReserveTokenAdded(token); |
| 85 | } |
| 86 | |
| 87 | function addPolToken(address lp) external onlyOwner { |
| 88 | if (lp == address(0)) revert ZeroAddress(); |
| 89 | if (!isPolToken[lp]) { |
| 90 | isPolToken[lp] = true; |
| 91 | polTokens.push(lp); |
| 92 | } |
| 93 | emit PolTokenAdded(lp); |
| 94 | } |
| 95 | |
| 96 | function addEquityToken(address token, address feed) external onlyOwner { |
| 97 | if (token == address(0) || feed == address(0)) revert ZeroAddress(); |
| 98 | if (!isEquityToken[token]) { |
| 99 | isEquityToken[token] = true; |
| 100 | equityTokens.push(token); |
| 101 | } |
| 102 | priceFeed[token] = IPriceFeed(feed); |
| 103 | emit EquityTokenAdded(token, feed); |
| 104 | } |
| 105 | |
| 106 | function addDepositor(address account) external onlyOwner { |
| 107 | if (account == address(0)) revert ZeroAddress(); |
| 108 | isDepositor[account] = true; |
| 109 | emit DepositorAdded(account); |
| 110 | } |
| 111 | |
| 112 | function addReserveManager(address account) external onlyOwner { |
| 113 | if (account == address(0)) revert ZeroAddress(); |
| 114 | isReserveManager[account] = true; |
| 115 | emit ReserveManagerAdded(account); |
| 116 | } |
| 117 | |
| 118 | /*////////////////////////////////////////////////////////////// |
| 119 | VALUATION |
| 120 | //////////////////////////////////////////////////////////////*/ |
| 121 | |
| 122 | function _toWad(uint256 amount, uint8 dec) internal pure returns (uint256) { |
| 123 | if (dec == 18) return amount; |
| 124 | if (dec < 18) return amount * (10 ** (18 - dec)); |
| 125 | return amount / (10 ** (dec - 18)); |
| 126 | } |
| 127 | |
| 128 | /// @notice Value of `amount` of `token` in 18-decimal USD. Reverts for unsupported tokens. |
| 129 | function valueOf(address token, uint256 amount) public view returns (uint256) { |
| 130 | if (isReserveToken[token]) { |
| 131 | return _toWad(amount, IERC20Like(token).decimals()); |
| 132 | } |
| 133 | if (isPolToken[token]) { |
| 134 | return _lpValue(token, amount); |
| 135 | } |
| 136 | if (isEquityToken[token]) { |
| 137 | return _equityValue(token, amount); |
| 138 | } |
| 139 | revert UnsupportedToken(); |
| 140 | } |
| 141 | |
| 142 | /// @dev Geometric-mean LP valuation: 2*sqrt(r401 * rUsdgWad) * share. |
| 143 | /// By AM-GM this is <= r401 * (1 USDG) + rUsdgWad, i.e. the 401K side is never |
| 144 | /// marked above the 1 USDG floor price. Market price is never consulted. |
| 145 | function _lpValue(address lp, uint256 amount) internal view returns (uint256) { |
| 146 | IUniswapV2Pair pair = IUniswapV2Pair(lp); |
| 147 | (uint112 r0, uint112 r1,) = pair.getReserves(); |
| 148 | address t0 = pair.token0(); |
| 149 | uint256 r401; |
| 150 | uint256 rUsd; |
| 151 | if (t0 == address(k401)) { |
| 152 | r401 = uint256(r0); |
| 153 | rUsd = _toWad(uint256(r1), usdgDecimals); |
| 154 | } else { |
| 155 | r401 = uint256(r1); |
| 156 | rUsd = _toWad(uint256(r0), usdgDecimals); |
| 157 | } |
| 158 | uint256 total = pair.totalSupply(); |
| 159 | if (total == 0) return 0; |
| 160 | uint256 k = Math.sqrt(r401 * rUsd); |
| 161 | return (2 * k * amount) / total; |
| 162 | } |
| 163 | |
| 164 | function _equityValue(address token, uint256 amount) internal view returns (uint256) { |
| 165 | IPriceFeed feed = priceFeed[token]; |
| 166 | (, int256 answer,, uint256 updatedAt,) = feed.latestRoundData(); |
| 167 | if (answer <= 0 || updatedAt == 0) revert StaleFeed(); |
| 168 | uint8 fd = feed.decimals(); |
| 169 | uint256 priceWad = _toWad(uint256(answer), fd); |
| 170 | uint256 amountWad = _toWad(amount, IERC20Like(token).decimals()); |
| 171 | return (amountWad * priceWad) / WAD; |
| 172 | } |
| 173 | |
| 174 | /*////////////////////////////////////////////////////////////// |
| 175 | BUCKET REPORTING |
| 176 | //////////////////////////////////////////////////////////////*/ |
| 177 | |
| 178 | /// @notice Bucket A — risk-free value, 18-decimal USD. |
| 179 | function reserveValueWad() public view returns (uint256 total) { |
| 180 | total = _toWad(usdg.balanceOf(address(this)), usdgDecimals); |
| 181 | uint256 n = polTokens.length; |
| 182 | for (uint256 i; i < n; ++i) { |
| 183 | address lp = polTokens[i]; |
| 184 | uint256 bal = IERC20(lp).balanceOf(address(this)); |
| 185 | if (bal != 0) total += _lpValue(lp, bal); |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | /// @notice Bucket B — marked equity reserve, 18-decimal USD. |
| 190 | function equityValueWad() public view returns (uint256 total) { |
| 191 | uint256 n = equityTokens.length; |
| 192 | for (uint256 i; i < n; ++i) { |
| 193 | address t = equityTokens[i]; |
| 194 | uint256 bal = IERC20(t).balanceOf(address(this)); |
| 195 | if (bal != 0) total += _equityValue(t, bal); |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | function totalValueWad() public view returns (uint256) { |
| 200 | return reserveValueWad() + equityValueWad(); |
| 201 | } |
| 202 | |
| 203 | /// @notice (Bucket A + Bucket B) per 401K, 1e18 fixed point. |
| 204 | function nav() public view returns (uint256) { |
| 205 | uint256 supply = k401.totalSupply(); |
| 206 | if (supply == 0) return 0; |
| 207 | return (totalValueWad() * WAD) / supply; |
| 208 | } |
| 209 | |
| 210 | /// @notice Bucket A per 401K, 1e18 fixed point. The buyback bids here. |
| 211 | function rfv() public view returns (uint256) { |
| 212 | uint256 supply = k401.totalSupply(); |
| 213 | if (supply == 0) return 0; |
| 214 | return (reserveValueWad() * WAD) / supply; |
| 215 | } |
| 216 | |
| 217 | function rfvPerToken() external view returns (uint256) { |
| 218 | return rfv(); |
| 219 | } |
| 220 | |
| 221 | /// @notice How much 401K can still be minted without pushing rfvPerToken below 1e18. |
| 222 | function maxMintable() public view returns (uint256) { |
| 223 | uint256 reserve = reserveValueWad(); |
| 224 | uint256 supply = k401.totalSupply(); |
| 225 | return reserve > supply ? reserve - supply : 0; |
| 226 | } |
| 227 | |
| 228 | function _assertFloor() internal view { |
| 229 | uint256 supply = k401.totalSupply(); |
| 230 | if (supply == 0) return; |
| 231 | if ((reserveValueWad() * WAD) / supply < FLOOR_RFV_PER_TOKEN) revert FloorBreached(); |
| 232 | } |
| 233 | |
| 234 | /*////////////////////////////////////////////////////////////// |
| 235 | RESERVE ACTIONS |
| 236 | //////////////////////////////////////////////////////////////*/ |
| 237 | |
| 238 | /** |
| 239 | * @notice Deposit reserve assets and mint `value - profit` of 401K to the caller. |
| 240 | * @param token Reserve or POL token. |
| 241 | * @param amount Raw token amount, pulled from the caller. |
| 242 | * @param profitWad Value (18-dec USD) retained by the treasury as backing surplus. |
| 243 | * @return send 401K minted to the caller. |
| 244 | */ |
| 245 | function deposit(address token, uint256 amount, uint256 profitWad) |
| 246 | external |
| 247 | nonReentrant |
| 248 | returns (uint256 send) |
| 249 | { |
| 250 | if (!isDepositor[msg.sender]) revert NotAuthorized(); |
| 251 | if (!isReserveToken[token] && !isPolToken[token]) revert UnsupportedToken(); |
| 252 | |
| 253 | IERC20(token).safeTransferFrom(msg.sender, address(this), amount); |
| 254 | uint256 valueWad = valueOf(token, amount); |
| 255 | if (profitWad > valueWad) revert ProfitTooHigh(); |
| 256 | send = valueWad - profitWad; |
| 257 | |
| 258 | if (send != 0) k401.mint(msg.sender, send); |
| 259 | _assertFloor(); |
| 260 | |
| 261 | emit Deposit(token, amount, valueWad, send); |
| 262 | } |
| 263 | |
| 264 | /// @notice Deposit equity into Bucket B. Permissionless — it can only raise NAV. |
| 265 | function depositEquity(address token, uint256 amount) external nonReentrant { |
| 266 | if (!isEquityToken[token]) revert UnsupportedToken(); |
| 267 | IERC20(token).safeTransferFrom(msg.sender, address(this), amount); |
| 268 | } |
| 269 | |
| 270 | /// @notice Mint against existing surplus backing. Floor-checked. Depositors only. |
| 271 | function mintForModule(address to, uint256 amount) external nonReentrant { |
| 272 | if (!isDepositor[msg.sender]) revert NotAuthorized(); |
| 273 | k401.mint(to, amount); |
| 274 | _assertFloor(); |
| 275 | } |
| 276 | |
| 277 | /// @notice Pull assets out of the treasury. Reverts if it would break the floor. |
| 278 | function manage(address token, uint256 amount, address to) external nonReentrant { |
| 279 | if (!isReserveManager[msg.sender]) revert NotAuthorized(); |
| 280 | IERC20(token).safeTransfer(to, amount); |
| 281 | _assertFloor(); |
| 282 | emit Managed(token, amount, to); |
| 283 | } |
| 284 | |
| 285 | function equityTokenCount() external view returns (uint256) { |
| 286 | return equityTokens.length; |
| 287 | } |
| 288 | |
| 289 | function polTokenCount() external view returns (uint256) { |
| 290 | return polTokens.length; |
| 291 | } |
| 292 | } |
| 293 |
Click any line number to deep-link to it — the target line highlights on load.