Documentation

Token vault

The complete external ABI of ChainHopTokenVault, covering quoting, bridging, the wire format, events, access control, and every revert an integrator can hit.

On this page

Addresses

One ChainHopTokenVault per chain, at the same address on all seven chains:

ContractAddressApeScan
ChainHopTokenVaultApeChain0xaED3…096D
Identical on ApeChain, Base, Ethereum, Robinhood Chain, Arbitrum One, BNB Chain and HyperEVM. Each chain's own explorer will show it.

ChainHopTokenVault is ChainHopMessenger (routing, peers, fees, CCIP) plus ReentrancyGuard plus the ERC20 lock and mint layer. Everything below is on one contract.

Registry reads

function homeSelectorOf(address token) external view returns (uint64);
function homeTokenOf(address token) external view returns (address);
function wrappedTokenOf(uint64 homeSelector, address homeToken) external view returns (address);
function predictWrappedToken(uint64 homeSelector, address homeToken) external view returns (address);
function wrappedImplementation() external view returns (address);

homeSelectorOf returning 0 is the canonical-versus-wrapper discriminator. The mapping is only ever written by this vault when it deploys a wrapper, so a non-zero value proves the address is a ChainHop wrapper this vault controls.

predictWrappedToken is pure CREATE2 arithmetic over the EIP-1167 clone init-code and the salt keccak256(abi.encode(homeSelector, homeToken)), so it answers before the asset has ever been bridged. See Wrapped assets for the derivation.

Routing and state reads

function localSelector() external view returns (uint64);   // immutable
function peers(uint64 selector) external view returns (address);
function nextHopOf(uint64 finalSelector) external view returns (uint64);
function laneConfigs(uint64 nextHopSelector) external view returns (uint256 gasLimit, bool allowOutOfOrder, bool useV1ExtraArgs);
function maxHops() external view returns (uint8);
function defaultGasLimit() external view returns (uint256);
function bridgingPaused() external view returns (bool);
function owner() external view returns (address);
function getRouter() external view returns (address);      // the CCIP router

A laneConfigs entry with gasLimit == 0 means unset: the vault falls back to defaultGasLimit() with allowOutOfOrder: true and V2 extraArgs. Read the lane and default values from the source vault.

getRouter() returns the CCIP router used by the vault.

Fees

function quoteBridgeTokens(uint64 finalSelector, address token, uint256 amount, address to) external view returns (uint256);
function flatFee() external view returns (uint256);
function destinationFeeOf(uint64 finalSelector) external view returns (uint256);
function feeReceiver() external view returns (address);

quoteBridgeTokens returns one number, in wei of the source chain's native coin, and it is the whole thing:

quote = CCIP first-hop fee (router.getFee) + flatFee() + destinationFeeOf(finalSelector)

The CCIP component is Chainlink's, priced live by the router for the exact message that will be sent, which means it depends on the payload size. For tokens the payload is close to fixed (identity, amount, name, symbol, decimals), so the quote barely moves with amount. For NFTs it scales with the batch and its URIs.

The protocol components are ChainHop's, owner-configured, and readable on chain from flatFee() and destinationFeeOf(selector). Read them, do not assume. They are charged once per originated bridge on the source chain, on both the lock and the burn path, and forwarded to feeReceiver inside the same transaction with a FlatFeeCollected(payer, feeReceiver, amount) event carrying the combined figure.

destinationFeeOf exists to recoup multi-hop transit. On a two-hop path the transit node fronts hop 2 out of its own balance, and the surcharge is how that gets priced back in. It is keyed by final destination, so a route change can make an entry stale in either direction, and nothing enforces that it is re-tuned when setRoute moves a path.

Your quote never includes later hops

On the ApeChain to Robinhood Chain path, quoteBridgeTokens(robinhoodSelector, ...) returns the same CCIP component as quoteBridgeTokens(baseSelector, ...) for the same token and amount because the first hop is the same lane in both cases. The difference is in destinationFeeOf. Do not derive a multi-hop cost by summing lane quotes.

Quotes revert for the same reasons sends do (SelfRoute(), RouteNotSet(...), PeerNotSet(...)), so a reverting quote is a real signal about reachability, not a transport error.

Bridging out

function bridgeTokens(
    uint64 finalSelector,
    address token,
    uint256 amount,
    address to
) external payable returns (bytes32 messageId);

nonReentrant, whenNotPaused.

  • finalSelector: the CCIP selector of the final destination, never the next hop. The vault does the routing.
  • token: a canonical ERC20 (locked) or one of this vault's wrappers (burned). One entry point for both.
  • amount: in the token's own decimals. For a canonical fee-on-transfer token, the amount actually bridged is the balance delta the vault observed, which can be less. The TokensBridgedOut event's amount is authoritative.
  • to: the recipient on the destination chain. Must be non-zero.
  • msg.value: at least the quote. Excess is refunded to msg.sender in the same transaction, so a contract caller must be payable.

The wire format

The payload is abi.encode of a ChainHopLib.TokenOrder, wrapped in an Envelope.

