Documentation

Wrapped assets

How ChainHopWrappedToken and ChainHopWrappedNFT are deployed and addressed, how to compute a wrapper address before it exists, and how a round trip unwraps.

On this page

How wrappers come into existence

A wrapper is never deployed by hand and never registered by the owner. The destination vault deploys it, permissionlessly, at the moment an asset first arrives on that chain.

  1. A message lands whose homeSelector is not the local selector.
  2. The vault looks up wrappedTokenOf[homeSelector][homeToken], or wrappedCollectionOf[...] on the NFT mesh.
  3. If zero, it CREATE2-deploys an EIP-1167 minimal proxy over wrappedImplementation(), calls initialize(...) on it with the metadata that rode along in the message, writes the three registry mappings, and emits WrappedDeployed or WrappedCollectionDeployed.
  4. Then it mints.

Deploy and first mint are in the same transaction, in that order.

The implementation contract itself is deployed by the vault's constructor and is bricked. Its constructor sets vault to the dead address, so initialize can never be called on it and only clones are ever live.

Addressing: the identity is (homeSelector, homeAsset)

The CREATE2 salt is keccak256(abi.encode(homeSelector, homeAsset)), and the deployer is the vault. So a wrapper's address is fully determined by:

  • the vault address on the destination chain,
  • the wrapped-implementation address on that chain,
  • the home chain's CCIP selector,
  • the home asset's address.

Nothing about the amount, the sender, the recipient or the time.

Looking one up

// deployed wrapper, or zero
function wrappedTokenOf(uint64 homeSelector, address homeToken) external view returns (address);
function wrappedCollectionOf(uint64 homeSelector, address homeCollection) external view returns (address);
 
// deployed or predicted wrapper. Deterministic before the first bridge.
function predictWrappedToken(uint64 homeSelector, address homeToken) external view returns (address);
function predictWrappedCollection(uint64 homeSelector, address homeCollection) external view returns (address);

Use wrappedTokenOf or wrappedCollectionOf to decide whether the destination will deploy or merely mint. Use the predict pair to show the user the address either way. Both are view calls on the destination vault.

Computing it yourself

predictWrappedToken is the standard EIP-1167 CREATE2 formula and nothing else. The only ChainHop-specific input is the salt.

salt         = keccak256(abi.encode(homeSelector, homeAsset))
initCodeHash = keccak256(EIP-1167 minimal proxy creation code for wrappedImplementation())
wrapper      = CREATE2(deployer = vault, salt, initCodeHash)

In Solidity, OpenZeppelin already has both halves, so there is nothing to transcribe:

import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
 
bytes32 salt = keccak256(abi.encode(homeSelector, homeAsset));
address wrapper = Clones.predictDeterministicAddress(impl, salt, vault);

In viem:

import { encodeAbiParameters, getCreate2Address, keccak256 } from "viem";
 
const salt = keccak256(
  encodeAbiParameters(
    [{ type: "uint64" }, { type: "address" }],
    [homeSelector, homeAsset],
  ),
);
 
// `initCodeHash` is keccak256 of the stock EIP-1167 creation code with
// `impl` spliced into its 20-byte slot. Take that byte sequence from EIP-1167
// or from OpenZeppelin's Clones library rather than retyping it, and validate
// the result against `predictWrappedToken` once.
const wrapper = getCreate2Address({ from: vault, salt, bytecodeHash: initCodeHash });

Prefer the on-chain call. Compute it locally only when you need the answer without an RPC round trip.

Wrapper addresses are identical across the mesh

The vault and implementation addresses use the same CREATE2 inputs on each supported chain. A given home asset therefore gets the same wrapper address on every destination. Confirm it with wrappedTokenOf / predictWrappedToken or the equivalent collection getters on the destination vault.

ChainHopWrappedToken

A minimal, hook-free ERC20. Metadata lives in storage rather than immutables because clones have no constructor.

function name() external view returns (string);
function symbol() external view returns (string);
function decimals() external view returns (uint8);
function vault() external view returns (address);        // also the init guard
function totalSupply() external view returns (uint256);
function balanceOf(address) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
 
