From 87e5ad795d83c1595538aa8146574db7c624747a Mon Sep 17 00:00:00 2001 From: Junkil Park Date: Mon, 18 Apr 2022 15:39:36 -0700 Subject: [PATCH] [evm] Added ERC721 and ERC1155 Move contracts to the hardhat project (#52) - Added the Move contracts ERC721Mock and ERC1155Mock - Added the Solidity contracts ERC721Mock_Sol and ERC1155Mock_Sol - Checked that they all passed the Openzepplin testsuite - Ignored .js files in the broken link test --- .github/workflows/ci-test.yml | 4 +- .../contracts/ERC1155Mock/Move.toml | 11 + .../ERC1155Mock/sources/ERC1155Mock.move | 394 ++++++++ .../contracts/ERC1155Mock_Sol.sol | 51 + .../contracts/ERC721Mock/Move.toml | 11 + .../ERC721Mock/sources/ERC721Mock.move | 361 +++++++ .../contracts/ERC721Mock_Sol.sol | 41 + .../contracts/ERC721ReceiverMock.sol | 6 + .../hardhat-examples/test/ERC1155.behavior.js | 776 ++++++++++++++ .../evm/hardhat-examples/test/ERC1155.test.js | 264 +++++ .../hardhat-examples/test/ERC1155_Sol.test.js | 264 +++++ .../hardhat-examples/test/ERC721.behavior.js | 947 ++++++++++++++++++ .../evm/hardhat-examples/test/ERC721.test.js | 18 + .../hardhat-examples/test/ERC721_Sol.test.js | 18 + .../test/SupportsInterface.behavior.js | 148 +++ 15 files changed, 3312 insertions(+), 2 deletions(-) create mode 100644 language/evm/hardhat-examples/contracts/ERC1155Mock/Move.toml create mode 100644 language/evm/hardhat-examples/contracts/ERC1155Mock/sources/ERC1155Mock.move create mode 100644 language/evm/hardhat-examples/contracts/ERC1155Mock_Sol.sol create mode 100644 language/evm/hardhat-examples/contracts/ERC721Mock/Move.toml create mode 100644 language/evm/hardhat-examples/contracts/ERC721Mock/sources/ERC721Mock.move create mode 100644 language/evm/hardhat-examples/contracts/ERC721Mock_Sol.sol create mode 100644 language/evm/hardhat-examples/test/ERC1155.behavior.js create mode 100644 language/evm/hardhat-examples/test/ERC1155.test.js create mode 100644 language/evm/hardhat-examples/test/ERC1155_Sol.test.js create mode 100644 language/evm/hardhat-examples/test/ERC721.behavior.js create mode 100644 language/evm/hardhat-examples/test/ERC721.test.js create mode 100644 language/evm/hardhat-examples/test/ERC721_Sol.test.js create mode 100644 language/evm/hardhat-examples/test/SupportsInterface.behavior.js diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 3e33e4ee9b..4856ebb39c 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -201,6 +201,6 @@ jobs: - name: Run Checks run: | gem install awesome_bot - # Don't look in git or target dirs. Don't check png, bib, tex, or shell files + # Don't look in git or target dirs. Don't check png, bib, tex, js, or shell files # We allow links to be redirects, allow duplicates, and we also allow Too Many Requests (429) errors - find . -not \( -path "./.git*" -prune \) -not \( -path "./target" -prune \) -type f -not -name "*.png" -not -name "*.sh" -not -name "*.bib" -not -name "*.tex" -not -name "hardhat.config.js" | while read arg; do awesome_bot --allow-redirect --allow-dupe --allow 429 --skip-save-results $arg; done + find . -not \( -path "./.git*" -prune \) -not \( -path "./target" -prune \) -type f -not -name "*.png" -not -name "*.sh" -not -name "*.bib" -not -name "*.tex" -not -name "*.js" | while read arg; do awesome_bot --allow-redirect --allow-dupe --allow 429 --skip-save-results $arg; done diff --git a/language/evm/hardhat-examples/contracts/ERC1155Mock/Move.toml b/language/evm/hardhat-examples/contracts/ERC1155Mock/Move.toml new file mode 100644 index 0000000000..951ab4328c --- /dev/null +++ b/language/evm/hardhat-examples/contracts/ERC1155Mock/Move.toml @@ -0,0 +1,11 @@ +[package] +name = "ERC1155Mock" +version = "0.0.0" + +[addresses] +Std = "0x1" +Evm = "0x2" + +[dependencies] +EvmStdlib = { local = "../../../stdlib" } +MoveStdlib = { local = "../../../../move-stdlib" } diff --git a/language/evm/hardhat-examples/contracts/ERC1155Mock/sources/ERC1155Mock.move b/language/evm/hardhat-examples/contracts/ERC1155Mock/sources/ERC1155Mock.move new file mode 100644 index 0000000000..9b421e6f3f --- /dev/null +++ b/language/evm/hardhat-examples/contracts/ERC1155Mock/sources/ERC1155Mock.move @@ -0,0 +1,394 @@ +#[contract] +/// An implementation of the ERC-1155 Multi Token Standard. +module Evm::ERC1155Mock { + use Evm::Evm::{sender, self, sign, emit, isContract, abort_with, require}; + use Evm::Table::{Self, Table}; + use Evm::ExternalResult::{Self, ExternalResult}; + use Evm::U256::{Self, U256}; + use Std::Vector; + + // --------------------- + // For test only + // --------------------- + + #[callable(sig=b"setURI(string)")] + public fun setURI(newuri: vector) acquires State { + borrow_global_mut(self()).uri = newuri; + } + + #[callable(sig=b"mint(address,uint256,uint256,bytes)")] + public fun mint(to: address, id: U256, amount: U256, data: vector) acquires State { + mint_(to, id, amount, data); + } + + #[callable(sig=b"mintBatch(address,uint256[],uint256[],bytes)")] + public fun mintBatch(to: address, ids: vector, amounts: vector, data: vector) acquires State { + mintBatch_(to, ids, amounts, data); + } + + #[callable(sig=b"burn(address,uint256,uint256)")] + public fun burn(owner: address, id: U256, amount: U256) acquires State { + burn_(owner, id, amount); + } + + #[callable(sig=b"burnBatch(address,uint256[],uint256[])")] + public fun burnBatch(owner: address, ids: vector, amounts: vector) acquires State { + burnBatch_(owner, ids, amounts); + } + + + // --------------------- + // Evm::IERC1155Receiver + // --------------------- + + #[external(sig=b"onERC1155Received(address,address,uint256,uint256,bytes) returns (bytes4)")] + public native fun IERC1155Receiver_try_call_onERC1155Received(contract: address, operator: address, from: address, id: U256, amount: U256, bytes: vector): ExternalResult>; + + #[external(sig=b"onERC1155BatchReceived(address,address,uint256[],uint256[],bytes) returns (bytes4)")] + public native fun IERC1155Receiver_try_call_onERC1155BatchReceived(contract: address, operator: address, from: address, ids: vector, amounts: vector, bytes: vector): ExternalResult>; + + /// Return the selector of the function `onERC1155Received` + public fun IERC1155Receiver_selector_onERC1155Received(): vector { + //bytes4(keccak256(b"onERC1155Received(address,address,uint256,uint256,bytes)")) + x"f23a6e61" + } + + /// Return the selector of the function `onERC1155Received` + public fun IERC1155Receiver_selector_onERC1155BatchReceived(): vector { + //bytes4(keccak256(b"onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)")) + x"bc197c81" + } + + /// Return the interface identifier of this interface. + public fun IERC1155Receiver_interfaceId(): vector { + // TODO: Eager evaulate this at the compile time for optimization. + // bytes_xor( + // IERC1155Receiver_selector_onERC1155Received(), + // IERC1155Receiver_selector_onERC1155BatchReceived() + // ) + //x"4e2312e0" + x"4e2312e1" // TODO: wrong value + } + + // --------------------- + // Evm::IERC165 + // --------------------- + public fun IERC165_interfaceId(): vector { + // TODO: Eager evaulate this at the compile time for optimization. + //bytes4(keccak256(b"supportsInterface(bytes4)")) + x"01ffc9a7" + } + + // --------------------- + // Evm::IERC1155 + // --------------------- + public fun IERC1155_interfaceId(): vector { + // TODO: Eager evaulate this at the compile time for optimization. + //bytes4(keccak256(b"supportsInterface(bytes4)")) + x"d9b67a26" + } + + #[event] + struct TransferSingle { + operator: address, + from: address, + to: address, + id: U256, + value: U256, + } + + #[event] + struct TransferBatch { + operator: address, + from: address, + to: address, + ids: vector, + values: vector, + } + + #[event] + struct ApprovalForAll { + account: address, + operator: address, + approved: bool, + } + + #[event] + struct URI { + value: vector, + id: U256, + } + + #[storage] + /// Represents the state of this contract. This is located at `borrow_global(self())`. + struct State has key { + balances: Table>, + operatorApprovals: Table>, + uri: vector, + owner: address, // Implements the "ownable" pattern. + } + + #[create(sig=b"constructor(string)")] + /// Constructor of this contract. + public fun create(uri: vector) acquires State { + // Initial state of contract + move_to( + &sign(self()), + State { + balances: Table::empty>(), + operatorApprovals: Table::empty>(), + uri, + owner: sender(), + } + ); + // for deployment test only + mint(sender(), U256::u256_from_u128(1), U256::u256_from_u128(10), b""); + mint(sender(), U256::u256_from_u128(2), U256::u256_from_u128(100), b""); + mint(sender(), U256::u256_from_u128(3), U256::u256_from_u128(1000), b""); + } + + #[callable(sig=b"uri(uint256) returns (string)"), view] + /// Returns the name of the token + public fun uri(_id: U256): vector acquires State { + *&borrow_global(self()).uri + } + + #[callable(sig=b"balanceOf(address,uint256) returns (uint256)"), view] + /// Get the balance of an account's token. + public fun balanceOf(account: address, id: U256): U256 acquires State { + require(account != @0x0, b"ERC1155: balance query for the zero address"); + let s = borrow_global_mut(self()); + *mut_balanceOf(s, id, account) + } + + #[callable(sig=b"balanceOfBatch(address[],uint256[]) returns (uint256[])"), view] + /// Get the balance of multiple account/token pairs. + public fun balanceOfBatch(accounts: vector
, ids: vector): vector acquires State { + require(Vector::length(&accounts) == Vector::length(&ids), b"ERC1155: accounts and ids length mismatch"); + let len = Vector::length(&accounts); + let i = 0; + let balances = Vector::empty(); + while(i < len) { + Vector::push_back( + &mut balances, + balanceOf( + *Vector::borrow(&accounts, i), + *Vector::borrow(&ids, i) + ) + ); + i = i + 1; + }; + balances + } + + #[callable(sig=b"setApprovalForAll(address,bool)")] + /// Enable or disable approval for a third party ("operator") to manage all of the caller's tokens. + public fun setApprovalForAll(operator: address, approved: bool) acquires State { + let owner = sender(); + require(owner != operator, b"ERC1155: setting approval status for self"); + let s = borrow_global_mut(self()); + let operatorApproval = mut_operatorApprovals(s, owner, operator); + *operatorApproval = approved; + emit(ApprovalForAll{account: owner, operator, approved}); + } + + #[callable(sig=b"isApprovedForAll(address,address) returns (bool)"), view] + /// Queries the approval status of an operator for a given owner. + public fun isApprovedForAll(account: address, operator: address): bool acquires State { + let s = borrow_global_mut(self()); + *mut_operatorApprovals(s, account, operator) + } + + #[callable(sig=b"safeTransferFrom(address,address,uint256,uint256,bytes)")] + /// Transfers `_value` amount of an `_id` from the `_from` address to the `_to` address specified (with safety call). + public fun safeTransferFrom(from: address, to: address, id: U256, amount: U256, data: vector) acquires State { + require(to != @0x0, b"ERC1155: transfer to the zero address"); + require(from == sender() || isApprovedForAll(from, sender()), b"ERC1155: caller is not owner nor approved"); + let s = borrow_global_mut(self()); + let mut_balance_from = mut_balanceOf(s, copy id, from); + require(U256::le(copy amount, *mut_balance_from), b"ERC1155: insufficient balance for transfer"); + *mut_balance_from = U256::sub(*mut_balance_from, copy amount); + let mut_balance_to = mut_balanceOf(s, copy id, to); + *mut_balance_to = U256::add(*mut_balance_to, copy amount); + let operator = sender(); + + emit(TransferSingle{operator, from, to, id: copy id, value: copy amount}); + + doSafeTransferAcceptanceCheck(operator, from, to, id, amount, data); + } + + #[callable(sig=b"safeBatchTransferFrom(address,address,uint256[],uint256[],bytes)")] + /// Transfers `_value` amount of an `_id` from the `_from` address to the `_to` address specified (with safety call). + public fun safeBatchTransferFrom(from: address, to: address, ids: vector, amounts: vector, data: vector) acquires State { + require(to != @0x0, b"ERC1155: transfer to the zero address"); + require(from == sender() || isApprovedForAll(from, sender()), b"ERC1155: transfer caller is not owner nor approved"); + require(Vector::length(&amounts) == Vector::length(&ids), b"ERC1155: ids and amounts length mismatch"); + let len = Vector::length(&amounts); + let i = 0; + + let operator = sender(); + let s = borrow_global_mut(self()); + + while(i < len) { + let id = *Vector::borrow(&ids, i); + let amount = *Vector::borrow(&amounts, i); + + let mut_balance_from = mut_balanceOf(s, copy id, from); + require(U256::le(copy amount, *mut_balance_from), b"ERC1155: insufficient balance for transfer"); + *mut_balance_from = U256::sub(*mut_balance_from, copy amount); + let mut_balance_to = mut_balanceOf(s, id, to); + *mut_balance_to = U256::add(*mut_balance_to, amount); + + i = i + 1; + }; + + emit(TransferBatch{operator, from, to, ids: copy ids, values: copy amounts}); + + doSafeBatchTransferAcceptanceCheck(operator, from, to, ids, amounts, data); + } + + #[callable(sig=b"supportsInterface(bytes4) returns (bool)"), view] + // Query if this contract implements a certain interface. + public fun supportsInterface(interfaceId: vector): bool { + interfaceId == IERC165_interfaceId() || interfaceId == IERC1155_interfaceId() + } + + #[callable(sig=b"owner() returns (address)"), view] + public fun owner(): address acquires State { + borrow_global_mut(self()).owner + } + + // Internal function for minting. + fun mint_(to: address, id: U256, amount: U256, _data: vector) acquires State { + require(to != @0x0, b"ERC1155: mint to the zero address"); + let s = borrow_global_mut(self()); + let mut_balance_to = mut_balanceOf(s, copy id, to); + *mut_balance_to = U256::add(*mut_balance_to, copy amount); + emit(TransferSingle{operator: sender(), from: @0x0, to, id: copy id, value: copy amount}); + } + + /// Internal function for mintBatch + fun mintBatch_(to: address, ids: vector, amounts: vector, _data: vector) acquires State { + require(to != @0x0, b"ERC1155: mint to the zero address"); + require(Vector::length(&amounts) == Vector::length(&ids), b"ERC1155: ids and amounts length mismatch"); + let len = Vector::length(&amounts); + let i = 0; + + let s = borrow_global_mut(self()); + + while(i < len) { + let id = *Vector::borrow(&ids, i); + let amount = *Vector::borrow(&amounts, i); + + let mut_balance_to = mut_balanceOf(s, id, to); + *mut_balance_to = U256::add(*mut_balance_to, amount); + + i = i + 1; + }; + emit(TransferBatch{operator: sender(), from: @0x0, to, ids: copy ids, values: copy amounts}); + } + + public fun burn_(owner: address, id: U256, amount: U256) acquires State { + require(owner != @0x0, b"ERC1155: burn from the zero address"); + let s = borrow_global_mut(self()); + let mut_balance_owner = mut_balanceOf(s, id, owner); + require(U256::ge(*mut_balance_owner, amount), b"ERC1155: burn amount exceeds balance"); + *mut_balance_owner = U256::sub(*mut_balance_owner, amount); + emit(TransferSingle{operator: sender(), from: owner, to: @0x0, id, value: amount}); + } + + public fun burnBatch_(owner: address, ids: vector, amounts: vector) acquires State { + require(owner != @0x0, b"ERC1155: burn from the zero address"); + require(Vector::length(&amounts) == Vector::length(&ids), b"ERC1155: ids and amounts length mismatch"); + let len = Vector::length(&amounts); + let i = 0; + let s = borrow_global_mut(self()); + while(i < len) { + let id = *Vector::borrow(&ids, i); + let amount = *Vector::borrow(&amounts, i); + + let mut_balance_owner = mut_balanceOf(s, id, owner); + require(U256::ge(*mut_balance_owner, amount), b"ERC1155: burn amount exceeds balance"); + *mut_balance_owner = U256::sub(*mut_balance_owner, amount); + + i = i + 1; + }; + emit(TransferBatch{operator: sender(), from: owner, to: @0x0, ids, values: amounts}); + } + + + /// Helper function to return a mut ref to the operatorApproval + fun mut_operatorApprovals(s: &mut State, account: address, operator: address): &mut bool { + if(!Table::contains(&s.operatorApprovals, &account)) { + Table::insert( + &mut s.operatorApprovals, + &account, + Table::empty() + ) + }; + let operatorApproval_account = Table::borrow_mut( + &mut s.operatorApprovals, + &account + ); + Table::borrow_mut_with_default(operatorApproval_account, &operator, false) + } + + /// Helper function to return a mut ref to the balance of a owner. + fun mut_balanceOf(s: &mut State, id: U256, account: address): &mut U256 { + if(!Table::contains(&s.balances, &id)) { + Table::insert( + &mut s.balances, + &id, + Table::empty() + ) + }; + let balances_id = Table::borrow_mut(&mut s.balances, &id); + Table::borrow_mut_with_default(balances_id, &account, U256::zero()) + } + + /// Helper function for the safe transfer acceptance check. + fun doSafeTransferAcceptanceCheck(operator: address, from: address, to: address, id: U256, amount: U256, data: vector) { + if (isContract(to)) { + let result = IERC1155Receiver_try_call_onERC1155Received(to, operator, from, id, amount, data); + if (ExternalResult::is_err_reason(&result)) { + // abort_with(b"err_reason"); + let reason = ExternalResult::unwrap_err_reason(result); + abort_with(reason); + } else if (ExternalResult::is_err_data(&result)) { + abort_with(b"ERC1155: transfer to non ERC1155Receiver implementer"); + } else if (ExternalResult::is_panic(&result)) { + abort_with(b"panic"); + } else if (ExternalResult::is_ok(&result)) { + // abort_with(b"ok"); + let retval = ExternalResult::unwrap(result); + let expected = IERC1155Receiver_selector_onERC1155Received(); + require(retval == expected, b"ERC1155: ERC1155Receiver rejected tokens"); + } else { + abort_with(b"other"); + } + } + } + + /// Helper function for the safe batch transfer acceptance check. + fun doSafeBatchTransferAcceptanceCheck(operator: address, from: address, to: address, ids: vector, amounts: vector, data: vector) { + if (isContract(to)) { + let result = IERC1155Receiver_try_call_onERC1155BatchReceived(to, operator, from, ids, amounts, data); + if (ExternalResult::is_err_reason(&result)) { + // abort_with(b"err_reason"); + let reason = ExternalResult::unwrap_err_reason(result); + abort_with(reason); + } else if (ExternalResult::is_err_data(&result)) { + abort_with(b"ERC1155: transfer to non ERC1155Receiver implementer"); + } else if (ExternalResult::is_panic(&result)) { + abort_with(b"panic"); + } else if (ExternalResult::is_ok(&result)) { + // abort_with(b"ok"); + let retval = ExternalResult::unwrap(result); + let expected = IERC1155Receiver_selector_onERC1155BatchReceived(); + require(retval == expected, b"ERC1155: ERC1155Receiver rejected tokens"); + } else { + abort_with(b"other"); + } + } + } +} diff --git a/language/evm/hardhat-examples/contracts/ERC1155Mock_Sol.sol b/language/evm/hardhat-examples/contracts/ERC1155Mock_Sol.sol new file mode 100644 index 0000000000..300a460e0d --- /dev/null +++ b/language/evm/hardhat-examples/contracts/ERC1155Mock_Sol.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; + +/** + * @title ERC1155Mock + * This mock just publicizes internal functions for testing purposes + */ +contract ERC1155Mock_Sol is ERC1155 { + constructor(string memory uri) ERC1155(uri) {} + + function setURI(string memory newuri) public { + _setURI(newuri); + } + + function mint( + address to, + uint256 id, + uint256 value, + bytes memory data + ) public { + _mint(to, id, value, data); + } + + function mintBatch( + address to, + uint256[] memory ids, + uint256[] memory values, + bytes memory data + ) public { + _mintBatch(to, ids, values, data); + } + + function burn( + address owner, + uint256 id, + uint256 value + ) public { + _burn(owner, id, value); + } + + function burnBatch( + address owner, + uint256[] memory ids, + uint256[] memory values + ) public { + _burnBatch(owner, ids, values); + } +} diff --git a/language/evm/hardhat-examples/contracts/ERC721Mock/Move.toml b/language/evm/hardhat-examples/contracts/ERC721Mock/Move.toml new file mode 100644 index 0000000000..4c4de09a8f --- /dev/null +++ b/language/evm/hardhat-examples/contracts/ERC721Mock/Move.toml @@ -0,0 +1,11 @@ +[package] +name = "ERC721Mock" +version = "0.0.0" + +[addresses] +Std = "0x1" +Evm = "0x2" + +[dependencies] +EvmStdlib = { local = "../../../stdlib" } +MoveStdlib = { local = "../../../../move-stdlib" } diff --git a/language/evm/hardhat-examples/contracts/ERC721Mock/sources/ERC721Mock.move b/language/evm/hardhat-examples/contracts/ERC721Mock/sources/ERC721Mock.move new file mode 100644 index 0000000000..10ce147e99 --- /dev/null +++ b/language/evm/hardhat-examples/contracts/ERC721Mock/sources/ERC721Mock.move @@ -0,0 +1,361 @@ +#[contract] +/// An implementation of the ERC-721 Non-Fungible Token Standard. +module Evm::ERC721 { + use Evm::Evm::{sender, self, sign, emit, isContract, tokenURI_with_baseURI, require, abort_with}; + use Evm::ExternalResult::{Self, ExternalResult}; + use Evm::Table::{Self, Table}; + use Evm::U256::{Self, U256}; + use Std::Vector; + + // --------------------- + // Evm::IERC165 + // --------------------- + public fun IERC165_interfaceId(): vector { + // TODO: Eager evaulate this at the compile time for optimization. + //bytes4(keccak256(b"supportsInterface(bytes4)")) + x"01ffc9a7" + } + + // --------------------- + // Evm::IERC721 + // --------------------- + public fun IERC721_interfaceId(): vector { + x"80ac58cd" + } + + // --------------------- + // Evm::IERC721Metadata + // --------------------- + public fun IERC721Metadata_interfaceId(): vector { + x"5b5e139f" + } + + // --------------------- + // Evm::IERC721Receiver + // --------------------- + public fun IERC721Receiver_selector_onERC721Received(): vector { + x"150b7a02" + } + + // --------------------- + // For test only + // --------------------- + + #[callable] + public fun mint(to: address, tokenId: U256) acquires State { + mint_(to, tokenId); + } + + #[callable(sig=b"safeMint(address,uint256)")] + fun safeMint(to: address, tokenId: U256) acquires State { + safeMint_(to, tokenId, b""); + } + + #[callable(sig=b"safeMint(address,uint256,bytes)")] + fun safeMint_with_data(to: address, tokenId: U256, data: vector) acquires State { + safeMint_(to, tokenId, data); + } + + fun mint_(to: address, tokenId: U256) acquires State { + require(to != @0x0, b"ERC721: mint to the zero address"); + require(!exists_(tokenId), b"ERC721: token already minted"); + + let s = borrow_global_mut(self()); + let mut_balance_to = mut_balanceOf(s, to); + *mut_balance_to = U256::add(*mut_balance_to, U256::one()); + + let mut_ownerOf_to = mut_ownerOf(s, tokenId); + *mut_ownerOf_to = to; + + emit(Transfer{from: @0x0, to, tokenId}); + } + + fun safeMint_(to: address, tokenId: U256, data: vector) acquires State { + mint_(to, tokenId); + doSafeTransferAcceptanceCheck(@0x0, to, tokenId, data); + } + + #[callable] + public fun burn(tokenId: U256) acquires State { + burn_(tokenId); + } + + fun burn_(tokenId: U256) acquires State { + let owner = ownerOf(tokenId); + approve(@0x0, tokenId); + let s = borrow_global_mut(self()); + let mut_balance_owner = mut_balanceOf(s, owner); + *mut_balance_owner = U256::sub(*mut_balance_owner, U256::one()); + let _ = Table::remove(&mut s.owners, &tokenId); + emit(Transfer{from: owner, to: @0x0, tokenId}); + } + + fun exists_(tokenId: U256): bool acquires State { + let s = borrow_global_mut(self()); + tokenExists(s, tokenId) + } + + // Disabled this for a fair gas comparison with `ERC721Mock_Sol.sol` + // which does not support `setBaseURI`. + // #[callable(sig=b"setBaseURI(string)")] + // public fun setBaseURI(newBaseURI: vector) acquires State { + // let s = borrow_global_mut(self()); + // s.baseURI = newBaseURI; + // } + + #[callable(sig=b"baseURI() returns (string)"), view] + public fun baseURI(): vector acquires State { + let s = borrow_global_mut(self()); + s.baseURI + } + + #[event] + struct Transfer { + from: address, + to: address, + tokenId: U256, + } + + #[event] + struct Approval { + owner: address, + approved: address, + tokenId: U256, + } + + #[event] + struct ApprovalForAll { + owner: address, + operator: address, + approved: bool, + } + + #[storage] + /// Represents the state of this contract. This is located at `borrow_global(self())`. + struct State has key { + name: vector, + symbol: vector, + owners: Table, + balances: Table, + tokenApprovals: Table, + operatorApprovals: Table>, + baseURI: vector, + } + + #[create(sig=b"constructor(string,string)")] + /// Constructor of this contract. + public fun create(name: vector, symbol: vector) { + // Initial state of contract + move_to( + &sign(self()), + State { + name, + symbol, + owners: Table::empty(), + balances: Table::empty(), + tokenApprovals: Table::empty(), + operatorApprovals: Table::empty>(), + baseURI: b"", + } + ); + } + + #[callable(sig=b"supportsInterface(bytes4) returns (bool)"), pure] + // Query if this contract implements a certain interface. + public fun supportsInterface(interfaceId: vector): bool { + interfaceId == IERC165_interfaceId() || + interfaceId == IERC721_interfaceId() || + interfaceId == IERC721Metadata_interfaceId() + } + + #[callable(sig=b"name() returns (string)"), view] + /// Get the name. + public fun name(): vector acquires State { + let s = borrow_global(self()); + *&s.name + } + + #[callable(sig=b"symbol() returns (string)"), view] + /// Get the symbol. + public fun symbol(): vector acquires State { + let s = borrow_global(self()); + *&s.symbol + } + + #[callable(sig=b"tokenURI(uint256) returns (string)"), view] + /// Get the name. + public fun tokenURI(tokenId: U256): vector acquires State { + require(exists_(tokenId), b"ERC721Metadata: URI query for nonexistent token"); + //let baseURI = b""; + tokenURI_with_baseURI(baseURI(), tokenId) + //b"" + } + + #[callable(sig=b"balanceOf(address) returns (uint256)"), view] + /// Count all NFTs assigned to an owner. + public fun balanceOf(owner: address): U256 acquires State { + require(owner != @0x0, b"ERC721: balance query for the zero address"); + let s = borrow_global_mut(self()); + *mut_balanceOf(s, owner) + } + + #[callable(sib=b"ownerOf(uint256) returns (address)"), view] + /// Find the owner of an NFT. + public fun ownerOf(tokenId: U256): address acquires State { + require(exists_(tokenId), b"ERC721: owner query for nonexistent token"); + let s = borrow_global_mut(self()); + *mut_ownerOf(s, tokenId) + } + + #[callable(sig=b"safeTransferFrom(address,address,uint256,bytes)")] // Overloading `safeTransferFrom` + /// Transfers the ownership of an NFT from one address to another address. + public fun safeTransferFrom_with_data(from: address, to: address, tokenId: U256, data: vector) acquires State { + transferFrom(from, to, tokenId); + doSafeTransferAcceptanceCheck(from, to, tokenId, data); + } + + #[callable(sig=b"safeTransferFrom(address,address,uint256)")] + /// Transfers the ownership of an NFT from one address to another address. + public fun safeTransferFrom(from: address, to: address, tokenId: U256) acquires State { + safeTransferFrom_with_data(from, to, tokenId, b""); + } + + #[callable] + /// Transfer ownership of an NFT. THE CALLER IS RESPONSIBLE + /// TO CONFIRM THAT `_to` IS CAPABLE OF RECEIVING NFTS OR ELSE + /// THEY MAY BE PERMANENTLY LOST + public fun transferFrom(from: address, to: address, tokenId: U256) acquires State { + require(isApprovedOrOwner(sender(), tokenId), b"ERC721: transfer caller is not owner nor approved"); + + require(ownerOf(tokenId) == from, b"ERC721: transfer from incorrect owner"); + require(to != @0x0, b"ERC721: transfer to the zero address"); + + // Clear approvals from the previous owner + approve_(@0x0, tokenId); + + let s = borrow_global_mut(self()); + + let mut_balance_from = mut_balanceOf(s, from); + *mut_balance_from = U256::sub(*mut_balance_from, U256::one()); + + let mut_balance_to = mut_balanceOf(s, to); + *mut_balance_to = U256::add(*mut_balance_to, U256::one()); + + let mut_owner_token = mut_ownerOf(s, tokenId); + *mut_owner_token = to; + + emit(Transfer{from, to, tokenId}); + } + + #[callable] + /// Change or reaffirm the approved address for an NFT. + public fun approve(approved: address, tokenId: U256) acquires State { + let owner = ownerOf(tokenId); + require(approved != owner, b"ERC721: approval to current owner"); + require((sender() == owner) || isApprovedForAll(owner, sender()), b"ERC721: approve caller is not owner nor approved for all"); + approve_(approved, tokenId); + } + + fun approve_(approved: address, tokenId: U256) acquires State { + let s = borrow_global_mut(self()); + *mut_tokenApproval(s, tokenId) = approved; + emit(Approval{owner: ownerOf(tokenId), approved, tokenId}) + } + + #[callable] + /// Enable or disable approval for a third party ("operator") to manage + /// all of the sender's assets. + public fun setApprovalForAll(operator: address, approved: bool) acquires State { + setApprovalForAll_(sender(), operator, approved); + } + + fun setApprovalForAll_(owner: address, operator: address, approved: bool) acquires State { + require(owner != operator, b"ERC721: approve to caller"); + let s = borrow_global_mut(self()); + *mut_operatorApproval(s, owner, operator) = approved; + emit(ApprovalForAll{owner, operator, approved}) + } + + #[callable, view] + /// Get the approved address for a single NFT. + public fun getApproved(tokenId: U256): address acquires State { + let s = borrow_global_mut(self()); + require(tokenExists(s, tokenId), b"ERC721: approved query for nonexistent token"); + *mut_tokenApproval(s, tokenId) + } + + #[callable, view] + /// Query if an address is an authorized operator for another address. + public fun isApprovedForAll(owner: address, operator: address): bool acquires State { + let s = borrow_global_mut(self()); + *mut_operatorApproval(s, owner, operator) + } + + /// Helper function to return true iff `spender` is the owner or an approved one for `tokenId`. + fun isApprovedOrOwner(spender: address, tokenId: U256): bool acquires State { + let s = borrow_global_mut(self()); + require(tokenExists(s, tokenId), b"ERC721: operator query for nonexistent token"); + let owner = ownerOf(tokenId); + return (spender == owner || getApproved(tokenId) == spender || isApprovedForAll(owner, spender)) + } + + /// Helper function to return a mut ref to the balance of a owner. + fun mut_balanceOf(s: &mut State, owner: address): &mut U256 { + Table::borrow_mut_with_default(&mut s.balances, &owner, U256::zero()) + } + + /// Helper function to return a mut ref to the balance of a owner. + fun mut_ownerOf(s: &mut State, tokenId: U256): &mut address { + Table::borrow_mut_with_default(&mut s.owners, &tokenId, @0x0) + } + + /// Helper function to return a mut ref to the balance of a owner. + fun mut_tokenApproval(s: &mut State, tokenId: U256): &mut address { + Table::borrow_mut_with_default(&mut s.tokenApprovals, &tokenId, @0x0) + } + + /// Helper function to return a mut ref to the operator approval. + fun mut_operatorApproval(s: &mut State, owner: address, operator: address): &mut bool { + if(!Table::contains(&s.operatorApprovals, &owner)) { + Table::insert( + &mut s.operatorApprovals, + &owner, + Table::empty() + ) + }; + let approvals = Table::borrow_mut(&mut s.operatorApprovals, &owner); + Table::borrow_mut_with_default(approvals, &operator, false) + } + + /// Helper function to return true iff the token exists. + fun tokenExists(s: &mut State, tokenId: U256): bool { + let mut_ownerOf_tokenId = mut_ownerOf(s, tokenId); + *mut_ownerOf_tokenId != @0x0 + } + + /// Helper function for the acceptance check. + fun doSafeTransferAcceptanceCheck(from: address, to: address, tokenId: U256, data: vector) { + if (isContract(to)) { + let result = IERC721Receiver_try_call_onERC721Received(to, sender(), from, tokenId, data); + if (ExternalResult::is_err_reason(&result)) { + // abort_with(b"err_reason"); + let reason = ExternalResult::unwrap_err_reason(result); + abort_with(reason); + } else if (ExternalResult::is_err_data(&result)) { + abort_with(b"ERC721: transfer to non ERC721Receiver implementer"); + } else if (ExternalResult::is_panic(&result)) { + abort_with(b"panic"); + } else if (ExternalResult::is_ok(&result)) { + // abort_with(b"ok"); + let retval = ExternalResult::unwrap(result); + let expected = IERC721Receiver_selector_onERC721Received(); + require(retval == expected, b"ERC721: transfer to non ERC721Receiver implementer"); + } else { + abort_with(b"other"); + } + } + } + + #[external(sig=b"onERC721Received(address,address,uint256,bytes) returns (bytes4)")] + public native fun IERC721Receiver_try_call_onERC721Received(contract: address, operator: address, from: address, tokenId: U256, bytes: vector): ExternalResult>; +} diff --git a/language/evm/hardhat-examples/contracts/ERC721Mock_Sol.sol b/language/evm/hardhat-examples/contracts/ERC721Mock_Sol.sol new file mode 100644 index 0000000000..e4c7f03cb4 --- /dev/null +++ b/language/evm/hardhat-examples/contracts/ERC721Mock_Sol.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +/** + * @title ERC721Mock + * This mock just provides a public safeMint, mint, and burn functions for testing purposes + */ +contract ERC721Mock_Sol is ERC721 { + constructor(string memory name, string memory symbol) ERC721(name, symbol) {} + + function baseURI() public view returns (string memory) { + return _baseURI(); + } + + function exists(uint256 tokenId) public view returns (bool) { + return _exists(tokenId); + } + + function mint(address to, uint256 tokenId) public { + _mint(to, tokenId); + } + + function safeMint(address to, uint256 tokenId) public { + _safeMint(to, tokenId); + } + + function safeMint( + address to, + uint256 tokenId, + bytes memory _data + ) public { + _safeMint(to, tokenId, _data); + } + + function burn(uint256 tokenId) public { + _burn(tokenId); + } +} diff --git a/language/evm/hardhat-examples/contracts/ERC721ReceiverMock.sol b/language/evm/hardhat-examples/contracts/ERC721ReceiverMock.sol index 65ffbce2f9..e1885b045e 100644 --- a/language/evm/hardhat-examples/contracts/ERC721ReceiverMock.sol +++ b/language/evm/hardhat-examples/contracts/ERC721ReceiverMock.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import "hardhat/console.sol"; + contract ERC721ReceiverMock is IERC721Receiver { enum Error { @@ -29,14 +31,18 @@ contract ERC721ReceiverMock is IERC721Receiver { bytes memory data ) public override returns (bytes4) { if (_error == Error.RevertWithMessage) { + // console.log('RevertWithMessage'); revert("ERC721ReceiverMock: reverting"); } else if (_error == Error.RevertWithoutMessage) { + // console.log('RevertWithoutMessage'); revert(); } else if (_error == Error.Panic) { + // console.log('Panic'); uint256 a = uint256(0) / uint256(0); a; } emit Received(operator, from, tokenId, data, gasleft()); + //console.log('Ok'); return _retval; } } diff --git a/language/evm/hardhat-examples/test/ERC1155.behavior.js b/language/evm/hardhat-examples/test/ERC1155.behavior.js new file mode 100644 index 0000000000..7f9d595d4b --- /dev/null +++ b/language/evm/hardhat-examples/test/ERC1155.behavior.js @@ -0,0 +1,776 @@ +const { BN, constants, expectEvent, expectRevert } = require('@openzeppelin/test-helpers'); +const { ZERO_ADDRESS } = constants; + +const { expect } = require('chai'); + +const { shouldSupportInterfaces } = require('./SupportsInterface.behavior'); + +const ERC1155ReceiverMock = artifacts.require('ERC1155ReceiverMock'); + +function shouldBehaveLikeERC1155 ([minter, firstTokenHolder, secondTokenHolder, multiTokenHolder, recipient, proxy]) { + const firstTokenId = new BN(1); + const secondTokenId = new BN(2); + const unknownTokenId = new BN(3); + + const firstAmount = new BN(1000); + const secondAmount = new BN(2000); + + const RECEIVER_SINGLE_MAGIC_VALUE = '0xf23a6e61'; + const RECEIVER_BATCH_MAGIC_VALUE = '0xbc197c81'; + + describe('like an ERC1155', function () { + describe('balanceOf', function () { + it('reverts when queried about the zero address', async function () { + await expectRevert( + this.token.balanceOf(ZERO_ADDRESS, firstTokenId), +// 'ERC1155: address zero is not a valid owner', + "VM Exception while processing transaction: reverted with reason string 'ERC1155: balance query for the zero address'", + ); + }); + + context('when accounts don\'t own tokens', function () { + it('returns zero for given addresses', async function () { + expect(await this.token.balanceOf( + firstTokenHolder, + firstTokenId, + )).to.be.bignumber.equal('0'); + + expect(await this.token.balanceOf( + secondTokenHolder, + secondTokenId, + )).to.be.bignumber.equal('0'); + + expect(await this.token.balanceOf( + firstTokenHolder, + unknownTokenId, + )).to.be.bignumber.equal('0'); + }); + }); + + context('when accounts own some tokens', function () { + beforeEach(async function () { + await this.token.mint(firstTokenHolder, firstTokenId, firstAmount, '0x', { + from: minter, + }); + await this.token.mint( + secondTokenHolder, + secondTokenId, + secondAmount, + '0x', + { + from: minter, + }, + ); + }); + + it('returns the amount of tokens owned by the given addresses', async function () { + expect(await this.token.balanceOf( + firstTokenHolder, + firstTokenId, + )).to.be.bignumber.equal(firstAmount); + + expect(await this.token.balanceOf( + secondTokenHolder, + secondTokenId, + )).to.be.bignumber.equal(secondAmount); + + expect(await this.token.balanceOf( + firstTokenHolder, + unknownTokenId, + )).to.be.bignumber.equal('0'); + }); + }); + }); + + describe('balanceOfBatch', function () { + it('reverts when input arrays don\'t match up', async function () { + await expectRevert( + this.token.balanceOfBatch( + [firstTokenHolder, secondTokenHolder, firstTokenHolder, secondTokenHolder], + [firstTokenId, secondTokenId, unknownTokenId], + ), + 'ERC1155: accounts and ids length mismatch', + ); + + await expectRevert( + this.token.balanceOfBatch( + [firstTokenHolder, secondTokenHolder], + [firstTokenId, secondTokenId, unknownTokenId], + ), + 'ERC1155: accounts and ids length mismatch', + ); + }); + + it('reverts when one of the addresses is the zero address', async function () { + await expectRevert( + this.token.balanceOfBatch( + [firstTokenHolder, secondTokenHolder, ZERO_ADDRESS], + [firstTokenId, secondTokenId, unknownTokenId], + ), +// 'ERC1155: address zero is not a valid owner', + "VM Exception while processing transaction: reverted with reason string 'ERC1155: balance query for the zero address'", + ); + }); + + context('when accounts don\'t own tokens', function () { + it('returns zeros for each account', async function () { + const result = await this.token.balanceOfBatch( + [firstTokenHolder, secondTokenHolder, firstTokenHolder], + [firstTokenId, secondTokenId, unknownTokenId], + ); + expect(result).to.be.an('array'); + expect(result[0]).to.be.a.bignumber.equal('0'); + expect(result[1]).to.be.a.bignumber.equal('0'); + expect(result[2]).to.be.a.bignumber.equal('0'); + }); + }); + + context('when accounts own some tokens', function () { + beforeEach(async function () { + await this.token.mint(firstTokenHolder, firstTokenId, firstAmount, '0x', { + from: minter, + }); + await this.token.mint( + secondTokenHolder, + secondTokenId, + secondAmount, + '0x', + { + from: minter, + }, + ); + }); + + it('returns amounts owned by each account in order passed', async function () { + const result = await this.token.balanceOfBatch( + [secondTokenHolder, firstTokenHolder, firstTokenHolder], + [secondTokenId, firstTokenId, unknownTokenId], + ); + expect(result).to.be.an('array'); + expect(result[0]).to.be.a.bignumber.equal(secondAmount); + expect(result[1]).to.be.a.bignumber.equal(firstAmount); + expect(result[2]).to.be.a.bignumber.equal('0'); + }); + + it('returns multiple times the balance of the same address when asked', async function () { + const result = await this.token.balanceOfBatch( + [firstTokenHolder, secondTokenHolder, firstTokenHolder], + [firstTokenId, secondTokenId, firstTokenId], + ); + expect(result).to.be.an('array'); + expect(result[0]).to.be.a.bignumber.equal(result[2]); + expect(result[0]).to.be.a.bignumber.equal(firstAmount); + expect(result[1]).to.be.a.bignumber.equal(secondAmount); + expect(result[2]).to.be.a.bignumber.equal(firstAmount); + }); + }); + }); + + describe('setApprovalForAll', function () { + let logs; + beforeEach(async function () { + ({ logs } = await this.token.setApprovalForAll(proxy, true, { from: multiTokenHolder })); + }); + + it('sets approval status which can be queried via isApprovedForAll', async function () { + expect(await this.token.isApprovedForAll(multiTokenHolder, proxy)).to.be.equal(true); + }); + + it('emits an ApprovalForAll log', function () { + expectEvent.inLogs(logs, 'ApprovalForAll', { account: multiTokenHolder, operator: proxy, approved: true }); + }); + + it('can unset approval for an operator', async function () { + await this.token.setApprovalForAll(proxy, false, { from: multiTokenHolder }); + expect(await this.token.isApprovedForAll(multiTokenHolder, proxy)).to.be.equal(false); + }); + + it('reverts if attempting to approve self as an operator', async function () { + await expectRevert( + this.token.setApprovalForAll(multiTokenHolder, true, { from: multiTokenHolder }), + 'ERC1155: setting approval status for self', + ); + }); + }); + + describe('safeTransferFrom', function () { + beforeEach(async function () { + await this.token.mint(multiTokenHolder, firstTokenId, firstAmount, '0x', { + from: minter, + }); + await this.token.mint( + multiTokenHolder, + secondTokenId, + secondAmount, + '0x', + { + from: minter, + }, + ); + }); + + it('reverts when transferring more than balance', async function () { + await expectRevert( + this.token.safeTransferFrom( + multiTokenHolder, + recipient, + firstTokenId, + firstAmount.addn(1), + '0x', + { from: multiTokenHolder }, + ), + 'ERC1155: insufficient balance for transfer', + ); + }); + + it('reverts when transferring to zero address', async function () { + await expectRevert( + this.token.safeTransferFrom( + multiTokenHolder, + ZERO_ADDRESS, + firstTokenId, + firstAmount, + '0x', + { from: multiTokenHolder }, + ), + 'ERC1155: transfer to the zero address', + ); + }); + + function transferWasSuccessful ({ operator, from, id, value }) { + it('debits transferred balance from sender', async function () { + const newBalance = await this.token.balanceOf(from, id); + expect(newBalance).to.be.a.bignumber.equal('0'); + }); + + it('credits transferred balance to receiver', async function () { + const newBalance = await this.token.balanceOf(this.toWhom, id); + expect(newBalance).to.be.a.bignumber.equal(value); + }); + + it('emits a TransferSingle log', function () { + expectEvent.inLogs(this.transferLogs, 'TransferSingle', { + operator, + from, + to: this.toWhom, + id, + value, + }); + }); + } + + context('when called by the multiTokenHolder', async function () { + beforeEach(async function () { + this.toWhom = recipient; + ({ logs: this.transferLogs } = + await this.token.safeTransferFrom(multiTokenHolder, recipient, firstTokenId, firstAmount, '0x', { + from: multiTokenHolder, + })); + }); + + transferWasSuccessful.call(this, { + operator: multiTokenHolder, + from: multiTokenHolder, + id: firstTokenId, + value: firstAmount, + }); + + it('preserves existing balances which are not transferred by multiTokenHolder', async function () { + const balance1 = await this.token.balanceOf(multiTokenHolder, secondTokenId); + expect(balance1).to.be.a.bignumber.equal(secondAmount); + + const balance2 = await this.token.balanceOf(recipient, secondTokenId); + expect(balance2).to.be.a.bignumber.equal('0'); + }); + }); + + context('when called by an operator on behalf of the multiTokenHolder', function () { + context('when operator is not approved by multiTokenHolder', function () { + beforeEach(async function () { + await this.token.setApprovalForAll(proxy, false, { from: multiTokenHolder }); + }); + + it('reverts', async function () { + await expectRevert( + this.token.safeTransferFrom(multiTokenHolder, recipient, firstTokenId, firstAmount, '0x', { + from: proxy, + }), + 'ERC1155: caller is not owner nor approved', + ); + }); + }); + + context('when operator is approved by multiTokenHolder', function () { + beforeEach(async function () { + this.toWhom = recipient; + await this.token.setApprovalForAll(proxy, true, { from: multiTokenHolder }); + ({ logs: this.transferLogs } = + await this.token.safeTransferFrom(multiTokenHolder, recipient, firstTokenId, firstAmount, '0x', { + from: proxy, + })); + }); + + transferWasSuccessful.call(this, { + operator: proxy, + from: multiTokenHolder, + id: firstTokenId, + value: firstAmount, + }); + + it('preserves operator\'s balances not involved in the transfer', async function () { + const balance1 = await this.token.balanceOf(proxy, firstTokenId); + expect(balance1).to.be.a.bignumber.equal('0'); + + const balance2 = await this.token.balanceOf(proxy, secondTokenId); + expect(balance2).to.be.a.bignumber.equal('0'); + }); + }); + }); + + context('when sending to a valid receiver', function () { + beforeEach(async function () { + this.receiver = await ERC1155ReceiverMock.new( + RECEIVER_SINGLE_MAGIC_VALUE, false, + RECEIVER_BATCH_MAGIC_VALUE, false, + ); + }); + + context('without data', function () { + beforeEach(async function () { + this.toWhom = this.receiver.address; + this.transferReceipt = await this.token.safeTransferFrom( + multiTokenHolder, + this.receiver.address, + firstTokenId, + firstAmount, + '0x', + { from: multiTokenHolder }, + ); + ({ logs: this.transferLogs } = this.transferReceipt); + }); + + transferWasSuccessful.call(this, { + operator: multiTokenHolder, + from: multiTokenHolder, + id: firstTokenId, + value: firstAmount, + }); + + it('calls onERC1155Received', async function () { + await expectEvent.inTransaction(this.transferReceipt.tx, ERC1155ReceiverMock, 'Received', { + operator: multiTokenHolder, + from: multiTokenHolder, + id: firstTokenId, + value: firstAmount, + data: null, + }); + }); + }); + + context('with data', function () { + const data = '0xf00dd00d'; + beforeEach(async function () { + this.toWhom = this.receiver.address; + this.transferReceipt = await this.token.safeTransferFrom( + multiTokenHolder, + this.receiver.address, + firstTokenId, + firstAmount, + data, + { from: multiTokenHolder }, + ); + ({ logs: this.transferLogs } = this.transferReceipt); + }); + + transferWasSuccessful.call(this, { + operator: multiTokenHolder, + from: multiTokenHolder, + id: firstTokenId, + value: firstAmount, + }); + + it('calls onERC1155Received', async function () { + await expectEvent.inTransaction(this.transferReceipt.tx, ERC1155ReceiverMock, 'Received', { + operator: multiTokenHolder, + from: multiTokenHolder, + id: firstTokenId, + value: firstAmount, + data, + }); + }); + }); + }); + + context('to a receiver contract returning unexpected value', function () { + beforeEach(async function () { + this.receiver = await ERC1155ReceiverMock.new( + '0x00c0ffee', false, + RECEIVER_BATCH_MAGIC_VALUE, false, + ); + }); + + it('reverts', async function () { + await expectRevert( + this.token.safeTransferFrom(multiTokenHolder, this.receiver.address, firstTokenId, firstAmount, '0x', { + from: multiTokenHolder, + }), + 'ERC1155: ERC1155Receiver rejected tokens', + ); + }); + }); + + context('to a receiver contract that reverts', function () { + beforeEach(async function () { + this.receiver = await ERC1155ReceiverMock.new( + RECEIVER_SINGLE_MAGIC_VALUE, true, + RECEIVER_BATCH_MAGIC_VALUE, false, + ); + }); + + it('reverts', async function () { + await expectRevert( + this.token.safeTransferFrom(multiTokenHolder, this.receiver.address, firstTokenId, firstAmount, '0x', { + from: multiTokenHolder, + }), + 'ERC1155ReceiverMock: reverting on receive', + ); + }); + }); + + context('to a contract that does not implement the required function', function () { + it('reverts', async function () { + const invalidReceiver = this.token; + await expectRevert.unspecified( + this.token.safeTransferFrom(multiTokenHolder, invalidReceiver.address, firstTokenId, firstAmount, '0x', { + from: multiTokenHolder, + }), + ); + }); + }); + }); + + describe('safeBatchTransferFrom', function () { + beforeEach(async function () { + await this.token.mint(multiTokenHolder, firstTokenId, firstAmount, '0x', { + from: minter, + }); + await this.token.mint( + multiTokenHolder, + secondTokenId, + secondAmount, + '0x', + { + from: minter, + }, + ); + }); + + it('reverts when transferring amount more than any of balances', async function () { + await expectRevert( + this.token.safeBatchTransferFrom( + multiTokenHolder, recipient, + [firstTokenId, secondTokenId], + [firstAmount, secondAmount.addn(1)], + '0x', { from: multiTokenHolder }, + ), + 'ERC1155: insufficient balance for transfer', + ); + }); + + it('reverts when ids array length doesn\'t match amounts array length', async function () { + await expectRevert( + this.token.safeBatchTransferFrom( + multiTokenHolder, recipient, + [firstTokenId], + [firstAmount, secondAmount], + '0x', { from: multiTokenHolder }, + ), + 'ERC1155: ids and amounts length mismatch', + ); + + await expectRevert( + this.token.safeBatchTransferFrom( + multiTokenHolder, recipient, + [firstTokenId, secondTokenId], + [firstAmount], + '0x', { from: multiTokenHolder }, + ), + 'ERC1155: ids and amounts length mismatch', + ); + }); + + it('reverts when transferring to zero address', async function () { + await expectRevert( + this.token.safeBatchTransferFrom( + multiTokenHolder, ZERO_ADDRESS, + [firstTokenId, secondTokenId], + [firstAmount, secondAmount], + '0x', { from: multiTokenHolder }, + ), + 'ERC1155: transfer to the zero address', + ); + }); + + function batchTransferWasSuccessful ({ operator, from, ids, values }) { + it('debits transferred balances from sender', async function () { + const newBalances = await this.token.balanceOfBatch(new Array(ids.length).fill(from), ids); + for (const newBalance of newBalances) { + expect(newBalance).to.be.a.bignumber.equal('0'); + } + }); + + it('credits transferred balances to receiver', async function () { + const newBalances = await this.token.balanceOfBatch(new Array(ids.length).fill(this.toWhom), ids); + for (let i = 0; i < newBalances.length; i++) { + expect(newBalances[i]).to.be.a.bignumber.equal(values[i]); + } + }); + + it('emits a TransferBatch log', function () { + expectEvent.inLogs(this.transferLogs, 'TransferBatch', { + operator, + from, + to: this.toWhom, + // ids, + // values, + }); + }); + } + + context('when called by the multiTokenHolder', async function () { + beforeEach(async function () { + this.toWhom = recipient; + ({ logs: this.transferLogs } = + await this.token.safeBatchTransferFrom( + multiTokenHolder, recipient, + [firstTokenId, secondTokenId], + [firstAmount, secondAmount], + '0x', { from: multiTokenHolder }, + )); + }); + + batchTransferWasSuccessful.call(this, { + operator: multiTokenHolder, + from: multiTokenHolder, + ids: [firstTokenId, secondTokenId], + values: [firstAmount, secondAmount], + }); + }); + + context('when called by an operator on behalf of the multiTokenHolder', function () { + context('when operator is not approved by multiTokenHolder', function () { + beforeEach(async function () { + await this.token.setApprovalForAll(proxy, false, { from: multiTokenHolder }); + }); + + it('reverts', async function () { + await expectRevert( + this.token.safeBatchTransferFrom( + multiTokenHolder, recipient, + [firstTokenId, secondTokenId], + [firstAmount, secondAmount], + '0x', { from: proxy }, + ), + 'ERC1155: transfer caller is not owner nor approved', + ); + }); + }); + + context('when operator is approved by multiTokenHolder', function () { + beforeEach(async function () { + this.toWhom = recipient; + await this.token.setApprovalForAll(proxy, true, { from: multiTokenHolder }); + ({ logs: this.transferLogs } = + await this.token.safeBatchTransferFrom( + multiTokenHolder, recipient, + [firstTokenId, secondTokenId], + [firstAmount, secondAmount], + '0x', { from: proxy }, + )); + }); + + batchTransferWasSuccessful.call(this, { + operator: proxy, + from: multiTokenHolder, + ids: [firstTokenId, secondTokenId], + values: [firstAmount, secondAmount], + }); + + it('preserves operator\'s balances not involved in the transfer', async function () { + const balance1 = await this.token.balanceOf(proxy, firstTokenId); + expect(balance1).to.be.a.bignumber.equal('0'); + const balance2 = await this.token.balanceOf(proxy, secondTokenId); + expect(balance2).to.be.a.bignumber.equal('0'); + }); + }); + }); + + context('when sending to a valid receiver', function () { + beforeEach(async function () { + this.receiver = await ERC1155ReceiverMock.new( + RECEIVER_SINGLE_MAGIC_VALUE, false, + RECEIVER_BATCH_MAGIC_VALUE, false, + ); + }); + + context('without data', function () { + beforeEach(async function () { + this.toWhom = this.receiver.address; + this.transferReceipt = await this.token.safeBatchTransferFrom( + multiTokenHolder, this.receiver.address, + [firstTokenId, secondTokenId], + [firstAmount, secondAmount], + '0x', { from: multiTokenHolder }, + ); + ({ logs: this.transferLogs } = this.transferReceipt); + }); + + batchTransferWasSuccessful.call(this, { + operator: multiTokenHolder, + from: multiTokenHolder, + ids: [firstTokenId, secondTokenId], + values: [firstAmount, secondAmount], + }); + + it('calls onERC1155BatchReceived', async function () { + await expectEvent.inTransaction(this.transferReceipt.tx, ERC1155ReceiverMock, 'BatchReceived', { + operator: multiTokenHolder, + from: multiTokenHolder, + // ids: [firstTokenId, secondTokenId], + // values: [firstAmount, secondAmount], + data: null, + }); + }); + }); + + context('with data', function () { + const data = '0xf00dd00d'; + beforeEach(async function () { + this.toWhom = this.receiver.address; + this.transferReceipt = await this.token.safeBatchTransferFrom( + multiTokenHolder, this.receiver.address, + [firstTokenId, secondTokenId], + [firstAmount, secondAmount], + data, { from: multiTokenHolder }, + ); + ({ logs: this.transferLogs } = this.transferReceipt); + }); + + batchTransferWasSuccessful.call(this, { + operator: multiTokenHolder, + from: multiTokenHolder, + ids: [firstTokenId, secondTokenId], + values: [firstAmount, secondAmount], + }); + + it('calls onERC1155Received', async function () { + await expectEvent.inTransaction(this.transferReceipt.tx, ERC1155ReceiverMock, 'BatchReceived', { + operator: multiTokenHolder, + from: multiTokenHolder, + // ids: [firstTokenId, secondTokenId], + // values: [firstAmount, secondAmount], + data, + }); + }); + }); + }); + + context('to a receiver contract returning unexpected value', function () { + beforeEach(async function () { + this.receiver = await ERC1155ReceiverMock.new( + RECEIVER_SINGLE_MAGIC_VALUE, false, + RECEIVER_SINGLE_MAGIC_VALUE, false, + ); + }); + + it('reverts', async function () { + await expectRevert( + this.token.safeBatchTransferFrom( + multiTokenHolder, this.receiver.address, + [firstTokenId, secondTokenId], + [firstAmount, secondAmount], + '0x', { from: multiTokenHolder }, + ), + 'ERC1155: ERC1155Receiver rejected tokens', + ); + }); + }); + + context('to a receiver contract that reverts', function () { + beforeEach(async function () { + this.receiver = await ERC1155ReceiverMock.new( + RECEIVER_SINGLE_MAGIC_VALUE, false, + RECEIVER_BATCH_MAGIC_VALUE, true, + ); + }); + + it('reverts', async function () { + await expectRevert( + this.token.safeBatchTransferFrom( + multiTokenHolder, this.receiver.address, + [firstTokenId, secondTokenId], + [firstAmount, secondAmount], + '0x', { from: multiTokenHolder }, + ), + 'ERC1155ReceiverMock: reverting on batch receive', + ); + }); + }); + + context('to a receiver contract that reverts only on single transfers', function () { + beforeEach(async function () { + this.receiver = await ERC1155ReceiverMock.new( + RECEIVER_SINGLE_MAGIC_VALUE, true, + RECEIVER_BATCH_MAGIC_VALUE, false, + ); + + this.toWhom = this.receiver.address; + this.transferReceipt = await this.token.safeBatchTransferFrom( + multiTokenHolder, this.receiver.address, + [firstTokenId, secondTokenId], + [firstAmount, secondAmount], + '0x', { from: multiTokenHolder }, + ); + ({ logs: this.transferLogs } = this.transferReceipt); + }); + + batchTransferWasSuccessful.call(this, { + operator: multiTokenHolder, + from: multiTokenHolder, + ids: [firstTokenId, secondTokenId], + values: [firstAmount, secondAmount], + }); + + it('calls onERC1155BatchReceived', async function () { + await expectEvent.inTransaction(this.transferReceipt.tx, ERC1155ReceiverMock, 'BatchReceived', { + operator: multiTokenHolder, + from: multiTokenHolder, + // ids: [firstTokenId, secondTokenId], + // values: [firstAmount, secondAmount], + data: null, + }); + }); + }); + + context('to a contract that does not implement the required function', function () { + it('reverts', async function () { + const invalidReceiver = this.token; + await expectRevert.unspecified( + this.token.safeBatchTransferFrom( + multiTokenHolder, invalidReceiver.address, + [firstTokenId, secondTokenId], + [firstAmount, secondAmount], + '0x', { from: multiTokenHolder }, + ), + ); + }); + }); + }); + + shouldSupportInterfaces(['ERC165', 'ERC1155']); + }); +} + +module.exports = { + shouldBehaveLikeERC1155, +}; diff --git a/language/evm/hardhat-examples/test/ERC1155.test.js b/language/evm/hardhat-examples/test/ERC1155.test.js new file mode 100644 index 0000000000..dffe054b65 --- /dev/null +++ b/language/evm/hardhat-examples/test/ERC1155.test.js @@ -0,0 +1,264 @@ +const { BN, constants, expectEvent, expectRevert } = require('@openzeppelin/test-helpers'); +const { ZERO_ADDRESS } = constants; + +const { expect } = require('chai'); + +const { shouldBehaveLikeERC1155 } = require('./ERC1155.behavior'); +const ERC1155Mock = artifacts.require('ERC1155Mock'); + +contract('ERC1155', function (accounts) { + const [operator, tokenHolder, tokenBatchHolder, ...otherAccounts] = accounts; + + const initialURI = 'https://token-cdn-domain/{id}.json'; + + beforeEach(async function () { + this.token = await ERC1155Mock.new(initialURI); + }); + + shouldBehaveLikeERC1155(otherAccounts); + + describe('internal functions', function () { + const tokenId = new BN(1990); + const mintAmount = new BN(9001); + const burnAmount = new BN(3000); + + const tokenBatchIds = [new BN(2000), new BN(2010), new BN(2020)]; + const mintAmounts = [new BN(5000), new BN(10000), new BN(42195)]; + const burnAmounts = [new BN(5000), new BN(9001), new BN(195)]; + + const data = '0x12345678'; + + describe('_mint', function () { + it('reverts with a zero destination address', async function () { + await expectRevert( + this.token.mint(ZERO_ADDRESS, tokenId, mintAmount, data), + 'ERC1155: mint to the zero address', + ); + }); + + context('with minted tokens', function () { + beforeEach(async function () { + ({ logs: this.logs } = await this.token.mint(tokenHolder, tokenId, mintAmount, data, { from: operator })); + }); + + it('emits a TransferSingle event', function () { + expectEvent.inLogs(this.logs, 'TransferSingle', { + operator, + from: ZERO_ADDRESS, + to: tokenHolder, + id: tokenId, + value: mintAmount, + }); + }); + + it('credits the minted amount of tokens', async function () { + expect(await this.token.balanceOf(tokenHolder, tokenId)).to.be.bignumber.equal(mintAmount); + }); + }); + }); + + describe('_mintBatch', function () { + it('reverts with a zero destination address', async function () { + await expectRevert( + this.token.mintBatch(ZERO_ADDRESS, tokenBatchIds, mintAmounts, data), + 'ERC1155: mint to the zero address', + ); + }); + + it('reverts if length of inputs do not match', async function () { + await expectRevert( + this.token.mintBatch(tokenBatchHolder, tokenBatchIds, mintAmounts.slice(1), data), + 'ERC1155: ids and amounts length mismatch', + ); + + await expectRevert( + this.token.mintBatch(tokenBatchHolder, tokenBatchIds.slice(1), mintAmounts, data), + 'ERC1155: ids and amounts length mismatch', + ); + }); + + context('with minted batch of tokens', function () { + beforeEach(async function () { + ({ logs: this.logs } = await this.token.mintBatch( + tokenBatchHolder, + tokenBatchIds, + mintAmounts, + data, + { from: operator }, + )); + }); + + it('emits a TransferBatch event', function () { + expectEvent.inLogs(this.logs, 'TransferBatch', { + operator, + from: ZERO_ADDRESS, + to: tokenBatchHolder, + }); + }); + + it('credits the minted batch of tokens', async function () { + const holderBatchBalances = await this.token.balanceOfBatch( + new Array(tokenBatchIds.length).fill(tokenBatchHolder), + tokenBatchIds, + ); + + for (let i = 0; i < holderBatchBalances.length; i++) { + expect(holderBatchBalances[i]).to.be.bignumber.equal(mintAmounts[i]); + } + }); + }); + }); + + describe('_burn', function () { + it('reverts when burning the zero account\'s tokens', async function () { + await expectRevert( + this.token.burn(ZERO_ADDRESS, tokenId, mintAmount), + 'ERC1155: burn from the zero address', + ); + }); + + it('reverts when burning a non-existent token id', async function () { + await expectRevert( + this.token.burn(tokenHolder, tokenId, mintAmount), + 'ERC1155: burn amount exceeds balance', + ); + }); + + it('reverts when burning more than available tokens', async function () { + await this.token.mint( + tokenHolder, + tokenId, + mintAmount, + data, + { from: operator }, + ); + + await expectRevert( + this.token.burn(tokenHolder, tokenId, mintAmount.addn(1)), + 'ERC1155: burn amount exceeds balance', + ); + }); + + context('with minted-then-burnt tokens', function () { + beforeEach(async function () { + await this.token.mint(tokenHolder, tokenId, mintAmount, data); + ({ logs: this.logs } = await this.token.burn( + tokenHolder, + tokenId, + burnAmount, + { from: operator }, + )); + }); + + it('emits a TransferSingle event', function () { + expectEvent.inLogs(this.logs, 'TransferSingle', { + operator, + from: tokenHolder, + to: ZERO_ADDRESS, + id: tokenId, + value: burnAmount, + }); + }); + + it('accounts for both minting and burning', async function () { + expect(await this.token.balanceOf( + tokenHolder, + tokenId, + )).to.be.bignumber.equal(mintAmount.sub(burnAmount)); + }); + }); + }); + + describe('_burnBatch', function () { + it('reverts when burning the zero account\'s tokens', async function () { + await expectRevert( + this.token.burnBatch(ZERO_ADDRESS, tokenBatchIds, burnAmounts), + 'ERC1155: burn from the zero address', + ); + }); + + it('reverts if length of inputs do not match', async function () { + await expectRevert( + this.token.burnBatch(tokenBatchHolder, tokenBatchIds, burnAmounts.slice(1)), + 'ERC1155: ids and amounts length mismatch', + ); + + await expectRevert( + this.token.burnBatch(tokenBatchHolder, tokenBatchIds.slice(1), burnAmounts), + 'ERC1155: ids and amounts length mismatch', + ); + }); + + it('reverts when burning a non-existent token id', async function () { + await expectRevert( + this.token.burnBatch(tokenBatchHolder, tokenBatchIds, burnAmounts), + 'ERC1155: burn amount exceeds balance', + ); + }); + + context('with minted-then-burnt tokens', function () { + beforeEach(async function () { + await this.token.mintBatch(tokenBatchHolder, tokenBatchIds, mintAmounts, data); + ({ logs: this.logs } = await this.token.burnBatch( + tokenBatchHolder, + tokenBatchIds, + burnAmounts, + { from: operator }, + )); + }); + + it('emits a TransferBatch event', function () { + expectEvent.inLogs(this.logs, 'TransferBatch', { + operator, + from: tokenBatchHolder, + to: ZERO_ADDRESS, + // ids: tokenBatchIds, + // values: burnAmounts, + }); + }); + + it('accounts for both minting and burning', async function () { + const holderBatchBalances = await this.token.balanceOfBatch( + new Array(tokenBatchIds.length).fill(tokenBatchHolder), + tokenBatchIds, + ); + + for (let i = 0; i < holderBatchBalances.length; i++) { + expect(holderBatchBalances[i]).to.be.bignumber.equal(mintAmounts[i].sub(burnAmounts[i])); + } + }); + }); + }); + }); + + describe('ERC1155MetadataURI', function () { + const firstTokenID = new BN('42'); + const secondTokenID = new BN('1337'); + + it('emits no URI event in constructor', async function () { + await expectEvent.notEmitted.inConstruction(this.token, 'URI'); + }); + + it('sets the initial URI for all token types', async function () { + expect(await this.token.uri(firstTokenID)).to.be.equal(initialURI); + expect(await this.token.uri(secondTokenID)).to.be.equal(initialURI); + }); + + describe('_setURI', function () { + const newURI = 'https://token-cdn-domain/{locale}/{id}.json'; + + it('emits no URI event', async function () { + const receipt = await this.token.setURI(newURI); + + expectEvent.notEmitted(receipt, 'URI'); + }); + + it('sets the new URI for all token types', async function () { + await this.token.setURI(newURI); + + expect(await this.token.uri(firstTokenID)).to.be.equal(newURI); + expect(await this.token.uri(secondTokenID)).to.be.equal(newURI); + }); + }); + }); +}); diff --git a/language/evm/hardhat-examples/test/ERC1155_Sol.test.js b/language/evm/hardhat-examples/test/ERC1155_Sol.test.js new file mode 100644 index 0000000000..8e675cfb0c --- /dev/null +++ b/language/evm/hardhat-examples/test/ERC1155_Sol.test.js @@ -0,0 +1,264 @@ +const { BN, constants, expectEvent, expectRevert } = require('@openzeppelin/test-helpers'); +const { ZERO_ADDRESS } = constants; + +const { expect } = require('chai'); + +const { shouldBehaveLikeERC1155 } = require('./ERC1155.behavior'); +const ERC1155Mock = artifacts.require('ERC1155Mock_Sol'); + +contract('ERC1155', function (accounts) { + const [operator, tokenHolder, tokenBatchHolder, ...otherAccounts] = accounts; + + const initialURI = 'https://token-cdn-domain/{id}.json'; + + beforeEach(async function () { + this.token = await ERC1155Mock.new(initialURI); + }); + + shouldBehaveLikeERC1155(otherAccounts); + + describe('internal functions', function () { + const tokenId = new BN(1990); + const mintAmount = new BN(9001); + const burnAmount = new BN(3000); + + const tokenBatchIds = [new BN(2000), new BN(2010), new BN(2020)]; + const mintAmounts = [new BN(5000), new BN(10000), new BN(42195)]; + const burnAmounts = [new BN(5000), new BN(9001), new BN(195)]; + + const data = '0x12345678'; + + describe('_mint', function () { + it('reverts with a zero destination address', async function () { + await expectRevert( + this.token.mint(ZERO_ADDRESS, tokenId, mintAmount, data), + 'ERC1155: mint to the zero address', + ); + }); + + context('with minted tokens', function () { + beforeEach(async function () { + ({ logs: this.logs } = await this.token.mint(tokenHolder, tokenId, mintAmount, data, { from: operator })); + }); + + it('emits a TransferSingle event', function () { + expectEvent.inLogs(this.logs, 'TransferSingle', { + operator, + from: ZERO_ADDRESS, + to: tokenHolder, + id: tokenId, + value: mintAmount, + }); + }); + + it('credits the minted amount of tokens', async function () { + expect(await this.token.balanceOf(tokenHolder, tokenId)).to.be.bignumber.equal(mintAmount); + }); + }); + }); + + describe('_mintBatch', function () { + it('reverts with a zero destination address', async function () { + await expectRevert( + this.token.mintBatch(ZERO_ADDRESS, tokenBatchIds, mintAmounts, data), + 'ERC1155: mint to the zero address', + ); + }); + + it('reverts if length of inputs do not match', async function () { + await expectRevert( + this.token.mintBatch(tokenBatchHolder, tokenBatchIds, mintAmounts.slice(1), data), + 'ERC1155: ids and amounts length mismatch', + ); + + await expectRevert( + this.token.mintBatch(tokenBatchHolder, tokenBatchIds.slice(1), mintAmounts, data), + 'ERC1155: ids and amounts length mismatch', + ); + }); + + context('with minted batch of tokens', function () { + beforeEach(async function () { + ({ logs: this.logs } = await this.token.mintBatch( + tokenBatchHolder, + tokenBatchIds, + mintAmounts, + data, + { from: operator }, + )); + }); + + it('emits a TransferBatch event', function () { + expectEvent.inLogs(this.logs, 'TransferBatch', { + operator, + from: ZERO_ADDRESS, + to: tokenBatchHolder, + }); + }); + + it('credits the minted batch of tokens', async function () { + const holderBatchBalances = await this.token.balanceOfBatch( + new Array(tokenBatchIds.length).fill(tokenBatchHolder), + tokenBatchIds, + ); + + for (let i = 0; i < holderBatchBalances.length; i++) { + expect(holderBatchBalances[i]).to.be.bignumber.equal(mintAmounts[i]); + } + }); + }); + }); + + describe('_burn', function () { + it('reverts when burning the zero account\'s tokens', async function () { + await expectRevert( + this.token.burn(ZERO_ADDRESS, tokenId, mintAmount), + 'ERC1155: burn from the zero address', + ); + }); + + it('reverts when burning a non-existent token id', async function () { + await expectRevert( + this.token.burn(tokenHolder, tokenId, mintAmount), + 'ERC1155: burn amount exceeds balance', + ); + }); + + it('reverts when burning more than available tokens', async function () { + await this.token.mint( + tokenHolder, + tokenId, + mintAmount, + data, + { from: operator }, + ); + + await expectRevert( + this.token.burn(tokenHolder, tokenId, mintAmount.addn(1)), + 'ERC1155: burn amount exceeds balance', + ); + }); + + context('with minted-then-burnt tokens', function () { + beforeEach(async function () { + await this.token.mint(tokenHolder, tokenId, mintAmount, data); + ({ logs: this.logs } = await this.token.burn( + tokenHolder, + tokenId, + burnAmount, + { from: operator }, + )); + }); + + it('emits a TransferSingle event', function () { + expectEvent.inLogs(this.logs, 'TransferSingle', { + operator, + from: tokenHolder, + to: ZERO_ADDRESS, + id: tokenId, + value: burnAmount, + }); + }); + + it('accounts for both minting and burning', async function () { + expect(await this.token.balanceOf( + tokenHolder, + tokenId, + )).to.be.bignumber.equal(mintAmount.sub(burnAmount)); + }); + }); + }); + + describe('_burnBatch', function () { + it('reverts when burning the zero account\'s tokens', async function () { + await expectRevert( + this.token.burnBatch(ZERO_ADDRESS, tokenBatchIds, burnAmounts), + 'ERC1155: burn from the zero address', + ); + }); + + it('reverts if length of inputs do not match', async function () { + await expectRevert( + this.token.burnBatch(tokenBatchHolder, tokenBatchIds, burnAmounts.slice(1)), + 'ERC1155: ids and amounts length mismatch', + ); + + await expectRevert( + this.token.burnBatch(tokenBatchHolder, tokenBatchIds.slice(1), burnAmounts), + 'ERC1155: ids and amounts length mismatch', + ); + }); + + it('reverts when burning a non-existent token id', async function () { + await expectRevert( + this.token.burnBatch(tokenBatchHolder, tokenBatchIds, burnAmounts), + 'ERC1155: burn amount exceeds balance', + ); + }); + + context('with minted-then-burnt tokens', function () { + beforeEach(async function () { + await this.token.mintBatch(tokenBatchHolder, tokenBatchIds, mintAmounts, data); + ({ logs: this.logs } = await this.token.burnBatch( + tokenBatchHolder, + tokenBatchIds, + burnAmounts, + { from: operator }, + )); + }); + + it('emits a TransferBatch event', function () { + expectEvent.inLogs(this.logs, 'TransferBatch', { + operator, + from: tokenBatchHolder, + to: ZERO_ADDRESS, + // ids: tokenBatchIds, + // values: burnAmounts, + }); + }); + + it('accounts for both minting and burning', async function () { + const holderBatchBalances = await this.token.balanceOfBatch( + new Array(tokenBatchIds.length).fill(tokenBatchHolder), + tokenBatchIds, + ); + + for (let i = 0; i < holderBatchBalances.length; i++) { + expect(holderBatchBalances[i]).to.be.bignumber.equal(mintAmounts[i].sub(burnAmounts[i])); + } + }); + }); + }); + }); + + describe('ERC1155MetadataURI', function () { + const firstTokenID = new BN('42'); + const secondTokenID = new BN('1337'); + + it('emits no URI event in constructor', async function () { + await expectEvent.notEmitted.inConstruction(this.token, 'URI'); + }); + + it('sets the initial URI for all token types', async function () { + expect(await this.token.uri(firstTokenID)).to.be.equal(initialURI); + expect(await this.token.uri(secondTokenID)).to.be.equal(initialURI); + }); + + describe('_setURI', function () { + const newURI = 'https://token-cdn-domain/{locale}/{id}.json'; + + it('emits no URI event', async function () { + const receipt = await this.token.setURI(newURI); + + expectEvent.notEmitted(receipt, 'URI'); + }); + + it('sets the new URI for all token types', async function () { + await this.token.setURI(newURI); + + expect(await this.token.uri(firstTokenID)).to.be.equal(newURI); + expect(await this.token.uri(secondTokenID)).to.be.equal(newURI); + }); + }); + }); +}); diff --git a/language/evm/hardhat-examples/test/ERC721.behavior.js b/language/evm/hardhat-examples/test/ERC721.behavior.js new file mode 100644 index 0000000000..1e9ddbfcc3 --- /dev/null +++ b/language/evm/hardhat-examples/test/ERC721.behavior.js @@ -0,0 +1,947 @@ +const { BN, constants, expectEvent, expectRevert } = require('@openzeppelin/test-helpers'); +const { expect } = require('chai'); +const { ZERO_ADDRESS } = constants; + +const { shouldSupportInterfaces } = require('./SupportsInterface.behavior'); + +const ERC721ReceiverMock = artifacts.require('ERC721ReceiverMock'); + +const Error = [ 'None', 'RevertWithMessage', 'RevertWithoutMessage', 'Panic' ] + .reduce((acc, entry, idx) => Object.assign({ [entry]: idx }, acc), {}); + +const firstTokenId = new BN('5042'); +const secondTokenId = new BN('79217'); +const nonExistentTokenId = new BN('13'); +const fourthTokenId = new BN(4); +const baseURI = 'https://api.example.com/v1/'; + +const RECEIVER_MAGIC_VALUE = '0x150b7a02'; + +function shouldBehaveLikeERC721 (errorPrefix, owner, newOwner, approved, anotherApproved, operator, other) { + shouldSupportInterfaces([ + 'ERC165', + 'ERC721', + ]); + + context('with minted tokens', function () { + beforeEach(async function () { + await this.token.mint(owner, firstTokenId); + await this.token.mint(owner, secondTokenId); + this.toWhom = other; // default to other for toWhom in context-dependent tests + }); + + describe('balanceOf', function () { + context('when the given address owns some tokens', function () { + it('returns the amount of tokens owned by the given address', async function () { + expect(await this.token.balanceOf(owner)).to.be.bignumber.equal('2'); + }); + }); + + context('when the given address does not own any tokens', function () { + it('returns 0', async function () { + expect(await this.token.balanceOf(other)).to.be.bignumber.equal('0'); + }); + }); + + context('when querying the zero address', function () { + it('throws', async function () { + await expectRevert( +// this.token.balanceOf(ZERO_ADDRESS), 'ERC721: address zero is not a valid owner', + this.token.balanceOf(ZERO_ADDRESS), "VM Exception while processing transaction: reverted with reason string 'ERC721: balance query for the zero address'", + ); + }); + }); + }); + + describe('ownerOf', function () { + context('when the given token ID was tracked by this token', function () { + const tokenId = firstTokenId; + + it('returns the owner of the given token ID', async function () { + expect(await this.token.ownerOf(tokenId)).to.be.equal(owner); + }); + }); + + context('when the given token ID was not tracked by this token', function () { + const tokenId = nonExistentTokenId; + + it('reverts', async function () { + await expectRevert( + this.token.ownerOf(tokenId), 'ERC721: owner query for nonexistent token', + ); + }); + }); + }); + + + describe('transfers', function () { + const tokenId = firstTokenId; + const data = '0x42'; + + let logs = null; + + beforeEach(async function () { + await this.token.approve(approved, tokenId, { from: owner }); + await this.token.setApprovalForAll(operator, true, { from: owner }); + }); + + const transferWasSuccessful = function ({ owner, tokenId, approved }) { + it('transfers the ownership of the given token ID to the given address', async function () { + expect(await this.token.ownerOf(tokenId)).to.be.equal(this.toWhom); + }); + + it('emits a Transfer event', async function () { + expectEvent.inLogs(logs, 'Transfer', { from: owner, to: this.toWhom, tokenId: tokenId }); + }); + + it('clears the approval for the token ID', async function () { + expect(await this.token.getApproved(tokenId)).to.be.equal(ZERO_ADDRESS); + }); + + it('emits an Approval event', async function () { + expectEvent.inLogs(logs, 'Approval', { owner, approved: ZERO_ADDRESS, tokenId: tokenId }); + }); + + it('adjusts owners balances', async function () { + expect(await this.token.balanceOf(owner)).to.be.bignumber.equal('1'); + }); + + it('adjusts owners tokens by index', async function () { + if (!this.token.tokenOfOwnerByIndex) return; + + expect(await this.token.tokenOfOwnerByIndex(this.toWhom, 0)).to.be.bignumber.equal(tokenId); + + expect(await this.token.tokenOfOwnerByIndex(owner, 0)).to.be.bignumber.not.equal(tokenId); + }); + }; + + const shouldTransferTokensByUsers = function (transferFunction) { + context('when called by the owner', function () { + beforeEach(async function () { + ({ logs } = await transferFunction.call(this, owner, this.toWhom, tokenId, { from: owner })); + }); + transferWasSuccessful({ owner, tokenId, approved }); + }); + + context('when called by the approved individual', function () { + beforeEach(async function () { + ({ logs } = await transferFunction.call(this, owner, this.toWhom, tokenId, { from: approved })); + }); + transferWasSuccessful({ owner, tokenId, approved }); + }); + + context('when called by the operator', function () { + beforeEach(async function () { + ({ logs } = await transferFunction.call(this, owner, this.toWhom, tokenId, { from: operator })); + }); + transferWasSuccessful({ owner, tokenId, approved }); + }); + + context('when called by the owner without an approved user', function () { + beforeEach(async function () { + await this.token.approve(ZERO_ADDRESS, tokenId, { from: owner }); + ({ logs } = await transferFunction.call(this, owner, this.toWhom, tokenId, { from: operator })); + }); + transferWasSuccessful({ owner, tokenId, approved: null }); + }); + + context('when sent to the owner', function () { + beforeEach(async function () { + ({ logs } = await transferFunction.call(this, owner, owner, tokenId, { from: owner })); + }); + + it('keeps ownership of the token', async function () { + expect(await this.token.ownerOf(tokenId)).to.be.equal(owner); + }); + + it('clears the approval for the token ID', async function () { + expect(await this.token.getApproved(tokenId)).to.be.equal(ZERO_ADDRESS); + }); + + it('emits only a transfer event', async function () { + expectEvent.inLogs(logs, 'Transfer', { + from: owner, + to: owner, + tokenId: tokenId, + }); + }); + + it('keeps the owner balance', async function () { + expect(await this.token.balanceOf(owner)).to.be.bignumber.equal('2'); + }); + + it('keeps same tokens by index', async function () { + if (!this.token.tokenOfOwnerByIndex) return; + const tokensListed = await Promise.all( + [0, 1].map(i => this.token.tokenOfOwnerByIndex(owner, i)), + ); + expect(tokensListed.map(t => t.toNumber())).to.have.members( + [firstTokenId.toNumber(), secondTokenId.toNumber()], + ); + }); + }); + + context('when the address of the previous owner is incorrect', function () { + it('reverts', async function () { + await expectRevert( + transferFunction.call(this, other, other, tokenId, { from: owner }), + 'ERC721: transfer from incorrect owner', + ); + }); + }); + + context('when the sender is not authorized for the token id', function () { + it('reverts', async function () { + await expectRevert( + transferFunction.call(this, owner, other, tokenId, { from: other }), + 'ERC721: transfer caller is not owner nor approved', + ); + }); + }); + + context('when the given token ID does not exist', function () { + it('reverts', async function () { + await expectRevert( + transferFunction.call(this, owner, other, nonExistentTokenId, { from: owner }), + 'ERC721: operator query for nonexistent token', + ); + }); + }); + + context('when the address to transfer the token to is the zero address', function () { + it('reverts', async function () { + await expectRevert( + transferFunction.call(this, owner, ZERO_ADDRESS, tokenId, { from: owner }), + 'ERC721: transfer to the zero address', + ); + }); + }); + }; + + describe('via transferFrom', function () { + shouldTransferTokensByUsers(function (from, to, tokenId, opts) { + return this.token.transferFrom(from, to, tokenId, opts); + }); + }); + + describe('via safeTransferFrom', function () { + const safeTransferFromWithData = function (from, to, tokenId, opts) { + return this.token.methods['safeTransferFrom(address,address,uint256,bytes)'](from, to, tokenId, data, opts); + }; + + const safeTransferFromWithoutData = function (from, to, tokenId, opts) { + return this.token.methods['safeTransferFrom(address,address,uint256)'](from, to, tokenId, opts); + }; + + const shouldTransferSafely = function (transferFun, data) { + describe('to a user account', function () { + shouldTransferTokensByUsers(transferFun); + }); + + describe('to a valid receiver contract', function () { + beforeEach(async function () { + this.receiver = await ERC721ReceiverMock.new(RECEIVER_MAGIC_VALUE, Error.None); + this.toWhom = this.receiver.address; + }); + + shouldTransferTokensByUsers(transferFun); + + it('calls onERC721Received', async function () { + const receipt = await transferFun.call(this, owner, this.receiver.address, tokenId, { from: owner }); + + await expectEvent.inTransaction(receipt.tx, ERC721ReceiverMock, 'Received', { + operator: owner, + from: owner, + tokenId: tokenId, + data: data, + }); + }); + + it('calls onERC721Received from approved', async function () { + const receipt = await transferFun.call(this, owner, this.receiver.address, tokenId, { from: approved }); + + await expectEvent.inTransaction(receipt.tx, ERC721ReceiverMock, 'Received', { + operator: approved, + from: owner, + tokenId: tokenId, + data: data, + }); + }); + + describe('with an invalid token id', function () { + it('reverts', async function () { + await expectRevert( + transferFun.call( + this, + owner, + this.receiver.address, + nonExistentTokenId, + { from: owner }, + ), + 'ERC721: operator query for nonexistent token', + ); + }); + }); + }); + }; + + describe('with data', function () { + shouldTransferSafely(safeTransferFromWithData, data); + }); + + describe('without data', function () { + shouldTransferSafely(safeTransferFromWithoutData, null); + }); + + describe('to a receiver contract returning unexpected value', function () { + it('reverts', async function () { + const invalidReceiver = await ERC721ReceiverMock.new('0x42', Error.None); + await expectRevert( + this.token.safeTransferFrom(owner, invalidReceiver.address, tokenId, { from: owner }), + 'ERC721: transfer to non ERC721Receiver implementer', + ); + }); + }); + + describe('to a receiver contract that reverts with message', function () { + it('reverts', async function () { + const revertingReceiver = await ERC721ReceiverMock.new(RECEIVER_MAGIC_VALUE, Error.RevertWithMessage); + await expectRevert( + this.token.safeTransferFrom(owner, revertingReceiver.address, tokenId, { from: owner }), + 'ERC721ReceiverMock: reverting', + ); + }); + }); + + describe('to a receiver contract that reverts without message', function () { + it('reverts', async function () { + const revertingReceiver = await ERC721ReceiverMock.new(RECEIVER_MAGIC_VALUE, Error.RevertWithoutMessage); + await expectRevert( + this.token.safeTransferFrom(owner, revertingReceiver.address, tokenId, { from: owner }), + 'ERC721: transfer to non ERC721Receiver implementer', + ); + }); + }); + + describe('to a receiver contract that panics', function () { + it('reverts', async function () { + const revertingReceiver = await ERC721ReceiverMock.new(RECEIVER_MAGIC_VALUE, Error.Panic); + await expectRevert.unspecified( + this.token.safeTransferFrom(owner, revertingReceiver.address, tokenId, { from: owner }), + ); + }); + }); + + describe('to a contract that does not implement the required function', function () { + it('reverts', async function () { + const nonReceiver = this.token; + await expectRevert( + this.token.safeTransferFrom(owner, nonReceiver.address, tokenId, { from: owner }), + 'ERC721: transfer to non ERC721Receiver implementer', + ); + }); + }); + }); + }); + + describe('safe mint', function () { + const tokenId = fourthTokenId; + const data = '0x42'; + + describe('via safeMint', function () { // regular minting is tested in ERC721Mintable.test.js and others + it('calls onERC721Received — with data', async function () { + this.receiver = await ERC721ReceiverMock.new(RECEIVER_MAGIC_VALUE, Error.None); + const receipt = await this.token.safeMint(this.receiver.address, tokenId, data); + + await expectEvent.inTransaction(receipt.tx, ERC721ReceiverMock, 'Received', { + from: ZERO_ADDRESS, + tokenId: tokenId, + data: data, + }); + }); + + it('calls onERC721Received — without data', async function () { + this.receiver = await ERC721ReceiverMock.new(RECEIVER_MAGIC_VALUE, Error.None); + const receipt = await this.token.safeMint(this.receiver.address, tokenId); + + await expectEvent.inTransaction(receipt.tx, ERC721ReceiverMock, 'Received', { + from: ZERO_ADDRESS, + tokenId: tokenId, + }); + }); + + context('to a receiver contract returning unexpected value', function () { + it('reverts', async function () { + const invalidReceiver = await ERC721ReceiverMock.new('0x42', Error.None); + await expectRevert( + this.token.safeMint(invalidReceiver.address, tokenId), + 'ERC721: transfer to non ERC721Receiver implementer', + ); + }); + }); + + context('to a receiver contract that reverts with message', function () { + it('reverts', async function () { + const revertingReceiver = await ERC721ReceiverMock.new(RECEIVER_MAGIC_VALUE, Error.RevertWithMessage); + await expectRevert( + this.token.safeMint(revertingReceiver.address, tokenId), + 'ERC721ReceiverMock: reverting', + ); + }); + }); + + context('to a receiver contract that reverts without message', function () { + it('reverts', async function () { + const revertingReceiver = await ERC721ReceiverMock.new(RECEIVER_MAGIC_VALUE, Error.RevertWithoutMessage); + await expectRevert( + this.token.safeMint(revertingReceiver.address, tokenId), + 'ERC721: transfer to non ERC721Receiver implementer', + ); + }); + }); + + context('to a receiver contract that panics', function () { + it('reverts', async function () { + const revertingReceiver = await ERC721ReceiverMock.new(RECEIVER_MAGIC_VALUE, Error.Panic); + await expectRevert.unspecified( + this.token.safeMint(revertingReceiver.address, tokenId), + ); + }); + }); + + context('to a contract that does not implement the required function', function () { + it('reverts', async function () { + const nonReceiver = this.token; + await expectRevert( + this.token.safeMint(nonReceiver.address, tokenId), + 'ERC721: transfer to non ERC721Receiver implementer', + ); + }); + }); + }); + }); + + describe('approve', function () { + const tokenId = firstTokenId; + + let logs = null; + + const itClearsApproval = function () { + it('clears approval for the token', async function () { + expect(await this.token.getApproved(tokenId)).to.be.equal(ZERO_ADDRESS); + }); + }; + + const itApproves = function (address) { + it('sets the approval for the target address', async function () { + expect(await this.token.getApproved(tokenId)).to.be.equal(address); + }); + }; + + const itEmitsApprovalEvent = function (address) { + it('emits an approval event', async function () { + expectEvent.inLogs(logs, 'Approval', { + owner: owner, + approved: address, + tokenId: tokenId, + }); + }); + }; + + context('when clearing approval', function () { + context('when there was no prior approval', function () { + beforeEach(async function () { + ({ logs } = await this.token.approve(ZERO_ADDRESS, tokenId, { from: owner })); + }); + + itClearsApproval(); + itEmitsApprovalEvent(ZERO_ADDRESS); + }); + + context('when there was a prior approval', function () { + beforeEach(async function () { + await this.token.approve(approved, tokenId, { from: owner }); + ({ logs } = await this.token.approve(ZERO_ADDRESS, tokenId, { from: owner })); + }); + + itClearsApproval(); + itEmitsApprovalEvent(ZERO_ADDRESS); + }); + }); + + context('when approving a non-zero address', function () { + context('when there was no prior approval', function () { + beforeEach(async function () { + ({ logs } = await this.token.approve(approved, tokenId, { from: owner })); + }); + + itApproves(approved); + itEmitsApprovalEvent(approved); + }); + + context('when there was a prior approval to the same address', function () { + beforeEach(async function () { + await this.token.approve(approved, tokenId, { from: owner }); + ({ logs } = await this.token.approve(approved, tokenId, { from: owner })); + }); + + itApproves(approved); + itEmitsApprovalEvent(approved); + }); + + context('when there was a prior approval to a different address', function () { + beforeEach(async function () { + await this.token.approve(anotherApproved, tokenId, { from: owner }); + ({ logs } = await this.token.approve(anotherApproved, tokenId, { from: owner })); + }); + + itApproves(anotherApproved); + itEmitsApprovalEvent(anotherApproved); + }); + }); + + context('when the address that receives the approval is the owner', function () { + it('reverts', async function () { + await expectRevert( + this.token.approve(owner, tokenId, { from: owner }), 'ERC721: approval to current owner', + ); + }); + }); + + context('when the sender does not own the given token ID', function () { + it('reverts', async function () { + await expectRevert(this.token.approve(approved, tokenId, { from: other }), + 'ERC721: approve caller is not owner nor approved'); + }); + }); + + context('when the sender is approved for the given token ID', function () { + it('reverts', async function () { + await this.token.approve(approved, tokenId, { from: owner }); + await expectRevert(this.token.approve(anotherApproved, tokenId, { from: approved }), + 'ERC721: approve caller is not owner nor approved for all'); + }); + }); + + context('when the sender is an operator', function () { + beforeEach(async function () { + await this.token.setApprovalForAll(operator, true, { from: owner }); + ({ logs } = await this.token.approve(approved, tokenId, { from: operator })); + }); + + itApproves(approved); + itEmitsApprovalEvent(approved); + }); + + context('when the given token ID does not exist', function () { + it('reverts', async function () { + await expectRevert(this.token.approve(approved, nonExistentTokenId, { from: operator }), + 'ERC721: owner query for nonexistent token'); + }); + }); + }); + + describe('setApprovalForAll', function () { + context('when the operator willing to approve is not the owner', function () { + context('when there is no operator approval set by the sender', function () { + it('approves the operator', async function () { + await this.token.setApprovalForAll(operator, true, { from: owner }); + + expect(await this.token.isApprovedForAll(owner, operator)).to.equal(true); + }); + + it('emits an approval event', async function () { + const { logs } = await this.token.setApprovalForAll(operator, true, { from: owner }); + + expectEvent.inLogs(logs, 'ApprovalForAll', { + owner: owner, + operator: operator, + approved: true, + }); + }); + }); + + context('when the operator was set as not approved', function () { + beforeEach(async function () { + await this.token.setApprovalForAll(operator, false, { from: owner }); + }); + + it('approves the operator', async function () { + await this.token.setApprovalForAll(operator, true, { from: owner }); + + expect(await this.token.isApprovedForAll(owner, operator)).to.equal(true); + }); + + it('emits an approval event', async function () { + const { logs } = await this.token.setApprovalForAll(operator, true, { from: owner }); + + expectEvent.inLogs(logs, 'ApprovalForAll', { + owner: owner, + operator: operator, + approved: true, + }); + }); + + it('can unset the operator approval', async function () { + await this.token.setApprovalForAll(operator, false, { from: owner }); + + expect(await this.token.isApprovedForAll(owner, operator)).to.equal(false); + }); + }); + + context('when the operator was already approved', function () { + beforeEach(async function () { + await this.token.setApprovalForAll(operator, true, { from: owner }); + }); + + it('keeps the approval to the given address', async function () { + await this.token.setApprovalForAll(operator, true, { from: owner }); + + expect(await this.token.isApprovedForAll(owner, operator)).to.equal(true); + }); + + it('emits an approval event', async function () { + const { logs } = await this.token.setApprovalForAll(operator, true, { from: owner }); + + expectEvent.inLogs(logs, 'ApprovalForAll', { + owner: owner, + operator: operator, + approved: true, + }); + }); + }); + }); + + context('when the operator is the owner', function () { + it('reverts', async function () { + await expectRevert(this.token.setApprovalForAll(owner, true, { from: owner }), + 'ERC721: approve to caller'); + }); + }); + }); + + describe('getApproved', async function () { + context('when token is not minted', async function () { + it('reverts', async function () { + await expectRevert( + this.token.getApproved(nonExistentTokenId), + 'ERC721: approved query for nonexistent token', + ); + }); + }); + + context('when token has been minted ', async function () { + it('should return the zero address', async function () { + expect(await this.token.getApproved(firstTokenId)).to.be.equal( + ZERO_ADDRESS, + ); + }); + + context('when account has been approved', async function () { + beforeEach(async function () { + await this.token.approve(approved, firstTokenId, { from: owner }); + }); + + it('returns approved account', async function () { + expect(await this.token.getApproved(firstTokenId)).to.be.equal(approved); + }); + }); + }); + }); + }); + + describe('_mint(address, uint256)', function () { + it('reverts with a null destination address', async function () { + await expectRevert( + this.token.mint(ZERO_ADDRESS, firstTokenId), 'ERC721: mint to the zero address', + ); + }); + + context('with minted token', async function () { + beforeEach(async function () { + ({ logs: this.logs } = await this.token.mint(owner, firstTokenId)); + }); + + it('emits a Transfer event', function () { + expectEvent.inLogs(this.logs, 'Transfer', { from: ZERO_ADDRESS, to: owner, tokenId: firstTokenId }); + }); + + it('creates the token', async function () { + expect(await this.token.balanceOf(owner)).to.be.bignumber.equal('1'); + expect(await this.token.ownerOf(firstTokenId)).to.equal(owner); + }); + + it('reverts when adding a token id that already exists', async function () { + await expectRevert(this.token.mint(owner, firstTokenId), 'ERC721: token already minted'); + }); + }); + }); + + describe('_burn', function () { + it('reverts when burning a non-existent token id', async function () { + await expectRevert( + this.token.burn(nonExistentTokenId), 'ERC721: owner query for nonexistent token', + ); + }); + + context('with minted tokens', function () { + beforeEach(async function () { + await this.token.mint(owner, firstTokenId); + await this.token.mint(owner, secondTokenId); + }); + + context('with burnt token', function () { + beforeEach(async function () { + ({ logs: this.logs } = await this.token.burn(firstTokenId)); + }); + + it('emits a Transfer event', function () { + expectEvent.inLogs(this.logs, 'Transfer', { from: owner, to: ZERO_ADDRESS, tokenId: firstTokenId }); + }); + + it('emits an Approval event', function () { + expectEvent.inLogs(this.logs, 'Approval', { owner, approved: ZERO_ADDRESS, tokenId: firstTokenId }); + }); + + it('deletes the token', async function () { + expect(await this.token.balanceOf(owner)).to.be.bignumber.equal('1'); + await expectRevert( + this.token.ownerOf(firstTokenId), 'ERC721: owner query for nonexistent token', + ); + }); + + it('reverts when burning a token id that has been deleted', async function () { + await expectRevert( + this.token.burn(firstTokenId), 'ERC721: owner query for nonexistent token', + ); + }); + }); + }); + }); +} + +function shouldBehaveLikeERC721Enumerable (errorPrefix, owner, newOwner, approved, anotherApproved, operator, other) { + shouldSupportInterfaces([ + 'ERC721Enumerable', + ]); + + context('with minted tokens', function () { + beforeEach(async function () { + await this.token.mint(owner, firstTokenId); + await this.token.mint(owner, secondTokenId); + this.toWhom = other; // default to other for toWhom in context-dependent tests + }); + + describe('totalSupply', function () { + it('returns total token supply', async function () { + expect(await this.token.totalSupply()).to.be.bignumber.equal('2'); + }); + }); + + describe('tokenOfOwnerByIndex', function () { + describe('when the given index is lower than the amount of tokens owned by the given address', function () { + it('returns the token ID placed at the given index', async function () { + expect(await this.token.tokenOfOwnerByIndex(owner, 0)).to.be.bignumber.equal(firstTokenId); + }); + }); + + describe('when the index is greater than or equal to the total tokens owned by the given address', function () { + it('reverts', async function () { + await expectRevert( + this.token.tokenOfOwnerByIndex(owner, 2), 'ERC721Enumerable: owner index out of bounds', + ); + }); + }); + + describe('when the given address does not own any token', function () { + it('reverts', async function () { + await expectRevert( + this.token.tokenOfOwnerByIndex(other, 0), 'ERC721Enumerable: owner index out of bounds', + ); + }); + }); + + describe('after transferring all tokens to another user', function () { + beforeEach(async function () { + await this.token.transferFrom(owner, other, firstTokenId, { from: owner }); + await this.token.transferFrom(owner, other, secondTokenId, { from: owner }); + }); + + it('returns correct token IDs for target', async function () { + expect(await this.token.balanceOf(other)).to.be.bignumber.equal('2'); + const tokensListed = await Promise.all( + [0, 1].map(i => this.token.tokenOfOwnerByIndex(other, i)), + ); + expect(tokensListed.map(t => t.toNumber())).to.have.members([firstTokenId.toNumber(), + secondTokenId.toNumber()]); + }); + + it('returns empty collection for original owner', async function () { + expect(await this.token.balanceOf(owner)).to.be.bignumber.equal('0'); + await expectRevert( + this.token.tokenOfOwnerByIndex(owner, 0), 'ERC721Enumerable: owner index out of bounds', + ); + }); + }); + }); + + describe('tokenByIndex', function () { + it('returns all tokens', async function () { + const tokensListed = await Promise.all( + [0, 1].map(i => this.token.tokenByIndex(i)), + ); + expect(tokensListed.map(t => t.toNumber())).to.have.members([firstTokenId.toNumber(), + secondTokenId.toNumber()]); + }); + + it('reverts if index is greater than supply', async function () { + await expectRevert( + this.token.tokenByIndex(2), 'ERC721Enumerable: global index out of bounds', + ); + }); + + [firstTokenId, secondTokenId].forEach(function (tokenId) { + it(`returns all tokens after burning token ${tokenId} and minting new tokens`, async function () { + const newTokenId = new BN(300); + const anotherNewTokenId = new BN(400); + + await this.token.burn(tokenId); + await this.token.mint(newOwner, newTokenId); + await this.token.mint(newOwner, anotherNewTokenId); + + expect(await this.token.totalSupply()).to.be.bignumber.equal('3'); + + const tokensListed = await Promise.all( + [0, 1, 2].map(i => this.token.tokenByIndex(i)), + ); + const expectedTokens = [firstTokenId, secondTokenId, newTokenId, anotherNewTokenId].filter( + x => (x !== tokenId), + ); + expect(tokensListed.map(t => t.toNumber())).to.have.members(expectedTokens.map(t => t.toNumber())); + }); + }); + }); + }); + + describe('_mint(address, uint256)', function () { + it('reverts with a null destination address', async function () { + await expectRevert( + this.token.mint(ZERO_ADDRESS, firstTokenId), 'ERC721: mint to the zero address', + ); + }); + + context('with minted token', async function () { + beforeEach(async function () { + ({ logs: this.logs } = await this.token.mint(owner, firstTokenId)); + }); + + it('adjusts owner tokens by index', async function () { + expect(await this.token.tokenOfOwnerByIndex(owner, 0)).to.be.bignumber.equal(firstTokenId); + }); + + it('adjusts all tokens list', async function () { + expect(await this.token.tokenByIndex(0)).to.be.bignumber.equal(firstTokenId); + }); + }); + }); + + describe('_burn', function () { + it('reverts when burning a non-existent token id', async function () { + await expectRevert( + this.token.burn(firstTokenId), 'ERC721: owner query for nonexistent token', + ); + }); + + context('with minted tokens', function () { + beforeEach(async function () { + await this.token.mint(owner, firstTokenId); + await this.token.mint(owner, secondTokenId); + }); + + context('with burnt token', function () { + beforeEach(async function () { + ({ logs: this.logs } = await this.token.burn(firstTokenId)); + }); + + it('removes that token from the token list of the owner', async function () { + expect(await this.token.tokenOfOwnerByIndex(owner, 0)).to.be.bignumber.equal(secondTokenId); + }); + + it('adjusts all tokens list', async function () { + expect(await this.token.tokenByIndex(0)).to.be.bignumber.equal(secondTokenId); + }); + + it('burns all tokens', async function () { + await this.token.burn(secondTokenId, { from: owner }); + expect(await this.token.totalSupply()).to.be.bignumber.equal('0'); + await expectRevert( + this.token.tokenByIndex(0), 'ERC721Enumerable: global index out of bounds', + ); + }); + }); + }); + }); +} + +function shouldBehaveLikeERC721Metadata (errorPrefix, name, symbol, owner) { + shouldSupportInterfaces([ + 'ERC721Metadata', + ]); + + describe('metadata', function () { + it('has a name', async function () { + expect(await this.token.name()).to.be.equal(name); + }); + + it('has a symbol', async function () { + expect(await this.token.symbol()).to.be.equal(symbol); + }); + + describe('token URI', function () { + beforeEach(async function () { + await this.token.mint(owner, firstTokenId); + }); + + it('return empty string by default', async function () { + expect(await this.token.tokenURI(firstTokenId)).to.be.equal(''); + }); + + it('reverts when queried for non existent token id', async function () { + await expectRevert( + this.token.tokenURI(nonExistentTokenId), 'ERC721Metadata: URI query for nonexistent token', + ); + }); + + describe('base URI', function () { + beforeEach(function () { + if (this.token.setBaseURI === undefined) { + this.skip(); + } + }); + + it('base URI can be set', async function () { + await this.token.setBaseURI(baseURI); + expect(await this.token.baseURI()).to.equal(baseURI); + }); + + it('base URI is added as a prefix to the token URI', async function () { + await this.token.setBaseURI(baseURI); + expect(await this.token.tokenURI(firstTokenId)).to.be.equal(baseURI + firstTokenId.toString()); + }); + + it('token URI can be changed by changing the base URI', async function () { + await this.token.setBaseURI(baseURI); + const newBaseURI = 'https://api.example.com/v2/'; + await this.token.setBaseURI(newBaseURI); + expect(await this.token.tokenURI(firstTokenId)).to.be.equal(newBaseURI + firstTokenId.toString()); + }); + }); + }); + }); +} + +module.exports = { + shouldBehaveLikeERC721, + shouldBehaveLikeERC721Enumerable, + shouldBehaveLikeERC721Metadata, +}; diff --git a/language/evm/hardhat-examples/test/ERC721.test.js b/language/evm/hardhat-examples/test/ERC721.test.js new file mode 100644 index 0000000000..1abbd6629f --- /dev/null +++ b/language/evm/hardhat-examples/test/ERC721.test.js @@ -0,0 +1,18 @@ +const { + shouldBehaveLikeERC721, + shouldBehaveLikeERC721Metadata, +} = require('./ERC721.behavior'); + +const ERC721Mock = artifacts.require('ERC721Mock'); + +contract('ERC721', function (accounts) { + const name = 'Non Fungible Token'; + const symbol = 'NFT'; + + beforeEach(async function () { + this.token = await ERC721Mock.new(name, symbol); + }); + + shouldBehaveLikeERC721('ERC721', ...accounts); + shouldBehaveLikeERC721Metadata('ERC721', name, symbol, ...accounts); +}); diff --git a/language/evm/hardhat-examples/test/ERC721_Sol.test.js b/language/evm/hardhat-examples/test/ERC721_Sol.test.js new file mode 100644 index 0000000000..1c561c4bae --- /dev/null +++ b/language/evm/hardhat-examples/test/ERC721_Sol.test.js @@ -0,0 +1,18 @@ +const { + shouldBehaveLikeERC721, + shouldBehaveLikeERC721Metadata, +} = require('./ERC721.behavior'); + +const ERC721Mock = artifacts.require('ERC721Mock_Sol'); + +contract('ERC721', function (accounts) { + const name = 'Non Fungible Token'; + const symbol = 'NFT'; + + beforeEach(async function () { + this.token = await ERC721Mock.new(name, symbol); + }); + + shouldBehaveLikeERC721('ERC721', ...accounts); + shouldBehaveLikeERC721Metadata('ERC721', name, symbol, ...accounts); +}); diff --git a/language/evm/hardhat-examples/test/SupportsInterface.behavior.js b/language/evm/hardhat-examples/test/SupportsInterface.behavior.js new file mode 100644 index 0000000000..cac6a81d1a --- /dev/null +++ b/language/evm/hardhat-examples/test/SupportsInterface.behavior.js @@ -0,0 +1,148 @@ +const { makeInterfaceId } = require('@openzeppelin/test-helpers'); + +const { expect } = require('chai'); + +const INTERFACES = { + ERC165: [ + 'supportsInterface(bytes4)', + ], + ERC721: [ + 'balanceOf(address)', + 'ownerOf(uint256)', + 'approve(address,uint256)', + 'getApproved(uint256)', + 'setApprovalForAll(address,bool)', + 'isApprovedForAll(address,address)', + 'transferFrom(address,address,uint256)', + 'safeTransferFrom(address,address,uint256)', + 'safeTransferFrom(address,address,uint256,bytes)', + ], + ERC721Enumerable: [ + 'totalSupply()', + 'tokenOfOwnerByIndex(address,uint256)', + 'tokenByIndex(uint256)', + ], + ERC721Metadata: [ + 'name()', + 'symbol()', + 'tokenURI(uint256)', + ], + ERC1155: [ + 'balanceOf(address,uint256)', + 'balanceOfBatch(address[],uint256[])', + 'setApprovalForAll(address,bool)', + 'isApprovedForAll(address,address)', + 'safeTransferFrom(address,address,uint256,uint256,bytes)', + 'safeBatchTransferFrom(address,address,uint256[],uint256[],bytes)', + ], + ERC1155Receiver: [ + 'onERC1155Received(address,address,uint256,uint256,bytes)', + 'onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)', + ], + AccessControl: [ + 'hasRole(bytes32,address)', + 'getRoleAdmin(bytes32)', + 'grantRole(bytes32,address)', + 'revokeRole(bytes32,address)', + 'renounceRole(bytes32,address)', + ], + AccessControlEnumerable: [ + 'getRoleMember(bytes32,uint256)', + 'getRoleMemberCount(bytes32)', + ], + Governor: [ + 'name()', + 'version()', + 'COUNTING_MODE()', + 'hashProposal(address[],uint256[],bytes[],bytes32)', + 'state(uint256)', + 'proposalSnapshot(uint256)', + 'proposalDeadline(uint256)', + 'votingDelay()', + 'votingPeriod()', + 'quorum(uint256)', + 'getVotes(address,uint256)', + 'hasVoted(uint256,address)', + 'propose(address[],uint256[],bytes[],string)', + 'execute(address[],uint256[],bytes[],bytes32)', + 'castVote(uint256,uint8)', + 'castVoteWithReason(uint256,uint8,string)', + 'castVoteBySig(uint256,uint8,uint8,bytes32,bytes32)', + ], + GovernorWithParams: [ + 'name()', + 'version()', + 'COUNTING_MODE()', + 'hashProposal(address[],uint256[],bytes[],bytes32)', + 'state(uint256)', + 'proposalSnapshot(uint256)', + 'proposalDeadline(uint256)', + 'votingDelay()', + 'votingPeriod()', + 'quorum(uint256)', + 'getVotes(address,uint256)', + 'getVotesWithParams(address,uint256,bytes)', + 'hasVoted(uint256,address)', + 'propose(address[],uint256[],bytes[],string)', + 'execute(address[],uint256[],bytes[],bytes32)', + 'castVote(uint256,uint8)', + 'castVoteWithReason(uint256,uint8,string)', + 'castVoteWithReasonAndParams(uint256,uint8,string,bytes)', + 'castVoteBySig(uint256,uint8,uint8,bytes32,bytes32)', + 'castVoteWithReasonAndParamsBySig(uint256,uint8,string,bytes,uint8,bytes32,bytes32)', + ], + GovernorTimelock: [ + 'timelock()', + 'proposalEta(uint256)', + 'queue(address[],uint256[],bytes[],bytes32)', + ], + ERC2981: [ + 'royaltyInfo(uint256,uint256)', + ], +}; + +const INTERFACE_IDS = {}; +const FN_SIGNATURES = {}; +for (const k of Object.getOwnPropertyNames(INTERFACES)) { + INTERFACE_IDS[k] = makeInterfaceId.ERC165(INTERFACES[k]); + for (const fnName of INTERFACES[k]) { + // the interface id of a single function is equivalent to its function signature + FN_SIGNATURES[fnName] = makeInterfaceId.ERC165([fnName]); + } +} + +function shouldSupportInterfaces (interfaces = []) { + describe('ERC165', function () { + beforeEach(function () { + this.contractUnderTest = this.mock || this.token || this.holder || this.accessControl; + }); + + it('supportsInterface uses less than 30k gas', async function () { + for (const k of interfaces) { + const interfaceId = INTERFACE_IDS[k]; + expect(await this.contractUnderTest.supportsInterface.estimateGas(interfaceId)).to.be.lte(30000); + //expect(await this.contractUnderTest.supportsInterface.estimateGas(interfaceId)).to.be.lte(1); + } + }); + + it('all interfaces are reported as supported', async function () { + for (const k of interfaces) { + const interfaceId = INTERFACE_IDS[k]; + expect(await this.contractUnderTest.supportsInterface(interfaceId)).to.equal(true); + } + }); + + it('all interface functions are in ABI', async function () { + for (const k of interfaces) { + for (const fnName of INTERFACES[k]) { + const fnSig = FN_SIGNATURES[fnName]; + expect(this.contractUnderTest.abi.filter(fn => fn.signature === fnSig).length).to.equal(1); + } + } + }); + }); +} + +module.exports = { + shouldSupportInterfaces, +};