SKIP TO CONTENT

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

  • SYNCING: ……

K401Staking.sol

"On the Clock". Index-based accrual, lazy materialize(), 24h clock-out cooldown. Seats are locked, not burned.

250 lines9.3 KBSolidity
Source of src/K401Staking.sol, 250 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 {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
7import {SeatMode, IK401, IK401SeatRegistry} from "./interfaces/IK401Interfaces.sol";
8 
9/**
10 * @title K401Staking — "On the Clock"
11 *
12 * @dev THE ONE RULE, enforced here: nothing in this contract mutates a liquid ERC20
13 * balance. Staked Seats are NOT escrowed and NOT burned — they stay in the
14 * holder's wallet, keep their tokenId, tier and TBA, and are simply made
15 * non-transferable by `K401.lockSeat`. Emission accrues as a global `index` and
16 * each user pulls their share with `materialize()`, paying their own gas.
17 *
18 * OHM v1 index accounting:
19 * index : monotonically increasing, starts at 1e18
20 * principal : the user's staked notional, in 401K wei
21 * pending = accrued + principal * (index - userIndex) / userIndex
22 */
23contract K401Staking is Ownable, ReentrancyGuard {
24 uint256 public constant WAD = 1e18;
25 uint256 public constant UNIT = 1e18; // 1 whole 401K == 1 Seat
26 uint256 public constant COOLDOWN = 24 hours;
27 
28 IK401 public immutable k401;
29 IK401SeatRegistry public immutable registry;
30 
31 address public distributor;
32 bool public distributorLocked;
33 
34 /// @notice Global emission index. Starts at 1e18 and only ever grows.
35 uint256 public index = WAD;
36 /// @notice Total 401K notional currently earning, in wei.
37 uint256 public totalStaked;
38 /// @notice Cumulative emission routed through the index.
39 uint256 public totalEmission;
40 
41 struct UserInfo {
42 uint256 principal;
43 uint256 userIndex;
44 uint256 accrued;
45 }
46 
47 mapping(address => UserInfo) internal _users;
48 
49 /// @notice Which address clocked a Seat in.
50 mapping(uint256 => address) public stakerOf;
51 /// @notice Timestamp of the clock-out request; 0 while actively earning.
52 mapping(uint256 => uint256) public exitRequestedAt;
53 
54 mapping(address => uint256[]) internal _stakedIds;
55 mapping(uint256 => uint256) internal _stakedIdIndex;
56 
57 event ClockedIn(address indexed user, uint256 indexed tokenId);
58 event ClockOutRequested(address indexed user, uint256 indexed tokenId, uint256 unlockAt);
59 event ClockedOut(address indexed user, uint256 indexed tokenId);
60 event Materialized(address indexed user, uint256 wholeTokens, uint256 remainder);
61 event EmissionReceived(uint256 amount, uint256 newIndex);
62 
63 error NotAuthorized();
64 error AlreadySet();
65 error ZeroAddress();
66 error NotSeatOwner();
67 error NotStaked();
68 error CooldownActive();
69 error NothingToMaterialize();
70 error EmptyInput();
71 
72 constructor(address k401_, address registry_, address owner_) Ownable(owner_) {
73 if (k401_ == address(0) || registry_ == address(0)) revert ZeroAddress();
74 k401 = IK401(k401_);
75 registry = IK401SeatRegistry(registry_);
76 }
77 
78 function setDistributor(address distributor_) external onlyOwner {
79 if (distributorLocked) revert AlreadySet();
80 if (distributor_ == address(0)) revert ZeroAddress();
81 distributor = distributor_;
82 distributorLocked = true;
83 }
84 
85 /*//////////////////////////////////////////////////////////////
86 INDEX ACCOUNTING
87 //////////////////////////////////////////////////////////////*/
88 
89 /// @notice Emission callback from the Distributor. The tokens have already been minted here.
90 function notifyEmission(uint256 amount) external {
91 if (msg.sender != distributor) revert NotAuthorized();
92 uint256 staked = totalStaked;
93 if (staked == 0 || amount == 0) return;
94 // index *= (staked + amount) / staked
95 index = Math.mulDiv(index, staked + amount, staked);
96 totalEmission += amount;
97 emit EmissionReceived(amount, index);
98 }
99 
100 /// @notice Emission earned by `user` but not yet materialised, in 401K wei.
101 function pending(address user) public view returns (uint256) {
102 UserInfo storage u = _users[user];
103 uint256 p = u.accrued;
104 if (u.principal != 0 && u.userIndex != 0 && index > u.userIndex) {
105 p += Math.mulDiv(u.principal, index - u.userIndex, u.userIndex);
106 }
107 return p;
108 }
109 
110 /// @notice Whole Seats that `materialize()` would mint right now.
111 function materializable(address user) external view returns (uint256) {
112 return (pending(user) / UNIT) * UNIT;
113 }
114 
115 function _settle(address user) internal {
116 UserInfo storage u = _users[user];
117 if (u.principal != 0 && u.userIndex != 0 && index > u.userIndex) {
118 u.accrued += Math.mulDiv(u.principal, index - u.userIndex, u.userIndex);
119 }
120 u.userIndex = index;
121 }
122 
123 /*//////////////////////////////////////////////////////////////
124 CLOCK IN / OUT
125 //////////////////////////////////////////////////////////////*/
126 
127 /// @notice Lock Seats in place and start earning. The NFTs never leave the wallet.
128 function clockIn(uint256[] calldata tokenIds) external nonReentrant {
129 uint256 n = tokenIds.length;
130 if (n == 0) revert EmptyInput();
131 _settle(msg.sender);
132 
133 for (uint256 i; i < n; ++i) {
134 uint256 id = tokenIds[i];
135 if (k401.ownerOfSeat(id) != msg.sender) revert NotSeatOwner();
136 k401.lockSeat(msg.sender, id);
137 registry.setMode(id, SeatMode.ON_THE_CLOCK);
138 stakerOf[id] = msg.sender;
139 _stakedIdIndex[id] = _stakedIds[msg.sender].length;
140 _stakedIds[msg.sender].push(id);
141 emit ClockedIn(msg.sender, id);
142 }
143 
144 uint256 added = n * UNIT;
145 _users[msg.sender].principal += added;
146 totalStaked += added;
147 }
148 
149 /**
150 * @notice Two-phase exit, matching the single frontend entry point.
151 * First call requests the exit (stops earning, starts the 24h cooldown).
152 * Second call, after the cooldown, unlocks the Seats.
153 */
154 function clockOut(uint256[] calldata tokenIds) external nonReentrant {
155 uint256 n = tokenIds.length;
156 if (n == 0) revert EmptyInput();
157 _settle(msg.sender);
158 
159 uint256 removed;
160 for (uint256 i; i < n; ++i) {
161 uint256 id = tokenIds[i];
162 if (stakerOf[id] != msg.sender) revert NotStaked();
163 
164 uint256 requestedAt = exitRequestedAt[id];
165 if (requestedAt == 0) {
166 exitRequestedAt[id] = block.timestamp;
167 removed += UNIT;
168 emit ClockOutRequested(msg.sender, id, block.timestamp + COOLDOWN);
169 } else {
170 if (block.timestamp < requestedAt + COOLDOWN) revert CooldownActive();
171 _finalize(msg.sender, id);
172 }
173 }
174 
175 if (removed != 0) {
176 UserInfo storage u = _users[msg.sender];
177 u.principal -= removed;
178 totalStaked -= removed;
179 }
180 }
181 
182 function _finalize(address user, uint256 id) internal {
183 k401.unlockSeat(user, id);
184 registry.setMode(id, SeatMode.VESTED);
185 
186 uint256[] storage list = _stakedIds[user];
187 uint256 idx = _stakedIdIndex[id];
188 uint256 last = list.length - 1;
189 if (idx != last) {
190 uint256 movedId = list[last];
191 list[idx] = movedId;
192 _stakedIdIndex[movedId] = idx;
193 }
194 list.pop();
195 
196 delete _stakedIdIndex[id];
197 delete stakerOf[id];
198 delete exitRequestedAt[id];
199 
200 emit ClockedOut(user, id);
201 }
202 
203 /*//////////////////////////////////////////////////////////////
204 MATERIALIZE
205 //////////////////////////////////////////////////////////////*/
206 
207 /**
208 * @notice Mint the whole Seats the caller has earned. Pull based: the user pays
209 * their own gas and only their own NFTs are minted. The fractional
210 * remainder carries forward.
211 */
212 function materialize() external nonReentrant returns (uint256 wholeTokens) {
213 _settle(msg.sender);
214 UserInfo storage u = _users[msg.sender];
215 wholeTokens = (u.accrued / UNIT) * UNIT;
216 if (wholeTokens == 0) revert NothingToMaterialize();
217 
218 // Defensive: never hand out more than the contract actually holds.
219 uint256 held = k401.balanceOf(address(this));
220 if (wholeTokens > held) wholeTokens = (held / UNIT) * UNIT;
221 if (wholeTokens == 0) revert NothingToMaterialize();
222 
223 u.accrued -= wholeTokens;
224 k401.transfer(msg.sender, wholeTokens);
225 
226 emit Materialized(msg.sender, wholeTokens, u.accrued);
227 }
228 
229 /*//////////////////////////////////////////////////////////////
230 VIEWS
231 //////////////////////////////////////////////////////////////*/
232 
233 function userInfo(address user) external view returns (UserInfo memory) {
234 return _users[user];
235 }
236 
237 function stakedSeatsOf(address user) external view returns (uint256[] memory) {
238 return _stakedIds[user];
239 }
240 
241 function stakedCountOf(address user) external view returns (uint256) {
242 return _stakedIds[user].length;
243 }
244 
245 function unlockAt(uint256 tokenId) external view returns (uint256) {
246 uint256 r = exitRequestedAt[tokenId];
247 return r == 0 ? 0 : r + COOLDOWN;
248 }
249}
250 

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