From abc6b050b3adfeabdea0e9d82687f1587006c4f4 Mon Sep 17 00:00:00 2001 From: Grigoriy Simonov Date: Thu, 28 Jul 2022 06:35:40 +0000 Subject: [PATCH] chore: implement transfer and burn for ERC-721 --- --- a/Makefile +++ b/Makefile @@ -41,10 +41,6 @@ UniqueRefungible.sol: PACKAGE=pallet-refungible NAME=erc::gen_iface OUTPUT=$(TESTS_API)/$@ ./.maintain/scripts/generate_sol.sh PACKAGE=pallet-refungible NAME=erc::gen_impl OUTPUT=$(REFUNGIBLE_EVM_STUBS)/$@ ./.maintain/scripts/generate_sol.sh - -UniqueRefungible.sol: - PACKAGE=pallet-refungible NAME=erc::gen_iface OUTPUT=$(TESTS_API)/$@ ./.maintain/scripts/generate_sol.sh - PACKAGE=pallet-refungible NAME=erc::gen_impl OUTPUT=$(REFUNGIBLE_EVM_STUBS)/$@ ./.maintain/scripts/generate_sol.sh UniqueRefungibleToken.sol: PACKAGE=pallet-refungible NAME=erc_token::gen_iface OUTPUT=$(TESTS_API)/$@ ./.maintain/scripts/generate_sol.sh @@ -73,10 +69,6 @@ UniqueRefungibleToken: UniqueRefungibleToken.sol INPUT=$(REFUNGIBLE_EVM_STUBS)/$< OUTPUT=$(REFUNGIBLE_EVM_STUBS)/UniqueRefungibleToken.raw ./.maintain/scripts/compile_stub.sh INPUT=$(REFUNGIBLE_EVM_STUBS)/$< OUTPUT=$(REFUNGIBLE_TOKEN_EVM_ABI) ./.maintain/scripts/generate_abi.sh - -UniqueRefungible: UniqueRefungible.sol - INPUT=$(REFUNGIBLE_EVM_STUBS)/$< OUTPUT=$(REFUNGIBLE_EVM_STUBS)/UniqueRefungible.raw ./.maintain/scripts/compile_stub.sh - INPUT=$(REFUNGIBLE_EVM_STUBS)/$< OUTPUT=$(REFUNGIBLE_EVM_ABI) ./.maintain/scripts/generate_abi.sh ContractHelpers: ContractHelpers.sol INPUT=$(CONTRACT_HELPERS_STUBS)/$< OUTPUT=$(CONTRACT_HELPERS_STUBS)/ContractHelpers.raw ./.maintain/scripts/compile_stub.sh --- a/pallets/refungible/src/erc.rs +++ b/pallets/refungible/src/erc.rs @@ -34,8 +34,9 @@ CommonEvmHandler, CollectionCall, static_property::{key, value as property_value}, }, + eth::collection_id_to_address, }; -use pallet_evm::{account::CrossAccountId, PrecompileHandle}; +use pallet_evm::{account::CrossAccountId, Pallet as PalletEvm, PrecompileHandle}; use pallet_evm_coder_substrate::{call, dispatch_to_evm}; use pallet_structure::{SelfWeightOf as StructureWeight, weights::WeightInfo as _}; use sp_core::H160; @@ -46,8 +47,8 @@ }; use crate::{ - AccountBalance, Config, CreateItemData, Pallet, RefungibleHandle, SelfWeightOf, - TokenProperties, TokensMinted, weights::WeightInfo, + AccountBalance, Balance, Config, CreateItemData, Pallet, RefungibleHandle, SelfWeightOf, + TokenProperties, TokensMinted, TotalSupply, weights::WeightInfo, }; /// @title A contract that allows to set and delete token properties and change token property permissions. @@ -331,16 +332,49 @@ Err("not implemented".into()) } - /// @dev Not implemented + /// @notice Transfer ownership of an RFT -- THE CALLER IS RESPONSIBLE + /// TO CONFIRM THAT `to` IS CAPABLE OF RECEIVING NFTS OR ELSE + /// THEY MAY BE PERMANENTLY LOST + /// @dev Throws unless `msg.sender` is the current owner or an authorized + /// operator for this RFT. Throws if `from` is not the current owner. Throws + /// if `to` is the zero address. Throws if `tokenId` is not a valid RFT. + /// Throws if RFT pieces have multiple owners. + /// @param from The current owner of the NFT + /// @param to The new owner + /// @param tokenId The NFT to transfer + /// @param _value Not used for an NFT + #[weight(>::transfer_from_creating_removing())] fn transfer_from( &mut self, - _caller: caller, - _from: address, - _to: address, - _token_id: uint256, + caller: caller, + from: address, + to: address, + token_id: uint256, _value: value, ) -> Result { - Err("not implemented".into()) + let caller = T::CrossAccountId::from_eth(caller); + let from = T::CrossAccountId::from_eth(from); + let to = T::CrossAccountId::from_eth(to); + let token = token_id.try_into()?; + let budget = self + .recorder + .weight_calls_budget(>::find_parent()); + + let balance = balance(&self, token, &from)?; + ensure_single_owner(&self, token, balance)?; + + >::transfer_from(self, &caller, &from, &to, token, balance, &budget) + .map_err(dispatch_to_evm::)?; + + >::deposit_log( + ERC721Events::Transfer { + from: *from.as_eth(), + to: *to.as_eth(), + token_id: token_id.into(), + } + .to_log(collection_id_to_address(self.id)), + ); + Ok(()) } /// @dev Not implemented @@ -378,12 +412,48 @@ } } +/// Returns amount of pieces of `token` that `owner` have +fn balance( + collection: &RefungibleHandle, + token: TokenId, + owner: &T::CrossAccountId, +) -> Result { + collection.consume_store_reads(1)?; + let balance = >::get((collection.id, token, &owner)); + Ok(balance) +} + +/// Throws if `owner_balance` is lower than total amount of `token` pieces +fn ensure_single_owner( + collection: &RefungibleHandle, + token: TokenId, + owner_balance: u128, +) -> Result<()> { + collection.consume_store_reads(1)?; + let total_supply = >::get((collection.id, token)); + if total_supply != owner_balance { + return Err("token has multiple owners".into()); + } + Ok(()) +} + /// @title ERC721 Token that can be irreversibly burned (destroyed). #[solidity_interface(name = "ERC721Burnable")] impl RefungibleHandle { - /// @dev Not implemented - fn burn(&mut self, _caller: caller, _token_id: uint256, _value: value) -> Result { - Err("not implemented".into()) + /// @notice Burns a specific ERC721 token. + /// @dev Throws unless `msg.sender` is the current RFT owner, or an authorized + /// operator of the current owner. + /// @param tokenId The RFT to approve + #[weight(>::burn_item_fully())] + fn burn(&mut self, caller: caller, token_id: uint256) -> Result { + let caller = T::CrossAccountId::from_eth(caller); + let token = token_id.try_into()?; + + let balance = balance(&self, token, &caller)?; + ensure_single_owner(&self, token, balance)?; + + >::burn(self, &caller, token, balance).map_err(dispatch_to_evm::)?; + Ok(()) } } @@ -555,6 +625,75 @@ /// @title Unique extensions for ERC721. #[solidity_interface(name = "ERC721UniqueExtensions")] impl RefungibleHandle { + /// @notice Transfer ownership of an RFT + /// @dev Throws unless `msg.sender` is the current owner. Throws if `to` + /// is the zero address. Throws if `tokenId` is not a valid RFT. + /// Throws if RFT pieces have multiple owners. + /// @param to The new owner + /// @param tokenId The RFT to transfer + /// @param _value Not used for an RFT + #[weight(>::transfer_creating_removing())] + fn transfer( + &mut self, + caller: caller, + to: address, + token_id: uint256, + _value: value, + ) -> Result { + let caller = T::CrossAccountId::from_eth(caller); + let to = T::CrossAccountId::from_eth(to); + let token = token_id.try_into()?; + let budget = self + .recorder + .weight_calls_budget(>::find_parent()); + + let balance = balance(&self, token, &caller)?; + ensure_single_owner(&self, token, balance)?; + + >::transfer(self, &caller, &to, token, balance, &budget) + .map_err(dispatch_to_evm::)?; + >::deposit_log( + ERC721Events::Transfer { + from: *caller.as_eth(), + to: *to.as_eth(), + token_id: token_id.into(), + } + .to_log(collection_id_to_address(self.id)), + ); + Ok(()) + } + + /// @notice Burns a specific ERC721 token. + /// @dev Throws unless `msg.sender` is the current owner or an authorized + /// operator for this RFT. Throws if `from` is not the current owner. Throws + /// if `to` is the zero address. Throws if `tokenId` is not a valid RFT. + /// Throws if RFT pieces have multiple owners. + /// @param from The current owner of the RFT + /// @param tokenId The RFT to transfer + /// @param _value Not used for an RFT + #[weight(>::burn_from())] + fn burn_from( + &mut self, + caller: caller, + from: address, + token_id: uint256, + _value: value, + ) -> Result { + let caller = T::CrossAccountId::from_eth(caller); + let from = T::CrossAccountId::from_eth(from); + let token = token_id.try_into()?; + let budget = self + .recorder + .weight_calls_budget(>::find_parent()); + + let balance = balance(&self, token, &caller)?; + ensure_single_owner(&self, token, balance)?; + + >::burn_from(self, &caller, &from, token, balance, &budget) + .map_err(dispatch_to_evm::)?; + Ok(()) + } + /// @notice Returns next free RFT ID. fn next_token_id(&self) -> Result { self.consume_store_reads(1)?; --- a/pallets/refungible/src/lib.rs +++ b/pallets/refungible/src/lib.rs @@ -454,6 +454,14 @@ >::unnest_if_nested(owner, collection.id, token); >::insert((collection.id, owner), account_balance); Self::burn_token_unchecked(collection, token)?; + >::deposit_log( + ERC721Events::Transfer { + from: *owner.as_eth(), + to: H160::default(), + token_id: token.into(), + } + .to_log(collection_id_to_address(collection.id)), + ); >::deposit_event(CommonEvent::ItemDestroyed( collection.id, token, --- a/pallets/refungible/src/stubs/UniqueRefungible.sol +++ b/pallets/refungible/src/stubs/UniqueRefungible.sol @@ -51,44 +51,15 @@ event MintingFinished(); } -// Selector: 0784ee64 -contract ERC721UniqueExtensions is Dummy, ERC165 { - // @notice Returns next free RFT ID. - // - // Selector: nextTokenId() 75794a3c - function nextTokenId() public view returns (uint256) { - require(false, stub_error); - dummy; - return 0; - } - - // Selector: mintBulk(address,uint256[]) 44a9945e - function mintBulk(address to, uint256[] memory tokenIds) - public - returns (bool) - { - require(false, stub_error); - to; - tokenIds; - dummy = 0; - return false; - } - - // Selector: mintBulkWithTokenURI(address,(uint256,string)[]) 36543006 - function mintBulkWithTokenURI(address to, Tuple0[] memory tokens) - public - returns (bool) - { - require(false, stub_error); - to; - tokens; - dummy = 0; - return false; - } -} - // Selector: 41369377 contract TokenProperties is Dummy, ERC165 { + // @notice Set permissions for token property. + // @dev Throws error if `msg.sender` is not admin or owner of the collection. + // @param key Property key. + // @param is_mutable Permission to mutate property. + // @param collection_admin Permission to mutate property by collection admin if property is mutable. + // @param token_owner Permission to mutate property by token owner if property is mutable. + // // Selector: setTokenPropertyPermission(string,bool,bool,bool) 222d97fa function setTokenPropertyPermission( string memory key, @@ -104,6 +75,12 @@ dummy = 0; } + // @notice Set token property value. + // @dev Throws error if `msg.sender` has no permission to edit the property. + // @param tokenId ID of the token. + // @param key Property key. + // @param value Property value. + // // Selector: setProperty(uint256,string,bytes) 1752d67b function setProperty( uint256 tokenId, @@ -117,6 +94,11 @@ dummy = 0; } + // @notice Delete token property value. + // @dev Throws error if `msg.sender` has no permission to edit the property. + // @param tokenId ID of the token. + // @param key Property key. + // // Selector: deleteProperty(uint256,string) 066111d1 function deleteProperty(uint256 tokenId, string memory key) public { require(false, stub_error); @@ -125,7 +107,11 @@ dummy = 0; } - // Throws error if key not found + // @notice Get token property value. + // @dev Throws error if key not found + // @param tokenId ID of the token. + // @param key Property key. + // @return Property value bytes // // Selector: property(uint256,string) 7228c327 function property(uint256 tokenId, string memory key) @@ -143,7 +129,10 @@ // Selector: 42966c68 contract ERC721Burnable is Dummy, ERC165 { - // @dev Not implemented + // @notice Burns a specific ERC721 token. + // @dev Throws unless `msg.sender` is the current RFT owner, or an authorized + // operator of the current owner. + // @param tokenId The RFT to approve // // Selector: burn(uint256) 42966c68 function burn(uint256 tokenId) public { @@ -155,6 +144,12 @@ // Selector: 58800161 contract ERC721 is Dummy, ERC165, ERC721Events { + // @notice Count all RFTs assigned to an owner + // @dev RFTs assigned to the zero address are considered invalid, and this + // function throws for queries about the zero address. + // @param owner An address for whom to query the balance + // @return The number of RFTs owned by `owner`, possibly zero + // // Selector: balanceOf(address) 70a08231 function balanceOf(address owner) public view returns (uint256) { require(false, stub_error); @@ -203,7 +198,17 @@ dummy = 0; } - // @dev Not implemented + // @notice Transfer ownership of an RFT -- THE CALLER IS RESPONSIBLE + // TO CONFIRM THAT `to` IS CAPABLE OF RECEIVING NFTS OR ELSE + // THEY MAY BE PERMANENTLY LOST + // @dev Throws unless `msg.sender` is the current owner or an authorized + // operator for this RFT. Throws if `from` is not the current owner. Throws + // if `to` is the zero address. Throws if `tokenId` is not a valid RFT. + // Throws if RFT pieces have multiple owners. + // @param from The current owner of the NFT + // @param to The new owner + // @param tokenId The NFT to transfer + // @param _value Not used for an NFT // // Selector: transferFrom(address,address,uint256) 23b872dd function transferFrom( @@ -266,6 +271,8 @@ // Selector: 5b5e139f contract ERC721Metadata is Dummy, ERC165 { + // @notice A descriptive name for a collection of RFTs in this contract + // // Selector: name() 06fdde03 function name() public view returns (string memory) { require(false, stub_error); @@ -273,6 +280,8 @@ return ""; } + // @notice An abbreviated name for RFTs in this contract + // // Selector: symbol() 95d89b41 function symbol() public view returns (string memory) { require(false, stub_error); @@ -280,7 +289,15 @@ return ""; } - // Returns token's const_metadata + // @notice A distinct Uniform Resource Identifier (URI) for a given asset. + // + // @dev If the token has a `url` property and it is not empty, it is returned. + // Else If the collection does not have a property with key `schemaName` or its value is not equal to `ERC721Metadata`, it return an error `tokenURI not set`. + // If the collection property `baseURI` is empty or absent, return "" (empty string) + // otherwise, if token property `suffix` present and is non-empty, return concatenation of baseURI and suffix + // otherwise, return concatenation of `baseURI` and stringified token id (decimal stringifying, without paddings). + // + // @return token's const_metadata // // Selector: tokenURI(uint256) c87b56dd function tokenURI(uint256 tokenId) public view returns (string memory) { @@ -300,8 +317,11 @@ return false; } - // `token_id` should be obtained with `next_token_id` method, - // unlike standard, you can't specify it manually + // @notice Function to mint token. + // @dev `tokenId` should be obtained with `nextTokenId` method, + // unlike standard, you can't specify it manually + // @param to The new owner + // @param tokenId ID of the minted RFT // // Selector: mint(address,uint256) 40c10f19 function mint(address to, uint256 tokenId) public returns (bool) { @@ -312,8 +332,12 @@ return false; } - // `token_id` should be obtained with `next_token_id` method, - // unlike standard, you can't specify it manually + // @notice Function to mint token with the given tokenUri. + // @dev `tokenId` should be obtained with `nextTokenId` method, + // unlike standard, you can't specify it manually + // @param to The new owner + // @param tokenId ID of the minted RFT + // @param tokenUri Token URI that would be stored in the RFT properties // // Selector: mintWithTokenURI(address,uint256,string) 50bb4e7f function mintWithTokenURI( @@ -341,6 +365,11 @@ // Selector: 780e9d63 contract ERC721Enumerable is Dummy, ERC165 { + // @notice Enumerate valid RFTs + // @param index A counter less than `totalSupply()` + // @return The token identifier for the `index`th NFT, + // (sort order not specified) + // // Selector: tokenByIndex(uint256) 4f6ccce7 function tokenByIndex(uint256 index) public view returns (uint256) { require(false, stub_error); @@ -364,6 +393,10 @@ return 0; } + // @notice Count RFTs tracked by this contract + // @return A count of valid RFTs tracked by this contract, where each one of + // them has an assigned and queryable owner not equal to the zero address + // // Selector: totalSupply() 18160ddd function totalSupply() public view returns (uint256) { require(false, stub_error); @@ -599,6 +632,87 @@ } } +// Selector: d74d154f +contract ERC721UniqueExtensions is Dummy, ERC165 { + // @notice Transfer ownership of an RFT + // @dev Throws unless `msg.sender` is the current owner. Throws if `to` + // is the zero address. Throws if `tokenId` is not a valid RFT. + // Throws if RFT pieces have multiple owners. + // @param to The new owner + // @param tokenId The RFT to transfer + // @param _value Not used for an RFT + // + // Selector: transfer(address,uint256) a9059cbb + function transfer(address to, uint256 tokenId) public { + require(false, stub_error); + to; + tokenId; + dummy = 0; + } + + // @notice Burns a specific ERC721 token. + // @dev Throws unless `msg.sender` is the current owner or an authorized + // operator for this RFT. Throws if `from` is not the current owner. Throws + // if `to` is the zero address. Throws if `tokenId` is not a valid RFT. + // Throws if RFT pieces have multiple owners. + // @param from The current owner of the RFT + // @param tokenId The RFT to transfer + // @param _value Not used for an RFT + // + // Selector: burnFrom(address,uint256) 79cc6790 + function burnFrom(address from, uint256 tokenId) public { + require(false, stub_error); + from; + tokenId; + dummy = 0; + } + + // @notice Returns next free RFT ID. + // + // Selector: nextTokenId() 75794a3c + function nextTokenId() public view returns (uint256) { + require(false, stub_error); + dummy; + return 0; + } + + // @notice Function to mint multiple tokens. + // @dev `tokenIds` should be an array of consecutive numbers and first number + // should be obtained with `nextTokenId` method + // @param to The new owner + // @param tokenIds IDs of the minted RFTs + // + // Selector: mintBulk(address,uint256[]) 44a9945e + function mintBulk(address to, uint256[] memory tokenIds) + public + returns (bool) + { + require(false, stub_error); + to; + tokenIds; + dummy = 0; + return false; + } + + // @notice Function to mint multiple tokens with the given tokenUris. + // @dev `tokenIds` is array of pairs of token ID and token URI. Token IDs should be consecutive + // numbers and first number should be obtained with `nextTokenId` method + // @param to The new owner + // @param tokens array of pairs of token ID and token URI for minted tokens + // + // Selector: mintBulkWithTokenURI(address,(uint256,string)[]) 36543006 + function mintBulkWithTokenURI(address to, Tuple0[] memory tokens) + public + returns (bool) + { + require(false, stub_error); + to; + tokens; + dummy = 0; + return false; + } +} + contract UniqueRefungible is Dummy, ERC165, --- a/tests/src/eth/api/UniqueRefungible.sol +++ b/tests/src/eth/api/UniqueRefungible.sol @@ -42,26 +42,15 @@ event MintingFinished(); } -// Selector: 0784ee64 -interface ERC721UniqueExtensions is Dummy, ERC165 { - // @notice Returns next free RFT ID. - // - // Selector: nextTokenId() 75794a3c - function nextTokenId() external view returns (uint256); - - // Selector: mintBulk(address,uint256[]) 44a9945e - function mintBulk(address to, uint256[] memory tokenIds) - external - returns (bool); - - // Selector: mintBulkWithTokenURI(address,(uint256,string)[]) 36543006 - function mintBulkWithTokenURI(address to, Tuple0[] memory tokens) - external - returns (bool); -} - // Selector: 41369377 interface TokenProperties is Dummy, ERC165 { + // @notice Set permissions for token property. + // @dev Throws error if `msg.sender` is not admin or owner of the collection. + // @param key Property key. + // @param is_mutable Permission to mutate property. + // @param collection_admin Permission to mutate property by collection admin if property is mutable. + // @param token_owner Permission to mutate property by token owner if property is mutable. + // // Selector: setTokenPropertyPermission(string,bool,bool,bool) 222d97fa function setTokenPropertyPermission( string memory key, @@ -70,6 +59,12 @@ bool tokenOwner ) external; + // @notice Set token property value. + // @dev Throws error if `msg.sender` has no permission to edit the property. + // @param tokenId ID of the token. + // @param key Property key. + // @param value Property value. + // // Selector: setProperty(uint256,string,bytes) 1752d67b function setProperty( uint256 tokenId, @@ -77,10 +72,19 @@ bytes memory value ) external; + // @notice Delete token property value. + // @dev Throws error if `msg.sender` has no permission to edit the property. + // @param tokenId ID of the token. + // @param key Property key. + // // Selector: deleteProperty(uint256,string) 066111d1 function deleteProperty(uint256 tokenId, string memory key) external; - // Throws error if key not found + // @notice Get token property value. + // @dev Throws error if key not found + // @param tokenId ID of the token. + // @param key Property key. + // @return Property value bytes // // Selector: property(uint256,string) 7228c327 function property(uint256 tokenId, string memory key) @@ -91,7 +95,10 @@ // Selector: 42966c68 interface ERC721Burnable is Dummy, ERC165 { - // @dev Not implemented + // @notice Burns a specific ERC721 token. + // @dev Throws unless `msg.sender` is the current RFT owner, or an authorized + // operator of the current owner. + // @param tokenId The RFT to approve // // Selector: burn(uint256) 42966c68 function burn(uint256 tokenId) external; @@ -99,6 +106,12 @@ // Selector: 58800161 interface ERC721 is Dummy, ERC165, ERC721Events { + // @notice Count all RFTs assigned to an owner + // @dev RFTs assigned to the zero address are considered invalid, and this + // function throws for queries about the zero address. + // @param owner An address for whom to query the balance + // @return The number of RFTs owned by `owner`, possibly zero + // // Selector: balanceOf(address) 70a08231 function balanceOf(address owner) external view returns (uint256); @@ -124,7 +137,17 @@ uint256 tokenId ) external; - // @dev Not implemented + // @notice Transfer ownership of an RFT -- THE CALLER IS RESPONSIBLE + // TO CONFIRM THAT `to` IS CAPABLE OF RECEIVING NFTS OR ELSE + // THEY MAY BE PERMANENTLY LOST + // @dev Throws unless `msg.sender` is the current owner or an authorized + // operator for this RFT. Throws if `from` is not the current owner. Throws + // if `to` is the zero address. Throws if `tokenId` is not a valid RFT. + // Throws if RFT pieces have multiple owners. + // @param from The current owner of the NFT + // @param to The new owner + // @param tokenId The NFT to transfer + // @param _value Not used for an NFT // // Selector: transferFrom(address,address,uint256) 23b872dd function transferFrom( @@ -159,13 +182,25 @@ // Selector: 5b5e139f interface ERC721Metadata is Dummy, ERC165 { + // @notice A descriptive name for a collection of RFTs in this contract + // // Selector: name() 06fdde03 function name() external view returns (string memory); + // @notice An abbreviated name for RFTs in this contract + // // Selector: symbol() 95d89b41 function symbol() external view returns (string memory); - // Returns token's const_metadata + // @notice A distinct Uniform Resource Identifier (URI) for a given asset. + // + // @dev If the token has a `url` property and it is not empty, it is returned. + // Else If the collection does not have a property with key `schemaName` or its value is not equal to `ERC721Metadata`, it return an error `tokenURI not set`. + // If the collection property `baseURI` is empty or absent, return "" (empty string) + // otherwise, if token property `suffix` present and is non-empty, return concatenation of baseURI and suffix + // otherwise, return concatenation of `baseURI` and stringified token id (decimal stringifying, without paddings). + // + // @return token's const_metadata // // Selector: tokenURI(uint256) c87b56dd function tokenURI(uint256 tokenId) external view returns (string memory); @@ -176,14 +211,21 @@ // Selector: mintingFinished() 05d2035b function mintingFinished() external view returns (bool); - // `token_id` should be obtained with `next_token_id` method, - // unlike standard, you can't specify it manually + // @notice Function to mint token. + // @dev `tokenId` should be obtained with `nextTokenId` method, + // unlike standard, you can't specify it manually + // @param to The new owner + // @param tokenId ID of the minted RFT // // Selector: mint(address,uint256) 40c10f19 function mint(address to, uint256 tokenId) external returns (bool); - // `token_id` should be obtained with `next_token_id` method, - // unlike standard, you can't specify it manually + // @notice Function to mint token with the given tokenUri. + // @dev `tokenId` should be obtained with `nextTokenId` method, + // unlike standard, you can't specify it manually + // @param to The new owner + // @param tokenId ID of the minted RFT + // @param tokenUri Token URI that would be stored in the RFT properties // // Selector: mintWithTokenURI(address,uint256,string) 50bb4e7f function mintWithTokenURI( @@ -200,6 +242,11 @@ // Selector: 780e9d63 interface ERC721Enumerable is Dummy, ERC165 { + // @notice Enumerate valid RFTs + // @param index A counter less than `totalSupply()` + // @return The token identifier for the `index`th NFT, + // (sort order not specified) + // // Selector: tokenByIndex(uint256) 4f6ccce7 function tokenByIndex(uint256 index) external view returns (uint256); @@ -211,6 +258,10 @@ view returns (uint256); + // @notice Count RFTs tracked by this contract + // @return A count of valid RFTs tracked by this contract, where each one of + // them has an assigned and queryable owner not equal to the zero address + // // Selector: totalSupply() 18160ddd function totalSupply() external view returns (uint256); } @@ -363,6 +414,59 @@ function setCollectionMintMode(bool mode) external; } +// Selector: d74d154f +interface ERC721UniqueExtensions is Dummy, ERC165 { + // @notice Transfer ownership of an RFT + // @dev Throws unless `msg.sender` is the current owner. Throws if `to` + // is the zero address. Throws if `tokenId` is not a valid RFT. + // Throws if RFT pieces have multiple owners. + // @param to The new owner + // @param tokenId The RFT to transfer + // @param _value Not used for an RFT + // + // Selector: transfer(address,uint256) a9059cbb + function transfer(address to, uint256 tokenId) external; + + // @notice Burns a specific ERC721 token. + // @dev Throws unless `msg.sender` is the current owner or an authorized + // operator for this RFT. Throws if `from` is not the current owner. Throws + // if `to` is the zero address. Throws if `tokenId` is not a valid RFT. + // Throws if RFT pieces have multiple owners. + // @param from The current owner of the RFT + // @param tokenId The RFT to transfer + // @param _value Not used for an RFT + // + // Selector: burnFrom(address,uint256) 79cc6790 + function burnFrom(address from, uint256 tokenId) external; + + // @notice Returns next free RFT ID. + // + // Selector: nextTokenId() 75794a3c + function nextTokenId() external view returns (uint256); + + // @notice Function to mint multiple tokens. + // @dev `tokenIds` should be an array of consecutive numbers and first number + // should be obtained with `nextTokenId` method + // @param to The new owner + // @param tokenIds IDs of the minted RFTs + // + // Selector: mintBulk(address,uint256[]) 44a9945e + function mintBulk(address to, uint256[] memory tokenIds) + external + returns (bool); + + // @notice Function to mint multiple tokens with the given tokenUris. + // @dev `tokenIds` is array of pairs of token ID and token URI. Token IDs should be consecutive + // numbers and first number should be obtained with `nextTokenId` method + // @param to The new owner + // @param tokens array of pairs of token ID and token URI for minted tokens + // + // Selector: mintBulkWithTokenURI(address,(uint256,string)[]) 36543006 + function mintBulkWithTokenURI(address to, Tuple0[] memory tokens) + external + returns (bool); +} + interface UniqueRefungible is Dummy, ERC165, --- a/tests/src/eth/reFungible.test.ts +++ b/tests/src/eth/reFungible.test.ts @@ -14,8 +14,8 @@ // You should have received a copy of the GNU General Public License // along with Unique Network. If not, see . -import {createCollectionExpectSuccess} from '../util/helpers'; -import {collectionIdToAddress, createEthAccount, createEthAccountWithBalance, evmCollection, evmCollectionHelpers, GAS_ARGS, getCollectionAddressFromResult, itWeb3, normalizeEvents, tokenIdToAddress} from './util/helpers'; +import {createCollectionExpectSuccess, UNIQUE} from '../util/helpers'; +import {collectionIdToAddress, createEthAccount, createEthAccountWithBalance, evmCollection, evmCollectionHelpers, GAS_ARGS, getCollectionAddressFromResult, itWeb3, normalizeEvents, recordEthFee, tokenIdToAddress} from './util/helpers'; import reFungibleAbi from './reFungibleAbi.json'; import reFungibleTokenAbi from './reFungibleTokenAbi.json'; import {expect} from 'chai'; @@ -26,7 +26,7 @@ const helper = evmCollectionHelpers(web3, caller); const result = await helper.methods.createRefungibleCollection('Mint collection', '6', '6').send(); const {collectionIdAddress} = await getCollectionAddressFromResult(api, result); - const contract = new web3.eth.Contract(reFungibleAbi as any, collectionIdAddress, {from: caller, ...GAS_ARGS}); + const contract = evmCollection(web3, caller, collectionIdAddress, {type: 'ReFungible'}); const nextTokenId = await contract.methods.nextTokenId().call(); await contract.methods.mint(caller, nextTokenId).send(); const totalSupply = await contract.methods.totalSupply().call(); @@ -38,7 +38,7 @@ const helper = evmCollectionHelpers(web3, caller); const result = await helper.methods.createRefungibleCollection('Mint collection', '6', '6').send(); const {collectionIdAddress} = await getCollectionAddressFromResult(api, result); - const contract = new web3.eth.Contract(reFungibleAbi as any, collectionIdAddress, {from: caller, ...GAS_ARGS}); + const contract = evmCollection(web3, caller, collectionIdAddress, {type: 'ReFungible'}); { const nextTokenId = await contract.methods.nextTokenId().call(); @@ -63,7 +63,7 @@ const helper = evmCollectionHelpers(web3, caller); const result = await helper.methods.createRefungibleCollection('Mint collection', '6', '6').send(); const {collectionIdAddress} = await getCollectionAddressFromResult(api, result); - const contract = new web3.eth.Contract(reFungibleAbi as any, collectionIdAddress, {from: caller, ...GAS_ARGS}); + const contract = evmCollection(web3, caller, collectionIdAddress, {type: 'ReFungible'}); const tokenId = await contract.methods.nextTokenId().call(); await contract.methods.mint(caller, tokenId).send(); @@ -79,7 +79,7 @@ const helper = evmCollectionHelpers(web3, caller); const result = await helper.methods.createRefungibleCollection('Mint collection', '6', '6').send(); const {collectionIdAddress, collectionId} = await getCollectionAddressFromResult(api, result); - const contract = new web3.eth.Contract(reFungibleAbi as any, collectionIdAddress, {from: caller, ...GAS_ARGS}); + const contract = evmCollection(web3, caller, collectionIdAddress, {type: 'ReFungible'}); const tokenId = await contract.methods.nextTokenId().call(); await contract.methods.mint(caller, tokenId).send(); @@ -105,7 +105,7 @@ let result = await helper.methods.createRefungibleCollection('Mint collection', '6', '6').send(); const {collectionIdAddress} = await getCollectionAddressFromResult(api, result); const receiver = createEthAccount(web3); - const contract = evmCollection(web3, owner, collectionIdAddress); + const contract = evmCollection(web3, owner, collectionIdAddress, {type: 'ReFungible'}); const nextTokenId = await contract.methods.nextTokenId().call(); expect(nextTokenId).to.be.equal('1'); @@ -137,7 +137,7 @@ const helper = evmCollectionHelpers(web3, caller); const result = await helper.methods.createRefungibleCollection('Mint collection', '6', '6').send(); const {collectionIdAddress} = await getCollectionAddressFromResult(api, result); - const contract = new web3.eth.Contract(reFungibleAbi as any, collectionIdAddress, {from: caller, ...GAS_ARGS}); + const contract = evmCollection(web3, caller, collectionIdAddress, {type: 'ReFungible'}); const receiver = createEthAccount(web3); @@ -189,8 +189,148 @@ expect(await contract.methods.tokenURI(+nextTokenId + 2).call()).to.be.equal('Test URI 2'); } }); + + itWeb3('Can perform burn()', async ({web3, api, privateKeyWrapper}) => { + const caller = await createEthAccountWithBalance(api, web3, privateKeyWrapper); + const helper = evmCollectionHelpers(web3, caller); + const result = await helper.methods.createRefungibleCollection('Mint collection', '6', '6').send(); + const {collectionIdAddress} = await getCollectionAddressFromResult(api, result); + const contract = evmCollection(web3, caller, collectionIdAddress, {type: 'ReFungible'}); + + const tokenId = await contract.methods.nextTokenId().call(); + await contract.methods.mint(caller, tokenId).send(); + { + const result = await contract.methods.burn(tokenId).send(); + const events = normalizeEvents(result.events); + + expect(events).to.be.deep.equal([ + { + address: collectionIdAddress, + event: 'Transfer', + args: { + from: caller, + to: '0x0000000000000000000000000000000000000000', + tokenId: tokenId.toString(), + }, + }, + ]); + } + }); + + itWeb3('Can perform transferFrom()', async ({web3, api, privateKeyWrapper}) => { + const caller = await createEthAccountWithBalance(api, web3, privateKeyWrapper); + const helper = evmCollectionHelpers(web3, caller); + const result = await helper.methods.createRefungibleCollection('Mint collection', '6', '6').send(); + const {collectionIdAddress} = await getCollectionAddressFromResult(api, result); + const contract = evmCollection(web3, caller, collectionIdAddress, {type: 'ReFungible'}); + + const receiver = createEthAccount(web3); + + const tokenId = await contract.methods.nextTokenId().call(); + await contract.methods.mint(caller, tokenId).send(); + { + const result = await contract.methods.transferFrom(caller, receiver, tokenId).send(); + const events = normalizeEvents(result.events); + expect(events).to.include.deep.members([ + { + address: collectionIdAddress, + event: 'Transfer', + args: { + from: caller, + to: receiver, + tokenId: tokenId.toString(), + }, + }, + ]); + } + + { + const balance = await contract.methods.balanceOf(receiver).call(); + expect(+balance).to.equal(1); + } + + { + const balance = await contract.methods.balanceOf(caller).call(); + expect(+balance).to.equal(0); + } + }); + + itWeb3('Can perform transfer()', async ({web3, api, privateKeyWrapper}) => { + const caller = await createEthAccountWithBalance(api, web3, privateKeyWrapper); + const helper = evmCollectionHelpers(web3, caller); + const result = await helper.methods.createRefungibleCollection('Mint collection', '6', '6').send(); + const {collectionIdAddress} = await getCollectionAddressFromResult(api, result); + const contract = evmCollection(web3, caller, collectionIdAddress, {type: 'ReFungible'}); + + const receiver = createEthAccount(web3); + + const tokenId = await contract.methods.nextTokenId().call(); + await contract.methods.mint(caller, tokenId).send(); + + { + const result = await contract.methods.transfer(receiver, tokenId).send(); + const events = normalizeEvents(result.events); + expect(events).to.include.deep.members([ + { + address: collectionIdAddress, + event: 'Transfer', + args: { + from: caller, + to: receiver, + tokenId: tokenId.toString(), + }, + }, + ]); + } + + { + const balance = await contract.methods.balanceOf(caller).call(); + expect(+balance).to.equal(0); + } + + { + const balance = await contract.methods.balanceOf(receiver).call(); + expect(+balance).to.equal(1); + } + }); }); +describe('RFT: Fees', () => { + itWeb3('transferFrom() call fee is less than 0.2UNQ', async ({web3, api, privateKeyWrapper}) => { + const caller = await createEthAccountWithBalance(api, web3, privateKeyWrapper); + const helper = evmCollectionHelpers(web3, caller); + const result = await helper.methods.createRefungibleCollection('Mint collection', '6', '6').send(); + const {collectionIdAddress} = await getCollectionAddressFromResult(api, result); + const contract = evmCollection(web3, caller, collectionIdAddress, {type: 'ReFungible'}); + + const receiver = createEthAccount(web3); + + const tokenId = await contract.methods.nextTokenId().call(); + await contract.methods.mint(caller, tokenId).send(); + + const cost = await recordEthFee(api, caller, () => contract.methods.transferFrom(caller, receiver, tokenId).send()); + expect(cost < BigInt(0.2 * Number(UNIQUE))); + expect(cost > 0n); + }); + + itWeb3('transfer() call fee is less than 0.2UNQ', async ({web3, api, privateKeyWrapper}) => { + const caller = await createEthAccountWithBalance(api, web3, privateKeyWrapper); + const helper = evmCollectionHelpers(web3, caller); + const result = await helper.methods.createRefungibleCollection('Mint collection', '6', '6').send(); + const {collectionIdAddress} = await getCollectionAddressFromResult(api, result); + const contract = evmCollection(web3, caller, collectionIdAddress, {type: 'ReFungible'}); + + const receiver = createEthAccount(web3); + + const tokenId = await contract.methods.nextTokenId().call(); + await contract.methods.mint(caller, tokenId).send(); + + const cost = await recordEthFee(api, caller, () => contract.methods.transfer(receiver, tokenId).send()); + expect(cost < BigInt(0.2 * Number(UNIQUE))); + expect(cost > 0n); + }); +}); + describe('Common metadata', () => { itWeb3('Returns collection name', async ({api, web3, privateKeyWrapper}) => { const collection = await createCollectionExpectSuccess({ @@ -200,7 +340,7 @@ const caller = await createEthAccountWithBalance(api, web3, privateKeyWrapper); const address = collectionIdToAddress(collection); - const contract = new web3.eth.Contract(reFungibleAbi as any, address, {from: caller, ...GAS_ARGS}); + const contract = evmCollection(web3, caller, address, {type: 'ReFungible'}); const name = await contract.methods.name().call(); expect(name).to.equal('token name'); @@ -214,7 +354,7 @@ const caller = await createEthAccountWithBalance(api, web3, privateKeyWrapper); const address = collectionIdToAddress(collection); - const contract = new web3.eth.Contract(reFungibleAbi as any, address, {from: caller, ...GAS_ARGS}); + const contract = evmCollection(web3, caller, address, {type: 'ReFungible'}); const symbol = await contract.methods.symbol().call(); expect(symbol).to.equal('TOK'); --- a/tests/src/eth/reFungibleAbi.json +++ b/tests/src/eth/reFungibleAbi.json @@ -136,6 +136,16 @@ "type": "function" }, { + "inputs": [ + { "internalType": "address", "name": "from", "type": "address" }, + { "internalType": "uint256", "name": "tokenId", "type": "uint256" } + ], + "name": "burnFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { "inputs": [{ "internalType": "string", "name": "key", "type": "string" }], "name": "collectionProperty", "outputs": [{ "internalType": "bytes", "name": "", "type": "bytes" }], @@ -498,6 +508,16 @@ }, { "inputs": [ + { "internalType": "address", "name": "to", "type": "address" }, + { "internalType": "uint256", "name": "tokenId", "type": "uint256" } + ], + "name": "transfer", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ { "internalType": "address", "name": "from", "type": "address" }, { "internalType": "address", "name": "to", "type": "address" }, { "internalType": "uint256", "name": "tokenId", "type": "uint256" } -- gitstuff