K401StockDesk.sol
RWA acquisition. Batched buys with a hard max-slippage; distributes equities pro-rata by multiplier.
307 lines13.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 {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; |
| 7 | import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; |
| 8 | import { |
| 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 | */ |
| 31 | contract 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.