@hashgraph/system-contracts-forking v0.1.2
Hedera Forking for System Contracts
This projects allows Smart Contract developers working on Hedera to use fork testing while using Hedera System Contracts. It does so by providing an emulation layer for the Hedera Token Service (more System Contracts to come) written in Solidity. Given it is written in Solidity, it can be executed in a forked network environment, such as Foundry or Hardhat.
You can use either our Foundry library or Hardhat plugin to enable HTS emulation in your project.
See Hedera Token Service Supported Methods for a list of methods currently implemented as part of this project.
!IMPORTANT The HTS emulation contract SHOULD BE ONLY used to ease development workflow when working with Hedera Tokens. The HTS emulation contract DOES NOT replicate Hedera Token Services fully. That is, behavior might differ when switching from local development to a real Hedera network. Always test your contracts against a real Hedera network before launching your contracts.
For detailed information on how this project works, see Internals.
!NOTE The FAQ provides common issues that this project solves.
Foundry library
We provide a Foundry library that enables fork testing when using HTS Tokens.
To see how this library works in details, see Foundry library in Internals.
Installation
First, install our library in your Foundry project
forge install hashgraph/hedera-forking
Set up
To use this library in your tests, you need to enable ffi
.
You can do so by adding the following lines to your .toml
file
[profile.default]
ffi = true
Alternatively, you can add the --ffi
flag to your execution script.
This is necessary because
- On Unix-like operating systems, the library relies on
curl
to make HTTP requests to the Hedera remote network. This allows the library to fetch token state from the remote network. Sincecurl
is an external command,ffi
must be enabled. - On Windows, if PowerShell is available, the library uses the
Invoke-WebRequest
command to handle HTTP requests, which also requiresffi
to be enabled.
!NOTE
The Foundry library assumes it is running on either a Windows system with PowerShell or a Unix-like operating system.
- On Unix-like systems, it uses
curl
,bash
,tail
,sed
, andtr
to make requests and parse responses.- On Windows, it uses
Invoke-WebRequest
andStart-Process
in PowerShell for a similar effect.
To activate HTS emulation in your tests, you need to add the following setup code in your test files. Import our wrapper function to deploy HTS emulation and enable cheat codes for it.
import {htsSetup} from "hedera-forking/contracts/htsSetup.sol";
and then invoke it in your test setup
function setUp() public {
htsSetup();
}
Running your Tests
Now you can use Hedera Token Services and remote tokens as if they were deployed locally when fork testing. For example
// 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 {IERC20} from "hedera-forking/contracts/IERC20.sol";
contract USDCExampleTest is Test {
// https://hashscan.io/mainnet/token/0.0.456858
address USDC_mainnet = 0x000000000000000000000000000000000006f89a;
address private user1;
function setUp() external {
htsSetup();
user1 = makeAddr("user1");
deal(USDC_mainnet, user1, 1000 * 10e8);
}
function test_get_balance_of_existing_account() view external {
// https://hashscan.io/mainnet/account/0.0.1528
address usdcHolder = 0x00000000000000000000000000000000000005f8;
// Balance retrieved from mainnet at block 72433403
assertEq(IERC20(USDC_mainnet).balanceOf(usdcHolder), 28_525_752677);
}
function test_dealt_balance_of_local_account() view external {
assertEq(IERC20(USDC_mainnet).balanceOf(user1), 1000 * 10e8);
}
}
To run your tests, use the usual command
forge test --fork-url https://mainnet.hashio.io/api
You can also include a specific block number.
For example, the test above is known to work at block 72433403
forge test --fork-url https://mainnet.hashio.io/api --fork-block-number 72433403
You can use all the tools and cheatcodes Foundry provides, e.g., console.log
// 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 {IERC20} from "hedera-forking/contracts/IERC20.sol";
contract USDCConsoleExampleTest is Test {
function setUp() external {
htsSetup();
}
function test_using_console_log() view external {
// https://hashscan.io/mainnet/token/0.0.456858
address USDC_mainnet = 0x000000000000000000000000000000000006f89a;
string memory name = IERC20(USDC_mainnet).name();
string memory symbol = IERC20(USDC_mainnet).symbol();
uint8 decimals = IERC20(USDC_mainnet).decimals();
assertEq(name, "USD Coin");
assertEq(symbol, "USDC");
assertEq(decimals, 6);
console.log("name: %s, symbol: %s, decimals: %d", name, symbol, decimals);
}
}
Hardhat plugin
We provide a Hardhat plugin that enables fork testing when using HTS Tokens.
In addition, this plugin provides the following features
- Sets up Hardfork history for Hedera networks. This solves the issue
No known hardfork for execution on historical block
. - Avoids
Missing access list
error. Hardhat throws this error when ablockNumber
is provided in the forking configuration. The plugin solves this issue.
This plugin intercepts the calls made by Hardhat to fetch remote state, _i.e.,
eth_getCode
and eth_getStorageAt
, to provide emulation for HTS.
It assigns the Hedera Token Service code to the 0x167
address.
In your tests, you will be able to query Hedera Token data as if they were stored as regular Smart Contracts.
To see how this plugin works in details, see Hardhat plugin in Internals.
Installation
To use this plugin, install it via your package manager.
If you are using npm
npm install --save-dev @hashgraph/system-contracts-forking
or using yarn
yarn add --dev @hashgraph/system-contracts-forking
Set up
Next, add the following line to the top of your Hardhat config file, e.g., hardhat.config.js
require('@hashgraph/system-contracts-forking/plugin');
or if you are using TypeScript, include the following line into your hardhat.config.ts
import '@hashgraph/system-contracts-forking/plugin';
This will automatically create a worker thread to intercept calls to fetch remote state. You can then proceed with writing your tests, and the plugin will allow you to query Hedera token data seamlessly.
Configuration
By default, the plugin uses the Hedera Mirror Node based on the guessed chain ID from the currently forked network.
Two additional values needs to be set in order to activate the plugin.
chainId
. The call toeth_chainId
of configuration parameterhardhat.forking.url
needs to match thechainId
configuration argument. Only chain IDs295
(Mainnet),296
(Testnet), and297
(Previewnet) are supported.workerPort
. Any free port to start the worker to intercept Hardhat calls to fetch remote state.
For example
networks: {
hardhat: {
forking: {
url: 'https://mainnet.hashio.io/api',
// This allows Hardhat to enable JSON-RPC's response cache.
// Forking from a block is not fully integrated yet into HTS emulation.
blockNumber: 70531900,
chainId: 295,
workerPort: 1235,
},
},
},
!NOTE These configuration settings cannot be set automatically by the Plugin. This is because the way Hardhat plugins are loaded by Hardhat. The main issue is "since Hardhat needs to be loadable via
require
call, configuration must be synchronous". See here and here for more details.Creating a Worker so we can hook into
eth_getCode
andeth_getStorageAt
to provide HTS emulation and querying thechainId
of a remote network are asynchronous operations. That is why we need to shift the setting ofchainId
andworkerPort
to the user.
Running Your Tests
For example, to query USDC information on mainnet you can use the following test
const { expect } = require('chai');
const { ethers: { getContractAt } } = require('hardhat');
describe('USDC example -- informational', function () {
it('should get name, symbol and decimals', async function () {
// https://hashscan.io/mainnet/token/0.0.456858
const usdc = await getContractAt('IERC20', '0x000000000000000000000000000000000006f89a');
expect(await usdc['name']()).to.be.equal('USD Coin');
expect(await usdc['symbol']()).to.be.equal('USDC');
expect(await usdc['decimals']()).to.be.equal(6n);
});
});
Then run your Hardhat tests as usual
npx hardhat
You can also query an existing account's balance.
For example using the blockNumber
as shown above in Configuration,
you can query the USDC balance for an existing account.
const { expect } = require('chai');
const { ethers: { getContractAt } } = require('hardhat');
describe('USDC example -- balanceOf', function () {
it('should get `balanceOf` account holder', async function () {
// https://hashscan.io/mainnet/token/0.0.456858
const usdc = await getContractAt('IERC20', '0x000000000000000000000000000000000006f89a');
// https://hashscan.io/mainnet/account/0.0.6279
const holderAddress = '0x0000000000000000000000000000000000001887';
expect(await usdc['balanceOf'](holderAddress)).to.be.equal(31_166_366226);
});
});
You can also perform modifications in the forked network.
Let's see an example where we execute the transfer
method from an existing account to a local Hardhat signer.
It is usually paired with Fixtures,
so that each test can start in a known state.
Moreover, you can use the tools you are already familiar with, for example,
hardhat_impersonateAccount
.
const { expect } = require('chai');
const { ethers: { getSigner, getSigners, getContractAt }, network: { provider } } = require('hardhat');
const { loadFixture } = require('@nomicfoundation/hardhat-toolbox/network-helpers');
describe('USDC example -- transfer', function () {
async function id() {
return [(await getSigners())[0]];
}
it("should `tranfer` tokens from account holder to one of Hardhat' signers", async function () {
const [receiver] = await loadFixture(id);
// https://hashscan.io/mainnet/account/0.0.6279
const holderAddress = '0x0000000000000000000000000000000000001887';
await provider.request({
method: 'hardhat_impersonateAccount',
params: [holderAddress],
});
const holder = await getSigner(holderAddress);
// https://hashscan.io/mainnet/token/0.0.456858
const usdc = await getContractAt('IERC20', '0x000000000000000000000000000000000006f89a');
expect(await usdc['balanceOf'](receiver.address)).to.be.equal(0n);
await usdc.connect(holder)['transfer'](receiver, 10_000_000n);
expect(await usdc['balanceOf'](receiver.address)).to.be.equal(10_000_000n);
});
});
Hedera Token Service Supported Methods
Given your HTS token address, you can invoke these functions whether the token is either fungible or non-fungible.
Fungible Tokens
The following methods and events are applicable to Fungible Tokens.
ERC20 Interface
Function | Comment |
---|---|
allowance(address owner, address spender) view | Returns the remaining number of tokens that spender will be allowed to spend on behalf of owner through {transferFrom}. This is zero by default. This value changes when {approve} or {transferFrom} are called. |
approve(address spender, uint256 amount) | Sets a value amount of tokens as the allowance of spender over the caller's tokens. Returns a boolean value indicating whether the operation succeeded. |
balanceOf(address account) view | Returns the value of tokens owned by account . |
decimals() view | Returns the decimals places of the token. |
name() view | Returns the name of the token. |
symbol() view | Returns the symbol of the token. |
totalSupply() view | Returns the value of tokens in existence. |
transfer(address recipient, uint256 amount) | Moves a value amount of tokens from the caller's account to to . Returns a boolean value indicating whether the operation succeeded. |
transferFrom(address sender, address recipient, uint256 amount) | Moves a value amount of tokens from from to to using the allowance mechanism. value is then deducted from the caller's allowance. Returns a boolean value indicating whether the operation succeeded. |
Event | Comment |
---|---|
Approval(address indexed owner, address indexed spender, uint256 amount) | Emitted when the allowance of a spender for an owner is set by a call to {approve}. value is the new allowance. |
Transfer(address indexed from, address indexed to, uint256 amount) | Emitted when value tokens are moved from one account (from ) to another (to ). Note that value may be zero. |
Association Methods Interface
Function | Comment |
---|---|
associate() | Associates the calling account with the token. This function allows an account to opt-in to receive the token |
dissociate() | Dissociates the calling account from the token. This function allows an account to opt-out from receiving the token |
isAssociated() view | Checks if the calling account is associated with the token. This function returns the association status of the calling account |
Non-Fungible Tokens
The following methods and events are applicable to Non-Fungible Tokens.
Function | Comment |
---|---|
approve(address spender, uint256 serialId) payable | 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. |
balanceOf(address owner) view | Returns the number of tokens in owner 's account. |
getApproved(uint256 serialId) view | Returns the account approved for serialId token. Requirements: - serialId must exist. |
isApprovedForAll(address owner, address operator) view | Returns if the operator is allowed to manage all of the assets of owner . See {setApprovalForAll} |
name() view | Returns the token collection name. |
ownerOf(uint256 serialId) view | Returns the owner of the serialId token. Requirements: - serialId must exist. This value changes when {approve} or {transferFrom} are called. |
setApprovalForAll(address operator, bool approved) | 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. |
symbol() view | Returns the token collection symbol. |
tokenURI(uint256 serialId) view | Returns the Uniform Resource Identifier (URI) for serialId token. |
totalSupply() view | Returns the total amount of tokens stored by the contract. |
transferFrom(address sender, address recipient, uint256 serialId) payable | 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. |
Event | Comment |
---|---|
Approval(address indexed owner, address indexed approved, uint256 indexed tokenId) | Emitted when the approved address for an NFT is changed or reaffirmed from {from} to {to} address. The zero {to} address indicates there will be no approved address. Additionally the approved address for that NFT (if any) is reset to none. |
ApprovalForAll(address indexed owner, address indexed operator, bool approved) | Emitted when an operator {operator} is enabled or disabled {approved} for an owner {owner}. The operator {operator} can than manage all NFTs of the owner {owner}. |
Transfer(address indexed from, address indexed to, uint256 indexed tokenId) | Emitted when ownership of any NFT changes by any mechanism. This event emits when NFTs are created (from == 0) and destroyed (to == 0). Otherwise, it indicates that the token with ID {tokenId} was transferred from {from} to {to}, where {from} represents the previous owner of the token, not the approved spender. Exception: during contract creation, any number of NFTs may be created and assigned without emitting Transfer. At the time of any transfer, the approved address for that NFT (if any) is reset to none. |
Association Methods Interface
Function | Comment |
---|---|
associate() | Associates the calling account with the token. This function allows an account to opt-in to receive the token |
dissociate() | Dissociates the calling account from the token. This function allows an account to opt-out from receiving the token |
isAssociated() view | Checks if the calling account is associated with the token. This function returns the association status of the calling account |
Hedera Token Service (located at address 0x167
)
The following methods can be invoked on the Hedera Token Service contract located at address 0x167
.
Function | Comment |
---|---|
allowance(address token, address owner, address spender) | Returns the amount which spender is still allowed to withdraw from owner. Only Applicable to Fungible Tokens |
approve(address token, address spender, uint256 amount) | Allows spender to withdraw from your account multiple times, up to the value amount. If this function is called again it overwrites the current allowance with value. Only Applicable to Fungible Tokens |
approveNFT(address token, address approved, uint256 serialNumber) | Allow or reaffirm the approved address to transfer an NFT the approved address does not own. Only Applicable to NFT Tokens |
associateToken(address account, address token) | Single-token variant of associateTokens. Will be mapped to a single entry array call of associateTokens |
associateTokens(address account, address[] tokens) | Associates the provided account with the provided tokens. Must be signed by the provided Account's key or called from the accounts contract key If the provided account is not found, the transaction will resolve to INVALID_ACCOUNT_ID. If the provided account has been deleted, the transaction will resolve to ACCOUNT_DELETED. If any of the provided tokens is not found, the transaction will resolve to INVALID_TOKEN_REF. If any of the provided tokens has been deleted, the transaction will resolve to TOKEN_WAS_DELETED. If an association between the provided account and any of the tokens already exists, the transaction will resolve to TOKEN_ALREADY_ASSOCIATED_TO_ACCOUNT. If the provided account's associations count exceed the constraint of maximum token associations per account, the transaction will resolve to TOKENS_PER_ACCOUNT_LIMIT_EXCEEDED. On success, associations between the provided account and tokens are made and the account is ready to interact with the tokens. |
burnToken(address token, int64 amount, int64[] serialNumbers) | Burns an amount of the token from the defined treasury account |
dissociateToken(address account, address token) | Single-token variant of dissociateTokens. Will be mapped to a single entry array call of dissociateTokens |
dissociateTokens(address account, address[] tokens) | Dissociates the provided account with the provided tokens. Must be signed by the provided Account's key. If the provided account is not found, the transaction will resolve to INVALID_ACCOUNT_ID. If the provided account has been deleted, the transaction will resolve to ACCOUNT_DELETED. If any of the provided tokens is not found, the transaction will resolve to INVALID_TOKEN_REF. If any of the provided tokens has been deleted, the transaction will resolve to TOKEN_WAS_DELETED. If an association between the provided account and any of the tokens does not exist, the transaction will resolve to TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. If a token has not been deleted and has not expired, and the user has a nonzero balance, the transaction will resolve to TRANSACTION_REQUIRES_ZERO_TOKEN_BALANCES. If a fungible token has expired, the user can disassociate even if their token balance is not zero. If a non fungible token has expired, the user can not disassociate if their token balance is not zero. The transaction will resolve to TRANSACTION_REQUIRED_ZERO_TOKEN_BALANCES. On success, associations between the provided account and tokens are removed. |
getApproved(address token, uint256 serialNumber) | Get the approved address for a single NFT Only Applicable to NFT Tokens |
getFungibleTokenInfo(address token) | Query fungible token info |
getNonFungibleTokenInfo(address token, int64 serialNumber) | Query non fungible token info |
getTokenCustomFees(address token) | Query token custom fees |
getTokenDefaultFreezeStatus(address token) | Query token default freeze status |
getTokenDefaultKycStatus(address token) | Query token default kyc status |
getTokenExpiryInfo(address token) | Query token expiry info |
getTokenInfo(address token) | Query token info |
getTokenKey(address token, uint256 keyType) | Query token KeyValue |
getTokenType(address token) | Query to return the token type for a given address |
isApprovedForAll(address token, address owner, address operator) | Query if an address is an authorized operator for another address Only Applicable to NFT Tokens |
isToken(address token) | Query if valid token found for the given address |
mintToken(address token, int64 amount, bytes[] metadata) | Mints an amount of the token to the defined treasury account |
setApprovalForAll(address token, address operator, bool approved) | Enable or disable approval for a third party ("operator") to manage all of msg.sender 's assets |
transferFrom(address token, address from, address to, uint256 amount) | Only applicable to fungible tokens |
transferFromNFT(address token, address from, address to, uint256 serialNumber) | Transfers serialNumber of token from from to to using the allowance mechanism. Only applicable to NFT tokens |
transferNFT(address token, address sender, address recipient, int64 serialNumber) | Transfers tokens where the calling account/contract is implicitly the first entry in the token transfer list, where the amount is the value needed to zero balance the transfers. Regular signing rules apply for sending (positive amount) or receiving (negative amount) |
transferNFTs(address token, address[] sender, address[] receiver, int64[] serialNumber) | Initiates a Non-Fungable Token Transfer |
transferToken(address token, address sender, address recipient, int64 amount) | Transfers tokens where the calling account/contract is implicitly the first entry in the token transfer list, where the amount is the value needed to zero balance the transfers. Regular signing rules apply for sending (positive amount) or receiving (negative amount) |