Skip to main content

Bridge Assets from Base to Horizen

Horizen runs two bridges. They are different products with different mechanics, and picking the wrong one is the most common way to lose an afternoon.

Stargate (LayerZero OFT)Native bridge
AssetsZEN, USDC, cbBTCETH
MechanismBurn on source, mint on destinationOP Stack lock and mint via Base
RoutesHorizen to 80+ chainsHorizen and Base only
Deposit timeTypically under 2 minutesA few minutes after Base finality
WithdrawalSame mechanism both directionsChallenge period applies (7 days)
Fees0.06% protocol fee plus source gasSource gas only

The detail that trips people up: ETH, the gas token on Horizen, moves only through the native bridge. It is not a Stargate asset here. Your users' first onboarding transaction is a native bridge deposit, and so is yours.

This tutorial does both routes programmatically on mainnet (Base to Horizen). There is no contract to write. You are calling contracts that are already deployed, which makes this a scripting exercise, not a Foundry project.

Prerequisites

  • Foundry installed (cast in your PATH)
  • Node 18+ with viem and @layerzerolabs/lz-v2-utilities if you want the TypeScript route
  • A wallet funded with Base ETH (for gas on both routes)
  • ZEN on Base for the Stargate route — acquire via any DEX on Base (Uniswap, Aerodrome) using the ZEN ERC-20 address below
  • RPC endpoints: Base at https://mainnet.base.org, Horizen Mainnet at https://horizen.calderachain.xyz/http

Contract reference

Mainnet, Base to Horizen:

TokenChainContract
ZENBaseERC-20 0xf43eB8De897Fbc7F2502483B2Bef7Bb9EA179229 · OFT Adapter 0x57da2D504bf8b83Ef304759d9f2648522D7a9280
ZENHorizenOFT 0x57da2D504bf8b83Ef304759d9f2648522D7a9280
USDCBaseLock contract 0x27a16dc786820b16e5c9028b75b99f6f604b5d26
USDCHorizenOFT 0x3a1293Bdb83bBbDd5Ebf4fAc96605aD2021BbC0f
cbBTCBaseOFT Adapter 0x68fb5BB8330C0b9d907F50f278143873276ee056
cbBTCHorizenOFT 0x68fb5BB8330C0b9d907F50f278143873276ee056
ETH (native bridge)BaseL1StandardBridge 0xf4a6cc4171fda694439f856d912777aa6ab05369
note

ZEN's Base OFT Adapter and Horizen OFT share the same address — that is how this deployment is wired.

Testnet, Base Sepolia to Horizen Testnet:

TokenChainContract
tZENBase SepoliaOFT Adapter 0x2ead4B0beBD8e54F9B7cC1007DF4c44a27b9a339
tZENHorizen TestnetOFT 0xb06EC4ce262D8dbDc24Fac87479A49A7DC4cFb87
cbBTCBase SepoliaOFT Adapter 0x5dE29d14E72feb79967596F3Ae57A9BfBA192769
cbBTCHorizen TestnetOFT 0x06DA6bDD2aB23447af5162ab0975edDA7E8d3747
Testnet tokens