function mint(address to, uint256 amount) external;              // onlyVault
function vaultBurn(address from, uint256 amount) external;       // onlyVault
function initialize(string name_, string symbol_, uint8 decimals_, address vault_) external;

Notes an integrator will care about:

  • Only Transfer and Approval events. There are no mint or burn events of its own. A mint is a Transfer from the zero address, and a burn is a Transfer to it.
  • Infinite allowance is not decremented. transferFrom skips the subtraction when the allowance is type(uint256).max.
  • Reverts are custom errors, not strings: NotVault(), AlreadyInitialized(), ZeroAddress(), InsufficientBalance(), InsufficientAllowance().
  • No permit, no hooks, no fee on transfer. Wrapping a fee-on-transfer token produces a wrapper with none of that behavior. The fee was already taken on the home chain at lock time.
  • Decimals are inherited from the home asset, including odd values.

ChainHopWrappedNFT

A full collection contract, not a stub. Beyond ERC721 and Metadata:

// enumerable
function totalSupply() external view returns (uint256);
function tokenByIndex(uint256 index) external view returns (uint256);
function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256);
function tokensOfOwner(address owner) external view returns (uint256[] memory);
 
// collection ownership. Defaults to the vault's owner, transferable.
function owner() external view returns (address);
function transferOwnership(address newOwner) external;              // onlyCollectionOwner
 
// owner controls
function setTransferValidator(address validator) external;          // onlyCollectionOwner
function getTransferValidator() external view returns (address);
function setDefaultRoyalty(address receiver, uint96 bps) external;  // onlyCollectionOwner, bps <= 1000
function royaltyInfo(uint256 tokenId, uint256 salePrice) external view returns (address, uint256);
function setBaseURI(string baseURI_, string suffix_) external;      // onlyCollectionOwner
 
// vault only
function mint(address to, uint256 tokenId, string uri) external;
function vaultBurn(address from, uint256 tokenId) external;

supportsInterface reports ERC165, ERC721, ERC721Metadata, ERC721Enumerable and ERC2981.

tokenURI on the destination

function tokenURI(uint256 tokenId) external view returns (string memory);

Two modes:

  • No base URI set, the default: returns the snapshot that was shipped in the bridge message and stored at mint, capped at 256 bytes.
  • Base URI set: returns baseURI + decimal(tokenId) + uriSuffix, ignoring the snapshots entirely. Setting an empty base URI reverts to snapshots.

tokenURI reverts NonexistentToken(tokenId) for an id that is not currently on this chain, which for a wrapper is the normal state of any id that has been bridged away. Treat that revert as "not here", not as an error.

Because the collection owner can flip modes at any time, always read tokenURI() live. Do not cache the bridged snapshot for display.

Ownership defaults

owner() returns the explicitly set collection owner if there is one, and otherwise falls through to IOwnableVault(vault).owner(), the vault's owner. So until a bridged project calls transferOwnership, whoever owns the vault controls that wrapper's royalties, base URI and transfer validator.

Mint and burn remain vault-only regardless of collection ownership. Until ownership is transferred, however, the vault owner can change royalties, the base URI and the transfer validator.

The round trip

Bridging a wrapper back to its home chain unwraps it.

  1. On the wrapper's chain, homeSelectorOf(wrapper) is non-zero, so bridgeTokens or bridgeNFTs calls vaultBurn. No approval is needed or possible: the vault has mint authority, so an allowance check would add a step without adding security.
  2. The vault ships an order carrying (homeSelector, homeAsset), the identity, not the wrapper's own address.
  3. On the home chain, order.homeSelector == localSelector, so the vault releases canonical escrow to to and emits TokensUnlocked or NFTUnlocked (one per id) rather than minting.

Burning to a third chain works the same way on the source side, but the destination mints its own wrapper for the same (homeSelector, homeAsset). The canonical escrow never moves off the home chain, and the wrapper's identity is preserved: a wrapper on chain B and a wrapper on chain C for the same home asset are interchangeable claims on the same escrow.

For NFTs this means an id exists in circulation on exactly one chain at a time. On its home chain it is either in a holder's wallet or in vault escrow. Elsewhere it is a wrapper mint. ownerOf(id) reverting on a wrapper means the id is currently somewhere else.