练习 3: 使用Chainlink Local做跨链合约的测试

您可能已经注意到,由于存在一些额外的摩擦点——例如设置钱包、获取测试网代币、等待跨链交易完成等,直接在测试网络上使用 CCIP 构建并不理想。这是因为测试网络用于测试,而本地环境(如 Foundry、Hardhat 或 Remix IDE)用于构建和单元测试。

为了解决这个问题,我们创建了 Chainlink Local - the Chainlink CCIP Local Simulator。在本章中,您将学习如何在本地模拟跨链交易,并以比在测试网络上工作快 2000 倍的速度使用 Chainlink CCIP 构建项目!

Chainlink Local 是一个可安装的依赖项,例如 OpenZeppelin。它提供了一个工具(Chainlink Local 模拟器),开发人员可以将其导入到他们的 Foundry、Hardhat 或 Remix IDE 项目中。这个工具在本地运行 Chainlink CCIP,这意味着开发人员可以在本地环境中快速探索、设计原型和迭代 CCIP dApps,只有在准备好在实际环境中测试时再将项目移动至测试网。

最重要的是,用 Chainlink Local 测试的智能合约可以在不做任何修改的情况下部署到测试网络(假设通过构造函数传入了网络特定的合约地址,如 Router 合约和 LINK 代币地址)。

要查看更详细的文档和更多示例,请访问 Chainlink Local DocumentationChainlink Local YouTube Playlist

本地模式 vs 分叉模式

模拟器支持两种模式:

  • 本地模式:使用在本地运行的开发区块链节点上的模拟合约,运行在 localhost 上。

  • 分叉模式:使用已部署的 Chainlink CCIP 合约,通过多个分叉网络进行工作。

在这个示例中,我们将使用分叉模式

在作业中,您必须使用本地模式

本地模式

在本地模拟模式下工作时,模拟器会将一组智能合约预部署到一个空白的 Hardhat/Anvil 网络的EVM 状态,并通过调用 configuration() 函数公开它们的详细信息。尽管应该存在两个 Router 合约(sourceRouterdestinationRouter),开发人员通过这两个不同的 Router 路由跨链消息,但在本地模式中它们都是在本地区块链节点上运行的同一个合约。

分叉模式

在分叉模式下,您需要创建多个本地运行的区块链网络(您需要一个归档节点,该节点具有固定块中的历史网络状态且您已经从中进行本地分叉 - 请参见此处)并与官方 Chainlink 文档中提供的合约地址进行交互。

代码时间: 为我们的XNFT.sol合约编写单元测试

完整的例子参见:https://github.com/smartcontractkit/ccip-cross-chain-nft

创建一个新的Foundry工程

运行以下命令创建一个新的Foundry工程:

forge init

如果命令执行失败,请确保您已安装了Foundry

安装必要的依赖项

接下来,我们需要安装以下依赖项。如果您不想在每次安装后提交更改,请在运行以下每个命令后附加--no-commit 标志。

@chainlink/contracts

forge install smartcontractkit/chainlink-brownie-contracts

@chainlink/contracts-ccip

forge install smartcontractkit/ccip@b06a3c2eecb9892ec6f76a015624413fffa1a122

@openzeppelin/contracts

forge install OpenZeppelin/openzeppelin-contracts

@chainlink/local

forge install smartcontractkit/chainlink-local

然后在您的 foundry.tomlremappings.txt 文件中设置以下 remappings:

# foundry.toml
[profile.default]
src = "src"
out = "out"
test = "test"
libs = ["lib"]
solc = '0.8.24'

remappings = [
    '@chainlink/contracts-ccip=lib/ccip/contracts',
    '@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/',
    '@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/',
    '@chainlink/local/=lib/chainlink-local/',
]

删除 src/Counter.soltest/Counter.t.solscript/Counter.s.sol 文件。在 src 文件夹中创建一个新的 XNFT.sol 文件,并粘贴上一页/练习中的 XNFT 智能合约内容。运行 forge build 来编译该合约。如果您正确安装了依赖项会编译成功。

配置Ethereum Sepolia和Arbitrum Sepolia的RPC URLs

