ERC-7743 Multi-owner NFT (MO-NFT) (Multi-Owner Non-Fungible Tokens (MO-NFT))
Final · NFTs & multi-token · created 2024-07-13
A standard that lets multiple people own a single NFT at the same time. Transferring it keeps the existing owners in place, and a set fee is paid to the provider on every transfer.
Why it is needed
The existing NFT standard (ERC-721) uses a sole-ownership model in which a token has exactly one owner. Digital assets such as files, however, do not lose quality when copied and can be held by many people, and the sole-ownership model cannot capture this. Passing ownership strips the original owner of their rights, so a structure in which several people jointly own the same asset cannot be built in a standard way. The standard also has no mechanism for the party who first provided an asset to keep earning rewards as it circulates afterward. This standard supports joint ownership by adding the new owner to a list on transfer instead of removing the existing owner, and it sends a set fee to the provider on every transfer so that creators and providers are rewarded. It also defines an archive mechanism through which an owner voluntarily gives up the right to transfer.
What to implement
function mintToken() public onlyOwner returns (uint256);: Mints a new MO-NFT. The caller is recorded as the first owner and the provider, and the caller's balance increases by 1.function provide(string memory assetName, uint256 size, bytes32 fileHash, address provider, uint256 transferValue) external returns (uint256);: Registers a digital asset token by specifying the asset name, size, file hash, provider address, and transfer fee.function transferFrom(address from, address to, uint256 tokenId) public;: Transfers a token by adding the new owner to the list. Existing owners are kept and the fee is paid to the provider.function safeTransferFrom(address from, address to, uint256 tokenId) public;: The safe transfer function from ERC-721. It is disabled or redefined in MO-NFT.function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public;: A safe transfer function that passes data along. It is likewise disabled or redefined.function isOwner(uint256 tokenId, address account) public view returns (bool);: Checks whether the given address is in the token's owner list.function getOwnersCount(uint256 tokenId) public view returns (uint256);: Returns the number of owners of the token.function balanceOf(address owner) external view returns (uint256 balance);: Returns the number of tokens owned by the given address.function ownerOf(uint256 tokenId) external view returns (address owner);: Returns the first address in the owner list for compatibility.function setTransferValue(uint256 tokenId, uint256 newTransferValue) external;: Sets or changes the transfer fee. Only the provider can call it.function archive(uint256 tokenId) external;: Marks the calling owner as archived for that token. After that the token cannot be transferred by that owner, and this cannot be undone.event TokenMinted(uint256 indexed tokenId, address indexed owner);: Emitted when a new token is minted.event TokenTransferred(uint256 indexed tokenId, address indexed from, address indexed to);: Emitted when a token is transferred and a new owner is added.event TransferValueUpdated(uint256 indexed tokenId, uint256 oldTransferValue, uint256 newTransferValue);: Emitted when the transfer fee changes, carrying the previous and new values.event ArchivedStatusUpdated(uint256 indexed tokenId, address indexed owner, bool archived);: Emitted when an owner's archive status changes.
In depth
Additive ownership model
MO-NFT keeps each token's owner list as an enumerable set, which prevents duplicates and makes lookups efficient. transferFrom does not remove the existing owner. It adds the to address to the set and increases balanceOf of to by 1. The reference implementation uses require to check that the caller is an owner (isOwner), that to is not the zero address, and that to is not already an owner, and a transfer to an address that is already an owner reverts with the error "MO-NFT: Recipient is already an owner". When the transfer completes, a TokenTransferred event is emitted.
Provider role and fee handling
The account that called mintToken() is recorded as the first owner and the provider. Only the provider can set or change the transfer fee with setTransferValue, and a TransferValueUpdated event is emitted when it changes. When a transfer occurs, ether equal to transferValue is paid from the contract to the provider. The reference implementation checks address(this).balance >= transferValue, sends the payment with call, and reverts if it fails. In other words, the contract must hold enough balance to pay the fee for a transfer to succeed.
Archive: irreversibly giving up transfers
An owner can mark themselves as archived for a specific token with archive(tokenId). An archived owner can no longer transfer that token, and once set it cannot be undone. On a digital asset platform, this state means the actual digital asset can be downloaded directly, and an owner who is not archived must first switch to the archived state to download the asset. The Rationale explains that this mechanism replaces the existing notion of burning ownership. An ArchivedStatusUpdated event is emitted when the status changes.
ERC-721 compatibility and intentional differences
MO-NFT implements balanceOf, ownerOf, and transferFrom from ERC-721 and declares the IERC721 and IERC165 interfaces in supportsInterface so that it works with existing tools and platforms. However, approve, getApproved, setApprovalForAll, and isApprovedForAll are intentionally disabled or redefined. Without a joint-ownership consensus mechanism, allowing approvals could let a single co-owner's approval trigger an unwanted transfer, and the reference implementation reverts approval-related calls. safeTransferFrom is also restricted, and the source text describes an implementation approach that, as in ERC-721, adds the new owner and then checks onERC721Received on the receiving contract. Because of these differences, compatibility with existing marketplaces that depend on approve may be limited.
Interpreting ownerOf
For compatibility, ownerOf returns the first owner in the owner list. However, the notion of a single owner does not fully apply to MO-NFT, so the address returned by ownerOf must not be interpreted as the sole owner. Actual ownership is checked with isOwner(tokenId, account), and the number of owners with getOwnersCount(tokenId). The source text states that developers must be aware of these differences when integrating MO-NFT into systems designed for standard ERC-721.
The digital asset registration function provide
Besides mintToken(), the minting function provide(string memory assetName, uint256 size, bytes32 fileHash, address provider, uint256 transferValue) is defined. It takes the asset name, size, file hash, provider address, and transfer fee, and returns a token identifier. The reference implementation includes the base implementation MONFT.sol, DigitalAsset.sol, an extended implementation for digital assets with provider fees, and the IDigitalAsset.sol interface that defines asset management functions.
Things to watch out for
- Reentrancy attack: Reentrancy is possible when the transfer fee is sent to the provider in ether. Follow the Checks-Effects-Interactions pattern, and using OpenZeppelin's ReentrancyGuard is recommended.
- Gas burden (DoS) from iterating the owner list: A function that iterates over the owner list can see its gas cost grow sharply as the list gets larger. Avoid such functions or limit the number of owners.
- Access control: Only owners should be able to transfer, and only the provider should be able to set transferValue, and require statements must enforce this.
- Handling ether receipts: To pay fees to the provider, the contract must be able to receive ether. Implement a receive() function so that it can accept ether.
- Integer overflow and underflow: Solidity 0.8.x automatically checks for overflow and underflow and throws an exception when one occurs.