256 lines
13 KiB
Markdown
256 lines
13 KiB
Markdown
---
|
||
eip: 5753
|
||
title: Lockable Extension for EIP-721
|
||
description: Interface for disabling token transfers (locking) and re-enabling them (unlocking).
|
||
author: Filipp Makarov (@filmakarov)
|
||
discussions-to: https://ethereum-magicians.org/t/lockable-nfts-extension/8800
|
||
status: Draft
|
||
type: Standards Track
|
||
category: ERC
|
||
created: 2022-10-05
|
||
requires: 165, 721
|
||
---
|
||
|
||
## Abstract
|
||
|
||
This standard is an extension of [EIP-721](./eip-721.md). It introduces lockable NFTs. The locked asset can be used in any way except by selling and/or transferring it. The owner or operator can lock the token. When a token is locked, the unlocker address (an EOA or a contract) is set. Only the unlocker is able to `unlock` the token.
|
||
|
||
## Motivation
|
||
|
||
With NFTs, digital objects become digital goods, which are verifiably ownable, easily tradable, and immutably stored on the blockchain. That's why it's very important to continuously improve UX for non-fungible tokens, not just inherit it from one of the fungible tokens.
|
||
|
||
In DeFi there is an UX pattern when you lock your tokens on a service smart contract. For example, if you want to borrow some $DAI, you have to provide some $ETH as collateral for a loan. During the loan period this $ETH is being locked into the lending service contract. Such a pattern works for $ETH and other fungible tokens.
|
||
|
||
However, it should be different for NFTs because NFTs have plenty of use cases that require the NFT to stay in the holder's wallet even when it is used as collateral for a loan. You may want to keep using your NFT as a verified PFP on Twitter, or use it to authorize a Discord server through collab.land. You may want to use your NFT in a P2E game. And you should be able to do all of this even during the lending period, just like you are able to live in your house even if it is mortgaged.
|
||
|
||
The following use cases are enabled for lockable NFTs:
|
||
|
||
- **NFT-collateralised loans** Use your NFT as collateral for a loan without locking it on the lending protocol contract. Lock it on your wallet instead and continue enjoying all the utility of your NFT.
|
||
- **No collateral rentals of NFTs** Borrow NFT for a fee, without a need for huge collateral. You can use NFT, but not transfer it, so the lender is safe. The borrowing service contract automatically transfers NFT back to the lender as soon as the borrowing period expires.
|
||
- **Primary sales** Mint NFT for only the part of the price and pay the rest when you are satisfied with how the collection evolves.
|
||
- **Secondary sales** Buy and sell your NFT by installments. Buyer gets locked NFT and immediately starts using it. At the same time he/she is not able to sell the NFT until all the installments are paid. If full payment is not received, NFT goes back to the seller together with a fee.
|
||
- **S is for Safety** Use your exclusive blue chip NFTs safely and conveniently. The most convenient way to use NFT is together with MetaMask. However, MetaMask is vulnerable to various bugs and attacks. With `Lockable` extension you can lock your NFT and declare your safe cold wallet as an unlocker. Thus, you can still keep your NFT on MetaMask and use it conveniently. Even if a hacker gets access to your MetaMask, they won’t be able to transfer your NFT without access to the cold wallet. That’s what makes `Lockable` NFTs safe.
|
||
- **Metaverse ready** Locking NFT tickets can be useful during huge Metaverse events. That will prevent users, who already logged in with an NFT, from selling it or transferring it to another user. Thus we avoid double usage of one ticket.
|
||
- **Non-custodial staking** There are different approaches to non-custodial staking proposed by communities like CyberKongz, Moonbirds and other. Approach suggested in this impementation supposes that the token can only be staked in one place, not several palces at a time (it is like you can not deposit money in two bank accounts simultaneously). Also it doesn't require any additional code and is available with just locking feature.
|
||
Another approach to the same concept is using locking to provide proof of HODL. You can lock your NFTs from selling as a manifestation of loyalty to the community and start earning rewards for that. It is better version of the rewards mechanism, that was originally introduced by The Hashmasks and their $NCT token.
|
||
- **Safe and convenient co-ownership and co-usage** Extension of safe co-ownership and co-usage. For example, you want to purchase an expensive NFT asset together with friends, but it is not handy to use it with multisig, so you can safely rotate and use it between wallets. The NFT will be stored on one of the co-owners' wallet and he will be able to use it in any way (except transfers) without requiring multi-approval. Transfers will require multi-approval.
|
||
|
||
|
||
## Specification
|
||
|
||
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
|
||
|
||
EIP-721 compliant contracts MAY implement this EIP to provide standard methods of locking and unlocking the token at its current owner address.
|
||
If the token is locked, the `getLocked` function MUST return an address that is able to unlock the token.
|
||
For tokens that are not locked, the `getLocked` function MUST return `address(0)`.
|
||
The user MAY permanently lock the token by calling `lock(address(1), tokenId)`.
|
||
|
||
When the token is locked, all the [EIP-721](./eip-721.md) transfer functions MUST revert, except if the transaction has been initiated by an unlocker.
|
||
When the token is locked, the [EIP-721](./eip-721.md) `approve` method MUST revert for this token.
|
||
When the token is locked, the [EIP-721](./eip-721.md) `getApproved` method SHOULD return `unlocker` address for this token so the unlocker is able to transfer this token.
|
||
When the token is locked, the `lock` method MUST revert for this token, even when it is called with the same `unlocker` as argument.
|
||
When the locked token is transferred by an unlocker, the token MUST be unlocked after the transfer.
|
||
|
||
Marketplaces should call `getLocked` method of an EIP-721 Lockable token contract to learn whether a token with a specified tokenId is locked or not. Locked tokens SHOULD NOT be available for listings. Locked tokens can not be sold. Thus, marketplaces SHOULD hide the listing for the tokens that has been locked, because such orders can not be fulfilled.
|
||
|
||
### Contract Interface
|
||
|
||
```solidity
|
||
pragma solidity >=0.8.0;
|
||
|
||
/// @dev Interface for the Lockable extension
|
||
|
||
interface ILockable {
|
||
|
||
/**
|
||
* @dev Emitted when `id` token is locked, and `unlocker` is stated as unlocking wallet.
|
||
*/
|
||
event Lock (address indexed unlocker, uint256 indexed id);
|
||
|
||
/**
|
||
* @dev Emitted when `id` token is unlocked.
|
||
*/
|
||
event Unlock (uint256 indexed id);
|
||
|
||
/**
|
||
* @dev Locks the `id` token and gives the `unlocker` address permission to unlock.
|
||
*/
|
||
function lock(address unlocker, uint256 id) external;
|
||
|
||
/**
|
||
* @dev Unlocks the `id` token.
|
||
*/
|
||
function unlock(uint256 id) external;
|
||
|
||
/**
|
||
* @dev Returns the wallet, that is stated as unlocking wallet for the `tokenId` token.
|
||
* If address(0) returned, that means token is not locked. Any other result means token is locked.
|
||
*/
|
||
function getLocked(uint256 tokenId) external view returns (address);
|
||
|
||
}
|
||
```
|
||
|
||
The `supportsInterface` method MUST return `true` when called with `0x72b68110`.
|
||
|
||
## Rationale
|
||
|
||
This approach proposes a solution that is designed to be as minimal as possible. It only allows to lock the item (stating who will be able to unlock it) and unlock it when needed if a user has permission to do it.
|
||
|
||
At the same time, it is a generalized implementation. It allows for a lot of extensibility and any of the potential use cases (or all of them), mentioned in the Motivation section.
|
||
|
||
When there is a need to grant temporary and/or redeemable rights for the token (rentals, purchase with instalments) this EIP involves the real transfer of the token to the temporary user's wallet, not just assigning a role.
|
||
This choice was made to increase compatibility with all the existing NFT eco-system tools and dApps, such as Collab.land. Otherwise, it would require from all of such dApps implementing additional interfaces and logic.
|
||
|
||
Naming and reference implementation for the functions and storage entities mimics that of Approval flow for [EIP-721] in order to be intuitive.
|
||
|
||
## Backwards Compatibility
|
||
|
||
This standard is compatible with current [EIP-721](./eip-721.md) standards.
|
||
|
||
## Reference Implementation
|
||
|
||
```solidity
|
||
// SPDX-License-Identifier: CC0-1.0
|
||
pragma solidity >=0.8.0;
|
||
|
||
import '../ILockable.sol';
|
||
import '@openzeppelin/contracts/token/ERC721/ERC721.sol';
|
||
|
||
/// @title Lockable Extension for ERC721
|
||
|
||
abstract contract ERC721Lockable is ERC721, ILockable {
|
||
|
||
/*///////////////////////////////////////////////////////////////
|
||
LOCKABLE EXTENSION STORAGE
|
||
//////////////////////////////////////////////////////////////*/
|
||
|
||
mapping(uint256 => address) internal unlockers;
|
||
|
||
/*///////////////////////////////////////////////////////////////
|
||
LOCKABLE LOGIC
|
||
//////////////////////////////////////////////////////////////*/
|
||
|
||
/**
|
||
* @dev Public function to lock the token. Verifies if the msg.sender is the owner
|
||
* or approved party.
|
||
*/
|
||
|
||
function lock(address unlocker, uint256 id) public virtual {
|
||
address tokenOwner = ownerOf(id);
|
||
require(msg.sender == tokenOwner || isApprovedForAll(tokenOwner, msg.sender)
|
||
, "NOT_AUTHORIZED");
|
||
require(unlockers[id] == address(0), "ALREADY_LOCKED");
|
||
unlockers[id] = unlocker;
|
||
_approve(unlocker, id);
|
||
}
|
||
|
||
/**
|
||
* @dev Public function to unlock the token. Only the unlocker (stated at the time of locking) can unlock
|
||
*/
|
||
function unlock(uint256 id) public virtual {
|
||
require(msg.sender == unlockers[id], "NOT_UNLOCKER");
|
||
unlockers[id] = address(0);
|
||
}
|
||
|
||
/**
|
||
* @dev Returns the unlocker for the tokenId
|
||
* address(0) means token is not locked
|
||
* reverts if token does not exist
|
||
*/
|
||
function getLocked(uint256 tokenId) public virtual view returns (address) {
|
||
require(_exists(tokenId), "Lockable: locking query for nonexistent token");
|
||
return unlockers[tokenId];
|
||
}
|
||
|
||
/**
|
||
* @dev Locks the token
|
||
*/
|
||
function _lock(address unlocker, uint256 id) internal virtual {
|
||
unlockers[id] = unlocker;
|
||
}
|
||
|
||
/**
|
||
* @dev Unlocks the token
|
||
*/
|
||
function _unlock(uint256 id) internal virtual {
|
||
unlockers[id] = address(0);
|
||
}
|
||
|
||
/*///////////////////////////////////////////////////////////////
|
||
OVERRIDES
|
||
//////////////////////////////////////////////////////////////*/
|
||
|
||
function approve(address to, uint256 tokenId) public virtual override {
|
||
require (getLocked(tokenId) == address(0), "Can not approve locked token");
|
||
super.approve(to, tokenId);
|
||
}
|
||
|
||
function _beforeTokenTransfer(
|
||
address from,
|
||
address to,
|
||
uint256 tokenId
|
||
) internal virtual override {
|
||
// if it is a Transfer or Burn
|
||
if (from != address(0)) {
|
||
// token should not be locked or msg.sender should be unlocker to do that
|
||
require(getLocked(tokenId) == address(0) || msg.sender == getLocked(tokenId), "LOCKED");
|
||
}
|
||
}
|
||
|
||
function _afterTokenTransfer(
|
||
address from,
|
||
address to,
|
||
uint256 tokenId
|
||
) internal virtual override {
|
||
// if it is a Transfer or Burn, we always deal with one token, that is startTokenId
|
||
if (from != address(0)) {
|
||
// clear locks
|
||
delete unlockers[tokenId];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @dev Optional override, if to clear approvals while the tken is locked
|
||
*/
|
||
function getApproved(uint256 tokenId) public view virtual override returns (address) {
|
||
if (getLocked(tokenId) != address(0)) {
|
||
return address(0);
|
||
}
|
||
return super.getApproved(tokenId);
|
||
}
|
||
|
||
/*///////////////////////////////////////////////////////////////
|
||
ERC165 LOGIC
|
||
//////////////////////////////////////////////////////////////*/
|
||
|
||
function supportsInterface(bytes4 interfaceId)
|
||
public
|
||
view
|
||
virtual
|
||
override
|
||
returns (bool)
|
||
{
|
||
return
|
||
interfaceId == type(IERC721Lockable).interfaceId ||
|
||
super.supportsInterface(interfaceId);
|
||
}
|
||
|
||
}
|
||
```
|
||
|
||
## Security Considerations
|
||
|
||
There are no security considerations related directly to the implementation of this standard for the contract that manages [EIP-721](./eip-721.md) tokens.
|
||
|
||
### Considerations for the contracts that work with lockable tokens
|
||
|
||
- Make sure that every contract that is stated as `unlocker` can actually unlock the token in all cases.
|
||
- There are use cases, that involve transferring the token to a temporary owner and then lock it. For example, NFT rentals. Smart contracts that manage such services should always use `transferFrom` instead of `safeTransferFrom` to avoid re-entrancies.
|
||
- There are no MEV considerations regarding lockable tokens as only authorized parties are allowed to lock and unlock.
|
||
|
||
|
||
## Copyright
|
||
|
||
Copyright and related rights waived via [CC0](../LICENSE.md)
|