tZEN on Base Sepolia has no public faucet. Request an allocation in the Horizen Discord (#developer channel). For the native bridge on testnet, get the L1StandardBridge address from a real deposit transaction on hub-testnet.horizen.io.

LayerZero endpoint IDs — LayerZero's own routing identifiers, unrelated to EVM chain IDs:

ChainEID
Base30184
Base Sepolia40245
Horizen Mainnet30399
Horizen Testnet40435

Route 1: Bridge ZEN over Stargate

If you want the UI path instead, Stargate handles this route without code. This tutorial covers the programmatic path for integrations and automation.

An OFT transfer is one contract call: send() on the source-chain OFT contract, preceded by a fee quote from quoteSend(). You pay the LayerZero message fee in native gas through msg.value. Everything routes through a single struct:

struct SendParam {
uint32 dstEid; // destination endpoint ID, not the chain ID
bytes32 to; // recipient address, left-padded to 32 bytes
uint256 amountLD; // amount in the token's local decimals
uint256 minAmountLD; // equal to amountLD for burn/mint OFTs
bytes extraOptions; // executor options, 0x when enforced options exist
bytes composeMsg; // 0x for a plain transfer
bytes oftCmd; // 0x for a plain transfer
}

Step 1: Approve the adapter

Going Base to Horizen, the adapter pulls ZEN from your wallet, so it needs allowance. Going the other way, the Horizen OFT is the token itself and burns directly, no approval needed. This asymmetry is inherent to the adapter pattern.

export ADAPTER=0x57da2D504bf8b83Ef304759d9f2648522D7a9280
export ZEN=0xf43eB8De897Fbc7F2502483B2Bef7Bb9EA179229
export RPC_BASE=https://mainnet.base.org

cast send $ZEN "approve(address,uint256)" $ADAPTER 1ether \
--rpc-url $RPC_BASE --private-key $PK

Step 2: Quote the fee

export DST_EID=30399
export RECIPIENT_B32=0x000000000000000000000000<your address without 0x>

cast call $ADAPTER \
"quoteSend((uint32,bytes32,uint256,uint256,bytes,bytes,bytes),bool)((uint256,uint256))" \
"($DST_EID,$RECIPIENT_B32,1000000000000000000,1000000000000000000,0x,0x,0x)" false \
--rpc-url $RPC_BASE

This returns (nativeFee, lzTokenFee). The native fee covers DVN verification and executor delivery on the destination chain. You pay it once, on the source chain. No gas is needed on Horizen.

Step 3: Send

cast send $ADAPTER \
"send((uint32,bytes32,uint256,uint256,bytes,bytes,bytes),(uint256,uint256),address)" \
"($DST_EID,$RECIPIENT_B32,1000000000000000000,1000000000000000000,0x,0x,0x)" \
"($NATIVE_FEE,0)" $YOUR_ADDRESS \
--value $NATIVE_FEE \
--rpc-url $RPC_BASE --private-key $PK

The last argument is the refund address for any fee overpayment.

Step 4: Track and Verify

Paste the transaction hash into LayerZero Scan. The message moves through Inflight, Confirming, and Delivered. Delivered means the executor has landed the mint on Horizen.

Confirm the balance yourself rather than trusting the UI:

cast call 0x57da2D504bf8b83Ef304759d9f2648522D7a9280 \
"balanceOf(address)(uint256)" $YOUR_ADDRESS \
--rpc-url https://horizen.calderachain.xyz/http

The same flow in Viem

import { createWalletClient, createPublicClient, http, parseEther, pad } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { base } from 'viem/chains'
import { Options } from '@layerzerolabs/lz-v2-utilities'
import { oftAbi } from './abi/oft' // IOFT quoteSend + send fragments from @layerzerolabs/oft-evm

const ADAPTER = '0x57da2D504bf8b83Ef304759d9f2648522D7a9280'
const DST_EID = 30399

const account = privateKeyToAccount(process.env.PK as `0x${string}`)
const publicClient = createPublicClient({ chain: base, transport: http() })
const walletClient = createWalletClient({ account, chain: base, transport: http() })

// If quoteSend reverts with empty options, the pathway has no enforced options.
// Build explicit executor options instead of guessing at gas on the destination:
const extraOptions = Options.newOptions()
.addExecutorLzReceiveOption(200_000, 0)
.toHex() as `0x${string}`

const sendParam = {
dstEid: DST_EID,
to: pad(account.address, { size: 32 }),
amountLD: parseEther('1'),
minAmountLD: parseEther('1'),
extraOptions,
composeMsg: '0x' as const,
oftCmd: '0x' as const,
}

const [nativeFee, lzTokenFee] = await publicClient.readContract({
address: ADAPTER,
abi: oftAbi,
functionName: 'quoteSend',
args: [sendParam, false],
}) as [bigint, bigint]

const hash = await walletClient.writeContract({
address: ADAPTER,
abi: oftAbi,
functionName: 'send',
args: [sendParam, { nativeFee, lzTokenFee }, account.address],
value: nativeFee,
})

console.log(`sent: https://layerzeroscan.com/tx/${hash}`)
tip

dstEid is a LayerZero endpoint ID, not an EVM chain ID. The two numbering schemes are unrelated. Passing a chain ID here is the single most common OFT integration bug, and the failure mode is a revert at quote time if you are lucky, or a misrouted message if you are not.

Route 2: Bridge ETH over the Native Bridge

ETH moves through Horizen's native OP Stack bridge. Deposits are a single transaction on Base. Withdrawals follow the standard OP Stack two-step: initiate on Horizen, wait out the challenge period, finalize on Base.

If you want the UI path, hub.horizen.io handles both deposits and the prove/finalize withdrawal flow.

Deposit, Base to Horizen

export L1_STANDARD_BRIDGE=0xf4a6cc4171fda694439f856d912777aa6ab05369

cast send $L1_STANDARD_BRIDGE \
"bridgeETH(uint32,bytes)" 200000 0x \
--value 0.01ether \
--rpc-url $RPC_BASE --private-key $PK

The first argument is the minimum gas limit for the deposit transaction on Horizen. 200k is comfortable headroom for a plain ETH transfer.

ETH lands after Base finality (typically a few minutes). Verify:

cast balance $YOUR_ADDRESS --rpc-url https://horizen.calderachain.xyz/http

Withdrawal, Horizen to Base

Withdrawals are subject to a 7-day challenge period before they can be finalized on Base. The bridge hub at hub.horizen.io handles the prove and finalize steps for you, and for most workflows that is the right tool. Budget for the challenge period in anything user-facing you build on top of this: it is a property of the rollup's security model, not a UI limitation.

Challenge period

7 days is the standard OP Stack default. Caldera can configure this differently per deployment. Verify the current value with the Horizen team or check the FINALIZATION_PERIOD_SECONDS setting on the L2OutputOracle contract before publishing a hard deadline to users.

Troubleshooting

SymptomCauseFix
quoteSend revertsEmpty extraOptions on a pathway without enforced optionsBuild explicit options with addExecutorLzReceiveOption(200000, 0)
send reverts on allowanceAdapter not approved, Base to Horizen direction onlyApprove the adapter for the ERC-20 first
Stuck at Inflight on LayerZero ScanDVN verification pendingWait it out; past 10 minutes on testnet, check the pathway config on LayerZero Scan
Reverts or misroutes with a valid-looking dstEidEVM chain ID used instead of the LayerZero EIDUse the EID table above; the schemes are unrelated
ETH transfer attempted through StargateETH is not a Stargate asset on HorizenUse the native bridge

Further reading