EIP.tools

EIP.tools

⚠️ DraftStandards Track: ERC

ERC-8463: Pool Liquidity Locks

One interface for contracts that lock pool liquidity, with each lock's pool, amount, unlock time and beneficiary

Authors
Created2026-10-20
Discussion Linkhttps://ethereum-magicians.org/t/erc-8463-liquidity-locks/30028
Requires

Markdown

https://raw.githubusercontent.com/ethereum/ERCs/re...
Pull Request#2107PR open

EIP-GPT summary

Contents
AbstractMotivationSpecificationInterfaceLocksCreating locksHoldingExtendingReleasingFeesReadingDetector inputsRationaleBackwards CompatibilityTest CasesReference ImplementationSecurity ConsiderationsCopyright

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).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, ERC-6982 and ERC-7066 (lock, unlock, Locked), with the TokenLocked event of ERC-7432 and ERC-7573, 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 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 and related rights waived via CC0.

EIP.tools

EIP.tools

Search, read, and map Ethereum improvement proposals, ERCs, RIPs, and CAIPs from one focused interface.

Farcaster
by @apoorveth