Contract reference.
Five contracts, all deployed on Robinhood Chain. Read them yourself before you send anyone to them. That is the point of publishing the addresses.
Addresses#
| Contract | Address | Purpose |
|---|---|---|
| Locker | 0xd0f7d8c6e9f6d80c297bebe4f7fd1b9c8125c32f | Holds ERC-20 tokens until a date |
| Burner | 0x6bf43ca706faa8ea46803299c191484e82280652 | Sends tokens to the dead address, on the record |
| Vesting | 0x910e19bcC4bce46999994Ed7297E0Fc4431ec72E | Releases tokens gradually to a beneficiary |
| Airdrop | 0x6B1fE7b821001144Ee74EEA258b0AafdE20102E8 | Holds a funded airdrop that recipients claim with a Merkle proof |
| Liquidity Locker | 0x91Aa2dA1956C77F8448E18DCaf53383bCc658e67 | Holds a Uniswap v3 or v4 position until a date; the owner keeps collecting its fees |
None of the five is upgradeable and none sits behind a proxy. We compiled the source of each one and checked that it matches the bytecode running at these addresses. You can open any address on the explorer and compare it yourself. The network parameters are on the network page.
None of these contracts has a function that lets us move your tokens. That is a property of the code, not a promise. The section on each contract below points at the specific functions to check.
Locker#
One vault, many locks. Each lock records an owner, a token, an amount and an unlock time.
Functions#
| Function | Notes |
|---|---|
lock(address token, uint256 amount, uint256 unlockTime) payable | Returns the new lock id. Requires msg.value >= fee, a non-zero token, a positive amount and a future unlock time. Excess ETH is refunded. |
withdraw(uint256 id) | Only the lock's owner, only once, and only at or after unlockTime. |
extend(uint256 id, uint256 newUnlockTime) | Requires newUnlockTime > unlockTime. A lock can be pushed further out but never pulled in. |
transferLockOwnership(uint256 id, address newOwner) | Moves the right to withdraw. The tokens do not move. |
locks(id), getLock(id), lockedAmount(id), isUnlocked(id), timeRemaining(id) | Views. lockedAmount returns 0 once withdrawn. |
locksByOwner(address), locksByToken(address), totalLocks() | Enumeration, used by the explorer and by proof pages. After a lock changes owner, locksByOwner still lists it under the previous owner as well, so check locks(id).owner before treating a lock as theirs. |
Events#
event Locked(uint256 indexed id, address indexed owner, address indexed token, uint256 amount, uint256 unlockTime);
event Withdrawn(uint256 indexed id, address indexed owner, uint256 amount);
event Extended(uint256 indexed id, uint256 newUnlockTime);
event LockOwnershipTransferred(uint256 indexed id, address indexed from, address indexed to);
event FeeChanged(uint256 fee);What the code guarantees#
- Locked tokens can only be withdrawn by the lock's owner, and only at or after the unlock time.
- There is no admin function that can move locked tokens.
unlockTimecan only ever be extended.- The amount recorded is measured as the balance the contract actually gained, so a fee-on-transfer token is not over-credited.
The locker has no hard cap on the fee. The admin can change it, and a change applies to new locks only. An existing lock is never re-priced. The vesting and airdrop contracts do have caps; see below.
An ordinary ERC-20 works, and that is almost every token. Two narrow cases do not, and both are worth knowing before you lock.
The first is a token whose transfer returns no value at all. The locker rejects it, so the
transaction fails and nothing is ever deposited. You find out immediately rather than later.
The second is a token that changes balances on its own. The locker records how many tokens arrived when you lock, and pays back exactly that number. All locks of the same token share one balance in the contract.
If a token adds to holders' balances by itself, like a reflection token or a positive rebase, the extra tokens stay in the contract and nobody can withdraw them. If a token takes away from holders' balances, like a negative rebase, the contract ends up holding less than it owes, and at least the last owner of that token will not be able to unlock.
A standard ERC-20 cannot lose tokens held by the locker, because moving or burning them needs the locker's approval, which it never gives. That is why the ordinary case is safe. The contract does not block a rebasing token, so read the token before you lock it.
Burner#
A burn registry rather than a vault. Tokens are moved from your wallet to the dead address in a single transfer and never rest in the contract.
| Function | Notes |
|---|---|
burn(address token, uint256 amount) payable | transferFrom(msg.sender, DEAD, amount) in one hop, so the token must be approved for the burner first. Returns the burn id. Excess ETH is refunded. |
DEAD() | 0x000000000000000000000000000000000000dEaD |
burns(id), getBurn(id), burnsByBurner(address), burnsByToken(address), totalBurns() | Views. |
totalBurnedOf(address token) | Cumulative amount burned through this contract for one token. |
event Burned(uint256 indexed id, address indexed burner, address indexed token, uint256 amount);
event FeeChanged(uint256 fee);Nothing in this contract can move tokens back out of the dead address, and no key controls that address. From HoodLock's side a burn is final. Read burning versus locking before choosing it.
The token can still undo it. Sending to the dead address parks tokens there; it does not
reduce totalSupply. A token that is mintable can re-issue the same amount, and an upgradeable
token can add a function that moves the dead address's balance. Neither is detectable from here.
The same limitation applies to totalBurnedOf: it is derived from the token's own
balanceOf readings, so a token that reports whatever it likes produces a burn record that
looks legitimate. Check whether the token is mintable or upgradeable before treating a burn total as
supply reduction.
Vesting#
Linear release with an optional cliff. There is no revoke, no sweep and no rescue function in this contract, so no HoodLock function can cancel or alter a schedule once it exists.
“Irrevocable” describes this contract, not the token. A token that is upgradeable, pausable, mintable, or that can blacklist an address, can still make a schedule worthless or unclaimable after the fact, and the schedule's creator is very often the token's deployer, which means they may retain exactly that power.
The reverse also holds: because there is no rescue path, a token that is paused or that freezes the beneficiary locks the tokens permanently, with no recourse from anyone. Irrevocable and unrecoverable are the same property.
| Function | Notes |
|---|---|
create(address token, address beneficiary, uint256 amount, uint64 start, uint64 cliff, uint64 end) payable | Requires msg.value == fee, a non-zero beneficiary, a token with code, a positive amount, start <= cliff <= end, end > start, and end > block.timestamp + MIN_DURATION. |
createMany(...) | Batch, up to MAX_BATCH beneficiaries. Requires msg.value == fee * n. |
claim(uint256 id) | Beneficiary only. Claiming is free. The fee is charged once, at creation. |
claimable(uint256 id) | 0 before the cliff; linear between cliff and end; the exact remainder at end, so nothing is left as dust. |
transferBeneficiary(uint256 id, address newBeneficiary) | Current beneficiary only. |
schedules(id), getSchedule(id), totalSchedules(), schedulesByToken, schedulesByBeneficiary, schedulesByCreator | Views. After a beneficiary transfer, schedulesByBeneficiary still lists the schedule under the old address too, so check schedules(id).beneficiary. |
Constants#
| Constant | Value | What it protects |
|---|---|---|
MAX_FEE | 0.05 ETH | A hard ceiling the admin can never exceed. |
MIN_DURATION | 24 hours | Stops a schedule that would be fully vested on arrival. |
MAX_BATCH | 200 | Bounds gas on createMany. |
Vesting and airdrops take the exact fee only. Their create functions require
msg.value to equal the fee exactly and have no refund path, while the locker and burner accept
an overpayment and refund the difference. Read the fee immediately before building the transaction rather
than caching it.
Leave a margin on the 24 hours. The REST API and the embed widget check that
end - start >= 86400. The contract checks end > block.timestamp +
MIN_DURATION in the block where the transaction lands. A schedule of exactly 24 hours, or one with
a back-dated start, can pass those checks and still revert on-chain. If you build your own UI,
check against block.timestamp and add a few minutes.
event VestingCreated(uint256 indexed id, address indexed token, address indexed beneficiary,
address creator, uint256 total, uint64 start, uint64 cliff, uint64 end);
event Claimed(uint256 indexed id, address indexed beneficiary, uint256 amount);
event BeneficiaryTransferred(uint256 indexed id, address indexed from, address indexed to);
event FeeChanged(uint256 fee);
event FeeCollectorChanged(address collector);
event FeesWithdrawn(address indexed collector, uint256 amount);
event AdminTransferStarted(address indexed current, address indexed pending);
event AdminTransferred(address indexed previous, address indexed current);Airdrop#
Pull-based Merkle airdrops. The creator deposits the tokens and publishes a Merkle root, and each recipient claims their own share. Nothing is pushed to a wallet. How to run one is covered in the airdrops guide.
| Function | Notes |
|---|---|
create(address token, bytes32 merkleRoot, uint256 total, uint32 maxClaims, uint64 endTime, string uri) payable | Returns the airdrop id. Requires msg.value == quote(maxClaims) exactly, a non-zero root, a positive total, between 1 and MAX_RECIPIENTS recipients, a token with code, a list URI of at most 256 bytes, and an endTime that is either 0 or at least 7 days away. The full total must arrive, so fee-on-transfer tokens are rejected. |
claim(uint256 id, uint256 index, address account, uint256 amount, bytes32[] proof) | Anyone can submit a claim, but the tokens always go to account. Requires a valid proof, an index not yet claimed, an airdrop that has not ended, fewer than maxClaims claims so far, and enough tokens left in this airdrop. Claiming is free. |
sweep(uint256 id) | Creator only, once, at or after endTime. Returns whatever nobody claimed. An airdrop created with endTime 0 can never be swept. |
quote(uint32 maxClaims) | The exact fee create will charge for that many recipients. |
airdrops(id), getAirdrop(id), isClaimed(id, index), listURI(id), totalAirdrops() | Views. There are no per-token or per-creator lists; read the AirdropCreated logs instead. |
Each leaf is keccak256(bytes.concat(keccak256(abi.encode(index, account, amount)))), and
pairs are hashed in sorted order. That is the OpenZeppelin convention, so proofs from standard Merkle
libraries verify here.
Constants#
| Constant | Value | What it protects |
|---|---|---|
MAX_FEE | 0.25 ETH | The most any single airdrop can be charged, however the admin sets the fee. |
MIN_WINDOW | 7 days | A deadline has to give recipients at least a week to claim. |
MAX_RECIPIENTS | 250,000 | Bounds the fee quote and the size of the list. |
MAX_URI | 256 bytes | Bounds the stored list URI. |
The fee is feeBase + feePerWallet × maxClaims, capped at MAX_FEE. On
15 September 2026 both parameters were zero on-chain, so creating an airdrop cost only gas. Call
quote before building the transaction rather than assuming it is still free.
maxClaims is a hard ceiling. The fee scales with it, and it also limits how
many claims can ever succeed. Declare fewer recipients than your list holds and the last recipients will
not be able to claim.
Each airdrop can only pay out its own deposit. A tree that promises more than was deposited cannot spend
another airdrop's tokens; claims simply fail once this one runs out. An endTime of 0 means the
airdrop stays claimable forever, and anything unclaimed stays in the contract permanently.
event AirdropCreated(uint256 indexed id, address indexed token, address indexed creator,
bytes32 merkleRoot, uint256 total, uint32 maxClaims, uint64 endTime, string listURI);
event Claimed(uint256 indexed id, uint256 index, address indexed account, uint256 amount);
event Swept(uint256 indexed id, address indexed creator, uint256 amount);
event FeeChanged(uint256 feeBase, uint256 feePerWallet);
event FeeCollectorChanged(address collector);
event FeesWithdrawn(address indexed collector, uint256 amount);
event AdminTransferStarted(address indexed current, address indexed pending);
event AdminTransferred(address indexed previous, address indexed current);Liquidity Locker#
Locks a Uniswap v3 or v4 position until a date. The locker holds the position NFT for the whole lock,
and the owner keeps collecting the position's trading fees while it is locked. How to use it is covered in
the liquidity locker guide. The position managers it accepts are the Uniswap v3
NonfungiblePositionManager 0x73991a25C818Bf1f1128dEAaB1492D45638DE0D3 and the Uniswap v4
PositionManager 0x58daec3116aae6D93017bAAea7749052E8a04fA7.
Functions#
| Function | Notes |
|---|---|
lock(address manager, uint256 tokenId, uint256 unlockTime, address feeRecipient, bool acceptUnverifiedHook, uint16 maxCollectFeeBps) payable | Returns the new lock id. The caller must own the position and have approved it to the locker. maxCollectFeeBps is the highest collect fee the caller accepts, so a rate change cannot land before the transaction. A v4 position in a pool with a hook that is not on the reviewed list needs acceptUnverifiedHook. |
withdraw(uint256 id, address to) | Only the lock's owner, only once, and only at or after unlockTime. Hands the position NFT back with one plain transfer. |
extend(uint256 id, uint256 newUnlockTime) | A lock can be pushed further out but never pulled in. |
collect(uint256 id), collectMany(uint256[] ids) | Collects the position's trading fees. The owner's share goes to the lock's fee recipient and HoodLock's share, the collect fee frozen at lock time, to HoodLock. The liquidity itself stays locked. |
setFeeRecipient(uint256 id, address recipient), transferLockOwnership(uint256 id, address newOwner) | Owner only. Changes where fees go, or who can withdraw. The position does not move. |
lockIdOf(address manager, uint256 tokenId) | The active lock id for a position, or 0 when it is not locked. The quickest way to answer “is this position locked?” |
getLock(uint256 id) | owner, unlockTime, collectFeeBps, isV4, hookVerified, withdrawn, feeRecipient, token0, token1, tokenId. For v4, token0 = address(0) means native ETH. |
locksOf(address owner), nextLockId(), isUnlocked(uint256 id) | Enumeration and views. Lock ids start at 1, so ids 1..nextLockId-1 all exist. locksOf keeps a lock listed under a previous owner too, so check getLock(id).owner. |
Events#
event Locked(uint256 indexed id, address indexed owner, address indexed manager, uint256 tokenId,
address token0, address token1, bytes32 pool, uint256 unlockTime, uint16 collectFeeBps,
bool hookVerified, address feeRecipient, uint256 fixedFeePaid);
event Extended(uint256 indexed id, uint256 newUnlockTime);
event Withdrawn(uint256 indexed id, address indexed to);
event LockOwnershipTransferred(uint256 indexed id, address indexed from, address indexed to);
event FeeRecipientChanged(uint256 indexed id, address indexed recipient);
event Collected(uint256 indexed id, address indexed recipient, uint256 amount0, uint256 amount1,
uint256 cut0, uint256 cut1, bool byAdmin);In Collected, amount0 and amount1 are everything the position paid out, before HoodLock's cut. The fee recipient received amount0 - cut0 and amount1 - cut1.
pool in Locked is the v4 pool id, or the v3 pool address left-padded to 32 bytes.
What the code guarantees#
- A locked position can only be withdrawn by the lock's owner, and only at or after the unlock time.
- No admin function can move a locked position. The admin's stray-position return reverts for any position with an active lock.
unlockTimecan only ever be extended, and at most 100 years ahead.- The collect fee is frozen per lock at creation and capped at 30% in the contract; the fixed fee is capped at 0.1 ETH.
- The admin, or the keeper role it appoints, can trigger a fee collection with
adminCollect. It pays exactly what an owner's own collect pays, to the lock's own fee recipient, and has no recipient parameter.
Integrating#
Explorers, scanners and security tools can tell whether a pool's liquidity is locked with two reads:
- Find who owns the position NFT on the v3 or v4 position manager. If it is the Liquidity Locker, call
lockIdOf(manager, tokenId). - A non-zero id is an active lock.
getLock(id)gives the owner andunlockTime.
Every lock has a public proof page at https://hoodlock.tech/proof/position/{lockId}, and the
same data as JSON from GET /api/lp/lock/{lockId}; GET /api/lp/locks lists every
liquidity lock with its pair, amounts and value. The REST API covers the rest.
Administration#
All five contracts share one admin address, 0x79c1230cAb12d53D040f5FE1F5279e1A481CCeA2. It is a single wallet, not a multisig.
Fees go to the fee collector, which is a contract at 0xd59948E49B1B56784626bDe2d6D1712eD24a6BBc. No admin function reads or moves
user tokens, and none can change an existing lock, burn record, schedule or funded airdrop.
| Contract | What the admin can change | Admin handover |
|---|---|---|
| Locker | The fee, with no cap, and the fee collector. | setAdmin, one step, effective immediately. |
| Burner | The fee, with no cap, and the fee collector. | setAdmin, one step, effective immediately. |
| Vesting | The fee, up to 0.05 ETH, and the fee collector. | transferAdmin, then acceptAdmin by the new admin. |
| Airdrop | The two fee parameters, each up to 0.25 ETH, and the fee collector. A single airdrop never pays more than 0.25 ETH. | transferAdmin, then acceptAdmin by the new admin. |
| Liquidity Locker | The collect fee and fixed fee for new locks, within limits written into the contract; where HoodLock's share is sent; pausing new locks; the reviewed-hook list. An existing lock keeps the rate it was created with. | transferAdmin, then acceptAdmin by the new admin. |
- The locker and burner forward each fee to the collector in the same transaction. Vesting and airdrops keep fees in the contract until the collector pulls them with
withdrawFees(). - With a two-step admin transfer the new admin has to accept the role, so a mistyped address cannot strand it. The locker and burner do not have this protection.
The $LOCK buyback and burn uses separate contracts, which are not covered on this page.
How to confirm any of this yourself is covered in the security model, and reading a contract on the explorer is covered in this walkthrough.