通过复制 .env.example 文件创建一个新文件并将其命名为 .env。在其中加入 Ethereum Sepolia 和 Arbitrum Sepolia 的 RPC URL。这里可以使用本地归档节点或提供归档数据的服务,例如 InfuraAlchemy

ETHEREUM_SEPOLIA_RPC_URL=""
ARBITRUM_SEPOLIA_RPC_URL=""

然后在您的 foundry.toml 文件中添加 rpc_endpoints 部分。其最终版如下所示:

# foundry.toml
[profile.default]
src = "src"
out = "out"
test = "test"
libs = ["lib"]
solc = '0.8.24'

remappings = [
    '@chainlink/contracts-ccip=lib/ccip/contracts',
    '@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/',
    '@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/',
    '@chainlink/local/=lib/chainlink-local/',
]

[rpc_endpoints]
ethereumSepolia = "${ETHEREUM_SEPOLIA_RPC_URL}"
arbitrumSepolia = "${ARBITRUM_SEPOLIA_RPC_URL}"

# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options

在上一个练习中,我们在两个不同的测试网络上执行了 7 个步骤:从部署、铸造、转移 XNFT 到另一个账户。使用 Chainlink Local,您可以编写一个测试文件在本地环境中执行相同的操作。测试中使用与测试网络上完全相同的 CCIP 合约,并且速度更快。

创建一个新的 test/XNFT.t.sol 文件。粘贴以下内容:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test, console} from "forge-std/Test.sol";
import {CCIPLocalSimulatorFork, Register} from "@chainlink/local/src/ccip/CCIPLocalSimulatorFork.sol";

import {XNFT} from "../src/XNFT.sol";
import {EncodeExtraArgs} from "./utils/EncodeExtraArgs.sol";

contract XNFTTest is Test {
    CCIPLocalSimulatorFork public ccipLocalSimulatorFork;
    uint256 ethSepoliaFork;
    uint256 arbSepoliaFork;
    Register.NetworkDetails ethSepoliaNetworkDetails;
    Register.NetworkDetails arbSepoliaNetworkDetails;

    address alice;
    address bob;

    XNFT public ethSepoliaXNFT;
    XNFT public arbSepoliaXNFT;

    EncodeExtraArgs public encodeExtraArgs;

    function setUp() public {
        alice = makeAddr("alice");
        bob = makeAddr("bob");

        string memory ETHEREUM_SEPOLIA_RPC_URL = vm.envString("ETHEREUM_SEPOLIA_RPC_URL");
        string memory ARBITRUM_SEPOLIA_RPC_URL = vm.envString("ARBITRUM_SEPOLIA_RPC_URL");
        ethSepoliaFork = vm.createSelectFork(ETHEREUM_SEPOLIA_RPC_URL);
        arbSepoliaFork = vm.createFork(ARBITRUM_SEPOLIA_RPC_URL);

        ccipLocalSimulatorFork = new CCIPLocalSimulatorFork();
        vm.makePersistent(address(ccipLocalSimulatorFork));
    }

    // YOUR TEST GOES HERE...
}

如果您尝试编译此合约,可能会遇到错误,因为缺少上一个练习中的 EncodeExtraArgs.sol 辅助智能合约。通过创建一个新文件 test/utils/EncodeExtraArgs.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);
    }
}

让我们分析一下目前的代码。我们从 @chainlink/local 包中导入了 CCIPLocalSimulatorFork,这意味着我们使用的是分叉模式。我们创建了一个新的 CCIPLocalSimulatorFork 实例,并在所有不同的分叉之间使其保持持久性,使它能正常工作。

在作业中,您需要从 @chainlink/local 包中导入 CCIPLocalSimulator

我们还创建了两个分叉网络(即最新区块的区块链网络副本——最好固定这个区块号,而不是总是使用最新的区块号)Ethereum Sepolia 和 Arbitrum Sepolia 测试网络。现在我们可以使用 Foundry 在多个分叉网络之间切换,但我们选择 Ethereum Sepolia 作为开始的源区块链。

