Documentation

Bridge overview

ChainHop is a permissionless lock-and-mint bridge for ERC20s and ERC721s, built on Chainlink CCIP as an owner-routed mesh of one vault per chain.

On this page

What ChainHop is

ChainHop moves ERC20 tokens and ERC721 NFTs between chains. It is not a liquidity network and it holds no pools. It is a mesh of vault contracts, one per chain per asset class, that talk to each other over Chainlink CCIP.

There are two independent meshes:

  • ChainHopTokenVault, the ERC20 mesh. One instance per chain.
  • ChainHopNFTVault, the ERC721 mesh. One instance per chain, with its own peer registry and its own routing table.

The two meshes share a base contract and therefore share a read surface (homeSelectorOf, nextHopOf, bridgingPaused), but they are separate contracts with separate state. Calling one with the other's addresses is always a mistake.

Both are permissionless in the asset dimension: the owner wires chains, never tokens. Any ERC20 and any ERC721 can be bridged without registration, allowlisting, or a pool being seeded first.

No CCIP token pools

ChainHop uses CCIP for arbitrary messaging only. Every message it sends carries an empty tokenAmounts array. The asset movement is ChainHop's own lock and mint accounting, not CCIP's token-transfer path. That is why an arbitrary ERC20 works without a CCIP token pool existing for it.

The CCIP concepts you need

You do not need to know CCIP to use ChainHop, but five terms appear in the ABI and in the events.

Chain selector. CCIP identifies a chain by a uint64 selector, which is not the EVM chain id and has no relationship to it. Every ChainHop entry point takes a selector, never a chain id. Selectors exceed JavaScript's safe integer range: handle them as bigint or as strings. Number(selector) silently corrupts the value.

Router. Each chain has a CCIP Router contract. A vault calls router.ccipSend(destinationSelector, message) to send and router.getFee(destinationSelector, message) to price. Inbound, the router is the only address allowed to deliver a message into the vault.

Fee token. CCIP lets a sender pay in LINK or in the source chain's native coin. ChainHop always pays in native coin: its outbound message is built with feeToken: address(0). So the fee you attach as msg.value on ApeChain is APE, on BNB Chain it is BNB, on HyperEVM it is HYPE, and on Base, Ethereum, Robinhood Chain and Arbitrum it is ETH.

extraArgs. Per-lane send options. ChainHop sets two: a destination gasLimit (how much gas the receiving vault gets when the message executes) and allowOutOfOrderExecution. It builds GenericExtraArgsV2 by default and can fall back to EVMExtraArgsV1 per lane if a lane ever rejects the V2 tag.

Message id and execution. ccipSend returns a bytes32 message id. CCIP then waits for source-chain finality, commits the message, and executes it on the destination by calling the receiver. Execution can fail. A failed CCIP message is retryable by manual execution and is never dropped.

Finality is the reason a hop takes minutes rather than seconds. Each leg waits for source-chain finality before it can execute on the next chain.

Lock and mint, burn and unlock

Every asset has exactly one home chain: the chain whose vault holds the canonical asset in escrow. Which model applies to a given transfer follows from where the asset is home relative to where it is going.

You are bridgingSource sideDestination side
A canonical asset, from its home chainlock in vault escrowmint a wrapper, deploying it on first arrival
A ChainHop wrapper, back to the home chainburnunlock canonical escrow
A ChainHop wrapper, to a third chainburnmint that chain's wrapper

So it is both: lock and mint outbound from home, burn and unlock inbound to home, and burn and mint between two non-home chains. Escrow never leaves the home chain.

The ordering invariant is the safety property. The lock or the burn happens in the source transaction, strictly before any release message exists. Home-chain escrow therefore always covers total wrapped supply everywhere else.

A vault decides which case it is in by a single read. homeSelectorOf(asset) returns 0 for anything the vault did not itself deploy: that is a canonical asset, and this chain is (or becomes) its home. A non-zero value means the address is a ChainHop wrapper, and gives the home chain's selector.

Home is per-deployment, not per-token

The mesh has no notion of "the same token on two chains". If a project deploys its ERC20 independently on ApeChain and on Base, bridging each one creates two unrelated mesh assets with two different homes and two different wrappers. Identity in the mesh is the pair (homeSelector, homeAsset), and the first bridge out of a chain fixes that pairing permanently. Nothing warns you.

The chains

