Deploy an ERC-20 Token on Horizen
Horizen runs a near-vanilla EVM where all standard Ethereum opcodes are supported, with minor OP Stack extensions for L2 data fees.
This tutorial deploys a production-grade ERC-20 using OpenZeppelin Contracts, with both Foundry and Hardhat paths. It also covers contract verification on the Horizen Explorer.
Architecture Context
Before touching code, understand the fee model. Horizen is an OP Stack L3 settling on Base (L2), which settles on Ethereum (L1). Your transaction fee has two components:
- L2 execution fee — gas consumed by your transaction × the Horizen base fee
- L1 data fee — the cost of publishing your transaction's calldata as a batch to Base
The L1 data fee is automatically appended by the OP Stack. It's typically small but non-zero, and it fluctuates with Base's gas price. Your ETH on Horizen is native bridged ETH — the same asset as on Base.
Network Reference
For full network details (RPC endpoints, chain IDs, explorer, faucet), see:
Step 1: Write the Contract
Write a minimal but complete ERC-20 contract with an owner-controlled mint function and a fixed max supply:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Capped} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Capped.sol";
import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
/// @title HorizenToken
/// @notice A capped, burnable ERC-20 with owner-controlled minting.
contract HorizenToken is ERC20Capped, ERC20Burnable, Ownable {
constructor(
string memory name,
string memory symbol,
uint256 cap,
address initialOwner
)
ERC20(name, symbol)
ERC20Capped(cap)
Ownable(initialOwner)
{}
/// @notice Mint tokens to an address. Caller must be the owner.
/// @param to Recipient address
/// @param amount Amount in the token's smallest unit (wei equivalent)
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
/// @dev Required override — ERC20Capped hooks into _update, not _mint.
function _update(
address from,
address to,
uint256 value
) internal override(ERC20, ERC20Capped) {
super._update(from, to, value);
}
}
A few design decisions worth noting:
ERC20Cappedenforces a maximum supply at the_updatelevel — it's impossible to exceed the cap regardless of how many timesmintis called.- The
_updateoverride is mandatory in OZ v5.x. The v4.x pattern using_beforeTokenTransferno longer works. - Deploying with
initialOwneras a constructor argument (rather than hardcodingmsg.sender) makes the contract significantly easier to test and safer to deploy via a script wheremsg.senderis a hot deployment key you may not want as permanent owner.
Path A — Foundry
Install Foundry
curl -L https://foundry.paradigm.xyz | bash
foundryup
Initialize the project
forge init horizen-token && cd horizen-token
Install OpenZeppelin Contracts
forge install OpenZeppelin/openzeppelin-contracts --no-commit
Add the remapping so Forge can resolve the import path:
echo '@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/' >> remappings.txt
Place the contract
Save the Solidity above to src/HorizenToken.sol.
Test it locally first
// test/HorizenToken.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
import {Test} from "forge-std/Test.sol";
import {HorizenToken} from "../src/HorizenToken.sol";
contract HorizenTokenTest is Test {
HorizenToken token;
address owner = address(0xBEEF);
address user = address(0xCAFE);
uint256 constant CAP = 1_000_000 ether; // 1M tokens
function setUp() public {
token = new HorizenToken("Horizen Token", "HZN", CAP, owner);
}
function test_MintUnderCap() public {
vm.prank(owner);
token.mint(user, 500_000 ether);
assertEq(token.balanceOf(user), 500_000 ether);
assertEq(token.totalSupply(), 500_000 ether);
}
function test_MintOverCapReverts() public {
vm.prank(owner);
vm.expectRevert();
token.mint(user, CAP + 1);
}
function test_OnlyOwnerCanMint() public {
vm.prank(user);
vm.expectRevert();
token.mint(user, 1 ether);
}
function test_Burn() public {
vm.prank(owner);
token.mint(user, 100 ether);
vm.prank(user);
token.burn(50 ether);
assertEq(token.balanceOf(user), 50 ether);
}
}
forge test -vvv
Don't deploy until tests pass. Gas costs ETH on testnet; time costs more.
Build
forge build
Deploy to Horizen Testnet
forge create src/HorizenToken.sol:HorizenToken \
--rpc-url https://horizen-testnet.rpc.caldera.xyz/http \
--private-key $PRIVATE_KEY \
--constructor-args "Horizen Token" "HZN" 1000000000000000000000000 $OWNER_ADDRESS
Constructor args breakdown:
"Horizen Token"— token name"HZN"— symbol1000000000000000000000000— cap: 1,000,000 tokens × 1e18 (must be passed as a rawuint256wei value, not a decimal)$OWNER_ADDRESS— your intended owner; if you wantmsg.sender, pass$(cast wallet address --private-key $PRIVATE_KEY)
Forge prints the deployed address on success:
Deployer: 0x...
Deployed to: 0x...
Transaction hash: 0x...
Deploy with a script (recommended for production)
Rather than passing private keys on the command line, use a Forge script with a hardware wallet or environment variable:
// script/Deploy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
import {Script, console} from "forge-std/Script.sol";
import {HorizenToken} from "../src/HorizenToken.sol";
contract DeployScript is Script {
function run() external {
uint256 deployerKey = vm.envUint("PRIVATE_KEY");
address owner = vm.envAddress("OWNER_ADDRESS");
vm.startBroadcast(deployerKey);
HorizenToken token = new HorizenToken(
"Horizen Token",
"HZN",
1_000_000 ether,
owner
);
console.log("HorizenToken deployed at:", address(token));
console.log("Owner:", token.owner());
console.log("Cap:", token.cap());
vm.stopBroadcast();
}
}
forge script script/Deploy.s.sol:DeployScript \
--rpc-url https://horizen-testnet.rpc.caldera.xyz/http \
--broadcast \
--verify
The --verify flag submits source code to the explorer automatically after deployment. If verification fails (network timing), rerun with --resume.
Path B — Hardhat
Initialize the project
mkdir horizen-token && cd horizen-token
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts
npx hardhat init
Select "TypeScript project" when prompted.
Configure Hardhat (hardhat.config.ts)
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
const config: HardhatUserConfig = {
solidity: {
version: "0.8.22",
settings: {
optimizer: { enabled: true, runs: 200 },
},
},
networks: {
horizen: {
type: "http",
url: "https://horizen-testnet.rpc.caldera.xyz/http",
accounts: [process.env.PRIVATE_KEY ?? ""],
chainId: 2651420,
},
"horizen-mainnet": {
type: "http",
url: "https://horizen.calderachain.xyz/http",
accounts: [process.env.PRIVATE_KEY ?? ""],
chainId: 26514,
},
},
};
export default config;
Write the Ignition deploy module
// ignition/modules/HorizenToken.ts
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
import { parseEther } from "ethers";
export default buildModule("HorizenToken", (m) => {
const owner = m.getParameter("owner", "0xYourOwnerAddress");
const token = m.contract("HorizenToken", [
"Horizen Token",
"HZN",
parseEther("1000000"), // 1M token cap
owner,
]);
return { token };
});
Deploy
npx hardhat compile
npx hardhat ignition deploy ignition/modules/HorizenToken.ts \
--network horizen \
--parameters '{"owner": "0xYourOwnerAddress"}'
Ignition records deployment state in ignition/deployments/ — rerunning the command is idempotent. The deployed address is in ignition/deployments/chain-2651420/deployed_addresses.json.
Verifying on the Explorer
Verification links your contract's source code to the on-chain bytecode, enabling the explorer to decode transactions and display the ABI.
With Foundry:
forge verify-contract <DEPLOYED_ADDRESS> \
src/HorizenToken.sol:HorizenToken \
--rpc-url https://horizen-testnet.rpc.caldera.xyz/http \
--constructor-args $(cast abi-encode "constructor(string,string,uint256,address)" "Horizen Token" "HZN" 1000000000000000000000000 $OWNER_ADDRESS)
With Hardhat:
npx hardhat verify --network horizen <DEPLOYED_ADDRESS> \
"Horizen Token" "HZN" "1000000000000000000000000" $OWNER_ADDRESS
Horizen's explorer is powered by Blockscout. If
hardhat-verifydoesn't auto-detect the verifier, addetherscan: { apiKey: { horizen: "any-non-empty-string" }, customChains: [{ ... }] }to your Hardhat config pointing at the explorer's verification API.
Interacting with the Deployed Contract
Use cast (Foundry) for quick on-chain reads and writes:
# Read name
cast call $TOKEN "name()(string)" \
--rpc-url https://horizen-testnet.rpc.caldera.xyz/http
# Read total supply
cast call $TOKEN "totalSupply()(uint256)" \
--rpc-url https://horizen-testnet.rpc.caldera.xyz/http
# Mint 100 tokens to an address (as owner)
cast send $TOKEN "mint(address,uint256)" \
$RECIPIENT 100000000000000000000 \
--rpc-url https://horizen-testnet.rpc.caldera.xyz/http \
--private-key $PRIVATE_KEY