K401SeatRegistry.sol
The game layer: tier, multiplier, mode, stock slots. Tier persists through transfer. upgrade() and fuse().
384 lines14.7 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 {SeatMode, Symbol, IK401, IK401StockDesk} from "./interfaces/IK401Interfaces.sol"; |
| 7 | |
| 8 | /** |
| 9 | * @title K401SeatRegistry — the game layer |
| 10 | * @notice Per-Seat tier, multiplier, mode, stock allocation and lifetime stats. |
| 11 | * |
| 12 | * @dev TIER PERSISTS THROUGH TRANSFER. `onSeatTransfer` is a deliberate no-op for |
| 13 | * wallet -> wallet moves; it only initialises on mint and clears on burn. |
| 14 | * A Seat's tier is its resale value and resale volume is protocol revenue. |
| 15 | */ |
| 16 | contract K401SeatRegistry is Ownable, ReentrancyGuard { |
| 17 | /*////////////////////////////////////////////////////////////// |
| 18 | CONSTANTS |
| 19 | //////////////////////////////////////////////////////////////*/ |
| 20 | |
| 21 | uint8 public constant MAX_TIER = 7; |
| 22 | uint256 public constant BPS = 10_000; |
| 23 | /// @notice `upgrade()` cost curve: 25_000e18 * 2^(tier-1). |
| 24 | uint256 public constant UPGRADE_BASE_COST = 25_000e18; |
| 25 | /// @notice Number of same-tier Seats consumed by one `fuse()`. 3 in, 1 out. |
| 26 | uint256 public constant FUSE_INPUT_COUNT = 3; |
| 27 | /// @notice Number of tokenized-equity slots per Seat. |
| 28 | uint256 public constant SLOTS = 3; |
| 29 | uint8 internal constant SYMBOL_COUNT = 12; // includes USDG sentinel |
| 30 | uint8 internal constant EQUITY_SYMBOL_COUNT = 11; // NVDA .. SPACEX |
| 31 | |
| 32 | /*////////////////////////////////////////////////////////////// |
| 33 | STORAGE |
| 34 | //////////////////////////////////////////////////////////////*/ |
| 35 | |
| 36 | struct Seat { |
| 37 | uint8 tier; // 0 == does not exist |
| 38 | SeatMode mode; |
| 39 | uint32 generation; // bumped on every create/destroy; rekeys the StockDesk ledger |
| 40 | uint32 startSeq; // StockDesk batch counter at creation; the accrual start point |
| 41 | uint64 clockInTs; |
| 42 | uint128 lifetimeStockUsd; |
| 43 | uint8[3] symbols; |
| 44 | uint16[3] weightsBps; |
| 45 | } |
| 46 | |
| 47 | IK401 public immutable k401; |
| 48 | |
| 49 | mapping(uint256 => Seat) internal _seats; |
| 50 | |
| 51 | /// @notice Sum of the multipliers of every VESTED Seat. |
| 52 | uint256 public totalVestedMultiplier; |
| 53 | /// @notice Per-symbol sum of `multiplier * weightBps / 10000` over VESTED Seats. |
| 54 | mapping(uint8 => uint256) public weightedMultiplier; |
| 55 | |
| 56 | address public staking; |
| 57 | address public stockDesk; |
| 58 | bool public stakingLocked; |
| 59 | bool public stockDeskLocked; |
| 60 | |
| 61 | /*////////////////////////////////////////////////////////////// |
| 62 | EVENTS / ERRORS |
| 63 | //////////////////////////////////////////////////////////////*/ |
| 64 | |
| 65 | event SeatCreated(uint256 indexed tokenId, address indexed to); |
| 66 | event SeatDestroyed(uint256 indexed tokenId); |
| 67 | event TierUpgraded(uint256 indexed tokenId, uint8 newTier, uint256 cost); |
| 68 | event SeatsFused(uint256 indexed keptTokenId, uint256[] burned, uint8 newTier); |
| 69 | event StocksSet(uint256 indexed tokenId, uint8[3] symbols, uint16[3] weightsBps); |
| 70 | event ModeChanged(uint256 indexed tokenId, SeatMode mode); |
| 71 | event StockCredited(uint256 indexed tokenId, uint256 usdWad); |
| 72 | |
| 73 | error NotAuthorized(); |
| 74 | error AlreadySet(); |
| 75 | error ZeroAddress(); |
| 76 | error SeatDoesNotExist(); |
| 77 | error NotSeatOwner(); |
| 78 | error MaxTier(); |
| 79 | error BadFuseInput(); |
| 80 | error BadWeights(); |
| 81 | error BadSymbol(); |
| 82 | error SeatIsOnTheClock(); |
| 83 | error DuplicateSeat(); |
| 84 | |
| 85 | /*////////////////////////////////////////////////////////////// |
| 86 | CONSTRUCTOR |
| 87 | //////////////////////////////////////////////////////////////*/ |
| 88 | |
| 89 | constructor(address k401_, address owner_) Ownable(owner_) { |
| 90 | if (k401_ == address(0)) revert ZeroAddress(); |
| 91 | k401 = IK401(k401_); |
| 92 | } |
| 93 | |
| 94 | function setStaking(address staking_) external onlyOwner { |
| 95 | if (stakingLocked) revert AlreadySet(); |
| 96 | if (staking_ == address(0)) revert ZeroAddress(); |
| 97 | staking = staking_; |
| 98 | stakingLocked = true; |
| 99 | } |
| 100 | |
| 101 | function setStockDesk(address desk) external onlyOwner { |
| 102 | if (stockDeskLocked) revert AlreadySet(); |
| 103 | if (desk == address(0)) revert ZeroAddress(); |
| 104 | stockDesk = desk; |
| 105 | stockDeskLocked = true; |
| 106 | } |
| 107 | |
| 108 | /*////////////////////////////////////////////////////////////// |
| 109 | MULTIPLIERS |
| 110 | //////////////////////////////////////////////////////////////*/ |
| 111 | |
| 112 | /// @notice Tier -> multiplier, 1e18 fixed point. Tier 1 = 1.00x ... Tier 7 = 3.50x. |
| 113 | function tierMultiplier(uint8 tier) public pure returns (uint256) { |
| 114 | if (tier == 0 || tier > MAX_TIER) return 0; |
| 115 | if (tier == 1) return 1.00e18; |
| 116 | if (tier == 2) return 1.25e18; |
| 117 | if (tier == 3) return 1.60e18; |
| 118 | if (tier == 4) return 2.00e18; |
| 119 | if (tier == 5) return 2.45e18; |
| 120 | if (tier == 6) return 2.95e18; |
| 121 | return 3.50e18; |
| 122 | } |
| 123 | |
| 124 | /// @notice `upgrade()` cost to go from `tier` to `tier + 1`. |
| 125 | function upgradeCost(uint8 tier) public pure returns (uint256) { |
| 126 | if (tier == 0 || tier >= MAX_TIER) return 0; |
| 127 | return UPGRADE_BASE_COST << (tier - 1); |
| 128 | } |
| 129 | |
| 130 | /*////////////////////////////////////////////////////////////// |
| 131 | VIEWS |
| 132 | //////////////////////////////////////////////////////////////*/ |
| 133 | |
| 134 | function tierOf(uint256 tokenId) public view returns (uint8) { |
| 135 | return _seats[tokenId].tier; |
| 136 | } |
| 137 | |
| 138 | function multiplierOf(uint256 tokenId) public view returns (uint256) { |
| 139 | return tierMultiplier(_seats[tokenId].tier); |
| 140 | } |
| 141 | |
| 142 | function modeOf(uint256 tokenId) external view returns (SeatMode) { |
| 143 | return _seats[tokenId].mode; |
| 144 | } |
| 145 | |
| 146 | function generationOf(uint256 tokenId) external view returns (uint32) { |
| 147 | return _seats[tokenId].generation; |
| 148 | } |
| 149 | |
| 150 | /// @notice StockDesk batch counter at the moment this Seat was created. |
| 151 | function startSeqOf(uint256 tokenId) external view returns (uint256) { |
| 152 | return _seats[tokenId].startSeq; |
| 153 | } |
| 154 | |
| 155 | function clockInTsOf(uint256 tokenId) external view returns (uint64) { |
| 156 | return _seats[tokenId].clockInTs; |
| 157 | } |
| 158 | |
| 159 | function lifetimeStockUsd(uint256 tokenId) external view returns (uint256) { |
| 160 | return _seats[tokenId].lifetimeStockUsd; |
| 161 | } |
| 162 | |
| 163 | function seatInfo(uint256 tokenId) external view returns (Seat memory) { |
| 164 | return _seats[tokenId]; |
| 165 | } |
| 166 | |
| 167 | function stocksOf(uint256 tokenId) external view returns (uint8[3] memory, uint16[3] memory) { |
| 168 | Seat storage s = _seats[tokenId]; |
| 169 | return (s.symbols, s.weightsBps); |
| 170 | } |
| 171 | |
| 172 | /// @notice Effective weight of `tokenId` in symbol `symbolId`, in multiplier units. |
| 173 | function seatWeightedMultiplier(uint256 tokenId, uint8 symbolId) public view returns (uint256 w) { |
| 174 | Seat storage s = _seats[tokenId]; |
| 175 | if (s.tier == 0 || s.mode != SeatMode.VESTED) return 0; |
| 176 | uint256 mul = tierMultiplier(s.tier); |
| 177 | for (uint256 i; i < SLOTS; ++i) { |
| 178 | if (s.symbols[i] == symbolId && s.weightsBps[i] != 0) { |
| 179 | w += (mul * s.weightsBps[i]) / BPS; |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | /*////////////////////////////////////////////////////////////// |
| 185 | LIFECYCLE (K401 ONLY) |
| 186 | //////////////////////////////////////////////////////////////*/ |
| 187 | |
| 188 | /// @notice Called by K401 on Seat creation (`from == 0`) and destruction (`to == 0`). |
| 189 | function onSeatTransfer(address from, address to, uint256 tokenId) external { |
| 190 | if (msg.sender != address(k401)) revert NotAuthorized(); |
| 191 | if (from == address(0) && to != address(0)) { |
| 192 | _createSeat(tokenId, to); |
| 193 | } else if (to == address(0)) { |
| 194 | _destroySeat(tokenId); |
| 195 | } |
| 196 | // from != 0 && to != 0 -> plain transfer -> intentionally a no-op. TIER PERSISTS. |
| 197 | } |
| 198 | |
| 199 | function _createSeat(uint256 tokenId, address to) internal { |
| 200 | Seat storage s = _seats[tokenId]; |
| 201 | if (s.tier != 0) return; // already live, nothing to do |
| 202 | unchecked { |
| 203 | s.generation += 1; |
| 204 | } |
| 205 | s.tier = 1; |
| 206 | s.mode = SeatMode.VESTED; |
| 207 | s.clockInTs = 0; |
| 208 | s.lifetimeStockUsd = 0; |
| 209 | // One cheap read, no per-Seat write on the StockDesk: this pins where the Seat's |
| 210 | // equity accrual begins so it can never back-claim historical batches. |
| 211 | address desk = stockDesk; |
| 212 | s.startSeq = desk == address(0) ? 0 : uint32(IK401StockDesk(desk).batchSeq()); |
| 213 | // Default allocation: 100% NVDA. Holders can re-allocate with `setStocks`. |
| 214 | s.symbols = [uint8(Symbol.NVDA), uint8(Symbol.NVDA), uint8(Symbol.NVDA)]; |
| 215 | s.weightsBps = [uint16(10_000), uint16(0), uint16(0)]; |
| 216 | _addWeights(tokenId, 1); |
| 217 | emit SeatCreated(tokenId, to); |
| 218 | } |
| 219 | |
| 220 | function _destroySeat(uint256 tokenId) internal { |
| 221 | Seat storage s = _seats[tokenId]; |
| 222 | if (s.tier == 0) return; |
| 223 | _addWeights(tokenId, -1); |
| 224 | unchecked { |
| 225 | s.generation += 1; |
| 226 | } |
| 227 | s.tier = 0; |
| 228 | s.mode = SeatMode.VESTED; |
| 229 | s.clockInTs = 0; |
| 230 | s.lifetimeStockUsd = 0; |
| 231 | s.weightsBps = [uint16(0), uint16(0), uint16(0)]; |
| 232 | emit SeatDestroyed(tokenId); |
| 233 | } |
| 234 | |
| 235 | /// @dev Adds (sign = 1) or removes (sign = -1) a Seat's contribution to the aggregates. |
| 236 | function _addWeights(uint256 tokenId, int256 sign) internal { |
| 237 | Seat storage s = _seats[tokenId]; |
| 238 | if (s.tier == 0 || s.mode != SeatMode.VESTED) return; |
| 239 | uint256 mul = tierMultiplier(s.tier); |
| 240 | if (sign > 0) { |
| 241 | totalVestedMultiplier += mul; |
| 242 | } else { |
| 243 | totalVestedMultiplier -= mul; |
| 244 | } |
| 245 | for (uint256 i; i < SLOTS; ++i) { |
| 246 | uint16 w = s.weightsBps[i]; |
| 247 | if (w == 0) continue; |
| 248 | uint8 sym = s.symbols[i]; |
| 249 | uint256 part = (mul * w) / BPS; |
| 250 | if (sign > 0) { |
| 251 | weightedMultiplier[sym] += part; |
| 252 | } else { |
| 253 | weightedMultiplier[sym] -= part; |
| 254 | } |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | function _settle(uint256 tokenId) internal { |
| 259 | address desk = stockDesk; |
| 260 | if (desk != address(0)) IK401StockDesk(desk).settleSeat(tokenId); |
| 261 | } |
| 262 | |
| 263 | /*////////////////////////////////////////////////////////////// |
| 264 | HOLDER ACTIONS |
| 265 | //////////////////////////////////////////////////////////////*/ |
| 266 | |
| 267 | /// @notice Burn 401K to raise a Seat's tier by one. Cost = 25_000e18 * 2^(tier-1). |
| 268 | function upgradeTier(uint256 tokenId) external nonReentrant returns (uint8 newTier, uint256 cost) { |
| 269 | Seat storage s = _seats[tokenId]; |
| 270 | if (s.tier == 0) revert SeatDoesNotExist(); |
| 271 | if (k401.ownerOfSeat(tokenId) != msg.sender) revert NotSeatOwner(); |
| 272 | if (s.tier >= MAX_TIER) revert MaxTier(); |
| 273 | |
| 274 | cost = upgradeCost(s.tier); |
| 275 | |
| 276 | // Settle stock accrual at the OLD multiplier, then drop the old weights. |
| 277 | _settle(tokenId); |
| 278 | _addWeights(tokenId, -1); |
| 279 | |
| 280 | // Burn the fee. `burnFromKeeping` guarantees the upgraded Seat is not consumed. |
| 281 | k401.burnFromKeeping(msg.sender, cost, tokenId); |
| 282 | |
| 283 | newTier = s.tier + 1; |
| 284 | s.tier = newTier; |
| 285 | _addWeights(tokenId, 1); |
| 286 | |
| 287 | emit TierUpgraded(tokenId, newTier, cost); |
| 288 | } |
| 289 | |
| 290 | /// @notice Burn FUSE_INPUT_COUNT same-tier Seats to produce one Seat at tier + 1. |
| 291 | /// Strictly reduces the Seat supply. `tokenIds[0]` is the survivor. |
| 292 | function fuse(uint256[] calldata tokenIds) external nonReentrant returns (uint256 keptId, uint8 newTier) { |
| 293 | if (tokenIds.length != FUSE_INPUT_COUNT) revert BadFuseInput(); |
| 294 | keptId = tokenIds[0]; |
| 295 | |
| 296 | Seat storage kept = _seats[keptId]; |
| 297 | if (kept.tier == 0) revert SeatDoesNotExist(); |
| 298 | if (kept.tier >= MAX_TIER) revert MaxTier(); |
| 299 | uint8 baseTier = kept.tier; |
| 300 | |
| 301 | uint256[] memory toBurn = new uint256[](FUSE_INPUT_COUNT - 1); |
| 302 | for (uint256 i; i < FUSE_INPUT_COUNT; ++i) { |
| 303 | uint256 id = tokenIds[i]; |
| 304 | for (uint256 j; j < i; ++j) { |
| 305 | if (tokenIds[j] == id) revert DuplicateSeat(); |
| 306 | } |
| 307 | Seat storage s = _seats[id]; |
| 308 | if (s.tier == 0) revert SeatDoesNotExist(); |
| 309 | if (s.tier != baseTier) revert BadFuseInput(); |
| 310 | if (s.mode != SeatMode.VESTED) revert SeatIsOnTheClock(); |
| 311 | if (k401.ownerOfSeat(id) != msg.sender) revert NotSeatOwner(); |
| 312 | _settle(id); |
| 313 | if (i != 0) toBurn[i - 1] = id; |
| 314 | } |
| 315 | |
| 316 | // Drop the survivor's old weights before the tier bump. |
| 317 | _addWeights(keptId, -1); |
| 318 | |
| 319 | // Burning triggers `onSeatTransfer(_, 0, id)` for each consumed Seat, which |
| 320 | // removes their weights from the aggregates. |
| 321 | k401.burnSeats(msg.sender, toBurn); |
| 322 | |
| 323 | newTier = baseTier + 1; |
| 324 | kept.tier = newTier; |
| 325 | _addWeights(keptId, 1); |
| 326 | |
| 327 | emit SeatsFused(keptId, toBurn, newTier); |
| 328 | } |
| 329 | |
| 330 | /// @notice Set a Seat's 3 equity slots. Weights must sum to exactly 10000 bps. |
| 331 | function setStocks(uint256 tokenId, uint8[3] calldata symbols, uint16[3] calldata weightsBps) |
| 332 | external |
| 333 | nonReentrant |
| 334 | { |
| 335 | Seat storage s = _seats[tokenId]; |
| 336 | if (s.tier == 0) revert SeatDoesNotExist(); |
| 337 | if (k401.ownerOfSeat(tokenId) != msg.sender) revert NotSeatOwner(); |
| 338 | |
| 339 | uint256 sum; |
| 340 | for (uint256 i; i < SLOTS; ++i) { |
| 341 | if (symbols[i] >= EQUITY_SYMBOL_COUNT) revert BadSymbol(); |
| 342 | sum += weightsBps[i]; |
| 343 | } |
| 344 | if (sum != BPS) revert BadWeights(); |
| 345 | |
| 346 | _settle(tokenId); |
| 347 | _addWeights(tokenId, -1); |
| 348 | s.symbols = symbols; |
| 349 | s.weightsBps = weightsBps; |
| 350 | _addWeights(tokenId, 1); |
| 351 | |
| 352 | emit StocksSet(tokenId, symbols, weightsBps); |
| 353 | } |
| 354 | |
| 355 | /*////////////////////////////////////////////////////////////// |
| 356 | MODULE-ONLY MUTATIONS |
| 357 | //////////////////////////////////////////////////////////////*/ |
| 358 | |
| 359 | /// @notice Flip a Seat between VESTED and ON_THE_CLOCK. Staking only. |
| 360 | function setMode(uint256 tokenId, SeatMode newMode) external { |
| 361 | if (msg.sender != staking) revert NotAuthorized(); |
| 362 | Seat storage s = _seats[tokenId]; |
| 363 | if (s.tier == 0) revert SeatDoesNotExist(); |
| 364 | if (s.mode == newMode) return; |
| 365 | |
| 366 | _settle(tokenId); |
| 367 | _addWeights(tokenId, -1); // no-op if it was already ON_THE_CLOCK |
| 368 | s.mode = newMode; |
| 369 | s.clockInTs = newMode == SeatMode.ON_THE_CLOCK ? uint64(block.timestamp) : 0; |
| 370 | _addWeights(tokenId, 1); // no-op if it is now ON_THE_CLOCK |
| 371 | |
| 372 | emit ModeChanged(tokenId, newMode); |
| 373 | } |
| 374 | |
| 375 | /// @notice Record delivered equity value. StockDesk only. |
| 376 | function creditStockUsd(uint256 tokenId, uint256 usdWad) external { |
| 377 | if (msg.sender != stockDesk) revert NotAuthorized(); |
| 378 | Seat storage s = _seats[tokenId]; |
| 379 | if (s.tier == 0) return; |
| 380 | s.lifetimeStockUsd += uint128(usdWad); |
| 381 | emit StockCredited(tokenId, usdWad); |
| 382 | } |
| 383 | } |
| 384 |
Click any line number to deep-link to it — the target line highlights on load.