ChainHopLib.sol
library ChainHopLib {
    struct Envelope {
        uint64 originSelector;  // chain the message was originated on
        uint64 finalSelector;   // chain the message terminates on
        uint8  hopsRemaining;   // TTL, decremented per forward
        bytes  payload;         // opaque to forwarding nodes
    }
 
    struct TokenOrder {
        uint64  homeSelector;
        address homeToken;
        address to;
        uint256 amount;
        uint8   decimals;
        string  name;
        string  symbol;
    }
}

Only the envelope is read by a transit node. hopsRemaining is the TTL, and payload is opaque to anything that is not the terminal chain, which is what makes a forward byte-for-byte faithful.

The metadata rides along so the destination can deploy a wrapper on first arrival with no off-chain input. It is read from the source asset at bridge time with try/catch and length caps, and the destination snapshots exactly what it receives.

FieldRead fromCapFallback if missing or reverting
nameIERC20Metadata.name()64 bytes"ChainHop Token"
symbolIERC20Metadata.symbol()32 bytes"CHT"
decimalsIERC20Metadata.decimals()none18

Caps are on bytes, applied by truncating the string in memory, so a multibyte character split at the boundary becomes a replacement character. An empty string is treated as missing and takes the fallback.

Envelope and TokenOrder are plain abi.encode, so you can decode them yourself from a CCIP message's data field with abi.decode(data, (ChainHopLib.Envelope)) and then abi.decode(env.payload, (ChainHopLib.TokenOrder)).

Bridging in

Not callable by you. The CCIP router calls the vault, the vault authenticates the peer, and the terminal handler either mints a wrapper or releases escrow.

One externally visible function belongs to this path and is not for you.

function deliverTokens(address token, address to, uint256 amount) external; // reverts OnlySelf()

It is external only so the vault can try/catch its own escrow release. It reverts OnlySelf() for every caller but the vault itself.

Claims

function claims(address token, address claimant) external view returns (uint256);
function claimTokens(address token, uint256 amount, address recipient) external; // nonReentrant

Credits accrue only on the unlock path, when the escrow transfer reverts. amount must be non-zero and no greater than the credit, or NothingToClaim(). Partial claims are allowed and recipient is free, which is the point: the original delivery failed for a reason that may be tied to the address.

Events

Everything an indexer keys on, with indexed parameters marked.

// ChainHopTokenVault
event TokensBridgedOut(
    bytes32 indexed messageId, address indexed token, address indexed from,
    uint64 finalSelector, address to, uint256 amount,
    uint64 homeSelector, address homeToken
);
event TokensUnlocked(bytes32 indexed messageId, address indexed token, address indexed to, uint256 amount, bool escrowed);
event WrappedMinted(bytes32 indexed messageId, address indexed wrapped, uint64 homeSelector, address homeToken, address to, uint256 amount);
event WrappedDeployed(address indexed wrapped, uint64 indexed homeSelector, address indexed homeToken, string name, string symbol, uint8 decimals);
event TokensClaimed(address indexed token, address indexed claimant, address recipient, uint256 amount);
event BridgingPausedSet(bool paused);
event RescueQueued(bytes32 indexed id, address token, address to, uint256 amount, uint256 executableAt);
event RescueCancelled(bytes32 indexed id);
event RescueExecuted(bytes32 indexed id);
 
// inherited from ChainHopMessenger
event MessageOriginated(bytes32 indexed messageId, uint64 indexed finalSelector, uint64 nextHopSelector, uint256 fee);
event MessageForwarded(
    bytes32 indexed inboundMessageId, bytes32 indexed outboundMessageId,
    uint64 originSelector, uint64 finalSelector, uint64 nextHopSelector,
    uint8 hopsRemaining, uint256 fee
);
event PeerSet(uint64 indexed selector, address peer);
event RouteSet(uint64 indexed finalSelector, uint64 nextHopSelector);
event PeerQueued(bytes32 indexed id, uint64 indexed selector, address peer, uint256 executableAt);
event RouteQueued(bytes32 indexed id, uint64 indexed finalSelector, uint64 nextHopSelector, uint256 executableAt);
event RouterQueued(bytes32 indexed id, address router, uint256 executableAt);
event ConfigCancelled(bytes32 indexed id);
event SetupFinalized();
event LaneConfigSet(uint64 indexed nextHopSelector, uint256 gasLimit, bool allowOutOfOrder, bool useV1ExtraArgs);
event MaxHopsSet(uint8 maxHops);
event FlatFeeSet(uint256 flatFee);
event DestinationFeeSet(uint64 indexed finalSelector, uint256 fee);
event FeeReceiverSet(address feeReceiver);
event FlatFeeCollected(address indexed payer, address indexed feeReceiver, uint256 amount);

The three an indexer cannot do without are TokensBridgedOut (the origin, where messageId is hop 1's id), MessageForwarded (hop chaining, keyed by the indexed inbound id), and the terminal pair WrappedMinted and TokensUnlocked (keyed by the final hop's id).

Access control

