Skip to main content
In this advanced tutorial, you’ll learn how to interact with the Hedera Token Service (HTS) using System Contracts precompiles on a forked network. This guide covers creating HTS tokens, minting, transferring, and understanding the limitations of the forking emulation layer. This guide shows how to:
  • Create HTS fungible tokens using System Contracts precompiles
  • Mint and transfer HTS tokens on a forked network
References:
For a deeper understanding of how Hedera forking works and its limitations, see Forking Hedera Network for Local Testing.
You can take a look at the complete code in the advanced-hts-fork-test repository.

Prerequisites


Table of Contents

  1. Step 1: Project Setup
  2. Step 2: Create the HTS Contract and Deploy to Testnet
  3. Step 3: Write Tests for Supported HTS Methods
  4. Step 4: Run Tests on the Forked Network

Step 1: Project Setup

Initialize Project

Create a new directory and initialize the project:
mkdir advanced-hts-fork-test
cd advanced-hts-fork-test
npm init -y

Install Dependencies

Create or update your package.json with all required dependencies:
package.json
{
  "name": "advanced-hts-fork-test",
  "version": "1.0.0",
  "description": "Advanced Hedera HTS Fork Testing with Hardhat",
  "private": true,
  "scripts": {
    "compile": "hardhat compile",
    "test": "hardhat test",
    "deploy:testnet": "hardhat run scripts/deploy.ts --network hederaTestnet"
  },
  "license": "MIT",
  "devDependencies": {
    "@hashgraph/smart-contracts": "github:hashgraph/hedera-smart-contracts",
    "@hashgraph/system-contracts-forking": "0.1.2",
    "@nomicfoundation/hardhat-chai-matchers": "^2.0.0",
    "@nomicfoundation/hardhat-ethers": "^3.0.0",
    "@nomicfoundation/hardhat-ignition": "^0.15.16",
    "@nomicfoundation/hardhat-ignition-ethers": "^0.15.0",
    "@nomicfoundation/hardhat-network-helpers": "^1.0.0",
    "@nomicfoundation/hardhat-toolbox": "5.0.0",
    "@nomicfoundation/hardhat-verify": "^2.0.0",
    "@nomicfoundation/ignition-core": "^0.15.15",
    "@openzeppelin/contracts": "^5.0.0",
    "@typechain/ethers-v6": "^0.5.0",
    "@typechain/hardhat": "^9.0.0",
    "@types/chai": "^4.2.0",
    "@types/mocha": ">=9.1.0",
    "@types/node": "^20.0.0",
    "chai": "^4.2.0",
    "hardhat": "2.22.19",
    "hardhat-gas-reporter": "^1.0.8",
    "solidity-coverage": "^0.8.1",
    "ts-node": "^10.9.0",
    "typechain": "^8.3.0",
    "typescript": "^5.0.0"
  }
}
Note the addition of @hashgraph/smart-contracts which provides the HTS System Contracts interfaces and helper contracts. Then install all dependencies:
npm install --legacy-peer-deps
Why these specific versions?The @hashgraph/system-contracts-forking plugin requires Hardhat 2.22.x. Newer versions of Hardhat (2.28+) introduced breaking changes that cause a No known hardfork for execution error when forking Hedera networks.
  • [email protected] - Last compatible version before breaking changes
  • @nomicfoundation/[email protected] - Compatible with Hardhat 2.22.x
  • @hashgraph/[email protected] - The Hedera forking plugin
  • @hashgraph/smart-contracts - HTS System Contracts interfaces
  • --legacy-peer-deps - Required to resolve dependency conflicts between these versions
Verify Hardhat is installed correctly:
npx hardhat --version
# Should output: 2.22.19

Create Project Structure

Create the necessary directories:
mkdir contracts test scripts

Configure TypeScript

Create tsconfig.json in your project root:
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "dist",
    "resolveJsonModule": true
  },
  "include": ["./scripts", "./test", "./typechain-types"],
  "files": ["./hardhat.config.ts"]
}

Configure Hardhat

Create hardhat.config.ts in your project root:
hardhat.config.ts
import { HardhatUserConfig, vars } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "@hashgraph/system-contracts-forking/plugin";

// Load configuration variables
const HEDERA_RPC_URL = vars.get("HEDERA_RPC_URL");
const HEDERA_PRIVATE_KEY = vars.get("HEDERA_PRIVATE_KEY");

