React Native DApp 开发全栈实战·从 0 到 1 系列(跨链转账-合约部分)

前言

本文借助Hardhat + Chainlink CCIP 接口 + OpenZeppelin 搭建一条最小可运行的跨链铸币链路

  1. 用户在 源链 调用 transferCrossChain 并支付手续费;
  2. MockRouter 模拟 Chainlink 路由器完成费用计算与消息发出;
  3. DestinationMinter (CCIPReceiver)在 目标链 接收消息并铸币;
  4. 全流程通过 本地双节点 + 双链部署 + 事件断言 验证,无需测试网 LINK 即可调试。

阅读完你将得到:

  • 一套可复制的 Lock-Mint 跨链代码;
  • MockRouter 规避嵌套类型编译错误的技巧;
  • Hardhat 双链并发impersonate 测试方案;
  • 可平滑迁移到 正式 CCIP Router 的接口兼容层。

前期准备

  • hardhat.config.js配置:主要针对network项的配置,便于本地跨链转账测试
  • 核心代码配置
yaml 复制代码
 networks:{
    hardhat: {
      chainId: 1337,           // 节点将使用这个 id
    },
    localA: { url: "http://127.0.0.1:8545", chainId: 1337,  saveDeployments: true, },//src
    localB: { url: "http://127.0.0.1:8546", chainId: 1338,  saveDeployments: true, },//dst
    }

智能合约

代币合约

typescript 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

contract MyToken4 is ERC20, ERC20Burnable, AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

    constructor(
        string memory name_,
        string memory symbol_,
        address[] memory initialMinters   // 👈 部署时一次性给多地址授权
    ) ERC20(name_, symbol_) {
        // 部署者拥有 DEFAULT_ADMIN_ROLE(可继续授权/撤销)
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);

        // 把 MINTER_ROLE 给所有传入地址
        for (uint256 i = 0; i < initialMinters.length; ++i) {
            _grantRole(MINTER_ROLE, initialMinters[i]);
        }

        // 给部署者自己先发 1000 个
        _mint(msg.sender, 1000 * 10 ** decimals());
    }

    // 任何拥有 MINTER_ROLE 的人都能铸币
    function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
        _mint(to, amount);
    }
}

MockCCIPRouter合约

  • 特别说明 :保证本地部署的合约中包含MockCCIPRouter所有的方法,以及applyRampUpdates方法通过用 bytes 绕过嵌套类型命名
php 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import { IRouterClient } from "@chainlink/contracts-ccip/contracts/interfaces/IRouterClient.sol";
import { Client } from "@chainlink/contracts-ccip/contracts/libraries/Client.sol";

contract MockCCIPRouter is IRouterClient {
    uint256 public fee = 0.001 ether;

    function ccipSend(uint64, Client.EVM2AnyMessage calldata)
        external
        payable
        override
        returns (bytes32)
    {
        require(msg.value >= fee, "Insufficient fee");
        return keccak256(abi.encodePacked(msg.sender, block.timestamp));
    }

    function getFee(uint64, Client.EVM2AnyMessage calldata)
        external
        pure
        override
        returns (uint256)
    {
        return 0.001 ether;
    }

    function isChainSupported(uint64) external pure override returns (bool) {
        return true;
    }

    function getSupportedTokens(uint64)
        external
        pure
        
        returns (address[] memory)
    {
        return new address[](0);
    }

    function getPool(uint64, address) external pure  returns (address) {
        return address(0);
    }

    // ✅ 用 bytes 绕过嵌套类型命名
    function applyRampUpdates(
        bytes calldata,
        bytes calldata,
        bytes calldata
    ) external pure  {}
}

SourceMinter合约

