How to Mint & Burn an ERC-721 Token using Foundry(Part 1)

In this tutorial, you’ll deploy, mint, and burn ERC‑721 tokens (NFTs) using Foundry and OpenZeppelin on the Hedera Testnet. You’ll set up a Foundry project, write an ERC‑721 contract, deploy it via a Foundry script, mint an NFT to your account, add burn functionality, and burn an NFT.

We’ll connect to Hedera via the JSON‑RPC relay (Hashio) and use Foundry tools:

  • forge: build and deploy through scripts

  • cast: quick RPC interactions

You can take a look at the complete code in the Hedera-Code-Snippets repository.


Prerequisites

  • Foundry installed (forge, cast, anvil, chisel):

    • curl -L https://foundry.paradigm.xyz | bash

    • foundryup

  • ECDSA account and 0x‑prefixed private key for Hedera Testnet (create/fund via the Hedera Portal)

  • Basic Solidity / CLI familiarity


Table of Contents


Step 1: Project Setup

Initialize Project

Set up your project by initializing the hardhat project:

forge init foundry-erc-721-mint-burn
cd foundry-erc-721-mint-burn

This creates a new directory with a standard Foundry project structure, including src, test, and script folders.

Install Dependencies

Foundry uses git submodules to manage dependencies. We'll install the OpenZeppelin Contracts library, which provides a standard and secure implementation of the ERC20 token.

forge install OpenZeppelin/openzeppelin-contracts

This command will download the contracts and add them to your lib folder.

Create .env File

Create an .env for your RPC URL and private key.

touch .env

Put the following into your environment file.

.env
HEDERA_RPC_URL=https://testnet.hashio.io/api
HEDERA_PRIVATE_KEY=0x-your-private-key

Now, let's also load these to the terminal:

source .env

Configure Foundry

Update your foundry.toml file in the root directory of your project. Open it and add profiles for the Hedera Testnet RPC endpoint.

foundry.toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
remappings = [
  "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/",
  "forge-std/=lib/forge-std/src/"
]

# Add this section for Hedera testnet
[rpc_endpoints]
testnet = "${HEDERA_RPC_URL}"

Note the values in remappings field. We need this to import prefix to a filesystem path so both Foundry(forge) and our editor can resolve short, package-like imports instead of long relative paths.

We will be removing the default contracts that comes with foundry default project:

rm -rf script/* src/* test/*

Step 2: Creating the ERC-721 Contract

Create a new Solidity file (MyToken.sol) in our contracts directory:

src/MyToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract MyToken is ERC721, Ownable {
    uint256 private _nextTokenId;

    constructor(address initialOwner)
        ERC721("MyToken", "MTK")
        Ownable(initialOwner)
    {}

    function safeMint(address to) public onlyOwner returns (uint256) {
        uint256 tokenId = _nextTokenId++;
        _safeMint(to, tokenId);
        return tokenId;
    }
}

This contract was created using the OpenZeppelin Contracts Wizard and OpenZeppelin's ERC-721 standard implementation with an ownership model. The ERC-721 token's name has been set to "MyToken." The contract implements the safeMint function, which accepts the address of the owner of the new token and uses auto-increment IDs, starting from 0.

Let's compile this contract by running:

forge build

This command will generate the smart contract artifacts, including the ABI. We are now ready to deploy the smart contract.


Step 3: Deploy Your ERC-721 Smart Contract

Create a deployment script (DeployMyToken.s.sol) in script directory:

script/DeployMyToken.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {Script, console} from "forge-std/Script.sol";
import {MyToken} from "../src/MyToken.sol";

contract MyTokenScript is Script {
    function run() external returns (address) {
        // Load the private key from the .env file
        uint256 deployerPrivateKey = vm.envUint("HEDERA_PRIVATE_KEY");

        // Start broadcasting transactions with the loaded private key
        vm.startBroadcast(deployerPrivateKey);

        // Get the deployer's address to use as the initial owner
        address deployerAddress = vm.addr(deployerPrivateKey);

        // Deploy the contract
        MyToken myToken = new MyToken(deployerAddress);

        // Stop broadcasting
        vm.stopBroadcast();

        console.log("MyToken deployed to:", address(myToken));

        return address(myToken);
    }
}

In this script, we first retrieve your account (the deployer) that's on our .env file. This account will own the deployed smart contract. Next, we use this account to deploy the contract by calling MyToken.deploy(deployerAddress). This passes your account address as the initial owner and signer of the deployment transaction.

Deploy your contract by executing the script:

forge script script/DeployMyToken.s.sol --rpc-url testnet --broadcast

After a few moments, you will see the address of your newly deployed contract:

Compiler run successful!
Script ran successfully.

== Return ==
0: address 0x1112a82254f48e0daEEE3fFD009B4E44a66A7f77

== Logs ==
  MyToken deployed to: 0x1112a82254f48e0daEEE3fFD009B4E44a66A7f77

## Setting up 1 EVM.

==========================

Chain 296

Estimated gas price: 740.000000001 gwei

Estimated total gas used for script: 2524191

Estimated amount required: 1.867901340002524191 ETH

==========================

##### 296
✅  [Success] Hash: 0x0679fa510bda823be55600902aaef81b92814c010cee9af818b4c5712e625de2
Contract Address: 0x4397fa3bD44bb9b2986C9463d794bDD73763A3dE
Block: 25122524
Paid: 0.70677355 ETH (2019353 gas * 350 gwei)

✅ Sequence #1 on 296 | Total Paid: 0.70677355 ETH (2019353 gas * avg 350 gwei)
                                                                                                             

==========================

ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.

Next, set up variables for your contract address and public address to make the next commands easier to read. Please export these variables in your shell.

# Replace with the contract address from the previous step
export CONTRACT_ADDRESS=<your-contract-address>

# Derive your public address from the private key
export MY_ADDRESS=$(cast wallet address $HEDERA_PRIVATE_KEY)

Let's also verify our contract because it is so easy to do so and it is good practice:

forge verify-contract $CONTRACT_ADDRESS src/MyToken.sol:MyToken \
    --chain-id 296 \
    --verifier sourcify \
    --verifier-url "https://server-verify.hashscan.io/" \
    --constructor-args $(cast abi-encode "constructor(address)" $MY_ADDRESS)

Step 4: Minting an NFT

We will now create a new file MintMyToken.s.sol script in our script directory to mint an NFT. Don't forget to replace the <your-contract-address> with the address you've just copied.

script/MintMyToken.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {Script, console} from "forge-std/Script.sol";
import {MyToken} from "../src/MyToken.sol";

contract MintMyTokenScript is Script {
    function run() external {
        // Load the private key from the .env file
        uint256 deployerPrivateKey = vm.envUint("HEDERA_PRIVATE_KEY");

        address contractAddr = <your-contract-address>; // Replace with your deployed contract address
        address recipient = vm.addr(deployerPrivateKey); 

        vm.startBroadcast(deployerPrivateKey);
        MyToken token = MyToken(contractAddr);
        uint256 beforeBal = token.balanceOf(recipient);
        uint256 tokenId = token.safeMint(recipient);
        uint256 afterBal = token.balanceOf(recipient);
        vm.stopBroadcast();

        console.log("Minted tokenId:", tokenId);
        console.log("Recipient:", recipient);
        console.log("Balance before:", beforeBal);
        console.log("Balance after:", afterBal);
    }
}

The code mints a new NFT to your account ( deployer.address ). Then we verify the balance to see if we own an ERC-721 token of type MyToken.

Mint an NFT:

forge script script/MintMyToken.s.sol --rpc-url testnet --broadcast

Expected output:

Script ran successfully.

== Logs ==
  Minted tokenId: 0
  Recipient: 0xA98556A4deeB07f21f8a66093989078eF86faa30
  Balance before: 0
  Balance after: 1

Step 5: Adding the Burn Functionality

Update your contract to add NFT burning capability by importing the burnable extension and adding it to the interfaces list for your contract:

// [...]
import {ERC721Burnable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";

contract MyToken is ERC721, ERC721Burnable, Ownable {
// [...]

Redeploy:

forge script script/DeployMyToken.s.sol --rpc-url testnet --broadcast

Reverify:

export CONTRACT_ADDRESS=<your-contract-address>
forge verify-contract $CONTRACT_ADDRESS src/MyToken.sol:MyToken \
    --chain-id 296 \
    --verifier sourcify \
    --verifier-url "https://server-verify.hashscan.io/" \
    --constructor-args $(cast abi-encode "constructor(address)" $MY_ADDRESS)

Copy the new smart contract address and replace the address in the script/MintMyToken.s.sol script with your new address. Let's mint a new NFT for the redeployed contract:

forge script script/MintMyToken.s.sol --rpc-url testnet --broadcast

Step 6: Burning an NFT

Create a burn script (BurnMyToken.s.sol ) in your script directory. Don't forget to replace the <your-contract-address> with your own contract address and <your-token-id> with the token Id you want to burn(eg. 0).

script/BurnMyToken.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {Script, console} from "forge-std/Script.sol";
import {MyToken} from "../src/MyToken.sol";

contract BurnMyTokenScript is Script {
    function run() external {
        // Load the private key from the .env file
        uint256 deployerPrivateKey = vm.envUint("HEDERA_PRIVATE_KEY");

        address contractAddr = 0x4397fa3bD44bb9b2986C9463d794bDD73763A3dE; // Replace with your deployed contract address
        uint256 tokenId = <your-token-id>; // Replace with the tokenId you want to burn
        address recipient = vm.addr(deployerPrivateKey);

        vm.startBroadcast(deployerPrivateKey);
        MyToken token = MyToken(contractAddr);
        uint256 beforeBal = token.balanceOf(recipient);
        token.burn(tokenId);
        uint256 afterBal = token.balanceOf(recipient);
        vm.stopBroadcast();

        console.log("Burned tokenId:", tokenId);
        console.log("Recipient:", recipient);
        console.log("Balance before:", beforeBal);
        console.log("Balance after:", afterBal);
    }
}

The script will burn the ERC-721 token with the ID set to 1, which is the ERC-721 token you've just minted. To be sure the token has been deleted, let's print the balance for our account to the terminal. The balance should show a balance of 0.

Burn the NFT:

forge script script/BurnMyToken.s.sol --rpc-url testnet --broadcast

Expected output:

Script ran successfully.

== Logs ==
  Burned tokenId: 0
  Recipient: 0xA98556A4deeB07f21f8a66093989078eF86faa30
  Balance before: 1
  Balance after: 0

Congratulations! 🎉 You have successfully learned how to deploy an ERC-721 smart contract using Foundry and OpenZeppelin. Feel free to reach out in Discord!

Step 7: Run tests(Optional)

You can find both types of tests in the Hedera-Code-Snippets repository. You will find the following files:

  • test/MyToken.t.sol

Copy this file and then run the tests:

forge test

To deep dive into how to write these tests from scratch, go to the section under "How to Write Tests in Solidity (Part 2)".

Interacting with the Contract using "cast"(Optional - Advanced)

Apart from interacting with the contract using dedicated scripts like MintMyToken.s.sol or BurnMyToken.s.sol, we can also use cast, Foundry's command-line tool for doing the exact same thing by making RPC calls. We are going to deploy the contract using cast in this section but you should be able to able to perform any other operation such as mint or burn using cast as well(even though it might be a little bit more complicated to do so).

To use cast and other command-line tools, you need to load the variables from your .env file into your current terminal session.

Environment Setup

Run the following command to load the HEDERA_PRIVATE_KEY and HEDERA_RPC_URL into your shell. In addition, we will derive our address and save it to MY_ADDRESS.

source .env
# Derive your EVM address (the deployer/owner for MyToken)
export MY_ADDRESS=$(cast wallet address "$HEDERA_PRIVATE_KEY")
# Confirm you’re on Hedera Testnet (chain id 296)
cast chain-id --rpc-url "$HEDERA_RPC_URL"

Please make sure to have jq installed on your machine for the following exercises. You can learn more about it on the official jq site.

Compile and fetch creation bytecode

We’ll use forge to inspect the creation bytecode of MyToken and cast to encode constructor args.

# Compile your project (generates artifacts)
forge build

# Get creation bytecode (0x…)
export BYTECODE=$(forge inspect src/MyToken.sol:MyToken bytecode)

# Encode constructor(address initialOwner) with your deployer address
export CTOR_ARGS=$(cast abi-encode "constructor(address)" "$MY_ADDRESS")

# Concatenate bytecode + constructor args (both hex)
export DEPLOY_DATA="0x${BYTECODE#0x}${CTOR_ARGS#0x}"

Deploy the contract with cast

cast will submit a contract creation transaction. Then we’ll fetch the receipt and extract the deployed contract address.

# Send the deployment tx (returns a tx hash)
export DEPLOY_TX=$(cast send --rpc-url "$HEDERA_RPC_URL" \
    --private-key "$HEDERA_PRIVATE_KEY" \
    --json --create "$DEPLOY_DATA" | jq -r .transactionHash)
echo "Deploy tx: $DEPLOY_TX"

# Capture the contract address
export CONTRACT_ADDRESS=$(cast receipt "$DEPLOY_TX" --rpc-url "$HEDERA_RPC_URL" \
    --json | jq -r .contractAddress)
echo "MyToken deployed to: $CONTRACT_ADDRESS"

Further Learning & Next Steps

Want to take your local development setup even further? Here are some excellent tutorials to help you dive deeper into smart contract development on Hedera using Foundry:

  1. How to Write Tests in Solidity (Part 2) Learn how to start writing tests in Foundry using Solidity

  2. How to Fork the Hedera Network for Local Testing Learn how to fork hedera network(testnet/mainnet) locally so you can start testing against the forked network

Last updated

Was this helpful?