什么是跨链NFT?
跨链 NFT 是一种可以存在于任何区块链上的智能合约,从而无需用户明确知道他们当前正在使用哪个区块链。
通常,NFT 从一个链移动到另一个链是件引人注目的事,但这一简单事实却带来了一个更大的问题,即 Web3 中间件基础设施的可靠性和安全性。在一个无缝的跨链世界中,将数字资产从一个链移动到另一个链应该像在同一区块链上提交交易一样正常。
当 NFT 从一个区块链转移到另一个区块链时,它就成为跨链 NFT。
跨链NFT是如何工作的?
从总体上看,NFT是区块链上的数字代币,具有与链上其他任何代币不同的唯一标识符。
本质上,任何NFT都是单一区块链上的智能合约实现的。智能合约可以说是这个等式中最重要的部分,因为它控制着NFT的实现:铸造的数量、铸造的时间、分发所需满足的条件等。这意味着任何跨链 NFT 的实现至少需要在两个区块链上部署两个智能合约,并且它们之间需要互联。
这就是跨链NFT看起来样子——存在于多个区块链上的等效 NFT。
考虑到这一点,跨链 NFT 可以通过以下三种方式实现:
销毁与铸造:一个NFT所有者将他的NFT放入源链上的智能合约并将其销毁,从而将该NFT从该区块链中移除。一旦完成以上过程,另一个等效的NFT会从目标区块链上的对应智能合约中被创建。这个过程是可逆的。
锁定与铸造:NFT所有者将其 NFT锁定在源链上的智能合约中,并在目标区块链上创建一个等效的 NFT。当所有者想要将其NFT移回到源链上时,他们可以销毁目标链上的NFT,从而解锁源区块链上的NFT。
锁定与解锁:同一个NFT系列在多个区块链上铸造。NFT所有者可以在源区块链上锁定其NFT,以解锁目标区块链上的等效NFT。这意味着即使在多个区块链上存在着该NFT的多个实例,同一时间也只有一个NFT是活跃可使用的。
在本次大师班中,我们将实现销毁与铸造机制。
在每种情况中都需要一个跨链消息协议来从一个区块链向另一个区块链发送数据指令。
如何使用 Chainlink CCIP 创建跨链 NFT?
使用Chainlink CCIP构建跨链NFT之前,首先让我们了解一下可以用Chainlink CCIP做什么。通过 Chainlink CCIP,我们可以:
CCIP的发送者可以是:
CCIP的接受者可以是:
任何实现了CCIPReceiver.sol
的智能合约
实现销毁-铸造模型
通过Chainlink CCIP实现销毁-铸造模型,我们将在跨链转移功能中销毁源区块链(Arbitrum Sepolia)上的NFT,并使用Chainlink CCIP发送跨链消息。我们需要编码收发地址、NFT的 tokenId和tokenURI,以便在目标区块链接收到跨链消息后能够铸造完全相同的 NFT。
Arbitrum Sepolia 端:
function crossChainTransferFrom(
address from,
address to,
uint256 tokenId,
uint64 destinationChainSelector,
PayFeesIn payFeesIn
)
external
nonReentrant
onlyEnabledChain(destinationChainSelector)
returns (bytes32 messageId)
{
string memory tokenUri = tokenURI(tokenId);
// 在源区块链上销毁token
_burn(tokenId);
Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
receiver: abi.encode(
s_chains[destinationChainSelector].xNftAddress
),
// 为了在目标区块链上铸造而编码一些细节参数
data: abi.encode(from, to, tokenId, tokenUri),
tokenAmounts: new Client.EVMTokenAmount[](0),
extraArgs: s_chains[destinationChainSelector].ccipExtraArgsBytes,
feeToken: payFeesIn == PayFeesIn.LINK
? address(i_linkToken)
: address(0)
});
}
Ethereum Sepolia端:
function ccipReceive(
Client.Any2EVMMessage calldata message
)
external
virtual
override
onlyRouter
nonReentrant
onlyEnabledChain(message.sourceChainSelector)
onlyEnabledSender(
message.sourceChainSelector,
abi.decode(message.sender, (address))
)
{
(
address from,
address to,
uint256 tokenId,
string memory tokenUri
) = abi.decode(message.data, (address, address, uint256, string));
_safeMint(to, tokenId);
_setTokenURI(tokenId, tokenUri);
}
这种设计允许跨链双向转移,即使用完全相同的代码和销毁-铸造机制从Ethereum Sepolia返回到Arbitrum Sepolia。
NFT的元数据
对于这个跨链NFT,我们将使用托管在IPFS上的四个Chainlink Warriors作为元数据。
开发最佳实践
本次练习中,我们将尝试遵循一些CCIP最佳实践。整个完整列表应参考Chainlink官方文档。
验证源链、目标链、发送者和接收者地址
在本次练习中,确保跨链消息在跨链 NFT 智能合约之间发送是至关重要的。为此,我们需要使用 CCIP 链选择器在不同区块链上跟踪这些地址的记录。
struct XNftDetails {
address xNftAddress;
bytes ccipExtraArgsBytes;
}
mapping(uint64 destChainSelector => XNftDetails xNftDetailsPerChain)
public s_chains;
modifier onlyEnabledChain(uint64 _chainSelector) {
if (s_chains[_chainSelector].xNftAddress == address(0))
revert ChainNotEnabled(_chainSelector);
_;
}
modifier onlyEnabledSender(uint64 _chainSelector, address _sender) {
if (s_chains[_chainSelector].xNftAddress != _sender)
revert SenderNotEnabled(_sender);
_;
}
function enableChain(
uint64 chainSelector,
address xNftAddress,
bytes memory ccipExtraArgs
) external onlyOwner onlyOtherChains(chainSelector) {
s_chains[chainSelector] = XNftDetails({
xNftAddress: xNftAddress,
ccipExtraArgsBytes: ccipExtraArgs
});
emit ChainEnabled(chainSelector, xNftAddress, ccipExtraArgs);
}
function disableChain(
uint64 chainSelector
) external onlyOwner onlyOtherChains(chainSelector) {
delete s_chains[chainSelector];
emit ChainDisabled(chainSelector);
}
验证Router地址
在目标链上的合约中实现 ccipReceive
方法时,验证 msg.sender
是否为正确的Router地址。此验证确保只有Router合约可以调用接收合约上的 ccipReceive
函数,并为希望限制哪些账户可以调用ccipReceive
的开发人员使用。
设置gasLimit
gasLimit
指定了CCIP在目标区块链上的合约中执行 ccipReceive()
可以消耗的最大gas量。它是确定发送消息费用的主要因素。未使用的 gas不会退还。
extraArgs
是为了兼容未来的 CCIP 升级。为了获得这个特性,请确保extraArgs
在生产部署中是可变的。这允许您在链下构建它,并将其传递给函数调用或存储在可以按需更新的变量中。
如果extraArgs
为空值,系统将默认 gasLimit 为 200,000。 为了使extraArgs
可变,请在前面提及的enableChain
函数中设置它们。你可以创建一个合约作为工具来计算要传递的bytes值
,如下所示:
// EncodeExtraArgs.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
contract EncodeExtraArgs {
// 以下是一个使用存储的简单示例(所有消息使用相同的参数),该示例允许在不升级dapp的情况下添加新选项。
// 请注意,额外参数是由链种类决定的(比如,gasLimit是EVM特有的等),并且始终向后兼容,即升级是可选择的。
// 我们可以在链下计算V1 extraArgs:
// Client.EVMExtraArgsV1 memory extraArgs = Client.EVMExtraArgsV1({gasLimit: 300_000});
// bytes memory encodedV1ExtraArgs = Client._argsToBytes(extraArgs);
// 如果V2增加了一个退款功能,可按照以下方式计算V2 extraArgs并用新的extraArgs更新存储:
// Client.EVMExtraArgsV2 memory extraArgs = Client.EVMExtraArgsV2({gasLimit: 300_000, destRefundAddress: 0x1234});
// bytes memory encodedV2ExtraArgs = Client._argsToBytes(extraArgs);
// 如果不同的消息需要不同的选项,如:gasLimit不同,可以简单地基于(chainSelector, messageType)而不是只基于chainSelector进行存储。
function encode(
uint256 gasLimit
) external pure returns (bytes memory extraArgsBytes) {
Client.EVMExtraArgsV1 memory extraArgs = Client.EVMExtraArgsV1({
gasLimit: gasLimit
});
extraArgsBytes = Client._argsToBytes(extraArgs);
}
}