前言本文借助Hardhat+ChainlinkCCIP接口+OpenZeppelin搭建一条最小可运行的跨链铸币链路:用户在源链调用transferCrossChain并支付手续费;MockRouter模拟Chainlink路由器完成费用计算与消息发出;Des
本文借助Hardhat + Chainlink CCIP 接口 + OpenZeppelin 搭建一条最小可运行的跨链铸币链路:
- 用户在 源链 调用
transferCrossChain
并支付手续费;- MockRouter 模拟 Chainlink 路由器完成费用计算与消息发出;
- DestinationMinter(CCIPReceiver)在 目标链 接收消息并铸币;
- 全流程通过 本地双节点 + 双链部署 + 事件断言 验证,无需测试网 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 实战。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!