How a hop works
The full lifecycle of one ChainHop transfer, from approve through CCIP delivery, including multi-hop tracing and what recourse exists when a delivery fails.
On this page
The lifecycle, end to end
1. Plan the transfer (view calls only)
Before a wallet is involved, three reads on the source vault settle what will happen.
const homeSel = await src.readContract({ address: srcVault, abi, functionName: "homeSelectorOf", args: [asset] });
const paused = await src.readContract({ address: srcVault, abi, functionName: "bridgingPaused" });
const nextHop = await src.readContract({ address: srcVault, abi, functionName: "nextHopOf", args: [dest.selector] });homeSel === 0n: the asset is canonical here. Bridging will lock it, and the vault needs an ERC20 allowance (or an ERC721 approval).homeSel !== 0n: the asset is a ChainHop wrapper. Bridging will burn it. There is no approval to grant, and asking for one is a UX bug. The vault already has unconditional mint authority over the wrapper, sovaultBurndeliberately does not check allowance.nextHop === 0n: the owner has not routed this destination. The send will revert withRouteNotSet(finalSelector). Treat the destination as unavailable.paused === true: outbound bridging is off.BridgingPausedErr().
One more read on the destination vault settles what lands.
const existing = await dst.readContract({ address: destVault, abi, functionName: "wrappedTokenOf", args: [homeSelector, homeAsset] });- If the destination's selector equals
homeSelector, nothing is deployed or minted. The canonical asset is unlocked from that vault's escrow. - Else if
existingis non-zero, the destination mints that existing wrapper. - Else this is the asset's first arrival: the destination will deploy the
wrapper and then mint. Its address is already knowable via
predictWrappedToken(homeSelector, homeAsset).
2. Approve (canonical assets only)
ERC20: token.approve(tokenVault, amount).
ERC721: collection.setApprovalForAll(nftVault, true), or a per-id approve.
Skip entirely when sourceMode === "burn".
3. Quote the fee
const fee = await src.readContract({
address: srcVault, abi: TOKEN_VAULT_ABI, functionName: "quoteBridgeTokens",
args: [dest.selector, token, amount, recipient],
});The returned value is wei of the source chain's native coin. It covers the first hop only, plus whatever protocol fee the owner has configured (see Token vault). Later hops on a multi-hop path are paid by the transit nodes out of their own native balances. They are not in your quote, and you do not send extra for them.
4. Send
await wallet.writeContract({
address: srcVault, abi: TOKEN_VAULT_ABI, functionName: "bridgeTokens",
args: [dest.selector, token, amount, recipient],
value: fee,
});In one transaction the vault:
- burns the wrapper, or transfers the canonical asset in and measures the balance delta, so a fee-on-transfer token bridges what actually arrived rather than what was asked;
- builds the envelope
{ originSelector, finalSelector, hopsRemaining, payload }and the order payload; - reads
nextHopOf(finalSelector)andpeers[nextHop], builds the CCIP message addressed to that peer, prices it withrouter.getFee; - reverts with
InsufficientFee(required, provided)ifmsg.valueis short; - calls
router.ccipSend{value: ccipFee}and gets the message id; - forwards the protocol fee to
feeReceiverif one is configured; - refunds
msg.value - totalFeetomsg.sender; - emits
MessageOriginatedandTokensBridgedOutorNFTsBridgedOut.
Because the CCIP fee can move between quoting and inclusion, callers may attach a small buffer and rely on the refund in step 7.
The refund goes to msg.sender
If a contract calls bridgeTokens with more than the exact fee, the refund is a
plain call back to that contract. Without a payable receive() or
fallback(), the refund fails and the whole bridge reverts with
RefundFailed(). Either send exactly the quoted amount, or make your contract
payable. There is a worked example in
Integration recipes.
5. Get the message id
Both entry points return bytes32 messageId, but a return value from a
simulation can go stale between simulate and send. Read it from the receipt
instead.
const [event] = parseEventLogs({ abi: TOKEN_VAULT_ABI, eventName: "TokensBridgedOut", logs: receipt.logs })
.filter((log) => log.address.toLowerCase() === vault.toLowerCase());
const messageId = event?.args.messageId;
const bridged = event?.args.amount; // authoritative for fee-on-transfer tokensFilter by the vault address. A malicious token in the same transaction can emit an identically shaped log.
6. In flight
Between send and delivery the message is with CCIP. It has to reach source finality, be committed, then be executed on the destination. Nothing on the destination chain exists yet: no pending balance, no placeholder.
Track each hop through Chainlink's CCIP explorer. For application state, use the vault events described below and treat the destination delivery event as final.
7. Destination execution
CCIP calls the destination vault, which authenticates the peer, decodes the
envelope and, because finalSelector matches its own localSelector, runs the
terminal handler. It emits exactly one of:
All are keyed by the final hop's message id, which on a multi-hop path is not the id your source transaction returned.
A multi-hop example: ApeChain to Robinhood via Base
Routes are configurable, so read nextHopOf rather than assuming a fixed path.
This example shows the shape of a two-hop route through Base.
ApeChain Base Robinhood Chain
+---------------+ +---------------+ +---------------+
| TokenVault | | TokenVault | | TokenVault |
| | CCIP #1 | (transit) | CCIP #2 | (terminal) |
| lock or burn |----------->| forward |---------->| mint / unlock |
| | msg id A | | msg id B | |
+---------------+ +---------------+ +---------------+
user pays hop 1 Base node pays hop 2 nothing to pay
MessageOriginated(A) MessageForwarded(A -> B) WrappedMinted(B)
TokensBridgedOut(A)Facts that follow from this shape:
- One user transaction, one user fee. The user signs once on ApeChain and pays only hop 1. The Base node fronts hop 2 from its own native balance.
- Two CCIP messages, two ids, two explorer pages. Message id A says nothing about hop 2. A UI that links only A will show "delivered" while the transfer is still mid-flight.
- The Base vault is not a destination. On the transit chain the vault does
not touch the payload.
finalSelectoris not itslocalSelector, so it decrementshopsRemaining, rebuilds the message for its own next hop, and relays. No lock, no mint, no escrow on Base. - Total time is the sum of the hops. Each leg waits for its own source chain's finality.
A transit node that runs dry stalls the second leg
Hop 2 is paid out of the Base node's own native balance and nothing enforces that the balance is sufficient. An underfunded forward reverts, which leaves the message stuck but retryable on CCIP until someone tops the node up. No funds are at risk, but the transfer does not complete on its own. The same applies to any chain used as a transit hop.
Tracing both legs
Phase 1, enumerate the path. Walk nextHopOf(dest.selector) from the source
vault, bounded by MAX_HOPS, until the next hop equals the destination
selector. Stop if a selector is unrouted or unknown.
Phase 2, walk the ids. You have hop 1's id. To learn hop 2's, look on the transit chain's vault for the forwarding event.
event MessageForwarded(
bytes32 indexed inboundMessageId,
bytes32 indexed outboundMessageId,
uint64 originSelector,
uint64 finalSelector,
uint64 nextHopSelector,
uint8 hopsRemaining,
uint256 fee
);inboundMessageId is indexed, so the lookup is a single filter. Repeat until
the path is exhausted. Later hops legitimately have no id yet. That is what
"still in flight on the previous leg" looks like, and it must not be reported as
an error.
Two ways to run that lookup, in preference order:
- The transit chain's subgraph.
hopForward(id: $inboundMessageId)returnsoutboundMessageIdin one query. The entity is keyed by the inbound id precisely so this is a constant-time lookup. See Subgraph reference. eth_getLogson the transit vault, filtered on the indexedinboundMessageId.
When a hop fails
Three distinct failure modes. None of them loses funds, and your copy must not say they do.
The source transaction reverts
Nothing happened. No lock, no burn, no message. See the revert reference.
The CCIP message fails to execute on the destination
The destination vault's handler reverted. For example, an underfunded transit node could not pay its outward hop, or the delivered gas limit was too low. CCIP does not drop the message: it stays manually executable from its CCIP explorer page, and manual execution can raise the gas limit. The source-side lock or burn already settled, and nothing on the destination has been released, so the asset is exactly where the invariant says it is.
Link the hop's CCIP explorer page so the user can inspect or manually execute it.
Delivery succeeded, but the transfer to the recipient did not
This is the interesting one, and it is the reason TokensUnlocked and
NFTUnlocked carry an escrowed flag.
On the home chain only, which is the unlock path, the vault releases escrow with
try this.deliverTokens(...). If that transfer reverts (a blacklisting token,
an ERC721C transfer validator that blocks the recipient), the vault does not
revert the CCIP message. It credits the asset to the recipient as an on-vault
claim and emits the terminal event with escrowed = true.
There is an anti-griefing guard around this. A delivery that ran out of gas also
lands in the catch, and silently escrowing in that case would let a gas
starver force a false escrow. So the vault applies the EIP-150 rule: after a
call that consumed all available gas, only about 1/64 of the pre-call gas
remains in this frame. If gasleft() <= gasBefore / 64, the vault reverts the
whole message with DeliveryOutOfGas(), pushing the message back into CCIP's
retryable state where manual execution can supply more gas, rather than
escrowing.
Note the asymmetry: mint paths do not escrow. A wrapper mint cannot fail for policy reasons, because ChainHop's own wrapped contracts have no hooks and mint bypasses the transfer validator, so there is no claim path on the mint side.
The claims path
Claims are per (asset, claimant) and the claimant is the original bridge
recipient. They never expire, and pausing the bridge does not block them.
// ERC20
function claims(address token, address claimant) external view returns (uint256);
function claimTokens(address token, uint256 amount, address recipient) external;
// ERC721
function nftClaims(address collection, uint256 tokenId) external view returns (address);
function claimNFTs(address collection, uint256[] calldata tokenIds, address recipient) external;The claimant chooses recipient, so a blocked address can redirect elsewhere.
claimTokens supports partial claims. claimNFTs takes a batch and reverts
with NothingToClaim() if any id in the batch is not credited to msg.sender.
An NFT unlock batch can partially escrow: each id is released in its own
try/catch, so one batch can deliver some ids and escrow others. Do not treat
the unlock path as atomic.
Finding a user's open claims without knowing which assets to ask about is the one question RPC cannot answer, and it is what the subgraph exists for. See Subgraph reference.