arduino 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {IRouterClient} from "@chainlink/contracts-ccip/contracts/interfaces/IRouterClient.sol";
import {Client} from "@chainlink/contracts-ccip/contracts/libraries/Client.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract SourceMinter is Ownable {
    IRouterClient public router;
    uint64   public destChainSelector; // 1338
    address  public destMinter;        // 目标链 DestinationMinter 地址

    event CCIPSendRequested(bytes32 msgId, uint256 amount);

    constructor(address _router, uint64 _destChainSelector, address _destMinter,address _owner) Ownable(_owner) {
        router = IRouterClient(_router);
        destChainSelector = _destChainSelector;
        destMinter = _destMinter;
    }

    /**
     * 用户入口:锁定 amount 个 Link,发起 CCIP 跨链转账
     */
    function transferCrossChain(uint256 amount) external returns (bytes32 msgId) {
        // 1. 构造 CCIP 消息
        Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
            receiver: abi.encode(destMinter),
            data: abi.encode(msg.sender, amount), // 把 (to,amount) 发到对端
            tokenAmounts: new Client.EVMTokenAmount[](0), // 本例不直接搬 token,只发消息
            extraArgs: "",
            feeToken: address(0) // 用原生币付 CCIP 手续费;也可填 LINK
        });

        // 2. 计算并交手续费
        uint256 fee = router.getFee(destChainSelector, message);
        require(address(this).balance >= fee, "Fee not enough");

        // 3. 发送
        msgId = router.ccipSend{value: fee}(destChainSelector, message);
        emit CCIPSendRequested(msgId, amount);
        return msgId;
    }

    receive() external payable {}
}

DestinationMinter合约

css 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {CCIPReceiver} from "@chainlink/contracts-ccip/contracts/applications/CCIPReceiver.sol";
import {Client} from "@chainlink/contracts-ccip/contracts/libraries/Client.sol";
import "./Token4.sol";
contract DestinationMinter is CCIPReceiver {
    MyToken4 public token;
    event MintedByCCIP(address to, uint256 amount);

    constructor(address _router, address _token) CCIPReceiver(_router) {
        token = MyToken4(_token);
    }

    /**
     * CCIP 回调:只有路由器能调
     */
    function _ccipReceive(Client.Any2EVMMessage memory message)
        internal
        override
    {
        // 解码 (address to, uint256 amount)
        (address to, uint256 amount) =
            abi.decode(message.data, (address, uint256));
        token.mint(to, amount);
        emit MintedByCCIP(to, amount);
    }
}

编译指令npx hardhat compile

测试合约

说明:本地双链部署 → 用户锁仓发事件 → 路由器 impersonate 转发 → 目标链铸币 → 余额断言

ini 复制代码
const { expect } = require("chai");
const { ethers, deployments } = require("hardhat");

describe("CrossChain mint via CCIP (MockRouter)", function () {
  this.timeout(120000);

  const amount = 123;
  let srcMinter, dstMinter, token, router;
  let deployer, user;

  beforeEach(async () => {
    [deployer, user] = await ethers.getSigners();
    await deployments.fixture(["token4", "SourceMinter", "DestinationMinter"]);
    const tokenAddress = await deployments.get("MyToken4");          // 存入资产        // 奖励代币(USDC)
        const routerAddress = await deployments.get("MockCCIPRouter");
        const srcMinterAddress = await deployments.get("SourceMinter");
        const dstMinterAddress = await deployments.get("DestinationMinter");
        token=await ethers.getContractAt("MyToken4", tokenAddress.address);
        router=await ethers.getContractAt("MockCCIPRouter", routerAddress.address);
        srcMinter=await ethers.getContractAt("SourceMinter", srcMinterAddress.address);
        dstMinter=await ethers.getContractAt("DestinationMinter", dstMinterAddress.address);
        console.log('token',token.address)
        console.log('router',router.target)
        // 授权铸币
        const role = await token.MINTER_ROLE();
        await token.grantRole(role, dstMinter.target);

        // 预存手续费
        await deployer.sendTransaction({
            to: srcMinter.target,
            value: ethers.parseEther("1"),
        });
    
  })


  it("user calls transferCrossChain on src", async () => {
    
    // console.log("------",await srcMinter.connect(user).transferCrossChain(amount))
    await expect(srcMinter.connect(user).transferCrossChain(amount))
      .to.emit(srcMinter, "CCIPSendRequested");
  });

it("simulate Router forwarding message to dst", async () => {
  const routerAddr = (await deployments.get("MockCCIPRouter")).address;
  const srcAddr    = (await deployments.get("SourceMinter")).address;

  // 1. 硬hat 内置 impersonate
  await network.provider.send("hardhat_impersonateAccount", [routerAddr]);
  // 2. 给路由器补点余额(否则 gas 为 0)
  await network.provider.send("hardhat_setBalance", [
    routerAddr,
    "0x1000000000000000000", // 1 ETH
  ]);
  const routerSigner = await ethers.getSigner(routerAddr);

  // 3. 构造消息
  const msg = {
    messageId: ethers.keccak256(ethers.toUtf8Bytes("mock")),
    sourceChainSelector: 1337,
    sender: ethers.zeroPadValue(srcAddr, 32), // 来源链上的 SourceMinter
    data: ethers.AbiCoder.defaultAbiCoder().encode(
      ["address", "uint256"],
      [user.address, amount]
    ),
    destTokenAmounts: [],
  };

  // 4. 用路由器调 ccipReceive ✅
  await expect(dstMinter.connect(routerSigner).ccipReceive(msg))
    .to.emit(dstMinter, "MintedByCCIP")
    .withArgs(user.address, amount);

  const bal = await token.balanceOf(user.address);
  console.log(bal)
  expect(bal).to.equal(amount);
});
});

