--- eip: 8463 title: Pool Liquidity Locks description: One interface for contracts that lock pool liquidity, with each lock's pool, amount, unlock time and beneficiary author: Priors contributors (@priors-agents) discussions-to: https://ethereum-magicians.org/t/erc-8463-liquidity-locks/30028 status: Draft type: Standards Track category: ERC created: 2026-10-20 requires: 20, 165, 721 --- ## 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](./eip-20.md) token whose balance is a share of a pool, or one position held as an [ERC-721](./eip-721.md) 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](./eip-8376.md) 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](./eip-165.md), returning `true` for the interface identifier of `ILiquidityLocks` and for `0x01ffc9a7`. ```solidity 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).max` means 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, `amount` MUST 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 and `amount` MUST be its liquidity at that time. - MUST take `pool` and `poolId` from the pool token or from the position manager's record of the position, never from the caller. - MUST revert if `amount` would be zero, if `unlockAt` is not later than the block timestamp, or if `beneficiary` is the zero address. For a position it MUST revert if `positionId` is zero or `feeRecipient` is the zero address. For pool tokens `feeRecipient` MUST be the zero address. - MUST give the lock a `lockId` that is not zero and is never reused, and MUST emit `LiquidityLockCreated` with 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`, `beneficiary` and `feeRecipient` MUST NOT change. Its `amount` MUST NOT change except to zero at release, and its `unlockAt` MUST NOT change except through `extendLiquidityLock`. 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 `unlockAt` is later than the lock's current `unlockAt`. - MUST set the lock's `unlockAt` and emit `LiquidityLockExtended`. An extension to `type(uint64).max` makes 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 `amount` of pool tokens, to its `beneficiary` and to no other account, set its `amount` to zero, and emit `LiquidityLockReleased` with `to` set 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 `feeRecipient` and to no other account, and MUST NOT reduce the position's liquidity. It MUST emit `LiquidityLockFeesCollected` with `to` set to `feeRecipient` and 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 position `start` and returning at most `limit` ids, and an empty list when `start` is 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 the `amount` of the permanent ones as `permanent`, the sum of the `amount` of the timed ones as `timed`, and the earliest `unlockAt` among the timed ones as `earliestUnlock`, or zero when there is none. A timed lock whose `unlockAt` has passed counts until it is released. - The cost of `lockedLiquidity` MUST 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 of `permanent` and `timed` over 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 where `earliestUnlock` is not later than `t`, and at most `MAX`; - `MAX` when 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 (`balanceOf` against `ownerOf`) and `amount` counts different things, but a reader asks the same questions of both. A `positionId` of 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).max` turns 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 `lockedLiquidity` shares its name with the `lockedLiquidity` field of the `ChainInputs` an 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 in `lockSinks`, and today it is supplied. A conforming locker is what a detector would list in `lockSinks`, 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.** `amount` is 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 `lockedLiquidity` must 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.** `permanent` plus `timed` is exactly what the locker holds for the pool, and all of it stays at least until `earliestUnlock`. When `earliestUnlock` is 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](./eip-5058.md), [ERC-6982](./eip-6982.md) and [ERC-7066](./eip-7066.md) (`lock`, `unlock`, `Locked`), with the `TokenLocked` event of [ERC-7432](./eip-7432.md) and [ERC-7573](./eip-7573.md), which a proposed extension of ERC-6982 also uses as an error, or with the `unlock` of 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](./eip-7444.md) 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, and `lockedLiquidity` of 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. `getLiquidityLock` reverts for id 0 and for an id never issued. - At any time before `unlockAt` and from any account, release reverts, and no call moves or reduces the liquidity. - Release one second before `unlockAt` reverts. At `unlockAt`, a third party's release sends the tokens to the beneficiary, emits `LiquidityLockReleased`, 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).max` moves 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 `unlockAt` and `amount` is unchanged, and `amount` changed 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, `lockedLiquidity` returns 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, `lockedLiquidity` of 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 and `type(uint32).max - 1`; a permanent lock of 100 and a lock of 20 ending 30 days after `t` give 120 and 2,592,000; positions ending 60 and 90 days after `t` give their sum and 5,184,000; a lock ending 200 years after `t` gives `type(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: 1. 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 least `amount`; for pool tokens, the token's `balanceOf` the locker is at least the pool's `permanent` plus `timed`; 2. that `position` is a pool token or a position manager the reader recognises for `pool`: a locker that accepts any position manager can be made to record a fake manager's figures under a real pool; 3. 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. - **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 `earliestUnlock` earlier and make a pool look less protected. A reader that needs the time protecting most of the liquidity pages through the pool's locks with `liquidityLocksOfPool`, 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](../LICENSE.md).