练习 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 文档中提供的合约地址进行交互。

Chainlink Local 速度非常快,因为它不需要启动任何用于跨链转账的链下组件。这就是为什么 CCIP Local 模拟器分叉(Foundry 的智能合约、 Hardhat 的 TypeScript 脚本)暴露了在分叉网络之间切换的功能并需要开发人员直接将消息路由到目标区块链上 - 所以不要忘记这一步😉

代码时间: 为我们的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