Documentation

Integration recipes

Working viem and Solidity for quoting, approving, bridging tokens and NFT batches, polling a transfer to completion, and listing and paying out claims.

On this page

The TypeScript examples use viem. They assume you have created a public client for each chain and loaded the vault addresses and ABIs shown in these docs.

setup.ts
import { createPublicClient, http } from "viem";
import { TOKEN_VAULT_ABI, NFT_VAULT_ABI, ERC20_META_ABI, ERC721_META_ABI } from "./abis";
 
const src = { name: "ApeChain", selector: APECHAIN_SELECTOR, tokenVault: TOKEN_VAULT, nftVault: NFT_VAULT };
const dest = { name: "Base", selector: BASE_SELECTOR, tokenVault: TOKEN_VAULT, nftVault: NFT_VAULT };
const vaultOf = (chain, kind: "token" | "nft") => kind === "token" ? chain.tokenVault : chain.nftVault;
const srcClient = createPublicClient({ transport: http() });

Any viem PublicClient for the source chain works.

Recipe 1: quote a bridge fee

const fee = await srcClient.readContract({
  address: vaultOf(src, "token"),
  abi: TOKEN_VAULT_ABI,
  functionName: "quoteBridgeTokens",
  args: [dest.selector, token, amount, recipient],
});
// wei of the source chain's native coin. First hop plus protocol fee.

A reverting quote is information. SelfRoute() means source and destination are the same chain. RouteNotSet(finalSelector) means the owner has not wired that destination, so hide it rather than letting the user try.

Quote before every send. A small buffer can absorb fee drift before inclusion; the vault refunds any excess.

const padFee = (fee: bigint) => fee + fee / 10n;

Recipe 2: approve and bridge an ERC20

import { maxUint256, parseEventLogs } from "viem";
 
const vault = vaultOf(src, "token");
 
// 1. Canonical or wrapper?
const homeSel = await srcClient.readContract({
  address: vault, abi: TOKEN_VAULT_ABI, functionName: "homeSelectorOf", args: [token],
});
const needsApproval = homeSel === 0n;
 
// 2. Approve only if canonical, and only if the allowance is short.
if (needsApproval) {
  const allowance = await srcClient.readContract({
    address: token, abi: ERC20_META_ABI, functionName: "allowance", args: [owner, vault],
  });
  if (allowance < amount) {
    const hash = await walletClient.writeContract({
      address: token, abi: ERC20_META_ABI, functionName: "approve", args: [vault, maxUint256],
    });
    await srcClient.waitForTransactionReceipt({ hash });
  }
}
 
// 3. Quote, then send.
const fee = await srcClient.readContract({
  address: vault, abi: TOKEN_VAULT_ABI, functionName: "quoteBridgeTokens",
  args: [dest.selector, token, amount, recipient],
});
 
const hash = await walletClient.writeContract({
  address: vault, abi: TOKEN_VAULT_ABI, functionName: "bridgeTokens",
  args: [dest.selector, token, amount, recipient],
  value: padFee(fee),
});
const receipt = await srcClient.waitForTransactionReceipt({ hash });
 
// 4. The messageId and the amount actually bridged come from the event.
const [ev] = parseEventLogs({ abi: TOKEN_VAULT_ABI, eventName: "TokensBridgedOut", logs: receipt.logs })
  .filter((log) => log.address.toLowerCase() === vault.toLowerCase());
 
const messageId = ev?.args.messageId;   // hop 1's CCIP id
const bridged = ev?.args.amount;        // honest about fee-on-transfer tokens

Never prompt for an approval on a wrapper

If homeSelectorOf(token) is non-zero there is no allowance to grant and no approve to call. The wrapper is burned by the vault directly, which has unconditional mint authority over it. Prompting for an approval on a wrapper produces a transaction that costs gas and does nothing.

Recipe 3: bridge an NFT batch

const nftVault = vaultOf(src, "nft");
 
