// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; import {FistGovernance} from "./FistGovernance.sol"; import {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol"; import {FistArt} from "./FistArt.sol"; import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; interface IFagentBurn is IERC20 { function burnForNFT(address payer, uint256 amount) external; function marketplace() external view returns (address); } /// @notice 550k burn; three levels with conserved 1/3/9 staking and voting weights. contract FistNFT is ERC721, ReentrancyGuard { using SafeERC20 for IERC20; using Checkpoints for Checkpoints.Trace208; uint256 public constant BURN_PRICE = 550_000 ether; uint256 private constant SCALE = 1e24; uint256 public constant REWARD_DURATION = 7 days; uint256 public lastRewardUpdate; uint256 public constant AUTO_EXPIRIES = 8; struct Stream { uint256 index; uint256 remaining; uint256 next; uint256 timeLeft; } struct Preview { Stream agent; Stream fist; uint256 timestamp; uint256 totalWeight; uint256 accountWeight; uint256 agentIndex; uint256 fistIndex; uint256 earned; uint256 earnedFist; } Stream private _agentStream; Stream private _fistStream; uint256 private _totalRewardWeight; mapping(address => uint256) private _rewardWeights; mapping(uint256 => uint8) public stakingPlan; mapping(uint256 => uint256) public unlockAt; uint256[] private _expiryHeap; uint256 public deferredRewards; uint256 public deferredFistRewards; error StakeLocked(); error CheckpointRequired(); event PlanSelected(address indexed account, uint256 indexed id, uint8 plan, uint256 unlockAt); event PlanExpired(address indexed account, uint256 indexed id, uint256 expiredAt); IFagentBurn public immutable token; FistGovernance public immutable governance; IERC20 public immutable fist; uint256 public totalFistReceived; uint256 public totalFistClaimed; mapping(address => uint256) public fistUserIndex; mapping(address => uint256) public fistCredits; uint256 public minted; mapping(uint256 => uint8) public levelOf; mapping(uint256 => uint256) public transferNonce; uint256 public totalStakedWeight; mapping(address => uint256) public stakedWeight; mapping(address => Checkpoints.Trace208) private _votes; Checkpoints.Trace208 private _totalVotes; bool private _stakingMove; uint256 public totalStaked; uint256 public totalReceived; uint256 public totalClaimed; mapping(uint256 => address) public stakerOf; mapping(address => uint256) public stakedCount; mapping(address => uint256) public userIndex; mapping(address => uint256) public credits; error Unauthorized(); error InvalidAmount(); event Merged(address indexed owner, uint256[3] consumed, uint256 indexed created, uint8 level); event Forged(address indexed owner, uint256 indexed id, uint256 burned); event Staked(address indexed owner, uint256 indexed id); event Unstaked(address indexed owner, uint256 indexed id); event RewardsReceived(uint256 amount, uint256 queued); event Claimed(address indexed owner, uint256 amount); event FistRewardsReceived(uint256 amount, uint256 queued); event FistClaimed(address indexed owner, uint256 amount); constructor(address token_, address proposer_, address fist_) ERC721("FIST Agent Genesis", "FISTNFT") { token = IFagentBurn(token_); lastRewardUpdate = block.timestamp; fist = IERC20(fist_); governance = new FistGovernance(address(this), proposer_); } function forge(uint256 count) external nonReentrant { if (count == 0 || count > 20) revert InvalidAmount(); token.burnForNFT(msg.sender, BURN_PRICE * count); for (uint256 i; i < count; ++i) { uint256 id = ++minted; levelOf[id] = 1; _safeMint(msg.sender, id); emit Forged(msg.sender, id, BURN_PRICE); } } function weightOf(uint256 id) public view returns (uint256) { _requireOwned(id); uint8 level = levelOf[id]; return level == 1 ? 1 : level == 2 ? 3 : 9; } function merge(uint256[3] calldata ids) external nonReentrant returns (uint256 id) { uint8 level = levelOf[ids[0]]; if (level == 0 || level >= 3) revert InvalidAmount(); for (uint256 i; i < 3; ++i) { if (ownerOf(ids[i]) != msg.sender || stakerOf[ids[i]] != address(0)) revert Unauthorized(); if (levelOf[ids[i]] != level) revert InvalidAmount(); // A duplicate ID reverts at ownerOf on the next iteration, atomically restoring all burns. _burn(ids[i]); delete levelOf[ids[i]]; } id = ++minted; levelOf[id] = level + 1; _safeMint(msg.sender, id); emit Merged(msg.sender, ids, id, level + 1); } function votingWeight(address account) external view returns (uint256) { return _votes[account].latest(); } function totalVotingWeight() external view returns (uint256) { return _totalVotes.latest(); } function getPastVotes(address account, uint256 atBlock) external view returns (uint256) { if (atBlock >= block.number) revert InvalidAmount(); return _votes[account].upperLookupRecent(uint48(atBlock)); } function getPastTotalWeight(uint256 atBlock) external view returns (uint256) { if (atBlock >= block.number) revert InvalidAmount(); return _totalVotes.upperLookupRecent(uint48(atBlock)); } function _update(address to, uint256 id, address auth) internal override returns (address from) { from = super._update(to, id, auth); ++transferNonce[id]; if (!_stakingMove) { uint208 w = uint208(levelOf[id] == 1 ? 1 : levelOf[id] == 2 ? 3 : 9); uint48 key = uint48(block.number); if (from != address(0)) _votes[from].push(key, _votes[from].latest() - w); else _totalVotes.push(key, _totalVotes.latest() + w); if (to != address(0)) _votes[to].push(key, _votes[to].latest() + w); else _totalVotes.push(key, _totalVotes.latest() - w); } } function rewardIndex() public view returns (uint256) { return _agentStream.index; } function fistRewardIndex() public view returns (uint256) { return _fistStream.index; } function rewardTimeRemaining() external view returns (uint256) { return _agentStream.timeLeft; } function fistRewardTimeRemaining() external view returns (uint256) { return _fistStream.timeLeft; } function queuedRewards() public view returns (uint256) { return (_agentStream.remaining + _agentStream.next) / SCALE + deferredRewards; } function queuedFistRewards() public view returns (uint256) { return (_fistStream.remaining + _fistStream.next) / SCALE + deferredFistRewards; } function nextPeriodRewards() external view returns (uint256) { return _agentStream.next / SCALE; } function nextPeriodFistRewards() external view returns (uint256) { return _fistStream.next / SCALE; } /// Reward units are twice the displayed weight, preserving exact 1.5x multipliers. function rewardWeightOf(address account) external view returns (uint256 weight) { weight = _rewardWeights[account]; for (uint256 i; i < _expiryHeap.length; ++i) { uint256 id = _expiryHeap[i]; if (unlockAt[id] <= block.timestamp && stakerOf[id] == account) weight -= _bonus(id); } } function totalRewardWeight() external view returns (uint256 weight) { weight = _totalRewardWeight; for (uint256 i; i < _expiryHeap.length; ++i) { uint256 id = _expiryHeap[i]; if (unlockAt[id] <= block.timestamp) weight -= _bonus(id); } } function effectivePlan(uint256 id) public view returns (uint8) { return unlockAt[id] <= block.timestamp ? 0 : stakingPlan[id]; } function _bonus(uint256 id) private view returns (uint256) { return stakingPlan[id] == 0 ? 0 : weightOf(id) * (stakingPlan[id] == 1 ? 1 : 2); } function _earlier(uint256 a, uint256 b) private view returns (bool) { return unlockAt[a] < unlockAt[b] || (unlockAt[a] == unlockAt[b] && a < b); } function _pushExpiry(uint256 id) private { _expiryHeap.push(id); uint256 i = _expiryHeap.length - 1; while (i != 0) { uint256 parent = (i - 1) / 2; if (!_earlier(id, _expiryHeap[parent])) break; _expiryHeap[i] = _expiryHeap[parent]; i = parent; } _expiryHeap[i] = id; } function _popExpiry() private { uint256 last = _expiryHeap[_expiryHeap.length - 1]; _expiryHeap.pop(); uint256 size = _expiryHeap.length; if (size == 0) return; uint256 i; while (i * 2 + 1 < size) { uint256 child = i * 2 + 1; if (child + 1 < size && _earlier(_expiryHeap[child + 1], _expiryHeap[child])) ++child; if (!_earlier(_expiryHeap[child], last)) break; _expiryHeap[i] = _expiryHeap[child]; i = child; } _expiryHeap[i] = last; } function _popMemory(uint256[] memory heap, uint256 size) private view returns (uint256) { --size; if (size == 0) return 0; uint256 last = heap[size]; uint256 i; while (i * 2 + 1 < size) { uint256 child = i * 2 + 1; if (child + 1 < size && _earlier(heap[child + 1], heap[child])) ++child; if (!_earlier(heap[child], last)) break; heap[i] = heap[child]; i = child; } heap[i] = last; return size; } function _advance(Stream memory s, uint256 elapsed, uint256 weight) private pure returns (Stream memory) { if (weight == 0) return s; for (uint256 i; i < 2 && s.timeLeft != 0; ++i) { uint256 dt = elapsed < s.timeLeft ? elapsed : s.timeLeft; uint256 released = s.remaining * dt / s.timeLeft; s.remaining -= released; s.timeLeft -= dt; elapsed -= dt; s.index += released / weight; if (s.timeLeft == 0 && s.next != 0) { s.remaining = s.next; s.next = 0; s.timeLeft = REWARD_DURATION; } if (elapsed == 0) break; } return s; } function _advanceTo(uint256 timestamp) private { uint256 elapsed = timestamp - lastRewardUpdate; if (elapsed != 0) { _agentStream = _advance(_agentStream, elapsed, _totalRewardWeight); _fistStream = _advance(_fistStream, elapsed, _totalRewardWeight); lastRewardUpdate = timestamp; } } function _settleStored(address account) private { credits[account] += _rewardWeights[account] * (_agentStream.index - userIndex[account]) / SCALE; fistCredits[account] += _rewardWeights[account] * (_fistStream.index - fistUserIndex[account]) / SCALE; userIndex[account] = _agentStream.index; fistUserIndex[account] = _fistStream.index; } function _fund(Stream storage s, uint256 amount) private { if (amount == 0) return; if (s.timeLeft == 0) { s.remaining = amount * SCALE; s.timeLeft = REWARD_DURATION; } else { s.next += amount * SCALE; } } /// Fixed bounded work on trading paths; extra catch-up never requires owner authorization. function _checkpoint(uint256 limit) private returns (bool current) { uint256 processed; while (_expiryHeap.length != 0 && unlockAt[_expiryHeap[0]] <= block.timestamp && processed < limit) { uint256 id = _expiryHeap[0]; uint256 expiry = unlockAt[id]; address account = stakerOf[id]; uint256 bonus = _bonus(id); _advanceTo(expiry); _settleStored(account); _rewardWeights[account] -= bonus; _totalRewardWeight -= bonus; _popExpiry(); stakingPlan[id] = 0; ++processed; emit PlanExpired(account, id, expiry); } current = _expiryHeap.length == 0 || unlockAt[_expiryHeap[0]] > block.timestamp; if (current) { _advanceTo(block.timestamp); if (deferredRewards != 0) { _fund(_agentStream, deferredRewards); deferredRewards = 0; } if (deferredFistRewards != 0) { _fund(_fistStream, deferredFistRewards); deferredFistRewards = 0; } } } function checkpoint(uint256 maxExpiries) external nonReentrant returns (bool current) { if (maxExpiries == 0 || maxExpiries > 64) revert InvalidAmount(); return _checkpoint(maxExpiries); } function checkpointStatus() external view returns (uint256 earliestExpiry, bool backlog, uint256 entries) { entries = _expiryHeap.length; earliestExpiry = entries == 0 ? 0 : unlockAt[_expiryHeap[0]]; backlog = entries != 0 && earliestExpiry <= block.timestamp; } function _settle(address account) private { if (!_checkpoint(AUTO_EXPIRIES)) revert CheckpointRequired(); _settleStored(account); } function _previewSettle(Preview memory p) private pure returns (Preview memory) { p.earned += p.accountWeight * (p.agent.index - p.agentIndex) / SCALE; p.earnedFist += p.accountWeight * (p.fist.index - p.fistIndex) / SCALE; p.agentIndex = p.agent.index; p.fistIndex = p.fist.index; return p; } function _preview(address account) private view returns (uint256, uint256) { Preview memory p = Preview( _agentStream, _fistStream, lastRewardUpdate, _totalRewardWeight, _rewardWeights[account], userIndex[account], fistUserIndex[account], credits[account], fistCredits[account] ); if (_expiryHeap.length != 0 && unlockAt[_expiryHeap[0]] <= block.timestamp) { // Read-only simulation; total live NFTs are supply-bounded. No unbounded loop in a trade. uint256[] memory heap = _expiryHeap; uint256 size = heap.length; while (size != 0 && unlockAt[heap[0]] <= block.timestamp) { uint256 id = heap[0]; uint256 expiry = unlockAt[id]; uint256 bonus = _bonus(id); p.agent = _advance(p.agent, expiry - p.timestamp, p.totalWeight); p.fist = _advance(p.fist, expiry - p.timestamp, p.totalWeight); p.timestamp = expiry; if (stakerOf[id] == account) { p = _previewSettle(p); p.accountWeight -= bonus; } p.totalWeight -= bonus; size = _popMemory(heap, size); } } p.agent = _advance(p.agent, block.timestamp - p.timestamp, p.totalWeight); p.fist = _advance(p.fist, block.timestamp - p.timestamp, p.totalWeight); p = _previewSettle(p); return (p.earned, p.earnedFist); } function pending(address account) public view returns (uint256 amount) { (amount,) = _preview(account); } function pendingFist(address account) public view returns (uint256 amount) { (, amount) = _preview(account); } function stake(uint256[] calldata ids) external nonReentrant { _stake(ids, 0); } function stakeWithPlan(uint256[] calldata ids, uint8 plan) external nonReentrant { _stake(ids, plan); } function _stake(uint256[] calldata ids, uint8 plan) private { if (ids.length == 0 || ids.length > 100 || plan > 2) revert InvalidAmount(); _settle(msg.sender); for (uint256 i; i < ids.length; ++i) { uint256 id = ids[i]; if (ownerOf(id) != msg.sender) revert Unauthorized(); _stakingMove = true; _transfer(msg.sender, address(this), id); _stakingMove = false; stakerOf[id] = msg.sender; uint256 base = weightOf(id); stakedWeight[msg.sender] += base; totalStakedWeight += base; uint256 units = base * (plan == 0 ? 2 : plan == 1 ? 3 : 4); _rewardWeights[msg.sender] += units; _totalRewardWeight += units; stakingPlan[id] = plan; if (plan != 0) { unlockAt[id] = block.timestamp + (plan == 1 ? 7 days : 14 days); _pushExpiry(id); } emit Staked(msg.sender, id); emit PlanSelected(msg.sender, id, plan, unlockAt[id]); } stakedCount[msg.sender] += ids.length; totalStaked += ids.length; } function renew(uint256[] calldata ids, uint8 plan) external nonReentrant { if (ids.length == 0 || ids.length > 100 || plan == 0 || plan > 2) revert InvalidAmount(); _settle(msg.sender); for (uint256 i; i < ids.length; ++i) { uint256 id = ids[i]; if (stakerOf[id] != msg.sender) revert Unauthorized(); if (block.timestamp < unlockAt[id]) revert StakeLocked(); uint256 bonus = weightOf(id) * (plan == 1 ? 1 : 2); _rewardWeights[msg.sender] += bonus; _totalRewardWeight += bonus; stakingPlan[id] = plan; unlockAt[id] = block.timestamp + (plan == 1 ? 7 days : 14 days); _pushExpiry(id); emit PlanSelected(msg.sender, id, plan, unlockAt[id]); } } function unstake(uint256[] calldata ids) external nonReentrant { if (ids.length == 0 || ids.length > 100) revert InvalidAmount(); _settle(msg.sender); for (uint256 i; i < ids.length; ++i) { uint256 id = ids[i]; if (stakerOf[id] != msg.sender) revert Unauthorized(); if (block.timestamp < unlockAt[id]) revert StakeLocked(); uint256 base = weightOf(id); stakedWeight[msg.sender] -= base; totalStakedWeight -= base; _rewardWeights[msg.sender] -= base * 2; _totalRewardWeight -= base * 2; delete stakerOf[id]; delete stakingPlan[id]; delete unlockAt[id]; _stakingMove = true; _transfer(address(this), msg.sender, id); _stakingMove = false; emit Unstaked(msg.sender, id); } stakedCount[msg.sender] -= ids.length; totalStaked -= ids.length; } function notifyReward(uint256 amount) external { if (msg.sender != address(token)) revert Unauthorized(); bool current = _checkpoint(AUTO_EXPIRIES); totalReceived += amount; if (current) _fund(_agentStream, amount); else deferredRewards += amount; emit RewardsReceived(amount, queuedRewards()); } function notifyFistReward(uint256 amount) external { if (msg.sender != token.marketplace()) revert Unauthorized(); bool current = _checkpoint(AUTO_EXPIRIES); totalFistReceived += amount; if (current) _fund(_fistStream, amount); else deferredFistRewards += amount; emit FistRewardsReceived(amount, queuedFistRewards()); } function claim() external nonReentrant { _settle(msg.sender); uint256 amount = credits[msg.sender]; if (amount == 0) revert InvalidAmount(); credits[msg.sender] = 0; totalClaimed += amount; IERC20(address(token)).safeTransfer(msg.sender, amount); emit Claimed(msg.sender, amount); } function claimFist() external nonReentrant { _settle(msg.sender); uint256 amount = fistCredits[msg.sender]; if (amount == 0) revert InvalidAmount(); fistCredits[msg.sender] = 0; totalFistClaimed += amount; uint256 beforeBalance = fist.balanceOf(msg.sender); fist.safeTransfer(msg.sender, amount); if (fist.balanceOf(msg.sender) != beforeBalance + amount) revert InvalidAmount(); emit FistClaimed(msg.sender, amount); } // Prevent accidental direct NFT deposits that would not record a staker. function transferFrom(address from, address to, uint256 id) public override { if (to == address(this)) revert Unauthorized(); super.transferFrom(from, to, id); } function tokenURI(uint256 id) public view override returns (string memory) { _requireOwned(id); string memory svg = FistArt.image(levelOf[id]); string memory metal = levelOf[id] == 1 ? "Bronze" : levelOf[id] == 2 ? "Silver" : "Gold"; return string.concat( "data:application/json;base64,", Base64.encode( bytes( string.concat( '{"name":"FIST ', metal, " #", Strings.toString(id), '","attributes":[{"trait_type":"Material","value":"', metal, '"},{"trait_type":"Level","value":', Strings.toString(levelOf[id]), '},{"trait_type":"Weight","value":', Strings.toString(weightOf(id)), '}],"description":"Base NFT forged by burning 550,000 FAGENT. Levels 1/2/3 have conserved weights 1/3/9. Rewards depend on trading taxes, not guaranteed yield.","image":"data:image/svg+xml;base64,', Base64.encode(bytes(svg)), '"}' ) ) ) ); } }