部署合约

代币部署

ini 复制代码
module.exports = async  ({getNamedAccounts,deployments})=>{
    const getNamedAccount = (await getNamedAccounts()).firstAccount;
    const secondAccount= (await getNamedAccounts()).secondAccount;
    console.log('secondAccount',secondAccount)
    const TokenName = "MyReward";
    const TokenSymbol = "MYREWARD";
    const {deploy,log} = deployments;
    const TokenC=await deploy("MyToken4",{
        from:getNamedAccount,
        args: [TokenName,TokenSymbol,[getNamedAccount,secondAccount]],//参数 name,symblo,[Owner1,Owner1]
        log: true,
    })
    // await hre.run("verify:verify", {
    //     address: TokenC.address,
    //     constructorArguments: [TokenName, TokenSymbol],
    //     });
    console.log('MYTOKEN4合约地址 多Owner合约',TokenC.address)
}
module.exports.tags = ["all", "token4"];

DestinationMinter部署

javascript 复制代码
module.exports = async  ({getNamedAccounts,deployments})=>{
    const getNamedAccount = (await getNamedAccounts()).firstAccount;
    const secondAccount= (await getNamedAccounts()).secondAccount;
    console.log('secondAccount',secondAccount)
    const {deploy,log} = deployments;
    const MyAsset  = await deployments.get("MyToken4");
    console.log('MyAsset',MyAsset.address)
    const MockCCIPRouter=await deploy("MockCCIPRouter",{
        from:getNamedAccount,
        args: [],//参数
        log: true,
    })
  console.log("MockCCIPRouter 合约地址:", MockCCIPRouter.address);
//执行DestinationMinter部署合约
  const DestinationMinter=await deploy("DestinationMinter",{
        from:getNamedAccount,
        args: [MockCCIPRouter.address,MyAsset.address],//参数 picc路由,资产地址
        log: true,
    })
console.log('DestinationMinter 合约地址',DestinationMinter.address)
    // const SourceMinter=await deploy("SourceMinter",{
    //     from:getNamedAccount,
    //     args: [MockCCIPRouter.address,1337,DestinationMinter.address,getNamedAccount],//参数 picc路由,链id(1337),目标dis,资产地址
    //     log: true,
    // })
    // // await hre.run("verify:verify", {
    // //     address: TokenC.address,
    // //     constructorArguments: [TokenName, TokenSymbol],
    // //     });
    // console.log('SourceMinter 合约地址',SourceMinter.address)
}
module.exports.tags = ["all", "DestinationMinter"];

SourceMinter部署

