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.
- A message lands whose
homeSelectoris not the local selector. - The vault looks up
wrappedTokenOf[homeSelector][homeToken], orwrappedCollectionOf[...]on the NFT mesh. - If zero, it CREATE2-deploys an EIP-1167 minimal proxy over
wrappedImplementation(), callsinitialize(...)on it with the metadata that rode along in the message, writes the three registry mappings, and emitsWrappedDeployedorWrappedCollectionDeployed. - 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
TransferandApprovalevents. There are no mint or burn events of its own. A mint is aTransferfrom the zero address, and a burn is aTransferto it. - Infinite allowance is not decremented.
transferFromskips the subtraction when the allowance istype(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.
- On the wrapper's chain,
homeSelectorOf(wrapper)is non-zero, sobridgeTokensorbridgeNFTscallsvaultBurn. No approval is needed or possible: the vault has mint authority, so an allowance check would add a step without adding security. - The vault ships an order carrying
(homeSelector, homeAsset), the identity, not the wrapper's own address. - On the home chain,
order.homeSelector == localSelector, so the vault releases canonical escrow totoand emitsTokensUnlockedorNFTUnlocked(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.