--- eip: 8451 title: Permanent Locks for Lockable Tokens description: Extends default lockable tokens with permanent locks, multi-token support, and mint and burn rules author: Ryan Ghods (@ryanio) discussions-to: https://ethereum-magicians.org/t/erc-8451-permanent-locks-for-lockable-tokens-erc-6982-extension/30030 status: Draft type: Standards Track category: ERC created: 2026-10-19 requires: 165, 721, 1155, 6982 --- ## Abstract This proposal extends [ERC-6982](./eip-6982.md) so that a lock can be marked permanent, so that the same interface applies to [ERC-1155](./eip-1155.md) contracts as well as [ERC-721](./eip-721.md) contracts, and so that minting and burning are defined while tokens are locked. Contracts keep using ERC-6982's `DefaultLocked` and `Locked` events to lock and unlock tokens. This proposal adds one event, one view function, and one custom error. Marketplaces, wallets, and indexers can then tell a temporary trading pause, such as one during a public mint, from a collection that will never be transferable. ## Motivation Creators often disable secondary transfers for a period: while a public mint is running, before a reveal, or during a migration. Other collections are non-transferable for good, such as credentials, memberships, and achievement badges. In both cases marketplaces need to stop offering listings, offers, and sales that the contract would reject. ERC-6982 already gives contracts a way to lock or unlock every token with one `DefaultLocked` event, and to override single tokens with `Locked` events. It leaves three gaps: 1. A consumer cannot tell a collection-wide lock that will lift from one that never will. A wallet cannot label a token as non-transferable, and a marketplace cannot tell whether to keep orders for when trading resumes. 2. ERC-6982 requires ERC-721, so ERC-1155 contracts have no equivalent signal. 3. ERC-6982 does not say whether minting and burning continue while tokens are locked. Pausing trading during a mint depends on minting continuing. [ERC-5192](./eip-5192.md) covers permanent locks, but only with one event per token, so a 10,000 token collection needs 10,000 logs at deployment. ## 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](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). ### Definitions - A *holder transfer* is a transfer of a token where both the sender (`from`) and the recipient (`to`) are not the zero address. Mints (`from` is the zero address) and burns (`to` is the zero address) are not holder transfers. - A token is *locked* when ERC-6982's `locked(tokenId)` returns `true`. - A contract is *permanently locked* once it has emitted `PermanentlyLocked`. - For an ERC-1155 contract, `tokenId` is the token type ID, and a token ID *exists* while its total supply is greater than zero. ### Interface A compliant contract MUST implement ERC-721 or ERC-1155, MUST implement ERC-6982, and MUST implement the following interface: ```solidity // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.4; /// @dev The ERC-165 identifier for this interface is 0x9051c597. interface IERC8451 /* is IERC165, IERC6982 */ { /// @notice Emitted when every token in the contract becomes permanently locked. event PermanentlyLocked(); /// @notice Thrown when a holder transfer of a locked token is attempted. error TokenLocked(uint256 tokenId); /// @notice Returns true if every token in the contract is permanently locked. function permanentlyLocked() external view returns (bool); } ``` ### Behavior 1. The contract MUST return `true` from `supportsInterface` for both `0x9051c597` and the ERC-6982 identifier `0x6b61a747`, as defined by [ERC-165](./eip-165.md). 2. The requirements of ERC-6982 apply to ERC-1155 contracts that implement this proposal, with `tokenId` read as the token type ID. 3. Locking restricts holder transfers only. A locked status MUST NOT by itself prevent minting. The contract MAY allow burning locked tokens. 4. A holder transfer that reverts because the token is locked SHOULD revert with `TokenLocked(tokenId)`. 5. When the contract emits `PermanentlyLocked`, `defaultLocked()` MUST already return `true`. 6. Before the contract emits `PermanentlyLocked`, it MUST emit `Locked(tokenId, true)` for every existing token ID whose most recent `Locked` event set it to `false`. 7. After the contract emits `PermanentlyLocked`: 1. `defaultLocked()` MUST return `true` for the life of the contract, and `locked(tokenId)` MUST return `true` for every token ID that exists, including token IDs minted later and regardless of any earlier `Locked` event. 2. The contract MUST revert every holder transfer, including transfers made by the holder and by any approved operator. 3. The contract MUST NOT emit `DefaultLocked(false)` or `Locked(tokenId, false)` for any token ID. 8. `permanentlyLocked()` MUST return `false` until the contract emits `PermanentlyLocked`, and `true` from then on. 9. The contract SHOULD NOT revert `approve` or `setApprovalForAll` solely because a token is locked while `permanentlyLocked()` returns `false`. ### Consumers 1. Consumers SHOULD NOT create, display, or fulfill listings, offers, or sales of a locked token. 2. Consumers MAY keep existing orders for a locked token while the contract's `permanentlyLocked()` returns `false`, and show them again when the token is unlocked. 3. Consumers MAY present every token of a permanently locked contract as non-transferable, and other locked tokens as temporarily unavailable for trading. 4. After a contract emits `PermanentlyLocked`, consumers SHOULD treat every token in the contract as non-transferable, including tokens minted later. 5. Consumers MAY treat a holder transfer that reverts with `TokenLocked` as a lock on that token, even when they have not seen a lock event. ## Rationale ### Building on ERC-6982 An earlier draft of this proposal defined its own range event for pausing a whole collection. [ERC-6982](./eip-6982.md) already covers that case with `DefaultLocked`, and per-token exceptions with `Locked`. Reusing it means existing ERC-6982 contracts keep working, and indexers learn one base standard. This proposal adds only what ERC-6982 leaves open. A range event is not included: the default status covers a whole collection, `Locked` covers single tokens, and nothing in the motivating cases needs a partial range. ### A separate permanence event ERC-6982 is Final, so its events cannot gain a field. A separate `PermanentlyLocked` event lets a contract mark permanence without changing how ERC-6982 consumers read lock status. Behavior rules 5 and 6 require the default lock, and a lock on every token that had been unlocked individually, to be in place before the contract is marked permanent. A consumer that only understands ERC-6982 then sees every token as locked, whether it calls `locked` or rebuilds lock status from ERC-6982 events, where a token's latest `Locked` event overrides the default. Permanence goes one way. A contract can move from locked to permanently locked, but never back. A consumer that has seen the event can rely on it without polling. ### Permanence for whole collections only Permanence applies to the whole contract, not to single tokens. The motivating cases lock whole collections: a drop pauses trading for every token, and a credential collection is non-transferable from the start. A per-token permanence event and view would double the interface for a case [ERC-5192](./eip-5192.md) already covers for ERC-721, since a contract can implement ERC-5192 alongside this proposal through the shared `locked(uint256)` function. An ERC-1155 contract that needs one token type to stay non-transferable can keep it locked with ERC-6982's `Locked(tokenId, true)`. The event and view drop ERC-6982's `Default` prefix because permanence is not a default that `Locked` events can override: once it is set, every token is locked. `permanentlyLocked()` is kept as a view so that a consumer can read permanence before any token is minted, and so that an indexer can re-read it after a chain reorganization removes the event. ### A standard revert `TokenLocked(tokenId)` gives every compliant contract the same revert data for a transfer blocked by a lock. A consumer that simulates or submits a fill can recognize the failure and stop offering the order, instead of reporting an unknown revert. ### Strict reverts only for permanent locks ERC-6982 notes that a locked token may still be transferable under some conditions, for example when a designated protector wallet starts the transfer. This proposal keeps that flexibility for temporary locks, since marketplaces only need to know not to offer the token. A permanent lock is a promise to holders that the token is non-transferable, so behavior rule 7 makes that promise binding on the contract. ### Mints and burns Pausing secondary trading during a mint is the main temporary use case, so locking restricts holder transfers only. A permanently locked collection still allows burning, so holders of a non-transferable token can dispose of it if the contract supports burns. ### ERC-1155 ERC-6982's interface uses only a `tokenId`, which maps directly to an ERC-1155 token type ID. Applying the same events and functions to ERC-1155 gives multi-token contracts a lock signal without a parallel standard. ERC-6982 requires `locked` to revert for tokens that do not exist, so ERC-1155 needs a definition of existence. A token ID exists while its total supply is above zero, which matches ERC-721, where a burned token stops existing and `locked` reverts for it. ### No time-based changes ERC-6982 requires `defaultLocked` to reflect the most recent `DefaultLocked` event, so lock status already changes only through events. Behavior rule 8 applies the same rule to permanence. Some pauses have a known end, such as the close of a public mint, but this proposal leaves out an end time field. Pauses often end early or late, and a consumer that trusted a stale end time would offer orders that revert. ### Approvals Blocking `approve` and `setApprovalForAll` during a temporary lock would stop holders from preparing listings and would add no protection, since the transfer itself already reverts or is not offered. ### Relationship to ERC-5192 ERC-5192 and ERC-6982 share the `locked(uint256)` function, so a contract can implement both. Every token of a permanently locked contract is what ERC-5192 calls a soulbound token. A permanently non-transferable collection can emit `DefaultLocked(true)` and `PermanentlyLocked()` once at deployment instead of one ERC-5192 `Locked` event per token. ERC-5192 remains the way to make single tokens soulbound. ## Backwards Compatibility Every contract that implements this proposal is also a valid ERC-6982 contract. Consumers that only understand ERC-6982 see the tokens of a permanently locked contract as locked, whether they call `locked` or replay ERC-6982 events, which is the safe reading. Behavior rule 6 is what makes the event history agree with the view. Contracts deployed before this proposal do not implement it, and consumers continue to handle them as they do today. ## Reference Implementation The following ERC-721 contract locks every token at deployment. The owner can unlock, lock again, or make the lock permanent, after which it can no longer change. It uses the `_update` hook of OpenZeppelin Contracts 5.x, which runs for mints, burns, and transfers. It never unlocks single tokens, so behavior rule 6 needs no extra events before `lockPermanently` emits `PermanentlyLocked`. The `IERC6982` interface is copied from ERC-6982. ```solidity // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.20; import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; interface IERC6982 { event DefaultLocked(bool locked); event Locked(uint256 indexed tokenId, bool locked); function defaultLocked() external view returns (bool); function locked(uint256 tokenId) external view returns (bool); } contract LockableDrop is ERC721, Ownable, IERC6982, IERC8451 { error LockIsPermanent(); bool private _defaultLocked; bool private _permanentlyLocked; constructor() ERC721("Lockable Drop", "DROP") Ownable(msg.sender) { _setDefaultLocked(true); } function mint(address to, uint256 tokenId) external onlyOwner { _mint(to, tokenId); } function setDefaultLocked(bool isLocked) external onlyOwner { _setDefaultLocked(isLocked); } function lockPermanently() external onlyOwner { if (!_defaultLocked) _setDefaultLocked(true); _permanentlyLocked = true; emit PermanentlyLocked(); } function defaultLocked() external view returns (bool) { return _defaultLocked; } function locked(uint256 tokenId) external view returns (bool) { _requireOwned(tokenId); return _defaultLocked; } function permanentlyLocked() external view returns (bool) { return _permanentlyLocked; } function supportsInterface(bytes4 interfaceId) public view override returns (bool) { return interfaceId == type(IERC6982).interfaceId || interfaceId == type(IERC8451).interfaceId || super.supportsInterface(interfaceId); } function _setDefaultLocked(bool isLocked) internal { if (_permanentlyLocked) revert LockIsPermanent(); _defaultLocked = isLocked; emit DefaultLocked(isLocked); } function _update(address to, uint256 tokenId, address auth) internal override returns (address) { address from = _ownerOf(tokenId); if (from != address(0) && to != address(0) && _defaultLocked) { revert TokenLocked(tokenId); } return super._update(to, tokenId, auth); } } ``` An ERC-1155 contract applies the same check to each ID in `_update(from, to, ids, values)`. ## Security Considerations The lock status is reported by the token contract itself. A contract can report a token as unlocked and still revert transfers, or report it as permanently locked and allow them. Consumers should treat the signal as a way to hide orders that will fail, and should keep simulating fulfillments rather than trusting the signal to approve them. A permanent lock is only as permanent as the contract's code. If the contract sits behind an upgradeable proxy, or delegates its transfer checks to logic an administrator can replace, an upgrade can make tokens transferable again without emitting any unlock event. Wallets and marketplaces that label tokens as non-transferable should check whether the contract is upgradeable and who controls the upgrade. Whoever controls the lock controls the secondary market for the affected tokens. A creator can make a lock permanent at any time, which removes the secondary market for good. A creator can also keep a temporary lock in place indefinitely, or lock just before a sale to make outstanding orders unfillable. Holders and buyers should be able to see who can change the lock and whether it can still be lifted. Consumers may want to show that information alongside the token. The lock status can change between the time an order is created and the time it is filled. A buyer's transaction can revert if the creator locks in the same block. Consumers should check the status close to fulfillment. Consumers calling `locked` or `permanentlyLocked` should bound the gas they pass to the call, since the functions run arbitrary contract code, and should handle the revert `locked` raises for tokens that do not exist. ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).