// 1. Read the current cap from the source vault.
const maxBatch = await srcClient.readContract({
  address: nftVault, abi: NFT_VAULT_ABI, functionName: "maxNftBatch",
});
if (BigInt(tokenIds.length) > maxBatch) throw new Error(`Max ${maxBatch} ids per bridge`);
 
// 2. Canonical collections need setApprovalForAll; wrappers need nothing.
const homeSel = await srcClient.readContract({
  address: nftVault, abi: NFT_VAULT_ABI, functionName: "homeSelectorOf", args: [collection],
});
if (homeSel === 0n) {
  const approved = await srcClient.readContract({
    address: collection, abi: ERC721_META_ABI, functionName: "isApprovedForAll", args: [owner, nftVault],
  });
  if (!approved) {
    const hash = await walletClient.writeContract({
      address: collection, abi: ERC721_META_ABI, functionName: "setApprovalForAll", args: [nftVault, true],
    });
    await srcClient.waitForTransactionReceipt({ hash });
  }
}
 
// 3. Pre-flight each id: it must exist here and be yours, and its URI must fit.
for (const tokenId of tokenIds) {
  const [ownerOf, uri] = await Promise.allSettled([
    srcClient.readContract({ address: collection, abi: ERC721_META_ABI, functionName: "ownerOf", args: [tokenId] }),
    srcClient.readContract({ address: collection, abi: ERC721_META_ABI, functionName: "tokenURI", args: [tokenId] }),
  ]);
  if (ownerOf.status !== "fulfilled") throw new Error(`Id ${tokenId} is not on ${src.name}`);
  if (ownerOf.value.toLowerCase() !== owner.toLowerCase()) throw new Error(`Id ${tokenId} is not yours`);
  if (uri.status === "fulfilled" && new TextEncoder().encode(uri.value).length > 256) {
    // The vault truncates at 256 bytes. Warn before, not after.
  }
}
 
// 4. Quote for this exact batch. The fee scales with the URI payload.
const fee = await srcClient.readContract({
  address: nftVault, abi: NFT_VAULT_ABI, functionName: "quoteBridgeNFTs",
  args: [dest.selector, collection, tokenIds, recipient],
});
 
const hash = await walletClient.writeContract({
  address: nftVault, abi: NFT_VAULT_ABI, functionName: "bridgeNFTs",
  args: [dest.selector, collection, tokenIds, recipient],
  value: padFee(fee),
});
const receipt = await srcClient.waitForTransactionReceipt({ hash });
const [ev] = parseEventLogs({ abi: NFT_VAULT_ABI, eventName: "NFTsBridgedOut", logs: receipt.logs })
  .filter((log) => log.address.toLowerCase() === nftVault.toLowerCase());
const messageId = ev?.args.messageId;

Deduplicate ids before sending. A repeated id reverts the whole batch.

Recipe 4: track a transfer to completion

Start with the messageId emitted by TokensBridgedOut or NFTsBridgedOut. For each transit hop, query the MessageForwarded event by its indexed inboundMessageId; the event gives you the next hop's outboundMessageId. The subgraph's hopForward(id:) query provides the same lookup.

A transfer is complete when the destination vault emits WrappedMinted, TokensUnlocked, WrappedNFTsMinted or NFTUnlocked for the final hop's message id. Link each message id to Chainlink's CCIP explorer so users can inspect a delayed or failed hop and request manual execution when available.

Recipe 5: list a wallet's open claims and claim one

RPC can answer "does this wallet have a claim on this asset". Only the subgraph can answer "which assets". Do both: the subgraph for discovery, RPC for the amount you are about to claim.

query OpenClaims($claimant: Bytes!) {
  tokenClaims(where: { claimant: $claimant, amount_gt: 0 }, first: 50) {
    token
    amount
  }
  nftClaims(where: { claimant: $claimant }, first: 50) {
    collection
    tokenId
  }
}

Query the endpoint listed for the chain in question. There is no cross-chain view.

// Confirm on chain, then claim.
const credit = await client.readContract({
  address: vaultOf(chain, "token"), abi: TOKEN_VAULT_ABI,
  functionName: "claims", args: [token, claimant],
});
if (credit > 0n) {
  await walletClient.writeContract({
    address: vaultOf(chain, "token"), abi: TOKEN_VAULT_ABI,
    functionName: "claimTokens", args: [token, credit, recipient],
  });
}
 
