-
Notifications
You must be signed in to change notification settings - Fork 0
AINFTize contract template, integration test, documentation #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
24861dd
6fd7788
b65bc07
b9845aa
7fff439
c0b26a2
9d59358
3242397
1114c2b
8112374
30b6c89
e41e592
c0f57dc
14836e5
2dc9ca2
8682c4e
7410acb
01bcd92
b76b414
b988e9c
ac959a7
8f7e5f2
c838f11
183078e
f47461d
d50b374
bac7db4
5d79ad8
58bf9b0
89c6e7b
bcd81a0
11a999e
64fc428
43362ec
8ad1804
fb1b7da
3c523c2
42a293a
95edd77
355f0dd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| node_modules | ||
| .env | ||
| coverage | ||
| coverage.json | ||
| typechain | ||
| typechain-types | ||
|
|
||
| # Hardhat files | ||
| cache | ||
| artifacts | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,50 @@ | ||
| # ainftize-contract | ||
| AINFTize contract template | ||
|
|
||
| *Upgradeable contract template is not completely implemented yet. Do not use it.* | ||
|
|
||
| This repository contains the core contract template of AINFTize. AINFTize is a NFT customize service that enables NFT holders to upgrade and customize their NFTs on their own. The service can be built on top of AIN blockchain as a history layer, and L1 as contract layer such as Ethereum. AINFTize is designed to be compatible with existing NFTs on Ethereum and other evm-compatible blockchains. | ||
| Using this contract, you can choose two options: create AINFT721 from the scratch or clone existing ERC721 projects to AINFT721. The former option is recommended if you want to get started a new NFT project. The latter option is recommended if you want to upgrade your existing NFT project to AINFT721, give your NFT holders more control over their NFTs. | ||
|
|
||
| ## Call graphs of the core contracts | ||
|
|
||
| There are three core contracts in this project: `AINFT721.sol`, `AINFTFactory.sol`, `AINPayment.sol`. Each contract serves the following purpose: | ||
|
|
||
| - `AINFT721.sol`: This contract is the ERC721-extended contract that supports some update schemes related to AINFTs. The main feature is that token holders have permissions to update and rollback their metadata. History is kept in both contract and AIN blockchain. | ||
| - `AINFTFactory.sol`: This contract is the factory contract that is used to create new AINFT721 or clone AINFT721 from existing ERC721 contract. It is a standard ERC721 contract with some additional functions to support the AINFTize protocol. | ||
| - `AINPayment.sol`: This contract is the payment contract that is used to pay the creator of the NFT. It is a standard ERC20 contract with some additional functions to support the AINFTize protocol. | ||
|
|
||
| The following call graphs show the interactions between these three contracts. | ||
|
|
||
|
|
||
|  | ||
|
|
||
|
|
||
| ## Prerequisite | ||
| - node.js | ||
| - yarn | ||
|
|
||
| ## Installation | ||
| ``` | ||
| > yarn | ||
| ``` | ||
|
|
||
|
|
||
| ## Compilation | ||
| ``` | ||
| > yarn compile | ||
| ``` | ||
|
|
||
| ## Deployment(Not Implemented yet) | ||
| ``` | ||
| > yarn deploy:hardhat // for localtest | ||
| > yarn deploy:mainnet // Ethereum mainnet | ||
| ``` | ||
|
|
||
| ## Test | ||
| ``` | ||
| # for integration test | ||
| > yarn test:integration | ||
|
|
||
| # for entire test | ||
| > yarn test | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,287 @@ | ||
| // SPDX-License-Identifier: MIT | ||
| pragma solidity ^0.8.9; | ||
|
|
||
| import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; | ||
| import "@openzeppelin/contracts/security/Pausable.sol"; | ||
| import "@openzeppelin/contracts/access/AccessControl.sol"; | ||
| import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol"; | ||
| import "@openzeppelin/contracts/utils/Strings.sol"; | ||
| import "@openzeppelin/contracts/utils/Address.sol"; | ||
| import "@openzeppelin/contracts/interfaces/IERC721.sol"; | ||
|
|
||
| import "./interfaces/IAINFT721.sol"; | ||
|
|
||
| /** | ||
| *@dev AINFT721 contract | ||
| */ | ||
| contract AINFT721 is | ||
| ERC721, | ||
| Pausable, | ||
| AccessControl, | ||
| ERC721Burnable, | ||
| IAINFT721 | ||
| { | ||
| using Strings for uint256; | ||
| using Address for address; | ||
| struct MetadataContainer { | ||
| address updater; | ||
| string metadataURI; | ||
| } | ||
| bool public immutable IS_CLONED; | ||
| IERC721 public immutable ORIGIN_NFT; | ||
| address public PAYMENT_PLUGIN; | ||
| bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); | ||
| bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); | ||
| string private baseURI; | ||
| uint256 public totalMinted = 0; | ||
| mapping(bytes32 => MetadataContainer) private _metadataStorage; // keccak256(bytes(tokenId, <DELIMETER>, version)) => MetadataContainer | ||
| mapping(uint256 => uint256) private _tokenURICurrentVersion; // tokenId => tokenURIVersion | ||
|
|
||
| constructor(string memory name_, string memory symbol_, bool isCloned_, address originNFT_) ERC721(name_, symbol_) { | ||
| //FIXME(jakepyo): If AINFT721 is created/cloned by AINFTFactory, tx.origin should be set corresponding roles. | ||
| // However under the discussion, if AINFTFactory should be removed, tx.origin should be replaced with msg.sender. | ||
| _grantRole(DEFAULT_ADMIN_ROLE, tx.origin); | ||
| _grantRole(PAUSER_ROLE, tx.origin); | ||
| _grantRole(MINTER_ROLE, tx.origin); | ||
| ORIGIN_NFT = IERC721(originNFT_); | ||
| IS_CLONED = isCloned_; | ||
| require((address(ORIGIN_NFT) == address(0) && !IS_CLONED) || | ||
| (address(ORIGIN_NFT) != address(0) && IS_CLONED), | ||
| "If AINFT721 is created first time, the originNFT_ should be set zero address. \ | ||
| \n If AINFT721 is cloned by already existing ERC721 contract, the originNFT_ should be set existing contract address."); | ||
| } | ||
|
|
||
| modifier Cloned() { | ||
| require(IS_CLONED, "Only cloned contract can execute."); | ||
| _; | ||
| } | ||
|
|
||
| modifier NotCloned() { | ||
| require(!IS_CLONED, "Only created contract can execute."); | ||
| _; | ||
| } | ||
|
|
||
| function isCloned() public view returns (bool) { | ||
| return IS_CLONED; | ||
| } | ||
|
|
||
| function mintFromOriginInstance( | ||
| uint256 tokenId_ | ||
| ) public Cloned { | ||
| require(!_exists(tokenId_), "The tokenId_ is already minted or cloned"); | ||
| require(_msgSender() == ORIGIN_NFT.ownerOf(tokenId_), "The sender should be the holder of origin NFT."); | ||
| _safeMint(_msgSender(), tokenId_); | ||
|
orth0gonal marked this conversation as resolved.
|
||
| totalMinted += 1; | ||
| } | ||
|
|
||
| function mintBulkFromOriginInstance( | ||
| uint256[] calldata tokenIds_, | ||
|
orth0gonal marked this conversation as resolved.
|
||
| address[] calldata recipients_ | ||
| ) public Cloned { | ||
| for (uint i = 0; i < tokenIds_.length; i++) { | ||
| require(!_exists(tokenIds_[i]), "The tokenId_ is already minted or cloned"); | ||
| require(recipients_[i] == ORIGIN_NFT.ownerOf(tokenIds_[i]), "The sender should be the holder of origin NFT."); | ||
| _safeMint(recipients_[i], tokenIds_[i]); | ||
| } | ||
| totalMinted += tokenIds_.length; | ||
| } | ||
|
|
||
| function setPaymentContract(address paymentPlugin_) public onlyRole(DEFAULT_ADMIN_ROLE) { | ||
| require(_msgSender() == tx.origin && _msgSender() != address(0), "Only EOA can set payment contract."); | ||
| require(paymentPlugin_.isContract(), "The paymentPlugin_ should be a contract address."); | ||
| PAYMENT_PLUGIN = paymentPlugin_; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we need to check the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there any desirable way that The first method I've thought was to make
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm going to just add There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In my first glance, I was thinking the way by checking paymentPlugin_.ainft value is same with this contract's address. just value comparision. that's all.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be discussed on #4 . Thanks! |
||
| } | ||
|
|
||
| /** | ||
| * @dev See {IAINFT721}. | ||
| */ | ||
| function supportsInterface( | ||
| bytes4 interfaceId | ||
| ) | ||
| public | ||
| view | ||
| override( | ||
| AccessControl, | ||
| ERC721, | ||
| IERC165 | ||
| ) | ||
| returns (bool) | ||
| { | ||
| return | ||
| interfaceId == type(IAINFT721).interfaceId || | ||
| super.supportsInterface(interfaceId); | ||
| } | ||
|
|
||
| function pause() public onlyRole(PAUSER_ROLE) { | ||
| _pause(); | ||
| } | ||
|
|
||
| function unpause() public onlyRole(PAUSER_ROLE) { | ||
| _unpause(); | ||
| } | ||
|
|
||
| function safeMint( | ||
| address to, | ||
| uint256 tokenId | ||
| ) | ||
| public | ||
| NotCloned | ||
| { | ||
| _safeMint(to, tokenId); | ||
| totalMinted += 1; | ||
| } | ||
|
|
||
| /** | ||
| * @dev Reverts if the `tokenId` has not been minted yet. | ||
| */ | ||
| function _requireMinted(uint256 tokenId) internal view virtual override(ERC721) { | ||
| super._requireMinted(tokenId); | ||
| } | ||
|
|
||
| function isApprovedOrOwner( | ||
| address spender, | ||
| uint256 tokenId | ||
| ) public view virtual returns (bool) | ||
| { | ||
| return super._isApprovedOrOwner(spender, tokenId); | ||
| } | ||
|
|
||
| function burn(uint256 tokenId) public override(ERC721Burnable) { | ||
| super.burn(tokenId); | ||
| } | ||
|
|
||
| //// | ||
| // URI & METADATA RELATED FUNCTIONS | ||
| //// | ||
|
|
||
| function _baseURI() internal view override(ERC721) returns (string memory) { | ||
| return baseURI; | ||
| } | ||
|
|
||
| function tokenURI( | ||
| uint256 tokenId | ||
| ) public view override(ERC721) returns (string memory) { | ||
| _requireMinted(tokenId); | ||
| uint256 currentVersion = _tokenURICurrentVersion[tokenId]; | ||
|
|
||
| return tokenURIByVersion(tokenId, currentVersion); | ||
| } | ||
|
|
||
| /** | ||
| * @dev Returns the key for the metadata storage. | ||
| * @return The metadata storage key. | ||
| */ | ||
| function _metadataStorageKey( | ||
| uint256 tokenId, | ||
| uint256 version | ||
| ) internal pure returns (bytes32) { | ||
| return keccak256(abi.encodePacked(tokenId.toString(), "AINFT delimeter", version)); | ||
| } | ||
|
|
||
| /** | ||
| * @dev The metadata storage is a mapping of token ID to metadata. | ||
| * @return The metadata storage. | ||
| */ | ||
| function metadataStorageByVersion( | ||
| uint256 tokenId, | ||
| uint256 version | ||
| ) public view returns (MetadataContainer memory) { | ||
|
|
||
| bytes32 key = _metadataStorageKey(tokenId, version); | ||
| return _metadataStorage[key]; | ||
| } | ||
|
|
||
| function tokenURICurrentVersion( | ||
| uint256 tokenId | ||
| ) public view returns (uint256) { | ||
| _requireMinted(tokenId); | ||
| return _tokenURICurrentVersion[tokenId]; | ||
| } | ||
|
|
||
| function tokenURIByVersion( | ||
| uint256 tokenId, | ||
| uint256 uriVersion | ||
| ) public view returns (string memory) { | ||
| _requireMinted(tokenId); | ||
| if (uriVersion == 0) { | ||
| return string(abi.encodePacked(baseURI, tokenId.toString())); | ||
| } else { | ||
| MetadataContainer memory metadata = metadataStorageByVersion(tokenId, uriVersion); | ||
| return metadata.metadataURI; | ||
| } | ||
| } | ||
|
|
||
| function setBaseURI( | ||
| string memory newBaseURI | ||
| ) public onlyRole(DEFAULT_ADMIN_ROLE) returns (bool) { | ||
| require( | ||
| bytes(newBaseURI).length > 0, | ||
| "AINFT721::setBaseURI() - Empty newBaseURI" | ||
| ); | ||
| require( | ||
| keccak256(bytes(newBaseURI)) != keccak256(bytes(baseURI)), | ||
| "AINFT721::setBaseURI() - Same newBaseURI as baseURI" | ||
| ); | ||
|
|
||
| baseURI = newBaseURI; | ||
| if (totalMinted == 1) emit MetadataUpdate(0); | ||
| else if (totalMinted > 1) emit BatchMetadataUpdate(0, totalMinted - 1); | ||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * @dev version up & upload the metadata. You should call this function externally when the token is updated. | ||
| */ | ||
| function updateTokenURI( | ||
| uint256 tokenId, | ||
| string memory newTokenURI | ||
| ) external returns (bool) { | ||
| require( | ||
| (isApprovedOrOwner(tx.origin, tokenId) || | ||
| PAYMENT_PLUGIN == _msgSender() || | ||
| PAYMENT_PLUGIN == address(0)), | ||
| "AINFT721::updateTokenURI() - only payment contract can call this funciton. Or, you can call this function directly if PAYMENT_PLUGIN is unset." | ||
| ); | ||
| _requireMinted(tokenId); | ||
|
|
||
| uint256 updatedVersion = ++_tokenURICurrentVersion[tokenId]; | ||
| bytes32 metadataKey = _metadataStorageKey(tokenId, updatedVersion); | ||
| _metadataStorage[metadataKey] = MetadataContainer({ | ||
| updater: _msgSender(), | ||
| metadataURI: newTokenURI | ||
| }); | ||
|
|
||
| emit MetadataUpdate(tokenId); | ||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * @dev if you've ever updated the metadata more than once, rollback the metadata to the previous one and return true. | ||
| * if its metadata has not been updated yet or failed to update, return false | ||
| */ | ||
| function rollbackTokenURI(uint256 tokenId) external returns (bool) { | ||
| require( | ||
| (isApprovedOrOwner(tx.origin, tokenId) || | ||
| PAYMENT_PLUGIN == _msgSender() || | ||
| PAYMENT_PLUGIN == address(0)), | ||
| "AINFT721::rollbackTokenURI() - only payment contract can call this function. Or, you can call this function directly if PAYMENT_PLUGIN is unset." | ||
| ); | ||
| _requireMinted(tokenId); | ||
|
|
||
| uint256 currentVersion = _tokenURICurrentVersion[tokenId]; | ||
| if (currentVersion == 0) return false; | ||
| else { | ||
| //delete the currentVersion of _metadataStorage | ||
| bytes32 currentMetadataKey = _metadataStorageKey( | ||
| tokenId, | ||
| currentVersion | ||
| ); | ||
| delete _metadataStorage[currentMetadataKey]; | ||
|
|
||
| //rollback the version | ||
| _tokenURICurrentVersion[tokenId]--; | ||
| emit MetadataUpdate(tokenId); | ||
| return true; | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.