HTS x EVM - Part 1: How to Mint NFTs
In Hedera Token Service - Part 1: How to Mint NFTs, we showed how to create, mint, burn, and transfer non-fungible tokens (NFTs) on Hedera without deploying any smart contracts—using only the Hedera Token Service (HTS) and official SDKs.
It's possible to create a token on Hedera using a smart contract and still benefit from the native Hedera Token Service. However, the contract needs to interact with the HTS System Contract, which provides Hedera-specific token operations. By combining HTS and Solidity, you:
Get all the performance, cost-efficiency, and security of native HTS tokens.
Can embed custom, decentralized logic in your contract for advanced use cases.
In this tutorial, you’ll:
Create an NFT collection with a royalty fee schedule.
Mint new NFTs with metadata pointing to IPFS.
Transfer NFTs.
Burn an existing NFT.
Prerequisites
ECDSA account from the Hedera Portal.
Basic understanding of Solidity.
Table of Contents
Step 1: Project Setup
Clone the repository
git clone https://github.com/hedera-dev/hts-evm-hybrid-mint-nfts.git
cd hts-evm-hybrid-mint-nfts
Install dependencies
npm install
Create
.env
file set up environment variables
cp .env.example .env
Edit the .env
file to include your Hedera Testnet account's private key. Use your ECDSA Hex Encoded Private Key when interacting with Hedera's EVM via the JSON-RPC relay.
PRIVATE_KEY=0x
Run the test script
npx hardhat test test/1-MintNFT.ts
This script deploys and tests all of the functionality inside of the MintNFT
Smart Contract. We'll deep dive into the Smart Contract's functions and corresponding tests below!
Step 2. Creating an NFT
Function: createNFT(string memory name, string memory symbol, string memory memo)
createNFT(string memory name, string memory symbol, string memory memo)
Purpose: Deploy a Hedera NFT using Solidity. This involves specifying key token details (name, symbol, and memo), as well as setting up any custom fees (such as royalty fees).
Key Code Snippet:
function createNFT(
string memory name,
string memory symbol,
string memory memo
) external payable onlyOwner {
// Create the royalty fee
IHederaTokenService.RoyaltyFee[] memory royaltyFees =
new IHederaTokenService.RoyaltyFee[](1);
royaltyFees[0] = IHederaTokenService.RoyaltyFee({
numerator: 1,
denominator: 10,
amount: 100000000, // Fallback fee of 1 HBAR in tinybars (1 HBAR = 1e8 tinybars)
tokenId: address(0),
useHbarsForPayment: true,
feeCollector: owner
});
// Create fixed fees array (empty for this example)
IHederaTokenService.FixedFee[] memory fixedFees =
new IHederaTokenService.FixedFee[](0);
// Create the token definition
IHederaTokenService.HederaToken memory token;
token.name = name;
token.symbol = symbol;
token.memo = memo;
token.treasury = address(this);
// Set the supply key (the contract itself)
IHederaTokenService.TokenKey[] memory keys =
new IHederaTokenService.TokenKey[](1);
keys[0] = getSingleKey(
KeyType.SUPPLY,
KeyValueType.CONTRACT_ID,
address(this)
);
token.tokenKeys = keys;
(int responseCode, address createdToken) =
createNonFungibleTokenWithCustomFees(token, fixedFees, royaltyFees);
require(
responseCode == HederaResponseCodes.SUCCESS,
"Failed to create NFT"
);
tokenAddress = createdToken;
emit NFTCreated(createdToken);
}
Test Implementation
it("should create an NFT", async () => {
// Deploy the MintNFT contract earlier, then call createNFT:
const mintTx = await mintNftContract.createNFT("Test NFT", "TST", "Test NFT", {
gasLimit: 250_000,
value: ethers.parseEther("7") // HBAR needed to pay for token creation fee
});
await expect(mintTx)
.to.emit(mintNftContract, "NFTCreated")
.withArgs(ethers.isAddress);
});
Here, we pass "Test NFT"
, "TST"
, and "Test NFT"
as arguments. We also send some HBAR (in this case, 7 HBAR) to cover the creation costs on the Hedera network. We expect the NFTCreated
event to have a valid token address to confirm success.
Step 3. Minting an NFT
Function: mintNFT(bytes[] memory metadata)
Purpose: Add new tokens to the existing NFT collection. Each minted token includes a unique piece of metadata (for instance, an IPFS URI).
Key Code Snippet:
function mintNFT(bytes[] memory metadata) external onlyOwner {
require(tokenAddress != address(0), "Token not created yet");
(
int responseCode,
int64 newTotalSupply,
int64[] memory serialNumbers
) = mintToken(tokenAddress, 0, metadata);
require(responseCode == HederaResponseCodes.SUCCESS, "Failed to mint NFT");
emit NFTMinted(newTotalSupply, serialNumbers);
}
Test Implementation
it("should mint an NFT with metadata", async () => {
// Example single metadata entry (an IPFS URI)
const metadata = [
ethers.toUtf8Bytes("ipfs://bafkreibr7cyxmy4iyc...")
];
const mintTx = await mintNftContract.mintNFT(metadata, {
gasLimit: 350_000
});
await expect(mintTx)
.to.emit(mintNftContract, "NFTMinted");
});
We pass an array with one IPFS URI (encoded as bytes
). The test expects an NFTMinted
event on success, indicating new tokens are now part of our NFT supply.
Step 4. Transferring NFTs
Function: transferNFT(address receiver, uint256 serialNumber)
transferNFT(address receiver, uint256 serialNumber)
Purpose: Transfer an existing NFT from the contract’s treasury (in this case, address(this)
) to another address. This leverages the standard ERC-721 transfer pattern.
Key Code Snippet:
function transferNFT(address receiver, uint256 serialNumber) external {
require(tokenAddress != address(0), "Token not created yet");
IERC721(tokenAddress).transferFrom(
address(this),
receiver,
serialNumber
);
emit NFTTransferred(receiver, serialNumber);
}
Test Implementation
it("should transfer the NFT to owner", async () => {
const [owner] = await ethers.getSigners();
const serialNumber = 1n; // the 'n' notation is shorthand to declare a BigInt type. This represents the first minted NFT
const transferTx = await mintNftContract.transferNFT(owner.address, serialNumber, {
gasLimit: 350_000
});
await expect(transferTx)
.to.emit(mintNftContract, "NFTTransferred")
.withArgs(owner.address, serialNumber);
});
Here, we transfer serial #1 from the contract to the owner
address. On success, we expect the NFTTransferred
event. We can also verify ownership using the standard IERC721(tokenAddress).ownerOf(serialNumber)
call.
it("should transfer the NFT back to contract before burning", async () => {
const [owner] = await ethers.getSigners();
const serialNumber = 1n;
const tokenAddress = await mintNftContract.getTokenAddress();
// Get the NFT contract interface
const nftContract = await ethers.getContractAt("IERC721", tokenAddress);
// Then transfer from owner back to contract
await nftContract.transferFrom(
owner.address,
mintNftContract.target,
serialNumber,
{
gasLimit: 350_000
}
);
// Verify the NFT is now owned by the contract
expect(await nftContract.ownerOf(serialNumber))
.to.equal(mintNftContract.target);
});
As you can see, despite our NFT not actually being an ERC-721 (it is a Native Token; not a Smart Contract), we can treat it as though it is one. The same goes for Native Fungible Tokens and ERC-20 interfaces. Find out more here.
Step 5. Burning an NFT
Function: burnNFT(int64[] memory serialNumbers)
burnNFT(int64[] memory serialNumbers)
Purpose: Burn (permanently remove) existing NFTs from circulation. This is helpful if you need to remove misprints, decommission tokens, or reduce supply to increase rarity. The serials must be in the Treasury account in order to burn them.
Key Code Snippet:
function burnNFT(int64[] memory serialNumbers) external onlyOwner {
require(tokenAddress != address(0), "Token not created yet");
(int responseCode, int64 newTotalSupply) =
burnToken(tokenAddress, 0, serialNumbers);
require(responseCode == HederaResponseCodes.SUCCESS, "Failed to burn NFT");
emit NFTBurned(responseCode, newTotalSupply);
}
Test Implementation
it("should burn the minted NFT", async () => {
// Assume serial 1 NFT has been returned to the Contract
const serialNumbers = [1n];
const burnTx = await mintNftContract.burnNFT(serialNumbers, {
gasLimit: 60_000
});
await expect(burnTx)
.to.emit(mintNftContract, "NFTBurned");
});
We pass an array of serial numbers—in this case, [1n]
for the first minted NFT. The test ensures that NFTBurned
fires, confirming the token is removed from the supply.
Conclusion
Using Solidity on Hedera, you can create, mint, burn, and transfer native NFTs with minimal code thanks to the HTS System Contract. In this tutorial, you saw how to:
Create a new NFT class with royalty fees (
createNFT
).Mint new tokens (
mintNFT
).Burn tokens when they are no longer needed (
burnNFT
).Transfer NFTs via standard ERC-721 calls (
transferNFT
).
Continue exploring our Part 2: KYC & Update to see how advanced compliance flags (e.g., KYC) or updating tokens can be handled natively.
HTS x EVM - Part 2: KYC & UpdateAdditional Resources
Check out our GitHub repo to find the full contract and Hardhat test scripts, along with the configuration files you need to deploy and test on Hedera!
Last updated
Was this helpful?