Documentation

NFT vault

ChainHopNFTVault, the ERC721 mesh, its batch limit, per-id delivery semantics, wrapped-collection behavior and tokenURI handling on the destination chain.

On this page

Addresses

One ChainHopNFTVault per chain: a different contract from the token vault, but at the same address on all seven chains.

ContractAddressApeScan
ChainHopNFTVaultApeChain0xAC87…516c
Identical on ApeChain, Base, Ethereum, Robinhood Chain, Arbitrum One, BNB Chain and HyperEVM. Each chain's own explorer will show it.

ChainHopNFTVault is the same mesh design as the token vault over ERC721, but it is a separate node network: its own peers, its own nextHopOf table, its own pause flag. Never call it with a token vault address or assume the two meshes share routing state. Read each vault independently.

Registry reads

function homeSelectorOf(address collection) external view returns (uint64);
function homeCollectionOf(address collection) external view returns (address);
function wrappedCollectionOf(uint64 homeSelector, address homeCollection) external view returns (address);
function predictWrappedCollection(uint64 homeSelector, address homeCollection) external view returns (address);
function wrappedImplementation() external view returns (address);
function maxNftBatch() external view returns (uint256);
function nftClaims(address collection, uint256 tokenId) external view returns (address);

Same discriminator rule as the token mesh. homeSelectorOf(collection) == 0 means canonical here.

The batch limit: read it, do not hardcode it

function maxNftBatch() external view returns (uint256);

The value is owner-settable via setMaxNftBatch(uint256), which emits MaxNftBatchSet(uint256). Read maxNftBatch() from the source vault and enforce it before quoting or submitting a batch.

Bridging out

function bridgeNFTs(
    uint64 finalSelector,
    address collection,
    uint256[] calldata tokenIds,
    address to
) external payable returns (bytes32 messageId);
 
function quoteBridgeNFTs(
    uint64 finalSelector,
    address collection,
    uint256[] calldata tokenIds,
    address to
) external view returns (uint256);

nonReentrant, whenNotPaused. tokenIds.length must be at least 1 and at most maxNftBatch(), or BadBatch(size, max).

Approval is needed for canonical collections only: setApprovalForAll(nftVault, true), or a per-id approve. Wrappers need nothing.

The order of operations inside bridgeNFTs matters and is deliberate. Metadata and per-token URIs are read before anything is burned, because burning a wrapped id deletes its stored URI. Then:

  • Wrapper: vaultBurn(msg.sender, id) per id.
  • Canonical: transferFrom(msg.sender, vault, id) per id, followed by an ownerOf(id) == address(this) check. A collection that silently no-ops its transfer cannot fake a lock. The vault reverts EscrowMissingToken(id).

Plain transferFrom is used in both directions on purpose: it works with blacklist-style ERC721C transfer validators without the vault needing to be whitelisted.

The quote scales with the batch, because the payload carries a tokenURI string per id. A different set of ids is genuinely a different price. Re-quote whenever the batch changes, and deduplicate ids first, because a repeated id reverts the whole batch.

The wire format

ChainHopLib.sol
struct NFTOrder {
    uint64    homeSelector;
    address   homeCollection;
    address   to;
    uint256[] tokenIds;
    string    name;
    string    symbol;
    string[]  tokenURIs;   // aligned 1:1 with tokenIds
}

The destination rejects an order whose tokenIds is empty, or whose tokenURIs.length differs from tokenIds.length, with BadOrder().

FieldRead fromCapFallback
nameIERC721Metadata.name()64 bytes"ChainHop NFT"
symbolIERC721Metadata.symbol()32 bytes"chNFT"
tokenURIs[i]IERC721Metadata.tokenURI(id)256 bytesempty string

The 256-byte cap truncates long tokenURIs silently

A URI longer than 256 bytes is truncated, not rejected. For an ipfs:// or https:// URI that is usually fine. For a collection that returns a base64 data:application/json URI, truncation destroys the metadata irrecoverably on the destination, and nothing reverts to tell you.

