Hedera Token Service for NFTs
Hedera is a layer-1 blockchain implemented in Java programming language. It uses a custom consensus mechanism called Hashgraph, a subset of a better-known PoS.
Hedera Hashgraph released the Hedera Token Service (HTS) platform on February 9th,2021. It is a public distributed ledger technology (DLT) network. HTS enables digital token creation, issuance, and management, including non-fungible tokens (NFTs).
The minting costs are predictably low. A contract deployment costs around $1. A write
contract call costs around $0.05, while a read
call is also payable and costs around $0.001.
Differences with ERC721
In traditional EVM-compatible NFTs, the collection address distinguishes a group of NFTs while each token has its unique tokenId
, differentiating it from the other tokens of the same collection.
In HTS, the token ID
represents a collection, while a serial number
distinguishes unique NFTs.
To deploy a Hedera-compatible NFT Contract
- Hedera has created a convenient repository for quick deployment of HTS compatible collections.
To embed and import the above-mentioned repo into your project, add the following file to the root of your project:
[submodule "lib"]
path = lib
url = https://github.com/hashgraph/hedera-smart-contracts.git
Then install the Open Zeppelin library.
yarn add @openzeppelin/contracts
- Starting the HTS-compatible contract
Create a XPNFTHTS.sol
file in the contracts
folder:
mkdir contracts
cd ./contracts/
touch XPNFTHTS.sol
Place the following code in the newly created file.
// SPDX-License-Identifier: MIT
// Hedera does not support nightly versions of Solidity.
pragma solidity ^0.8.0;
import "./BridgeNFT.sol"; // For compatibility with XP.NETWORK Bridge
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
import "./lib/contracts/hts-precompile/HederaTokenService.sol";
import "./lib/contracts/hts-precompile/IHederaTokenService.sol";
import "./lib/contracts/hts-precompile/HederaResponseCodes.sol";
import "./lib/contracts/hts-precompile/ExpiryHelper.sol";
contract XPNftHts is Ownable, HederaTokenService, BridgeNFT, ExpiryHelper {
// The contract code goes here ...
}
Add the following functions to the newly created contract.
1. Contract storage
The contract's global variables and structures are usually placed before the constructor or the initialization function. They represent the long-term memory of the contract. The values reside on every blockchain node; therefore, writing to the storage is the most expensive operation.
// Adding functional libraries to types & structs
using Strings for uint256;
using EnumerableSet for EnumerableSet.UintSet;
// Initialization flag
bool private initialized;
// The common part of the URI
string public baseUri;
// HTS compatibility slots
uint32 public constant DEFAULT_EXPIRY = 7890000;
int64 public constant MAX_INT = 0xFFFFFFFF;
address public htsToken;
// HTS compatibility mappings
// A token must be claimed by a user
mapping(address => mapping(address => EnumerableSet.UintSet))
private nftClaims;
// A token must be associated by a user
mapping(address => bool) private associations;
2. Contract init
Before a contract can be used, it must be initialized. It means the storage variables must get their initial or permanent values.
function initialize(
string memory name, // Collection name
string memory symbol, // Collection Symbol
string memory baseURI_ // Common part of the URI
) external payable {
// Prevent multiple initializations
require(!initialized, "Already initialized");
initialized = true;
// Set the common URI
baseUri = baseURI_;
// Conect to HTS
IHederaTokenService.TokenKey[]
memory keys = new IHederaTokenService.TokenKey[](1);
keys[0] = getSingleKey(
KeyHelper.KeyType.SUPPLY,
KeyHelper.KeyValueType.CONTRACT_ID,
address(this)
);
// Setting the HTS values
IHederaTokenService.HederaToken memory token;
token.name = name;
token.symbol = symbol;
token.treasury = address(this);
token.memo = "";
token.tokenSupplyType = true;
token.maxSupply = MAX_INT;
token.freezeDefault = false;
token.tokenKeys = keys;
token.expiry = createAutoRenewExpiry(address(this), DEFAULT_EXPIRY);
// Create an NFT Contract
(int256 resp, address createdToken) = HederaTokenService
.createNonFungibleToken(token);
// Check that it worked
require(
resp == HederaResponseCodes.SUCCESS,
string(
abi.encodePacked(
"Failed to create token. Reason Code: ",
Strings.toString(uint256(resp))
)
)
);
// Associate the token
resp = associateToken(address(this), createdToken);
require(
resp == HederaResponseCodes.SUCCESS ||
resp == HederaResponseCodes.TOKEN_ALREADY_ASSOCIATED_TO_ACCOUNT,
string(
abi.encodePacked(
"Failed to associate token. Reason Code: ",
Strings.toString(uint256(resp))
)
)
);
// Assign the htsToken to the created one
htsToken = createdToken;
}
3. Minting
Minting is another word for coining or creating tokens. During minting, new records are made in the contract storage. Here we add the onlyOwner
modifier because only the XP.NETWORK bridge must mint wrapped NFTs on Hedera. If you're using this code to deploy your collection, replace the modifier with something relevant to your use case.
function mint(
address to, // New NFT Owner
uint256 id, // NFT serial number
bytes calldata // Metadata
) external override onlyOwner {
// Extract metadata from the incomming bytes
bytes[] memory metadata = new bytes[](1);
metadata[0] = abi.encodePacked(baseUri, id.toString());
// Create the NFT
(int256 resp, , int64[] memory serialNum) = mintToken(
htsToken,
0,
metadata
);
// Check that creation successeded or revert everything
require(
resp == HederaResponseCodes.SUCCESS,
string(
abi.encodePacked(
"Failed to mint token. Reason Code: ",
Strings.toString(uint256(resp))
)
)
);
// Transfer the newly created token to the new owner
int256 tresp = _transferClaim(to, serialNum[0], htsToken);
if (tresp == HederaResponseCodes.TOKEN_NOT_ASSOCIATED_TO_ACCOUNT) {
nftClaims[to][htsToken].add(uint256(uint64(serialNum[0])));
return;
}
// Check that it worked or revert everything
require(
tresp == HederaResponseCodes.SUCCESS,
string(
abi.encodePacked(
"Failed to transfer token. Reason Code: ",
Strings.toString(uint256(resp))
)
)
);
}
4. Burning
If an NFT arrives at Hedera from a foreign chain, it must be burnt once sent to the chain of origin or another foreign chain. Therefore, the wrapped contract must implement the burnFor
function.
function burnFor(
address from, // Current Owner of the NFT
uint256 serialNum // Serial Number of the NFT
) external
override
onlyOwner
{
int256 resp = transferNFT(
htsToken,
from,
address(this),
int64(uint64(serialNum))
);
require(
resp == HederaResponseCodes.SUCCESS,
string(
abi.encodePacked(
"Failed to transfer token. Reason Code: ",
Strings.toString(uint256(resp))
)
)
);
int64[] memory serialNums = new int64[](1);
serialNums[0] = int64(uint64(serialNum));
(resp, ) = burnToken(htsToken, 0, serialNums);
require(
resp == HederaResponseCodes.SUCCESS,
string(
abi.encodePacked(
"Failed to burn token. Reason Code: ",
Strings.toString(uint256(resp))
)
)
);
}
5. Claiming an NFT
On Hedera, NFTs must be claimed.
function claimNft(int64 serialNum, address token) external {
// Extract the claimable NFTs
EnumerableSet.UintSet storage serialNums = nftClaims[msg.sender][token];
// Remove the serial number for the claimables
require(
serialNums.remove(uint256(uint64(serialNum))),
"Cannot claim this nft"
);
// Transferring an NFT to the legitimate owner
int256 resp = _transferClaim(msg.sender, serialNum, token);
require(
resp == HederaResponseCodes.SUCCESS,
string(
abi.encodePacked(
"Failed to transfer token. Reason Code: ",
Strings.toString(uint256(resp))
)
)
);
}
6. Transferring NFTs
Transfer
is among the most popular fungible and non-fungible token functions.
function safeTransferFrom(
address _from, // Previous owner
address _to, // new owner
uint256 _serialNum // Serial Number of the NFT
) external onlyOwner {
// Extract the token address & the serial number
(address token, int64 serial) = decodeHts(_serialNum);
int256 resp;
if (!associations[token]) {
// Associate the token to this contract's address
resp = associateToken(address(this), token);
// Check for success or revert everything
require(
resp == HederaResponseCodes.SUCCESS ||
resp ==
HederaResponseCodes.TOKEN_ALREADY_ASSOCIATED_TO_ACCOUNT,
string(
abi.encodePacked(
"Failed to associate token. Reason Code: ",
Strings.toString(uint256(resp))
)
)
);
associations[token] = true;
}
if (_to == owner()) {
resp = transferNFT(
token,
_from,
address(this),
serial
);
} else if (_from == owner()){
resp = transferNFT(token, address(this), _to, serial);
if (resp == HederaResponseCodes.TOKEN_NOT_ASSOCIATED_TO_ACCOUNT) {
nftClaims[_to][token].add(uint256(int256(serial)));
return;
}
}
require(
resp == HederaResponseCodes.SUCCESS,
string(
abi.encodePacked(
"Failed to transfer token. Reason Code: ",
Strings.toString(uint256(resp))
)
)
);
}
Utility functions
Those are technical functions used by the other functions. Small pieces of logic are abstracted away from the main business logic and placed in such utility functions.
function decodeHts(uint256 data) public pure returns (address, int64) {
bytes32 d2 = bytes32(data);
address token = address(uint160(bytes20(d2)));
int96 serialNum = int96(uint96(uint256(d2)));
return (token, int64(serialNum));
}
function getClaimableNfts(address claimer, address token)
public
view
returns (uint256[] memory)
{
return nftClaims[claimer][token].values();
}
function baseURI() external view override returns (string memory) {
return string(abi.encodePacked(baseUri, "{id}"));
}
function tokenURI(uint256 tokenId) external returns (string memory) {
(address token, int64 serialNumber) = decodeHts(tokenId);
(
int256 response,
IHederaTokenService.NonFungibleTokenInfo memory tokenInfo
) = getNonFungibleTokenInfo(token, serialNumber);
require(
response == HederaResponseCodes.SUCCESS,
"Failed to get token info"
);
return string(tokenInfo.metadata);
}
function _transferClaim(
address to,
int64 serialNum,
address token
) private returns (int256) {
return transferNFT(token, address(this), to, serialNum);
}
Deploying XPNftHTS
We've created a repo for Custom HTS NFT contract deployment compatible with the XP.NETWORK bridge for your convenience.
Install
git clone https://github.com/XP-NETWORK/deploy-customHTS-hedera.git
Initiate
cd deploy-customHTS-hedera/
yarn
Populate the variables
Rename the .env
file:
mv .emv.example > .emv
Populate the variables
Deploy
ts-node ./scripts/deploy.ts