SKIP TO CONTENT

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

  • SYNCING: ……

K401StockDesk.sol

RWA acquisition. Batched buys with a hard max-slippage; distributes equities pro-rata by multiplier.

307 lines13.0 KBSolidity
Source of src/K401StockDesk.sol, 307 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 {
9 IERC20Like,
10 IK401Oracle,
11 IK401SeatRegistry,
12 IK401Seat6551,
13 IPriceFeed,
14 IUniswapV2Router
15} from "./interfaces/IK401Interfaces.sol";
16 
17/**
18 * @title K401StockDesk — RWA acquisition
19 *
20 * @notice Accumulates USDG from the FeeSplitter and buys tokenized equity in BATCHES,
21 * at most once an hour per symbol, with a HARD 300 bps slippage ceiling measured
22 * against the Chainlink-style feed. If the pool cannot fill inside that band the
23 * batch reverts and the desk simply keeps holding USDG.
24 *
25 * Robinhood equity pools are thin. Swapping every block is a donation to MEV.
26 *
27 * @dev Distribution is index based, never a loop over holders: each batch bumps
28 * `accPerMultiplier[symbol]` and Seats pull their pro-rata share (weighted by tier
29 * multiplier and by the Seat's own allocation weights) into their token bound account.
30 */
31contract K401StockDesk is Ownable, ReentrancyGuard {
32 using SafeERC20 for IERC20;
33 
34 uint256 public constant WAD = 1e18;
35 uint256 public constant BPS = 10_000;
36 /// @notice Minimum time between batches for a given symbol.
37 uint256 public constant MIN_BATCH_INTERVAL = 1 hours;
38 /// @notice HARD slippage ceiling. Constant — no owner, no governance, no exception.
39 uint256 public constant MAX_SLIPPAGE_BPS = 300;
40 uint8 public constant EQUITY_SYMBOL_COUNT = 11; // NVDA .. SPACEX
41 uint256 public constant SLOTS = 3;
42 
43 IERC20 public immutable usdg;
44 uint8 public immutable usdgDecimals;
45 IK401Oracle public immutable oracle;
46 IK401SeatRegistry public immutable registry;
47 IK401Seat6551 public immutable seat6551;
48 
49 IUniswapV2Router public router;
50 bool public routerLocked;
51 
52 /// @notice symbolId -> tokenized equity ERC20. ADD ONLY.
53 mapping(uint8 => address) public equityToken;
54 /// @notice symbolId -> Chainlink-style USD feed. Updatable by owner (feeds can be migrated).
55 mapping(uint8 => address) public equityFeed;
56 
57 /// @notice symbolId -> accumulated equity per unit of weighted multiplier, 1e18 scaled.
58 mapping(uint8 => uint256) public accPerMultiplier;
59 mapping(uint8 => uint256) public lastBatchTs;
60 mapping(uint8 => uint256) public totalBought;
61 
62 /// @notice Monotonic batch counter. A Seat records the value at its creation so its
63 /// accrual starts there, with no per-Seat write on mint.
64 uint256 public batchSeq;
65 /// @notice batchSeq -> symbolId -> `accPerMultiplier` at the end of that batch.
66 mapping(uint256 => mapping(uint8 => uint256)) public accSnapshot;
67 
68 /// @dev All per-Seat state is keyed by (tokenId, generation), so a recycled tokenId
69 /// starts from a clean slate with no wipe loop.
70 mapping(bytes32 => bool) public settledOnce;
71 mapping(bytes32 => mapping(uint8 => uint256)) public debt;
72 mapping(bytes32 => mapping(uint8 => uint256)) public credited;
73 
74 event SymbolListed(uint8 indexed symbolId, address token, address feed);
75 event BatchExecuted(uint8 indexed symbolId, uint256 usdgIn, uint256 bought, uint256 accPerMultiplier);
76 event Delivered(uint256 indexed tokenId, uint8 indexed symbolId, address tba, uint256 amount, uint256 usdWad);
77 
78 error AlreadySet();
79 error ZeroAddress();
80 error BadSymbol();
81 error BatchTooSoon();
82 error StaleOracle();
83 error StaleFeed();
84 error SlippageExceeded();
85 error NoVestedSeats();
86 error NothingToDeliver();
87 error NotAuthorized();
88 
89 constructor(address usdg_, address oracle_, address registry_, address seat6551_, address owner_) Ownable(owner_) {
90 if (usdg_ == address(0) || oracle_ == address(0) || registry_ == address(0) || seat6551_ == address(0)) {
91 revert ZeroAddress();
92 }
93 usdg = IERC20(usdg_);
94 usdgDecimals = IERC20Like(usdg_).decimals();
95 oracle = IK401Oracle(oracle_);
96 registry = IK401SeatRegistry(registry_);
97 seat6551 = IK401Seat6551(seat6551_);
98 }
99 
100 function setRouter(address router_) external onlyOwner {
101 if (routerLocked) revert AlreadySet();
102 if (router_ == address(0)) revert ZeroAddress();
103 router = IUniswapV2Router(router_);
104 routerLocked = true;
105 }
106 
107 /// @notice List a tradable symbol. Symbols are never delisted, only re-fed.
108 function listSymbol(uint8 symbolId, address token, address feed) external onlyOwner {
109 if (symbolId >= EQUITY_SYMBOL_COUNT) revert BadSymbol();
110 if (token == address(0) || feed == address(0)) revert ZeroAddress();
111 if (equityToken[symbolId] == address(0)) equityToken[symbolId] = token;
112 equityFeed[symbolId] = feed;
113 emit SymbolListed(symbolId, equityToken[symbolId], feed);
114 }
115 
116 /*//////////////////////////////////////////////////////////////
117 PRICING
118 //////////////////////////////////////////////////////////////*/
119 
120 function _toWad(uint256 amount, uint8 dec) internal pure returns (uint256) {
121 if (dec == 18) return amount;
122 if (dec < 18) return amount * (10 ** (18 - dec));
123 return amount / (10 ** (dec - 18));
124 }
125 
126 function _fromWad(uint256 amountWad, uint8 dec) internal pure returns (uint256) {
127 if (dec == 18) return amountWad;
128 if (dec < 18) return amountWad / (10 ** (18 - dec));
129 return amountWad * (10 ** (dec - 18));
130 }
131 
132 /// @notice Feed price of `symbolId` in 18-decimal USD.
133 function feedPriceWad(uint8 symbolId) public view returns (uint256) {
134 address feed = equityFeed[symbolId];
135 if (feed == address(0)) revert BadSymbol();
136 (, int256 answer,, uint256 updatedAt,) = IPriceFeed(feed).latestRoundData();
137 if (answer <= 0 || updatedAt == 0) revert StaleFeed();
138 return _toWad(uint256(answer), IPriceFeed(feed).decimals());
139 }
140 
141 /*//////////////////////////////////////////////////////////////
142 BATCHED BUYS
143 //////////////////////////////////////////////////////////////*/
144 
145 /**
146 * @notice Buy `usdgIn` worth of `symbolId` and index it out to Vested Seats.
147 * @dev Permissionless. Fail-closed on a stale 401K TWAP, fail-closed on a stale equity
148 * feed, fail-closed on >300 bps slippage. Reverting means the desk holds USDG,
149 * which is exactly the intended behaviour when the pool is too thin.
150 */
151 function executeBatch(uint8 symbolId, uint256 usdgIn, uint256 minOut)
152 external
153 nonReentrant
154 returns (uint256 bought)
155 {
156 if (symbolId >= EQUITY_SYMBOL_COUNT) revert BadSymbol();
157 address token = equityToken[symbolId];
158 if (token == address(0)) revert BadSymbol();
159 if (!oracle.isValid()) revert StaleOracle();
160 if (block.timestamp < lastBatchTs[symbolId] + MIN_BATCH_INTERVAL) revert BatchTooSoon();
161 
162 uint256 weighted = registry.weightedMultiplier(symbolId);
163 if (weighted == 0) revert NoVestedSeats();
164 
165 uint256 balance = usdg.balanceOf(address(this));
166 if (usdgIn > balance) usdgIn = balance;
167 if (usdgIn == 0) revert NothingToDeliver();
168 
169 // Fair amount implied by the feed, minus the hard 300 bps ceiling.
170 uint8 tokenDecimals = IERC20Like(token).decimals();
171 uint256 expectedWad = (_toWad(usdgIn, usdgDecimals) * WAD) / feedPriceWad(symbolId);
172 uint256 floorOut = _fromWad((expectedWad * (BPS - MAX_SLIPPAGE_BPS)) / BPS, tokenDecimals);
173 if (minOut < floorOut) minOut = floorOut;
174 
175 lastBatchTs[symbolId] = block.timestamp;
176 
177 address[] memory path = new address[](2);
178 path[0] = address(usdg);
179 path[1] = token;
180 
181 uint256 before = IERC20(token).balanceOf(address(this));
182 usdg.forceApprove(address(router), usdgIn);
183 router.swapExactTokensForTokens(usdgIn, minOut, path, address(this), block.timestamp);
184 bought = IERC20(token).balanceOf(address(this)) - before;
185 if (bought < floorOut) revert SlippageExceeded();
186 
187 totalBought[symbolId] += bought;
188 accPerMultiplier[symbolId] += (_toWad(bought, tokenDecimals) * WAD) / weighted;
189 
190 // Freeze the full index vector so Seats minted after this batch start here.
191 uint256 seq = ++batchSeq;
192 for (uint8 s; s < EQUITY_SYMBOL_COUNT; ++s) {
193 accSnapshot[seq][s] = accPerMultiplier[s];
194 }
195 
196 emit BatchExecuted(symbolId, usdgIn, bought, accPerMultiplier[symbolId]);
197 }
198 
199 /*//////////////////////////////////////////////////////////////
200 PER-SEAT ACCOUNTING
201 //////////////////////////////////////////////////////////////*/
202 
203 /// @dev Per-Seat storage key. Includes the registry generation, so a destroyed and
204 /// re-minted tokenId can never inherit the previous Seat's balances.
205 function seatKey(uint256 tokenId) public view returns (bytes32) {
206 return keccak256(abi.encode(tokenId, registry.generationOf(tokenId)));
207 }
208 
209 /// @dev A Seat that has never been settled starts from the index vector frozen at
210 /// the batch that was current when it was minted.
211 function _debtOf(bytes32 sk, uint256 tokenId, uint8 sym) internal view returns (uint256) {
212 if (settledOnce[sk]) return debt[sk][sym];
213 return accSnapshot[registry.startSeqOf(tokenId)][sym];
214 }
215 
216 /// @notice Move a Seat's index accrual into its credited balance. Permissionless;
217 /// also called by the registry before any multiplier / weight / mode change.
218 function settleSeat(uint256 tokenId) public {
219 bytes32 sk = seatKey(tokenId);
220 (uint8[3] memory symbols,) = registry.stocksOf(tokenId);
221 
222 for (uint256 i; i < SLOTS; ++i) {
223 uint8 sym = symbols[i];
224 if (sym >= EQUITY_SYMBOL_COUNT) continue;
225 bool seen;
226 for (uint256 j; j < i; ++j) {
227 if (symbols[j] == sym) seen = true;
228 }
229 if (seen) continue;
230 
231 uint256 acc = accPerMultiplier[sym];
232 uint256 d = _debtOf(sk, tokenId, sym);
233 if (acc > d) {
234 uint256 w = registry.seatWeightedMultiplier(tokenId, sym);
235 if (w != 0) credited[sk][sym] += (w * (acc - d)) / WAD;
236 }
237 }
238 
239 // Advance the WHOLE vector, not just the Seat's current slots: otherwise a later
240 // `setStocks` into a fresh symbol would back-claim that symbol's whole history.
241 for (uint8 s; s < EQUITY_SYMBOL_COUNT; ++s) {
242 debt[sk][s] = accPerMultiplier[s];
243 }
244 settledOnce[sk] = true;
245 }
246 
247 /// @notice Equity of `symbolId` claimable by `tokenId`, in 18-decimal units.
248 function claimable(uint256 tokenId, uint8 symbolId) public view returns (uint256) {
249 if (symbolId >= EQUITY_SYMBOL_COUNT) return 0;
250 bytes32 sk = seatKey(tokenId);
251 uint256 amount = credited[sk][symbolId];
252 uint256 acc = accPerMultiplier[symbolId];
253 uint256 d = _debtOf(sk, tokenId, symbolId);
254 if (acc > d) {
255 uint256 w = registry.seatWeightedMultiplier(tokenId, symbolId);
256 if (w != 0) amount += (w * (acc - d)) / WAD;
257 }
258 return amount;
259 }
260 
261 /**
262 * @notice Deliver accrued equity into the Seat's token bound account.
263 * @dev Lazily deploys the TBA on the first delivery. Permissionless — anyone can push
264 * a Seat's equity into its own account; it can only ever land in the TBA, which
265 * travels with the NFT.
266 */
267 function deliver(uint256 tokenId, uint8[] calldata symbolIds) external nonReentrant returns (address tba) {
268 settleSeat(tokenId);
269 bytes32 sk = seatKey(tokenId);
270 tba = seat6551.tbaFor(tokenId);
271 
272 uint256 totalUsdWad;
273 bool any;
274 for (uint256 i; i < symbolIds.length; ++i) {
275 uint8 sym = symbolIds[i];
276 if (sym >= EQUITY_SYMBOL_COUNT) revert BadSymbol();
277 uint256 amountWad = credited[sk][sym];
278 if (amountWad == 0) continue;
279 
280 address token = equityToken[sym];
281 uint8 dec = IERC20Like(token).decimals();
282 uint256 raw = _fromWad(amountWad, dec);
283 if (raw == 0) continue;
284 
285 // Effects before interaction.
286 credited[sk][sym] = amountWad - _toWad(raw, dec);
287 uint256 usdWad = (_toWad(raw, dec) * feedPriceWad(sym)) / WAD;
288 totalUsdWad += usdWad;
289 any = true;
290 
291 IERC20(token).safeTransfer(tba, raw);
292 emit Delivered(tokenId, sym, tba, raw, usdWad);
293 }
294 if (!any) revert NothingToDeliver();
295 registry.creditStockUsd(tokenId, totalUsdWad);
296 }
297 
298 /// @notice Current USDG war chest waiting to be deployed.
299 function usdgBalance() external view returns (uint256) {
300 return usdg.balanceOf(address(this));
301 }
302 
303 function nextBatchTs(uint8 symbolId) external view returns (uint256) {
304 return lastBatchTs[symbolId] + MIN_BATCH_INTERVAL;
305 }
306}
307 

Click any line number to deep-link to it — the target line highlights on load.