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.
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 anownerOf(id) == address(this)check. A collection that silently no-ops its transfer cannot fake a lock. The vault revertsEscrowMissingToken(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
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().
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; // nonReentrantclaimNFTs 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 whichtokenURIreturnsbaseURI + tokenId + uriSuffixand the snapshots are ignored. So always readtokenURI()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 viatransferOwnership. Until that handover, whoever owns the vault controls royalties, base URI and the transfer validator on every wrapped collection.
Reverts specific to this 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.