SKIP TO CONTENT

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

  • SYNCING: ……

K401.sol

The DN404 hybrid token. ERC20 + ERC721 mirror, 1e18 = 1 Seat, immutable 5% fee on mapped AMM pairs only.

408 lines16.1 KBSolidity
Source of src/K401.sol, 408 lines of Solidity
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.24;
3 
4import {DN404} from "dn404/DN404.sol";
5import {DN404Mirror} from "dn404/DN404Mirror.sol";
6import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
7import {LibString} from "solady/utils/LibString.sol";
8import {IK401SeatRegistry} from "./interfaces/IK401Interfaces.sol";
9 
10/**
11 * @title K401 — "Four Oh One K" ($401K)
12 * @notice DN404 hybrid (ERC20 + ERC721 mirror). 1e18 units == 1 Seat NFT.
13 *
14 * @dev THE ONE RULE: the liquid ERC20 balance NEVER rebases. There is no
15 * `rebase()` on this contract, no gons, no scaling factor, no balance
16 * mutation outside of explicit `transfer` / `mint` / `burn`. Emission
17 * accrues only inside `K401Staking` as an index counter and is materialised
18 * by the user, who pays their own gas.
19 *
20 * Fee: a 5% fee applies ONLY when one leg of the transfer is a mapped AMM pair
21 * and neither leg is whitelisted. Wallet <-> wallet is always 0. The rate is
22 * a `constant` — it can never be changed by anybody, ever.
23 *
24 * Locking ("On the Clock"): staked Seats are NOT escrowed and NOT burned. They stay
25 * in the holder's wallet, keep their tokenId, tier and token bound account, and
26 * simply become non-transferable. To make that safe against DN404's LIFO NFT
27 * burn ordering, locked ids are *pinned* to the front of the owner's `owned`
28 * array and the ERC20 outflow guard keeps `ownedLength >= lockedSeats`.
29 */
30contract K401 is DN404, Ownable {
31 /*//////////////////////////////////////////////////////////////
32 CONSTANTS
33 //////////////////////////////////////////////////////////////*/
34 
35 /// @notice Immutable 5% fee, in basis points. Not settable. Not upgradeable.
36 uint256 public constant FEE_BPS = 500;
37 uint256 public constant BPS = 10_000;
38 
39 string private constant _NAME = "Four Oh One K";
40 string private constant _SYMBOL = "401K";
41 
42 /*//////////////////////////////////////////////////////////////
43 STORAGE
44 //////////////////////////////////////////////////////////////*/
45 
46 string private _baseURI;
47 
48 /// @notice Mapped AMM pairs. ADD ONLY.
49 mapping(address => bool) public isPair;
50 /// @notice Fee-exempt protocol contracts. ADD ONLY.
51 mapping(address => bool) public isWhitelisted;
52 /// @notice Modules allowed to mint. ADD ONLY.
53 mapping(address => bool) public isMinter;
54 /// @notice Modules allowed to burn. ADD ONLY.
55 mapping(address => bool) public isBurner;
56 
57 /// @notice Number of Seats currently locked ("On the Clock") per owner.
58 mapping(address => uint256) public lockedSeats;
59 /// @notice Per-tokenId lock flag.
60 mapping(uint256 => bool) public isSeatLocked;
61 
62 /// @notice The staking contract, the only address allowed to lock/unlock Seats.
63 address public staking;
64 /// @notice Destination for the 5% fee stream.
65 address public feeSplitter;
66 /// @notice Seat metadata / game layer. Notified on Seat mint & burn.
67 address public seatRegistry;
68 
69 bool public stakingLocked;
70 bool public feeSplitterLocked;
71 bool public seatRegistryLocked;
72 
73 /*//////////////////////////////////////////////////////////////
74 EVENTS
75 //////////////////////////////////////////////////////////////*/
76 
77 event PairMapped(address indexed pair);
78 event Whitelisted(address indexed account);
79 event MinterAdded(address indexed account);
80 event BurnerAdded(address indexed account);
81 event StakingSet(address indexed staking);
82 event FeeSplitterSet(address indexed feeSplitter);
83 event SeatRegistrySet(address indexed registry);
84 event SeatLocked(address indexed owner, uint256 indexed id);
85 event SeatUnlocked(address indexed owner, uint256 indexed id);
86 event FeeTaken(address indexed from, address indexed to, uint256 amount);
87 
88 /*//////////////////////////////////////////////////////////////
89 ERRORS
90 //////////////////////////////////////////////////////////////*/
91 
92 error RemovalDisabled();
93 error NotAuthorized();
94 error AlreadySet();
95 error SeatIsLocked();
96 error SeatNotLocked();
97 error LockedBalance();
98 error NotSeatOwner();
99 error ZeroAddress();
100 error DuplicateSeat();
101 
102 /*//////////////////////////////////////////////////////////////
103 CONSTRUCTOR
104 //////////////////////////////////////////////////////////////*/
105 
106 constructor(uint96 initialSupply, address initialSupplyOwner, address owner_) Ownable(owner_) {
107 if (owner_ == address(0)) revert ZeroAddress();
108 address mirror = address(new DN404Mirror(msg.sender));
109 _initializeDN404(initialSupply, initialSupplyOwner, mirror);
110 }
111 
112 /*//////////////////////////////////////////////////////////////
113 METADATA
114 //////////////////////////////////////////////////////////////*/
115 
116 function name() public pure override returns (string memory) {
117 return _NAME;
118 }
119 
120 function symbol() public pure override returns (string memory) {
121 return _SYMBOL;
122 }
123 
124 function _tokenURI(uint256 tokenId) internal view override returns (string memory result) {
125 if (bytes(_baseURI).length != 0) {
126 result = string(abi.encodePacked(_baseURI, LibString.toString(tokenId)));
127 }
128 }
129 
130 function setBaseURI(string calldata baseURI_) external onlyOwner {
131 _baseURI = baseURI_;
132 }
133 
134 /*//////////////////////////////////////////////////////////////
135 ADD-ONLY CONFIGURATION
136 //////////////////////////////////////////////////////////////*/
137 
138 /// @notice Map an AMM pair. `enabled` must be true — pairs can never be unmapped.
139 function mapPair(address pair, bool enabled) external onlyOwner {
140 if (!enabled) revert RemovalDisabled();
141 if (pair == address(0)) revert ZeroAddress();
142 isPair[pair] = true;
143 _setSkipNFT(pair, true);
144 emit PairMapped(pair);
145 }
146 
147 /// @notice Whitelist a protocol contract. `enabled` must be true — no removals.
148 function setWhitelist(address account, bool enabled) external onlyOwner {
149 if (!enabled) revert RemovalDisabled();
150 if (account == address(0)) revert ZeroAddress();
151 isWhitelisted[account] = true;
152 _setSkipNFT(account, true);
153 emit Whitelisted(account);
154 }
155 
156 /// @notice Grant mint rights. ADD ONLY.
157 function addMinter(address account) external onlyOwner {
158 if (account == address(0)) revert ZeroAddress();
159 isMinter[account] = true;
160 emit MinterAdded(account);
161 }
162 
163 /// @notice Grant burn rights. ADD ONLY.
164 function addBurner(address account) external onlyOwner {
165 if (account == address(0)) revert ZeroAddress();
166 isBurner[account] = true;
167 emit BurnerAdded(account);
168 }
169 
170 /// @notice Set once, then permanently frozen.
171 function setStaking(address staking_) external onlyOwner {
172 if (stakingLocked) revert AlreadySet();
173 if (staking_ == address(0)) revert ZeroAddress();
174 staking = staking_;
175 stakingLocked = true;
176 emit StakingSet(staking_);
177 }
178 
179 /// @notice Set once, then permanently frozen.
180 function setFeeSplitter(address splitter) external onlyOwner {
181 if (feeSplitterLocked) revert AlreadySet();
182 if (splitter == address(0)) revert ZeroAddress();
183 feeSplitter = splitter;
184 feeSplitterLocked = true;
185 isWhitelisted[splitter] = true;
186 _setSkipNFT(splitter, true);
187 emit FeeSplitterSet(splitter);
188 }
189 
190 /// @notice Set once, then permanently frozen.
191 function setSeatRegistry(address registry) external onlyOwner {
192 if (seatRegistryLocked) revert AlreadySet();
193 if (registry == address(0)) revert ZeroAddress();
194 seatRegistry = registry;
195 seatRegistryLocked = true;
196 emit SeatRegistrySet(registry);
197 }
198 
199 /*//////////////////////////////////////////////////////////////
200 MINT / BURN
201 //////////////////////////////////////////////////////////////*/
202 
203 function mint(address to, uint256 amount) external {
204 if (!isMinter[msg.sender]) revert NotAuthorized();
205 _mint(to, amount);
206 }
207 
208 function burnFrom(address from, uint256 amount) external {
209 if (!isBurner[msg.sender]) revert NotAuthorized();
210 _burn(from, amount);
211 }
212 
213 /**
214 * @notice Burn a specific set of Seats belonging to `from`.
215 * @dev DN404 burns NFTs LIFO from the owner's `owned` array, so we first move the
216 * targeted ids to the tail, then burn `ids.length * 1e18`. Locked seats are
217 * rejected outright.
218 */
219 function burnSeats(address from, uint256[] calldata ids) external {
220 if (!isBurner[msg.sender]) revert NotAuthorized();
221 uint256 n = ids.length;
222 if (n == 0) return;
223 
224 DN404Storage storage $ = _getDN404Storage();
225 uint256 ownedLength = $.addressData[from].ownedLength;
226 if (ownedLength < n) revert LockedBalance();
227 if (ownedLength - n < lockedSeats[from]) revert LockedBalance();
228 
229 for (uint256 j; j < n; ++j) {
230 uint256 id = ids[j];
231 if (isSeatLocked[id]) revert SeatIsLocked();
232 if (_ownerAt(id) != from) revert NotSeatOwner();
233 uint256 target = ownedLength - 1 - j;
234 uint256 cur = _get($.oo, _ownedIndex(id));
235 // Anything at index > target has already been staged for burning; seeing an
236 // id there means `ids` contained a duplicate.
237 if (cur > target) revert DuplicateSeat();
238 if (cur != target) _swapOwned(from, cur, target);
239 }
240 
241 _burn(from, n * _unit());
242 }
243 
244 /**
245 * @notice Burn `amount` from `from` while guaranteeing that Seat `keepId` survives.
246 * @dev Used by `upgrade()`, where the fee is paid in whole Seats but the Seat being
247 * upgraded must not itself be consumed. `keepId` is moved to the first unlocked
248 * slot so DN404's LIFO burn can never reach it.
249 */
250 function burnFromKeeping(address from, uint256 amount, uint256 keepId) external {
251 if (!isBurner[msg.sender]) revert NotAuthorized();
252 if (_ownerAt(keepId) != from) revert NotSeatOwner();
253 
254 uint256 pinned = lockedSeats[from];
255 uint256 cur = _get(_getDN404Storage().oo, _ownedIndex(keepId));
256 if (!isSeatLocked[keepId]) {
257 if (cur != pinned) _swapOwned(from, cur, pinned);
258 // At least `pinned + 1` Seats must survive so the kept Seat is never popped.
259 if ((balanceOf(from) - amount) / _unit() < pinned + 1) revert LockedBalance();
260 }
261 _burn(from, amount);
262 }
263 
264 /*//////////////////////////////////////////////////////////////
265 SEAT LOCKING
266 //////////////////////////////////////////////////////////////*/
267 
268 function lockSeat(address owner_, uint256 id) external {
269 if (msg.sender != staking) revert NotAuthorized();
270 if (_ownerAt(id) != owner_) revert NotSeatOwner();
271 if (isSeatLocked[id]) revert SeatIsLocked();
272 
273 uint256 pinned = lockedSeats[owner_];
274 uint256 cur = _get(_getDN404Storage().oo, _ownedIndex(id));
275 if (cur != pinned) _swapOwned(owner_, cur, pinned);
276 
277 isSeatLocked[id] = true;
278 lockedSeats[owner_] = pinned + 1;
279 emit SeatLocked(owner_, id);
280 }
281 
282 function unlockSeat(address owner_, uint256 id) external {
283 if (msg.sender != staking) revert NotAuthorized();
284 if (!isSeatLocked[id]) revert SeatNotLocked();
285 if (_ownerAt(id) != owner_) revert NotSeatOwner();
286 
287 uint256 pinned = lockedSeats[owner_];
288 uint256 last = pinned - 1;
289 uint256 cur = _get(_getDN404Storage().oo, _ownedIndex(id));
290 if (cur != last) _swapOwned(owner_, cur, last);
291 
292 isSeatLocked[id] = false;
293 lockedSeats[owner_] = last;
294 emit SeatUnlocked(owner_, id);
295 }
296 
297 /// @dev Swap two positions inside an owner's `owned` array, keeping `oo` consistent.
298 function _swapOwned(address owner_, uint256 i, uint256 j) private {
299 DN404Storage storage $ = _getDN404Storage();
300 Uint32Map storage ownedMap = $.owned[owner_];
301 uint32 idI = _get(ownedMap, i);
302 uint32 idJ = _get(ownedMap, j);
303 uint32 ownerAlias = _get($.oo, _ownershipIndex(idI));
304 _set(ownedMap, i, idJ);
305 _set(ownedMap, j, idI);
306 _setOwnerAliasAndOwnedIndex($.oo, idI, ownerAlias, uint32(j));
307 _setOwnerAliasAndOwnedIndex($.oo, idJ, ownerAlias, uint32(i));
308 }
309 
310 /*//////////////////////////////////////////////////////////////
311 TRANSFER / FEE / GUARDS
312 //////////////////////////////////////////////////////////////*/
313 
314 /// @dev True when the 5% pair fee must be charged.
315 function feeApplies(address from, address to) public view returns (bool) {
316 if (feeSplitter == address(0)) return false;
317 if (isWhitelisted[from] || isWhitelisted[to]) return false;
318 return isPair[from] || isPair[to];
319 }
320 
321 function _checkOutflow(address from, uint256 amount) private view {
322 uint256 locked = lockedSeats[from];
323 if (locked == 0) return;
324 if (balanceOf(from) - amount < locked * _unit()) revert LockedBalance();
325 }
326 
327 function _transfer(address from, address to, uint256 amount) internal override {
328 if (from != to) _checkOutflow(from, amount);
329 
330 if (amount != 0 && feeApplies(from, to)) {
331 uint256 fee = (amount * FEE_BPS) / BPS;
332 if (fee != 0) {
333 super._transfer(from, feeSplitter, fee);
334 emit FeeTaken(from, to, fee);
335 }
336 super._transfer(from, to, amount - fee);
337 } else {
338 super._transfer(from, to, amount);
339 }
340 }
341 
342 function _burn(address from, uint256 amount) internal override {
343 _checkOutflow(from, amount);
344 super._burn(from, amount);
345 }
346 
347 function _transferFromNFT(address from, address to, uint256 id, address msgSender) internal override {
348 if (isSeatLocked[id]) revert SeatIsLocked();
349 super._transferFromNFT(from, to, id, msgSender);
350 }
351 
352 /*//////////////////////////////////////////////////////////////
353 REGISTRY HOOK
354 //////////////////////////////////////////////////////////////*/
355 
356 function _useAfterNFTTransfers() internal pure override returns (bool) {
357 return true;
358 }
359 
360 /// @dev Notifies the Seat registry on Seat creation and destruction only.
361 /// Plain transfers are deliberately a no-op: TIER PERSISTS THROUGH TRANSFER.
362 function _afterNFTTransfers(address[] memory from, address[] memory to, uint256[] memory ids) internal override {
363 address registry = seatRegistry;
364 if (registry == address(0)) return;
365 uint256 n = ids.length;
366 for (uint256 i; i < n; ++i) {
367 address f = from[i];
368 address t = to[i];
369 if (f == address(0) || t == address(0)) {
370 IK401SeatRegistry(registry).onSeatTransfer(f, t, ids[i]);
371 }
372 }
373 }
374 
375 /*//////////////////////////////////////////////////////////////
376 VIEWS
377 //////////////////////////////////////////////////////////////*/
378 
379 function ownerOfSeat(uint256 id) external view returns (address) {
380 return _ownerAt(id);
381 }
382 
383 function seatExists(uint256 id) external view returns (bool) {
384 return _exists(id);
385 }
386 
387 function totalSeats() external view returns (uint256) {
388 return _totalNFTSupply();
389 }
390 
391 function seatBalanceOf(address owner_) external view returns (uint256) {
392 return _balanceOfNFT(owner_);
393 }
394 
395 function seatsOf(address owner_) external view returns (uint256[] memory) {
396 return _ownedIds(owner_, 0, type(uint256).max);
397 }
398 
399 function seatsOfRange(address owner_, uint256 begin, uint256 end) external view returns (uint256[] memory) {
400 return _ownedIds(owner_, begin, end);
401 }
402 
403 /// @notice Portion of `owner`'s balance that cannot be moved because it backs locked Seats.
404 function lockedBalanceOf(address owner_) external view returns (uint256) {
405 return lockedSeats[owner_] * _unit();
406 }
407}
408 

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