Building with NFT Shadows
NFT Shadows enable cross-chain NFT ownership through a novel mechanism that replicates ownership states across chains. This allows developers to build applications that interact with NFT ownership on any supported chain, whether it's the NFT's native chain or a shadow chain.
Shadow NFTs maintain ownership consistency across chains through the Beacon contract's cross-chain messaging system.
Core Components
- Beacon.sol
- ShadowNFT.sol
- IShadowCallbackReceiver.sol
The Beacon
The Beacon contract coordinates cross-chain messaging and ownership verification. It:
- Manages LayerZero messaging configuration
- Handles NFT locking/unlocking on native chains
- Coordinates ownership reads across chains
- Maintains shadow-to-base collection mappings
Shadow NFTs
Shadow NFTs are ERC721-compatible tokens that mirror ownership from their native chain. They have two states:
- Locked: Can only be transferred by the Beacon contract, follows canonical ownership
- Unlocked: Behaves like a standard ERC721, can be transferred freely
Integration Patterns
Native Chain Integration
When building on the native chain where the original NFTs exist, you can leverage the delegation system to enable cross-chain ownership control. Here's an example rental system:
contract NFTRental {
IExclusiveDelegateResolver public resolver;
IDelegateRegistry public registry;
mapping(uint256 => uint40) public tokenIdToExpiration;
function rentOut(
address nft,
uint256 tokenId,
uint40 duration
) external {
// Verify contract owns the NFT
require(IERC721(nft).ownerOf(tokenId) == address(this), "Contract does not own NFT");
// Verify NFT is not already rented out
require(tokenIdToExpiration[tokenId] < block.timestamp, "NFT is already rented out");
// Calculate expiration timestamp
uint40 expiration = uint40(block.timestamp) + duration;
// Generate delegation rights with expiration
bytes32 rights = resolver.generateRightsWithExpiration(
bytes24(0), // rights identifier
expiration
);
// Issue delegation to renter
registry.delegateERC721(
renter,
nft,
tokenId,
rights,
true // enable delegation
);
tokenIdToExpiration[tokenId] = expiration;
}
}Shadow Chain Integration
When building on chains where Shadow NFTs exist, you have two approaches for ownership verification:
1. Optimistic Ownership Checks
Best for non-critical operations where slight ownership lag is acceptable:
contract OptimisticIntegration {
INFTShadow public shadowNft;
function doSomethingWithNFT(uint256 tokenId) external {
// Simple ownership check
require(shadowNft.ownerOf(tokenId) == msg.sender, "Not owner");
// Proceed with operation...
}
}Optimistic checks should only be used for low-risk operations as there may be a slight delay in ownership updates across chains.
2. Verified Ownership with Callbacks
For operations requiring guaranteed up-to-date ownership:
contract VerifiedIntegration is IShadowCallbackReceiver {
INFTShadow public shadowNft;
IBeacon public beacon;
struct PendingOperation {
address caller;
uint256 tokenId;
}
mapping(bytes32 => PendingOperation) public pendingOperations;
function doSomethingWithVerifiedOwnership(uint256 tokenId) external payable {
uint256[] memory tokenIds = new uint256[](1);
tokenIds[0] = tokenId;
uint32[] memory eids = new uint32[](1);
eids[0] = sourceChainEid;
uint256 fee = beacon.quoteRead(
address(shadowNft),
tokenIds,
eids,
60000 // callback gas limit
);
require(msg.value >= fee, "Insufficient fee");
bytes32 guid = shadowNft.readWithCallback{value: fee}(
tokenIds,
eids,
60000
);
pendingOperations[guid] = PendingOperation({
caller: msg.sender,
tokenId: tokenId
});
}
}Error Handling and Edge Cases
Read Request Failures
- If a read request fails, the callback will not be executed
- Operations should be designed to be idempotent
- Consider allowing operations to be cancelled after a timeout period
Ownership Transitions
- Shadow ownership can change between read request and callback
- Implement appropriate checks in callback handler
- Consider if operation should still proceed if ownership changed
Gas Considerations
readWithCallback accepts a callbackGasLimit parameter that is used to estimate the gas limit for the callback. This is added as a buffer to the predicted lzReceive gas cost.
// Calculate gas limit based on operation complexity
uint128 baseGas = 60000; // example base gas limit that callback requires to execute
uint128 perNftGas = 15000; // example gas for operations per NFT
uint128 callbackGasLimit = baseGas + (perNftGas * tokenIds.length);Best Practices
-
Ownership Verification
- Use optimistic checks for low-risk operations
- Implement callbacks for high-value transactions
- Consider implementing timeout mechanisms
-
Gas Management
- Scale callback gas limits with operation complexity
- Include buffer for unexpected gas costs
-
State Management
- Store minimal data in pending operation mappings
- Clean up state after callback execution
- Implement operation timeout cleanup
Contracts:
| Contract | Network | Address |
|---|---|---|
| BAYC Shadow | ApeChain | 0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d (opens in a new tab) |
| MAYC Shadow | ApeChain | 0x60e4d786628fea6478f785a6d7e704777c86a7c6 (opens in a new tab) |
| BAKC Shadow | ApeChain | 0xba30e5f9bb24caa003e9f2f0497ad287fdf95623 (opens in a new tab) |