ChainHop spans ApeChain, Base, Ethereum, Robinhood Chain, Arbitrum One, BNB Chain and HyperEVM. The vault addresses are identical on every chain: one token-vault address and one NFT-vault address, mesh-wide. Every wrapped asset also resolves to one address across destination chains because its CREATE2 inputs are the same.

ContractAddressApeScan
ChainHopTokenVaultApeChain0xaED3…096D
ChainHopNFTVaultApeChain0xAC87…516c
The same two addresses on all seven chains; the tag names the explorer being linked.

Chain ids and selectors

Bridge entry points take CCIP selectors, not EVM chain ids.

ChainkeyChain idCCIP selector (uint64)Explorer
ApeChainapechain3313914894068710063348487ApeScan
Basebase845315971525489660198786BaseScan
Ethereumethereum15009297550715157269Etherscan
Robinhood Chainrobinhood46636180753054346818345Blockscout
Arbitrum Onearbitrum421614949039107694359620Arbiscan
BNB Chainbnb5611344663589394136015BscScan
HyperEVMhyperevm9992442541497099098535HyperEVMScan

The chain id and the selector are unrelated numbers. Never derive one from the other, and never round-trip a selector through a JavaScript number.

Read each vault's CCIP router with getRouter() and its wrapper implementation with wrappedImplementation().

Routing: the mesh is a distance-vector table

A vault does not know a path. It knows one thing per destination: the next hop. nextHopOf(finalSelector) returns the selector of the chain to send to next. When that equals the final selector, the hop is direct. When it differs, the message is relayed: the next vault receives it, reads its own table, and forwards.

Routes can change behind the configuration timelock. Read nextHopOf(dest.selector) on the source vault and continue the lookup on each transit vault until the next hop equals the destination. See How a hop works for multi-hop tracing.

Envelopes carry a TTL. The vault stamps maxHops() into an envelope at origination and decrements it on each forward. A routing loop ends with HopLimitReached instead of draining node balances.

Trust and security model

Who can execute a message

Two gates, both mandatory:

  1. The CCIP router is the only caller that can deliver into the vault. This is enforced by the CCIPReceiverBase base contract.
  2. The vault then checks the CCIP-authenticated source. It decodes the message sender and requires it to equal peers[message.sourceChainSelector]. Anything else reverts with UntrustedSource(selector, sender).

Peers are instances of the same contract, so a node either originates an envelope truthfully or forwards it byte for byte. That is what makes envelope contents trustworthy across a multi-hop path: no intermediate node can rewrite the order.

What the owner can do

The vault is Ownable. Trust-critical wiring is protected by an on-chain timelock, while operational settings remain immediate.

Timelocked. After finalizeSetup(), peers, routes and the router can no longer change in one transaction. Each change must be queued, emits a public event and waits TIMELOCK_DELAY before execution. Read setupFinalized() and configExecutableAt(id) for the current state.

CallEffect
queueSetPeersetPeer(selector, node)register or replace the trusted node on another chain. Queued, 72h.
queueSetRoutesetRoute(finalSelector, nextHopSelector)set or unset (pass 0) a route. Queued, 72h.
queueSetRoutersetRouter(address)repoint at a new CCIP router. Queued, 72h.
queueRescueexecuteRescuemove escrowed assets. Queued, 72h (RESCUE_DELAY).
setLaneConfig(nextHop, gasLimit, allowOutOfOrder, useV1ExtraArgs)per-lane send options. Immediate.
setMaxHops(uint8)envelope TTL for locally originated messages. Immediate.
setFlatFee, setDestinationFee, setFeeReceiverprotocol fee configuration. Immediate.
setBridgingPaused(bool)pause outbound bridging. Immediate.
setMaxNftBatch(uint256)NFT vault only. Immediate.
withdrawNative(to, amount)withdraw the node's native float. Immediate.

The residual trust surface is the owner: a malicious peer or router can redirect traffic or mint unbacked wrappers after the queued change matures. Monitor the queue events if your integration depends on the bridge's trust configuration.

withdrawNative is not timelocked because a node's native balance is owner-supplied fee float used to pay forward hops, never user escrow.

Pause semantics

bridgingPaused gates the whenNotPaused modifier on bridgeTokens and bridgeNFTs only. Inbound delivery, minting, unlocking and claiming all keep working while paused, so a pause can never wedge messages that are already in flight.

Where to go next