Skip to main content

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:

  • ERC20Capped enforces a maximum supply at the _update level — it's impossible to exceed the cap regardless of how many times mint is called.
  • The _update override is mandatory in OZ v5.x. The v4.x pattern using _beforeTokenTransfer no longer works.
  • Deploying with initialOwner as a constructor argument (rather than hardcoding msg.sender) makes the contract significantly easier to test and safer to deploy via a script where msg.sender is 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" — symbol
  • 1000000000000000000000000 — cap: 1,000,000 tokens × 1e18 (must be passed as a raw uint256 wei value, not a decimal)
  • $OWNER_ADDRESS — your intended owner; if you want msg.sender, pass $(cast wallet address --private-key $PRIVATE_KEY)

Forge prints the deployed address on success:

Deployer: 0x...
Deployed to: 0x...
Transaction hash: 0x...

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-verify doesn't auto-detect the verifier, add etherscan: { 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