Documentation

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.

ContractAddressApeScan
ArmoryTokenLocker0xD5d7…259F

The contract is ReentrancyGuard. createLock, createLockFor, withdraw, createVestings and claim are all nonReentrant. extendLock is not, because it moves no tokens.

Storage and structs

ArmoryTokenLocker.sol
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:

  1. createLockFor only: owner == address(0) reverts ZeroAddress().
  2. amount == 0 reverts ZeroAmount().
  3. unlockTime <= block.timestamp reverts InvalidUnlockTime().
  4. unlocker == address(0) resolves to owner, which is msg.sender for createLock. There is no way to create a lock with no unlocker.
  5. 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 == 0 or a length mismatch reverts LengthMismatch().
  • duration == 0 reverts InvalidDuration().
  • interval > duration reverts InvalidInterval().
  • start != 0 && start < block.timestamp reverts InvalidStart(). A start of 0 resolves to block.timestamp. A start exactly equal to now is legal.
  • Any zero recipient reverts ZeroAddress(). Any zero amount reverts ZeroAmount().
  • 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 interval of 0 is linear, not "no vesting". It streams continuously.
  • There is no cliff parameter. A cliff is expressed as an interval: the first tranche lands at start + interval and nothing is claimable before it. A single-tranche cliff is interval == duration, which is the largest legal interval.
  • The stepped branch divides by the step count, not by elapsed seconds. totalSteps is ceil(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.