const config: HardhatUserConfig = {
  solidity: {
    version: "0.8.33",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      }
    }
  },
  networks: {
    // Network for deploying to real testnet
    hederaTestnet: {
      url: HEDERA_RPC_URL,
      accounts: [HEDERA_PRIVATE_KEY],
      chainId: 296
    },
    // Local fork of testnet for testing
    hardhat: {
      forking: {
        url: HEDERA_RPC_URL,
        // Pin to a specific block for reproducible tests
        // Update this after deploying your contract
        blockNumber: 29900000,
        // @ts-ignore - custom properties for hedera-forking plugin
        chainId: 296,
        // @ts-ignore
        workerPort: 10001
      }
    }
  }
};

export default config;
Important configuration notes:
  • HEDERA_RPC_URL - Loaded from Hardhat configuration variables
  • HEDERA_PRIVATE_KEY - Loaded securely from configuration variables
  • hederaTestnet - Network configuration for deploying to real testnet
  • hardhat.forking - Configuration for forking testnet locally
  • blockNumber - Pin to a block where your deployed contract exists
  • chainId: 296 - Required for testnet (295 for mainnet)
  • workerPort: 10001 - Any free port for the worker that intercepts Hardhat calls
  • @ts-ignore - Required because chainId and workerPort are custom properties not in Hardhat’s type definitions
  • Optimizer is enabled for gas efficiency

Set Configuration Variables

Now that hardhat.config.ts exists, you can set the configuration variables. Hardhat allows you to securely store sensitive values using configuration variables:
npx hardhat vars set HEDERA_RPC_URL
When prompted, enter: https://testnet.hashio.io/api
npx hardhat vars set HEDERA_PRIVATE_KEY
When prompted, enter the HEX Encoded Private Key for your ECDSA account from the Hedera Portal.
Make sure your ECDSA account exists on testnet and has sufficient HBAR for deployment. You can fund your testnet account using the Hedera Portal.

Step 2: Create the HTS Contract and Deploy to Testnet

Create the HTS Interaction Contract

Create a new file contracts/HTSTokenManager.sol:
contracts/HTSTokenManager.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.33;

import "@hashgraph/smart-contracts/contracts/system-contracts/hedera-token-service/HederaTokenService.sol";
import "@hashgraph/smart-contracts/contracts/system-contracts/hedera-token-service/ExpiryHelper.sol";
import "@hashgraph/smart-contracts/contracts/system-contracts/hedera-token-service/KeyHelper.sol";
import "@hashgraph/smart-contracts/contracts/system-contracts/HederaResponseCodes.sol";
import "@hashgraph/smart-contracts/contracts/system-contracts/hedera-token-service/IHederaTokenService.sol";
import "@hashgraph/smart-contracts/contracts/system-contracts/hedera-token-service/FeeHelper.sol";

contract HTSTokenManager is
    HederaTokenService,
    ExpiryHelper,
    KeyHelper,
    FeeHelper
{
    bool finiteTotalSupplyType = true;

    event ResponseCode(int256 responseCode);
    event CreatedToken(address tokenAddress);
    event FungibleTokenInfo(IHederaTokenService.FungibleTokenInfo tokenInfo);
    event TransferToken(address tokenAddress, address receiver, int64 amount);
    event MintedToken(int64 newTotalSupply, int64[] serialNumbers);

    /**
     * @notice Creates a new fungible token using HTS
     */
    function createFungibleTokenPublic(
        string memory _name,
        string memory _symbol
    ) public payable {
        // Build token definition
        IHederaTokenService.HederaToken memory token;
        token.name = _name;
        token.symbol = _symbol;
        token.treasury = address(this);
        token.memo = "This is a fungible token";

        // Keys: SUPPLY + ADMIN -> contractId
        IHederaTokenService.TokenKey[]
            memory keys = new IHederaTokenService.TokenKey[](2);
        keys[0] = getSingleKey(
            KeyType.SUPPLY,
            KeyValueType.CONTRACT_ID,
            address(this)
        );
        keys[1] = getSingleKey(
            KeyType.ADMIN,
            KeyValueType.CONTRACT_ID,
            address(this)
        );
        token.tokenKeys = keys;

        (int256 responseCode, address tokenAddress) = HederaTokenService
            .createFungibleToken(token, 0, 0);

        if (responseCode != HederaResponseCodes.SUCCESS) {
            revert();
        }
        emit CreatedToken(tokenAddress);
    }

    /**
     * @notice Mints tokens
     */
    function mintTokenPublic(
        address token,
        int64 amount,
        bytes[] memory metadata
    )
        public
        returns (
            int256 responseCode,
            int64 newTotalSupply,
            int64[] memory serialNumbers
        )
    {
        (responseCode, newTotalSupply, serialNumbers) = HederaTokenService
            .mintToken(token, amount, metadata);
        emit ResponseCode(responseCode);

        if (responseCode != HederaResponseCodes.SUCCESS) {
            revert();
        }

        emit MintedToken(newTotalSupply, serialNumbers);
    }

    /**
     * @notice Transfers tokens using HTS transferToken
     * @dev This is a SUPPORTED method in hedera-forking
     */
    function transferTokenPublic(
        address token,
        address sender,
        address receiver,
        int64 amount
    ) public returns (int256 responseCode) {
        responseCode = HederaTokenService.transferToken(
            token,
            sender,
            receiver,
            amount
        );
        emit ResponseCode(responseCode);

        if (responseCode != HederaResponseCodes.SUCCESS) {
            revert();
        }
    }

    /**
     * @notice Gets token info
     */
    function getTokenInfoPublic(
        address token
    )
        public
        returns (
            int256 responseCode,
            IHederaTokenService.TokenInfo memory tokenInfo
        )
    {
        (responseCode, tokenInfo) = HederaTokenService.getTokenInfo(token);
        emit ResponseCode(responseCode);
    }

    /**
     * @notice Gets fungible token info
     */
    function getFungibleTokenInfoPublic(
        address token
    )
        public
        returns (
            int256 responseCode,
            IHederaTokenService.FungibleTokenInfo memory tokenInfo
        )
    {
        (responseCode, tokenInfo) = HederaTokenService.getFungibleTokenInfo(
            token
        );
        emit ResponseCode(responseCode);
        emit FungibleTokenInfo(tokenInfo);
    }
}
Key features of this contract:
  • createFungibleTokenPublic - Creates new HTS fungible tokens
  • mintTokenPublic - Mints additional tokens (requires supply key)
  • transferTokenPublic - Supported HTS transfer method
  • getTokenInfoPublic / getFungibleTokenInfoPublic - Query token information

