Chainlink Local is a simulator for developers to be able to use Chainlink CCIP locally in Hardhat and Foundry. It is a set of smart contracts and scripts that aims to enable you to build, deploy and execute CCIP token transfers and arbitrary messages on a local Hardhat or Anvil (Foundry) node, both with and without forking.
User Contracts tested with Chainlink Local can be deployed to test networks without any modifications.
The simulator supports two modes:
- working with mock contracts on a locally running development blockchain node running on
localhost
, and - working with deployed Chainlink CCIP contracts using multiple forked networks.
When working in local simulation mode, the simulator pre-deploys a set of smart contracts to a blank Hardhat/Anvil network EVM state and exposes their details via a call to the configuration()
function (see ./src/ccip/CCIPLocalSimulator.sol
). Even though there are two Router contracts exposed, sourceRouter
and destinationRouter
, to support the developer's mental model of routing cross-chain messages through two different Routers, both are actually the same contract running on the locally development blockchain node.
When working in fork mode, we create multiple locally running blockchain networks (you need an archive node that has historical network state in the pinned block from which you have forked for local development - see here) and interact with the contract addresses provided in the Official Chainlink Documentation.
CCIP Local Simulator Fork (smart contract for Foundry, and typescript script for Hardhat) exposes functionality to switch between forks and route messages to the destination blockchain.
To use Chainlink Local in a localhost environment in any smart contract development framework, user must import the CCIPLocalSimulator.sol
singleton contract from the @chainlink/local
package. Then deploy it on your local development network, and after that the simulator is ready for usage.
pragma solidity ^0.8.19;
import {CCIPLocalSimulator} from "@chainlink/local/src/ccip/CCIPLocalSimulator.sol";
contract Demo is Test {
CCIPLocalSimulator public ccipLocalSimulator;
function setUp() public {
ccipLocalSimulator = new CCIPLocalSimulator();
(
uint64 chainSelector,
IRouterClient sourceRouter,
IRouterClient destinationRouter,
WETH9 wrappedNative,
LinkToken linkToken,
BurnMintERC677Helper ccipBnM,
BurnMintERC677Helper ccipLnM
) = ccipLocalSimulator.configuration();
}
}
Create CCIPLocalSimulator.sol
file inside the contracts
folder and paste the following code:
pragma solidity ^0.8.19;
import {CCIPLocalSimulator} from "@chainlink/local/src/ccip/CCIPLocalSimulator.sol";
And then use it inside your JavaScript/TypeScript tests and scripts:
async function deploy() {
const localSimulatorFactory = await ethers.getContractFactory("CCIPLocalSimulator");
const localSimulator = await localSimulatorFactory.deploy();
const config: {
chainSelector_: bigint;
sourceRouter_: string;
destinationRouter_: string;
wrappedNative_: string;
linkToken_: string;
ccipBnM_: string;
ccipLnM_: string;
} = await localSimulator.configuration();
return { localSimulator };
}
Create CCIPLocalSimulator.sol
and paste the following code:
pragma solidity ^0.8.19;
import {CCIPLocalSimulator} from "https://github.com/smartcontractkit/chainlink-local/blob/main/src/ccip/CCIPLocalSimulator.sol";
Compile it and deploy it to RemixVM. If deployment fails, go back to the "Solidity compiler" tab, toggle the "Advanced Configurations" and under "Compiler configuration" check the "Enable optimization" check box. Then compile it and try deploying it again.
After that deploy your smart contracts using addresses provided by the configuration()
function (Router, LinkToken, etc.) and start interacting and testing your smart contracts.
If you encounter the NotEnoughGasForCall
custom error provided by the Router smart contract, scroll up to the "Gas Limit" section, select the "Custom" radio button and try again.
Call the .configuration()
method on the deployed CCIPLocalSimulator
contract to obtain the configuration details for pre-deployed contracts and services needed for local CCIP simulations.
chainSelector_
(uint64): The unique CCIP Chain Selector.sourceRouter_
(IRouterClient): The source chain Router contract.destinationRouter_
(IRouterClient): The destination chain Router contract.wrappedNative_
(WETH9): The wrapped native token which can be used for CCIP fees.linkToken_
(LinkToken): The LINK token.ccipBnM_
(BurnMintERC677Helper): The ccipBnM token.ccipLnM_
(BurnMintERC677Helper): The ccipLnM token.
See the code snippets in Foundry and Hardhat above for usage.
function requestLinkFromFaucet(address to, uint256 amount) external returns (bool success);
Requests LINK tokens from the faucet. The provided amount of tokens are transferred to provided destination address.
to
(address): The address to which LINK tokens are to be sent.amount
(uint256): The amount of LINK tokens to send.
success
(bool): Returnstrue
if the transfer of tokens was successful, otherwisefalse
.
function getSupportedTokens(uint64 chainSelector) external view returns (address[] memory tokens);
Gets a list of token addresses that are supported for cross-chain transfers by the simulator.
chainSelector
(uint64): The unique CCIP Chain Selector.
tokens
(address[]): Returns a list of token addresses that are supported for cross-chain transfers by the simulator.
function isChainSupported(uint64 chainSelector) public pure returns (bool supported);
Checks whether the provided chainSelector
is supported by the simulator.
chainSelector
(uint64): The unique CCIP Chain Selector.
supported
(bool): Returns true ifchainSelector
is supported by the simulator.
function supportNewToken(address tokenAddress) external;
Allows user to support any new token, besides CCIP BnM and CCIP LnM, for cross-chain transfers.
tokenAddress
(address): The address of the token to add to the list of supported tokens.
Note: the RPC URLs here must point to archive nodes.
Note also that in Foundry, scripts are generally written in Solidity, which may be different from what you've come to expect after using tools like Hardhat.
pragma solidity ^0.8.19;
import {CCIPLocalSimulatorFork} from "@chainlink/local/src/ccip/CCIPLocalSimulatorFork.sol";
contract Demo is Test {
CCIPLocalSimulatorFork public ccipLocalSimulatorFork;
uint256 sepoliaFork;
uint256 arbSepoliaFork;
function setUp() public {
string memory ETHEREUM_SEPOLIA_RPC_URL = vm.envString("ETHEREUM_SEPOLIA_RPC_URL");
string memory ARBITRUM_SEPOLIA_RPC_URL = vm.envString("ARBITRUM_SEPOLIA_RPC_URL");
sepoliaFork = vm.createSelectFork(ETHEREUM_SEPOLIA_RPC_URL);
arbSepoliaFork = vm.createFork(ARBITRUM_SEPOLIA_RPC_URL);
ccipLocalSimulatorFork = new CCIPLocalSimulatorFork();
vm.makePersistent(address(ccipLocalSimulatorFork));
}
function testDemo() public {
sender.transferTokensPayLINK(arbSepoliaChainSelector, alice, address(ccipBnM), amountToSend);
ccipLocalSimulatorFork.switchChainAndRouteMessage(arbSepoliaFork);
}
}
function switchChainAndRouteMessage(uint256 forkId) external;
To be called after the sending of the cross-chain message (ccipSend
). Goes through the list of past logs and looks for the CCIPSendRequested
event. Switches to a destination network fork. Routes the sent cross-chain message on the destination network.
forkId
(uint256) - The ID of the destination network fork. This is the returned value ofcreateFork()
orcreateSelectFork()
function requestLinkFromFaucet(address to, uint256 amount) external returns (bool success);
Requests LINK tokens from the faucet. The provided amount of tokens are transferred to provided destination address.
to
(address): The address to which LINK tokens are to be sent.amount
(uint256): The amount of LINK tokens to send.
success
(bool): Returnstrue
if the transfer of tokens was successful, otherwisefalse
.
function getNetworkDetails(uint256 chainId) external view returns (Register.NetworkDetails memory);
Returns the default values for currently CCIP supported networks. If network is not present or some of the values are changed, user can manually add new network details using the setNetworkDetails
function.
chainId
(uint256) - The blockchain network chain ID. For example 11155111 for Ethereum Sepolia. Not CCIP chain selector.
- Tuple containing:
chainSelector
(uint64) - The unique CCIP Chain Selector.routerAddress
(address) - The address of the CCIP Router contract.linkAddress
(address) - The address of the LINK token.wrappedNativeAddress
(address) - The address of the wrapped native token that can be used for CCIP fees.ccipBnMAddress
(address) - The address of the CCIP BnM token.ccipLnMAddress
(address) - The address of the CCIP LnM token.
unction setNetworkDetails(uint256 chainId, Register.NetworkDetails memory networkDetails) external;
If network details are not present or some of the values are changed, user can manually add new network details using the setNetworkDetails
function.
chainId
(uint256) - The blockchain network chain ID. For example 11155111 for Ethereum Sepolia. Not CCIP chain selector.- Tuple containing:
chainSelector
(uint64) - The unique CCIP Chain Selector.routerAddress
(address) - The address of the CCIP Router contract.linkAddress
(address) - The address of the LINK token.wrappedNativeAddress
(address) - The address of the wrapped native token that can be used for CCIP fees.ccipBnMAddress
(address) - The address of the CCIP BnM token.ccipLnMAddress
(address) - The address of the CCIP LnM token.
import { getEvm2EvmMessage, requestLinkFromTheFaucet, routeMessage } from "@chainlink/local/scripts/CCIPLocalSimulatorFork";
// 1st Terminal: npx hardhat node
// 2nd Terminal: npx hardhat run ./scripts/myScript.ts --network localhost
async function main() {
const ETHEREUM_SEPOLIA_RPC_URL = process.env.ETHEREUM_SEPOLIA_RPC_URL; // Archive node
const ARBITRUM_SEPOLIA_RPC_URL = process.env.ARBITRUM_SEPOLIA_RPC_URL; // Archive node
await network.provider.request({
method: "hardhat_reset",
params: [
{
forking: {
jsonRpcUrl: ETHEREUM_SEPOLIA_RPC_URL,
blockNumber: 5663645,
},
},
],
});
const linkAmountForFees = 5000000000000000000n; // 5 LINK
await requestLinkFromTheFaucet(linkTokenAddressSepolia, await CCIPSender_Unsafe.getAddress(), linkAmountForFees);
const tx = await CCIPSender_Unsafe.send(CCIPReceiver_Unsafe.target, textToSend, arbSepoliaChainSelector, ccipBnMTokenAddressSepolia, amountToSend);
const receipt = await tx.wait();
if (!receipt) return;
const evm2EvmMessage = getEvm2EvmMessage(receipt);
console.log("-------------------------------------------");
await network.provider.request({
method: "hardhat_reset",
params: [
{
forking: {
jsonRpcUrl: ARBITRUM_SEPOLIA_RPC_URL,
blockNumber: 33079804,
},
},
],
});
// We must redeploy it because of the network reset but it will be deployed to the same address because of the CREATE opcode: address = keccak256(rlp([sender_address,sender_nonce]))[12:]
CCIPReceiver_Unsafe = await CCIPReceiver_UnsafeFactory.deploy(ccipRouterAddressArbSepolia);
if (!evm2EvmMessage) return;
await routeMessage(ccipRouterAddressArbSepolia, evm2EvmMessage);
}
async function requestLinkFromTheFaucet(linkAddress: string, to: string, amount: bigint): Promise<string>;
Requests LINK tokens from the faucet and returns the transaction hash.
linkAddress
(string) - The address of the LINK contract on the current network.to
(string) - The address to send LINK to.amount
(bigint) - The amount of LINK to request.
Promise<string>
- Promise resolving to the transaction hash of the fund transfer.
function getEvm2EvmMessage(receipt: TransactionReceipt): Evm2EvmMessage | null;
Parses a transaction receipt to extract the sent message. Scans through transaction logs to find a CCIPSendRequested
event and then decodes it to Evm2EvmMessage.
receipt
(TransactionReceipt) - The transaction receipt from theccipSend
call.
Evm2EvmMessage | null
- Returns either the sent message or null if provided receipt does not containCCIPSendRequested
log. Evm2EvmMessage tuple:sourceChainSelector
(bigint)sender
(string)receiver
(string)sequenceNumber
(bigint)gasLimit
(bigint)strict
(boolean)nonce
(bigint)feeToken
(string)feeTokenAmount
(bigint)data
(string)tokenAmounts
([])sourceTokenData
([])messageId
(string)
async function routeMessage(routerAddress: string, evm2EvmMessage: Evm2EvmMessage): Promise<void>;
Routes the sent message from the source network (got it from the getEvm2EvmMessage
function) on the destination (current) network.
routerAddress
(string) - The address of the destination Router contract (Router on the current network).evm2EvmMessage
(Evm2EvmMessage) - Sent cross-chain message, (got from thegetEvm2EvmMessage
function).
Promise<void>
- Either resolves with no value if the message is successfully routed, or reverts.
You can check our current examples for reference:
- Unsafe Token And Data Transfer Test in Foundry
- Unsafe Token And Data Transfer Test in Hardhat
- Unsafe Token And Data Transfer Script in Hardhat
- Unsafe Token Transfer With Native Coin Test in Foundry
- Ping Pong example Test in Foundry (Reply cross-chain messages)
And also recreated test examples in Foundry from the Official Chainlink Documentation:
Forking examples with Foundry:
User must provide ETHEREUM_SEPOLIA_RPC_URL
and ARBITRUM_SEPOLIA_RPC_URL
.
- Ping Pong example Fork Test in Foundry (Reply cross-chain messages)
- Token Transferor Fork Test in Foundry
Forking examples with Hardhat:
User must provide ETHEREUM_SEPOLIA_RPC_URL
and ARBITRUM_SEPOLIA_RPC_URL
.
To run this example:
- In the first terminal window run:
npx hardhat node
- In the second terminal window run:
npx hardhat run ./scripts/examples/UnsafeTokenAndDataTransferFork.ts --network localhost
To get started:
- Clone this repo.
- Run
npm i && forge install
to install dependencies. - Run
npx hardhat test
to run Hardhat tests. - Run
forge test
to run Foundry tests.
Contributions are welcome, feel free to raise issues and/or open pull requests.
Thank you!