SKIP TO CONTENT

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

  • SYNCING: ……

K401SeatRegistry.sol

The game layer: tier, multiplier, mode, stock slots. Tier persists through transfer. upgrade() and fuse().

384 lines14.7 KBSolidity
Source of src/K401SeatRegistry.sol, 384 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 {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 */
16contract 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.