Foundry 使用分叉 ID 管理同一测试中不同的分叉网络,通常是 1、2、3 等这样的数字。分叉 ID 与 block.chainid 并不相同——后者是可以从Solidity 中获取的区块链网络的实际 Chain ID,例如,Ethereum Sepolia 的 Chain ID 是 11155111。

Foundry 中的 CCIPLocalSimulatorFork 提供了Register 辅助合约,其中包含 NetworkDetails 结构,如下所示:

struct NetworkDetails {
        uint64 chainSelector;
        address routerAddress;
        address linkAddress;
        address wrappedNativeAddress;
        address ccipBnMAddress;
        address ccipLnMAddress;
}

在该结构体中已预先设置了一些测试网络的详细信息,并且其中特意不包含主网络。因此最好的做法是始终验证这些信息——否则模拟将无法工作:

ethSepoliaNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid); // we are currently on Ethereum Sepolia Fork
assertEq(
    ethSepoliaNetworkDetails.chainSelector,
    16015286601757825753,
    "Sanity check: Ethereum Sepolia chain selector should be 16015286601757825753"
);

如果没有预置的网络详细信息(例如主网络),您必须使用 setNetworkDetails 函数手动添加这些信息。

我们将在 setup() 函数中直接执行前一个练习的步骤 1)和 2),如下所示:

    function setUp() public {
        alice = makeAddr("alice");
        bob = makeAddr("bob");

        string memory ETHEREUM_SEPOLIA_RPC_URL = vm.envString("ETHEREUM_SEPOLIA_RPC_URL");
        string memory ARBITRUM_SEPOLIA_RPC_URL = vm.envString("ARBITRUM_SEPOLIA_RPC_URL");
        ethSepoliaFork = vm.createSelectFork(ETHEREUM_SEPOLIA_RPC_URL);
        arbSepoliaFork = vm.createFork(ARBITRUM_SEPOLIA_RPC_URL);

        ccipLocalSimulatorFork = new CCIPLocalSimulatorFork();
        vm.makePersistent(address(ccipLocalSimulatorFork));

        // 步骤 1) 在Ethereum Sepolia网络中部署XNFT.sol
        assertEq(vm.activeFork(), ethSepoliaFork);

        ethSepoliaNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid); // 目前我们处于Ethereum Sepolia的分叉网络中
        assertEq(
            ethSepoliaNetworkDetails.chainSelector,
            16015286601757825753,
            "Sanity check: Ethereum Sepolia chain selector should be 16015286601757825753"
        );

        ethSepoliaXNFT = new XNFT(
            ethSepoliaNetworkDetails.routerAddress,
            ethSepoliaNetworkDetails.linkAddress,
            ethSepoliaNetworkDetails.chainSelector
        );

        // 步骤 2) 在Arbitrum Sepolia网络中部署XNFT.sol
        vm.selectFork(arbSepoliaFork);
        assertEq(vm.activeFork(), arbSepoliaFork);

        arbSepoliaNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid); // 目前我们处于Arbitrum Sepolia的分叉网络中
        assertEq(
            arbSepoliaNetworkDetails.chainSelector,
            3478487238524512106,
            "Sanity check: Arbitrum Sepolia chain selector should be 421614"
        );

        arbSepoliaXNFT = new XNFT(
            arbSepoliaNetworkDetails.routerAddress,
            arbSepoliaNetworkDetails.linkAddress,
            arbSepoliaNetworkDetails.chainSelector
        );
    }

在一个新的test函数中直接执行前一个练习的其余步骤,如下所示:

