ERC-6105 Intermediary-Free NFT Trading Protocol (No Intermediary NFT Trading Protocol)
Final · NFTs & multi-token · created 2022-12-02
A standard that builds listing and purchase functions into the NFT contract itself, so ERC-721 tokens can be bought and sold without an intermediary marketplace. Creators can implement a wider variety of royalty schemes.
Why it is needed
Most NFT trading today goes through intermediary platforms. To trade, you must use the setApprovalForAll function to give the platform authority over all of your NFTs. If the platform contract has a problem, you can suffer large losses, and phishing scams can abuse this authority to trick you into signing orders at low prices. As platforms multiply, liquidity fragments and you must repeat approvals and orders across several of them, which raises gas costs and risk, and platform fees are also a burden. Because order data lives off-chain, the efficiency of aggregators (services that collect and display listings from multiple platforms) depends on platform APIs. Whether royalties are enforced also depends on a platform's centralized decision. Some platforms made royalties optional without the consent of the projects, and delisting criteria are opaque, which leaves them open to censorship.
What to implement
function delistItem(uint256 tokenId) external;: Removes a token's listing. Only the owner or an approved address can call it.function buyItem(uint256 tokenId, uint256 salePrice, address supportedToken) external payable;: Buys a listed token and transfers it to the caller. The price and payment token passed in must match the listing to prevent front-running.function getListing(uint256 tokenId) external view returns (uint256, uint64, address, uint256);: Returns a token's sale information (sale price, expiry time, payment token, benchmark price). If the price or the expiry is 0, the token is not for sale.event UpdateCollectionOffer(address indexed from, uint256 amount, uint256 salePrice ,uint64 expires, address supportedToken);: An event emitted when a collection-wide purchase offer is created or canceled.function makeCollectionOffer(uint256 amount, uint256 salePrice, uint64 expires, address supportedToken) external;: Creates or updates an offer to buy amount of any tokens in the collection at salePrice each. The caller must hold enough tokens and have approved them in advance.function acceptCollectionOffer(uint256 tokenId, uint256 salePrice, address supportedToken, address buyer) external;: A token owner or approved address accepts a collection offer and transfers a token to the buyer. When it is filled, the remaining amount decreases by 1.function cancelCollectionOffer() external;: Cancels a collection offer you made. Only the account that made the offer can call it.function getCollectionOffer(address buyer) external view returns (uint256, uint256, uint64, address);: Returns a collection offer made by a given address (quantity, price per token, expiry time, payment token).function makeItemOffer(uint256 tokenId, uint256 salePrice, uint64 expires, address supportedToken) external;: Creates or updates an offer to buy a specific token at salePrice. The caller must hold enough tokens and have approved them in advance.function cancelItemOffer(uint256 tokenId) external;: Cancels an offer made on a specific token. Only the account that made the offer can call it.function acceptItemOffer(uint256 tokenId, uint256 salePrice, address supportedToken, address buyer) external;: A token owner or approved address accepts an item offer and transfers the token to the buyer. When it is filled, the offer data is deleted.function getItemOffer(uint256 tokenId, address buyer) external view returns (uint256, uint64, address);: Returns an offer made by a given address on a given token (price, expiry time, payment token).
In depth
Base interface: list, cancel, buy, query
listItem creates or updates a listing. The tokenId must exist, the caller must be the owner, an approved operator or an approved address, salePrice cannot be 0, and expires must be valid. It must emit an UpdateListing event. delistItem cancels the sale of a listed token and also emits an UpdateListing event. buyItem is the function through which the caller pays and receives the token. The salePrice and supportedToken passed in must match the listed values, and it emits a Purchased event when the trade settles. getListing returns (sale price, expiry time, payment token, benchmark price). If the price or the expiry is 0, the token is not for sale, and a payment token address of 0 means payment in ETH.
Design rationale for salePrice, expires and supportedToken
There are three reasons salePrice cannot be 0. Listing at a price of 0 is mostly an operating mistake and easily leads to loss of assets. Listing costs gas, so a price of 0 makes the proceeds negative, which makes no economic sense. Using a price of 0 as the marker for not for sale also simplifies implementations. With expires, a listing expires automatically, so the owner does not need to call delistItem separately, which saves gas. With supportedToken, the seller or the contract owner can choose the payment token to receive and is not tied to a single token. The zero address means ETH. The design rationale for the parameters of acceptCollectionOffer and acceptItemOffer is the same.
benchmarkPrice and varied royalty schemes
Adding a benchmarkPrice parameter to listItem, acceptCollectionOffer and acceptItemOffer lets implementations replace the _salePrice passed to ERC-2981's royaltyInfo(uint256 _tokenId, uint256 _salePrice) with a taxablePrice, which allows a variety of royalty schemes. The original text gives four examples. Value-added royalty (VAR) uses taxablePrice=max(salePrice-historicalPrice, 0) and charges royalties only on the seller's profit. Sales royalty (SR) uses taxablePrice=salePrice, capped royalty (CR) uses taxablePrice=min(salePrice, constant), and quota royalty (QR) uses taxablePrice=constant, which charges a fixed royalty on every trade.
Optional extensions: collection offers and item offers
Both extensions are optional. In the collection offer extension, a prospective buyer uses makeCollectionOffer to propose buying up to amount of any tokens in the collection at salePrice each. The caller must hold enough of the payment token and have approved enough for the contract. When a token owner or approved address accepts with acceptCollectionOffer, a Purchased event is emitted and the desired quantity amount decreases by 1. In the item offer extension, a proposal is placed on a specific tokenId with makeItemOffer and accepted with acceptItemOffer, and when it is filled the offer data is deleted. In both extensions, only the account that made the offer can call the cancel function, and the accept functions have an overload that takes benchmarkPrice.
How the reference implementation handles it
The reference implementation uses the value-added royalty (VAR) scheme. The Listing struct stores historicalPrice, the price at which the seller last bought this token, and _calculateRoyalties passes the difference to royaltyInfo only when salePrice is greater than historicalPrice. buyItem is protected by nonReentrant. For ETH payment, msg.value must equal salePrice. For ERC-20 payment, it checks that the allowance is at least salePrice, then uses transferFrom to split the payment between the royalty recipient and the seller, and transfers the token. In addition, _beforeTokenTransfer automatically deletes the listing when a listed token is transferred, which prevents a stale listing from remaining after the owner changes.
Blocklists are outside the standard
Some argue that tokens should be blocked from trading on intermediary markets that do not honor royalties, but this standard only provides intermediary-free trading and does not define a standard interface for blocking such markets. If the project team and community decide protection is needed, they can consider adding a blocklist to the implementing contract to block trades on platforms that do not follow the royalty policy. This standard has been finalized with Final status, is compatible with ERC-721 and ERC-2981, and assumes ERC-20, ERC-165, ERC-721 and ERC-2981.
Things to watch out for
- Front-running risk: buyItem, acceptCollectionOffer and acceptItemOffer carry a front-running risk. You must verify that the salePrice and supportedToken passed in the call match the expected price and token to prevent the attack.
- Reentrancy risk: acceptCollectionOffer and acceptItemOffer carry a reentrancy risk. Follow the checks-effects-interactions pattern or use a reentrancy guard.
- ERC-20 approval amounts: To buy an NFT with an ERC-20 token, you must first call that token's approve(address spender, uint256 amount) to give the NFT contract permission to use it. Approve only an appropriate amount, and be careful with unaudited contracts.