// NFTs: filter to the ids actually credited to you, or the batch reverts.
const claimants = await Promise.all(
  tokenIds.map((tokenId) =>
    client.readContract({
      address: vaultOf(chain, "nft"), abi: NFT_VAULT_ABI,
      functionName: "nftClaims", args: [collection, tokenId],
    }),
  ),
);
const mine = tokenIds.filter((_, i) => claimants[i].toLowerCase() === claimant.toLowerCase());
if (mine.length > 0) {
  await walletClient.writeContract({
    address: vaultOf(chain, "nft"), abi: NFT_VAULT_ABI,
    functionName: "claimNFTs", args: [collection, mine, recipient],
  });
}

recipient is yours to choose. That is the point of the claim path, since the original delivery failed for a reason that may be tied to the address.

Recipe 6: a contract that bridges on a user's behalf

BridgeForwarder.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
 
interface IChainHopTokenVault {
    function quoteBridgeTokens(uint64 finalSelector, address token, uint256 amount, address to)
        external view returns (uint256);
    function bridgeTokens(uint64 finalSelector, address token, uint256 amount, address to)
        external payable returns (bytes32 messageId);
    function homeSelectorOf(address token) external view returns (uint64);
    function bridgingPaused() external view returns (bool);
    function nextHopOf(uint64 finalSelector) external view returns (uint64);
}
 
/// Pulls a user's ERC20 and bridges it to `to` on `finalSelector`.
/// The caller supplies the native fee as msg.value.
contract BridgeForwarder {
    using SafeERC20 for IERC20;
 
    error NotRouted();
    error Paused();
    error FeeShort(uint256 required, uint256 provided);
 
    IChainHopTokenVault public immutable vault;
 
    constructor(IChainHopTokenVault vault_) {
        vault = vault_;
    }
 
    /// The vault refunds any excess msg.value to THIS contract, so it must be
    /// able to receive native. Without this, an over-payment reverts the
    /// whole bridge with RefundFailed().
    receive() external payable {}
 
    function bridgeFor(
        uint64 finalSelector,
        address token,
        uint256 amount,
        address to
    ) external payable returns (bytes32 messageId) {
        if (vault.bridgingPaused()) revert Paused();
        if (vault.nextHopOf(finalSelector) == 0) revert NotRouted();
 
        // Wrapped assets are burned from THIS contract's balance and need no
        // approval; canonical assets are pulled in and approved to the vault.
        bool wrapped = vault.homeSelectorOf(token) != 0;
 
        uint256 before = IERC20(token).balanceOf(address(this));
        IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
        uint256 received = IERC20(token).balanceOf(address(this)) - before;
 
        if (!wrapped) {
            IERC20(token).forceApprove(address(vault), received);
        }
 
        uint256 fee = vault.quoteBridgeTokens(finalSelector, token, received, to);
        if (msg.value < fee) revert FeeShort(fee, msg.value);
 
        messageId = vault.bridgeTokens{value: msg.value}(finalSelector, token, received, to);
 
        // Return whatever the vault refunded.
        if (address(this).balance > 0) {
            (bool ok, ) = msg.sender.call{value: address(this).balance}("");
            require(ok, "refund");
        }
    }
}

Three things this example is showing on purpose.

  • receive() is mandatory if you might over-pay. The vault refunds msg.value - totalFee to msg.sender with a raw call, and a failed refund reverts the bridge with RefundFailed().
  • Measure the balance delta before approving, so a fee-on-transfer token does not leave you approving more than you hold.
  • to is on the destination chain. If you pass a contract address, that contract must exist at the same address on the destination. There is no callback and no hook on delivery: the destination vault mints or transfers to to and stops. Anything you want to happen next has to be triggered separately on that chain.

For NFTs the shape is the same with setApprovalForAll(address(vault), true) and bridgeNFTs, plus an onERC721Received implementation if you take custody by safeTransferFrom.