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:
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 routerA 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. TheTokensBridgedOutevent'samountis authoritative.to: the recipient on the destination chain. Must be non-zero.msg.value: at least the quote. Excess is refunded tomsg.senderin 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.
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.
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; // nonReentrantCredits 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
ChainHopMessenger
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.