function testShouldMintNftOnArbitrumSepoliaAndTransferItToEthereumSepolia() public {
        // 步骤 3) 在Ethereum Sepolia网络中, 调用enableChain方法
        vm.selectFork(ethSepoliaFork);
        assertEq(vm.activeFork(), ethSepoliaFork);

        encodeExtraArgs = new EncodeExtraArgs();

        uint256 gasLimit = 200_000;
        bytes memory extraArgs = encodeExtraArgs.encode(gasLimit);
        assertEq(extraArgs, hex"97a657c90000000000000000000000000000000000000000000000000000000000030d40"); // 该值来源于 https://cll-devrel.gitbook.io/ccip-masterclass-3/ccip-masterclass/exercise-xnft#step-3-on-ethereum-sepolia-call-enablechain-function

        ethSepoliaXNFT.enableChain(arbSepoliaNetworkDetails.chainSelector, address(arbSepoliaXNFT), extraArgs);

        // 步骤 4) 在Arbitrum Sepolia网络中, 调用enableChain方法
        vm.selectFork(arbSepoliaFork);
        assertEq(vm.activeFork(), arbSepoliaFork);

        arbSepoliaXNFT.enableChain(ethSepoliaNetworkDetails.chainSelector, address(ethSepoliaXNFT), extraArgs);

        // 步骤 5) 在Arbitrum Sepolia网络中, 向XNFT.sol充值3 LINK
        assertEq(vm.activeFork(), arbSepoliaFork);

        ccipLocalSimulatorFork.requestLinkFromFaucet(address(arbSepoliaXNFT), 3 ether);

        // 步骤 6) 在Arbitrum Sepolia网络中, 铸造新的xNFT
        assertEq(vm.activeFork(), arbSepoliaFork);

        vm.startPrank(alice);

        arbSepoliaXNFT.mint();
        uint256 tokenId = 0;
        assertEq(arbSepoliaXNFT.balanceOf(alice), 1);
        assertEq(arbSepoliaXNFT.ownerOf(tokenId), alice);

        // 步骤 7) 在Arbitrum Sepolia网络中, 跨链转移xNFT
        arbSepoliaXNFT.crossChainTransferFrom(
            address(alice), address(bob), tokenId, ethSepoliaNetworkDetails.chainSelector, XNFT.PayFeesIn.LINK
        );

        vm.stopPrank();

        assertEq(arbSepoliaXNFT.balanceOf(alice), 0);

        // 在Ethereum Sepolia中验证xNFT已成功跨链转移
        ccipLocalSimulatorFork.switchChainAndRouteMessage(ethSepoliaFork); // 这行代码将更换CHAINLINK CCIP DONs, 不要遗漏
        assertEq(vm.activeFork(), ethSepoliaFork);

        assertEq(ethSepoliaXNFT.balanceOf(bob), 1);
        assertEq(ethSepoliaXNFT.ownerOf(tokenId), bob);
    }

最终的完整代码:

test/XNFT.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test, console} from "forge-std/Test.sol";
import {CCIPLocalSimulatorFork, Register} from "@chainlink/local/src/ccip/CCIPLocalSimulatorFork.sol";

import {XNFT} from "../src/XNFT.sol";
import {EncodeExtraArgs} from "./utils/EncodeExtraArgs.sol";

