Enforced by code, not promises

The Contract


Two things secure every lock: code that can't be changed, and the backing of OnChainHoodies — one of Robinhood Chain's bluechips. Every rule below is enforced by that code. Read it here, then verify it against the deployed address yourself.

statusNot deployeddeploys when $OCH launches
the feesmall $OCHper lock — free if you hold a hoodie
max fee, ever1M $OCHan immutable ceiling — the fee can never exceed this
chainRobinhood Chainchain id 4663
What the contract guarantees
what can be locked
ERC-20 tokens and ERC-721 NFTs.
can it be unlocked early?
No. There is no function that releases a lock early — not for the project, the deployer, or us. Locked means locked.
can a lock be shortened?
Never. A lock can only be extended to a later date, never brought forward.
who can withdraw?
Only the wallet chosen when the lock is made, and only after the unlock date. Locks are non-transferable — lock to a wallet you will still control then (a multisig, ideally).
what can be changed?
Only fee settings — the amount, the treasury, and how each fee is split between locking, burning and the treasury — and only for FUTURE locks. Held by a multisig. Nothing already locked can ever be touched.
can the fee surprise me?
No. Every lock states the maximum fee it will pay; if the fee was raised since, the transaction reverts instead of overcharging.
can the contract be upgraded?
No — it is immutable and cannot be changed. New features would ship as separate contracts.
address
pending — deploys when $OCH launches
Source — verbatimHoodLocker.sol · solidity 0.8.28

The exact code that deploys — nothing hidden, nothing off-chain.

// SPDX-License-Identifier: MIT
//
//            ████████              ██████████
//            ██████                  ████████
//            ████                    ████████
//            ████                      ██████
//            ██          ██████████      ████
//            ██        ██████████████    ████
//            ██      ██  ██  ██  ██      ████
//            ██      ████  ██████  ██    ████
//            ██        ██████████████    ████
//            ██          ██████  ████    ████
//            ████          ████████      ████
//            ████              ████      ████
//            ████          ██            ████
//            ████          ██████        ████
//            ██████          ██          ████
//            ████                ██      ████
//            ████                        ████
//
// ██████  ██  ██  ██████  ██  ██  ██████  ██████  ██  ██
// ██  ██  ███ ██  ██      ██  ██  ██  ██    ██    ███ ██
// ██  ██  ██████  ██      ██████  ██████    ██    ██████
// ██  ██  ██ ███  ██      ██  ██  ██  ██    ██    ██ ███
// ██████  ██  ██  ██████  ██  ██  ██  ██  ██████  ██  ██
//
// ██  ██  ██████  ██████  █████   ██████  ██████  ██████
// ██  ██  ██  ██  ██  ██  ██  ██    ██    ██      ██
// ██████  ██  ██  ██  ██  ██  ██    ██    █████   ██████
// ██  ██  ██  ██  ██  ██  ██  ██    ██    ██          ██
// ██  ██  ██████  ██████  █████   ██████  ██████  ██████
//
//
// ────────────────────────────────────────────────────────
//
// Welcome to the hood.
//
// Lock tokens or NFTs for any length of time and get permanent,
// on-chain proof anyone can verify. Custody is immutable — no upgrade
// path, no emergency unlock, no admin reach into locked assets. The
// only thing anyone can adjust is a capped fee. What goes in comes out
// only when you said it would, to the wallet you chose.
//
// Lock for a small $OCH fee — or free if your wallet owns a hoodie.
//
// built by cryptomouse.base.eth
// x.com/_Crypto_Mouse_
//
pragma solidity 0.8.28;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";

