Skip to content

Commit 888de05

Browse files
committed
Add comprehensive guide for example 04 Hardhat CCIP project
1 parent cb907d7 commit 888de05

21 files changed

Lines changed: 3591 additions & 113 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
node_modules/
2+
contracts/test/

examples/04-hardhat-ccip/README.md

Lines changed: 755 additions & 0 deletions
Large diffs are not rendered by default.

examples/04-hardhat-ccip/contracts/CCIPReceiver.sol

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
1010
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
1111

1212
/// @title CCIPReceiverExample
13-
/// @notice CCIP receiver with 3 modes: token-only, data-only, and data+tokens.
13+
/// @notice CCIP receiver with 3 modes: token-only, data-only (inbox), and data+tokens.
1414
/// @dev Implements the defensive pattern recommended by Chainlink:
1515
/// - Separates message reception from business logic via try/catch
1616
/// - Uses try/catch to prevent message failure from locking tokens
1717
/// - Tracks failed message IDs for owner-driven recovery via withdrawToken
1818
///
1919
/// Message modes:
2020
/// - Token-only: holds received tokens in the contract.
21-
/// - Data-only: emits event with the data payload.
21+
/// - Data-only: stores messages in an on-chain inbox, queryable by anyone.
2222
/// - Data+tokens: decodes a recipient address from data and forwards tokens.
2323
///
2424
/// Security patterns:
@@ -32,6 +32,26 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol
3232
contract CCIPReceiverExample is CCIPReceiver, Ownable2Step, Pausable, ReentrancyGuard {
3333
using SafeERC20 for IERC20;
3434

35+
// Cross-chain inbox: stores data-only messages for on-chain querying
36+
struct InboxMessage {
37+
bytes32 messageId;
38+
uint64 sourceChainSelector;
39+
address sender;
40+
bytes data;
41+
uint256 timestamp;
42+
}
43+
44+
InboxMessage[] public inbox;
45+
/// @dev Stores index + 1, so 0 means "not found". Subtract 1 to get the actual inbox index.
46+
mapping(bytes32 messageId => uint256 indexPlusOne) public messageIndex;
47+
48+
// Batch allowlist entry
49+
struct AllowlistEntry {
50+
uint64 sourceChainSelector;
51+
address sender;
52+
bool allowed;
53+
}
54+
3555
// Allowlisting: sender is allowlisted per source chain to prevent
3656
// a contract on chain B from impersonating an allowlisted sender on chain A.
3757
mapping(uint64 sourceChainSelector => mapping(address sender => bool allowed)) public allowlistedSenders;
@@ -48,8 +68,9 @@ contract CCIPReceiverExample is CCIPReceiver, Ownable2Step, Pausable, Reentrancy
4868

4969
// Events
5070
event TokensReceived(bytes32 indexed messageId, address[] tokens, uint256[] amounts);
51-
event DataReceived(bytes32 indexed messageId, bytes data);
71+
event DataReceived(bytes32 indexed messageId, uint64 indexed sourceChainSelector, address sender, bytes data);
5272
event TokensForwarded(bytes32 indexed messageId, address indexed recipient, address[] tokens, uint256[] amounts);
73+
event AllowlistUpdated(uint64 indexed sourceChainSelector, address indexed sender, bool allowed);
5374
event MessageFailed(bytes32 indexed messageId, bytes reason);
5475

5576
/// @dev Only this contract can call processMessage (used by the try/catch pattern).
@@ -68,6 +89,15 @@ contract CCIPReceiverExample is CCIPReceiver, Ownable2Step, Pausable, Reentrancy
6889
allowlistedSenders[sourceChainSelector][sender] = allowed;
6990
}
7091

92+
/// @notice Batch update the allowlist — add/remove multiple (chain, sender) pairs in one call.
93+
/// @param entries Array of AllowlistEntry structs with chain selector, sender, and allowed flag.
94+
function updateAllowlist(AllowlistEntry[] calldata entries) external onlyOwner {
95+
for (uint256 i = 0; i < entries.length; i++) {
96+
allowlistedSenders[entries[i].sourceChainSelector][entries[i].sender] = entries[i].allowed;
97+
emit AllowlistUpdated(entries[i].sourceChainSelector, entries[i].sender, entries[i].allowed);
98+
}
99+
}
100+
71101
/// @notice Defensive receive: validates allowlists, then delegates to processMessage
72102
/// via try/catch. If processing fails, tokens stay in the contract and the
73103
/// message ID is recorded. The owner can recover tokens via withdrawToken.
@@ -112,7 +142,41 @@ contract CCIPReceiverExample is CCIPReceiver, Ownable2Step, Pausable, Reentrancy
112142
}
113143