Compile the Contract

npx hardhat compile

Create Deployment Script

Create a new file scripts/deploy.ts:
scripts/deploy.ts
import { ethers } from "hardhat";

async function main(): Promise<void> {
  const [deployer] = await ethers.getSigners();

  console.log("Deploying contracts with the account:", deployer.address);

  const balance = await ethers.provider.getBalance(deployer.address);
  console.log("Account balance:", ethers.formatEther(balance), "HBAR");

  // 1) Deploy the HTSTokenManager contract
  console.log("\n--- Deploying HTSTokenManager ---");
  const HTSTokenManager = await ethers.getContractFactory("HTSTokenManager");
  const htsManager = await HTSTokenManager.deploy();
  await htsManager.waitForDeployment();

  const contractAddress = await htsManager.getAddress();
  console.log("HTSTokenManager deployed to:", contractAddress);
  console.log(
    "View on HashScan: https://hashscan.io/testnet/contract/" + contractAddress
  );

  // 2) Create a fungible token using the contract
  console.log("\n--- Creating HTS Fungible Token ---");
  const TOKEN_NAME = "TestForkToken";
  const TOKEN_SYMBOL = "TFT";
  const HBAR_TO_SEND = "15"; // HBAR to send for token creation

  console.log(`Creating token "${TOKEN_NAME}" (${TOKEN_SYMBOL})...`);
  console.log(`Sending ${HBAR_TO_SEND} HBAR for token creation...`);

  const createTx = await htsManager.createFungibleTokenPublic(
    TOKEN_NAME,
    TOKEN_SYMBOL,
    {
      gasLimit: 1_000_000,
      value: ethers.parseEther(HBAR_TO_SEND)
    }
  );
  const createReceipt = await createTx.wait();
  console.log("createFungibleTokenPublic() tx hash:", createTx.hash);

  // 3) Extract token address from CreatedToken event
  let tokenAddress: string | null = null;
  for (const log of createReceipt?.logs || []) {
    try {
      const parsed = htsManager.interface.parseLog({
        topics: log.topics as string[],
        data: log.data
      });
      if (parsed?.name === "CreatedToken") {
        tokenAddress = parsed.args[0];
        break;
      }
    } catch {
      // Not our event, skip
    }
  }

  if (!tokenAddress) {
    throw new Error("Failed to extract token address from CreatedToken event");
  }

  console.log("HTS Token created at:", tokenAddress);
  console.log(
    "View token on HashScan: https://hashscan.io/testnet/token/" + tokenAddress
  );

  // 4) Get deployment block number
  const blockNumber = await ethers.provider.getBlockNumber();
  console.log("\nDeployed at block number:", blockNumber);

  // 5) Summary
  console.log("\n" + "=".repeat(60));
  console.log("DEPLOYMENT SUMMARY");
  console.log("=".repeat(60));
  console.log("HTSTokenManager Contract:", contractAddress);
  console.log("HTS Token Address:        ", tokenAddress);
  console.log("Block Number:            ", blockNumber);
  console.log("=".repeat(60));
  console.log("\n=== IMPORTANT ===");
  console.log("Update your hardhat.config.ts with:");
  console.log(`  blockNumber: ${blockNumber}`);
  console.log("\nUpdate your test file with:");
  console.log(`  DEPLOYED_CONTRACT = "${contractAddress}"`);
  console.log(`  TOKEN_ADDRESS = "${tokenAddress}"`);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Deploy to Testnet

Deploy your contract to Hedera testnet:
npx hardhat run scripts/deploy.ts --network hederaTestnet
You should see output similar to:
Deploying contracts with the account: 0xA98556A4deeB07f21f8a66093989078eF86faa30
Account balance: 67044.71699545 HBAR

--- Deploying HTSTokenManager ---
HTSTokenManager deployed to: 0x525F2a20563A052F7dC65df59106EC82f0584102
View on HashScan: https://hashscan.io/testnet/contract/0x525F2a20563A052F7dC65df59106EC82f0584102

--- Creating HTS Fungible Token ---
Creating token "TestForkToken" (TFT)...
Sending 15 HBAR for token creation...
createFungibleTokenPublic() tx hash: 0xe71eb1253d11120dc9db1c764070fdb13db0b25374c30f2f0bd2792d1eead3fb
HTS Token created at: 0x000000000000000000000000000000000073E8dC
View token on HashScan: https://hashscan.io/testnet/token/0x000000000000000000000000000000000073E8dC

Deployed at block number: 29968809

============================================================
DEPLOYMENT SUMMARY
============================================================
HTSTokenManager Contract: 0x525F2a20563A052F7dC65df59106EC82f0584102
HTS Token Address:         0x000000000000000000000000000000000073E8dC
Block Number:             29968809
============================================================

=== IMPORTANT ===
Update your hardhat.config.ts with:
  blockNumber: 29968809

Update your test file with:
  DEPLOYED_CONTRACT = "0x525F2a20563A052F7dC65df59106EC82f0584102"
  TOKEN_ADDRESS = "0x000000000000000000000000000000000073E8dC"
Save the deployed contract address and block number! You’ll need these for your fork tests. The contract must exist at the block you’re forking from.

Update Hardhat Config with Deployment Block

After deployment, update your hardhat.config.ts with the block number:
blockNumber: 29966796, // <-- Update this with your deployment block or higher
We have already deployed this HTS contract on testnet at https://hashscan.io/testnet/contract/0xdC6F13e9Bb740593ffacdB7510548FD2E62bc035 so we will be using this for the remainder of this exercise.

Step 3: Write Tests for Supported HTS Methods

Create a new file test/HTSTokenManager.test.ts:
Make sure to update the DEPLOYED_CONTRACT and TOKEN_ADDRESS constants below with the values from your deployment.
test/HTSTokenManager.test.ts
import { expect } from "chai";
import { ethers, network } from "hardhat";
import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { HTSTokenManager } from "../typechain-types";
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";

// HTS Success response code
const SUCCESS = 22n;

describe("HTSTokenManager - HTS Forking Tests", function () {
  // Increase timeout for network operations
  this.timeout(120000); // 2 minutes

  // Add delay between tests to avoid rate limiting
  afterEach(async function () {
    await new Promise((resolve) => setTimeout(resolve, 1000)); // 1 second delay
  });

  // ============================================
  // UPDATE THESE VALUES AFTER RUNNING deploy.ts
  // ============================================
  // Your deployed testnet contract address
  const DEPLOYED_CONTRACT = "YOUR_CONTRACT_ADDRESS"; // <--- UPDATE THIS>
  // The HTS token created during deployment
  const TOKEN_ADDRESS = "YOUR_TOKEN_ADDRESS"; // <--- UPDATE THIS>
  // ============================================

  let htsManager: HTSTokenManager;
  let alice: HardhatEthersSigner;
  let bob: HardhatEthersSigner;

  async function setupFixture(): Promise<{
    htsManager: HTSTokenManager;
    alice: HardhatEthersSigner;
    bob: HardhatEthersSigner;
  }> {
    // Bind to the deployed contract on the forked network
    const contract = await ethers.getContractAt(
      "HTSTokenManager",
      DEPLOYED_CONTRACT
    );

    // Get local test accounts
    const [, aliceSigner, bobSigner] = await ethers.getSigners();

    // Fund local accounts
    await network.provider.send("hardhat_setBalance", [
      aliceSigner.address,
      "0x56BC75E2D63100000" // 100 ETH in hex
    ]);
    await network.provider.send("hardhat_setBalance", [
      bobSigner.address,
      "0x56BC75E2D63100000"
    ]);

    // Fund the contract (it's the treasury and needs gas for operations)
    await network.provider.send("hardhat_setBalance", [
      DEPLOYED_CONTRACT,
      "0x56BC75E2D63100000"
    ]);

    return {
      htsManager: contract as HTSTokenManager,
      alice: aliceSigner,
      bob: bobSigner
    };
  }

  beforeEach(async function () {
    const fixture = await loadFixture(setupFixture);
    htsManager = fixture.htsManager;
    alice = fixture.alice;
    bob = fixture.bob;
  });

  /**
   * Helper function to get response code from receipt
   */
  function getResponseCodeFromReceipt(
    receipt: ContractTransactionReceipt | null
  ): bigint | null {
    const responseEvent = receipt?.logs.find((log: any) => {
      try {
        const parsed = htsManager.interface.parseLog({
          topics: log.topics as string[],
          data: log.data
        });
        return parsed?.name === "ResponseCode";
      } catch {
        return false;
      }
    });

    if (responseEvent) {
      const parsed = htsManager.interface.parseLog({
        topics: responseEvent.topics as string[],
        data: responseEvent.data
      });
      return parsed?.args[0];
    }
    return null;
  }

  /**
   * Helper function to get minted token info from receipt
   */
  function getMintedTokenInfoFromReceipt(
    receipt: any
  ): { newTotalSupply: bigint } | null {
    const mintedEvent = receipt?.logs.find((log: any) => {
      try {
        const parsed = htsManager.interface.parseLog({
          topics: log.topics as string[],
          data: log.data
        });
        return parsed?.name === "MintedToken";
      } catch {
        return false;
      }
    });

    if (mintedEvent) {
      const parsed = htsManager.interface.parseLog({
        topics: mintedEvent.topics as string[],
        data: mintedEvent.data
      });
      return { newTotalSupply: parsed?.args[0] };
    }
    return null;
  }

  /* =========================
      Token Info Tests
     ========================= */

  describe("Token Info", function () {
    it("should get token info for the pre-created token", async function () {
      const tx = await htsManager.getTokenInfoPublic(TOKEN_ADDRESS);
      const receipt = await tx.wait();

      const responseCode = getResponseCodeFromReceipt(receipt);
      expect(responseCode).to.equal(SUCCESS);
      console.log("Successfully retrieved token info");
    });

    it("should get fungible token info for the pre-created token", async function () {
      const tx = await htsManager.getFungibleTokenInfoPublic(TOKEN_ADDRESS);
      const receipt = await tx.wait();

      const infoEvent = receipt?.logs.find((log) => {
        try {
          const parsed = htsManager.interface.parseLog({
            topics: log.topics as string[],
            data: log.data
          });
          return parsed?.name === "FungibleTokenInfo";
        } catch {
          return false;
        }
      });

      expect(infoEvent).to.not.be.undefined;
      console.log("Successfully retrieved fungible token info");
    });

    it("should read token properties via ERC-20 interface", async function () {
      const token = await ethers.getContractAt(
        [
          "function name() view returns (string)",
          "function symbol() view returns (string)",
          "function decimals() view returns (uint8)",
          "function totalSupply() view returns (uint256)",
          "function balanceOf(address) view returns (uint256)"
        ],
        TOKEN_ADDRESS
      );

      const name = await token.name();
      const symbol = await token.symbol();
      const decimals = await token.decimals();
      const totalSupply = await token.totalSupply();

      console.log(`Token Name:  ${name}`);
      console.log(`Token Symbol: ${symbol}`);
      console.log(`Token Decimals: ${decimals}`);
      console.log(`Token Total Supply: ${totalSupply}`);

      expect(name).to.equal("TestForkToken");
      expect(symbol).to.equal("TFT");
    });
  });

  /* =========================
      Token Minting Tests
     ========================= */

  describe("Token Minting", function () {
    it("should mint tokens successfully", async function () {
      const mintAmount = 1000n;

      const tx = await htsManager.mintTokenPublic(
        TOKEN_ADDRESS,
        mintAmount,
        []
      );
      const receipt = await tx.wait();

      const responseCode = getResponseCodeFromReceipt(receipt);
      expect(responseCode).to.equal(SUCCESS);

      const mintInfo = getMintedTokenInfoFromReceipt(receipt);
      expect(mintInfo).to.not.be.null;
      console.log(
        `Minted ${mintAmount} tokens.  New total supply: ${mintInfo?.newTotalSupply}`
      );
    });

    it("should mint tokens multiple times and track total supply", async function () {
      // First mint
      const tx1 = await htsManager.mintTokenPublic(TOKEN_ADDRESS, 500n, []);
      const receipt1 = await tx1.wait();
      const mintInfo1 = getMintedTokenInfoFromReceipt(receipt1);
      console.log(
        `First mint - New total supply: ${mintInfo1?.newTotalSupply}`
      );

      // Second mint
      const tx2 = await htsManager.mintTokenPublic(TOKEN_ADDRESS, 300n, []);
      const receipt2 = await tx2.wait();
      const mintInfo2 = getMintedTokenInfoFromReceipt(receipt2);
      console.log(
        `Second mint - New total supply: ${mintInfo2?.newTotalSupply}`
      );

      // Verify supply increased
      expect(mintInfo2?.newTotalSupply).to.be.gt(
        mintInfo1?.newTotalSupply || 0n
      );
    });

    it("should increase treasury balance after minting", async function () {
      // Get ERC-20 interface
      const token = await ethers.getContractAt(
        ["function balanceOf(address) view returns (uint256)"],
        TOKEN_ADDRESS
      );

      // Check balance before mint
      const balanceBefore = await token.balanceOf(DEPLOYED_CONTRACT);
      console.log(`Treasury balance before mint: ${balanceBefore}`);

      // Mint tokens
      const mintAmount = 2000n;
      const tx = await htsManager.mintTokenPublic(
        TOKEN_ADDRESS,
        mintAmount,
        []
      );
      await tx.wait();

      // Check balance after mint
      const balanceAfter = await token.balanceOf(DEPLOYED_CONTRACT);
      console.log(`Treasury balance after mint: ${balanceAfter}`);

      expect(balanceAfter).to.equal(balanceBefore + mintAmount);
    });
  });

  /* =========================
      Token Transfer Tests
     ========================= */

  describe("Token Transfers", function () {
    it("should transfer tokens from treasury to alice", async function () {
      // First mint some tokens
      const mintAmount = 5000n;
      await htsManager.mintTokenPublic(TOKEN_ADDRESS, mintAmount, []);

      // Get ERC-20 interface
      const token = await ethers.getContractAt(
        ["function balanceOf(address) view returns (uint256)"],
        TOKEN_ADDRESS
      );

      // Check alice's balance before transfer
      const aliceBalanceBefore = await token.balanceOf(alice.address);
      console.log(`Alice balance before transfer:  ${aliceBalanceBefore}`);

      // Transfer tokens from treasury (contract) to alice
      const transferAmount = 1000n;
      const tx = await htsManager.transferTokenPublic(
        TOKEN_ADDRESS,
        DEPLOYED_CONTRACT, // sender (treasury/contract)
        alice.address, // receiver
        transferAmount
      );
      const receipt = await tx.wait();

      const responseCode = getResponseCodeFromReceipt(receipt);
      expect(responseCode).to.equal(SUCCESS);

      // Check alice's balance after transfer
      const aliceBalanceAfter = await token.balanceOf(alice.address);
      console.log(`Alice balance after transfer: ${aliceBalanceAfter}`);

      expect(aliceBalanceAfter).to.equal(aliceBalanceBefore + transferAmount);
    });

    it("should transfer tokens to multiple recipients", async function () {
      // Mint tokens first
      const mintAmount = 10000n;
      await htsManager.mintTokenPublic(TOKEN_ADDRESS, mintAmount, []);

      const token = await ethers.getContractAt(
        ["function balanceOf(address) view returns (uint256)"],
        TOKEN_ADDRESS
      );

      // Transfer to alice
      const aliceAmount = 2000n;
      await htsManager.transferTokenPublic(
        TOKEN_ADDRESS,
        DEPLOYED_CONTRACT,
        alice.address,
        aliceAmount
      );
      console.log(`Transferred ${aliceAmount} to alice`);

      // Transfer to bob
      const bobAmount = 3000n;
      await htsManager.transferTokenPublic(
        TOKEN_ADDRESS,
        DEPLOYED_CONTRACT,
        bob.address,
        bobAmount
      );
      console.log(`Transferred ${bobAmount} to bob`);

      // Verify balances
      const aliceBalance = await token.balanceOf(alice.address);
      const bobBalance = await token.balanceOf(bob.address);

      expect(aliceBalance).to.be.gte(aliceAmount);
      expect(bobBalance).to.be.gte(bobAmount);

      console.log(`Alice final balance: ${aliceBalance}`);
      console.log(`Bob final balance: ${bobBalance}`);
    });

    it("should mint and then transfer in sequence", async function () {
      const token = await ethers.getContractAt(
        [
          "function balanceOf(address) view returns (uint256)",
          "function totalSupply() view returns (uint256)"
        ],
        TOKEN_ADDRESS
      );

      // Get initial state
      const initialSupply = await token.totalSupply();
      console.log(`Initial total supply: ${initialSupply}`);

      // Mint tokens
      const mintAmount = 3000n;
      const mintTx = await htsManager.mintTokenPublic(
        TOKEN_ADDRESS,
        mintAmount,
        []
      );
      await mintTx.wait();
      console.log(`Minted ${mintAmount} tokens`);

      // Verify supply increased
      const supplyAfterMint = await token.totalSupply();
      console.log(`Supply after mint: ${supplyAfterMint}`);
      expect(supplyAfterMint).to.equal(initialSupply + mintAmount);

      // Transfer some tokens
      const transferAmount = 1500n;
      const transferTx = await htsManager.transferTokenPublic(
        TOKEN_ADDRESS,
        DEPLOYED_CONTRACT,
        alice.address,
        transferAmount
      );
      await transferTx.wait();
      console.log(`Transferred ${transferAmount} to alice`);

      // Verify alice received tokens
      const aliceBalance = await token.balanceOf(alice.address);
      expect(aliceBalance).to.be.gte(transferAmount);
      console.log(`Alice balance:  ${aliceBalance}`);

      // Total supply should remain same after transfer
      const supplyAfterTransfer = await token.totalSupply();
      expect(supplyAfterTransfer).to.equal(supplyAfterMint);
    });
  });

  /* =========================
      Token Creation Tests
     ========================= */

  describe("Token Creation", function () {
    it("should create a new token via the contract", async function () {
      const tx = await htsManager.createFungibleTokenPublic(
        "New Test Token",
        "NTT",
        { value: ethers.parseEther("15") }
      );
      const receipt = await tx.wait();

      const createdEvent = receipt?.logs.find((log) => {
        try {
          const parsed = htsManager.interface.parseLog({
            topics: log.topics as string[],
            data: log.data
          });
          return parsed?.name === "CreatedToken";
        } catch {
          return false;
        }
      });

      expect(createdEvent).to.not.be.undefined;

      if (createdEvent) {
        const parsed = htsManager.interface.parseLog({
          topics: createdEvent.topics as string[],
          data: createdEvent.data
        });
        const newTokenAddress = parsed?.args[0];
        console.log(`Created new token at:  ${newTokenAddress}`);
        expect(newTokenAddress).to.not.equal(ethers.ZeroAddress);
      }
    });
  });

  /* =========================
      Fork Verification
     ========================= */

  describe("Fork Network Verification", function () {
    it("should be connected to a forked network", async function () {
      const blockNumber = await ethers.provider.getBlockNumber();
      console.log(`Current fork block number: ${blockNumber}`);
      expect(blockNumber).to.be.gt(0);
    });

    it("should be interacting with real deployed contract", async function () {
      const contractCode = await ethers.provider.getCode(DEPLOYED_CONTRACT);
      expect(contractCode).to.not.equal("0x");
      console.log(
        `Contract at ${DEPLOYED_CONTRACT} has ${contractCode.length} bytes of code`
      );
    });

    it("should be able to access the pre-created token", async function () {
      const tokenCode = await ethers.provider.getCode(TOKEN_ADDRESS);
      expect(tokenCode).to.not.equal("0x");
      console.log(`Token at ${TOKEN_ADDRESS} exists on the forked network`);
    });

    it("should verify contract is the token treasury", async function () {
      const token = await ethers.getContractAt(
        ["function balanceOf(address) view returns (uint256)"],
        TOKEN_ADDRESS
      );

      // The contract should be the treasury (where minted tokens go)
      // After we mint, the contract's balance should be > 0
      const mintTx = await htsManager.mintTokenPublic(TOKEN_ADDRESS, 100n, []);
      await mintTx.wait();

      const contractBalance = await token.balanceOf(DEPLOYED_CONTRACT);
      expect(contractBalance).to.be.gt(0n);
      console.log(`Contract (treasury) balance: ${contractBalance}`);
    });
  });
});
Key points about these tests:
  • TypeScript types - Uses generated types from typechain-types for type safety
  • Uses deployed contract - Tests bind to the already deployed HTSTokenManager contract using getContractAt
  • HTS token creation - Demonstrates creating fungible tokens using HTS System Contracts precompiles on the forked network
  • Event parsing - Parses CreatedToken, ResponseCode, and FungibleTokenInfo events to verify HTS operations
  • Response code validation - Checks for HTS SUCCESS response code (22) to confirm operations completed successfully
  • Self-contained tests - Each test creates its own token and operates on it, ensuring test isolation
  • Local modifications - All token creations and queries happen only on the local fork
  • No testnet changes - The real testnet is never modified by these tests
  • Uses fixtures - loadFixture ensures each test starts with a clean state
  • Funded accounts - Uses hardhat_setBalance to fund test accounts for gas fees and token creation costs

Step 4: Run Tests on the Forked Network

Run your tests against the forked Hedera testnet:
npx hardhat test
You should see output similar to:
  HTSTokenManager - HTS Forking Tests
    Token Info
Successfully retrieved token info
 should get token info for the pre-created token (870ms)
Successfully retrieved fungible token info
 should get fungible token info for the pre-created token (108ms)
Token Name:  TestForkToken
Token Symbol: TFT
Token Decimals: 0
Token Total Supply: 0
 should read token properties via ERC-20 interface (196ms)
    Token Minting
Minted 1000 tokens.  New total supply: 1000
 should mint tokens successfully (602ms)
First mint - New total supply: 500
Second mint - New total supply: 800
 should mint tokens multiple times and track total supply
Treasury balance before mint: 0
Treasury balance after mint: 2000
 should increase treasury balance after minting
    Token Transfers
Alice balance before transfer:  0
Alice balance after transfer: 1000
 should transfer tokens from treasury to alice (302ms)
Transferred 2000 to alice
Transferred 3000 to bob
Alice final balance: 2000
Bob final balance: 3000
 should transfer tokens to multiple recipients (275ms)
Initial total supply: 0
Minted 3000 tokens
Supply after mint: 3000
Transferred 1500 to alice
Alice balance:  1500
 should mint and then transfer in sequence
    Token Creation
Created new token at:  0x0000000000000000000000000000000000000408
 should create a new token via the contract (487ms)
    Fork Network Verification
Current fork block number: 29968809
 should be connected to a forked network
Contract at 0x525F2a20563A052F7dC65df59106EC82f0584102 has 17144 bytes of code
 should be interacting with real deployed contract
Token at 0x000000000000000000000000000000000073E8dC exists on the forked network
 should be able to access the pre-created token
Contract (treasury) balance: 100
 should verify contract is the token treasury


  14 passing (18s)

Pin to a Specific Block

For reproducible tests, make sure the blockNumber in your hardhat.config.ts is set to a block where your contract exists. If you try to fork at a block before your contract was deployed, you’ll see an error because the contract doesn’t exist yet at that block.

Best Practices for HTS Fork Testing

  1. Always verify on real network - Fork testing is for development; always test on testnet/mainnet before production
  2. Use supported methods - Stick to the currently supported HTS methods
  3. Handle associations - Remember that token associations work differently in emulation
  4. Check response codes - Always verify HTS response codes (SUCCESS = 22)
  5. Fund test accounts - Use hardhat_setBalance to fund accounts for gas

Understanding Fork Testing with Deployed Contracts

Why Test Against Deployed Contracts?

  1. Real-world state - Test against actual balances, allowances, and state
  2. No deployment costs - Don’t spend gas deploying for every test run
  3. Impersonation - Act as any account (even the contract owner) without their private key
  4. Safe experimentation - Try anything without affecting the real network

How Impersonation Works

Hardhat’s impersonation feature allows you to act as any address without having its private key:
// Impersonate an address
await network.provider.request({
  method: "hardhat_impersonateAccount",
  params: [someAddress]
});

// Get a signer for that address
const impersonatedSigner = await ethers.getSigner(someAddress);

// Act as that account
await token.connect(impersonatedSigner).transfer(recipient, amount);
Note: Impersonation is not needed in this tutorial because HTSTokenManager is designed with the contract itself as the treasury and supply key holder. All functions are public with no access control, so anyone can call them. See Part 1 for an example where impersonation is required for onlyOwner functions.

Funding Accounts with hardhat_setBalance

Local test accounts on a forked network start with no balance. Fund them for gas and operations:
// Fund an account with 100 HBAR (hex wei)
await network.provider.send("hardhat_setBalance", [
  accountAddress,
  "0x56BC75E2D63100000" // 100 HBAR in hex
]);

Further Learning & Next Steps

  1. Forking Hedera Network for Local Testing
    Deep dive into how Hedera forking works under the hood
  2. How to Fork Hedera with Foundry
    Learn fork testing with Foundry framework
  3. hedera-forking Repository
    Explore examples and documentation
  4. Hedera Smart Contracts Repository
    Explore HTS System Contracts interfaces

Writer: Kiran Pachhai, Developer Advocate