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

  • 木西
  • 发布于 12小时前
  • 阅读 79

前言本文借助Hardhat+ChainlinkCCIP接口+OpenZeppelin搭建一条最小可运行的跨链铸币链路:用户在源链调用transferCrossChain并支付手续费;MockRouter模拟Chainlink路由器完成费用计算与消息发出;Des

前言

本文借助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项的配置,便于本地跨链转账测试
  • 核心代码配置
    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
    }

    智能合约

  • 代币合约

    
    // 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 绕过嵌套类型命名`

// 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合约

// 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合约

// 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 转发 → 目标链铸币 → 余额断言

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); }); });

# 部署合约
   - ### 代币部署

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部署

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部署

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 部署脚本一次性完成跨链合约初始化。

1.  **核心合约**:

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

1.  **测试亮点**:

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

1.  **一键命令**:
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 实战。

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
木西
木西
0x5D5C...2dD7
江湖只有他的大名,没有他的介绍。