diff --git a/EIPS/eip-5773.md b/EIPS/eip-5773.md new file mode 100644 index 00000000000000..d9a61a0808a988 --- /dev/null +++ b/EIPS/eip-5773.md @@ -0,0 +1,510 @@ +--- +eip: 5773 +title: Context-Dependent Multi-Asset Tokens +description: An interface for Multi-Asset tokens with context dependent asset type output controlled by owner's preference. +author: Bruno Škvorc (@Swader), Cicada (@CicadaNCR), Steven Pineda (@steven2308), Stevan Bogosavljevic (@stevyhacker), Jan Turk (@ThunderDeliverer) +discussions-to: https://ethereum-magicians.org/t/multiresource-tokens/11326 +status: Draft +type: Standards Track +category: ERC +created: 2022-10-10 +requires: 165, 721 +--- + +## Abstract + +The Multi-Asset NFT standard allows for the construction of a new primitive: context-dependent output of information per single NFT. + +The context-dependent output of information means that the asset in an appropriate format is displayed based on how the token is being accessed. I.e. if the token is being opened in an e-book reader, the PDF asset is displayed, if the token is opened in the marketplace, the PNG or the SVG asset is displayed, if the token is accessed from within a game, the 3D model asset is accessed and if the token is accessed by the (Internet of Things) IoT hub, the asset providing the neseccary addressing and specification information is accessed. + +An NFT can have multiple assets (outputs), which can be any kind of file to be served to the consumer, and orders them by priority. They do not have to match in mimetype or tokenURI, nor do they depend on one another. Assets are not standalone entities, but should be thought of as “namespaced tokenURIs” that can be ordered at will by the NFT owner, but only modified, updated, added, or removed if agreed on by both the owner of the token and the issuer of the token. + +## Motivation + +With NFTs being a widespread form of tokens in the Ethereum ecosystem and being used for a variety of use cases, it is time to standardize additional utility for them. Having multiple assets associated with a single NFT allows for greater utility, usability and forward compatibility. + +In the four years since [EIP-721](./eip-721.md) was published, the need for additional functionality has resulted in countless extensions. This EIP improves upon EIP-721 in the following areas: + +- [Cross-metaverse compatibility](#cross-metaverse-compatibility) +- [Multi-media output](#multi-media-output) +- [Media redundancy](#media-redundancy) +- [NFT evolution](#nft-evolution) + +### Cross-metaverse compatibility + +At the time of writing this proposal, the metaverse is still a fledgling, not full defined, term. No matter how the definition of metaverse evolves, the proposal can support any number of different implementations. + +Cross-metaverse compatibility could also be referred to as cross-engine compatibility. An example of this is where a cosmetic item for game A is not available in game B because the frameworks are incompatible. + +Such NFT can be given further utility by means of new additional assets: more games, more cosmetic items, appended to the same NFT. Thus, a game cosmetic item as an NFT becomes an ever-evolving NFT of infinite utility. + +The following is a more concrete example. One asset is a cosmetic item for game A, a file containing the cosmetic assets. Another is a cosmetic asset file for game B. A third is a generic asset intended to be shown in catalogs, marketplaces, portfolio trackers, or other generalized NFT viewers, containing a representation, stylized thumbnail, and animated demo/trailer of the cosmetic item. + +This EIP adds a layer of abstraction, allowing game developers to directly pull asset data from a user's NFTs instead of hard-coding it. + +### Multi-media output + +An NFT of an eBook can be represented as a PDF, MP3, or some other format, depending on what software loads it. If loaded into an eBook reader, a PDF should be displayed, and if loaded into an audiobook application, the MP3 representation should be used. Other metadata could be present in the NFT (perhaps the book's cover image) for identification on various marketplaces, Search Engine Result Pages (SERPs), or portfolio trackers. + +### Media redundancy + +Many NFTs are minted hastily without best practices in mind - specifically, many NFTs are minted with metadata centralized on a server somewhere or, in some cases, a hardcoded IPFS gateway which can also go down, instead of just an IPFS hash. + +By adding the same metadata file as different assets, e.g., one asset of a metadata and its linked image on Arweave, one asset of this same combination on Sia, another of the same combination on IPFS, etc., the resilience of the metadata and its referenced information increases exponentially as the chances of all the protocols going down at once become less likely. + +### NFT evolution + +Many NFTs, particularly game related ones, require evolution. This is especially the case in modern metaverses where no metaverse is actually a metaverse - it is just a multiplayer game hosted on someone's server which replaces username/password logins with reading an account's NFT balance. + +When the server goes down or the game shuts down, the player ends up with nothing (loss of experience) or something unrelated (assets or accessories unrelated to the game experience, spamming the wallet, incompatible with other “verses” - see [cross-metaverse](#cross-metaverse-compatibility) compatibility above). + +With Multi-Asset NFTs, a minter or another pre-approved entity is allowed to suggest a new asset to the NFT owner who can then accept it or reject it. The asset can even target an existing asset which is to be replaced. + +Replacing an asset could, to some extent, be similar to replacing an EIP-721 token's URI. When an asset is replaced a clear line of traceability remains; the old asset is still reachable and verifiable. Replacing an asset's metadata URI obscures this lineage. It also gives more trust to the token owner if the issuer cannot replace the asset of the NFT at will. The propose-accept asset replacement mechanic of this proposal provides this assurance. + +This allows level-up mechanics where, once enough experience has been collected, a user can accept the level-up. The level-up consists of a new asset being added to the NFT, and once accepted, this new asset replaces the old one. + +As a concrete example, think of Pokemon™️ evolving - once enough experience has been attained, a trainer can choose to evolve their monster. With Multi-Asset NFTs, it is not necessary to have centralized control over metadata to replace it, nor is it necessary to airdrop another NFT into the user's wallet - instead, a new Raichu asset is minted onto Pikachu, and if accepted, the Pikachu asset is gone, replaced by Raichu, which now has its own attributes, values, etc. + +Alternative example of this, could be version control of an IoT device's firmware. An asset could represent its current firmware and once an update becomes available, the current asset could be replaced with the one containing the updated firmware. + +## 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. + +```solidity +/// @title EIP-5773 Multi-Asset context-dependent tokens +/// @dev See https://eips.ethereum.org/EIPS/eip-5773 +/// @dev Note: the ERC-165 identifier for this interface is 0xd1526708. + +pragma solidity ^0.8.16; + +interface IMultiAsset { + /** + * @notice Used to notify listeners that an asset object is initialised at `assetId`. + * @param assetId ID of the asset that was initialised + */ + event AssetSet(uint64 assetId); + + /** + * @notice Used to notify listeners that an asset object at `assetId` is added to token's pending asset + * array. + * @param tokenId ID of the token that received a new pending asset + * @param assetId ID of the asset that has been added to the token's pending assets array + * @param replacesId ID of the asset that would be replaced + */ + event AssetAddedToToken( + uint256 indexed tokenId, + uint64 indexed assetId, + uint64 indexed replacesId + ); + + /** + * @notice Used to notify listeners that an asset object at `assetId` is accepted by the token and migrated + * from token's pending assets array to active assets array of the token. + * @param tokenId ID of the token that had a new asset accepted + * @param assetId ID of the asset that was accepted + * @param replacesId ID of the asset that was replaced + */ + event AssetAccepted( + uint256 indexed tokenId, + uint64 indexed assetId, + uint64 indexed replacesId + ); + + /** + * @notice Used to notify listeners that an asset object at `assetId` is rejected from token and is dropped + * from the pending assets array of the token. + * @param tokenId ID of the token that had an asset rejected + * @param assetId ID of the asset that was rejected + */ + event AssetRejected(uint256 indexed tokenId, uint64 indexed assetId); + + /** + * @notice Used to notify listeners that token's priority array is reordered. + * @param tokenId ID of the token that had the asset priority array updated + */ + event AssetPrioritySet(uint256 indexed tokenId); + + /** + * @notice Used to notify listeners that owner has granted an approval to the user to manage the assets of a + * given token. + * @dev Approvals must be cleared on transfer + * @param owner Address of the account that has granted the approval for all token's assets + * @param approved Address of the account that has been granted approval to manage the token's assets + * @param tokenId ID of the token on which the approval was granted + */ + event ApprovalForAssets( + address indexed owner, + address indexed approved, + uint256 indexed tokenId + ); + + /** + * @notice Used to notify listeners that owner has granted approval to the user to manage assets of all of their + * tokens. + * @param owner Address of the account that has granted the approval for all assets on all of their tokens + * @param operator Address of the account that has been granted the approval to manage the token's assets on all of the + * tokens + * @param approved Boolean value signifying whether the permission has been granted (`true`) or revoked (`false`) + */ + event ApprovalForAllForAssets( + address indexed owner, + address indexed operator, + bool approved + ); + + /** + * @notice Accepts an asset at from the pending array of given token. + * @dev Migrates the asset from the token's pending asset array to the token's active asset array. + * @dev Active assets cannot be removed by anyone, but can be replaced by a new asset. + * @dev Requirements: + * + * - The caller must own the token or be approved to manage the token's assets + * - `tokenId` must exist. + * - `index` must be in range of the length of the pending asset array. + * @dev Emits an {AssetAccepted} event. + * @param tokenId ID of the token for which to accept the pending asset + * @param index Index of the asset in the pending array to accept + * @param assetId Id of the asset expected to be in the index + */ + function acceptAsset( + uint256 tokenId, + uint256 index, + uint64 assetId + ) external; + + /** + * @notice Rejects an asset from the pending array of given token. + * @dev Removes the asset from the token's pending asset array. + * @dev Requirements: + * + * - The caller must own the token or be approved to manage the token's assets + * - `tokenId` must exist. + * - `index` must be in range of the length of the pending asset array. + * @dev Emits a {AssetRejected} event. + * @param tokenId ID of the token that the asset is being rejected from + * @param index Index of the asset in the pending array to be rejected + * @param assetId Id of the asset expected to be in the index + */ + function rejectAsset( + uint256 tokenId, + uint256 index, + uint64 assetId + ) external; + + /** + * @notice Rejects all assets from the pending array of a given token. + * @dev Effectively deletes the pending array. + * @dev Requirements: + * + * - The caller must own the token or be approved to manage the token's assets + * - `tokenId` must exist. + * @dev Emits a {AssetRejected} event with assetId = 0. + * @param tokenId ID of the token of which to clear the pending array + * @param maxRejections to prevent from rejecting assets which arrive just before this operation. + */ + function rejectAllAssets(uint256 tokenId, uint256 maxRejections) external; + + /** + * @notice Sets a new priority array for a given token. + * @dev The priority array is a non-sequential list of `uint16`s, where the lowest value is considered highest + * priority. + * @dev Value `0` of a priority is a special case equivalent to uninitialised. + * @dev Requirements: + * + * - The caller must own the token or be approved to manage the token's assets + * - `tokenId` must exist. + * - The length of `priorities` must be equal the length of the active assets array. + * @dev Emits a {AssetPrioritySet} event. + * @param tokenId ID of the token to set the priorities for + * @param priorities An array of priorities of active assets. The succession of items in the priorities array + * matches that of the succession of items in the active array + */ + function setPriority(uint256 tokenId, uint16[] calldata priorities) + external; + + /** + * @notice Used to retrieve IDs of the active assets of given token. + * @dev Asset data is stored by reference, in order to access the data corresponding to the ID, call + * `getAssetMetadata(tokenId, assetId)`. + * @dev You can safely get 10k + * @param tokenId ID of the token to retrieve the IDs of the active assets + * @return uint64[] An array of active asset IDs of the given token + */ + function getActiveAssets(uint256 tokenId) + external + view + returns (uint64[] memory); + + /** + * @notice Used to retrieve IDs of the pending assets of given token. + * @dev Asset data is stored by reference, in order to access the data corresponding to the ID, call + * `getAssetMetadata(tokenId, assetId)`. + * @param tokenId ID of the token to retrieve the IDs of the pending assets + * @return uint64[] An array of pending asset IDs of the given token + */ + function getPendingAssets(uint256 tokenId) + external + view + returns (uint64[] memory); + + /** + * @notice Used to retrieve the priorities of the active assets of a given token. + * @dev Asset priorities are a non-sequential array of uint16 values with an array size equal to active asset + * priorites. + * @param tokenId ID of the token for which to retrieve the priorities of the active assets + * @return uint16[] An array of priorities of the active assets of the given token + */ + function getActiveAssetPriorities(uint256 tokenId) + external + view + returns (uint16[] memory); + + /** + * @notice Used to retrieve the asset that will be replaced if a given asset from the token's pending array + * is accepted. + * @dev Asset data is stored by reference, in order to access the data corresponding to the ID, call + * `getAssetMetadata(tokenId, assetId)`. + * @param tokenId ID of the token to check + * @param newAssetId ID of the pending asset which will be accepted + * @return uint64 ID of the asset which will be replaced + */ + function getAssetReplacements(uint256 tokenId, uint64 newAssetId) + external + view + returns (uint64); + + /** + * @notice Used to fetch the asset metadata of the specified token's active asset with the given index. + * @dev Can be overriden to implement enumerate, fallback or other custom logic. + * @param tokenId ID of the token from which to retrieve the asset metadata + * @param assetId Asset Id, must be in the active assets array + * @return string The metadata of the asset belonging to the specified index in the token's active assets + * array + */ + function getAssetMetadata(uint256 tokenId, uint64 assetId) + external + view + returns (string memory); + + /** + * @notice Used to grant permission to the user to manage token's assets. + * @dev This differs from transfer approvals, as approvals are not cleared when the approved party accepts or + * rejects an asset, or sets asset priorities. This approval is cleared on token transfer. + * @dev Only a single account can be approved at a time, so approving the `0x0` address clears previous approvals. + * @dev Requirements: + * + * - The caller must own the token or be an approved operator. + * - `tokenId` must exist. + * @dev Emits an {ApprovalForAssets} event. + * @param to Address of the account to grant the approval to + * @param tokenId ID of the token for which the approval to manage the assets is granted + */ + function approveForAssets(address to, uint256 tokenId) external; + + /** + * @notice Used to retrieve the address of the account approved to manage assets of a given token. + * @dev Requirements: + * + * - `tokenId` must exist. + * @param tokenId ID of the token for which to retrieve the approved address + * @return address Address of the account that is approved to manage the specified token's assets + */ + function getApprovedForAssets(uint256 tokenId) + external + view + returns (address); + + /** + * @notice Used to add or remove an operator of assets for the caller. + * @dev Operators can call {acceptAsset}, {rejectAsset}, {rejectAllAssets} or {setPriority} for any token + * owned by the caller. + * @dev Requirements: + * + * - The `operator` cannot be the caller. + * @dev Emits an {ApprovalForAllForAssets} event. + * @param operator Address of the account to which the operator role is granted or revoked from + * @param approved The boolean value indicating whether the operator role is being granted (`true`) or revoked + * (`false`) + */ + function setApprovalForAllForAssets(address operator, bool approved) + external; + + /** + * @notice Used to check whether the address has been granted the operator role by a given address or not. + * @dev See {setApprovalForAllForAssets}. + * @param owner Address of the account that we are checking for whether it has granted the operator role + * @param operator Address of the account that we are checking whether it has the operator role or not + * @return bool The boolean value indicating whether the account we are checking has been granted the operator role + */ + function isApprovedForAllForAssets(address owner, address operator) + external + view + returns (bool); +} +``` + +The `getAssetMetadata` function returns the asset's metadata URI. The metadata, to which the metadata URI of the asset points, MAY contain a JSON response with the following fields: + +```json +{ + "title": "Asset Metadata", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Identifies the name of the asset associated with the asset" + }, + "description": { + "type": "string", + "description": "Identifies the general notes, abstracts, or summaries about the contents of the asset" + }, + "type": { + "type": "string", + "description": "Identifies the definition of the type of content of the asset" + }, + "locale": { + "type": "string", + "description": "Identifies metadata locale in ISO 639-1 format for translations and localisation of the asset" + }, + "license": { + "type": "string", + "description": "Identifies the license attached to the asset" + }, + "licenseUri": { + "type": "string", + "description": "Identifies the URI to the license statement of the license attached to the asset" + }, + "mediaUri": { + "type": "string", + "description": "Identifies the URI of the main media file associated with the asset" + }, + "thumbnailUri": { + "type": "string", + "description": "Identifies the URI of the thumbnail image associated with the asset to be used for preview of the asset in the wallets and client applications (the recommended maximum size is 350x350 px)" + }, + "externalUri": { + "type": "string", + "description": "Identifies the URI to the additional information about the subject or content of the asset" + }, + "properties": { + "type": "object", + "properties": "Identifies the optional custom attributes of the asset" + } + } +} +``` + +While this is the suggested JSON schema for the asset metadata, it is not enforced and MAY be structured completely differently based on implementer's preference. + +The optional properties of the metadata JSON MAY include the following fields, or it MAY incorporate any number of custom fields, but MAY also not be included in the schema at all: + +```json + "properties": { + "rarity": { + "type": "string", + "value": "epic" + }, + "color": { + "type": "string", + "value": "red" + }, + "height": { + "type": "float", + "value": 192.4 + }, + "tags": { + "type": "array", + "value": ["music", "2020", "best"] + } + } +``` + +## Rationale + +Designing the proposal, we considered the following questions: + +1. **Should we use Asset or Resource when referring to the structure that comprises the token?** + +The original idea was to call the proposal Multi-Resource, but while this denoted the broadness of the structures that could be held by a single token, the term *asset* represents it better. + +An asset is defined as something that is owned by a person, company, or organization, such as money, property, or land. This is the best representation of what an asset of this proposal can be. An asset in this proposal can be a multimedia file, technical information, a land deed, or anything that the implementer has decided to be an asset of the token they are implementing. + +2. **Why are [EIP-712](./eip-712.md) permit-style signatures to manage approvals not used?** + +For consistency. This proposal extends EIP-721 which already uses 1 transaction for approving operations with tokens. It would be inconsistent to have this and also support signing messages for operations with assets. + +3. **Why use indexes?** + +To reduce the gas consumption. If the asset ID was used to find which asset to accept or reject, iteration over arrays would be required and the cost of the operation would depend on the size of the active or pending assets arrays. With the index, the cost is fixed. A list of active and pending assets arrays per token need to be maintained, since methods to get them are part of the proposed interface. + +To avoid race conditions in which the index of an asset changes, the expected asset ID is included in operations requiring asset index, to verify that the asset being accessed using the index is the expected asset. + +Implementation that would internally keep track of indices using mapping was attempted. The average cost of adding an asset to a token increased by over 25%, costs of accepting and rejecting assets also increased 4.6% and 7.1% respectively. We concluded that it is not necessary for this proposal and can be implemented as an extension for use cases willing to accept this cost. In the sample implementation provided, there are several hooks which make this possible. + +4. **Why is a method to get all the assets not included?** + +Getting all assets might not be an operation necessary for all implementers. Additionally, it can be added either as an extension, doable with hooks, or can be emulated using an indexer. + +5. **Why is pagination not included?** + +Asset IDs use `uint64`, testing has confirmed that the limit of IDs you can read before reaching the gas limit is around 30.000. This is not expected to be a common use case so it is not a part of the interface. However, an implementer can create an extension for this use case if needed. + +6. **How does this proposal differ from the other proposals trying to address a similar problem?** + +After reviewing them, we concluded that each contains at least one of these limitations: + +- Using a single URI which is replaced as new assets are needed, this introduces a trust issue for the token owner. +- Focusing only on a type of asset, while this proposal is asset type agnostic. +- Having a different token for each new use case, this means that the token is not forward-compatible. + +### Multi-Asset Storage Schema + +Assets are stored within a token as an array of `uint64` identifiers. + +In order to reduce redundant on-chain string storage, multi asset tokens store assets by reference via inner storage. An asset entry on the storage is stored via a `uint64` mapping to asset data. + +An asset array is an array of these `uint64` asset ID references. + +Such a structure allows that, a generic asset can be added to the storage one time, and a reference to it can be added to the token contract as many times as we desire. Implementers can then use string concatenation to procedurally generate a link to a content-addressed archive based on the base *SRC* in the asset and the *token ID*. Storing the asset in a new token will only take 16 bytes of storage in the asset array per token for recurrent as well as `tokenId` dependent assets. + +Structuring token's assets in such a way allows for URIs to be derived programmatically through concatenation, especially when they differ only by `tokenId`. + +### Propose-Commit pattern for asset addition + +Adding assets to an existing token MUST be done in the form of a propose-commit pattern to allow for limited mutability by a 3rd party. When adding an asset to a token, it is first placed in the *"Pending"* array, and MUST be migrated to the *"Active"* array by the token's owner. The *"Pending"* assets array SHOULD be limited to 128 slots to prevent spam and griefing. + +### Asset management + +Several functions for asset management are included. In addition to permissioned migration from "Pending" to "Active", the owner of a token MAY also drop assets from both the active and the pending array -- an emergency function to clear all entries from the pending array MUST also be included. + +## Backwards Compatibility + +The MultiAsset token standard has been made compatible with [EIP-721](./eip-721.md) in order to take advantage of the robust tooling available for implementations of EIP-721 and to ensure compatibility with existing EIP-721 infrastructure. + +## Test Cases + +Tests are included in [`multiasset.ts`](../assets/eip-5773/test/multiasset.ts). + +To run them in terminal, you can use the following commands: + +``` +cd ../assets/eip-5773 +npm install +npx hardhat test +``` + +## Reference Implementation + +See [`MultiAssetToken.sol`](../assets/eip-5773/contracts/MultiAssetToken.sol). + +## Security Considerations + +The same security considerations as with [EIP-721](./eip-721.md) apply: hidden logic may be present in any of the functions, including burn, add asset, accept asset, and more. + +Caution is advised when dealing with non-audited contracts. + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/assets/eip-5773/contracts/IMultiAsset.sol b/assets/eip-5773/contracts/IMultiAsset.sol new file mode 100644 index 00000000000000..a8453ccd26c664 --- /dev/null +++ b/assets/eip-5773/contracts/IMultiAsset.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity ^0.8.0; + +interface IMultiAsset { + event AssetSet(uint64 assetId); + + event AssetAddedToToken( + uint256 indexed tokenId, + uint64 indexed assetId, + uint64 indexed replacesId + ); + + event AssetAccepted( + uint256 indexed tokenId, + uint64 indexed assetId, + uint64 indexed replacesId + ); + + event AssetRejected(uint256 indexed tokenId, uint64 indexed assetId); + + event AssetPrioritySet(uint256 indexed tokenId); + + event ApprovalForAssets( + address indexed owner, + address indexed approved, + uint256 indexed tokenId + ); + + event ApprovalForAllForAssets( + address indexed owner, + address indexed operator, + bool approved + ); + + function acceptAsset( + uint256 tokenId, + uint256 index, + uint64 assetId + ) external; + + function rejectAsset( + uint256 tokenId, + uint256 index, + uint64 assetId + ) external; + + function rejectAllAssets(uint256 tokenId, uint256 maxRejections) external; + + function setPriority(uint256 tokenId, uint16[] calldata priorities) + external; + + function getActiveAssets(uint256 tokenId) + external + view + returns (uint64[] memory); + + function getPendingAssets(uint256 tokenId) + external + view + returns (uint64[] memory); + + function getActiveAssetPriorities(uint256 tokenId) + external + view + returns (uint16[] memory); + + function getAssetReplacements(uint256 tokenId, uint64 newAssetId) + external + view + returns (uint64); + + function getAssetMetadata(uint256 tokenId, uint64 assetId) + external + view + returns (string memory); + + function approveForAssets(address to, uint256 tokenId) external; + + function getApprovedForAssets(uint256 tokenId) + external + view + returns (address); + + function setApprovalForAllForAssets(address operator, bool approved) + external; + + function isApprovedForAllForAssets(address owner, address operator) + external + view + returns (bool); +} diff --git a/assets/eip-5773/contracts/MultiAssetToken.sol b/assets/eip-5773/contracts/MultiAssetToken.sol new file mode 100644 index 00000000000000..86d051ede66c80 --- /dev/null +++ b/assets/eip-5773/contracts/MultiAssetToken.sol @@ -0,0 +1,724 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity ^0.8.15; + +import "./IMultiAsset.sol"; +import "./library/MultiAssetLib.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import "@openzeppelin/contracts/utils/Address.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; +import "@openzeppelin/contracts/utils/Context.sol"; + +contract MultiAssetToken is Context, IERC721, IMultiAsset { + using MultiAssetLib for uint256; + using MultiAssetLib for uint64[]; + using MultiAssetLib for uint128[]; + using Address for address; + using Strings for uint256; + + // Token name + string private _name; + + // Token symbol + string private _symbol; + + // Mapping from token ID to owner address + mapping(uint256 => address) private _owners; + + // Mapping owner address to token count + mapping(address => uint256) private _balances; + + // Mapping from token ID to approved address + mapping(uint256 => address) private _tokenApprovals; + + // Mapping from owner to operator approvals + mapping(address => mapping(address => bool)) private _operatorApprovals; + + // Mapping from token ID to approved address for assets + mapping(uint256 => address) internal _tokenApprovalsForAssets; + + // Mapping from owner to operator approvals for assets + mapping(address => mapping(address => bool)) + internal _operatorApprovalsForAssets; + + //mapping of uint64 Ids to asset object + mapping(uint64 => string) internal _assets; + + //mapping of tokenId to new asset, to asset to be replaced + mapping(uint256 => mapping(uint64 => uint64)) private _assetReplacements; + + //mapping of tokenId to all assets + mapping(uint256 => uint64[]) internal _activeAssets; + + //mapping of tokenId to an array of asset priorities + mapping(uint256 => uint16[]) internal _activeAssetPriorities; + + //Double mapping of tokenId to active assets + mapping(uint256 => mapping(uint64 => bool)) private _tokenAssets; + + //mapping of tokenId to all assets by priority + mapping(uint256 => uint64[]) internal _pendingAssets; + + constructor(string memory name_, string memory symbol_) { + _name = name_; + _symbol = symbol_; + } + + //////////////////////////////////////// + // ERC-721 COMPLIANCE + //////////////////////////////////////// + + function supportsInterface(bytes4 interfaceId) public view returns (bool) { + return + interfaceId == type(IMultiAsset).interfaceId || + interfaceId == type(IERC721).interfaceId || + interfaceId == type(IERC165).interfaceId; + } + + function balanceOf(address owner) + public + view + virtual + override + returns (uint256) + { + require( + owner != address(0), + "ERC721: address zero is not a valid owner" + ); + return _balances[owner]; + } + + function ownerOf(uint256 tokenId) + public + view + virtual + override + returns (address) + { + address owner = _owners[tokenId]; + require( + owner != address(0), + "ERC721: owner query for nonexistent token" + ); + return owner; + } + + function name() public view virtual returns (string memory) { + return _name; + } + + function symbol() public view virtual returns (string memory) { + return _symbol; + } + + function approve(address to, uint256 tokenId) public virtual { + address owner = ownerOf(tokenId); + require(to != owner, "MultiAsset: approval to current owner"); + require( + _msgSender() == owner || isApprovedForAll(owner, _msgSender()), + "MultiAsset: approve caller is not owner nor approved for all" + ); + + _approve(to, tokenId); + } + + function approveForAssets(address to, uint256 tokenId) external virtual { + address owner = ownerOf(tokenId); + require(to != owner, "MultiAsset: approval to current owner"); + require( + _msgSender() == owner || + isApprovedForAllForAssets(owner, _msgSender()), + "MultiAsset: approve caller is not owner nor approved for all" + ); + _approveForAssets(to, tokenId); + } + + function getApproved(uint256 tokenId) + public + view + virtual + override + returns (address) + { + require( + _exists(tokenId), + "MultiAsset: approved query for nonexistent token" + ); + + return _tokenApprovals[tokenId]; + } + + function getApprovedForAssets(uint256 tokenId) + public + view + virtual + returns (address) + { + require( + _exists(tokenId), + "MultiAsset: approved query for nonexistent token" + ); + return _tokenApprovalsForAssets[tokenId]; + } + + function setApprovalForAll(address operator, bool approved) + public + virtual + override + { + _setApprovalForAll(_msgSender(), operator, approved); + } + + function isApprovedForAll(address owner, address operator) + public + view + virtual + override + returns (bool) + { + return _operatorApprovals[owner][operator]; + } + + function setApprovalForAllForAssets(address operator, bool approved) + public + virtual + override + { + _setApprovalForAllForAssets(_msgSender(), operator, approved); + } + + function isApprovedForAllForAssets(address owner, address operator) + public + view + virtual + returns (bool) + { + return _operatorApprovalsForAssets[owner][operator]; + } + + function transferFrom( + address from, + address to, + uint256 tokenId + ) public virtual override { + //solhint-disable-next-line max-line-length + require( + _isApprovedOrOwner(_msgSender(), tokenId), + "MultiAsset: transfer caller is not owner nor approved" + ); + + _transfer(from, to, tokenId); + } + + function safeTransferFrom( + address from, + address to, + uint256 tokenId + ) public virtual override { + safeTransferFrom(from, to, tokenId, ""); + } + + function safeTransferFrom( + address from, + address to, + uint256 tokenId, + bytes memory data + ) public virtual override { + require( + _isApprovedOrOwner(_msgSender(), tokenId), + "MultiAsset: transfer caller is not owner nor approved" + ); + _safeTransfer(from, to, tokenId, data); + } + + function _safeTransfer( + address from, + address to, + uint256 tokenId, + bytes memory data + ) internal virtual { + _transfer(from, to, tokenId); + require( + _checkOnERC721Received(from, to, tokenId, data), + "MultiAsset: transfer to non ERC721 Receiver implementer" + ); + } + + function _exists(uint256 tokenId) internal view virtual returns (bool) { + return _owners[tokenId] != address(0); + } + + function _isApprovedOrOwner(address spender, uint256 tokenId) + internal + view + virtual + returns (bool) + { + require( + _exists(tokenId), + "MultiAsset: approved query for nonexistent token" + ); + address owner = ownerOf(tokenId); + return (spender == owner || + isApprovedForAll(owner, spender) || + getApproved(tokenId) == spender); + } + + function _isApprovedForAssetsOrOwner(address user, uint256 tokenId) + internal + view + virtual + returns (bool) + { + require( + _exists(tokenId), + "MultiAsset: approved query for nonexistent token" + ); + address owner = ownerOf(tokenId); + return (user == owner || + isApprovedForAllForAssets(owner, user) || + getApprovedForAssets(tokenId) == user); + } + + function _safeMint(address to, uint256 tokenId) internal virtual { + _safeMint(to, tokenId, ""); + } + + function _safeMint( + address to, + uint256 tokenId, + bytes memory data + ) internal virtual { + _mint(to, tokenId); + require( + _checkOnERC721Received(address(0), to, tokenId, data), + "MultiAsset: transfer to non ERC721 Receiver implementer" + ); + } + + function _mint(address to, uint256 tokenId) internal virtual { + require(to != address(0), "MultiAsset: mint to the zero address"); + require(!_exists(tokenId), "MultiAsset: token already minted"); + + _beforeTokenTransfer(address(0), to, tokenId); + + _balances[to] += 1; + _owners[tokenId] = to; + + emit Transfer(address(0), to, tokenId); + + _afterTokenTransfer(address(0), to, tokenId); + } + + function _burn(uint256 tokenId) internal virtual { + // WARNING: If you intend to allow the reminting of a burned token, you + // might want to clean the assets for the token, that is: + // _pendingAssets, _activeAssets, _assetReplacements + // _activeAssetPriorities and _tokenAssets. + address owner = ownerOf(tokenId); + + _beforeTokenTransfer(owner, address(0), tokenId); + + // Clear approvals + _approve(address(0), tokenId); + _approveForAssets(address(0), tokenId); + + _balances[owner] -= 1; + delete _owners[tokenId]; + + emit Transfer(owner, address(0), tokenId); + + _afterTokenTransfer(owner, address(0), tokenId); + } + + function _transfer( + address from, + address to, + uint256 tokenId + ) internal virtual { + require( + ownerOf(tokenId) == from, + "MultiAsset: transfer from incorrect owner" + ); + require(to != address(0), "MultiAsset: transfer to the zero address"); + + _beforeTokenTransfer(from, to, tokenId); + + // Clear approvals from the previous owner + _approve(address(0), tokenId); + _approveForAssets(address(0), tokenId); + + _balances[from] -= 1; + _balances[to] += 1; + _owners[tokenId] = to; + + emit Transfer(from, to, tokenId); + + _afterTokenTransfer(from, to, tokenId); + } + + function _approve(address to, uint256 tokenId) internal virtual { + _tokenApprovals[tokenId] = to; + emit Approval(ownerOf(tokenId), to, tokenId); + } + + function _approveForAssets(address to, uint256 tokenId) internal virtual { + _tokenApprovalsForAssets[tokenId] = to; + emit ApprovalForAssets(ownerOf(tokenId), to, tokenId); + } + + function _setApprovalForAll( + address owner, + address operator, + bool approved + ) internal virtual { + require(owner != operator, "MultiAsset: approve to caller"); + _operatorApprovals[owner][operator] = approved; + emit ApprovalForAll(owner, operator, approved); + } + + function _setApprovalForAllForAssets( + address owner, + address operator, + bool approved + ) internal virtual { + require(owner != operator, "MultiAsset: approve to caller"); + _operatorApprovalsForAssets[owner][operator] = approved; + emit ApprovalForAllForAssets(owner, operator, approved); + } + + function _checkOnERC721Received( + address from, + address to, + uint256 tokenId, + bytes memory data + ) private returns (bool) { + if (to.isContract()) { + try + IERC721Receiver(to).onERC721Received( + _msgSender(), + from, + tokenId, + data + ) + returns (bytes4 retval) { + return retval == IERC721Receiver.onERC721Received.selector; + } catch (bytes memory reason) { + if (reason.length == 0) { + revert( + "MultiAsset: transfer to non ERC721 Receiver implementer" + ); + } else { + /// @solidity memory-safe-assembly + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + } else { + return true; + } + } + + function _beforeTokenTransfer( + address from, + address to, + uint256 tokenId + ) internal virtual {} + + function _afterTokenTransfer( + address from, + address to, + uint256 tokenId + ) internal virtual {} + + //////////////////////////////////////// + // ASSETS + //////////////////////////////////////// + + function acceptAsset( + uint256 tokenId, + uint256 index, + uint64 assetId + ) external virtual { + require( + index < _pendingAssets[tokenId].length, + "MultiAsset: index out of bounds" + ); + require( + _isApprovedForAssetsOrOwner(_msgSender(), tokenId), + "MultiAsset: not owner or approved" + ); + require( + assetId == _pendingAssets[tokenId][index], + "MultiAsset: Unexpected asset" + ); + + _beforeAcceptAsset(tokenId, index, assetId); + uint64 replacesId = _assetReplacements[tokenId][assetId]; + uint256 replaceIndex; + bool replacefound; + if (replacesId != uint64(0)) + (replaceIndex, replacefound) = _activeAssets[tokenId].indexOf( + replacesId + ); + + if (replacefound) { + // We don't want to remove and then push a new asset. + // This way we also keep the priority of the original resource + _activeAssets[tokenId][index] = assetId; + delete _tokenAssets[tokenId][replacesId]; + } else { + // We use the current size as next priority, by default priorities would be [0,1,2...] + _activeAssetPriorities[tokenId].push( + uint16(_activeAssets[tokenId].length) + ); + _activeAssets[tokenId].push(assetId); + replacesId = uint64(0); + } + _pendingAssets[tokenId].removeItemByIndex(index); + delete _assetReplacements[tokenId][assetId]; + + emit AssetAccepted(tokenId, assetId, replacesId); + _afterAcceptAsset(tokenId, index, assetId); + } + + function rejectAsset( + uint256 tokenId, + uint256 index, + uint64 assetId + ) external virtual { + require( + index < _pendingAssets[tokenId].length, + "MultiAsset: index out of bounds" + ); + require( + _pendingAssets[tokenId].length > index, + "MultiAsset: Pending asset index out of range" + ); + require( + _isApprovedForAssetsOrOwner(_msgSender(), tokenId), + "MultiAsset: not owner or approved" + ); + + _beforeRejectAsset(tokenId, index, assetId); + _pendingAssets[tokenId].removeItemByIndex(index); + delete _tokenAssets[tokenId][assetId]; + delete _assetReplacements[tokenId][assetId]; + + emit AssetRejected(tokenId, assetId); + _afterRejectAsset(tokenId, index, assetId); + } + + function rejectAllAssets(uint256 tokenId, uint256 maxRejections) + external + virtual + { + require( + _isApprovedForAssetsOrOwner(_msgSender(), tokenId), + "MultiAsset: not owner or approved" + ); + + uint256 len = _pendingAssets[tokenId].length; + if (len > maxRejections) revert("Unexpected number of assets"); + + _beforeRejectAllAssets(tokenId); + for (uint256 i; i < len; ) { + uint64 assetId = _pendingAssets[tokenId][i]; + delete _assetReplacements[tokenId][assetId]; + unchecked { + ++i; + } + } + delete (_pendingAssets[tokenId]); + + emit AssetRejected(tokenId, uint64(0)); + _afterRejectAllAssets(tokenId); + } + + function setPriority(uint256 tokenId, uint16[] memory priorities) + external + virtual + { + uint256 length = priorities.length; + require( + length == _activeAssets[tokenId].length, + "MultiAsset: Bad priority list length" + ); + require( + _isApprovedForAssetsOrOwner(_msgSender(), tokenId), + "MultiAsset: not owner or approved" + ); + + _beforeSetPriority(tokenId, priorities); + _activeAssetPriorities[tokenId] = priorities; + + emit AssetPrioritySet(tokenId); + _afterSetPriority(tokenId, priorities); + } + + function getActiveAssets(uint256 tokenId) + public + view + virtual + returns (uint64[] memory) + { + return _activeAssets[tokenId]; + } + + function getPendingAssets(uint256 tokenId) + public + view + virtual + returns (uint64[] memory) + { + return _pendingAssets[tokenId]; + } + + function getActiveAssetPriorities(uint256 tokenId) + public + view + virtual + returns (uint16[] memory) + { + return _activeAssetPriorities[tokenId]; + } + + function getAssetReplacements(uint256 tokenId, uint64 newAssetId) + public + view + virtual + returns (uint64) + { + return _assetReplacements[tokenId][newAssetId]; + } + + function getAssetMetadata(uint256 tokenId, uint64 assetId) + public + view + virtual + returns (string memory) + { + if (!_tokenAssets[tokenId][assetId]) + revert("MultiAsset: Token does not have asset"); + return _assets[assetId]; + } + + function tokenURI(uint256 tokenId) + public + view + virtual + returns (string memory) + { + return ""; + } + + // To be implemented with custom guards + + function _addAssetEntry(uint64 id, string memory metadataURI) internal { + require(id != uint64(0), "RMRK: Write to zero"); + require(bytes(_assets[id]).length == 0, "RMRK: asset already exists"); + + _beforeAddAsset(id, metadataURI); + _assets[id] = metadataURI; + + emit AssetSet(id); + _afterAddAsset(id, metadataURI); + } + + function _addAssetToToken( + uint256 tokenId, + uint64 assetId, + uint64 replacesAssetWithId + ) internal { + require( + !_tokenAssets[tokenId][assetId], + "MultiAsset: Asset already exists on token" + ); + + require( + bytes(_assets[assetId]).length != 0, + "MultiAsset: Asset not found in storage" + ); + + require( + _pendingAssets[tokenId].length < 128, + "MultiAsset: Max pending assets reached" + ); + + _beforeAddAssetToToken(tokenId, assetId, replacesAssetWithId); + _tokenAssets[tokenId][assetId] = true; + _pendingAssets[tokenId].push(assetId); + + if (replacesAssetWithId != uint64(0)) { + _assetReplacements[tokenId][assetId] = replacesAssetWithId; + } + + emit AssetAddedToToken(tokenId, assetId, replacesAssetWithId); + _afterAddAssetToToken(tokenId, assetId, replacesAssetWithId); + } + + // HOOKS + + function _beforeAddAsset(uint64 id, string memory metadataURI) + internal + virtual + {} + + function _afterAddAsset(uint64 id, string memory metadataURI) + internal + virtual + {} + + function _beforeAddAssetToToken( + uint256 tokenId, + uint64 assetId, + uint64 replacesAssetWithId + ) internal virtual {} + + function _afterAddAssetToToken( + uint256 tokenId, + uint64 assetId, + uint64 replacesAssetWithId + ) internal virtual {} + + function _beforeAcceptAsset( + uint256 tokenId, + uint256 index, + uint256 assetId + ) internal virtual {} + + function _afterAcceptAsset( + uint256 tokenId, + uint256 index, + uint256 assetId + ) internal virtual {} + + function _beforeRejectAsset( + uint256 tokenId, + uint256 index, + uint256 assetId + ) internal virtual {} + + function _afterRejectAsset( + uint256 tokenId, + uint256 index, + uint256 assetId + ) internal virtual {} + + function _beforeRejectAllAssets(uint256 tokenId) internal virtual {} + + function _afterRejectAllAssets(uint256 tokenId) internal virtual {} + + function _beforeSetPriority(uint256 tokenId, uint16[] memory priorities) + internal + virtual + {} + + function _afterSetPriority(uint256 tokenId, uint16[] memory priorities) + internal + virtual + {} +} diff --git a/assets/eip-5773/contracts/library/MultiAssetLib.sol b/assets/eip-5773/contracts/library/MultiAssetLib.sol new file mode 100644 index 00000000000000..5aa3e505895ba3 --- /dev/null +++ b/assets/eip-5773/contracts/library/MultiAssetLib.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity ^0.8.0; + +library MultiAssetLib { + function indexOf(uint64[] memory A, uint64 a) + internal + pure + returns (uint256, bool) + { + uint256 length = A.length; + for (uint256 i; i < length; ) { + if (A[i] == a) { + return (i, true); + } + unchecked { + ++i; + } + } + return (0, false); + } + + //For reasource storage array + function removeItemByIndex(uint64[] storage array, uint256 index) internal { + //Check to see if this is already gated by require in all calls + require(index < array.length); + array[index] = array[array.length - 1]; + array.pop(); + } +} diff --git a/assets/eip-5773/contracts/mocks/ERC721ReceiverMock.sol b/assets/eip-5773/contracts/mocks/ERC721ReceiverMock.sol new file mode 100644 index 00000000000000..1f0665b8c304ca --- /dev/null +++ b/assets/eip-5773/contracts/mocks/ERC721ReceiverMock.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity ^0.8.15; + +contract ERC721ReceiverMock { + bytes4 constant ERC721_RECEIVED = 0x150b7a02; + + function onERC721Received( + address, + address, + uint256, + bytes calldata + ) public returns (bytes4) { + return ERC721_RECEIVED; + } +} diff --git a/assets/eip-5773/contracts/mocks/MultiAssetTokenMock.sol b/assets/eip-5773/contracts/mocks/MultiAssetTokenMock.sol new file mode 100644 index 00000000000000..77f7025b98a55f --- /dev/null +++ b/assets/eip-5773/contracts/mocks/MultiAssetTokenMock.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity ^0.8.15; + +import "../MultiAssetToken.sol"; + +contract MultiAssetTokenMock is MultiAssetToken { + address private _issuer; + + constructor(string memory name, string memory symbol) + MultiAssetToken(name, symbol) + { + _setIssuer(_msgSender()); + } + + modifier onlyIssuer() { + require(_msgSender() == _issuer, "RMRK: Only issuer"); + _; + } + + function setIssuer(address issuer) external onlyIssuer { + _setIssuer(issuer); + } + + function getIssuer() external view returns (address) { + return _issuer; + } + + function mint(address to, uint256 tokenId) external onlyIssuer { + _mint(to, tokenId); + } + + function transfer(address to, uint256 tokenId) external { + _transfer(msg.sender, to, tokenId); + } + + function burn(uint256 tokenId) external { + _burn(tokenId); + } + + function addAssetToToken( + uint256 tokenId, + uint64 assetId, + uint64 overwrites + ) external onlyIssuer { + _addAssetToToken(tokenId, assetId, overwrites); + } + + function addAssetEntry(uint64 id, string memory metadataURI) + external + onlyIssuer + { + _addAssetEntry(id, metadataURI); + } + + function _setIssuer(address issuer) private { + _issuer = issuer; + } +} diff --git a/assets/eip-5773/contracts/mocks/NonReceiverMock.sol b/assets/eip-5773/contracts/mocks/NonReceiverMock.sol new file mode 100644 index 00000000000000..e9d2d6ed3ecb2e --- /dev/null +++ b/assets/eip-5773/contracts/mocks/NonReceiverMock.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity ^0.8.15; + +contract NonReceiverMock { + function dummy() external {} +} diff --git a/assets/eip-5773/contracts/utils/MultiAssetRenderUtils.sol b/assets/eip-5773/contracts/utils/MultiAssetRenderUtils.sol new file mode 100644 index 00000000000000..418c3c934f2308 --- /dev/null +++ b/assets/eip-5773/contracts/utils/MultiAssetRenderUtils.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: CC0-1.0 + +import "../IMultiAsset.sol"; + +pragma solidity ^0.8.15; + +/** + * @dev Extra utility functions for composing RMRK assets. + */ + +contract MultiAssetRenderUtils { + uint16 private constant _LOWEST_POSSIBLE_PRIORITY = 2**16 - 1; + + struct ActiveAsset { + uint64 id; + uint16 priority; + string metadata; + } + + struct PendingAsset { + uint64 id; + uint128 acceptRejectIndex; + uint64 overwritesAssetWithId; + string metadata; + } + + function getActiveAssets(address target, uint256 tokenId) + public + view + virtual + returns (ActiveAsset[] memory) + { + IMultiAsset target_ = IMultiAsset(target); + + uint64[] memory assets = target_.getActiveAssets(tokenId); + uint16[] memory priorities = target_.getActiveAssetPriorities(tokenId); + uint256 len = assets.length; + if (len == 0) { + revert("Token has no assets"); + } + + ActiveAsset[] memory activeAssets = new ActiveAsset[](len); + string memory metadata; + for (uint256 i; i < len; ) { + metadata = target_.getAssetMetadata(tokenId, assets[i]); + activeAssets[i] = ActiveAsset({ + id: assets[i], + priority: priorities[i], + metadata: metadata + }); + unchecked { + ++i; + } + } + return activeAssets; + } + + function getPendingAssets(address target, uint256 tokenId) + public + view + virtual + returns (PendingAsset[] memory) + { + IMultiAsset target_ = IMultiAsset(target); + + uint64[] memory assets = target_.getPendingAssets(tokenId); + uint256 len = assets.length; + if (len == 0) { + revert("Token has no assets"); + } + + PendingAsset[] memory pendingAssets = new PendingAsset[](len); + string memory metadata; + uint64 overwritesAssetWithId; + for (uint256 i; i < len; ) { + metadata = target_.getAssetMetadata(tokenId, assets[i]); + overwritesAssetWithId = target_.getAssetReplacements( + tokenId, + assets[i] + ); + pendingAssets[i] = PendingAsset({ + id: assets[i], + acceptRejectIndex: uint128(i), + overwritesAssetWithId: overwritesAssetWithId, + metadata: metadata + }); + unchecked { + ++i; + } + } + return pendingAssets; + } + + /** + * @notice Returns asset metadata strings for the given ids + * + * Requirements: + * + * - `assetIds` must exist. + */ + function getAssetsById( + address target, + uint256 tokenId, + uint64[] calldata assetIds + ) public view virtual returns (string[] memory) { + IMultiAsset target_ = IMultiAsset(target); + uint256 len = assetIds.length; + string[] memory assets = new string[](len); + for (uint256 i; i < len; ) { + assets[i] = target_.getAssetMetadata(tokenId, assetIds[i]); + unchecked { + ++i; + } + } + return assets; + } + + /** + * @notice Returns the asset metadata with the highest priority for the given token + */ + function getTopAssetMetaForToken(address target, uint256 tokenId) + external + view + returns (string memory) + { + IMultiAsset target_ = IMultiAsset(target); + uint16[] memory priorities = target_.getActiveAssetPriorities(tokenId); + uint64[] memory assets = target_.getActiveAssets(tokenId); + uint256 len = priorities.length; + if (len == 0) { + revert("Token has no assets"); + } + + uint16 maxPriority = _LOWEST_POSSIBLE_PRIORITY; + uint64 maxPriorityAsset; + for (uint64 i; i < len; ) { + uint16 currentPrio = priorities[i]; + if (currentPrio < maxPriority) { + maxPriority = currentPrio; + maxPriorityAsset = assets[i]; + } + unchecked { + ++i; + } + } + return target_.getAssetMetadata(tokenId, maxPriorityAsset); + } +} diff --git a/assets/eip-5773/hardhat.config.ts b/assets/eip-5773/hardhat.config.ts new file mode 100644 index 00000000000000..f283a2948f00b0 --- /dev/null +++ b/assets/eip-5773/hardhat.config.ts @@ -0,0 +1,21 @@ +import { HardhatUserConfig } from 'hardhat/config'; +import '@nomicfoundation/hardhat-chai-matchers'; +import '@nomiclabs/hardhat-etherscan'; +import '@typechain/hardhat'; +import 'hardhat-contract-sizer'; +import 'hardhat-gas-reporter'; +import 'solidity-coverage'; + +const config: HardhatUserConfig = { + solidity: { + version: '0.8.15', + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + }, + }, +}; + +export default config; diff --git a/assets/eip-5773/package.json b/assets/eip-5773/package.json new file mode 100644 index 00000000000000..e6f2346786f858 --- /dev/null +++ b/assets/eip-5773/package.json @@ -0,0 +1,41 @@ +{ + "name": "eip-5773", + "dependencies": { + "@openzeppelin/contracts": "^4.6.0" + }, + "devDependencies": { + "@nomicfoundation/hardhat-chai-matchers": "^1.0.1", + "@nomicfoundation/hardhat-network-helpers": "^1.0.3", + "@nomiclabs/hardhat-ethers": "^2.2.1", + "@nomiclabs/hardhat-etherscan": "^3.1.0", + "@openzeppelin/test-helpers": "^0.5.15", + "@primitivefi/hardhat-dodoc": "^0.2.3", + "@typechain/ethers-v5": "^10.1.0", + "@typechain/hardhat": "^6.1.2", + "@types/chai": "^4.3.1", + "@types/mocha": "^9.1.0", + "@types/node": "^18.0.3", + "@typescript-eslint/eslint-plugin": "^5.30.6", + "@typescript-eslint/parser": "^5.30.6", + "chai": "^4.3.6", + "eslint": "^8.27.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-promise": "^6.0.0", + "ethers": "^5.6.9", + "hardhat": "^2.12.2", + "hardhat-contract-sizer": "^2.6.1", + "hardhat-gas-reporter": "^1.0.8", + "prettier": "2.7.1", + "prettier-plugin-solidity": "^1.0.0-beta.20", + "solc": "^0.8.9", + "solhint": "^3.3.7", + "solidity-coverage": "^0.8.2", + "ts-node": "^10.8.2", + "typechain": "^8.1.0", + "typescript": "^4.7.4", + "walk-sync": "^3.0.0" + } +} diff --git a/assets/eip-5773/test/multiasset.ts b/assets/eip-5773/test/multiasset.ts new file mode 100644 index 00000000000000..0685a44e78792e --- /dev/null +++ b/assets/eip-5773/test/multiasset.ts @@ -0,0 +1,673 @@ +import { ethers } from 'hardhat'; +import { expect } from 'chai'; +import { + ERC721ReceiverMock, + MultiAssetReceiverMock, + MultiAssetTokenMock, + NonReceiverMock, + MultiAssetRenderUtils, +} from '../typechain-types'; +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; + +describe('MultiAsset', async () => { + let token: MultiAssetTokenMock; + let renderUtils: MultiAssetRenderUtils; + let nonReceiver: NonReceiverMock; + let receiver721: ERC721ReceiverMock; + + let owner: SignerWithAddress; + let addrs: SignerWithAddress[]; + + const name = 'RmrkTest'; + const symbol = 'RMRKTST'; + + const metaURIDefault = 'metaURI'; + + beforeEach(async () => { + const [signersOwner, ...signersAddr] = await ethers.getSigners(); + owner = signersOwner; + addrs = signersAddr; + + const multiassetFactory = await ethers.getContractFactory('MultiAssetTokenMock'); + token = await multiassetFactory.deploy(name, symbol); + await token.deployed(); + + const renderFactory = await ethers.getContractFactory('MultiAssetRenderUtils'); + renderUtils = await renderFactory.deploy(); + await renderUtils.deployed(); + }); + + describe('Init', async function () { + it('Name', async function () { + expect(await token.name()).to.equal(name); + }); + + it('Symbol', async function () { + expect(await token.symbol()).to.equal(symbol); + }); + }); + + describe('ERC165 check', async function () { + it('can support IERC165', async function () { + expect(await token.supportsInterface('0x01ffc9a7')).to.equal(true); + }); + + it('can support IERC721', async function () { + expect(await token.supportsInterface('0x80ac58cd')).to.equal(true); + }); + + it('can support IMultiAsset', async function () { + expect(await token.supportsInterface('0xd1526708')).to.equal(true); + }); + + it('cannot support other interfaceId', async function () { + expect(await token.supportsInterface('0xffffffff')).to.equal(false); + }); + }); + + describe('Check OnReceived ERC721 and Multiasset', async function () { + it('Revert on transfer to non onERC721/onMultiasset implementer', async function () { + const tokenId = 1; + await token.mint(owner.address, tokenId); + + const NonReceiver = await ethers.getContractFactory('NonReceiverMock'); + nonReceiver = await NonReceiver.deploy(); + await nonReceiver.deployed(); + + await expect( + token + .connect(owner) + ['safeTransferFrom(address,address,uint256)'](owner.address, nonReceiver.address, 1), + ).to.be.revertedWith('MultiAsset: transfer to non ERC721 Receiver implementer'); + }); + + it('onERC721Received callback on transfer', async function () { + const tokenId = 1; + await token.mint(owner.address, tokenId); + + const ERC721Receiver = await ethers.getContractFactory('ERC721ReceiverMock'); + receiver721 = await ERC721Receiver.deploy(); + await receiver721.deployed(); + + await token + .connect(owner) + ['safeTransferFrom(address,address,uint256)'](owner.address, receiver721.address, 1); + expect(await token.ownerOf(1)).to.equal(receiver721.address); + }); + }); + + describe('Asset storage', async function () { + it('can add asset', async function () { + const id = 10; + + await expect(token.addAssetEntry(id, metaURIDefault)).to.emit(token, 'AssetSet').withArgs(id); + }); + + it('cannot get non existing asset', async function () { + const tokenId = 1; + const resId = 10; + await token.mint(owner.address, tokenId); + await expect(token.getAssetMetadata(tokenId, resId)).to.be.revertedWith( + 'MultiAsset: Token does not have asset', + ); + }); + + it('cannot add asset entry if not issuer', async function () { + const id = 10; + await expect(token.connect(addrs[1]).addAssetEntry(id, metaURIDefault)).to.be.revertedWith( + 'RMRK: Only issuer', + ); + }); + + it('can set and get issuer', async function () { + const newIssuerAddr = addrs[1].address; + expect(await token.getIssuer()).to.equal(owner.address); + + await token.setIssuer(newIssuerAddr); + expect(await token.getIssuer()).to.equal(newIssuerAddr); + }); + + it('cannot set issuer if not issuer', async function () { + const newIssuer = addrs[1]; + await expect(token.connect(newIssuer).setIssuer(newIssuer.address)).to.be.revertedWith( + 'RMRK: Only issuer', + ); + }); + + it('cannot overwrite asset', async function () { + const id = 10; + + await token.addAssetEntry(id, metaURIDefault); + await expect(token.addAssetEntry(id, metaURIDefault)).to.be.revertedWith( + 'RMRK: asset already exists', + ); + }); + + it('cannot add asset with id 0', async function () { + const id = ethers.utils.hexZeroPad('0x0', 8); + + await expect(token.addAssetEntry(id, metaURIDefault)).to.be.revertedWith( + 'RMRK: Write to zero', + ); + }); + + it('cannot add same asset twice', async function () { + const id = 10; + + await expect(token.addAssetEntry(id, metaURIDefault)).to.emit(token, 'AssetSet').withArgs(id); + + await expect(token.addAssetEntry(id, metaURIDefault)).to.be.revertedWith( + 'RMRK: asset already exists', + ); + }); + }); + + describe('Adding assets', async function () { + it('can add asset to token', async function () { + const resId = 1; + const resId2 = 2; + const tokenId = 1; + + await token.mint(owner.address, tokenId); + await addAssets([resId, resId2]); + await expect(token.addAssetToToken(tokenId, resId, 0)).to.emit(token, 'AssetAddedToToken'); + await expect(token.addAssetToToken(tokenId, resId2, 0)).to.emit(token, 'AssetAddedToToken'); + + const pendingIds = await token.getPendingAssets(tokenId); + expect(await renderUtils.getAssetsById(token.address, tokenId, pendingIds)).to.be.eql([ + metaURIDefault, + metaURIDefault, + ]); + }); + + it('cannot add non existing asset to token', async function () { + const resId = 1; + const tokenId = 1; + + await token.mint(owner.address, tokenId); + await expect(token.addAssetToToken(tokenId, resId, 0)).to.be.revertedWith( + 'MultiAsset: Asset not found in storage', + ); + }); + + it('can add asset to non existing token and it is pending when minted', async function () { + const resId = 1; + const tokenId = 1; + await addAssets([resId]); + + await token.addAssetToToken(tokenId, resId, 0); + await token.mint(owner.address, tokenId); + expect(await token.getPendingAssets(tokenId)).to.eql([ethers.BigNumber.from(resId)]); + }); + + it('cannot add asset twice to the same token', async function () { + const resId = 1; + const tokenId = 1; + + await token.mint(owner.address, tokenId); + await addAssets([resId]); + await token.addAssetToToken(tokenId, resId, 0); + await expect( + token.addAssetToToken(tokenId, ethers.BigNumber.from(resId), 0), + ).to.be.revertedWith('MultiAsset: Asset already exists on token'); + }); + + it('cannot add too many assets to the same token', async function () { + const tokenId = 1; + + await token.mint(owner.address, tokenId); + for (let i = 1; i <= 128; i++) { + await addAssets([i]); + await token.addAssetToToken(tokenId, i, 0); + } + + // Now it's full, next should fail + const resId = 129; + await addAssets([resId]); + await expect(token.addAssetToToken(tokenId, resId, 0)).to.be.revertedWith( + 'MultiAsset: Max pending assets reached', + ); + }); + + it('can add same asset to 2 different tokens', async function () { + const resId = 1; + const tokenId1 = 1; + const tokenId2 = 2; + + await token.mint(owner.address, tokenId1); + await token.mint(owner.address, tokenId2); + await addAssets([resId]); + await token.addAssetToToken(tokenId1, resId, 0); + await token.addAssetToToken(tokenId2, resId, 0); + }); + }); + + describe('Accepting assets', async function () { + it('can accept asset if owner', async function () { + const { tokenOwner, tokenId } = await mintSampleToken(); + const approved = tokenOwner; + + await checkAcceptFromAddress(approved, tokenId); + }); + + it('can accept asset if approved for assets', async function () { + const { tokenId } = await mintSampleToken(); + const approved = addrs[1]; + + await token.approveForAssets(approved.address, tokenId); + await checkAcceptFromAddress(approved, tokenId); + }); + + it('can accept asset if approved for assets for all', async function () { + const { tokenId } = await mintSampleToken(); + const approved = addrs[2]; + + await token.setApprovalForAllForAssets(approved.address, true); + await checkAcceptFromAddress(approved, tokenId); + }); + + it('can accept multiple assets', async function () { + const resId = 1; + const resId2 = 2; + const tokenId = 1; + + await token.mint(owner.address, tokenId); + await addAssets([resId, resId2]); + await token.addAssetToToken(tokenId, resId, 0); + await token.addAssetToToken(tokenId, resId2, 0); + await expect(token.acceptAsset(tokenId, 1, resId2)) + .to.emit(token, 'AssetAccepted') + .withArgs(tokenId, resId2, 0); + await expect(token.acceptAsset(tokenId, 0, resId)) + .to.emit(token, 'AssetAccepted') + .withArgs(tokenId, resId, 0); + + expect(await token.getPendingAssets(tokenId)).to.be.eql([]); + + const activeIds = await token.getActiveAssets(tokenId); + expect(await renderUtils.getAssetsById(token.address, tokenId, activeIds)).to.eql([ + metaURIDefault, + metaURIDefault, + ]); + }); + + it('cannot accept asset twice', async function () { + const resId = 1; + const tokenId = 1; + + await token.mint(owner.address, tokenId); + await addAssets([resId]); + await token.addAssetToToken(tokenId, resId, 0); + await token.acceptAsset(tokenId, 0, resId); + }); + + it('cannot accept asset if not owner', async function () { + const resId = 1; + const tokenId = 1; + + await token.mint(owner.address, tokenId); + await addAssets([resId]); + await token.addAssetToToken(tokenId, resId, 0); + await expect(token.connect(addrs[1]).acceptAsset(tokenId, 0, resId)).to.be.revertedWith( + 'MultiAsset: not owner or approved', + ); + }); + + it('cannot accept non existing asset', async function () { + const tokenId = 1; + + await token.mint(owner.address, tokenId); + await expect(token.acceptAsset(tokenId, 0, 1)).to.be.revertedWith( + 'MultiAsset: index out of bounds', + ); + }); + }); + + describe('Overwriting assets', async function () { + it('can add asset to token overwritting an existing one', async function () { + const resId = 1; + const resId2 = 2; + const tokenId = 1; + + await token.mint(owner.address, tokenId); + await addAssets([resId, resId2]); + await token.addAssetToToken(tokenId, resId, 0); + await token.acceptAsset(tokenId, 0, resId); + + // Add new asset to overwrite the first, and accept + const activeAssets = await token.getActiveAssets(tokenId); + await expect(token.addAssetToToken(tokenId, resId2, activeAssets[0])) + .to.emit(token, 'AssetAddedToToken') + .withArgs(tokenId, resId2, resId); + const pendingAssets = await token.getPendingAssets(tokenId); + + expect(await token.getAssetReplacements(tokenId, pendingAssets[0])).to.eql(activeAssets[0]); + await expect(token.acceptAsset(tokenId, 0, resId2)) + .to.emit(token, 'AssetAccepted') + .withArgs(tokenId, resId2, resId); + + const activeIds = await token.getActiveAssets(tokenId); + expect(await renderUtils.getAssetsById(token.address, tokenId, activeIds)).to.eql([ + metaURIDefault, + ]); + // Overwrite should be gone + expect(await token.getAssetReplacements(tokenId, pendingAssets[0])).to.eql( + ethers.BigNumber.from(0), + ); + }); + + it('can overwrite non existing asset to token, it could have been deleted', async function () { + const resId = 1; + const tokenId = 1; + + await token.mint(owner.address, tokenId); + await addAssets([resId]); + await token.addAssetToToken(tokenId, resId, ethers.utils.hexZeroPad('0x1', 8)); + await token.acceptAsset(tokenId, 0, resId); + + const activeIds = await token.getActiveAssets(tokenId); + expect(await renderUtils.getAssetsById(token.address, tokenId, activeIds)).to.eql([ + metaURIDefault, + ]); + }); + }); + + describe('Rejecting assets', async function () { + it('can reject asset if owner', async function () { + const { tokenOwner, tokenId } = await mintSampleToken(); + const approved = tokenOwner; + + await checkRejectFromAddress(approved, tokenId); + }); + + it('can reject asset if approved for assets', async function () { + const { tokenId } = await mintSampleToken(); + const approved = addrs[1]; + + await token.approveForAssets(approved.address, tokenId); + await checkRejectFromAddress(approved, tokenId); + }); + + it('can reject asset if approved for assets for all', async function () { + const { tokenId } = await mintSampleToken(); + const approved = addrs[2]; + + await token.setApprovalForAllForAssets(approved.address, true); + await checkRejectFromAddress(approved, tokenId); + }); + + it('can reject all assets if owner', async function () { + const { tokenOwner, tokenId } = await mintSampleToken(); + const approved = tokenOwner; + + await checkRejectAllFromAddress(approved, tokenId); + }); + + it('can reject all assets if approved for assets', async function () { + const { tokenId } = await mintSampleToken(); + const approved = addrs[1]; + + await token.approveForAssets(approved.address, tokenId); + await checkRejectAllFromAddress(approved, tokenId); + }); + + it('can reject all assets if approved for assets for all', async function () { + const { tokenId } = await mintSampleToken(); + const approved = addrs[2]; + + await token.setApprovalForAllForAssets(approved.address, true); + await checkRejectAllFromAddress(approved, tokenId); + }); + + it('can reject asset and overwrites are cleared', async function () { + const resId = 1; + const resId2 = 2; + const tokenId = 1; + + await token.mint(owner.address, tokenId); + await addAssets([resId, resId2]); + await token.addAssetToToken(tokenId, resId, 0); + await token.acceptAsset(tokenId, 0, resId); + + // Will try to overwrite but we reject it + await token.addAssetToToken(tokenId, resId2, resId); + await token.rejectAsset(tokenId, 0, resId2); + + expect(await token.getAssetReplacements(tokenId, resId2)).to.eql(ethers.BigNumber.from(0)); + }); + + it('can reject all assets and overwrites are cleared', async function () { + const resId = 1; + const resId2 = 2; + const tokenId = 1; + + await token.mint(owner.address, tokenId); + await addAssets([resId, resId2]); + await token.addAssetToToken(tokenId, resId, 0); + await token.acceptAsset(tokenId, 0, resId); + + // Will try to overwrite but we reject all + await token.addAssetToToken(tokenId, resId2, resId); + await token.rejectAllAssets(tokenId, 1); + + expect(await token.getAssetReplacements(tokenId, resId2)).to.eql(ethers.BigNumber.from(0)); + }); + + it('can reject all pending assets at max capacity', async function () { + const tokenId = 1; + const resArr = []; + + for (let i = 1; i < 128; i++) { + resArr.push(i); + } + + await token.mint(owner.address, tokenId); + await addAssets(resArr); + + for (let i = 1; i < 128; i++) { + await token.addAssetToToken(tokenId, i, 1); + } + await token.rejectAllAssets(tokenId, 128); + + expect(await token.getAssetReplacements(1, 2)).to.eql(ethers.BigNumber.from(0)); + }); + + it('cannot reject asset twice', async function () { + const resId = 1; + const tokenId = 1; + + await token.mint(owner.address, tokenId); + await addAssets([resId]); + await token.addAssetToToken(tokenId, resId, 0); + await token.rejectAsset(tokenId, 0, resId); + }); + + it('cannot reject asset nor reject all if not owner', async function () { + const resId = 1; + const tokenId = 1; + + await token.mint(owner.address, tokenId); + await addAssets([resId]); + await token.addAssetToToken(tokenId, resId, 0); + + await expect(token.connect(addrs[1]).rejectAsset(tokenId, 0, resId)).to.be.revertedWith( + 'MultiAsset: not owner or approved', + ); + await expect(token.connect(addrs[1]).rejectAllAssets(tokenId, 1)).to.be.revertedWith( + 'MultiAsset: not owner or approved', + ); + }); + + it('cannot reject non existing asset', async function () { + const tokenId = 1; + + await token.mint(owner.address, tokenId); + await expect(token.rejectAsset(tokenId, 0, 1)).to.be.revertedWith( + 'MultiAsset: index out of bounds', + ); + }); + }); + + describe('Priorities', async function () { + it('can set and get priorities', async function () { + const tokenId = 1; + await addAssetsToToken(tokenId); + + expect(await token.getActiveAssetPriorities(tokenId)).to.be.eql([0, 1]); + await expect(token.setPriority(tokenId, [2, 1])) + .to.emit(token, 'AssetPrioritySet') + .withArgs(tokenId); + expect(await token.getActiveAssetPriorities(tokenId)).to.be.eql([2, 1]); + }); + + it('cannot set priorities for non owned token', async function () { + const tokenId = 1; + await addAssetsToToken(tokenId); + await expect(token.connect(addrs[1]).setPriority(tokenId, [2, 1])).to.be.revertedWith( + 'MultiAsset: not owner or approved', + ); + }); + + it('cannot set different number of priorities', async function () { + const tokenId = 1; + await addAssetsToToken(tokenId); + await expect(token.connect(addrs[1]).setPriority(tokenId, [1])).to.be.revertedWith( + 'MultiAsset: Bad priority list length', + ); + await expect(token.connect(addrs[1]).setPriority(tokenId, [2, 1, 3])).to.be.revertedWith( + 'MultiAsset: Bad priority list length', + ); + }); + + it('cannot set priorities for non existing token', async function () { + const tokenId = 1; + await expect(token.connect(addrs[1]).setPriority(tokenId, [])).to.be.revertedWith( + 'MultiAsset: approved query for nonexistent token', + ); + }); + }); + + describe('Approval Cleaning', async function () { + it('cleans token and assets approvals on transfer', async function () { + const tokenId = 1; + const tokenOwner = addrs[1]; + const newOwner = addrs[2]; + const approved = addrs[3]; + await token.mint(tokenOwner.address, tokenId); + await token.connect(tokenOwner).approve(approved.address, tokenId); + await token.connect(tokenOwner).approveForAssets(approved.address, tokenId); + + expect(await token.getApproved(tokenId)).to.eql(approved.address); + expect(await token.getApprovedForAssets(tokenId)).to.eql(approved.address); + + await token.connect(tokenOwner).transfer(newOwner.address, tokenId); + + expect(await token.getApproved(tokenId)).to.eql(ethers.constants.AddressZero); + expect(await token.getApprovedForAssets(tokenId)).to.eql(ethers.constants.AddressZero); + }); + + it('cleans token and assets approvals on burn', async function () { + const tokenId = 1; + const tokenOwner = addrs[1]; + const approved = addrs[3]; + await token.mint(tokenOwner.address, tokenId); + await token.connect(tokenOwner).approve(approved.address, tokenId); + await token.connect(tokenOwner).approveForAssets(approved.address, tokenId); + + expect(await token.getApproved(tokenId)).to.eql(approved.address); + expect(await token.getApprovedForAssets(tokenId)).to.eql(approved.address); + + await token.connect(tokenOwner).burn(tokenId); + + await expect(token.getApproved(tokenId)).to.be.revertedWith( + 'MultiAsset: approved query for nonexistent token', + ); + await expect(token.getApprovedForAssets(tokenId)).to.be.revertedWith( + 'MultiAsset: approved query for nonexistent token', + ); + }); + }); + + async function mintSampleToken(): Promise<{ tokenOwner: SignerWithAddress; tokenId: number }> { + const tokenOwner = owner; + const tokenId = 1; + await token.mint(tokenOwner.address, tokenId); + + return { tokenOwner, tokenId }; + } + + async function addAssets(ids: number[]): Promise { + ids.forEach(async (resId) => { + await token.addAssetEntry(resId, metaURIDefault); + }); + } + + async function addAssetsToToken(tokenId: number): Promise { + const resId = 1; + const resId2 = 2; + await token.mint(owner.address, tokenId); + await addAssets([resId, resId2]); + await token.addAssetToToken(tokenId, resId, 0); + await token.addAssetToToken(tokenId, resId2, 0); + await token.acceptAsset(tokenId, 0, resId); + await token.acceptAsset(tokenId, 0, resId2); + } + + async function checkAcceptFromAddress( + accepter: SignerWithAddress, + tokenId: number, + ): Promise { + const resId = 1; + + await addAssets([resId]); + await token.addAssetToToken(tokenId, resId, 0); + await expect(token.connect(accepter).acceptAsset(tokenId, 0, resId)) + .to.emit(token, 'AssetAccepted') + .withArgs(tokenId, resId, 0); + + expect(await token.getPendingAssets(tokenId)).to.be.eql([]); + + const activeIds = await token.getActiveAssets(tokenId); + expect(await renderUtils.getAssetsById(token.address, tokenId, activeIds)).to.eql([ + metaURIDefault, + ]); + } + + async function checkRejectFromAddress( + rejecter: SignerWithAddress, + tokenId: number, + ): Promise { + const resId = 1; + + await addAssets([resId]); + await token.addAssetToToken(tokenId, resId, 0); + + await expect(token.connect(rejecter).rejectAsset(tokenId, 0, resId)).to.emit( + token, + 'AssetRejected', + ); + + expect(await token.getPendingAssets(tokenId)).to.be.eql([]); + expect(await token.getActiveAssets(tokenId)).to.be.eql([]); + } + + async function checkRejectAllFromAddress( + rejecter: SignerWithAddress, + tokenId: number, + ): Promise { + const resId = 1; + const resId2 = 2; + + await addAssets([resId, resId2]); + await token.addAssetToToken(tokenId, resId, 0); + await token.addAssetToToken(tokenId, resId2, 0); + + await expect(token.connect(rejecter).rejectAllAssets(tokenId, 2)).to.emit( + token, + 'AssetRejected', + ); + + expect(await token.getPendingAssets(tokenId)).to.be.eql([]); + expect(await token.getActiveAssets(tokenId)).to.be.eql([]); + } +}); diff --git a/assets/eip-5773/test/renderUtils.ts b/assets/eip-5773/test/renderUtils.ts new file mode 100644 index 00000000000000..63644c4d2df0f3 --- /dev/null +++ b/assets/eip-5773/test/renderUtils.ts @@ -0,0 +1,86 @@ +import { BigNumber } from 'ethers'; +import { ethers } from 'hardhat'; +import { expect } from 'chai'; +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { MultiAssetTokenMock, MultiAssetRenderUtils } from '../typechain-types'; +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; + +function bn(x: number): BigNumber { + return BigNumber.from(x); +} + +async function assetsFixture() { + const multiassetFactory = await ethers.getContractFactory('MultiAssetTokenMock'); + const renderUtilsFactory = await ethers.getContractFactory('MultiAssetRenderUtils'); + + const multiasset = await multiassetFactory.deploy('Chunky', 'CHNK'); + await multiasset.deployed(); + + const renderUtils = await renderUtilsFactory.deploy(); + await renderUtils.deployed(); + + return { multiasset, renderUtils }; +} + +describe('Render Utils', async function () { + let owner: SignerWithAddress; + let multiasset: MultiAssetTokenMock; + let renderUtils: MultiAssetRenderUtils; + let tokenId: number; + + const resId = bn(1); + const resId2 = bn(2); + const resId3 = bn(3); + const resId4 = bn(4); + + before(async function () { + ({ multiasset, renderUtils } = await loadFixture(assetsFixture)); + + const signers = await ethers.getSigners(); + owner = signers[0]; + + tokenId = 1; + await multiasset.mint(owner.address, tokenId); + await multiasset.addAssetEntry(resId, 'ipfs://res1.jpg'); + await multiasset.addAssetEntry(resId2, 'ipfs://res2.jpg'); + await multiasset.addAssetEntry(resId3, 'ipfs://res3.jpg'); + await multiasset.addAssetEntry(resId4, 'ipfs://res4.jpg'); + await multiasset.addAssetToToken(tokenId, resId, 0); + await multiasset.addAssetToToken(tokenId, resId2, 0); + await multiasset.addAssetToToken(tokenId, resId3, resId); + await multiasset.addAssetToToken(tokenId, resId4, 0); + + await multiasset.acceptAsset(tokenId, 0, resId); + await multiasset.acceptAsset(tokenId, 1, resId2); + await multiasset.setPriority(tokenId, [10, 5]); + }); + + describe('Render Utils MultiAsset', async function () { + it('can get active assets', async function () { + expect(await renderUtils.getActiveAssets(multiasset.address, tokenId)).to.eql([ + [resId, 10, 'ipfs://res1.jpg'], + [resId2, 5, 'ipfs://res2.jpg'], + ]); + }); + it('can get pending assets', async function () { + expect(await renderUtils.getPendingAssets(multiasset.address, tokenId)).to.eql([ + [resId4, bn(0), bn(0), 'ipfs://res4.jpg'], + [resId3, bn(1), resId, 'ipfs://res3.jpg'], + ]); + }); + + it('can get top asset by priority', async function () { + expect(await renderUtils.getTopAssetMetaForToken(multiasset.address, tokenId)).to.eql( + 'ipfs://res2.jpg', + ); + }); + + it('cannot get top asset if token has no assets', async function () { + const otherTokenId = 2; + await multiasset.mint(owner.address, otherTokenId); + await expect( + renderUtils.getTopAssetMetaForToken(multiasset.address, otherTokenId), + ).to.be.revertedWith('Token has no assets'); + }); + }); +});