/// @title HoodLocker
/// @notice Immutable token & NFT locker. Projects lock ERC-20 supply or ERC-721
///         collections until a chosen time and prove it on-chain. Once locked,
///         nobody — not the project, not OnChainHoodies, not the deployer — can
///         release it early. There is no such function.
/// @dev Custody is immutable by design: no upgrade path, no emergency unlock, no
///      admin reach into locked assets. New features ship as new contracts, never
///      as upgrades.
///
///      Trust boundaries, spelled out:
///      - The lock owner (chosen at creation, fixed forever) is the ONLY address
///        that can withdraw or extend. Locks are deliberately non-transferable:
///        lock to a wallet you will still control at unlock (a multisig, ideally).
///      - Fees are payable ONLY in the $OCH token fixed at deploy, are capped at
///        MAX_FEE forever, and every lock states the max fee it will pay — so a fee
///        change can never take more than the locker agreed to.
///      - HOODIE PERK: any address holding at least one OnChainHoodies NFT pays no
///        fee. Checked live at lock time against the hoodie collection fixed at deploy.
///      - The admin can ONLY adjust FEE PARAMETERS for FUTURE locks: the fee amount
///        (<= MAX_FEE), the treasury, the fee routing (lock / burn / treasury split) and the
///        fee-lock duration. No admin power reaches a user's locked assets — custody is
///        immutable; only fee routing is (boundedly) adjustable. A LOCKED fee share becomes a
///        normal treasury-owned lock: the locker locking its own fees, withdrawable by the
///        treasury only after feeLockDuration. Point the owner at a multisig/timelock, never an EOA.
///      - UNSUPPORTED: rebasing / balance-mutating ERC-20s. Amounts are recorded at
///        lock time; a token that changes holder balances afterwards will strand or
///        misallocate funds. Do not lock them.
///      - WARNING on renounceOwnership(): it is a one-way door. If ownership is
///        renounced while a fee is set and $OCH transfers ever start reverting
///        (pause, blacklist, migration), NEW paid locks brick — nobody can turn the
///        fee off. Existing locks, and free (hoodie-holder) locks, are unaffected.
contract HoodLocker is Ownable2Step, ReentrancyGuard, IERC721Receiver {
    using SafeERC20 for IERC20;

    /// @notice What a lock holds: a fungible amount, or a set of NFTs.
    enum AssetType {
        ERC20,
        ERC721
    }

    struct Lock {
        address token; // the locked ERC-20, or the ERC-721 collection
        address owner; // who can withdraw after unlock — fixed at creation, forever
        uint256 amount; // ERC-20: amount held (net of transfer tax). ERC-721: number of NFTs
        uint256 unlockTime; // unix time when withdraw becomes possible
        AssetType kind; // ERC20 or ERC721
        bool withdrawn; // set once, on withdraw
    }

    /// @notice Every lock, indexed by lockId (= array index).
    Lock[] public locks;
    /// @notice For ERC-721 locks: the exact tokenIds held. Empty for ERC-20 locks.
    mapping(uint256 => uint256[]) private _lockTokenIds;

    /// @notice lockIds for a given token/collection — powers the public "proof" explorer.
    mapping(address => uint256[]) public lockIdsByToken;
    /// @notice lockIds owned by an address (the withdrawer, not necessarily the creator).
    mapping(address => uint256[]) public lockIdsByOwner;
    /// @notice Currently-locked amount per token (ERC-20) or NFT count per collection (ERC-721).
    mapping(address => uint256) public totalLocked;

    // ── fee config ──
    /// @notice The only token fees can ever be paid in ($OCH). Fixed at deploy, forever.
    IERC20 public immutable feeToken;
    /// @notice The OnChainHoodies collection. Holders of >=1 hoodie lock for free.
    ///         Fixed at deploy. If set to the zero address, the perk is disabled.
    IERC721 public immutable hoodieNft;
    /// @notice Hard ceiling on the per-lock fee (1% of $OCH's 100M supply). The live fee is set far
    ///         lower post-deploy; this is only the maximum the owner can ever charge. Assumes 18 decimals.
    uint256 public constant MAX_FEE = 1_000_000e18;
    /// @notice Flat fee per lock, in feeToken. 0 = fees off. Applies only to NEW paid locks.
    uint256 public feeAmount;
    /// @notice Treasury receiving the non-burned share of fees.
    address public feeRecipient;
    /// @notice Fee routing in basis points. `lockBps` is LOCKED (held in this contract as a
    ///         treasury-owned time-lock — the locker locking its own fees); `burnBps` is BURNED
    ///         (sent to BURN); the remainder (10000 - lockBps - burnBps) goes to `feeRecipient`.
    ///         Owner-adjustable, their sum must be <= 10000. Pure routing of the FEE only — it can
    ///         never reach a user's locked assets.
    uint16 public lockBps;
    uint16 public burnBps;
    /// @notice How long a locked fee is held before the treasury (feeRecipient) can withdraw it.
    ///         Owner-adjustable. A locked fee is a normal lock — it appears in totalLocked / getLocks.
    uint256 public feeLockDuration;
    /// @notice The burn sink. 0x…dEaD is a normal address, so ERC-20 transfers to it succeed (unlike 0x0).
    address public constant BURN = 0x000000000000000000000000000000000000dEaD;
    /// @notice Running total of $OCH burned through fees — a live, on-chain deflationary counter.
    uint256 public totalBurned;

    /// @notice Max items a single paginated view returns (RPC-safety cap).
    uint256 public constant MAX_PAGE = 500;

    /// @notice Max NFTs per ERC-721 lock. Bounds the withdraw loop so a lock can ALWAYS be
    ///         withdrawn in one transaction — no batch can ever be gas-stranded. Lock more by
    ///         creating multiple locks.
    uint256 public constant MAX_BATCH = 50;

    error ZeroAmount();
    error ZeroAddress();
    error InvalidUnlockTime();
    error NoTokensReceived();
    error NotLockOwner();
    error AlreadyWithdrawn();
    error StillLocked();
    error CannotShorten();
    error InvalidLockId();
    error FeeTooHigh();
    error NoTokenIds();
    error BatchTooLarge();
    error InvalidBps();

    event Locked(
        uint256 indexed lockId,
        address indexed token,
        address indexed owner,
        address creator,
        uint256 amount,
        uint256 unlockTime
    );
    /// @notice Emitted alongside Locked for ERC-721 locks, carrying the exact tokenIds.
    event NFTLocked(uint256 indexed lockId, address indexed collection, uint256[] tokenIds);
    event Withdrawn(uint256 indexed lockId, address indexed token, address indexed owner, uint256 amount);
    event Extended(uint256 indexed lockId, address indexed token, address indexed owner, uint256 newUnlockTime);
    event FeeUpdated(uint256 feeAmount, address feeRecipient);
    event FeeSplitUpdated(uint16 lockBps, uint16 burnBps);
    event FeeLockDurationUpdated(uint256 feeLockDuration);
    /// @param amount total fee; @param burned share sent to BURN; @param locked share time-locked (rest → treasury).
    event FeeCollected(address indexed payer, uint256 amount, uint256 burned, uint256 locked);

    /// @param initialOwner Admin (multisig recommended): can only set the fee and treasury.
    /// @param ochToken     The $OCH ERC-20 fee token. Fixed forever. Cannot be zero.
    /// @param hoodieNft_   The OnChainHoodies ERC-721. Holders lock free. Zero disables the perk.
    constructor(address initialOwner, address ochToken, address hoodieNft_) Ownable(initialOwner) {
        if (ochToken == address(0)) revert ZeroAddress();
        feeToken = IERC20(ochToken);
        hoodieNft = IERC721(hoodieNft_);
    }

    // ── locking ──

    /// @notice Lock `amount` of an ERC-20 `token` until `unlockTime`, withdrawable by `beneficiary`.
    /// @dev Records the ACTUAL amount received, so fee-on-transfer tokens lock correctly. The lock
    ///      fee (if any, and unless the caller holds a hoodie) is paid in $OCH by the caller BEFORE
    ///      the lock transfer, and only if it is <= `maxFee`.
    /// @param beneficiary The lock's owner: the ONLY address that can ever withdraw or extend it.
    /// @param maxFee The most $OCH the caller agrees to pay for this lock.
    function lock(address token, uint256 amount, uint256 unlockTime, address beneficiary, uint256 maxFee)
        external
        nonReentrant
        returns (uint256 lockId)
    {
        if (token == address(0) || beneficiary == address(0)) revert ZeroAddress();
        if (amount == 0) revert ZeroAmount();
        if (unlockTime <= block.timestamp) revert InvalidUnlockTime();

        _collectFee(msg.sender, maxFee);

        IERC20 t = IERC20(token);
        uint256 balBefore = t.balanceOf(address(this));
        t.safeTransferFrom(msg.sender, address(this), amount);
        uint256 received = t.balanceOf(address(this)) - balBefore;
        if (received == 0) revert NoTokensReceived();

        lockId = locks.length;
        locks.push(
            Lock({
                token: token,
                owner: beneficiary,
                amount: received,
                unlockTime: unlockTime,
                kind: AssetType.ERC20,
                withdrawn: false
            })
        );
        lockIdsByToken[token].push(lockId);
        lockIdsByOwner[beneficiary].push(lockId);
        totalLocked[token] += received;

        emit Locked(lockId, token, beneficiary, msg.sender, received, unlockTime);
    }

    /// @notice Lock a set of ERC-721 `tokenIds` from `collection` until `unlockTime`.
    /// @dev Pulls each tokenId via safeTransferFrom (caller must own & approve them). Duplicate ids
    ///      revert on the second pull. The fee behaves exactly as in `lock` (waived for hoodie holders).
    /// @param beneficiary The lock's owner: the ONLY address that can ever withdraw or extend it.
    /// @param maxFee The most $OCH the caller agrees to pay for this lock.
    function lockNFT(
        address collection,
        uint256[] calldata tokenIds,
        uint256 unlockTime,
        address beneficiary,
        uint256 maxFee
    ) external nonReentrant returns (uint256 lockId) {
        if (collection == address(0) || beneficiary == address(0)) revert ZeroAddress();
        if (tokenIds.length == 0) revert NoTokenIds();
        if (tokenIds.length > MAX_BATCH) revert BatchTooLarge();
        if (unlockTime <= block.timestamp) revert InvalidUnlockTime();

        _collectFee(msg.sender, maxFee);

        IERC721 c = IERC721(collection);
        uint256 n = tokenIds.length;
        for (uint256 i = 0; i < n; ++i) {
            c.safeTransferFrom(msg.sender, address(this), tokenIds[i]);
        }

        lockId = locks.length;
        locks.push(
            Lock({
                token: collection,
                owner: beneficiary,
                amount: n,
                unlockTime: unlockTime,
                kind: AssetType.ERC721,
                withdrawn: false
            })
        );
        _lockTokenIds[lockId] = tokenIds;
        lockIdsByToken[collection].push(lockId);
        lockIdsByOwner[beneficiary].push(lockId);
        totalLocked[collection] += n;

        emit Locked(lockId, collection, beneficiary, msg.sender, n, unlockTime);
        emit NFTLocked(lockId, collection, tokenIds);
    }

    // ── exit ──

    /// @notice Withdraw a matured lock. Only the lock owner, only at/after unlockTime.
    /// @dev ERC-721 payouts use plain transferFrom so a beneficiary contract without a receiver
    ///      hook can never be permanently locked out of its own NFTs.
    function withdraw(uint256 lockId) external nonReentrant {
        if (lockId >= locks.length) revert InvalidLockId();
        Lock storage l = locks[lockId];
        if (msg.sender != l.owner) revert NotLockOwner();
        if (l.withdrawn) revert AlreadyWithdrawn();
        if (block.timestamp < l.unlockTime) revert StillLocked();

        l.withdrawn = true; // effects before interaction
        totalLocked[l.token] -= l.amount;

        if (l.kind == AssetType.ERC20) {
            IERC20(l.token).safeTransfer(l.owner, l.amount);
        } else {
            IERC721 c = IERC721(l.token);
            uint256[] storage ids = _lockTokenIds[lockId];
            uint256 n = ids.length;
            for (uint256 i = 0; i < n; ++i) {
                c.transferFrom(address(this), l.owner, ids[i]);
            }
        }

        emit Withdrawn(lockId, l.token, l.owner, l.amount);
    }

    /// @notice Extend a lock to a later time. Longer only — never shortened, must be in the future.
    function extend(uint256 lockId, uint256 newUnlockTime) external {
        if (lockId >= locks.length) revert InvalidLockId();
        Lock storage l = locks[lockId];
        if (msg.sender != l.owner) revert NotLockOwner();
        if (l.withdrawn) revert AlreadyWithdrawn();
        if (newUnlockTime <= l.unlockTime) revert CannotShorten();
        if (newUnlockTime <= block.timestamp) revert InvalidUnlockTime();

        l.unlockTime = newUnlockTime;
        emit Extended(lockId, l.token, l.owner, newUnlockTime);
    }

    // ── views for the explorer ──

    function locksCount() external view returns (uint256) {
        return locks.length;
    }

    function getLock(uint256 lockId) external view returns (Lock memory) {
        if (lockId >= locks.length) revert InvalidLockId();
        return locks[lockId];
    }

    /// @notice Paginated view over ALL locks (creation order) — powers protocol-wide dashboards
    ///         (total value locked, per-token breakdowns) without any off-chain indexer.
    function getLocks(uint256 offset, uint256 limit) external view returns (Lock[] memory page) {
        uint256 total = locks.length;
        if (offset >= total) return new Lock[](0);
        if (limit > MAX_PAGE) limit = MAX_PAGE;
        uint256 end = offset + limit;
        if (end > total) end = total;
        page = new Lock[](end - offset);
        for (uint256 i = offset; i < end; ++i) {
            page[i - offset] = locks[i];
        }
    }

    /// @notice The tokenIds held by an ERC-721 lock (empty for ERC-20 locks).
    function getLockTokenIds(uint256 lockId) external view returns (uint256[] memory) {
        if (lockId >= locks.length) revert InvalidLockId();
        return _lockTokenIds[lockId];
    }

    function tokenLockCount(address token) external view returns (uint256) {
        return lockIdsByToken[token].length;
    }

    function ownerLockCount(address account) external view returns (uint256) {
        return lockIdsByOwner[account].length;
    }

    /// @notice Paginated locks for a token/collection — for the public proof explorer.
    function getLocksByToken(address token, uint256 offset, uint256 limit) external view returns (Lock[] memory) {
        return _page(lockIdsByToken[token], offset, limit);
    }

    /// @notice Paginated locks owned by an address.
    function getLocksByOwner(address account, uint256 offset, uint256 limit) external view returns (Lock[] memory) {
        return _page(lockIdsByOwner[account], offset, limit);
    }

    function _page(uint256[] storage ids, uint256 offset, uint256 limit) internal view returns (Lock[] memory page) {
        uint256 total = ids.length;
        if (offset >= total) return new Lock[](0);
        if (limit > MAX_PAGE) limit = MAX_PAGE;
        uint256 end = offset + limit;
        if (end > total) end = total;
        page = new Lock[](end - offset);
        for (uint256 i = offset; i < end; ++i) {
            page[i - offset] = locks[ids[i]];
        }
    }

    // ── commitment views (gating primitive for reward / recognition layers) ──

    /// @notice Total amount of `token` a wallet currently has locked here — the sum of its
    ///         not-yet-withdrawn locks of that token (ERC-721: number of NFTs). Includes
    ///         matured-but-unwithdrawn locks: they stay in custody until actually withdrawn.
    /// @dev Iterates the wallet's own locks, so gas scales with how many locks it holds. Read-only —
    ///      derived purely from existing state, no bearing on custody. A neutral fact reward and
    ///      leaderboard layers build on off-chain; scoring policy is deliberately kept off-chain.
    function activeLockedOf(address who, address token) external view returns (uint256 amount) {
        uint256[] storage ids = lockIdsByOwner[who];
        uint256 n = ids.length;
        for (uint256 i = 0; i < n; ++i) {
            Lock storage l = locks[ids[i]];
            if (!l.withdrawn && l.token == token) amount += l.amount;
        }
    }

    /// @notice True if `who` currently holds at least `minAmount` of `token` locked until at least
    ///         `minUnlock`. The on-chain gating primitive: any contract can gate a whitelist, role,
    ///         airdrop or vote on a real, time-bound commitment. Only locks that are both (a) not
    ///         withdrawn and (b) unlocking no earlier than `minUnlock` count toward the total.
    /// @dev `minAmount == 0` is trivially true. Gas scales with the wallet's lock count.
    function hasCommitment(address who, address token, uint256 minAmount, uint256 minUnlock)
        external
        view
        returns (bool)
    {
        if (minAmount == 0) return true;
        uint256 sum;
        uint256[] storage ids = lockIdsByOwner[who];
        uint256 n = ids.length;
        for (uint256 i = 0; i < n; ++i) {
            Lock storage l = locks[ids[i]];
            if (!l.withdrawn && l.token == token && l.unlockTime >= minUnlock) {
                sum += l.amount;
                if (sum >= minAmount) return true;
            }
        }
        return false;
    }

    // ── fee ──

    /// @notice The fee a given payer would pay right now: 0 if they hold a hoodie, else `feeAmount`.
    function feeFor(address payer) public view returns (uint256) {
        if (address(hoodieNft) != address(0) && hoodieNft.balanceOf(payer) > 0) return 0;
        return feeAmount;
    }

    /// @notice Set the per-lock fee (<= MAX_FEE, in $OCH) and treasury. Applies only to locks created
    ///         afterwards, and never more than a locker's stated `maxFee`.
    /// @dev Do NOT renounceOwnership() while a fee is set unless you accept the one-way door: if $OCH
    ///      transfers ever revert, new PAID locks brick with nobody able to turn the fee off. Free
    ///      (hoodie-holder) locks keep working regardless.
    function setFee(uint256 feeAmount_, address feeRecipient_) external onlyOwner {
        if (feeAmount_ > MAX_FEE) revert FeeTooHigh();
        if (feeAmount_ > 0 && feeRecipient_ == address(0)) revert ZeroAddress();
        feeAmount = feeAmount_;
        feeRecipient = feeRecipient_;
        emit FeeUpdated(feeAmount_, feeRecipient_);
    }

    /// @notice Set the share of each fee that is burned, in basis points (<= 10000). Applies to
    ///         FUTURE locks only. A pure routing knob between burn and treasury — it never touches
    ///         locked assets and can never overcharge a locker (the total is still bounded by maxFee).
    /// @notice Set how each fee is routed: `lockBps_` locked (treasury-owned time-lock), `burnBps_`
    ///         burned, the rest to the treasury. Sum must be <= 10000. Applies to FUTURE locks only.
    function setFeeSplit(uint16 lockBps_, uint16 burnBps_) external onlyOwner {
        if (uint256(lockBps_) + burnBps_ > 10_000) revert InvalidBps();
        lockBps = lockBps_;
        burnBps = burnBps_;
        emit FeeSplitUpdated(lockBps_, burnBps_);
    }

    /// @notice Set how long a locked fee is held before the treasury can withdraw it. FUTURE locks only.
    function setFeeLockDuration(uint256 seconds_) external onlyOwner {
        feeLockDuration = seconds_;
        emit FeeLockDurationUpdated(seconds_);
    }

    function _collectFee(address payer, uint256 maxFee) internal {
        uint256 amt = feeFor(payer);
        if (amt > maxFee) revert FeeTooHigh();
        if (amt == 0) return;

        uint256 burnAmt = (amt * burnBps) / 10_000;
        uint256 lockAmt = (amt * lockBps) / 10_000;
        uint256 treasuryAmt = amt - burnAmt - lockAmt;

        if (burnAmt > 0) {
            feeToken.safeTransferFrom(payer, BURN, burnAmt);
            totalBurned += burnAmt;
        }
        uint256 lockedRecorded;
        if (lockAmt > 0) {
            // pull the fee in and lock it as a normal treasury-owned lock — the locker locks its own
            // fees. Measure the ACTUAL amount received (same as user locks) so a fee-on-transfer fee
            // token can never over-record the lock and brick its own withdrawal.
            uint256 balBefore = feeToken.balanceOf(address(this));
            feeToken.safeTransferFrom(payer, address(this), lockAmt);
            lockedRecorded = feeToken.balanceOf(address(this)) - balBefore;
            if (lockedRecorded > 0) {
                uint256 id = locks.length;
                uint256 unlockAt = block.timestamp + feeLockDuration;
                locks.push(
                    Lock({
                        token: address(feeToken),
                        owner: feeRecipient,
                        amount: lockedRecorded,
                        unlockTime: unlockAt,
                        kind: AssetType.ERC20,
                        withdrawn: false
                    })
                );
                lockIdsByToken[address(feeToken)].push(id);
                lockIdsByOwner[feeRecipient].push(id);
                totalLocked[address(feeToken)] += lockedRecorded;
                emit Locked(id, address(feeToken), feeRecipient, address(this), lockedRecorded, unlockAt);
            }
        }
        if (treasuryAmt > 0) {
            feeToken.safeTransferFrom(payer, feeRecipient, treasuryAmt);
        }
        emit FeeCollected(payer, amt, burnAmt, lockedRecorded);
    }

    // ── ERC-721 receiver (so safeTransferFrom into custody succeeds) ──

    function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
        return IERC721Receiver.onERC721Received.selector;
    }
}

Locked means locked.

Immutable by design — no admin, not even us, can touch locked funds. Ever.

No early-unlock function. No upgrade path. No emergency exit. New features ship as new contracts, never as changes to this one.

Hood Locker isn't live yet — it deploys when $OCH launches, and the pages stay empty until the first locks land.