diff --git a/test/constants.js b/test/constants.js index 7ee65ec99..42a3df860 100644 --- a/test/constants.js +++ b/test/constants.js @@ -205,15 +205,19 @@ const Contract = { CancunOpcodes: 'CancunOpcodes', KZGPointEvaluation: 'KZGPointEvaluation', StateRegistry: 'StateRegistry', + Airdrop: 'Airdrop', + TokenReject: 'TokenReject', AliasAccountUtility: 'AliasAccountUtility', }; const CALL_EXCEPTION = 'CALL_EXCEPTION'; const CONTRACT_REVERT_EXECUTED_CODE = 3; const GAS_LIMIT_1_000_000 = { gasLimit: 1_000_000 }; +const GAS_LIMIT_2_000_000 = { gasLimit: 2_000_000 }; const GAS_LIMIT_10_000_000 = { gasLimit: 10_000_000 }; const GAS_LIMIT_800000 = { gasLimit: 800000 }; const GAS_LIMIT_8000000 = { gasLimit: 8000000 }; +const ONE_HBAR = ethers.parseEther('1'); const TOKEN_NAME = 'tokenName'; const TOKEN_SYMBOL = 'tokenSymbol'; const TOKEN_URL = 'tokenUrl'; @@ -224,6 +228,7 @@ const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; const WEEK = 7 * DAY; const GWEI = 1e9; +const HTS_SYSTEM_CONTRACT_ID = '0.0.359'; module.exports = { Events, @@ -232,6 +237,7 @@ module.exports = { CALL_EXCEPTION, CONTRACT_REVERT_EXECUTED_CODE, GAS_LIMIT_1_000_000, + GAS_LIMIT_2_000_000, GAS_LIMIT_10_000_000, GAS_LIMIT_800000, GAS_LIMIT_8000000, @@ -246,4 +252,6 @@ module.exports = { WEEK, WEI, GWEI, + HTS_SYSTEM_CONTRACT_ID, + ONE_HBAR, }; diff --git a/test/system-contracts/hedera-token-service/hrc-904/TokenRejectContract.js b/test/system-contracts/hedera-token-service/hrc-904/TokenRejectContract.js new file mode 100644 index 000000000..def0f69ce --- /dev/null +++ b/test/system-contracts/hedera-token-service/hrc-904/TokenRejectContract.js @@ -0,0 +1,290 @@ +/*- + * + * Hedera Smart Contracts + * + * Copyright (C) 2025 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 } = require('hardhat'); +const utils = require('../utils'); +const Constants = require('../../../constants'); + +describe('HIP904 TokenRejectContract Test Suite', function () { + let tokenRejectContract; + let tokenCreateContract; + let airdropContract; + let signers; + let owner; + let receiver; + let walletIHRC904AccountFacade; + + before(async function () { + signers = await ethers.getSigners(); + tokenRejectContract = await utils.deployContract( + Constants.Contract.TokenReject + ); + tokenCreateContract = await utils.deployContract( + Constants.Contract.TokenCreateContract + ); + airdropContract = await utils.deployContract(Constants.Contract.Airdrop); + owner = signers[0].address; + + const randomWallet = ethers.Wallet.createRandom(); + const receiverPrivateKey = randomWallet.privateKey; + receiver = randomWallet.connect(ethers.provider); + + await signers[0].sendTransaction({ + to: receiver.address, + value: ethers.parseEther('100'), + }); + + await utils.updateAccountKeysViaHapi([ + await tokenRejectContract.getAddress(), + await tokenCreateContract.getAddress(), + await airdropContract.getAddress(), + ]); + + await utils.updateAccountKeysViaHapi( + [ + await tokenRejectContract.getAddress(), + await tokenCreateContract.getAddress(), + await airdropContract.getAddress(), + ], + [receiverPrivateKey] + ); + + const IHRC904AccountFacade = new ethers.Interface( + (await hre.artifacts.readArtifact('IHRC904AccountFacade')).abi + ); + + walletIHRC904AccountFacade = new ethers.Contract( + receiver.address, + IHRC904AccountFacade, + receiver + ); + }); + + it('should reject tokens for a single account', async function () { + const tokenAddress = await utils.setupToken( + tokenCreateContract, + owner, + airdropContract + ); + const receiver = signers[1]; + + const ftAmount = BigInt(1); + const airdropTx = await airdropContract.tokenAirdrop( + tokenAddress, + owner, + receiver.address, + ftAmount, + { + value: Constants.ONE_HBAR, + gasLimit: 2_000_000, + } + ); + await airdropTx.wait(); + + await walletIHRC904AccountFacade.setUnlimitedAutomaticAssociations(true, { + gasLimit: 2_000_000, + }); + + const tx = await tokenRejectContract.rejectTokens( + receiver.address, + [tokenAddress], + [], + Constants.GAS_LIMIT_2_000_000 + ); + const responseCode = await utils.getHTSResponseCode(tx.hash); + expect(responseCode).to.eq('22'); // SUCCESS code + }); + + it('should reject NFTs for a single account', async function () { + const nftTokenAddress = await utils.setupNft( + tokenCreateContract, + owner, + airdropContract + ); + const receiver = signers[1]; + + const serial = utils.mintNFTToAddress(tokenCreateContract, nftTokenAddress); + + const airdropTx = await airdropContract.nftAirdrop( + nftTokenAddress, + owner, + receiver.address, + serial, + { + value: Constants.ONE_HBAR, + gasLimit: 2_000_000, + } + ); + await airdropTx.wait(); + + await walletIHRC904AccountFacade.setUnlimitedAutomaticAssociations(true, { + gasLimit: 2_000_000, + }); + + const tx = await tokenRejectContract.rejectTokens( + receiver.address, + [], + [nftTokenAddress], + Constants.GAS_LIMIT_2_000_000 + ); + const responseCode = await utils.getHTSResponseCode(tx.hash); + expect(responseCode).to.eq('22'); // SUCCESS code + }); + + it('should reject tokens for multiple accounts', async function () { + const tokenAddress = await utils.setupToken( + tokenCreateContract, + owner, + airdropContract + ); + const receivers = signers.slice(1, 3); + + for (const receiver of receivers) { + const airdropTx = await airdropContract.tokenAirdrop( + tokenAddress, + owner, + receiver.address, + BigInt(1), + { + value: Constants.ONE_HBAR, + gasLimit: 2_000_000, + } + ); + await airdropTx.wait(); + + const tx = await tokenRejectContract.rejectTokens( + receiver.address, + [tokenAddress], + [], + Constants.GAS_LIMIT_2_000_000 + ); + const responseCode = await utils.getHTSResponseCode(tx.hash); + expect(responseCode).to.eq('22'); // SUCCESS code + } + }); + + it('should fail when sender does not have any associated tokens', async function () { + const tokenAddress = await utils.setupToken( + tokenCreateContract, + owner, + airdropContract + ); + + await walletIHRC904AccountFacade.setUnlimitedAutomaticAssociations(false, { + gasLimit: 2_000_000, + }); + + const airdropTx = await airdropContract.tokenAirdrop( + tokenAddress, + owner, + receiver.address, + BigInt(1), + { + value: Constants.ONE_HBAR, + gasLimit: 2_000_000, + } + ); + await airdropTx.wait(); + + const tx = await tokenRejectContract.rejectTokens( + receiver.address, + [tokenAddress], + [], + Constants.GAS_LIMIT_2_000_000 + ); + const responseCode = await utils.getHTSResponseCode(tx.hash); + expect(responseCode).to.eq('184'); // TOKEN_NOT_ASSOCIATED_TO_ACCOUNT code + }); + + it('should fail when sender does not have a pending airdrop', async function () { + const tokenAddress = await utils.setupToken( + tokenCreateContract, + owner, + airdropContract + ); + const receiver = signers[1]; + + const tx = await tokenRejectContract.rejectTokens( + receiver.address, + [tokenAddress], + [], + Constants.GAS_LIMIT_2_000_000 + ); + const responseCode = await utils.getHTSResponseCode(tx.hash); + expect(responseCode).to.eq('178'); // INSUFFICIENT_TOKEN_BALANCE code + }); + + it('should fail when provided fungible token is invalid', async function () { + const invalidToken = ethers.Wallet.createRandom().address; + const nftTokenAddress = await utils.setupNft( + tokenCreateContract, + owner, + airdropContract + ); + + const tx = await tokenRejectContract.rejectTokens( + receiver.address, + [invalidToken], + [nftTokenAddress], + Constants.GAS_LIMIT_2_000_000 + ); + const responseCode = await utils.getHTSResponseCode(tx.hash); + expect(responseCode).to.eq('167'); // INVALID_TOKEN_ID code + }); + + it('should fail when provided NFT is invalid', async function () { + const invalidNft = ethers.Wallet.createRandom().address; + + const nftTokenAddress = await utils.setupNft( + tokenCreateContract, + owner, + airdropContract + ); + const receiver = signers[1]; + + const serial = utils.mintNFTToAddress(tokenCreateContract, nftTokenAddress); + + const airdropTx = await airdropContract.nftAirdrop( + nftTokenAddress, + owner, + receiver.address, + serial, + { + value: Constants.ONE_HBAR, + gasLimit: 2_000_000, + } + ); + await airdropTx.wait(); + + await walletIHRC904AccountFacade.setUnlimitedAutomaticAssociations(true, { + gasLimit: 2_000_000, + }); + + const tx = await tokenRejectContract.rejectTokens( + receiver.address, + [], + [invalidNft], + Constants.GAS_LIMIT_2_000_000 + ); + const responseCode = await utils.getHTSResponseCode(tx.hash); + expect(responseCode).to.eq('226'); // INVALID_NFT_ID code + }); +}); diff --git a/test/system-contracts/hedera-token-service/utils.js b/test/system-contracts/hedera-token-service/utils.js index e20b11a6b..6ee66e45f 100644 --- a/test/system-contracts/hedera-token-service/utils.js +++ b/test/system-contracts/hedera-token-service/utils.js @@ -36,6 +36,10 @@ const { ContractInfoQuery, } = require('@hashgraph/sdk'); const Constants = require('../../constants'); +const axios = require('axios'); +const { + getMirrorNodeUrl, +} = require('../native/evm-compatibility-ecrecover/utils'); class Utils { //createTokenCost is cost for creating the token, which is passed to the system-contracts. This is equivalent of 40 and 60hbars, any excess hbars are refunded. @@ -65,6 +69,18 @@ class Utils { DELEGETABLE_CONTRACT_ID: 4, }; + static async deployContract( + contractPath, + gasLimit = Constants.GAS_LIMIT_1_000_000 + ) { + const factory = await ethers.getContractFactory(contractPath); + const contract = await factory.deploy(gasLimit); + return await ethers.getContractAt( + contractPath, + await contract.getAddress() + ); + } + static async deployERC20Mock() { const erc20MockFactory = await ethers.getContractFactory( Constants.Path.HIP583_ERC20Mock @@ -942,6 +958,102 @@ class Utils { return; } } + + /** + * This method fetches the transaction actions from the mirror node corresponding to the current network, + * filters the actions to find the one directed to the Hedera Token Service (HTS) system contract, + * and extracts the result data from the precompile action. The result data is converted from a BigInt + * to a string before being returned. + * + * @param {string} txHash - The transaction hash to query. + * @returns {string} - The response code as a string. + */ + static async getHTSResponseCode(txHash) { + const network = hre.network.name; + const mirrorNodeUrl = getMirrorNodeUrl(network); + const res = await axios.get( + `${mirrorNodeUrl}/contracts/results/${txHash}/actions` + ); + const precompileAction = res.data.actions.find( + (x) => x.recipient === Constants.HTS_SYSTEM_CONTRACT_ID + ); + return BigInt(precompileAction.result_data).toString(); + } + + /** + * This method fetches the transaction actions from the mirror node corresponding to the current network, + * filters the actions to find the one directed to the Hedera Account Service (HAS) system contract, + * and extracts the result data from the precompile action. The result data is converted from a BigInt + * to a string before being returned. + * + * @param {string} txHash - The transaction hash to query. + * @returns {string} - The response code as a string. + */ + static async getHASResponseCode(txHash) { + const network = hre.network.name; + const mirrorNodeUrl = getMirrorNodeUrl(network); + const res = await axios.get( + `${mirrorNodeUrl}/contracts/results/${txHash}/actions` + ); + const precompileAction = res.data.actions.find( + (x) => x.recipient === Constants.HAS_SYSTEM_CONTRACT_ID + ); + return BigInt(precompileAction.result_data).toString(); + } + + static async setupNft(tokenCreateContract, owner, airdropContract) { + const nftTokenAddress = + await Utils.createNonFungibleTokenWithSECP256K1AdminKeyWithoutKYC( + tokenCreateContract, + owner, + Utils.getSignerCompressedPublicKey() + ); + + await Utils.updateTokenKeysViaHapi( + nftTokenAddress, + [ + await airdropContract.getAddress(), + await tokenCreateContract.getAddress(), + ], + true, + true, + false, + true, + true, + true, + false + ); + + await Utils.associateToken( + tokenCreateContract, + nftTokenAddress, + Constants.Contract.TokenCreateContract + ); + + return nftTokenAddress; + } + + static async setupToken(tokenCreateContract, owner, airdropContract) { + const tokenAddress = + await Utils.createFungibleTokenWithSECP256K1AdminKeyWithoutKYC( + tokenCreateContract, + owner, + Utils.getSignerCompressedPublicKey() + ); + + await Utils.updateTokenKeysViaHapi(tokenAddress, [ + await airdropContract.getAddress(), + await tokenCreateContract.getAddress(), + ]); + + await Utils.associateToken( + tokenCreateContract, + tokenAddress, + Constants.Contract.TokenCreateContract + ); + + return tokenAddress; + } } module.exports = Utils;