114144
function _handleDataOnly(Client.Any2EVMMessage memory message) internal {
115-
emit DataReceived(message.messageId, message.data);
145+
address sender = abi.decode(message.sender, (address));
146+
messageIndex[message.messageId] = inbox.length + 1; // +1 so that 0 means "not found"
147+
inbox.push(InboxMessage({
148+
messageId: message.messageId,
149+
sourceChainSelector: message.sourceChainSelector,
150+
sender: sender,
151+
data: message.data,
152+
timestamp: block.timestamp
153+
}));
154+
emit DataReceived(message.messageId, message.sourceChainSelector, sender, message.data);
155+
}
156+
157+
/// @notice Get the total number of messages in the inbox.
158+
function getInboxLength() external view returns (uint256) {
159+
return inbox.length;
160+
}
161+
162+
/// @notice Get a message from the inbox by index.
163+
/// @param index The inbox index (0-based).
164+
function getInboxMessage(uint256 index) external view returns (InboxMessage memory) {
165+
return inbox[index];
166+
}
167+
168+
/// @notice Get the latest N messages from the inbox.
169+
/// @param count Maximum number of messages to return.
170+
function getLatestMessages(uint256 count) external view returns (InboxMessage[] memory) {
171+
uint256 len = inbox.length;
172+
if (count > len) {
173+
count = len;
174+
}
175+
InboxMessage[] memory result = new InboxMessage[](count);
176+
for (uint256 i = 0; i < count; i++) {
177+
result[i] = inbox[len - count + i];
178+
}
179+
return result;
116180
}
117181