contract XNFTTest is Test {
    CCIPLocalSimulatorFork public ccipLocalSimulatorFork;
    uint256 ethSepoliaFork;
    uint256 arbSepoliaFork;
    Register.NetworkDetails ethSepoliaNetworkDetails;
    Register.NetworkDetails arbSepoliaNetworkDetails;

    address alice;
    address bob;

    XNFT public ethSepoliaXNFT;
    XNFT public arbSepoliaXNFT;

    EncodeExtraArgs public encodeExtraArgs;

    function setUp() public {
        alice = makeAddr("alice");
        bob = makeAddr("bob");

        string memory ETHEREUM_SEPOLIA_RPC_URL = vm.envString("ETHEREUM_SEPOLIA_RPC_URL");
        string memory ARBITRUM_SEPOLIA_RPC_URL = vm.envString("ARBITRUM_SEPOLIA_RPC_URL");
        ethSepoliaFork = vm.createSelectFork(ETHEREUM_SEPOLIA_RPC_URL);
        arbSepoliaFork = vm.createFork(ARBITRUM_SEPOLIA_RPC_URL);

        ccipLocalSimulatorFork = new CCIPLocalSimulatorFork();
        vm.makePersistent(address(ccipLocalSimulatorFork));

        // 步骤 1) 在Ethereum Sepolia网络中部署XNFT.sol
        assertEq(vm.activeFork(), ethSepoliaFork);

        ethSepoliaNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid); // 目前我们处于Ethereum Sepolia的分叉网络中
        assertEq(
            ethSepoliaNetworkDetails.chainSelector,
            16015286601757825753,
            "Sanity check: Ethereum Sepolia chain selector should be 16015286601757825753"
        );

        ethSepoliaXNFT = new XNFT(
            ethSepoliaNetworkDetails.routerAddress,
            ethSepoliaNetworkDetails.linkAddress,
            ethSepoliaNetworkDetails.chainSelector
        );

        // 步骤 2) 在Arbitrum Sepolia网络中部署XNFT.sol
        vm.selectFork(arbSepoliaFork);
        assertEq(vm.activeFork(), arbSepoliaFork);

        arbSepoliaNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid); // 目前我们处于Arbitrum Sepolia的分叉网络中
        assertEq(
            arbSepoliaNetworkDetails.chainSelector,
            3478487238524512106,
            "Sanity check: Arbitrum Sepolia chain selector should be 421614"
        );

        arbSepoliaXNFT = new XNFT(
            arbSepoliaNetworkDetails.routerAddress,
            arbSepoliaNetworkDetails.linkAddress,
            arbSepoliaNetworkDetails.chainSelector
        );
    }

    function testShouldMintNftOnArbitrumSepoliaAndTransferItToEthereumSepolia() public {
        // 步骤 3) 在Ethereum Sepolia网络中, 调用enableChain方法
        vm.selectFork(ethSepoliaFork);
        assertEq(vm.activeFork(), ethSepoliaFork);

        encodeExtraArgs = new EncodeExtraArgs();

        uint256 gasLimit = 200_000;
        bytes memory extraArgs = encodeExtraArgs.encode(gasLimit);
        assertEq(extraArgs, hex"97a657c90000000000000000000000000000000000000000000000000000000000030d40"); // 该值来源于 https://cll-devrel.gitbook.io/ccip-masterclass-3/ccip-masterclass/exercise-xnft#step-3-on-ethereum-sepolia-call-enablechain-function

        ethSepoliaXNFT.enableChain(arbSepoliaNetworkDetails.chainSelector, address(arbSepoliaXNFT), extraArgs);

        // 步骤 4) 在Arbitrum Sepolia网络中, 调用enableChain方法
        vm.selectFork(arbSepoliaFork);
        assertEq(vm.activeFork(), arbSepoliaFork);

        arbSepoliaXNFT.enableChain(ethSepoliaNetworkDetails.chainSelector, address(ethSepoliaXNFT), extraArgs);

        // 步骤 5) 在Arbitrum Sepolia网络中, 向XNFT.sol充值3 LINK
        assertEq(vm.activeFork(), arbSepoliaFork);

        ccipLocalSimulatorFork.requestLinkFromFaucet(address(arbSepoliaXNFT), 3 ether);

        // 步骤 6) 在Arbitrum Sepolia网络中, 增发新的xNFT
        assertEq(vm.activeFork(), arbSepoliaFork);

        vm.startPrank(alice);

        arbSepoliaXNFT.mint();
        uint256 tokenId = 0;
        assertEq(arbSepoliaXNFT.balanceOf(alice), 1);
        assertEq(arbSepoliaXNFT.ownerOf(tokenId), alice);

        // 步骤 7) 在Arbitrum Sepolia网络中, 跨链转移xNFT
        arbSepoliaXNFT.crossChainTransferFrom(
            address(alice), address(bob), tokenId, ethSepoliaNetworkDetails.chainSelector, XNFT.PayFeesIn.LINK
        );

        vm.stopPrank();

        assertEq(arbSepoliaXNFT.balanceOf(alice), 0);

        // 在Ethereum Sepolia中验证xNFT已成功跨链转移
        ccipLocalSimulatorFork.switchChainAndRouteMessage(ethSepoliaFork); // 这行代码将更换CHAINLINK CCIP DONs, 不要遗漏
        assertEq(vm.activeFork(), ethSepoliaFork);

        assertEq(ethSepoliaXNFT.balanceOf(bob), 1);
        assertEq(ethSepoliaXNFT.ownerOf(tokenId), bob);
    }
}

Last updated