SKIP TO CONTENT

Demo build — mock protocol data. No chain, no ABIs yet.

  • SYNCING: ……

K401Treasury.sol

Two-bucket reserve. Bucket A is USDG and enforces rfvPerToken() >= 1e18; Bucket B holds marked tokenized equities.

293 lines11.6 KBSolidity
Source of src/K401Treasury.sol, 293 lines of Solidity
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.24;
3 
4import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
5import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
6import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
7import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
8import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
9import {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 */
26contract 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.