// SPDX-License-Identifier: MIT pragma solidity 0.8.26; interface IERC20Min { function balanceOf(address) external view returns (uint256); function transfer(address to, uint256 amount) external returns (bool); function transferFrom(address from, address to, uint256 amount) external returns (bool); } /// @title FidesLock /// @notice Holds the FIDES reward reserve and the term locks. /// - The reserve is every FIDES token held here that is not someone's principal or someone's promised reward. /// It is funded once by the creator's opening buy (sent here by the launch transaction) and by anyone who /// sends tokens later. Nothing can take it out except rewards paid to lockers. /// - A lock returns the same principal at the end of its term, plus a reward that is fixed and set aside /// the moment the lock is opened. A promised reward can never be cut afterwards. /// - Each day (resetting at 16:00 UTC) only 1% of the free reserve can be promised, and one wallet can take /// at most 20% of that day's budget. The free reserve therefore never reaches zero. /// - No owner, no admin, no pause, no upgrade, no early withdrawal. contract FidesLock { IERC20Min public immutable token; uint256 public constant DAY = 1 days; uint256 public constant DAY_OFFSET = 16 hours; // the daily budget opens at 16:00 UTC uint256 public constant DAILY_BUDGET_BPS = 100; // 1% of the free reserve per day uint256 public constant WALLET_SHARE_BPS = 2_000; // max 20% of a day's budget per wallet uint256 public constant MIN_LOCK = 1_000e18; uint256 private constant BPS = 10_000; struct Position { address owner; uint8 term; // 0 = 7 days, 1 = 30 days, 2 = 90 days, 3 = 180 days bool claimed; uint40 start; uint40 unlockAt; uint128 principal; uint128 reward; } Position[] private _positions; mapping(address => uint256[]) private _byOwner; mapping(address => bool) private _seen; uint256 public totalPrincipal; // principal currently locked (owed back to lockers) uint256 public totalOwed; // rewards promised and not yet paid uint256 public totalPaid; // rewards already paid uint256 public lockers; // distinct wallets that ever locked uint256 public budgetDay; // day index of the stored budget uint256 public dayBudget; // rewards that may be promised during budgetDay uint256 public dayUsed; // rewards already promised during budgetDay mapping(uint256 => mapping(address => uint256)) public walletDayUsed; event DayOpened(uint256 indexed day, uint256 freeReserve, uint256 budget); event Locked(uint256 indexed id, address indexed owner, uint8 term, uint256 principal, uint256 reward, uint256 unlockAt); event Claimed(uint256 indexed id, address indexed owner, uint256 principal, uint256 reward); error InvalidTerm(); error AmountTooSmall(); error RewardBelowMinimum(uint256 reward, uint256 minimum); error NotUnlocked(uint256 unlockAt); error AlreadyClaimed(); error UnknownPosition(); error TransferFailed(); constructor(address token_) { token = IERC20Min(token_); } // ---------------------------------------------------------------- terms function termDays(uint8 term) public pure returns (uint256) { if (term == 0) return 7; if (term == 1) return 30; if (term == 2) return 90; if (term == 3) return 180; revert InvalidTerm(); } /// @notice Reward rate of a term, in basis points of the principal (before the daily budget caps apply). function rateBps(uint8 term) public pure returns (uint256) { if (term == 0) return 150; // 1.5% if (term == 1) return 800; // 8% if (term == 2) return 3_000; // 30% if (term == 3) return 8_000; // 80% revert InvalidTerm(); } // ---------------------------------------------------------------- views function dayIndex(uint256 timestamp) public pure returns (uint256) { return (timestamp - DAY_OFFSET) / DAY; } /// @notice Tokens held here that are neither principal nor promised rewards. function freeReserve() public view returns (uint256) { uint256 bal = token.balanceOf(address(this)); uint256 committed = totalPrincipal + totalOwed; return bal > committed ? bal - committed : 0; } /// @notice Budget, used amount and day index as they stand right now (a new day may not be stored yet). function currentBudget() public view returns (uint256 day, uint256 budget, uint256 used) { day = dayIndex(block.timestamp); if (day == budgetDay && (dayBudget != 0 || dayUsed != 0)) return (day, dayBudget, dayUsed); return (day, freeReserve() * DAILY_BUDGET_BPS / BPS, 0); } /// @notice Reward a lock of `amount` for `term` would receive now for `who`. function previewReward(uint256 amount, uint8 term, address who) public view returns (uint256) { (uint256 day, uint256 budget, uint256 used) = currentBudget(); return _reward(amount, term, budget, used, walletDayUsed[day][who]); } function positionCount() external view returns (uint256) { return _positions.length; } function positionOf(uint256 id) external view returns (Position memory) { if (id >= _positions.length) revert UnknownPosition(); return _positions[id]; } function positionsOf(address owner) external view returns (uint256[] memory) { return _byOwner[owner]; } // ---------------------------------------------------------------- actions /// @notice Lock `amount` FIDES for `term`. The reward is computed and set aside now; reverts if it is below /// `minReward` (use previewReward). Requires an ERC-20 approval of `amount` to this contract. function lock(uint256 amount, uint8 term, uint256 minReward) external returns (uint256 id) { uint256 days_ = termDays(term); // reverts on an invalid term if (amount < MIN_LOCK) revert AmountTooSmall(); _openDay(); uint256 before = token.balanceOf(address(this)); _safeTransferFrom(msg.sender, address(this), amount); uint256 principal = token.balanceOf(address(this)) - before; if (principal < MIN_LOCK) revert AmountTooSmall(); uint256 day = budgetDay; uint256 reward = _reward(principal, term, dayBudget, dayUsed, walletDayUsed[day][msg.sender]); if (reward < minReward) revert RewardBelowMinimum(reward, minReward); dayUsed += reward; walletDayUsed[day][msg.sender] += reward; totalPrincipal += principal; totalOwed += reward; if (!_seen[msg.sender]) { _seen[msg.sender] = true; lockers += 1; } uint256 unlockAt = block.timestamp + days_ * 1 days; id = _positions.length; _positions.push( Position({ owner: msg.sender, term: term, claimed: false, start: uint40(block.timestamp), unlockAt: uint40(unlockAt), principal: uint128(principal), reward: uint128(reward) }) ); _byOwner[msg.sender].push(id); emit Locked(id, msg.sender, term, principal, reward, unlockAt); } /// @notice Pay a matured position (principal + reward) to its owner. Anyone may call it; funds only go to the owner. function claim(uint256 id) public { if (id >= _positions.length) revert UnknownPosition(); Position storage p = _positions[id]; if (p.claimed) revert AlreadyClaimed(); if (block.timestamp < p.unlockAt) revert NotUnlocked(p.unlockAt); p.claimed = true; uint256 principal = p.principal; uint256 reward = p.reward; totalPrincipal -= principal; totalOwed -= reward; totalPaid += reward; _safeTransfer(p.owner, principal + reward); emit Claimed(id, p.owner, principal, reward); } function claimMany(uint256[] calldata ids) external { for (uint256 i = 0; i < ids.length; i++) claim(ids[i]); } // ---------------------------------------------------------------- internals function _openDay() internal { uint256 day = dayIndex(block.timestamp); if (day == budgetDay && (dayBudget != 0 || dayUsed != 0)) return; uint256 free = freeReserve(); budgetDay = day; dayBudget = free * DAILY_BUDGET_BPS / BPS; dayUsed = 0; emit DayOpened(day, free, dayBudget); } function _reward(uint256 amount, uint8 term, uint256 budget, uint256 used, uint256 walletUsed) internal pure returns (uint256 r) { r = amount * rateBps(term) / BPS; uint256 left = budget > used ? budget - used : 0; if (r > left) r = left; uint256 walletCap = budget * WALLET_SHARE_BPS / BPS; uint256 walletLeft = walletCap > walletUsed ? walletCap - walletUsed : 0; if (r > walletLeft) r = walletLeft; } function _safeTransfer(address to, uint256 amount) internal { (bool ok, bytes memory data) = address(token).call(abi.encodeCall(IERC20Min.transfer, (to, amount))); if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); } function _safeTransferFrom(address from, address to, uint256 amount) internal { (bool ok, bytes memory data) = address(token).call(abi.encodeCall(IERC20Min.transferFrom, (from, to, amount))); if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); } }