diff --git a/@hts-forking/test/data/MFCT/getBalanceOfToken_0.0.2669675.json b/@hts-forking/test/data/MFCT/getBalanceOfToken_0.0.2669675.json new file mode 100644 index 00000000..f8ce43f6 --- /dev/null +++ b/@hts-forking/test/data/MFCT/getBalanceOfToken_0.0.2669675.json @@ -0,0 +1,13 @@ +{ + "timestamp": "1727813254.582754397", + "balances": [ + { + "account": "0.0.2669675", + "balance": 5000, + "decimals": 0 + } + ], + "links": { + "next": null + } +} diff --git a/@hts-forking/test/data/USDC/getBalanceOfToken_0.0.5176.json b/@hts-forking/test/data/USDC/getBalanceOfToken_0.0.5176.json new file mode 100644 index 00000000..407f7eae --- /dev/null +++ b/@hts-forking/test/data/USDC/getBalanceOfToken_0.0.5176.json @@ -0,0 +1,13 @@ +{ + "timestamp": "1724799522.972495003", + "balances": [ + { + "account": "0.0.5176", + "balance": 5000000, + "decimals": 6 + } + ], + "links": { + "next": null + } +} diff --git a/@hts-forking/test/data/getAccount_0xa3612a87022a4706fc9452c50abd2703ac4fd7d9.json b/@hts-forking/test/data/getAccount_0xa3612a87022a4706fc9452c50abd2703ac4fd7d9.json new file mode 100644 index 00000000..383b4827 --- /dev/null +++ b/@hts-forking/test/data/getAccount_0xa3612a87022a4706fc9452c50abd2703ac4fd7d9.json @@ -0,0 +1,36 @@ +{ + "account": "0.0.2669675", + "alias": "HIQQEAMJCQN7IZLRPU6I53Q7VFOINWOXBZM365CNKLKTBQ5RXPYUKLWH", + "auto_renew_period": 7776000, + "balance": { + "balance": 99981012856, + "timestamp": "1727813254.582754397", + "tokens": [ + { + "token_id": "0.0.4730999", + "balance": 5000 + } + ] + }, + "created_timestamp": "1707161970.469792002", + "decline_reward": false, + "deleted": false, + "ethereum_nonce": 802, + "evm_address": "0xa3612a87022a4706fc9452c50abd2703ac4fd7d9", + "expiry_timestamp": "1714937970.469792002", + "key": { + "_type": "ECDSA_SECP256K1", + "key": "020189141bf465717d3c8eee1fa95c86d9d70e59bf744d52d530c3b1bbf1452ec7" + }, + "max_automatic_token_associations": 0, + "memo": "auto-created account", + "pending_reward": 0, + "receiver_sig_required": false, + "staked_account_id": null, + "staked_node_id": null, + "stake_period_start": null, + "transactions": [], + "links": { + "next": "/api/v1/accounts/0.0.2669675?timestamp=lt:1726314195.875000001" + } +} diff --git a/scripts/curl b/scripts/curl index dffdc566..b3fd504c 100755 --- a/scripts/curl +++ b/scripts/curl @@ -46,13 +46,23 @@ for (const [re, fn] of /** @type {const} */([ return require(`../@hts-forking/test/data/${token.symbol}/getBalanceOfToken_${accountId}.json`); }], - [/^accounts\/(0x[0-9a-fA-F]{40})$/, address => { - assert(typeof address === 'string'); + [/^accounts\/((0x[0-9a-fA-F]{40})|(0\.0\.\d+))$/, idOrAliasOrEvmAddress => { + assert(typeof idOrAliasOrEvmAddress === 'string'); try { - return require(`../@hts-forking/test/data/getAccount_${address.toLowerCase()}.json`); + return require(`../@hts-forking/test/data/getAccount_${idOrAliasOrEvmAddress.toLowerCase()}.json`); } catch { - if (address.startsWith(LONG_ZERO_PREFIX)) { - return { account: '0.0.' + parseInt(address, 16) }; + if (idOrAliasOrEvmAddress.startsWith(LONG_ZERO_PREFIX)) { + return { + account: '0.0.' + parseInt(idOrAliasOrEvmAddress, 16), + evm_address: idOrAliasOrEvmAddress, + }; + } + if (idOrAliasOrEvmAddress.startsWith('0.0.')) { + const accountNumber = parseInt(idOrAliasOrEvmAddress.slice(4)); + return { + account: idOrAliasOrEvmAddress, + evm_address: `0x${accountNumber.toString(16).padStart(40, '0')}` + }; } return undefined; } diff --git a/src/HtsSystemContract.sol b/src/HtsSystemContract.sol index 4080f9ae..7fff6079 100644 --- a/src/HtsSystemContract.sol +++ b/src/HtsSystemContract.sol @@ -40,18 +40,54 @@ contract HtsSystemContract is IHederaTokenService, IERC20Events { assembly { accountId := sload(slot) } } - /** - * Query token info - * @param token - The token address to check - * @return responseCode - the response code for the status of the request. SUCCESS is `22`. - * @return tokenInfo - token info for `token` - */ function getTokenInfo(address token) htsCall external returns (int64 responseCode, TokenInfo memory tokenInfo) { require(token != address(0), "getTokenInfo: invalid token"); (responseCode, tokenInfo) = IHederaTokenService(token).getTokenInfo(token); } + function mintToken(address token, int64 amount, bytes[] memory) htsCall external returns ( + int64 responseCode, + int64 newTotalSupply, + int64[] memory serialNumbers + ) { + require(token != address(0), "mintToken: invalid token"); + require(amount > 0, "mintToken: invalid amount"); + + (int64 tokenInfoResponseCode, TokenInfo memory tokenInfo) = IHederaTokenService(token).getTokenInfo(token); + require(tokenInfoResponseCode == 22, "mintToken: failed to get token info"); + + address treasuryAccount = tokenInfo.token.treasury; + require(treasuryAccount != address(0), "mintToken: invalid account"); + + HtsSystemContract(token)._update(address(0), treasuryAccount, uint256(uint64(amount))); + + responseCode = 22; // HederaResponseCodes.SUCCESS + newTotalSupply = int64(uint64(IERC20(token).totalSupply())); + serialNumbers = new int64[](0); + require(newTotalSupply >= 0, "mintToken: invalid total supply"); + } + + function burnToken(address token, int64 amount, int64[] memory) htsCall external returns ( + int64 responseCode, + int64 newTotalSupply + ) { + require(token != address(0), "burnToken: invalid token"); + require(amount > 0, "burnToken: invalid amount"); + + (int64 tokenInfoResponseCode, TokenInfo memory tokenInfo) = IHederaTokenService(token).getTokenInfo(token); + require(tokenInfoResponseCode == 22, "burnToken: failed to get token info"); + + address treasuryAccount = tokenInfo.token.treasury; + require(treasuryAccount != address(0), "burnToken: invalid account"); + + HtsSystemContract(token)._update(treasuryAccount, address(0), uint256(uint64(amount))); + + responseCode = 22; // HederaResponseCodes.SUCCESS + newTotalSupply = int64(uint64(IERC20(token).totalSupply())); + require(newTotalSupply >= 0, "burnToken: invalid total supply"); + } + /** * @dev Validates `redirectForToken(address,bytes)` dispatcher arguments. * @@ -194,6 +230,15 @@ contract HtsSystemContract is IHederaTokenService, IERC20Events { require(msg.sender == HTS_ADDRESS, "getTokenInfo: unauthorized"); _initTokenData(); return abi.encode(22, _tokenInfo); + } else if (selector == this._update.selector) { + require(msg.data.length >= 124, "update: Not enough calldata"); + require(msg.sender == HTS_ADDRESS, "update: unauthorized"); + address from = address(bytes20(msg.data[40:60])); + address to = address(bytes20(msg.data[72:92])); + uint256 amount = uint256(bytes32(msg.data[92:124])); + _initTokenData(); + _update(from, to, amount); + return abi.encode(true); } revert ("redirectForToken: not supported"); } @@ -236,22 +281,32 @@ contract HtsSystemContract is IHederaTokenService, IERC20Events { function _transfer(address from, address to, uint256 amount) private { require(from != address(0), "hts: invalid sender"); require(to != address(0), "hts: invalid receiver"); + _update(from, to, amount); + emit Transfer(from, to, amount); + } - bytes32 fromSlot = _balanceOfSlot(from); - uint256 fromBalance; - assembly { fromBalance := sload(fromSlot) } - require(fromBalance >= amount, "_transfer: insufficient balance"); - assembly { sstore(fromSlot, sub(fromBalance, amount)) } - - bytes32 toSlot = _balanceOfSlot(to); - uint256 toBalance; - assembly { toBalance := sload(toSlot) } - // Solidity's checked arithmetic will revert if this overflows - // https://soliditylang.org/blog/2020/12/16/solidity-v0.8.0-release-announcement - uint256 newToBalance = toBalance + amount; - assembly { sstore(toSlot, newToBalance) } + function _update(address from, address to, uint256 amount) public { + if (from == address(0)) { + totalSupply += amount; + } else { + bytes32 fromSlot = _balanceOfSlot(from); + uint256 fromBalance; + assembly { fromBalance := sload(fromSlot) } + require(fromBalance >= amount, "_transfer: insufficient balance"); + assembly { sstore(fromSlot, sub(fromBalance, amount)) } + } - emit Transfer(from, to, amount); + if (to == address(0)) { + totalSupply -= amount; + } else { + bytes32 toSlot = _balanceOfSlot(to); + uint256 toBalance; + assembly { toBalance := sload(toSlot) } + // Solidity's checked arithmetic will revert if this overflows + // https://soliditylang.org/blog/2020/12/16/solidity-v0.8.0-release-announcement + uint256 newToBalance = toBalance + amount; + assembly { sstore(toSlot, newToBalance) } + } } function _approve(address owner, address spender, uint256 amount) private { diff --git a/src/HtsSystemContractJson.sol b/src/HtsSystemContractJson.sol index e02ff609..9de6835a 100644 --- a/src/HtsSystemContractJson.sol +++ b/src/HtsSystemContractJson.sol @@ -46,11 +46,21 @@ contract HtsSystemContractJson is HtsSystemContract { /** * @dev Reading Smart Contract's data into it's storage directly from the MirrorNode. + * @dev Both `initialized` and `_mirrorNode` are stored in the same slot (with different offsets). + * Given how `initialized` is written to (see at the end of the method), it would seem that the + * instance variable `_mirrorNode` is overwritten. + * However, this is not the case because the slot space for each access is different: + * - `_mirrorNode` is accessed through the `0x167` address. + * - `initialized` is accessed through the address of the token. */ function _initTokenData() internal override { - if (initialized) return; - bytes32 slot; + assembly { slot := initialized.slot } + if (vm.load(address(this), slot) == bytes32(uint256(1))) { + // Already initialized + return; + } + string memory json = mirrorNode().fetchTokenData(address(this)); assembly { slot := name.slot } diff --git a/src/IHederaTokenService.sol b/src/IHederaTokenService.sol index a083e5c3..3f3b79c2 100644 --- a/src/IHederaTokenService.sol +++ b/src/IHederaTokenService.sol @@ -216,4 +216,35 @@ interface IHederaTokenService { /// @return responseCode The response code for the status of the request. SUCCESS is 22. /// @return tokenInfo TokenInfo info for `token` function getTokenInfo(address token) external returns (int64 responseCode, TokenInfo memory tokenInfo); + + /// Mints an amount of the token to the defined treasury account + /// @param token The token for which to mint tokens. If token does not exist, transaction results in + /// INVALID_TOKEN_ID + /// @param amount Applicable to tokens of type FUNGIBLE_COMMON. The amount to mint to the Treasury Account. + /// Amount must be a positive non-zero number represented in the lowest denomination of the + /// token. The new supply must be lower than 2^63. + /// @param metadata Applicable to tokens of type NON_FUNGIBLE_UNIQUE. A list of metadata that are being created. + /// Maximum allowed size of each metadata is 100 bytes + /// @return responseCode The response code for the status of the request. SUCCESS is 22. + /// @return newTotalSupply The new supply of tokens. For NFTs it is the total count of NFTs + /// @return serialNumbers If the token is an NFT the newly generate serial numbers, othersise empty. + function mintToken(address token, int64 amount, bytes[] memory metadata) external returns ( + int64 responseCode, + int64 newTotalSupply, + int64[] memory serialNumbers + ); + + /// Burns an amount of the token from the defined treasury account + /// @param token The token for which to burn tokens. If token does not exist, transaction results in + /// INVALID_TOKEN_ID + /// @param amount Applicable to tokens of type FUNGIBLE_COMMON. The amount to burn from the Treasury Account. + /// Amount must be a positive non-zero number, not bigger than the token balance of the treasury + /// account (0; balance], represented in the lowest denomination. + /// @param serialNumbers Applicable to tokens of type NON_FUNGIBLE_UNIQUE. The list of serial numbers to be burned. + /// @return responseCode The response code for the status of the request. SUCCESS is 22. + /// @return newTotalSupply The new supply of tokens. For NFTs it is the total count of NFTs + function burnToken(address token, int64 amount, int64[] memory serialNumbers) external returns ( + int64 responseCode, + int64 newTotalSupply + ); } diff --git a/test/HTS.t.sol b/test/HTS.t.sol index 2db580a0..a4ec0686 100644 --- a/test/HTS.t.sol +++ b/test/HTS.t.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.0; import {Test} from "forge-std/Test.sol"; import {HtsSystemContract, HTS_ADDRESS} from "../src/HtsSystemContract.sol"; +import {IHederaTokenService} from "../src/IHederaTokenService.sol"; +import {IERC20} from "../src/IERC20.sol"; import {TestSetup} from "./lib/TestSetup.sol"; contract HTSTest is Test, TestSetup { @@ -37,13 +39,13 @@ contract HTSTest is Test, TestSetup { // https://hashscan.io/testnet/account/0.0.1421 address alice = 0x4D1c823b5f15bE83FDf5adAF137c2a9e0E78fE15; uint32 accountId = HtsSystemContract(HTS_ADDRESS).getAccountId(alice); - assertEq(accountId, testMode != TestMode.JSON_RPC + assertEq(accountId, testMode != TestMode.JSON_RPC ? uint32(bytes4(keccak256(abi.encodePacked(alice)))) : 1421 ); accountId = HtsSystemContract(HTS_ADDRESS).getAccountId(unknownUser); - assertEq(accountId, testMode != TestMode.JSON_RPC + assertEq(accountId, testMode != TestMode.JSON_RPC ? uint32(bytes4(keccak256(abi.encodePacked(unknownUser)))) : uint32(uint160(unknownUser)) ); @@ -141,4 +143,130 @@ contract HTSTest is Test, TestSetup { vm.expectRevert(bytes("getTokenInfo: unauthorized")); HtsSystemContract(token).getTokenInfo(token); } + + function test_mintToken_should_succeed_with_valid_input() external { + if (TestMode.JSON_RPC == testMode) { + // TODO: skip this test until the hardhat solution for getTokenInfo is implemented + return; + } + + address token = USDC; + address treasury = USDC_TREASURY; + int64 amount = 1000; + int64 initialTotalSupply = 10000000005000000; + uint256 initialTreasuryBalance = IERC20(token).balanceOf(treasury); + bytes[] memory metadata = new bytes[](0); + + (int64 responseCode, int64 newTotalSupply, int64[] memory serialNumbers) = HtsSystemContract(HTS_ADDRESS).mintToken(token, amount, metadata); + assertEq(responseCode, 22); + assertEq(serialNumbers.length, 0); + assertEq(newTotalSupply, initialTotalSupply + amount); + assertEq(IERC20(token).balanceOf(treasury), uint64(initialTreasuryBalance) + uint64(amount)); + } + + function test_mintToken_should_revert_with_invalid_treasureAccount() external { + address token = USDC; + int64 amount = 1000; + bytes[] memory metadata = new bytes[](0); + int64 initialTotalSupply = 10000000005000000; + IHederaTokenService.TokenInfo memory tokenInfo; + tokenInfo.token = IHederaTokenService.HederaToken( + "USD Coin", + "USDC", + address(0), + "USDC HBAR", + false, + initialTotalSupply + amount, + false, + new IHederaTokenService.TokenKey[](0), + IHederaTokenService.Expiry(0, address(0), 0) + ); + + vm.mockCall(token, abi.encode(HtsSystemContract.getTokenInfo.selector), abi.encode(22, tokenInfo)); + vm.expectRevert(bytes("mintToken: invalid account")); + HtsSystemContract(HTS_ADDRESS).mintToken(token, amount, metadata); + } + + function test_mintToken_should_revert_with_invalid_token() external { + address token = address(0); + int64 amount = 1000; + bytes[] memory metadata = new bytes[](0); + + vm.expectRevert(bytes("mintToken: invalid token")); + HtsSystemContract(HTS_ADDRESS).mintToken(token, amount, metadata); + } + + function test_mintToken_should_revert_with_invalid_amount() external { + address token = address(0x123); + int64 amount = 0; + bytes[] memory metadata = new bytes[](0); + + vm.expectRevert(bytes("mintToken: invalid amount")); + HtsSystemContract(HTS_ADDRESS).mintToken(token, amount, metadata); + } + + function test_burnToken_should_succeed_with_valid_input() external { + if (TestMode.JSON_RPC == testMode) { + // TODO: skip this test until the hardhat solution for getTokenInfo is implemented + return; + } + + address token = MFCT; + address treasury = MFCT_TREASURY; + int64 amount = 1000; + int64 initialTotalSupply = 5000; + uint256 initialTreasuryBalance = IERC20(token).balanceOf(treasury); + + (int64 responseCodeMint, int64 newTotalSupplyAfterMint, int64[] memory serialNumbers) = HtsSystemContract(HTS_ADDRESS).mintToken(token, amount, new bytes[](0)); + assertEq(responseCodeMint, 22); + assertEq(serialNumbers.length, 0); + assertEq(newTotalSupplyAfterMint, initialTotalSupply + amount); + assertEq(IERC20(token).balanceOf(treasury), uint64(initialTreasuryBalance) + uint64(amount)); + + (int64 responseCodeBurn, int64 newTotalSupplyAfterBurn) = HtsSystemContract(HTS_ADDRESS).burnToken(token, amount, serialNumbers); + assertEq(responseCodeBurn, 22); + assertEq(newTotalSupplyAfterBurn, initialTotalSupply); + assertEq(IERC20(token).balanceOf(treasury), uint64(initialTreasuryBalance)); + } + + function test_burnToken_should_revert_with_invalid_treasureAccount() external { + address token = MFCT; + int64 amount = 1000; + int64 initialTotalSupply = 5000; + int64[] memory serialNumbers = new int64[](0); + IHederaTokenService.TokenInfo memory tokenInfo; + tokenInfo.token = IHederaTokenService.HederaToken( + "My Crypto Token is the name which the string length is greater than 31", + "Token symbol must be exactly 32!", + address(0), + "", + false, + initialTotalSupply + amount, + false, + new IHederaTokenService.TokenKey[](0), + IHederaTokenService.Expiry(0, address(0), 0) + ); + + vm.mockCall(token, abi.encode(HtsSystemContract.getTokenInfo.selector), abi.encode(22, tokenInfo)); + vm.expectRevert(bytes("burnToken: invalid account")); + HtsSystemContract(HTS_ADDRESS).burnToken(token, amount, serialNumbers); + } + + function test_burnToken_should_revert_with_invalid_token() external { + address token = address(0); + int64 amount = 1000; + int64[] memory serialNumbers = new int64[](0); + + vm.expectRevert(bytes("burnToken: invalid token")); + HtsSystemContract(HTS_ADDRESS).burnToken(token, amount, serialNumbers); + } + + function test_burnToken_should_revert_with_invalid_amount() external { + address token = address(0x123); + int64 amount = 0; + int64[] memory serialNumbers = new int64[](0); + + vm.expectRevert(bytes("burnToken: invalid amount")); + HtsSystemContract(HTS_ADDRESS).burnToken(token, amount, serialNumbers); + } } diff --git a/test/lib/TestSetup.sol b/test/lib/TestSetup.sol index b023723c..019d9095 100644 --- a/test/lib/TestSetup.sol +++ b/test/lib/TestSetup.sol @@ -15,12 +15,14 @@ abstract contract TestSetup { * https://testnet.mirrornode.hedera.com/api/v1/tokens/0.0.429274 */ address internal USDC = 0x0000000000000000000000000000000000068cDa; + address internal USDC_TREASURY = 0x0000000000000000000000000000000000001438; /** * https://hashscan.io/testnet/token/0.0.4730999 * https://testnet.mirrornode.hedera.com/api/v1/tokens/0.0.4730999 */ address internal MFCT = 0x0000000000000000000000000000000000483077; + address internal MFCT_TREASURY = 0xa3612A87022a4706FC9452C50abd2703ac4Fd7d9; /** * 3 test modes.