Documentation

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, so vaultBurn deliberately does not check allowance.
  • nextHop === 0n: the owner has not routed this destination. The send will revert with RouteNotSet(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 existing is 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:

  1. 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;
  2. builds the envelope { originSelector, finalSelector, hopsRemaining, payload } and the order payload;
  3. reads nextHopOf(finalSelector) and peers[nextHop], builds the CCIP message addressed to that peer, prices it with router.getFee;
  4. reverts with InsufficientFee(required, provided) if msg.value is short;
  5. calls router.ccipSend{value: ccipFee} and gets the message id;
  6. forwards the protocol fee to feeReceiver if one is configured;
  7. refunds msg.value - totalFee to msg.sender;
  8. emits MessageOriginated and TokensBridgedOut or NFTsBridgedOut.

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 tokens

Filter 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:

EventMeaning
WrappedMinted(messageId, wrapped, homeSelector, homeToken, to, amount)ERC20 wrapper minted
TokensUnlocked(messageId, token, to, amount, escrowed)ERC20 escrow released, or credited as a claim
WrappedCollectionDeployed(...) then WrappedNFTsMinted(messageId, wrapped, homeSelector, homeCollection, to, tokenIds)NFT wrapper deployed on first arrival, then minted
NFTUnlocked(messageId, collection, to, tokenId, escrowed)NFT escrow released. Once per id.

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. finalSelector is not its localSelector, so it decrements hopsRemaining, 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:

  1. The transit chain's subgraph. hopForward(id: $inboundMessageId) returns outboundMessageId in one query. The entity is keyed by the inbound id precisely so this is a constant-time lookup. See Subgraph reference.
  2. eth_getLogs on the transit vault, filtered on the indexed inboundMessageId.

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.