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.
statusLiveimmutable, deployed
chainRobinhood Testnetchain id 46630
the fee…reading the contract…
active locks…held right now, tokens, NFTs and LP
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).
- can anything be altered?
- Only the fee settings: the amount, the treasury, and how each fee splits between locking, the treasury and a dev share that can never exceed 5%. Those apply to future locks only. The dev address is fixed at deploy and has no setter. 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.
- how high could the fee go?
- Never above 25,000 $OCH. That ceiling is written into the contract as a constant — not a setting, so no owner can raise it, ever. The live fee is set far below it.
- can the contract be upgraded?
- No — it is immutable and cannot be changed. New features would ship as separate contracts.
- address
- 0x6a249F0c1173d68b0Dd6a7Eb265315a6B0707cC5
- checking this with an agent?
- The protocol is documented for machines at /llms.txt, and the ABI plus addresses are at /api/abi. What is locked can be read as JSON from /api/locks?token=0x… — pin the deployment with &chainId= and it will refuse rather than answer for the wrong one. All of it is derived from the same contract you can read below.
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 / dev / treasury split) and the
/// fee-lock duration. The dev share is capped at MAX_DEV_BPS forever and its
/// recipient is fixed at deploy, so neither can be raised or redirected later. 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: 0.025% of $OCH's 100M supply. The live fee is set
/// far below it; this is only the most the owner could ever charge. Assumes 18 decimals.
/// @dev Kept close to the fee actually intended rather than generous — a ceiling hundreds of
/// times the real fee rules out nothing anyone would have paid, and the guarantee is
/// worth exactly what it rules out.
/// The remaining headroom is for one direction only. The fee is denominated in tokens,
/// so a RISING price needs fewer of them and never approaches this; only a falling price
/// pushes towards the cap. 25,000 leaves room for roughly a tenfold fall from the fee
/// intended at launch. Reaching it would be a soft failure: the locker keeps working,
/// it simply can't charge much.
uint256 public constant MAX_FEE = 25_000e18;
/// @notice Flat fee per lock, in feeToken. 0 = fees off. Applies only to NEW paid locks.
uint256 public feeAmount;
/// @notice Treasury receiving the share of fees that is neither locked nor paid to the dev.
address public feeRecipient;
/// @notice Fee routing in basis points, both shares of the WHOLE fee (they do not nest).
/// `lockBps` is LOCKED (held in this contract as a treasury-owned time-lock — the locker
/// locking its own fees); `devBps` is paid to `devRecipient`; the remainder
/// (10000 - lockBps - devBps) 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.
/// @dev Nothing is burned. This is a locker: supply comes back to its owner at unlock, which
/// is the opposite of destroying it. The fee follows the same principle — it is locked,
/// not burned.
uint16 public lockBps;
uint16 public devBps;
/// @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 Hard ceiling on the dev share, forever. The owner can set `devBps` anywhere from 0 to
/// this and no higher, so the dev cut is bounded by code rather than by promise.
/// @dev Deliberately set to the share actually intended, not a headroom figure: the revenue
/// lever is `feeAmount`, so spare room here would only ever let the dev take a larger
/// slice away from the treasury — the exact thing this cap exists to rule out.
uint16 public constant MAX_DEV_BPS = 500; // 5%
/// @notice Who receives the dev share. Fixed at deploy and never changeable, so it cannot be
/// quietly redirected. Zero disables the dev share permanently.
address public immutable devRecipient;
/// @notice Running total of $OCH paid to `devRecipient` through fees.
uint256 public totalToDev;
/// @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();
error DevShareTooHigh();
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 devBps);
event FeeLockDurationUpdated(uint256 feeLockDuration);
/// @param amount total fee; @param dev share paid to devRecipient; @param locked share time-locked (rest → treasury).
event FeeCollected(address indexed payer, uint256 amount, uint256 dev, 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.
/// @param devRecipient_ Receives the dev share (<= MAX_DEV_BPS). Fixed forever; zero disables it.
constructor(address initialOwner, address ochToken, address hoodieNft_, address devRecipient_)
Ownable(initialOwner)
{
if (ochToken == address(0)) revert ZeroAddress();
feeToken = IERC20(ochToken);
hoodieNft = IERC721(hoodieNft_);
devRecipient = devRecipient_;
}
// ── 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, token, 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, collection, 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 to lock `asset` right now: 0 for a hoodie, else `feeAmount`.
/// @dev The waiver is a property of what is being locked, not of who is locking it. Locking a
/// hoodie takes it out of circulation for the duration, and that is what is being paid
/// for — a holder waiver instead rewards buying one and selling it straight back.
///
/// Locking a hoodie also freezes its ERC-6551 wallet: authority there follows the current
/// NFT owner, and this contract cannot sign. So one free lock can hold an entire treasury.
function feeForAsset(address asset) public view returns (uint256) {
if (asset != address(0) && asset == address(hoodieNft)) 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 how each fee is routed: `lockBps_` locked (treasury-owned time-lock), `devBps_`
/// to the dev, the rest to the treasury. Both are shares of the whole fee and must sum to
/// <= 10000; `devBps_` may never exceed MAX_DEV_BPS. Applies to FUTURE locks only.
/// @dev A pure routing knob — it never touches locked assets, and can never overcharge a
/// locker, since the total is still the fee they agreed to via `maxFee`.
function setFeeSplit(uint16 lockBps_, uint16 devBps_) external onlyOwner {
if (devBps_ > MAX_DEV_BPS) revert DevShareTooHigh();
if (uint256(lockBps_) + devBps_ > 10_000) revert InvalidBps();
lockBps = lockBps_;
devBps = devBps_;
emit FeeSplitUpdated(lockBps_, devBps_);
}
/// @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, address asset, uint256 maxFee) internal {
uint256 amt = feeForAsset(asset);
if (amt > maxFee) revert FeeTooHigh();
if (amt == 0) return;
uint256 devAmt = devRecipient == address(0) ? 0 : (amt * devBps) / 10_000;
uint256 lockAmt = (amt * lockBps) / 10_000;
uint256 treasuryAmt = amt - devAmt - lockAmt;
if (devAmt > 0) {
feeToken.safeTransferFrom(payer, devRecipient, devAmt);
totalToDev += devAmt;
}
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, devAmt, lockedRecorded);
}
// ── ERC-721 receiver (so safeTransferFrom into custody succeeds) ──
function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
}
}