// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; 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"; /// @title TimeLockVault /// @notice An immutable, ownerless vault that locks ONE ERC-20 token /// (e.g. USDC on Base) for a FIXED duration set once at deployment. /// /// Design goal: a genuine commitment device. Nobody — not the /// depositor, not the deployer, not any admin — can shorten, /// extend, pause, or bypass a lock. There is intentionally NO owner, /// NO setter, and NO upgrade path. Deploy one instance per duration /// (1d, 2d, 3d, 7d, ...). The contract's address IS the promise. /// /// Each deposit names a RECIPIENT — the address the funds are released /// to at maturity. It defaults to the depositor (lock for yourself) but /// can be any address. The recipient is snapshotted at deposit and is /// IMMUTABLE: nobody can redirect a lock once made. Pre-committing the /// destination is the point — it stops a moment of temptation from /// sending the funds somewhere else. /// /// A protocol FEE is taken at deposit time and sent to a fixed fee /// recipient. The fee is `min(amount * feeBps / 10000, feeCap)` — a /// percentage with an optional absolute cap so large deposits are not /// charged disproportionately (feeCap == 0 means no cap). The fee rate, /// the cap, and the fee recipient are all IMMUTABLE constructor args, /// with the rate capped at MAX_FEE_BPS, so they can never be raised, /// redirected, or switched on later. This is the ONLY privileged flow, /// and it touches only the fee slice at the moment of deposit — it /// grants no power whatsoever over funds already locked. The ownerless /// guarantee for locked deposits is intact. /// /// @dev Intended for standard, non-fee, non-rebasing tokens like USDC. /// Do NOT use with fee-on-transfer or rebasing tokens: the amount /// pulled in may not match the amount recorded. /// /// NOTE: USDC can freeze (blacklist) an address. If a deposit's /// recipient is later blacklisted, its withdrawal will revert and the /// funds are stuck forever — there is no rescue path. Choose a /// recipient you trust to stay transferable. contract TimeLockVault is ReentrancyGuard { using SafeERC20 for IERC20; /// @notice Hard ceiling on the deploy-time fee: 5%. Enforced in the /// constructor so no instance can ever be deployed with a /// confiscatory fee, immutable or not. uint256 public constant MAX_FEE_BPS = 500; /// @notice The only token this vault accepts (e.g. USDC). IERC20 public immutable token; /// @notice Lock length in SECONDS, fixed forever at deployment. uint256 public immutable lockDuration; /// @notice Protocol fee in basis points (100 = 1%), fixed forever at deployment. uint256 public immutable feeBps; /// @notice Absolute cap on the fee, in token units (e.g. 25e6 = 25 USDC). /// 0 means no cap. Fixed forever at deployment. Keeps large deposits /// from paying a disproportionate percentage fee. uint256 public immutable feeCap; /// @notice Where the fee is sent, fixed forever at deployment. address public immutable feeRecipient; struct Deposit { address owner; // who created the lock (may trigger release; can NOT redirect it) address recipient; // where the funds go at maturity — fixed forever at deposit uint256 amount; // net amount locked (after fee); exactly what is withdrawable uint256 unlockTime; // when it becomes withdrawable (snapshotted at deposit) } mapping(uint256 => Deposit) public deposits; uint256 public nextDepositId; event Deposited( uint256 indexed id, address indexed owner, address indexed recipient, uint256 amount, uint256 unlockTime ); event Withdrawn(uint256 indexed id, address indexed recipient, uint256 amount); event FeeCharged(uint256 indexed id, address indexed feeRecipient, uint256 fee); /// @param _token ERC-20 token address (e.g. USDC on Base). /// @param _lockDuration Lock length in seconds. Set once, forever. /// @param _feeBps Fee in basis points (100 = 1%). Set once, forever. Max MAX_FEE_BPS. /// @param _feeCap Absolute fee cap in token units (0 = uncapped). Set once, forever. /// @param _feeRecipient Address that receives fees. Set once, forever. constructor(IERC20 _token, uint256 _lockDuration, uint256 _feeBps, uint256 _feeCap, address _feeRecipient) { require(address(_token) != address(0), "token is zero address"); require(_lockDuration > 0, "duration must be > 0"); require(_feeBps <= MAX_FEE_BPS, "fee exceeds cap"); // A non-zero fee needs somewhere to go. A zero fee may leave it unset. require(_feeBps == 0 || _feeRecipient != address(0), "fee recipient is zero"); token = _token; lockDuration = _lockDuration; feeBps = _feeBps; feeCap = _feeCap; feeRecipient = _feeRecipient; // Deliberately no Ownable, no admin, no owner variable. The fee params // above are the only privileged values and they are immutable. } /// @notice Lock `amount` of the token for the fixed duration, releasing the /// net-of-fee amount to `recipient` at maturity. /// @dev Caller must FIRST call `approve(thisContract, amount)` on the token. /// Pass your own address as `recipient` to lock for yourself. /// @param amount Gross amount to pull in. The fee is taken from this; the /// remainder is locked. /// @param recipient Where the net funds go at maturity. Immutable once set. /// @return id The id of the newly created deposit (needed to withdraw). function deposit(uint256 amount, address recipient) external nonReentrant returns (uint256 id) { require(amount > 0, "amount must be > 0"); // A zero recipient would burn the funds at withdrawal time. Reject it up // front rather than let someone lock money that can never be released. require(recipient != address(0), "recipient is zero address"); uint256 fee = _feeOn(amount); uint256 locked = amount - fee; require(locked > 0, "amount too small after fee"); // Snapshot the unlock time to THIS deposit. uint256 unlock = block.timestamp + lockDuration; id = nextDepositId++; deposits[id] = Deposit({owner: msg.sender, recipient: recipient, amount: locked, unlockTime: unlock}); // Effects recorded above; external calls last. Pull the gross amount in, // then forward only the fee slice out. token.safeTransferFrom(msg.sender, address(this), amount); if (fee > 0) { token.safeTransfer(feeRecipient, fee); emit FeeCharged(id, feeRecipient, fee); } emit Deposited(id, msg.sender, recipient, locked, unlock); } /// @notice Release a matured deposit to its recipient. /// @dev Callable by the depositor OR the recipient, but ONLY after /// maturity, and the funds ALWAYS go to the recipient recorded at /// deposit — never to msg.sender. That is what makes the destination /// a genuine pre-commitment: triggering the release cannot redirect /// it. Either party may pay the gas; neither can change where it lands. function withdraw(uint256 id) external nonReentrant { Deposit memory d = deposits[id]; require(d.amount > 0, "nothing to withdraw"); require(msg.sender == d.owner || msg.sender == d.recipient, "not owner or recipient"); require(block.timestamp >= d.unlockTime, "still locked"); // the only timing gate delete deposits[id]; // clear state BEFORE sending out // Always to the recipient snapshotted at deposit, regardless of caller. token.safeTransfer(d.recipient, d.amount); emit Withdrawn(id, d.recipient, d.amount); } /// @notice Seconds left until a deposit unlocks (0 if ready). function timeRemaining(uint256 id) external view returns (uint256) { uint256 u = deposits[id].unlockTime; if (u == 0 || block.timestamp >= u) return 0; return u - block.timestamp; } /// @notice Preview the fee and net locked amount for a gross `amount`. /// @dev View helper for UIs; mirrors the split done in `deposit`. function quote(uint256 amount) external view returns (uint256 fee, uint256 locked) { fee = _feeOn(amount); locked = amount - fee; } /// @dev The fee for a gross `amount`: a `feeBps` percentage, optionally /// capped at `feeCap` (0 = uncapped). Rounds down. function _feeOn(uint256 amount) internal view returns (uint256 fee) { fee = (amount * feeBps) / 10_000; if (feeCap != 0 && fee > feeCap) fee = feeCap; } }