onlyOwner: setPeer, setRoute, setRouter (each gated by the config timelock below once setup is finalized), queueSetPeer, queueSetRoute, queueSetRouter, cancelConfig, finalizeSetup, setLaneConfig, setMaxHops, setFlatFee, setDestinationFee, setFeeReceiver, withdrawNative, setBridgingPaused, queueRescue, cancelRescue, executeRescue. Standard OpenZeppelin Ownable, so an unauthorized call reverts OwnableUnauthorizedAccount(address).

The config timelock

finalizeSetup() is a one-way switch. It emits SetupFinalized() and can never be unset (AlreadyFinalized()). Before it, setPeer / setRoute / setRouter act immediately: that is how the mesh got wired. After it, each of those calls executes only a previously queued change whose delay has matured:

uint256 public constant TIMELOCK_DELAY = 3 days;
function setupFinalized() external view returns (bool);
function finalizeSetup() external;
function peerConfigId(uint64 selector, address peer) external pure returns (bytes32);
function routeConfigId(uint64 finalSelector, uint64 nextHopSelector) external pure returns (bytes32);
function routerConfigId(address router) external pure returns (bytes32);
function configExecutableAt(bytes32 id) external view returns (uint256);
function queueSetPeer(uint64 selector, address peer) external returns (bytes32 id);
function queueSetRoute(uint64 finalSelector, uint64 nextHopSelector) external returns (bytes32 id);
function queueSetRouter(address router) external returns (bytes32 id);
function cancelConfig(bytes32 id) external;

Read TIMELOCK_DELAY and setupFinalized() on the vault. The queue events (PeerQueued, RouteQueued, RouterQueued, each carrying executableAt) provide public notice before rewiring can execute. Executing an unqueued or immature change reverts ConfigUnknownOrPending(id); double-queueing reverts ConfigAlreadyQueued(id).

Rescue

Rescue is the only owner path that can touch escrow, and it is timelocked the same way.

uint256 public constant RESCUE_DELAY = 3 days;
function rescueId(address token, address to, uint256 amount) external pure returns (bytes32);
function rescueExecutableAt(bytes32 id) external view returns (uint256);
function queueRescue(address token, address to, uint256 amount) external returns (bytes32 id);
function cancelRescue(bytes32 id) external;
function executeRescue(address token, address to, uint256 amount) external;
function totalClaims(address token) external view returns (uint256);

Read RESCUE_DELAY on the vault. RescueQueued carries every parameter plus executableAt, so the queue is fully observable from logs. Anyone building a monitor should watch it. New hardening: a rescue is bounded by the token balance not reserved for open claims. totalClaims(token) tracks the outstanding credits, and an over-large rescue reverts RescueExceedsUnclaimedBalance(requested, available) instead of draining what claimants are owed.

Reverts

Everything an integrator can hit, by origin.

ChainHopTokenVault

ErrorWhen
ZeroAddress()to is the zero address on bridge or claim
ZeroAmount()amount is zero, or a canonical transfer delivered nothing
BridgingPausedErr()outbound bridging is paused
OnlySelf()deliverTokens called by anyone but the vault
CloneFailed()CREATE2 wrapper deploy returned the zero address
NothingToClaim()claim amount is zero or exceeds the credit
DeliveryOutOfGas()escrow release was gas-starved. The message stays retryable.
RescueUnknownOrPending(bytes32 id)rescue not queued, or not yet executable
RescueAlreadyQueued(bytes32 id)that exact rescue is already queued
RescueExceedsUnclaimedBalance(uint256 requested, uint256 available)rescue would eat into balances backing open claims
TokensNotDelivered()the vault's own delivery self-call did not transfer the tokens

ChainHopMessenger

ErrorWhen
SelfRoute()finalSelector equals this vault's localSelector
RouteNotSet(uint64 finalSelector)no route configured for that destination
PeerNotSet(uint64 selector)route points at a chain with no registered peer
InsufficientFee(uint256 required, uint256 provided)msg.value below the quote
RefundFailed()the excess refund to msg.sender reverted
FeeTransferFailed()the protocol fee transfer to feeReceiver reverted
FeeReceiverRequired()a fee is configured with no receiver
UntrustedSource(uint64 selector, address sender)inbound message not from the registered peer
HopLimitReached(uint64 originSelector, uint64 finalSelector)envelope TTL exhausted
InvalidConfig()owner passed a nonsensical selector
WithdrawFailed()withdrawNative transfer reverted
AlreadyFinalized()finalizeSetup called twice
ConfigUnknownOrPending(bytes32 id)config change not queued, or its 72h delay has not matured
ConfigAlreadyQueued(bytes32 id)that exact config change is already queued

Inherited

OwnableUnauthorizedAccount(address), OwnableInvalidOwner(address), ReentrancyGuardReentrantCall(), and SafeERC20FailedOperation(address token) when a canonical token's transferFrom or transfer fails or returns false.

Unsupported assets

Rebasing and elastic-supply ERC20s are not supported, and nothing rejects them. Escrow accounting assumes a static balance. If the vault's balance shrinks, an unlock lands in claims rather than paying out.

Metadata is snapshotted at first wrapper deploy and never updates afterwards. A token that renames itself on its home chain keeps the old name on every wrapper forever.