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;
}
}