From 2e3bd9b2e217aa774060a26f1656987039890759 Mon Sep 17 00:00:00 2001 From: Mariusz Jasuwienas Date: Tue, 4 Feb 2025 09:01:09 +0100 Subject: [PATCH] feat: example usage in nft testing of our foundry library and hardhat plugin (#191) (#209) Signed-off-by: Mariusz Jasuwienas --- contracts/HtsSystemContractJson.sol | 20 ++- examples/foundry-hts/NFT.t.sol | 33 +++++ examples/foundry-hts/NFTConsole.t.sol | 25 ++++ examples/hardhat-hts/contracts/CallNFT.sol | 19 +++ examples/hardhat-hts/contracts/IERC721.sol | 103 ++++++++++++++ examples/hardhat-hts/package-lock.json | 4 +- examples/hardhat-hts/test/nft-info.test.js | 30 ++++ examples/hardhat-hts/test/nft-ownerof.test.js | 32 +++++ .../hardhat-hts/test/nft-transfer.test.js | 47 +++++++ examples/hardhat-hts/test/nft.test.js | 128 ++++++++++++++++++ src/forwarder/mirror-node-client.js | 7 +- src/slotmap.js | 12 +- 12 files changed, 450 insertions(+), 10 deletions(-) create mode 100644 examples/foundry-hts/NFT.t.sol create mode 100644 examples/foundry-hts/NFTConsole.t.sol create mode 100644 examples/hardhat-hts/contracts/CallNFT.sol create mode 100644 examples/hardhat-hts/contracts/IERC721.sol create mode 100644 examples/hardhat-hts/test/nft-info.test.js create mode 100644 examples/hardhat-hts/test/nft-ownerof.test.js create mode 100644 examples/hardhat-hts/test/nft-transfer.test.js create mode 100644 examples/hardhat-hts/test/nft.test.js diff --git a/contracts/HtsSystemContractJson.sol b/contracts/HtsSystemContractJson.sol index 2b6b59e0..1d8c3535 100644 --- a/contracts/HtsSystemContractJson.sol +++ b/contracts/HtsSystemContractJson.sol @@ -217,7 +217,7 @@ contract HtsSystemContractJson is HtsSystemContract { tokenInfo.token = _getHederaToken(json); tokenInfo.fixedFees = _getFixedFees(json); tokenInfo.fractionalFees = _getFractionalFees(json); - tokenInfo.royaltyFees = _getRoyaltyFees(json); + tokenInfo.royaltyFees = _getRoyaltyFees(_sanitizeFeesStructure(json)); tokenInfo.ledgerId = _getLedgerId(); tokenInfo.defaultKycStatus = false; // not available in the fetched JSON from mirror node tokenInfo.totalSupply = int64(vm.parseInt(vm.parseJsonString(json, ".total_supply"))); @@ -226,6 +226,16 @@ contract HtsSystemContractJson is HtsSystemContract { return tokenInfo; } + // In order to properly decode the bytes returned by the parseJson into the Solidity Structure, the full, + // correct structure has to be provided in the input json, with all of the corresponding fields. + function _sanitizeFeesStructure(string memory json) private pure returns (string memory) { + return vm.replace( + json, + "\"fallback_fee\":null}", + "\"fallback_fee\":{\"amount\":0,\"denominating_token_id\":\"\"}}" + ); + } + function _getHederaToken(string memory json) private returns (HederaToken memory token) { token.tokenKeys = _getTokenKeys(json); token.name = vm.parseJsonString(json, ".name"); @@ -394,7 +404,6 @@ contract HtsSystemContractJson is HtsSystemContract { if (!vm.keyExistsJson(json, ".custom_fees.royalty_fees")) { return new RoyaltyFee[](0); } - try vm.parseJson(json, ".custom_fees.royalty_fees") returns (bytes memory royaltyFeesBytes) { if (royaltyFeesBytes.length == 0) { return new RoyaltyFee[](0); @@ -404,11 +413,16 @@ contract HtsSystemContractJson is HtsSystemContract { for (uint i = 0; i < fees.length; i++) { string memory path = vm.replace(".custom_fees.royalty_fees[{i}]", "{i}", vm.toString(i)); address collectorAccount = mirrorNode().getAccountAddress(vm.parseJsonString(json, string.concat(path, ".collector_account_id"))); + bytes memory denominatingTokenBytes = vm.parseJson(json, string.concat(path, ".denominating_token_id")); + address denominatingToken; + if (keccak256(denominatingTokenBytes) != keccak256("")) { + denominatingToken = mirrorNode().getAccountAddress(vm.parseJsonString(json, string.concat(path, ".denominating_token_id"))); + } royaltyFees[i] = RoyaltyFee( int64(vm.parseJsonInt(json, string.concat(path, ".amount.numerator"))), int64(vm.parseJsonInt(json, string.concat(path, ".amount.denominator"))), int64(vm.parseJsonInt(json, string.concat(path, ".fallback_fee.amount"))), - mirrorNode().getAccountAddress(vm.parseJsonString(json, string.concat(path, ".denominating_token_id"))), + denominatingToken, collectorAccount == address(0), collectorAccount ); diff --git a/examples/foundry-hts/NFT.t.sol b/examples/foundry-hts/NFT.t.sol new file mode 100644 index 00000000..923fd5ac --- /dev/null +++ b/examples/foundry-hts/NFT.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; +import {htsSetup} from "hedera-forking/contracts/htsSetup.sol"; +import {IERC721} from "hedera-forking/contracts/IERC721.sol"; + +contract NFTExampleTest is Test { + // https://hashscan.io/mainnet/token/0.0.5083205 + address NFT_mainnet = 0x00000000000000000000000000000000004d9045; + + address private user; + + function setUp() external { + htsSetup(); + + user = makeAddr("user"); + dealERC721(NFT_mainnet, user, 3); + } + + function test_get_owner_of_existing_account() view external { + address owner = IERC721(NFT_mainnet).ownerOf(1); + assertNotEq(IERC721(NFT_mainnet).ownerOf(1), address(0)); + + // The assertion below cannot be guaranteed, since we can only query the current owner of the NFT, + // Note that the ownership of the NFT may change over time. + assertEq(owner, 0x000000000000000000000000000000000006889a); + } + + function test_dealt_nft_assigned_to_local_account() view external { + assertEq(IERC721(NFT_mainnet).ownerOf(3), user); + } +} diff --git a/examples/foundry-hts/NFTConsole.t.sol b/examples/foundry-hts/NFTConsole.t.sol new file mode 100644 index 00000000..6f30c074 --- /dev/null +++ b/examples/foundry-hts/NFTConsole.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {Test, console} from "forge-std/Test.sol"; +import {htsSetup} from "hedera-forking/contracts/htsSetup.sol"; +import {IERC721} from "hedera-forking/contracts/IERC721.sol"; + +contract NFTConsoleExampleTest is Test { + function setUp() external { + htsSetup(); + } + + function test_using_console_log() view external { + // https://hashscan.io/mainnet/token/0.0.5083205 + address NFT_mainnet = 0x00000000000000000000000000000000004d9045; + + string memory name = IERC721(NFT_mainnet).name(); + string memory symbol = IERC721(NFT_mainnet).symbol(); + string memory tokenURI = IERC721(NFT_mainnet).tokenURI(1); + assertEq(name, "THE BARKANEERS"); + assertEq(symbol, "BARKANEERS"); + assertEq(tokenURI, "ipfs://bafkreif4hpsgflzzvd7c4abx5u5xwrrjl7wkimbjtndvkxodklxdam5upm"); + console.log("name: %s, symbol: %s, tokenURI: %s", name, symbol, tokenURI); + } +} diff --git a/examples/hardhat-hts/contracts/CallNFT.sol b/examples/hardhat-hts/contracts/CallNFT.sol new file mode 100644 index 00000000..8aad2b4e --- /dev/null +++ b/examples/hardhat-hts/contracts/CallNFT.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {IERC721} from "./IERC721.sol"; +import {console} from "hardhat/console.sol"; + +contract CallNFT { + function getTokenName(address tokenAddress) external view returns (string memory) { + return IERC721(tokenAddress).name(); + } + + function invokeTransferFrom(address tokenAddress, address to, uint256 serialId) external { + // You can use `console.log` as usual + // https://hardhat.org/tutorial/debugging-with-hardhat-network#solidity--console.log + console.log("Transferring from %s to %s %s tokens", msg.sender, to, serialId); + address owner = IERC721(tokenAddress).ownerOf(serialId); + IERC721(tokenAddress).transferFrom(owner, to, serialId); + } +} diff --git a/examples/hardhat-hts/contracts/IERC721.sol b/examples/hardhat-hts/contracts/IERC721.sol new file mode 100644 index 00000000..a7189e07 --- /dev/null +++ b/examples/hardhat-hts/contracts/IERC721.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/** + * See https://ethereum.org/en/developers/docs/standards/tokens/erc-721/#events for more information. + */ +interface IERC721Events { + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); +} + +/** + * This interface is used to get the selectors and for testing. + * + * https://hips.hedera.com/hip/hip-218 + * https://hips.hedera.com/hip/hip-376 + */ +interface IERC721 { + /** + * @dev Returns the token collection name. + */ + function name() external view returns (string memory); + + /** + * @dev Returns the token collection symbol. + */ + function symbol() external view returns (string memory); + + /** + * @dev Returns the Uniform Resource Identifier (URI) for `serialId` token. + */ + function tokenURI(uint256 serialId) external view returns (string memory); + + /** + * @dev Returns the total amount of tokens stored by the contract. + */ + function totalSupply() external view returns (uint256); + + /** + * @dev Returns the number of tokens in `owner`'s account. + */ + function balanceOf(address owner) external view returns (uint256 balance); + + /** + * @dev Returns the owner of the `serialId` token. + * + * Requirements: + * - `serialId` must exist. + */ + function ownerOf(uint256 serialId) external view returns (address); + + /** + * @dev Transfers `serialId` token from `sender` to `recipient`. + * + * Requirements: + * - `sender` cannot be the zero address. + * - `recipient` cannot be the zero address. + * - `serialId` token must be owned by `sender`. + * - If the caller is not `sender`, it must be approved to move this token by either {approve} or {setApprovalForAll}. + * + * Emits a {Transfer} event. + */ + function transferFrom(address sender, address recipient, uint256 serialId) external payable; + + /** + * @dev Gives permission to `spender` to transfer `serialId` token to another account. + * The approval is cleared when the token is transferred. + * + * Only a single account can be approved at a time, so approving the zero address clears previous approvals. + * + * Requirements: + * - The caller must own the token or be an approved operator. + * - `serialId` must exist. + * + * Emits an {Approval} event. + */ + function approve(address spender, uint256 serialId) external payable; + + /** + * @dev Approve or remove `operator` as an operator for the caller. + * Operators can call {transferFrom} or {safeTransferFrom} for any token owned by the caller. + * + * Requirements: + * - The `operator` cannot be the address zero. + * + * Emits an {ApprovalForAll} event. + */ + function setApprovalForAll(address operator, bool approved) external; + + /** + * @dev Returns the account approved for `serialId` token. + * + * Requirements: + * - `serialId` must exist. + */ + function getApproved(uint256 serialId) external view returns (address operator); + + /** + * @dev Returns if the `operator` is allowed to manage all of the assets of `owner`. + * + * See {setApprovalForAll} + */ + function isApprovedForAll(address owner, address operator) external view returns (bool); +} diff --git a/examples/hardhat-hts/package-lock.json b/examples/hardhat-hts/package-lock.json index 2a25e6f1..f2b9d286 100644 --- a/examples/hardhat-hts/package-lock.json +++ b/examples/hardhat-hts/package-lock.json @@ -870,8 +870,8 @@ } }, "node_modules/@hashgraph/system-contracts-forking": { - "version": "0.0.1", - "resolved": "git+ssh://git@github.com/hashgraph/hedera-forking.git#daa80f304e396b35ab84135176ac6e48b8cab35f", + "version": "0.1.1", + "resolved": "git+ssh://git@github.com/hashgraph/hedera-forking.git#7c592e721c55d29d23052c49cc95db583129f923", "dev": true, "dependencies": { "ethers": "^6.1.0" diff --git a/examples/hardhat-hts/test/nft-info.test.js b/examples/hardhat-hts/test/nft-info.test.js new file mode 100644 index 00000000..78e506bc --- /dev/null +++ b/examples/hardhat-hts/test/nft-info.test.js @@ -0,0 +1,30 @@ +/*- + * Hedera Hardhat Forking Plugin + * + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { expect } = require('chai'); +// prettier-ignore +const { ethers: { getContractAt } } = require('hardhat'); + +describe('NFT example -- informational', function () { + it('should get name and symbol', async function () { + // https://hashscan.io/mainnet/token/0.0.4970613 + const nft = await getContractAt('IERC721', '0x00000000000000000000000000000000004bd875'); + expect(await nft['name']()).to.be.equal('Concierge Collectibles'); + expect(await nft['symbol']()).to.be.equal('Concierge Collectibles'); + }); +}); diff --git a/examples/hardhat-hts/test/nft-ownerof.test.js b/examples/hardhat-hts/test/nft-ownerof.test.js new file mode 100644 index 00000000..72cbba3d --- /dev/null +++ b/examples/hardhat-hts/test/nft-ownerof.test.js @@ -0,0 +1,32 @@ +/*- + * Hedera Hardhat Forking Plugin + * + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { expect } = require('chai'); +// prettier-ignore +const { ethers: { getContractAt } } = require('hardhat'); + +describe('NFT example -- ownerOf', function () { + it('should get `ownerOf` account holder', async function () { + // https://hashscan.io/mainnet/token/0.0.4970613 + const nft = await getContractAt('IERC721', '0x00000000000000000000000000000000004bd875'); + + // The assertion below cannot be guaranteed, since we can only query the current owner of the NFT, + // Note that the ownership of the NFT may change over time. + expect(await nft['ownerOf'](1)).to.be.equal('0x0000000000000000000000000000000000161927'); + }); +}); diff --git a/examples/hardhat-hts/test/nft-transfer.test.js b/examples/hardhat-hts/test/nft-transfer.test.js new file mode 100644 index 00000000..bd7ca92a --- /dev/null +++ b/examples/hardhat-hts/test/nft-transfer.test.js @@ -0,0 +1,47 @@ +/*- + * Hedera Hardhat Forking Plugin + * + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { expect } = require('chai'); +// prettier-ignore +const { ethers: { getSigner, getSigners, getContractAt }, network: { provider } } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-toolbox/network-helpers'); + +describe('NFT example -- transferFrom', function () { + async function id() { + return [(await getSigners())[0]]; + } + + it("should `transferFrom` tokens from account holder to one of Hardhat' signers", async function () { + const [receiver] = await loadFixture(id); + + // https://hashscan.io/mainnet/token/0.0.4970613 + const nft = await getContractAt('IERC721', '0x00000000000000000000000000000000004bd875'); + + const holderAddress = await nft['ownerOf'](1n); + + await provider.request({ + method: 'hardhat_impersonateAccount', + params: [holderAddress], + }); + + const holder = await getSigner(holderAddress); + await nft.connect(holder)['transferFrom'](holder, receiver, 1n); + + expect(await nft['ownerOf'](1n)).to.be.equal(receiver.address); + }); +}); diff --git a/examples/hardhat-hts/test/nft.test.js b/examples/hardhat-hts/test/nft.test.js new file mode 100644 index 00000000..aa35e4ea --- /dev/null +++ b/examples/hardhat-hts/test/nft.test.js @@ -0,0 +1,128 @@ +/*- + * Hedera Hardhat Forking Plugin + * + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { expect } = require('chai'); +const { ethers, network } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-toolbox/network-helpers'); + +/** + * Wrapper around `Contract::connect` to reify its return type. + * + * @param {import('ethers').Contract} contract + * @param {import('@nomicfoundation/hardhat-ethers/signers').HardhatEthersSigner} signer + * @returns {import('ethers').Contract} + */ +const connectAs = (contract, signer) => + /**@type{import('ethers').Contract}*/ (contract.connect(signer)); + +describe('NFT example', function () { + /** + * `signers[0]` is used in test `nft-transfer.test` + * Signers need to be different because otherwise the state is shared between test suites. + * This is because to use the same fixture, the `id` function needs to be shared among them. + * + * See https://hardhat.org/hardhat-network-helpers/docs/reference#fixtures for more details. + */ + async function id() { + const [, receiver, spender] = await ethers.getSigners(); + return { receiver, spender }; + } + + /** + * https://hashscan.io/mainnet/token/0.0.5083205 + * https://mainnet.mirrornode.hedera.com/api/v1/tokens/0.0.5083205 + */ + const nftAddress = '0x00000000000000000000000000000000004d9045'; + + /** @type {import('ethers').Contract} */ + let nft; + + /** + * @type {import('@nomicfoundation/hardhat-ethers/signers').HardhatEthersSigner} + */ + let holder; + + beforeEach(async function () { + nft = await ethers.getContractAt('IERC721', nftAddress); + const holderAddress = await nft['ownerOf'](1n); + // https://hardhat.org/hardhat-network/docs/reference#hardhat_impersonateaccount + await network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [holderAddress], + }); + holder = await ethers.getSigner(holderAddress); + }); + + it('should get name, symbol and tokenURI', async function () { + const name = await nft['name'](); + const symbol = await nft['symbol'](); + const tokenURI = await nft['tokenURI'](1n); + + expect(name).to.be.equal('THE BARKANEERS'); + expect(symbol).to.be.equal('BARKANEERS'); + expect(tokenURI).to.be.equal( + 'ipfs://bafkreif4hpsgflzzvd7c4abx5u5xwrrjl7wkimbjtndvkxodklxdam5upm' + ); + }); + + it('should get Token name through a contract call', async function () { + const CallToken = await ethers.getContractFactory('CallNFT'); + const callToken = await CallToken.deploy(); + + expect(await callToken['getTokenName'](nftAddress)).to.be.equal('THE BARKANEERS'); + }); + + it('should get `ownerOf` account holder', async function () { + const owner = await nft['ownerOf'](1n); + expect(owner).to.be.equal(holder.address); + }); + + it("should `transferFrom` tokens from account holder after `approve`d one of Hardhat' signers", async function () { + const { receiver, spender } = await loadFixture(id); + const serialId = 1n; + expect(await nft['ownerOf'](serialId)).to.be.equal(holder.address); + await connectAs(nft, holder)['approve'](spender, serialId); + expect(await nft['getApproved'](serialId)).to.be.equal(spender.address); + await connectAs(nft, spender)['transferFrom'](holder, receiver, serialId); + expect(await nft['getApproved'](serialId)).to.be.equal( + '0x0000000000000000000000000000000000000000' + ); + expect(await nft['ownerOf'](serialId)).to.be.equal(receiver); + }); + + it("should indirectly `transferFrom` tokens from account holder after `approve`d one of Hardhat' signers", async function () { + const { receiver, spender } = await loadFixture(id); + const serialId = 1n; + const ownerAddress = await nft['ownerOf'](serialId); + await network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [ownerAddress], + }); + const owner = await ethers.getSigner(ownerAddress); + expect(await nft['ownerOf'](serialId)).to.be.equal(owner.address); + + const CallToken = await ethers.getContractFactory('CallNFT'); + const callToken = await CallToken.deploy(); + const callTokenAddress = await callToken.getAddress(); + + await connectAs(nft, owner)['approve'](callTokenAddress, serialId); + await connectAs(callToken, spender)['invokeTransferFrom'](nftAddress, receiver, serialId); + expect(await nft['getApproved'](serialId)).to.not.be.equal(spender.address); + expect(await nft['ownerOf'](serialId)).to.be.equal(holder); + }); +}); diff --git a/src/forwarder/mirror-node-client.js b/src/forwarder/mirror-node-client.js index 77446bd3..012788ef 100644 --- a/src/forwarder/mirror-node-client.js +++ b/src/forwarder/mirror-node-client.js @@ -65,12 +65,11 @@ class MirrorNodeClient { * * @param {string} tokenId the token ID to fetch. * @param {number} serialId the serial ID of the NFT to fetch. - * @param {number} blockNumber + * @param {number} _blockNumber * @returns {Promise | null>} a `Promise` resolving to the token information or null if not found. */ - async getNftByTokenIdAndSerial(tokenId, serialId, blockNumber) { - const timestamp = await this.getBlockQueryParam(blockNumber); - return this._get(`tokens/${tokenId}/nft/${serialId}?${timestamp}`); + async getNftByTokenIdAndSerial(tokenId, serialId, _blockNumber) { + return this._get(`tokens/${tokenId}/nfts/${serialId}`); } /** diff --git a/src/slotmap.js b/src/slotmap.js index 2752f5ce..f0e16e4b 100644 --- a/src/slotmap.js +++ b/src/slotmap.js @@ -267,7 +267,17 @@ function slotMapOf(token) { maximum_amount: fee['maximum'], fee_collector: fee['collector_account_id'], })); - token['royalty_fees'] = customFees['royalty_fees'] ?? []; + token['royalty_fees'] = (customFees['royalty_fees'] ?? []).map(fee => ({ + all_collectors_are_exempt: fee['all_collectors_are_exempt'], + numerator: /**@type{{numerator: unknown}}*/ (fee['amount'])['numerator'], + denominator: /**@type{{denominator: unknown}}*/ (fee['amount'])['denominator'], + collector_account_id: fee['collector_account_id'], + amount: /**@type{{amount: unknown}}*/ (fee['fallback_fee'] || {})['amount'], + tokenId: /**@type{{denominating_token_id: unknown}}*/ (fee['fallback_fee'] || {})[ + 'denominating_token_id' + ], + fee_collector: fee['collector_account_id'], + })); const map = new SlotMap(); storage.forEach(slot => visit(slot, 0n, token, '', map));