Token locker
ERC-20 and V2 LP locks, plus batch vesting schedules, in ArmoryTokenLocker.
On this page
ArmoryTokenLocker is two products in one contract. Locks and vestings share no
storage, no ids, and no access rules.
The contract is ReentrancyGuard. createLock, createLockFor, withdraw,
createVestings and claim are all nonReentrant. extendLock is not,
because it moves no tokens.
Storage and structs
struct Lock {
address token;
address owner; // may extend the lock
address unlocker; // sole withdrawer once unlocked
uint256 amount;
uint64 unlockTime;
bool withdrawn;
}
struct Vesting {
address token;
address creator;
address beneficiary;
uint256 total;
uint256 released;
uint64 start;
uint64 duration; // seconds
uint32 interval; // seconds per release step; 0 = continuous (linear)
}
uint256 public lockCount;
uint256 public vestingCount;
mapping(uint256 lockId => Lock) public locks;
mapping(uint256 vestingId => Vesting) public vestings;Ids are 1-based and monotonic. lockId comes from ++lockCount, so id 0 is
never a lock and lockCount is both the last id issued and the total ever
created. The same holds for vestingCount.
Creating a lock
function createLock(address token, uint256 amount, uint64 unlockTime, address unlocker)
external returns (uint256 lockId);
function createLockFor(address owner, address token, uint256 amount, uint64 unlockTime, address unlocker)
external returns (uint256 lockId);Both pull amount of token from msg.sender. The difference is who the
lock belongs to: createLock uses msg.sender, createLockFor uses the owner
argument. createLockFor is permissionless. The lock router uses it so that
router-created locks belong to the end user, but any contract can use it to lock
on behalf of its own users.
Resolution and validation, in order:
createLockForonly:owner == address(0)revertsZeroAddress().amount == 0revertsZeroAmount().unlockTime <= block.timestamprevertsInvalidUnlockTime().unlocker == address(0)resolves toowner, which ismsg.senderforcreateLock. There is no way to create a lock with no unlocker.- Tokens are pulled and the received amount is measured.
Fee-on-transfer
_pull measures the balance delta rather than trusting amount:
function _pull(address token, uint256 amount) private returns (uint256 received) {
IERC20 erc20 = IERC20(token);
uint256 balanceBefore = erc20.balanceOf(address(this));
erc20.safeTransferFrom(msg.sender, address(this), amount);
received = erc20.balanceOf(address(this)) - balanceBefore;
if (received == 0) revert ZeroAmount();
}The stored Lock.amount is what arrived, not what you asked for, and
LockCreated.amount carries the same figure. A fee-on-transfer token locks
fine. Read the event or the record for the real number.
_pull measures the contract's whole balance
The delta is over balanceOf(address(this)), which holds every lock and every
unclaimed vesting for every token. If a token rebases or is airdropped into the
locker mid-transaction, the measurement is wrong. In practice the only way to
hit this is a token that transfers to the locker inside its own transferFrom.
Extending and withdrawing
function extendLock(uint256 lockId, uint64 newUnlockTime) external;
function withdraw(uint256 lockId) external;extendLock reverts UnknownId() if lock.owner == address(0), NotOwner()
if the caller is not the owner, AlreadyWithdrawn() if it is closed, and
InvalidUnlockTime() unless newUnlockTime is strictly greater than both
the current unlockTime and block.timestamp. An expired lock that has not
been withdrawn can be re-locked this way.
withdraw reverts UnknownId(), NotUnlocker(), AlreadyWithdrawn(), or
StillLocked(). It sets withdrawn = true before transferring, and it sends
the tokens to lock.unlocker, not to the caller as such and not to the owner. A
partial withdrawal is not possible: the whole amount moves.
Vesting
function createVestings(
address token,
address[] calldata recipients,
uint256[] calldata amounts,
uint64 start,
uint64 duration,
uint32 interval
) external returns (uint256 firstVestingId);One pull funds the whole batch. Validation:
recipients.length == 0or a length mismatch revertsLengthMismatch().duration == 0revertsInvalidDuration().interval > durationrevertsInvalidInterval().start != 0 && start < block.timestamprevertsInvalidStart(). Astartof0resolves toblock.timestamp. A start exactly equal to now is legal.- Any zero recipient reverts
ZeroAddress(). Any zero amount revertsZeroAmount(). - The pull must be exact:
if (_pull(token, total) != total) revert FeeOnTransferNotSupported();. Unlike locks, vesting cannot absorb a skim, because per-recipient amounts cannot be honestly rescaled after the fact.
The batch occupies ids [firstVestingId, firstVestingId + recipients.length).
firstVestingId is computed as vestingCount + 1 before the loop, so it is
correct even for a batch of one.
The release curve
function _vested(Vesting storage vesting, uint64 timestamp) private view returns (uint256) {
if (timestamp <= vesting.start) return 0;
uint256 elapsed = timestamp - vesting.start;
if (elapsed >= vesting.duration) return vesting.total;
if (vesting.interval == 0) return (vesting.total * elapsed) / vesting.duration;
uint256 totalSteps = (uint256(vesting.duration) + vesting.interval - 1) / vesting.interval;
uint256 stepsElapsed = elapsed / vesting.interval;
return (vesting.total * stepsElapsed) / totalSteps;
}Three things an integrator gets wrong here:
- An
intervalof0is linear, not "no vesting". It streams continuously. - There is no cliff parameter. A cliff is expressed as an
interval: the first tranche lands atstart + intervaland nothing is claimable before it. A single-tranche cliff isinterval == duration, which is the largest legal interval. - The stepped branch divides by the step count, not by elapsed seconds.
totalStepsisceil(duration / interval). Deriving the fraction from seconds is off by up to a whole tranche whenever the interval does not divide the duration evenly.
There is no revocation and no creator claw-back. creator is recorded for
indexing only. It grants no rights.
Claiming
function claim(uint256 vestingId) external;Beneficiary only (NotBeneficiary()), an unknown id reverts UnknownId(), and
a claim of zero reverts NothingToClaim(). It always claims everything
available, which is _vested(...) - released. There is no partial-claim
parameter and no claimFor. Tokens go to vesting.beneficiary.
Views for an indexer or UI
function getLock(uint256 lockId) external view returns (Lock memory);
function getVesting(uint256 vestingId) external view returns (Vesting memory);
function locksOf(address owner) external view returns (uint256[] memory);
function vestingsOf(address beneficiary) external view returns (uint256[] memory);
function vestingsCreatedBy(address creator) external view returns (uint256[] memory);
function vestedAmount(uint256 vestingId, uint64 timestamp) external view returns (uint256);
function claimable(uint256 vestingId) external view returns (uint256);getLock and getVesting return the struct in one call. The public mappings
return the same fields as a tuple if you prefer. vestedAmount takes an
arbitrary timestamp, which is what you want for drawing a schedule. claimable
is the vested amount at now, minus released.
locksOf is append-only and includes withdrawn locks. vestingsOf and
vestingsCreatedBy never shrink either. For a "my locks" screen, read the id
array once, then batch getLock over it and filter on withdrawn. See
Integration recipes for that read.
There is no global enumeration by token
Neither locker indexes by asset. To answer "how much of this LP is locked" you
either walk 1..lockCount or read an indexer. The subgraph carries
TokenLock, Vesting, PositionLock and LockFeeCollection entities for
exactly that.
Events
event LockCreated(
uint256 indexed lockId,
address indexed token,
address indexed owner,
address unlocker,
uint256 amount,
uint64 unlockTime
);
event LockExtended(uint256 indexed lockId, uint64 newUnlockTime);
event LockWithdrawn(uint256 indexed lockId, address indexed to, uint256 amount);
event VestingCreated(
uint256 indexed vestingId,
address indexed token,
address indexed beneficiary,
address creator,
uint256 total,
uint64 start,
uint64 duration,
uint32 interval
);
event VestingClaimed(uint256 indexed vestingId, address indexed beneficiary, uint256 amount, uint256 totalReleased);LockWithdrawn.to is always lock.unlocker. VestingClaimed.totalReleased is
the post-claim cumulative figure, so you can rebuild released from events
without reading state.
Full error list
ZeroAmount, ZeroAddress, InvalidUnlockTime, InvalidStart,
InvalidDuration, InvalidInterval, LengthMismatch, NotOwner,
NotUnlocker, NotBeneficiary, StillLocked, AlreadyWithdrawn,
NothingToClaim, UnknownId, FeeOnTransferNotSupported. All are
zero-argument custom errors.
Access control
There is none beyond the per-lock fields. ArmoryTokenLocker does not inherit
ApeOwnable, has no owner, no pause, no sweep, and no upgrade path. Nothing the
platform multisig can do reaches a lock or a vesting in this contract.