javascript 复制代码
module.exports = async  ({getNamedAccounts,deployments})=>{
    const getNamedAccount = (await getNamedAccounts()).firstAccount;
    const secondAccount= (await getNamedAccounts()).secondAccount;
    console.log('getNamedAccount-----',getNamedAccount)
    console.log('secondAccount',secondAccount)
    const {deploy,log} = deployments;
    const MyAsset  = await deployments.get("MyToken4");
    console.log('MyAsset',MyAsset.address)
    //执行MockCCIPRouter部署合约
  const MockCCIPRouter=await deploy("MockCCIPRouter",{
        from:getNamedAccount,
        args: [],//参数
        log: true,
    })
  console.log("MockCCIPRouter 合约地址:", MockCCIPRouter.address);
//执行DestinationMinter部署合约
  const DestinationMinter=await deploy("DestinationMinter",{
        from:getNamedAccount,
        args: [MockCCIPRouter.address,MyAsset.address],//参数 picc路由,资产地址
        log: true,
    })
console.log('DestinationMinter 合约地址',DestinationMinter.address)
    const SourceMinter=await deploy("SourceMinter",{
        from:getNamedAccount,
        args: [MockCCIPRouter.address,1337,DestinationMinter.address,getNamedAccount],//参数 picc路由,链id(1337),目标dis,资产地址
        log: true,
    })
    // await hre.run("verify:verify", {
    //     address: TokenC.address,
    //     constructorArguments: [TokenName, TokenSymbol],
    //     });
    console.log('SourceMinter 合约地址',SourceMinter.address)
}
module.exports.tags = ["all", "SourceMinter"];

测试步骤

  1. 启动第一条链:npx hardhat node --port 8545
  2. 启动第一条链:npx hardhat node --port 8546
  3. 测试脚本:npx hardhat test test/CrossChain.js --network localA

总结

  1. 环境:两条本地链(1337 / 1338)并行运行,Hardhat 部署脚本一次性完成跨链合约初始化。

  2. 核心合约

    • MyToken4:AccessControl 管理多铸币者,支持任意地址一次性授权。
    • MockCCIPRouter :用 bytes 绕过嵌套结构 7920 编译错误,提供 ccipSend / getFee 等完整接口。
    • SourceMinter :用户入口,锁仓 + 发事件 + 支付手续费
    • DestinationMinter :继承 CCIPReceiver仅路由器地址 可触发 _ccipReceive 完成铸币。
  3. 测试亮点

    • beforeEach 使用 deployments.fixture 保证测试隔离;
    • hardhat_impersonateAccount + setBalance路由器地址 成为签名者,通过 InvalidRouter 校验;
    • 事件断言 + 余额检查 双保险,确保跨链铸币真正到账。
  4. 一键命令

    css 复制代码
    npx hardhat node --port 8545  # 链 A
    npx hardhat node --port 8546  # 链 B
    npx hardhat test test/CrossChain.js --network localA

    三行即可在本地跑通 完整 CCIP 跨链铸币 流程,零测试网费用、零外部依赖

进一步优化: MockRouter 换成正式地址、把链 ID 换成测试网,同一套代码即可直接上 Sepolia ↔ Mumbai 实战。

相关推荐
木西2 天前
React Native DApp 开发全栈实战·从 0 到 1 系列(兑换-前端部分)
react native·web3·solidity
天涯学馆3 天前
Solidity中实现安全的代币转账
智能合约·solidity·以太坊
yiyesushu3 天前
solidity+chainlink 项目实例
solidity
木西5 天前
React Native DApp 开发全栈实战·从 0 到 1 系列(兑换-合约部分)
web3·智能合约·solidity
CodingBrother6 天前
ABI解析智能合约
区块链·智能合约
.刻舟求剑.6 天前
solidity得高级语法3
区块链·solidity·语法笔记
许强0xq6 天前
Ethernaut Level 1: Fallback - 回退函数权限提升攻击
区块链·solidity·foundry·ethernaut
全干engineer6 天前
区块链web3项目实战-Truffle petshop
web3·区块链
Armonia生态6 天前
Armonia Mall超级数字生态WEB3商城的引领者
web3·armonia-mall