前言
本文借助Hardhat + Chainlink CCIP 接口 + OpenZeppelin 搭建一条最小可运行的跨链铸币链路:
- 用户在 源链 调用
transferCrossChain
并支付手续费;- MockRouter 模拟 Chainlink 路由器完成费用计算与消息发出;
- DestinationMinter (CCIPReceiver)在 目标链 接收消息并铸币;
- 全流程通过 本地双节点 + 双链部署 + 事件断言 验证,无需测试网 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"];
测试步骤
- 启动第一条链:npx hardhat node --port 8545
- 启动第一条链:npx hardhat node --port 8546
- 测试脚本:npx hardhat test test/CrossChain.js --network localA
总结
-
环境:两条本地链(1337 / 1338)并行运行,Hardhat 部署脚本一次性完成跨链合约初始化。
-
核心合约:
- MyToken4:AccessControl 管理多铸币者,支持任意地址一次性授权。
- MockCCIPRouter :用
bytes
绕过嵌套结构 7920 编译错误,提供ccipSend / getFee
等完整接口。 - SourceMinter :用户入口,锁仓 + 发事件 + 支付手续费。
- DestinationMinter :继承
CCIPReceiver
,仅路由器地址 可触发_ccipReceive
完成铸币。
-
测试亮点:
beforeEach
使用deployments.fixture
保证测试隔离;hardhat_impersonateAccount + setBalance
让 路由器地址 成为签名者,通过InvalidRouter
校验;- 事件断言 + 余额检查 双保险,确保跨链铸币真正到账。
-
一键命令:
cssnpx hardhat node --port 8545 # 链 A npx hardhat node --port 8546 # 链 B npx hardhat test test/CrossChain.js --network localA
三行即可在本地跑通 完整 CCIP 跨链铸币 流程,零测试网费用、零外部依赖。
进一步优化: MockRouter 换成正式地址、把链 ID 换成测试网,同一套代码即可直接上 Sepolia ↔ Mumbai 实战。