Abstract
This proposal defines an interface for a contract that holds pool liquidity under locks: a locker. A lock is either a quantity of fungible pool tokens, an ERC-20 token whose balance is a share of a pool, or one position held as an ERC-721 token of a position manager. Each lock names its pool, the position, the liquidity locked, the time it unlocks or that it never does, the account that receives the liquidity at release, and the account paid the position's fees meanwhile. A locker lists its locks by pool and sums them per pool into permanent and timed liquidity with the earliest unlock time, so scanners, launchpads and lenders read any conforming locker with one integration. Which lockers to count stays the reader's choice; the interface makes that choice checkable against what the locker holds.
Motivation
The safety story of a new token usually starts with "the liquidity is locked", and every locker tells it differently:
its own functions, its own events, its own ids. Scanners report locks only for the lockers they have integrated one by
one, and report nothing for the rest. ERC-8376 scores the share of a pool's liquidity held in locks
(lpLockedShare) and the time left on the shortest lock (lpLockRemaining). It takes the lock addresses as an input
(lockSinks), and for pools whose positions are non-fungible, the locked and total liquidity reach its detector as
supplied figures, under the detector's bond. A common lock interface lets those figures be read from the lockers instead.
The same facts matter beyond launches. A lender that finances a pool position, or takes one as collateral, needs to know which liquidity cannot leave before its loan is due, and a launchpad that declares a launch's lock needs a stable handle to point at. Most launch liquidity is locked until a date rather than forever, so permanent and timed locks have to be read the same way, and pool tokens and position NFTs have to be read through one call.
Specification
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Interface
A locker MUST implement ILiquidityLocks and ERC-165, returning true for the interface identifier
of ILiquidityLocks and for 0x01ffc9a7.
interface ILiquidityLocks {
struct LiquidityLock {
address pool;
bytes32 poolId;
address position;
uint256 positionId;
uint256 amount;
uint64 unlockAt;
address beneficiary;
address feeRecipient;
}
event LiquidityLockCreated(uint256 indexed lockId, address indexed pool, bytes32 indexed poolId, address position, uint256 positionId, uint256 amount, uint64 unlockAt, address beneficiary, address feeRecipient);
event LiquidityLockExtended(uint256 indexed lockId, uint64 unlockAt);
event LiquidityLockReleased(uint256 indexed lockId, address to);
event LiquidityLockFeesCollected(uint256 indexed lockId, address to, uint256 amount0, uint256 amount1);
function getLiquidityLock(uint256 lockId) external view returns (LiquidityLock memory);
function liquidityLocksOfPool(address pool, bytes32 poolId, uint256 start, uint256 limit) external view returns (uint256[] memory);
function lockedLiquidity(address pool, bytes32 poolId) external view returns (uint256 permanent, uint256 timed, uint64 earliestUnlock);
function extendLiquidityLock(uint256 lockId, uint64 unlockAt) external;
function releaseLiquidityLock(uint256 lockId) external;
function collectLiquidityLockFees(uint256 lockId) external returns (uint256 amount0, uint256 amount1);
}Locks
A lock is liquidity of one pool that the locker holds until a time. It is either a quantity of pool tokens (such as those of a Uniswap v2 pair) or one position NFT (such as those of the Uniswap v3 and v4 position managers). Its fields:
pool: the pool. For pool tokens, the pool they are a share of; for a v2-style pair, the pair, which is also its pool token. For a position, the pool contract, or the singleton pool manager for a v4-style position.poolId: the pool's identifier within a singleton pool manager; zero otherwise.position: the pool token, or the position manager.positionId: the position's token id; zero for pool tokens.amount: the pool tokens the locker holds for the lock, in base units, or the position's liquidity in the pool's own unit; zero once the lock is released.unlockAt: the earliest block timestamp at which the liquidity may leave the locker.type(uint64).maxmeans never: the lock is permanent. Any other value makes it timed.beneficiary: the account the liquidity goes to when the lock is released.feeRecipient: the account a position's fees are paid to while it is locked; the zero address for pool tokens, whose fees stay in the tokens and reach the beneficiary at release.
A lock is released once its liquidity has left the locker through releaseLiquidityLock.
Creating locks
How a lock is created, and which pools, pool tokens and position managers a locker accepts, is left to the locker. When it creates a lock, a locker:
- MUST hold the lock's liquidity: for pool tokens,
amountMUST NOT exceed the increase of the locker's balance of the token when it took them; for a position, the locker MUST own the position andamountMUST be its liquidity at that time. - MUST take
poolandpoolIdfrom the pool token or from the position manager's record of the position, never from the caller. - MUST revert if
amountwould be zero, ifunlockAtis not later than the block timestamp, or ifbeneficiaryis the zero address. For a position it MUST revert ifpositionIdis zero orfeeRecipientis the zero address. For pool tokensfeeRecipientMUST be the zero address. - MUST give the lock a
lockIdthat is not zero and is never reused, and MUST emitLiquidityLockCreatedwith the lock's fields.
Holding
- A locker MUST NOT transfer a lock's pool tokens or position, reduce the position's liquidity, or approve any other
account to do either, except to release the lock through
releaseLiquidityLock. - A lock's
pool,poolId,position,positionId,beneficiaryandfeeRecipientMUST NOT change. ItsamountMUST NOT change except to zero at release, and itsunlockAtMUST NOT change except throughextendLiquidityLock.
Some position managers let anyone add liquidity to a position. A locked position can then hold more liquidity than its
amount, which stays the figure locked.
Extending
extendLiquidityLock(lockId, unlockAt):
- MUST revert unless called by the lock's beneficiary.
- MUST revert if the lock does not exist or was released, and unless
unlockAtis later than the lock's currentunlockAt. - MUST set the lock's
unlockAtand emitLiquidityLockExtended. An extension totype(uint64).maxmakes the lock permanent.
A lock whose unlockAt has passed and that is not yet released can be extended.
Releasing
releaseLiquidityLock(lockId):
- MUST be callable by any account.
- MUST revert if the lock does not exist or was released, or if the block timestamp is earlier than its
unlockAt. It MUST revert for a permanent lock at any time. - MUST transfer the lock's position, or its whole
amountof pool tokens, to itsbeneficiaryand to no other account, set itsamountto zero, and emitLiquidityLockReleasedwithtoset to the beneficiary. - For pool tokens, MUST NOT lower the locker's balance of the token by more than
amount, so that a release never takes what other locks hold, and MUST revert if the token would take more.
The beneficiary receives what the token delivers: less than amount for a pool token that takes a fee on transfer.
Fees
collectLiquidityLockFees(lockId):
- MUST be callable by any account.
- MUST revert for a lock of pool tokens, and if the lock does not exist or was released.
- MUST collect the fees the position has earned and pay them to
feeRecipientand to no other account, and MUST NOT reduce the position's liquidity. It MUST emitLiquidityLockFeesCollectedwithtoset tofeeRecipientand the amounts of the pool's two tokens in the pool's order, and return the same amounts.
Reading
getLiquidityLock(lockId)MUST return the lock as it is now, and MUST revert for an id never issued.liquidityLocksOfPool(pool, poolId, start, limit)MUST return the ids of every lock created for that pool and pool id, released or not, in the order they were created, starting at positionstartand returning at mostlimitids, and an empty list whenstartis at or past the end.lockedLiquidity(pool, poolId)MUST return, over the locks of that pool and pool id not yet released, the sum of theamountof the permanent ones aspermanent, the sum of theamountof the timed ones astimed, and the earliestunlockAtamong the timed ones asearliestUnlock, or zero when there is none. A timed lock whoseunlockAthas passed counts until it is released.- The cost of
lockedLiquidityMUST NOT grow with the number of locks of the pool.
Detector inputs
An ERC-8376 detector takes, in its ChainInputs, the addresses it treats as locks or burns (lockSinks), the pool
liquidity attributed to them where positions are non-fungible (lockedLiquidity), and the seconds left at window end on
the shortest lock securing that liquidity (lpLockRemaining, a uint32 whose type(uint32).max means unavailable).
Conforming lockers are what a detector lists in lockSinks. A reader that supplies these inputs for a pool from the
conforming lockers it lists, reading each one's lockedLiquidity(pool, poolId) at the block that ends the window, with
t that block's timestamp and MAX equal to type(uint32).max - 1, the largest value that is not the sentinel,
SHOULD compute:
lockedLiquidity: the sum ofpermanentandtimedover the lockers.lpLockRemaining:- zero when the lockers hold no liquidity of the pool;
- when at least one locker holds timed liquidity of the pool, the least, over those lockers, of
earliestUnlock - t, or zero whereearliestUnlockis not later thant, and at mostMAX; MAXwhen the lockers hold only permanent liquidity of the pool, which never unlocks.
Rationale
- One record for pool tokens and positions. Pool tokens and a position NFT are held differently (
balanceOfagainstownerOf) andamountcounts different things, but a reader asks the same questions of both. ApositionIdof zero marks pool tokens, which is why a position with token id zero cannot be locked; the Uniswap v3 and v4 position managers number positions from one. - The pool comes from the position. If the caller named the pool, anyone could lock a position of one pool and have it counted for another. Reading the pool from the pool token, or from the position manager's own record, ties each lock to the pool its liquidity is in. Which position managers a locker accepts remains its choice (see Security Considerations).
- Never as a time. A permanent lock is one whose time never comes. One field covers both kinds, an extension to
type(uint64).maxturns a timed lock into a permanent one, and a reader compares one number. - Only the beneficiary extends, and only later. An extension delays the beneficiary's own access, so no one else may impose it, and a lock that could be shortened would protect nothing. A lapsed lock that is still held can be extended: the liquidity is locked again without leaving, and keeps its id.
- Anyone releases, only to the beneficiary. Release has one outcome. Who sends the transaction does not change it,
so anyone may, and a beneficiary that is a contract without a way to call the locker still receives its liquidity. A
beneficiary that wants a lock to go on extends it before
unlockAt. - Fees go on. Locking liquidity should not lock its income: the creator of a launch is often paid from the
position's fees. Anyone may trigger a collection, the fees can only go to
feeRecipient, and a collection cannot reduce liquidity, so it is not a way out for the liquidity. Pool tokens of v2-style pairs earn fees by growing in value, so there is nothing to collect apart from the tokens, and their fees reach the beneficiary at release. The fee recipient and the beneficiary never change; a party that may need to change them names a contract it controls. - Named for the ERC-8376 detector input. The view
lockedLiquidityshares its name with thelockedLiquidityfield of theChainInputsan ERC-8376 detector takes, on purpose. For a pool whose positions are non-fungible, that field is the pool liquidity attributed to the addresses the detector lists inlockSinks, and today it is supplied. A conforming locker is what a detector would list inlockSinks, and it can supply the figure and the time left (see Detector inputs), so anyone can recompute both instead of taking them on the detector's word. Permanent liquidity maps to the largest time that is not the unavailability sentinel, because it is measured, not unavailable, and nothing that ends is longer. - The beneficiary gets what the token delivers.
amountis what the locker holds, and a release sends all of it. A pool token that keeps part of each transfer delivers less, which the locker cannot change; refusing such tokens at creation would not help against a token that starts charging later. What the locker can guarantee is that a release never takes more than its own lock's tokens, so the other locks of the same token stay whole. - Sums per pool, at a bounded cost. Anyone can lock a little liquidity in any pool, so the cost of
lockedLiquiditymust not depend on how many locks a pool has, or a check run inside a lender's transaction could be made to fail. - Timed includes lapsed locks.
permanentplustimedis exactly what the locker holds for the pool, and all of it stays at least untilearliestUnlock. WhenearliestUnlockis in the past, some of the timed liquidity can leave now. Leaving lapsed locks out would need the sum to visit each of them. - The lock id is the handle. Other contracts point at a lock by the locker's address and the lock id: a launch's declaration of its lock, a loan secured by a position. Ids are never reused, and the fields behind an id never change except the amount at release and the time by extension, so such a pointer keeps its meaning.
- Creation is left to lockers. Lockers differ in what they accept (which position managers, which pools, what they charge), and readers need the result, not the call.
- Prefixed names. Every event, and every function that reads or acts on a lock, carries
LiquidityLock; the one exception,lockedLiquidity, keeps the ERC-8376 name for the reason above. The names do not collide with the lockable-token vocabulary of ERC-5058, ERC-6982 and ERC-7066 (lock,unlock,Locked), with theTokenLockedevent of ERC-7432 and ERC-7573, which a proposed extension of ERC-6982 also uses as an error, or with theunlockof the Uniswap v4 pool manager. - Not a lockable token. ERC-5058, ERC-6982 and ERC-7066 mark a token as locked, a proposed extension of ERC-6982 adds permanent and temporary locks of tokens, and ERC-7444 returns a maturity for an opaque id. None names a pool, the liquidity in it, an unlock time and a beneficiary together, so none lets a reader compute the share of a pool that cannot leave, or until when.
Backwards Compatibility
No backward compatibility issues found. An existing locker can expose this interface from a new contract version; the locks it already holds stay readable through its own functions until they are released.
Test Cases
A conforming locker passes these cases. The reference tests use a v2-style pair token and positions of a v3-style position manager.
- A lock of 1,000 pool tokens until 30 days ahead gets id 1 and emits
LiquidityLockCreated.getLiquidityLock(1)names the pair as both pool and position, position id zero, amount 1,000 and no fee recipient, andlockedLiquidityof the pair returns no permanent liquidity, 1,000 timed, and the unlock time. - A lock of a position records the pool that the position manager's factory names for the position's tokens and fee, the position's liquidity, and the locker as its owner, with no approval given to any account. A position of a pool the factory does not name cannot be locked.
- With a pool token that keeps 1% of every transfer, a lock of 1,000 records 990, what arrived.
- A pool token that calls back into the locker while it is being locked cannot open a second lock: the call back reverts, and the one lock records the 100 tokens that arrived.
- A lock with a zero amount, an unlock time not later than now, a zero beneficiary, a position id of zero, a position without a fee recipient, or a position without liquidity reverts.
- Lock ids start at 1, and a lock created after a release gets a new id.
getLiquidityLockreverts for id 0 and for an id never issued. - At any time before
unlockAtand from any account, release reverts, and no call moves or reduces the liquidity. - Release one second before
unlockAtreverts. AtunlockAt, a third party's release sends the tokens to the beneficiary, emitsLiquidityLockReleased, sets the amount to zero and lowers the pool's figures; a second release reverts. A released position goes to the beneficiary. - With a pool token that keeps 1% of every transfer, from the lock to the release, locks of 1,000 and 500 record 990 and 495. Releasing the first takes exactly 990 from the locker, the beneficiary receives 980.1, and the second lock is still held whole.
- With two locks of 1,000 of a pool token that then starts charging its sender 1% on top of what it sends, releasing one reverts and leaves 2,000 in the locker; once the token stops, the release succeeds.
- A permanent lock cannot be released, even at the timestamp
type(uint64).max. - An extension by anyone but the beneficiary, or to the same time or an earlier one, reverts. An extension to a later
time moves the pool's earliest unlock to the next lock, and one to
type(uint64).maxmoves the amount from timed to permanent. A lapsed lock still held can be extended; a released one cannot. - A third party's fee collection pays the position's fees in both tokens to the fee recipient only and leaves the position's liquidity unchanged. A collection on a lock of pool tokens, or on a released lock, reverts.
- After liquidity is added to a locked position, its fees collected and its lock extended and released, every field but
unlockAtandamountis unchanged, andamountchanged only at release. - With one permanent and two timed locks of a pair, one lock of another pair and two positions in a v3-style pool,
lockedLiquidityreturns each pool's own sums and earliest unlock; a lapsed lock counts until it is released. - Pages: with five locks of a pool, one of them released,
liquidityLocksOfPool(pair, 0, 1, 2)returns ids 2 and 3, the full list still holds id 1, and a start of 5 returns an empty list. - After 200 locks of one unit each by another account,
lockedLiquidityof the pool costs less than 15,000 gas. - After random locks, extensions, releases and waits in three pools, each pool's figures equal those computed from its locks one by one, and the locker's balances and positions match its locks not yet released.
- A contract that reports the same figures without holding the tokens or the position fails the reader's holding check.
- Detector inputs from two lockers at a window end
t: permanent locks of 100 and 50 in one pool give 150 andtype(uint32).max - 1; a permanent lock of 100 and a lock of 20 ending 30 days aftertgive 120 and 2,592,000; positions ending 60 and 90 days aftertgive their sum and 5,184,000; a lock ending 200 years aftertgivestype(uint32).max - 1; a pool without locks gives zero and zero; and once the 30-day lock has lapsed, still held, its pool gives 120 and zero.
Reference Implementation
LiquidityLocker accompanies this proposal with its tests. It locks v2-style pool tokens, taking the token as its own
pool and recording the balance that arrived, and positions of one v3-style position manager fixed at deployment, taking
each position's pool from the manager's factory. It keeps per-pool sums and, per pool, a binary heap of timed locks
ordered by unlock time, so lockedLiquidity reads three stored values however many locks a pool has. A release of
pool tokens measures the locker's balance across the transfer, and every function that changes state refuses to be
entered again while it runs, so a pool token that calls back cannot open a lock counted twice. It has no owner, cannot
be upgraded, and moves liquidity only in releaseLiquidityLock. It does not handle v4-style positions.
Security Considerations
- Reader-side checks. Any contract can implement this interface and emit its events, so a lock reported is not a
lock held. Readers choose which lockers they count, and SHOULD check each one before counting it:
- that it holds what it reports: for a position, the position manager's
ownerOf(positionId)is the locker and the position's liquidity is at leastamount; for pool tokens, the token'sbalanceOfthe locker is at least the pool'spermanentplustimed; - that
positionis a pool token or a position manager the reader recognises forpool: a locker that accepts any position manager can be made to record a fake manager's figures under a real pool; - that its code has no path to move liquidity before
unlockAt, from a verified source or an audit the reader trusts: no upgrade, no administrator able to move positions, no approval given to another account, and a fee collection that cannot reduce liquidity.
- that it holds what it reports: for a position, the position manager's
- Small locks pull the earliest unlock forward. Anyone can lock a little liquidity in any pool with a short unlock
time. This cannot raise a pool's figures without real liquidity, but it can move
earliestUnlockearlier and make a pool look less protected. A reader that needs the time protecting most of the liquidity pages through the pool's locks withliquidityLocksOfPool, or reads the lock a launch declares by its id. - Positions can grow. Where anyone can add liquidity to a position, a locked position can hold more than
amount. The figures then understate what is locked, never overstate it. - Pool tokens whose balances change. A pool token that takes a fee on transfer is counted by what arrives, and at
release its beneficiary receives less than
amount: for such a token, a lock's figure is what the locker holds, not what the beneficiary will get. A token that charges its sender more than it sends cannot be released without taking from other locks, so its release reverts until the token stops. A token whose balances can later fall, by rebasing or by an administrator, can leave a locker holding less than its figures; the holding check finds it. - Calls back. A pool token or position manager is an external contract that runs during a lock, a release or a fee collection. A locker SHOULD refuse to be entered again during those calls; one that measures balances across a transfer can otherwise be made to count the same tokens twice.
- Release to a contract. The reference transfers a released position with
transferFrom, without the receiver callback, so a failing callback cannot hold the release up. A beneficiary contract must be able to use what it receives. - The pool itself. Liquidity is only as withdrawable as its pool. A pool whose tokens can block transfers can stop liquidity from leaving at release, or make it worthless meanwhile. A lock says the liquidity cannot leave early, not what it will be worth.
- Timestamps. Unlock times are block timestamps, which block producers can shift by seconds. Locks are measured in days, so this does not matter to them.
Copyright
Copyright and related rights waived via CC0.