Measure the UTF-8 byte length of tokenURI(id) before bridging and warn the user when it exceeds the cap.

Delivery: per id, not per batch

The single most important difference from the token mesh.

Mint side. One WrappedNFTsMinted(messageId, wrapped, homeSelector, homeCollection, to, tokenIds) for the whole batch, preceded by WrappedCollectionDeployed(...) on the collection's first arrival.

Unlock side. One NFTUnlocked(messageId, collection, to, tokenId, escrowed) per id, each with its own escrowed flag, because each id is released in its own try/catch. A single batch can deliver id 3 and escrow id 7, for example if a transfer validator blocks that specific transfer.

Any indexer or UI that assumes a batch succeeds or fails atomically on the unlock path is wrong. The subgraph models this correctly: a BridgeDelivery on an NFT unlock is keyed <messageId>:<tokenId>. See Subgraph reference.

Escrowed ids become claims.

function nftClaims(address collection, uint256 tokenId) external view returns (address); // zero address means none
function claimNFTs(address collection, uint256[] calldata tokenIds, address recipient) external; // nonReentrant

claimNFTs reverts NothingToClaim() if any id in the batch is not credited to msg.sender, so filter first. Note that nftClaims is assigned rather than accumulated on each escrowed delivery, which is safe only because an id can be escrowed in one place at a time.

Gas on the destination

function defaultGasLimit() external view returns (uint256);

The NFT vault overrides the messenger's default because its worst case, a first arrival with a clone deploy plus a full batch of URI-storing mints, is far heavier than a token mint. Read defaultGasLimit() rather than assuming a constant.

Direct transfers do not bridge

The vault implements onERC721Received so that safeTransferFrom and _safeMint into it succeed. That is plumbing, not an entry point. An NFT sent directly to a vault address is not bridged, and is recoverable only through the owner's 72-hour timelocked rescue. Never present a vault address as a deposit address.

Wrapped collections

The wrapper is ChainHopWrappedNFT. See Wrapped assets for the full surface. The parts that affect how a destination NFT behaves:

  • Ids mirror canonical ids 1:1. No remapping.
  • tokenURI(id) returns the snapshot taken at bridge time, until the collection owner sets a base URI, after which tokenURI returns baseURI + tokenId + uriSuffix and the snapshots are ignored. So always read tokenURI() live. Never cache what was bridged.
  • tokensOfOwner(address) returns a holder's complete id list in one call. Wrappers are ERC721Enumerable plus this convenience method. Canonical collections promise none of that.
  • ERC2981 royalties and an ERC721C-style validator hook exist and are owner-settable. The validator is off by default. Mint and burn always bypass it, so bridging can never be policy-blocked.
  • owner() defaults to the vault's owner and is transferable to the bridged project's team via transferOwnership. Until that handover, whoever owns the vault controls royalties, base URI and the transfer validator on every wrapped collection.

Reverts specific to this vault

ErrorWhen
BadBatch(uint256 size, uint256 max)empty batch, or over maxNftBatch()
BadOrder()inbound order has no ids, or tokenURIs.length does not equal tokenIds.length
EscrowMissingToken(uint256 tokenId)after transferFrom, the vault does not own the id
NothingToClaim()an id in claimNFTs is not credited to msg.sender
ZeroAddress(), BridgingPausedErr(), OnlySelf(), CloneFailed(), DeliveryOutOfGas(), RescueUnknownOrPending, RescueAlreadyQueuedas on the token vault

Plus everything from ChainHopMessenger and the inherited Ownable and ReentrancyGuard errors, listed on Token vault. Note that a canonical transferFrom failure surfaces as that collection's own revert, not a ChainHop error: ERC721 transfers are not wrapped in SafeERC20.

Rescue takes an id array rather than an amount: rescueId(collection, to, uint256[] tokenIds), queueRescue(...), executeRescue(...), with RescueQueued(id, collection, to, tokenIds, executableAt). The 72-hour RESCUE_DELAY is the same as the token vault's, and so is the config timelock over peers, routes and the router.