118182
function _handleDataAndTokens(Client.Any2EVMMessage memory message) internal {

examples/04-hardhat-ccip/contracts/CCIPSender.sol

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ contract CCIPSender is Ownable2Step, Pausable, ReentrancyGuard {
3535
/// @dev Only registered (chain, receiver) pairs can be used as destinations.
3636
mapping(uint64 destChainSelector => address peer) public peers;
3737

38+
error ArrayLengthMismatch();
3839
error NothingToWithdraw();
3940
error FailedToWithdrawEth(address owner, address target, uint256 value);
4041
error InsufficientNativeFee(uint256 required, uint256 provided);
@@ -55,6 +56,17 @@ contract CCIPSender is Ownable2Step, Pausable, ReentrancyGuard {
5556
emit PeerSet(destChainSelector, peer);
5657
}
5758

59+
/// @notice Register or remove multiple trusted peers in one call.
60+
/// @param destChainSelectors The CCIP chain selectors of the destinations.
61+
/// @param peerAddresses The trusted receiver addresses (address(0) to remove).
62+
function setPeers(uint64[] calldata destChainSelectors, address[] calldata peerAddresses) external onlyOwner {
63+
if (destChainSelectors.length != peerAddresses.length) revert ArrayLengthMismatch();
64+
for (uint256 i = 0; i < destChainSelectors.length; i++) {
65+
peers[destChainSelectors[i]] = peerAddresses[i];
66+
emit PeerSet(destChainSelectors[i], peerAddresses[i]);
67+
}
68+
}
69+
5870
/// @notice Send a CCIP message with pre-encoded extraArgs.
5971
/// @dev Handles both native and ERC20 fee payment. When the fee token is the same
6072
/// as a transfer token, combines into a single transferFrom + approval for gas efficiency.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.24;
3+
4+
import {CCIPLocalSimulator} from "@chainlink/local/src/ccip/CCIPLocalSimulator.sol";
5+
6+
/// @dev Re-export CCIPLocalSimulator so Hardhat generates an artifact for it.
7+
contract LocalSimulator is CCIPLocalSimulator {}

examples/04-hardhat-ccip/hardhat.config.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import "dotenv/config";
22
import { defineConfig, configVariable, task } from "hardhat/config";
3+
import hardhatViemPlugin from "@nomicfoundation/hardhat-viem";
4+
import hardhatNodeTestRunnerPlugin from "@nomicfoundation/hardhat-node-test-runner";
35
import { NETWORKS, getChainIdForNetwork } from "@ccip-examples/shared-config";
46

57
// Build Hardhat network entries from shared-config NETWORKS (EVM only)
@@ -47,6 +49,43 @@ const tasks = [
4749
.setAction(() => import("./tasks/deploy-receiver.js"))
4850
.build(),
4951

52+
task(
53+
"manage-allowlist",
54+
"Manage allowlist on CCIPSender (peers) or CCIPReceiverExample (senders)"
55+
)
56+
.addOption({
57+
name: "contract",
58+
description: "Deployed contract address",
59+
defaultValue: "",
60+
})
61+
.addOption({
62+
name: "type",
63+
description: "Contract type: sender or receiver",
64+
defaultValue: "",
65+
})
66+
.addOption({
67+
name: "chains",
68+
description: "Comma-separated chain network IDs",
69+
defaultValue: "",
70+
})
71+
.addOption({
72+
name: "peers",
73+
description: "Comma-separated peer addresses (for sender type)",
74+
defaultValue: "",
75+
})
76+
.addOption({
77+
name: "senders",
78+
description: "Comma-separated sender addresses (for receiver type)",
79+
defaultValue: "",
80+
})
81+
.addOption({
82+
name: "remove",
83+
description: "Set to 'true' to remove from allowlist",
84+
defaultValue: "",
85+
})
86+
.setAction(() => import("./tasks/manage-allowlist.js"))
87+
.build(),
88+
5089
task("send-via-sender", "Send CCIP message through deployed sender contract")
5190
.addOption({ name: "dest", description: "Destination network ID", defaultValue: "" })
5291
.addOption({
@@ -112,9 +151,132 @@ const tasks = [
112151
.addOption({ name: "messageId", description: "CCIP message ID to check", defaultValue: "" })
113152
.setAction(() => import("./tasks/check-status.js"))
114153
.build(),
154+
155+
task("check-inbox", "Read cross-chain inbox messages from receiver contract")
156+
.addOption({
157+
name: "receiverContract",
158+
description: "Deployed CCIPReceiverExample address",
159+
defaultValue: "",
160+
})
161+
.addOption({
162+
name: "count",
163+
description: "Number of latest messages to show",
164+
defaultValue: "5",
165+
})
166+
.addOption({
167+
name: "messageId",
168+
description: "Look up a specific message by ID",
169+
defaultValue: "",
170+
})
171+
.setAction(() => import("./tasks/check-inbox.js"))
172+
.build(),
173+
174+
task("manual-execute", "Manually execute a failed CCIP message on destination")
175+
.addOption({
176+
name: "messageId",
177+
description: "CCIP message ID to execute",
178+
defaultValue: "",
179+
})
180+
.addOption({
181+
name: "gasLimit",
182+
description: "Gas limit override for ccipReceive execution",
183+
defaultValue: "0",
184+
})
185+
.setAction(() => import("./tasks/manual-execute.js"))
186+
.build(),
187+
188+
task("list-messages", "Search and list CCIP messages with filters")
189+
.addOption({ name: "sender", description: "Filter by sender address", defaultValue: "" })
190+
.addOption({ name: "receiver", description: "Filter by receiver address", defaultValue: "" })
191+
.addOption({
192+
name: "sourceChain",
193+
description: "Filter by source chain (network ID)",
194+
defaultValue: "",
195+
})
196+
.addOption({
197+
name: "destChain",
198+
description: "Filter by destination chain (network ID)",
199+
defaultValue: "",
200+
})
201+
.addOption({
202+
name: "txHash",
203+
description: "Filter by source transaction hash",
204+
defaultValue: "",
205+
})
206+
.addOption({
207+
name: "readyForExec",
208+
description: "Show only messages ready for manual execution (true/false)",
209+
defaultValue: "",
210+
})
211+
.addOption({
212+
name: "limit",
213+
description: "Maximum number of messages to show",
214+
defaultValue: "10",
215+
})
216+
.setAction(() => import("./tasks/list-messages.js"))
217+
.build(),
218+
219+
task("pause-contract", "Pause or unpause a CCIPSender or CCIPReceiverExample contract")
220+
.addOption({ name: "contract", description: "Deployed contract address", defaultValue: "" })
221+
.addOption({ name: "type", description: "Contract type: sender or receiver", defaultValue: "" })
222+
.addOption({
223+
name: "action",
224+
description: "Action: pause or unpause",
225+
defaultValue: "",
226+
})
227+
.setAction(() => import("./tasks/pause-contract.js"))
228+
.build(),
229+
230+
task("withdraw-funds", "Withdraw native currency or ERC20 tokens from a contract")
231+
.addOption({ name: "contract", description: "Deployed contract address", defaultValue: "" })
232+
.addOption({ name: "type", description: "Contract type: sender or receiver", defaultValue: "" })
233+
.addOption({
234+
name: "beneficiary",
235+
description: "Address to receive the withdrawn funds",
236+
defaultValue: "",
237+
})
238+
.addOption({
239+
name: "token",
240+
description: "ERC20 token address (omit for native withdrawal)",
241+
defaultValue: "",
242+
})
243+
.addOption({
244+
name: "amount",
245+
description: "Amount to withdraw in smallest unit (0 = full balance)",
246+
defaultValue: "0",
247+
})
248+
.setAction(() => import("./tasks/withdraw-funds.js"))
249+
.build(),
250+
251+
task("query-config", "Query contract configuration (peers, allowlist, failed messages, status)")
252+
.addOption({ name: "contract", description: "Deployed contract address", defaultValue: "" })
253+
.addOption({ name: "type", description: "Contract type: sender or receiver", defaultValue: "" })
254+
.addOption({
255+
name: "query",
256+
description: "Query type: peer, allowlist, failed, or status",
257+
defaultValue: "",
258+
})
259+
.addOption({
260+
name: "chain",
261+
description: "Chain network ID (for peer/allowlist queries)",
262+
defaultValue: "",
263+
})
264+
.addOption({
265+
name: "address",
266+
description: "Sender address to check (for allowlist query)",
267+
defaultValue: "",
268+
})
269+
.addOption({
270+
name: "messageId",
271+
description: "Message ID to check (for failed query)",
272+
defaultValue: "",
273+
})
274+
.setAction(() => import("./tasks/query-config.js"))
275+
.build(),
115276
];
116277

117278
export default defineConfig({
279+
plugins: [hardhatViemPlugin, hardhatNodeTestRunnerPlugin],
118280
solidity: { version: "0.8.24" },
119281
networks,
120282
tasks,

examples/04-hardhat-ccip/package.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,32 @@
11
{
22
"name": "@ccip-examples/04-hardhat-ccip",
33
"version": "1.0.0",
4+
"engines": {
5+
"node": ">=22.0.0"
6+
},
47
"description": "Hardhat v3 project with CCIP SDK for deploying and interacting with custom Sender/Receiver contracts",
58
"type": "module",
69
"scripts": {
710
"build": "hardhat compile",
811
"typecheck": "tsc --noEmit",
912
"lint": "solhint --max-warnings 0 \"./contracts/**/*.sol\" && eslint hardhat.config.ts tasks helpers --max-warnings 0",
1013
"lint:fix": "solhint --fix --max-warnings 0 \"./contracts/**/*.sol\" --noPrompt && eslint hardhat.config.ts tasks helpers --fix",
14+
"test": "hardhat test",
1115
"clean": "hardhat clean && rm -rf dist"
1216
},
1317
"dependencies": {
1418
"@ccip-examples/shared-config": "workspace:*",
1519
"@ccip-examples/shared-utils": "workspace:*",
1620
"@chainlink/ccip-sdk": "1.2.0",
17-
"@chainlink/contracts-ccip": "^1.6.4",
18-
"@openzeppelin/contracts": "^5.1.0",
21+
"@chainlink/contracts-ccip": "1.6.4",
22+
"@openzeppelin/contracts": "5.1.0",
1923
"dotenv": "^16.4.5",
2024
"viem": "^2.21.0"
2125
},
2226
"devDependencies": {
27+
"@chainlink/local": "0.2.7",
28+
"@nomicfoundation/hardhat-node-test-runner": "^3.0.0",
29+
"@nomicfoundation/hardhat-viem": "^3.0.4",
2330
"hardhat": "^3.0.0",
2431
"solhint": "^6.0.2",
2532
"solhint-plugin-chainlink-solidity": "github:smartcontractkit/chainlink-solhint-rules#v1.2.1",

0 commit comments

Comments
 (0)