Skip to content

smartcontractkit/docs-cct-foundry

Repository files navigation

CCIP Cross-Chain Token Deployment and Registration Scripts

NOTE: This repository represents an educational example to use a Chainlink system, product, or service and is provided to demonstrate how to interact with Chainlink’s systems, products, and services to integrate them into your own. This template is provided “AS IS” and “AS AVAILABLE” without warranties of any kind, it has not been audited, and it may be missing key checks or error handling to make the usage of the system, product or service more clear. Do not use the code in this example in a production environment without completing your own audits and application of best practices. Neither Chainlink Labs, the Chainlink Foundation, nor Chainlink node operators are responsible for unintended outputs that are generated due to errors in code.

Foundry scripts for deploying and managing cross-chain tokens using Chainlink CCIP.

Prerequisites

Everything a fresh machine needs, in one place:

Tool Needed for Check
Foundry (forge, cast) building, testing, and every deploy/config script forge --version
Node.js + npm installing the Solidity dependencies (npm install) npm --version
make the golden-path targets in the Makefile (preinstalled on macOS/Linux; on Windows use WSL) make --version
bash the thin wrapper scripts under script/config/ bash --version
curl + jq only the chain-config sync tooling (fetching the CCIP API) — deploys don't use them curl --version, jq --version

The chain-config sync tooling (make discover / add-chain / sync / sync-check / doctor) needs no RPC URL, no keystore, and no API key — it only reads the public CCIP API. make tools runs this same presence check and prints install hints for anything missing.

  1. Install Foundry

  2. Install dependencies:

    npm install
  3. Create an encrypted Foundry keystore (if you don't have one already):

    cast wallet import your_keystore_name --interactive
  4. Set up environment variables in .env. You can copy .env.example to .env and fill in your values:

    cp .env.example .env
    # Keystore name (created via `cast wallet import`)
    KEYSTORE_NAME=your_keystore_name
    
    # RPC URLs
    ETHEREUM_SEPOLIA_RPC_URL=your_eth_sepolia_rpc
    MANTLE_SEPOLIA_RPC_URL=your_mantle_sepolia_rpc
    
    # Etherscan API key (required only if you pass --verify to deployment scripts)
    ETHERSCAN_API_KEY=your_etherscan_api_key
  5. Load environment variables:

    source .env
  6. Build the project:

    forge build

Deployment Flow

Step 1: Deploy Token (on both chains)

Configure token parameters in script/input/token.json (see the Configuration section), or override any field with environment variables:

Env var Default (from token.json)
TOKEN_NAME .name
TOKEN_SYMBOL .symbol
TOKEN_DECIMALS .decimals
TOKEN_MAX_SUPPLY .maxSupply
TOKEN_PRE_MINT .preMint
TOKEN_PRE_MINT_RECIPIENT broadcaster (if TOKEN_PRE_MINT > 0)
CCIP_ADMIN_ADDRESS msg.sender (broadcaster)
# Deploy on Ethereum Sepolia
forge script \
  script/deploy/DeployToken.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast \
  --verify

# Deploy on Mantle Sepolia
forge script \
  script/deploy/DeployToken.s.sol \
  --rpc-url \
  $MANTLE_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast \
  --verify

Note: --verify requires ETHERSCAN_API_KEY to be set (see Prerequisites). Etherscan API v2 supports all chains with a single key.

Optional: Set ROLES_RECIPIENT to grant mint/burn roles to a specific address (defaults to the deployer).

After each deployment, the token address is automatically saved to:

script/deployments/tokens/{CHAIN_NAME_IDENTIFIER}/{timestamp}-{SYMBOL}-Token.json

The file uses the env var name as the key (e.g. ETHEREUM_SEPOLIA_TOKEN). If you need to retrieve the deployed address later, open the file — the key is the env var name and the value is the address, so you can copy both directly into an export command. The script/deployments/ directory is ignored by .gitignore — files are local to each user.

A broadcast deploy also records the address in the address registry (addresses/<chainId>.json), so subsequent scripts resolve the token automatically — no export needed. Re-running the deploy on the same chain is refused while the registry holds a live address (set FORCE_REDEPLOY=true to deploy a replacement).

To override the registry address for a session, choose one approach:

# Option A: export for the session (persists across all commands in the current terminal)
export ETHEREUM_SEPOLIA_TOKEN=0x...
export MANTLE_SEPOLIA_TOKEN=0x...

# Option B: inline alias per command (no export needed; applies to that one command only)
TOKEN=0x... forge script script/setup/ClaimAdmin.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account $KEYSTORE_NAME --broadcast

Step 2: Deploy Token Pools (on both chains)

Burn & Mint Pool

Tokens are burned on source, minted on destination.

# Deploy pool on Ethereum Sepolia
forge script \
  script/deploy/DeployBurnMintTokenPool.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast \
  --verify

# Deploy pool on Mantle Sepolia
forge script \
  script/deploy/DeployBurnMintTokenPool.s.sol \
  --rpc-url \
  $MANTLE_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast \
  --verify

After each deployment, the pool address is automatically saved to:

script/deployments/token-pools/{CHAIN_NAME_IDENTIFIER}/{timestamp}-{SYMBOL}-BurnMintTokenPool.json

The file records the pool address under {CHAIN_NAME_IDENTIFIER}_TOKEN_POOL and its bound token address under {CHAIN_NAME_IDENTIFIER}_TOKEN.

Optional: Set POOL_HOOKS=0x... to attach an AdvancedPoolHooks contract at deploy time. Set DECIMALS=<n> if your token does not implement the optional decimals() ERC20 function — the script will fall back to this value and fail if neither is available.

The script also attempts to call grantMintAndBurnRoles on the token to grant the pool mint and burn rights. If the token does not implement this function, the script will print instructions to grant the roles manually.

Lock & Release Pool

Use the following when you don't have burn/mint rights on the source chain token (e.g. it was issued by a third party). Two patterns are supported:

Pattern A — Lock on Source, Mint on Destination

Token was originally issued on one chain; you control the token on the destination and can grant mint rights.

The ERC20LockBox is only needed on the chain where tokens are locked. The destination chain uses a standard BurnMintTokenPool, which requires mint/burn rights on the destination token.

# 1. Deploy ERC20LockBox first (pool address isn't known yet)
forge script \
  script/deploy/DeployERC20LockBox.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast \
  --verify
# Optional: add AUTHORIZED_CALLERS=<DEPLOYER_OR_TOKEN_ISSUER_EOA> to the command above to authorize initial liquidity deposits/withdrawals

# 2. Deploy LockRelease pool, passing the lockbox address from step 1
LOCK_BOX=<LOCKBOX_ADDRESS> \
  forge script \
  script/deploy/DeployLockReleaseTokenPool.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast \
  --verify

# 3. Authorize the pool to call the lockbox (deposit/withdraw)
LOCK_BOX=<LOCKBOX_ADDRESS> \
  ADD_ADDRESSES=<POOL_ADDRESS> \
  forge script \
  script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# 4. BurnMint pool on Mantle Sepolia (minting side)
forge script \
  script/deploy/DeployBurnMintTokenPool.s.sol \
  --rpc-url \
  $MANTLE_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast \
  --verify
Pattern B — Lock on Source, Release on Destination

Token already exists on both chains independently.

Each chain needs its own ERC20LockBox and LockReleaseTokenPool.

# 1. Deploy ERC20LockBox on Ethereum Sepolia
forge script \
  script/deploy/DeployERC20LockBox.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast \
  --verify

# 2. Deploy ERC20LockBox on Mantle Sepolia
forge script \
  script/deploy/DeployERC20LockBox.s.sol \
  --rpc-url \
  $MANTLE_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast \
  --verify

# Optional: add AUTHORIZED_CALLERS=<DEPLOYER_OR_TOKEN_ISSUER_EOA> to either or both commands below for initial liquidity management

# 3. Deploy LockRelease pool on Ethereum Sepolia, passing its lockbox address from step 1
LOCK_BOX=<ETHEREUM_SEPOLIA_LOCKBOX_ADDRESS> \
  forge script \
  script/deploy/DeployLockReleaseTokenPool.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast \
  --verify

# 4. Authorize the Ethereum Sepolia pool on its lockbox
LOCK_BOX=<ETHEREUM_SEPOLIA_LOCKBOX_ADDRESS> \
  ADD_ADDRESSES=<ETHEREUM_SEPOLIA_POOL_ADDRESS> \
  forge script \
  script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# 5. Deploy LockRelease pool on Mantle Sepolia, passing its lockbox address from step 2
LOCK_BOX=<MANTLE_SEPOLIA_LOCKBOX_ADDRESS> \
  forge script \
  script/deploy/DeployLockReleaseTokenPool.s.sol \
  --rpc-url \
  $MANTLE_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast \
  --verify

# 6. Authorize the Mantle Sepolia pool on its lockbox
LOCK_BOX=<MANTLE_SEPOLIA_LOCKBOX_ADDRESS> \
  ADD_ADDRESSES=<MANTLE_SEPOLIA_POOL_ADDRESS> \
  forge script \
  script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol \
  --rpc-url \
  $MANTLE_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

The LockReleaseTokenPool requires the ERC20LockBox at deploy time, and the lockbox must authorize the pool (via UpdateAuthorizedCallers) before it can deposit/withdraw tokens. The deployment order is always: lockbox → pool → authorize pool on lockbox.

LOCK_BOX is required and must be the address of a deployed ERC20LockBox for the token. Optional: Set POOL_HOOKS=0x... to attach an already-deployed AdvancedPoolHooks contract at deploy time. Set DECIMALS=<n> if your token does not implement the optional decimals() ERC20 function. When deploying the lockbox, you can optionally set AUTHORIZED_CALLERS (CSV or JSON array) to authorize addresses immediately — useful for authorizing the deployer or token issuer to deposit/withdraw liquidity initially.

A broadcast deploy records the pool (and lockbox) address in the address registry, so subsequent scripts resolve it automatically — no export needed (the LockRelease pool deploy also resolves LOCK_BOX from the registry). To override the registry address for a session, choose one approach:

# Option A: export for the session (persists across all commands in the current terminal)
export ETHEREUM_SEPOLIA_TOKEN_POOL=0x...
export MANTLE_SEPOLIA_TOKEN_POOL=0x...

# Option B: inline alias per command (no export needed; applies to that one command only)
TOKEN_POOL=0x... forge script script/setup/SetPool.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account $KEYSTORE_NAME --broadcast

Each deployment is automatically saved:

  • ERC20LockBox → script/deployments/lock-boxes/{CHAIN_NAME_IDENTIFIER}/{timestamp}-{SYMBOL}-LockBox.json — keys: LOCK_BOX, {CHAIN_NAME_IDENTIFIER}_TOKEN
  • LockReleaseTokenPool → script/deployments/token-pools/{CHAIN_NAME_IDENTIFIER}/{timestamp}-{SYMBOL}-LockReleaseTokenPool.json — keys: {CHAIN_NAME_IDENTIFIER}_TOKEN_POOL, LOCK_BOX, {CHAIN_NAME_IDENTIFIER}_TOKEN

To verify the lockbox address is correctly attached to the pool:

forge script script/configure/GetLockBox.s.sol --rpc-url $MANTLE_SEPOLIA_RPC_URL

Step 3: Claim Admin (on both chains)

# On Ethereum Sepolia
forge script \
  script/setup/ClaimAdmin.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# On Mantle Sepolia
forge script \
  script/setup/ClaimAdmin.s.sol \
  --rpc-url \
  $MANTLE_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

Optional: Set CCIP_ADMIN_ADDRESS=0x... to specify the token's current admin address (defaults to the EOA broadcasting the transaction).

Step 4: Accept Admin Role (on both chains)

# On Ethereum Sepolia
forge script \
  script/setup/AcceptAdminRole.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# On Mantle Sepolia
forge script \
  script/setup/AcceptAdminRole.s.sol \
  --rpc-url \
  $MANTLE_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

Step 5: Apply Chain Updates (configure cross-chain routes)

Dry run first: omit --broadcast from any command in this step to simulate the apply — the script runs against the fork and prints exactly what it would do, without sending a transaction. Add --broadcast back once the output looks right.

# Configure Ethereum Sepolia → Mantle Sepolia
DEST_CHAIN=MANTLE_SEPOLIA \
  forge script \
  script/setup/ApplyChainUpdates.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Configure Mantle Sepolia → Ethereum Sepolia
DEST_CHAIN=ETHEREUM_SEPOLIA \
  forge script \
  script/setup/ApplyChainUpdates.s.sol \
  --rpc-url \
  $MANTLE_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Configure Ethereum Sepolia → Solana Devnet (non-EVM destination)
DEST_CHAIN=SOLANA_DEVNET \
  SOLANA_DEVNET_TOKEN_POOL=<SOLANA_TOKEN_POOL_ADDRESS> \
  SOLANA_DEVNET_TOKEN=<SOLANA_TOKEN_ADDRESS> \
  forge script \
  script/setup/ApplyChainUpdates.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

Non-EVM destinations: For non-EVM chains like Solana Devnet, supply the destination pool and token addresses via {DEST_CHAIN}_TOKEN_POOL and {DEST_CHAIN}_TOKEN (e.g. SOLANA_DEVNET_TOKEN_POOL, SOLANA_DEVNET_TOKEN). These are base58-encoded addresses — not 0x-prefixed EVM addresses. Rate limiting is not applicable for non-EVM destinations and is ignored.

This script is idempotent — if the destination chain is already configured on the pool, the existing config is removed and replaced automatically.

Rate limits resolve per direction through a two-rung ladder, matching the repo's inline > env > registry idiom:

  1. Rate-limit env vars set — the env values win, exactly as documented in the table below. This is the explicit override path (for example, an incident-response throttle). If the local chain config declares a diverging lanes{} policy for the destination, the script prints a one-line notice naming both values, and the closing output prints the exact make add-lane command to bring the declaration in line — make doctor WARNs until the two agree. An apply never writes lanes{} back: the declaration is owner intent, reconciled through a reviewed edit.
  2. Env vars unset — the buckets come from the declared lanes{} entry in config/chains/<local>.json (matched by the remote's config name, falling back to remoteSelector equality): capacity/rate drive the outbound bucket (enabled when either is non-zero), and the optional inbound{capacity,rate} block drives the inbound bucket. An absent inbound{} block keeps the default: disabled.

With neither env vars nor a lanes{} entry, rate limiting stays disabled (the historical default) and the console says so. The golden path is declare once, apply from the declaration: make add-lane (see Chain config tooling), then run the script with no rate-limit env vars.

To enable rate limits via the env override, pass the capacity and rate — isEnabled is automatically set to true when either value is provided:

# Ethereum Sepolia → Mantle Sepolia: enable both directions
DEST_CHAIN=MANTLE_SEPOLIA \
  OUTBOUND_RATE_LIMIT_CAPACITY=1000000000000000000000 \
  OUTBOUND_RATE_LIMIT_RATE=100000000000000000 \
  INBOUND_RATE_LIMIT_CAPACITY=1000000000000000000000 \
  INBOUND_RATE_LIMIT_RATE=100000000000000000 \
  forge script \
  script/setup/ApplyChainUpdates.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Mantle Sepolia → Ethereum Sepolia: enable both directions
DEST_CHAIN=ETHEREUM_SEPOLIA \
  OUTBOUND_RATE_LIMIT_CAPACITY=1000000000000000000000 \
  OUTBOUND_RATE_LIMIT_RATE=100000000000000000 \
  INBOUND_RATE_LIMIT_CAPACITY=1000000000000000000000 \
  INBOUND_RATE_LIMIT_RATE=100000000000000000 \
  forge script \
  script/setup/ApplyChainUpdates.s.sol \
  --rpc-url \
  $MANTLE_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Enable outbound only (Sepolia → Mantle)
DEST_CHAIN=MANTLE_SEPOLIA \
  OUTBOUND_RATE_LIMIT_CAPACITY=1000000000000000000000 \
  OUTBOUND_RATE_LIMIT_RATE=100000000000000000 \
  forge script \
  script/setup/ApplyChainUpdates.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Enable inbound only (Sepolia → Mantle)
DEST_CHAIN=MANTLE_SEPOLIA \
  INBOUND_RATE_LIMIT_CAPACITY=1000000000000000000000 \
  INBOUND_RATE_LIMIT_RATE=100000000000000000 \
  forge script \
  script/setup/ApplyChainUpdates.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast
Env var Required Description
DEST_CHAIN Yes Destination chain name (e.g. MANTLE_SEPOLIA)
TOKEN_POOL No Inline alias for the source chain pool address. Takes priority over {CHAIN}_TOKEN_POOL.
DEST_TOKEN_POOL No Inline alias for the destination chain pool address. Takes priority over {DEST_CHAIN}_TOKEN_POOL.
DEST_TOKEN No Inline alias for the destination chain token address. Takes priority over {DEST_CHAIN}_TOKEN.
OUTBOUND_RATE_LIMIT_CAPACITY No Token bucket capacity for outbound transfers
OUTBOUND_RATE_LIMIT_RATE No Token bucket refill rate (tokens/second) for outbound transfers
OUTBOUND_RATE_LIMIT_ENABLED No Override isEnabled explicitly (true/false; defaults to true when CAPACITY or RATE are set)
INBOUND_RATE_LIMIT_CAPACITY No Token bucket capacity for inbound transfers
INBOUND_RATE_LIMIT_RATE No Token bucket refill rate (tokens/second) for inbound transfers
INBOUND_RATE_LIMIT_ENABLED No Override isEnabled explicitly (true/false; defaults to true when CAPACITY or RATE are set)

Note: ApplyChainUpdates only configures the standard finality rate limit bucket. To configure the fast finality bucket, run UpdateRateLimiters with FAST_FINALITY=true after the lane is set up.

Declare vs apply: argument name mapping

make add-lane (which declares the policy into config/chains/<local>.json) and the apply scripts (ApplyChainUpdates, UpdateRateLimiters) name the same rate-limit values differently. When translating a declared lane into an env-override apply, map the arguments as follows:

make add-lane argument Apply-script env var
REMOTE DEST_CHAIN
CAPACITY OUTBOUND_RATE_LIMIT_CAPACITY
RATE OUTBOUND_RATE_LIMIT_RATE
INBOUND_CAPACITY INBOUND_RATE_LIMIT_CAPACITY
INBOUND_RATE INBOUND_RATE_LIMIT_RATE

LOCAL names the source chain — the chain whose pool is being configured. The apply scripts infer the source chain from the --rpc-url you pass (its block.chainid), so there is no LOCAL env var: point --rpc-url at the source chain's RPC.

To read the list of supported chains and their remote pool addresses:

forge script script/setup/GetSupportedChains.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL

Step 6: Set Pool (on both chains)

# On Ethereum Sepolia
forge script \
  script/setup/SetPool.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# On Mantle Sepolia
forge script \
  script/setup/SetPool.s.sol \
  --rpc-url \
  $MANTLE_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

Adding a New Chain

Supporting a new chain is a config edit, not a code change — three commands generate and verify config/chains/<name>.json from the live CCIP API (needs only curl + jq from the Prerequisites — no RPC URL, no keystore, no API key):

make discover FILTER=base            # 1. find the chain in the API catalog, note its NAME + SELECTOR
make add-chain CHAIN=ethereum-testnet-sepolia-base-1 SELECTOR=10344971235874465080   # 2. generate from the API
make doctor CHAIN=ethereum-testnet-sepolia-base-1   # 3. layered verification — re-run until green

CHAIN is the chain's canonical CCIP selectorName as shown by make discover (the API/registry name — e.g. ethereum-testnet-sepolia-base-1, not a bespoke base-sepolia); it becomes the file name config/chains/<CHAIN>.json and is validated against the API. SELECTOR is the explicit identity key, also from make discover — every fetch cross-checks both: a valid-but-wrong selector fails loudly as SELECTOR MISMATCH, and a non-canonical name as SELECTOR NAME MISMATCH, instead of silently writing another chain's contracts. New chains are discovered automatically from config/chains/HelperConfig scans the directory, so no Solidity edit is needed anywhere. For a newly added chain the chainNameIdentifier (and hence the rpcEnv and the <ID>_TOKEN/<ID>_TOKEN_POOL override prefix) is derived from the selectorName as UPPER_SNAKE — so it may differ in style from the six bundled chains' curated short forms (e.g. AVALANCHE_TESTNET_FUJI, not AVALANCHE_FUJI); add-chain prints the exact chainNameIdentifier and rpcEnv names it generated so you never have to guess (or open the JSON) which env var to export. add-chain prints your next steps: add the chain's RPC env var to .env, then deploy your token and pool there (Step 1 / Step 2). Then declare the lane policy with make add-lane LOCAL=<name> REMOTE=<remote> CAPACITY=<wei> RATE=<wei> BOTH=1, apply it on-chain via Step 5, and re-run make doctor — its lanes rung reconciles the declared policy against the pool. To retire a lane, make remove-lane LOCAL=<name> REMOTE=<remote> [BOTH=1] removes the declaration; that is a separate step from the on-chain removal (the pool's applyChainUpdates takes the selector in remoteChainSelectorsToRemove), and between the two make doctor WARNs that the on-chain lane is not declared.

Full details: Configuration overview, the per-field docs/config-schema.md, and the command + architecture reference docs/config-architecture.md.

Which command when

I want to... Run
See which chains exist / find a selector make discover FILTER=<term>
Onboard a new chain make add-chain CHAIN=<name> SELECTOR=<sel>, then make doctor CHAIN=<name>
Check whether any config drifted from the API (routine; what CI runs weekly) make sync-check (CI/automation: bash script/config/sync-check.sh for the 0/1/2 exit codes)
Inspect what the API currently has for one chain before changing anything make sync-preview CHAIN=<name>
Apply the API's current values make sync CHAIN=<name> / make sync-all
Declare a lane policy between two chains make add-lane LOCAL=<name> REMOTE=<remote> CAPACITY=<wei> RATE=<wei> [BOTH=1]
Retire a declared lane policy (undo of add-lane) make remove-lane LOCAL=<name> REMOTE=<remote> [BOTH=1]
Bring externally deployed contracts into the registry make adopt-token CHAIN=<name> TOKEN=<addr> [TOKEN_POOL=<addr>]
Deep-verify one chain end to end (human health check) make doctor CHAIN=<name>
Restore canonical formatting after a raw forge script run make fmt-config

doctor and sync-check layer rather than overlap: doctor is the deep single-chain health check for a human (schema, identity, drift, RPC, on-chain code, registry warnings, mesh reciprocity, on-chain lane reconciliation), while sync-check is the fleet-wide drift verdict for routine use and CI.

Ownership Management (Optional)

The following scripts are not required for the core deployment flow but are useful when handing off control to a multisig or a different EOA after initial setup. All token ownership scripts auto-detect the correct ownership pattern — no configuration needed.

Transfer Ownership

Initiates an ownership transfer for a token, token pool, pool hooks, or lockbox. Use ENTITY_TYPE to specify which entity to transfer, and ADDRESS to specify the contract address. If ENTITY_TYPE is omitted, the contract is treated as a generic IOwnable — the same path used for tokenPool, poolHooks, and lockBox.

For token transfers, the script auto-detects the token type and calls the appropriate function:

Detection Token type Transfer action Accept required?
pendingDefaultAdmin() succeeds CrossChainToken beginDefaultAdminTransfer Yes — run AcceptOwnership
pendingOwner() + owner() succeed OZ Ownable2Step transferOwnership Yes — run AcceptOwnership
owner() only (no pendingOwner()) ConfirmedOwner or plain Ownable transferOwnership Yes for ConfirmedOwner; plain Ownable transfers immediately
Neither BurnMintERC20 v1 (plain AccessControl) grantRole + revokeRole (atomic, 1-step) No

For tokenPool, poolHooks, and lockBox: uses Chainlink's ConfirmedOwner (two-step) — always requires AcceptOwnership.

Step 1 — initiate (run as current owner/admin):

# Omit ENTITY_TYPE to use the generic IOwnable path (works for any tokenPool/poolHooks/lockBox)
ADDRESS=0xYourPool NEW_OWNER=0xNewOwner \
  forge script \
  script/setup/transfer-ownership/TransferOwnership.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast

# Or specify ENTITY_TYPE for a named label in the output
# Token pool
ENTITY_TYPE=tokenPool ADDRESS=0xYourPool NEW_OWNER=0xNewOwner \
  forge script \
  script/setup/transfer-ownership/TransferOwnership.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast

# Token
ENTITY_TYPE=token ADDRESS=0xYourToken NEW_OWNER=0xNewOwner \
  forge script \
  script/setup/transfer-ownership/TransferOwnership.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast

# Pool hooks
ENTITY_TYPE=poolHooks ADDRESS=0xYourHooks NEW_OWNER=0xNewOwner \
  forge script \
  script/setup/transfer-ownership/TransferOwnership.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast

# LockBox
ENTITY_TYPE=lockBox ADDRESS=0xYourLockBox NEW_OWNER=0xNewOwner \
  forge script \
  script/setup/transfer-ownership/TransferOwnership.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast

Step 2 — accept (run as NEW_OWNER):

# Omit ENTITY_TYPE to use the generic IOwnable path
ADDRESS=0xYourPool \
  forge script \
  script/setup/transfer-ownership/AcceptOwnership.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast

# Or specify ENTITY_TYPE for a named label in the output
# Token pool
ENTITY_TYPE=tokenPool ADDRESS=0xYourPool \
  forge script \
  script/setup/transfer-ownership/AcceptOwnership.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast

# Token
ENTITY_TYPE=token ADDRESS=0xYourToken \
  forge script \
  script/setup/transfer-ownership/AcceptOwnership.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast

# Pool hooks
ENTITY_TYPE=poolHooks ADDRESS=0xYourHooks \
  forge script \
  script/setup/transfer-ownership/AcceptOwnership.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast

# LockBox
ENTITY_TYPE=lockBox ADDRESS=0xYourLockBox \
  forge script \
  script/setup/transfer-ownership/AcceptOwnership.s.sol \
  --rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
  --account $KEYSTORE_NAME \
  --broadcast

For BurnMintERC20 v1, step 2 exits early — the transfer was already atomic. For plain Ownable, step 1 completes immediately — step 2 will revert on-chain.

Transfer Token Admin Role

Initiates a transfer of the CCIP token admin role to a new address. This is step 1 of a two-step process — the new admin must run AcceptAdminRole to complete it.

NEW_ADMIN=0xNewAdminAddress \
  forge script \
  script/setup/TransferTokenAdminRole.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

Optional: Set TOKEN=0x... to override the token address (defaults to the {CHAIN}_TOKEN env var).

Token Operations

Mint Tokens

forge script \
  script/operations/MintTokens.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

Optional: Set AMOUNT to override the amount to mint (defaults to tokenAmountToMint from script/input/token.json). Set MINT_RECEIVER to mint to a different address (defaults to the EOA broadcasting the transaction).

Transfer Tokens Cross-Chain

Install ccip-cli globally if not already installed:

npm install -g @chainlink/ccip-cli

Minimum version: This README assumes @chainlink/ccip-cli >= 1.5.0 (supports --extra finality=...). Verify your version with:

ccip-cli --version

First, export the router address for the source chain — find it in HelperConfig.s.sol or the CCIP Directory, for example:

export ETHEREUM_SEPOLIA_ROUTER=0x...
# Receiver address
export RECEIVER=0xYourReceiverAddress

# The Foundry scripts use smallest-unit amounts (wei-like). In the ccip-cli example below, we use a compact, human-readable token amount
ccip-cli send \
  --source ethereum-testnet-sepolia \
  --router $ETHEREUM_SEPOLIA_ROUTER \
  --dest ethereum-testnet-sepolia-mantle-1 \
  --transfer-tokens $ETHEREUM_SEPOLIA_TOKEN=1.23 \
  --receiver $RECEIVER \
  --wallet foundry:$KEYSTORE_NAME

Finality options (--extra finality=...):

  • finality=finalized (default): Wait for full finality.
  • finality=safe: Use Fast Confirmation Rule (wait for the safe head).
  • finality=<blockDepth>: Wait for N block confirmations (example: finality=5).

Omit -x finality=<n> to use default finality. When set, <n> must be greater than or equal to the pool's configured block depth (see Set Finality Config). Pass --fee-token LINK to pay fees with LINK (defaults to the native network token).

See the CCIP CLI docs for more details.

Deposit to LockBox

Manually deposit tokens into an ERC20LockBox. Useful for token issuers managing liquidity.

LOCK_BOX=0x... \
  forge script \
  script/operations/DepositToLockBox.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

Optional: Set AMOUNT to override the amount to deposit (defaults to tokenAmountToTransfer from script/input/token.json). Requires the broadcaster to be an authorized caller on the lockbox.

Withdraw from LockBox

Manually withdraw tokens from an ERC20LockBox. Useful for token issuers managing liquidity.

LOCK_BOX=0x... \
  forge script \
  script/operations/WithdrawFromLockBox.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

By default, withdraws the entire lockbox balance. Optional: Set AMOUNT to withdraw a specific amount instead. Set RECIPIENT=0x... to send withdrawn tokens to a different address (defaults to broadcaster). Requires the broadcaster to be an authorized caller on the lockbox.

Get Fee Token Balances

Inspect the fee token balances held by a token pool. Run this before WithdrawFeeTokens — it prints each token's balance and a pre-filled withdrawal command for any non-zero tokens.

FEE_TOKENS="0xTokenA,0xTokenB" \
  forge script \
  script/operations/GetFeeTokenBalances.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL

Withdraw Fee Tokens

Withdraws accrued fee token balances from a token pool to a specified recipient. Only callable by the pool owner or the designated fee admin.

Note: Pool-level fee accrual and withdrawal are introduced in TokenPool v2.0. If run against a v1 pool, the script will exit with an informative message and suggest using FeeQuoter instead.

This script makes no assumptions about which token(s) have accumulated as fees — you must explicitly specify them via FEE_TOKENS. Accepts a comma-separated list or JSON array.

# Single fee token
RECIPIENT=0xYourAddress \
  FEE_TOKENS="0xTokenThatAccruedFees" \
  forge script \
  script/operations/WithdrawFeeTokens.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Multiple fee tokens
RECIPIENT=0xYourAddress \
  FEE_TOKENS="0xFirstFeeToken,0xSecondFeeToken" \
  forge script \
  script/operations/WithdrawFeeTokens.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast
Env var Required Description
FEE_TOKENS Yes CSV or JSON array of ERC20 token addresses to withdraw
RECIPIENT No Address to receive the withdrawn fee tokens (defaults to the broadcaster)

The pool token address is printed at runtime for reference so you can identify whether to include it in FEE_TOKENS.

Optional Configuration

Manage Dynamic Config

Reads or updates the dynamic configuration on a token pool: the CCIP router, rate limit admin, and fee admin.

View Dynamic Config
forge script script/configure/dynamic-config/GetDynamicConfig.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL
Set Dynamic Config
ROUTER=0xYourRouterAddress \
  forge script \
  script/configure/dynamic-config/SetDynamicConfig.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast
Env var Required Description
ROUTER No The CCIP router address to set on the pool (default: current on-chain value)
RATE_LIMIT_ADMIN No Rate limit admin address (default: current on-chain value, then broadcaster)
FEE_ADMIN No Fee admin address (default: current on-chain value, then broadcaster). Set to address(0) to restrict fee withdrawal to the owner only

Manage Finality Config

Note: Requires TokenPool v2.0 or later. The finality config controls which fast finality modes are accepted for cross-chain transfers. Setting it to WAIT_FOR_FINALITY (no env vars, the default) disables fast finality transfers.

View Finality Config
forge script script/configure/finality-config/GetFinalityConfig.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL
Set Finality Config

Best practice: When enabling fast finality, consider configuring the fast finality bucket rate limits at the same time. If the fast finality bucket is not configured, fast finality transfers fall back to the standard finality bucket. Configuring it explicitly gives you isolated, independently tuned rate limits for fast finality transfers — useful when their volume or risk profile differs from standard finality transfers.

# Set block depth and configure the fast finality rate limit bucket:
BLOCK_DEPTH=5 \
  DEST_CHAIN=MANTLE_SEPOLIA \
  OUTBOUND_RATE_LIMIT_CAPACITY=1000000000000000000000 \
  OUTBOUND_RATE_LIMIT_RATE=100000000000000000 \
  INBOUND_RATE_LIMIT_CAPACITY=1000000000000000000000 \
  INBOUND_RATE_LIMIT_RATE=100000000000000000 \
  forge script \
  script/configure/finality-config/SetFinalityConfig.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Set block depth only (no rate limit changes):
BLOCK_DEPTH=5 \
  forge script \
  script/configure/finality-config/SetFinalityConfig.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Set WAIT_FOR_SAFE mode and view current rate limits for a lane (no update):
WAIT_FOR_SAFE=true \
  DEST_CHAIN=MANTLE_SEPOLIA \
  forge script \
  script/configure/finality-config/SetFinalityConfig.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Combine BLOCK_DEPTH and WAIT_FOR_SAFE (pool accepts either mode simultaneously):
BLOCK_DEPTH=5 \
  WAIT_FOR_SAFE=true \
  forge script \
  script/configure/finality-config/SetFinalityConfig.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Reset to default finality (disables fast finality transfers):
forge script \
  script/configure/finality-config/SetFinalityConfig.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

When DEST_CHAIN is provided, the script logs the current rate limits before applying any changes, and the updated state after. Each direction is shown independently: the fast finality bucket is displayed for directions where it is enabled; the standard finality bucket (fallback) is displayed for directions where it is not.

Env var Required Description
BLOCK_DEPTH No Number of block confirmations for fast finality (1–65535). Can be combined with WAIT_FOR_SAFE to allow both modes simultaneously. Omit both to reset to default finality.
WAIT_FOR_SAFE No Set to true to use the safe head for fast finality. Can be combined with BLOCK_DEPTH to allow both modes simultaneously.
DEST_CHAIN No Remote chain whose lane is queried/updated (e.g. MANTLE_SEPOLIA). Required when any rate limit var is set; if omitted, the rate limiter section is skipped entirely
OUTBOUND_RATE_LIMIT_CAPACITY No uint128, outbound token bucket capacity (fast finality bucket)
OUTBOUND_RATE_LIMIT_RATE No uint128, outbound token bucket refill rate (tokens/second)
OUTBOUND_RATE_LIMIT_ENABLED No Override isEnabled explicitly (true/false; defaults to true when CAPACITY or RATE are set)
INBOUND_RATE_LIMIT_CAPACITY No uint128, inbound token bucket capacity (fast finality bucket)
INBOUND_RATE_LIMIT_RATE No uint128, inbound token bucket refill rate (tokens/second)
INBOUND_RATE_LIMIT_ENABLED No Override isEnabled explicitly (true/false; defaults to true when CAPACITY or RATE are set)

Manage LockRelease Liquidity (v1.x pools)

Applies to LockRelease pools on contract versions 1.5.0, 1.5.1, and 1.6.1 only. These versions hold the locked liquidity on the pool itself and manage it through a rebalancer: the pool owner appoints a rebalancer, and only that rebalancer may add or remove liquidity. On LockRelease pool version 2.0.0 the pool holds no liquidity — an external lock box does, so use Deposit to LockBox and Withdraw from LockBox instead. Burn & Mint pools have no liquidity to manage; they mint and burn.

The rebalancer model has three roles:

  • setRebalancer — the pool owner appoints the rebalancer.
  • provideLiquidity — the rebalancer adds liquidity. The pool pulls the tokens with transferFrom, so the token is approved to the pool first and then the liquidity is provided, in one step.
  • withdrawLiquidity — the rebalancer removes liquidity, which is transferred back to it. The pool reverts InsufficientLiquidity if its balance is below the requested amount.

Each script resolves the pool from the address registry (or the TOKEN_POOL / {CHAIN}_TOKEN_POOL alias) and the token from the pool's getToken(). The write scripts refuse, with a clear message, before broadcasting when the pool is the wrong type (not LockRelease) or the wrong version (2.0.0, which points you at the lock box), or when the broadcaster is not the pool's rebalancer.

View the rebalancer

forge script script/configure/liquidity/GetRebalancer.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL

This read-only script degrades gracefully: on a 2.0.0 LockRelease pool it prints the lock box pointer instead of a rebalancer, and on a non-LockRelease pool it explains that only LockRelease pools have a rebalancer.

Set the rebalancer

Broadcast as the pool owner:

REBALANCER=0xYourRebalancerAddress \
  forge script \
  script/configure/liquidity/SetRebalancer.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

Provide liquidity

Broadcast as the pool rebalancer. AMOUNT is in the token's smallest unit (wei):

AMOUNT=1000000000000000000 \
  forge script \
  script/configure/liquidity/ProvideLiquidity.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

Withdraw liquidity

Broadcast as the pool rebalancer:

AMOUNT=1000000000000000000 \
  forge script \
  script/configure/liquidity/WithdrawLiquidity.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast
Env var Script Required Description
REBALANCER SetRebalancer Yes Address to appoint as the pool's rebalancer.
AMOUNT ProvideLiquidity, WithdrawLiquidity Yes Amount of liquidity to add or remove, in the token's smallest unit (wei).

Manage Remote Pools

Remote pools represent the pool addresses registered on a given chain for each supported remote chain. When a pool is upgraded on a remote chain, the old address should be kept active until all inflight messages have completed, then removed.

View Remote Pools
DEST_CHAIN=MANTLE_SEPOLIA \
  forge script \
  script/configure/remote-pools/GetRemotePools.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL
Add a Remote Pool

Use after upgrading a pool on a remote chain. Both the old and new pool addresses can be active simultaneously to allow inflight messages to complete.

DEST_CHAIN=MANTLE_SEPOLIA \
  REMOTE_POOL_ADDRESS=0xNewRemotePoolAddress \
  forge script \
  script/configure/remote-pools/AddRemotePool.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast
Remove a Remote Pool

Warning: All inflight transactions from the removed pool will be rejected after removal. Ensure there are no inflight transactions before proceeding.

DEST_CHAIN=MANTLE_SEPOLIA \
  REMOTE_POOL_ADDRESS=0xOldRemotePoolAddress \
  forge script \
  script/configure/remote-pools/RemoveRemotePool.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

Deploy Advanced Pool Hooks

Use this script for enhanced security features like allowlists, CCV management, policy engine integration, and threshold-based validation.

Configure defaults in script/input/advanced-pool-hooks.json (see the Configuration section), or override any field with environment variables:

Env var Type Default (from advanced-pool-hooks.json)
ALLOWLIST CSV or JSON array .allowlist
AUTHORIZED_CALLERS CSV or JSON array .authorizedCallers
THRESHOLD_AMOUNT uint256 .thresholdAmount
POLICY_ENGINE address .policyEngine

Important: The allowlistEnabled flag is set immutably at deploy time based on whether ALLOWLIST is non-empty. If you deploy with an empty allowlist (the default), allowlist functionality is permanently disabled — subsequent calls to UpdateAllowList will always revert. To enable allowlisting, pass at least one address via ALLOWLIST at deploy time.

forge script \
  script/configure/allowlist/DeployAdvancedPoolHooks.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast \
  --verify

After deployment, the hooks address is automatically saved to:

script/deployments/advanced-pool-hooks/{CHAIN_NAME_IDENTIFIER}/{timestamp}-AdvancedPoolHooks.json

The file records the address under the POOL_HOOKS key.

Then pass the hooks address as POOL_HOOKS when deploying a new token pool, or connect it to an existing pool via UpdateAdvancedPoolHooks.

Get Advanced Pool Hooks

Reads and displays the AdvancedPoolHooks contract address currently attached to a token pool.

forge script script/configure/allowlist/GetAdvancedPoolHooks.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL

Connect Advanced Pool Hooks to a Token Pool

TOKEN_POOL=0x... \
  NEW_HOOK=0x... \
  forge script \
  script/configure/allowlist/UpdateAdvancedPoolHooks.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

Manage Allowlist

Add/Remove Addresses

Supports CSV or JSON array.

# Via AdvancedPoolHooks
POOL_HOOKS=0x... \
  ADD_ADDRESSES="0xAAA...,0xBBB..." \
  forge script \
  script/configure/allowlist/UpdateAllowList.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Remove addresses
POOL_HOOKS=0x... \
  REMOVE_ADDRESSES=0xAAA... \
  forge script \
  script/configure/allowlist/UpdateAllowList.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

If POOL_HOOKS is not set, the script falls back to calling directly on the TOKEN_POOL (v1 pools only).

View Current Allowlist
POOL_HOOKS=0x... \
  forge script \
  script/configure/allowlist/GetAllowList.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME
Check if an Address Is Allowlisted
POOL_HOOKS=0x... \
  CHECK_ADDRESS=0x... \
  forge script \
  script/configure/allowlist/IsAllowListed.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME

Manage Authorized Callers

AuthorizedCallers is used in two places:

  • AdvancedPoolHooks — authorized callers are the token pools permitted to invoke the hooks.
  • ERC20LockBox — authorized callers are the LockReleaseTokenPool contracts permitted to call deposit/withdraw.

Both use the same scripts, passing either POOL_HOOKS=<hooksAddress> or LOCK_BOX=<lockBoxAddress>.

Add/Remove Authorized Callers

Supports CSV or JSON array.

# Add callers — AdvancedPoolHooks
POOL_HOOKS=0x... \
  ADD_ADDRESSES="0xAAA...,0xBBB..." \
  forge script \
  script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Add callers — ERC20LockBox
LOCK_BOX=0x... \
  ADD_ADDRESSES=0xAAA... \
  forge script \
  script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Remove callers — AdvancedPoolHooks
POOL_HOOKS=0x... \
  REMOVE_ADDRESSES=0xAAA... \
  forge script \
  script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Remove callers — ERC20LockBox
LOCK_BOX=0x... \
  REMOVE_ADDRESSES=0xAAA... \
  forge script \
  script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast
View Current Authorized Callers
# AdvancedPoolHooks
POOL_HOOKS=0x... \
  forge script \
  script/configure/authorized-callers/GetAuthorizedCallers.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME

# ERC20LockBox
LOCK_BOX=0x... \
  forge script \
  script/configure/authorized-callers/GetAuthorizedCallers.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME

Manage Rate Limiters

View Current Rate Limits

Reads and displays the current rate limiter state for a token pool lane. Compatible with both v1 and v2 pools.

DEST_CHAIN=MANTLE_SEPOLIA \
  forge script \
  script/configure/rate-limiter/GetCurrentRateLimits.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL

Optional: Set FAST_FINALITY=true to query the fast finality bucket (v2 pools only). Each direction is shown independently: the fast finality bucket is displayed where it is enabled; the standard finality bucket (fallback) is displayed where it is not.

Update Rate Limits

Updates rate limiter configuration for a specific lane. Compatible with both v1 and v2 pools. The direction is inferred automatically from whichever OUTBOUND_* / INBOUND_* vars are set — no need to pass ENABLED separately. isEnabled defaults to true when CAPACITY or RATE are provided; pass ENABLED=false to explicitly disable.

The golden path for v2 lanes is to declare the policy in the local chain config and apply from the declaration: with no rate-limit env vars, a direction resolves from the lanes{} entry in config/chains/<local>.json — the standard bucket from capacity/rate (plus the optional inbound{} block), the fast finality bucket (FAST_FINALITY=true, 2.0.0 pools) from v2.fastFinality.outbound / v2.fastFinality.inbound. Env vars remain the explicit override for incident response: they win as-is, and when they disagree with (or are missing from) the declaration, the script prints a divergence notice plus a hand-edit hint with the applied values, and make doctor WARNs until the declaration is reconciled. Applies never write lanes{} back. See docs/config-schema.md.

# Enable both directions (ENABLED is optional — defaults to true when CAPACITY/RATE are set)
DEST_CHAIN=MANTLE_SEPOLIA \
  OUTBOUND_RATE_LIMIT_CAPACITY=1000000000000000000000 \
  OUTBOUND_RATE_LIMIT_RATE=100000000000000000 \
  INBOUND_RATE_LIMIT_CAPACITY=1000000000000000000000 \
  INBOUND_RATE_LIMIT_RATE=100000000000000000 \
  forge script \
  script/configure/rate-limiter/UpdateRateLimiters.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Disable outbound only
DEST_CHAIN=MANTLE_SEPOLIA \
  OUTBOUND_RATE_LIMIT_ENABLED=false \
  forge script \
  script/configure/rate-limiter/UpdateRateLimiters.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Disable inbound only
DEST_CHAIN=MANTLE_SEPOLIA \
  INBOUND_RATE_LIMIT_ENABLED=false \
  forge script \
  script/configure/rate-limiter/UpdateRateLimiters.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

# Disable both directions
DEST_CHAIN=MANTLE_SEPOLIA \
  OUTBOUND_RATE_LIMIT_ENABLED=false \
  INBOUND_RATE_LIMIT_ENABLED=false \
  forge script \
  script/configure/rate-limiter/UpdateRateLimiters.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast
Env var Required Description
DEST_CHAIN Yes Remote chain whose lane is being updated
OUTBOUND_RATE_LIMIT_CAPACITY To update outbound (unless declared in lanes{}) Token bucket capacity for outbound transfers
OUTBOUND_RATE_LIMIT_RATE To update outbound Token bucket refill rate (tokens/second) for outbound transfers
OUTBOUND_RATE_LIMIT_ENABLED No Override isEnabled explicitly (true/false; defaults to true when CAPACITY or RATE are set)
INBOUND_RATE_LIMIT_CAPACITY To update inbound (unless declared in lanes{}) Token bucket capacity for inbound transfers
INBOUND_RATE_LIMIT_RATE To update inbound Token bucket refill rate (tokens/second) for inbound transfers
INBOUND_RATE_LIMIT_ENABLED No Override isEnabled explicitly (true/false; defaults to true when CAPACITY or RATE are set)
FAST_FINALITY No true to update the fast finality bucket instead of the standard finality bucket (v2 only, default: false)

Manage Token Transfer Fee Config

Token pools v2.0 and later allow token issuers to configure fee parameters directly on the pool, overriding FeeQuoter defaults. If run against a v1 pool, these scripts will exit with an informative message — on v1, fee configuration is managed entirely by FeeQuoter and must be requested from the Chainlink team.

View Fee Config

Reads the raw stored fee configuration for a destination lane.

DEST_CHAIN=MANTLE_SEPOLIA \
  forge script \
  script/configure/fee-config/GetTokenTransferFeeConfig.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL
Set or Update Fee Config

All fee config env vars are optional. Each field resolves independently: an env var wins when set; an unset field takes the declared lanes.<remote>.v2.feeConfig.<field> from config/chains/<local>.json when the block declares it; otherwise it keeps the current on-chain value. The golden path for v2 lanes is to declare the whole fee config in v2.feeConfig and run the script with no fee env vars — the declaration drives the apply. Env vars remain the explicit override for incident response: a value that disagrees with the declaration is applied as-is with a per-field divergence notice plus a hand-edit hint, and make doctor WARNs until the declaration is reconciled (applies never write lanes{} back). When setting a fee config for the first time with no declaration and no env vars, unset fields default to 0.

DEST_CHAIN=MANTLE_SEPOLIA \
  DEST_GAS_OVERHEAD=50000 \
  DEST_BYTES_OVERHEAD=32 \
  FINALITY_FEE_USD_CENTS=0 \
  FAST_FINALITY_FEE_USD_CENTS=1000 \
  FINALITY_TRANSFER_FEE_BPS=0 \
  FAST_FINALITY_TRANSFER_FEE_BPS=1000 \
  forge script \
  script/configure/fee-config/UpdateTokenTransferFeeConfig.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast
Env var Required Description
DEST_CHAIN Yes Remote chain to configure fees for
DEST_GAS_OVERHEAD No Gas overhead charged on the destination chain (must be > 0; defaults to the declared v2.feeConfig value, then the current on-chain value)
DEST_BYTES_OVERHEAD No Data availability bytes overhead (defaults to the declared v2.feeConfig value, then the current on-chain value)
FINALITY_FEE_USD_CENTS No Flat fee in 0.01 USD units for finality transfers (defaults to the declared v2.feeConfig value, then the current on-chain value)
FAST_FINALITY_FEE_USD_CENTS No Flat fee in 0.01 USD units for fast finality transfers (defaults to the declared v2.feeConfig value, then the current on-chain value)
FINALITY_TRANSFER_FEE_BPS No Fee in basis points deducted from the transferred amount for finality transfers [0–9999] (defaults to the declared v2.feeConfig value, then the current on-chain value)
FAST_FINALITY_TRANSFER_FEE_BPS No Fee in basis points deducted from the transferred amount for fast finality transfers [0–9999] (defaults to the declared v2.feeConfig value, then the current on-chain value)
DISABLE No Set to true to disable the fee config for this lane, reverting the OnRamp to FeeQuoter defaults (default: false)
Disable Fee Config

Disabling the fee config for a lane causes the OnRamp to fall back to FeeQuoter defaults.

DEST_CHAIN=MANTLE_SEPOLIA \
  DISABLE=true \
  forge script \
  script/configure/fee-config/UpdateTokenTransferFeeConfig.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast

Manage CCV Config

Token pools v2.0 and later verify cross-chain messages through CCVs (Cross-Chain Verifiers), configured per lane on the pool's AdvancedPoolHooks contract: a required set of verifiers for each direction (outboundCCVs / inboundCCVs) plus an optional additional set that applies once a transfer reaches (is at or above) the threshold amount (thresholdOutboundCCVs / thresholdInboundCCVs). Because the config lives on the hooks contract, these scripts resolve it via pool.getAdvancedPoolHooks(). The setter refuses by name with a named require on a pre-2.0.0 pool (the CCV surface is 2.0.0-only) or when no hooks contract is wired; the getter degrades gracefully, printing an informative message instead of reverting.

View CCV Config

Reads the per-lane verifier arrays and the pool-global threshold.

forge script \
  script/configure/ccv/GetCCVConfig.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL
Set or Update CCV Config

Each verifier array and the threshold resolve independently through the same ladder as the fee and rate-limit scripts: an env var (a comma-separated address list) wins when set; an unset array takes the declared lanes.<remote>.v2.ccv.<field> from config/chains/<local>.json when the block declares it; otherwise it keeps the current on-chain value. The golden path for v2 lanes is to declare the CCV set in v2.ccv (and the threshold in the chain-level ccvThreshold) and run the script with no CCV env vars — the declaration drives the apply. The read-modify-write is important: applyCCVConfigUpdates replaces a lane's whole entry, so the script reads the current on-chain arrays first and overwrites only the arrays you declare — changing OUTBOUND_CCVS never wipes your inbound verifiers. Env vars remain the explicit override for incident response: a value that disagrees with the declaration (compared as a set, order-insensitive) is applied as-is with a divergence notice plus a hand-edit hint (the v2.ccv block has no make add-lane flag — reconcile it with a reviewed hand edit), and make doctor WARNs until reconciled (applies never write lanes{} back).

DEST_CHAIN=MANTLE_SEPOLIA \
  OUTBOUND_CCVS=0xVerifierA,0xVerifierB \
  INBOUND_CCVS=0xVerifierC \
  forge script \
  script/configure/ccv/UpdateCCVConfig.s.sol \
  --rpc-url \
  $ETHEREUM_SEPOLIA_RPC_URL \
  --account \
  $KEYSTORE_NAME \
  --broadcast
Env var Required Description
DEST_CHAIN For lane arrays Remote chain whose CCV set is being configured (omit to set only the pool-global threshold)
OUTBOUND_CCVS No Comma-separated required verifier addresses for outgoing messages (defaults to the declared v2.ccv.outboundCCVs, then the current on-chain value)
INBOUND_CCVS No Comma-separated required verifier addresses for incoming messages (defaults to the declared value, then on-chain)
THRESHOLD_OUTBOUND_CCVS No Additional outbound verifiers required at or above the threshold amount; requires a non-empty outbound base set
THRESHOLD_INBOUND_CCVS No Additional inbound verifiers required at or above the threshold amount; requires a non-empty inbound base set
CCV_THRESHOLD_AMOUNT No Pool-global transfer amount at or above which the threshold verifier sets apply (defaults to the declared chain-level ccvThreshold, then the current on-chain value; 0 = no threshold)

Supported Networks

EVM chains (source or destination):

  • Ethereum Sepolia (ETHEREUM_SEPOLIA)
  • Mantle Sepolia (MANTLE_SEPOLIA)

Non-EVM chains (destination only):

Non-EVM chains can only be used as the destination chain in ApplyChainUpdates — i.e. to register a non-EVM token pool on an EVM source chain. They cannot be used as source chains in this repo.

  • Solana Devnet (SOLANA_DEVNET)

Configuration

The repo keeps its cross-chain state as data, not code, in two git-visible stores:

  • config/chains/<selectorName>.json — one reviewed file per chain: the API-synced ccip{} address block + API-synced identity/metadata (displayName, chainFamily, environment, explorerUrl, nativeCurrencySymbol), the owner-written lanes{} policy subtree (which remotes this pool connects to, at what outbound rate limits — one entry per remote, keyed by the remote's selectorName, carrying its remoteSelector + capacity/rate), and the hand-authored keys (chainNameIdentifier, rpcEnv, confirmations, ccipBnM). Files are named by the canonical CCIP selectorName (e.g. ethereum-testnet-sepolia.json). HelperConfig reads them through src/config/ChainConfig.sol and discovers the chain list by scanning the directory, so adding a chain — or updating a CCIP address — is a reviewed config edit with zero Solidity changes.
  • addresses/<chainId>.json — the deployed-address registry (schema v2: active role pointers + versioned deployments), written automatically on a real broadcast via a single DeploymentRecorder call per artifact (user-specific, gitignored, local to the deploy machine; see addresses/11155111.example.json).

One writer per field: the CCIP REST API owns every field it serves in the git-tracked config/chains/*.json (via the sync) — the ccip{} addresses AND the identity/metadata fields (displayName, chainFamily, environment, explorerUrl, nativeCurrencySymbol); the lanes{} subtree is owner policy, written only by make add-lane (or a reviewed hand edit) and never by the API sync — which remotes to connect and at what rate limits is a decision, not an API fact; only the keys the API serves nothing for (chainNameIdentifier, rpcEnv, confirmations, ccipBnM) are hand-authored in reviewed PRs, and the join keys are guard-validated — so for that file a git diff is an unambiguous audit log. The deployed-address registry addresses/ is a separate, gitignored store (not a git audit trail); its integrity comes from a single writer — the DeploymentRecorder that emits the script/deployments/** ledger and the registry entry in one call. Environment variables remain available as overrides on top of both.

Reference docs: docs/config-schema.md is the per-field reference (every key in a chain config — identity, the ccip{} block, extras, and the non-EVM shape — with its type, writer, and whether it is API-synced, hand-authored, or deploy-written); docs/config-architecture.md is the make-command reference plus the layered architecture (brand-colored Mermaid diagrams: layering, sync data-flow, the one-writer store model, and the selectorName join). The operational how-to (discover → add-chain → sync → doctor) stays below.

Token Deployment Configuration

Default token parameters are in script/input/token.json. Deployment fields can be overridden with env vars (see Step 1). tokenAmountToMint and tokenAmountToTransfer are used as defaults by the Token Operations scripts:

{
  "name": "BnM Test",
  "symbol": "BnM-T",
  "decimals": 18,
  "maxSupply": 0,
  "preMint": 0,
  "tokenAmountToMint": 1000000000000000000000,
  "tokenAmountToTransfer": 1000000000000000000
}

Advanced Pool Hooks Configuration

Default hooks parameters are in script/input/advanced-pool-hooks.json. All fields can be overridden with env vars (see Deploy Advanced Pool Hooks):

{
  "allowlist": [],
  "thresholdAmount": 0,
  "policyEngine": "0x0000000000000000000000000000000000000000",
  "authorizedCallers": []
}

Chain config tooling - discover, add, sync, verify

Tooling under script/config/ keeps the config files true to the live CCIP API. The golden path is the repo Makefile: every target is a thin wrapper that sets FOUNDRY_PROFILE=sync for you (it enables ffi so Foundry can fetch the API via curl + jq; no RPC URL or keystore needed). The full command reference - each target's required args, the raw forge script / bash command it runs underneath, the 0/1/2 drift exit-code contract, and the architecture diagrams - is in docs/config-architecture.md.

make discover [FILTER=<term>]                        # list the API catalog vs your local configs
make add-chain CHAIN=<selectorName> SELECTOR=<sel>   # generate config/chains/<selectorName>.json from the API
make add-lane LOCAL=<a> REMOTE=<b> CAPACITY=<wei> RATE=<wei> [INBOUND_CAPACITY=<wei> INBOUND_RATE=<wei>] [BOTH=1]  # declare a lanes{} policy entry (inbound pair adds the inbound block; BOTH=1 adds the reciprocal)
make adopt-token CHAIN=<name> TOKEN=<addr> [TOKEN_POOL=<addr>]        # adopt externally deployed contracts into the address registry
make sync-preview CHAIN=<name>                        # fetch + log a chain's ccip{}, no write
make sync CHAIN=<name> / make sync-all               # rewrite API-served fields (ccip{} + identity/metadata) from the API
make sync-check [CHAIN=<name>]                        # read-only drift check (CI: bash script/config/sync-check.sh for 0/1/2)
make doctor CHAIN=<name>                              # layered [PASS]/[FAIL]/[WARN]/[SKIP] check of one chain
make fmt-config                                       # restore the canonical config format after a raw forge run

CHAIN= is the canonical selectorName (the file basename). The sync writes every API-served field — the ccip{} subtree plus the identity/metadata fields (displayName, chainFamily, environment, explorerUrl, nativeCurrencySymbol) — leaving every hand-authored key (chainNameIdentifier, rpcEnv, confirmations, ccipBnM) and the lanes{} policy subtree untouched, and re-canonicalizes as jq --indent 2 -S, so a no-drift make sync is a zero-diff no-op. Every fetch is guarded: the API chainId must equal the config's (SELECTOR MISMATCH) and the config name must equal the API selectorName (SELECTOR NAME MISMATCH); non-EVM chains (e.g. solana-devnet) skip the EVM address sync cleanly but still refresh their served identity/metadata. A scheduled workflow (.github/workflows/config-drift.yml) runs the drift check weekly: drift fails visibly, an unreachable API only warns.

make adopt-token brings externally deployed contracts into the deployed-address registry, and it is guarded too: everything is validated on-chain before anything is written (the token's registration path is probed, and a given pool must report a cataloged contract version per docs/pool-versions.md and manage exactly that token); see docs/enabling-existing-token.md.

make add-lane is the mirror-image writer for the lanes{} subtree: it writes only .lanes (same preserve-and-replace pattern the sync uses for .ccip), copying the remote's chainSelector into the entry as remoteSelector. It is guarded too: a duplicate lane is a logged no-op that leaves the file byte-identical, a self-lane (same chain, or two config files sharing one selector) is refused, and a lane to a remote whose pool is not deployed yet logs a WARN naming the missing deploy. make doctor proves the mesh on top: every lane must resolve to an existing config file with a matching selector, and a one-sided lane FAILs, naming both chains (add the reciprocal with BOTH=1); lanes to non-EVM chains are exempt from reciprocity (destination-only). With an RPC configured, the doctor's final lanes rung reconciles the declared policy against the on-chain pool itself, both directions (declared-but-not-applied, rate-limit drift including declared inbound{}/v2{} blocks, and on-chain-but-not-declared lanes are WARNs; see docs/config-architecture.md).

The declaration is also consumed: Step 5 run with no rate-limit env vars applies the declared lanes{} policy directly (declare once, apply from the declaration), while the rate-limit env vars remain the explicit override for incident response. An env-override apply that leaves the declaration missing or diverging prints the exact make add-lane command to bring it in line, and the doctor WARNs until it is — applies never write lanes{} back.

The tooling is tested twice over: forge test pins the API->config transform against a committed real API response (test/fixtures/ccip-api/) and proves the lanes{} subtree contracts (test/config/LaneConfig.t.sol: a lane survives a ccip sync, add-lane touches no other subtree, a one-sided lane fails the mesh rung from either side; the inbound block is written and nested policy blocks survive rewrites; test/config/VerifyChainLaneReconcile.t.sol proves the on-chain lanes rung per state, including the 1.5.0 version-dispatch path; test/setup/ApplyChainUpdatesLaneSource.t.sol proves the env > lanes{} rate-limit resolution ladder and the make add-lane remediation hint states), and bash script/config/test-tooling.sh is a re-runnable failure-path suite (unknown chain, overwrite refusal, invalid names, SELECTOR MISMATCH, SELECTOR NAME MISMATCH, NOT_FOUND, API-down, non-EVM skip, the sync-check exit contract, an offline end-to-end sync against a local fixture server, the Makefile golden-path guards, the add-lane preflights and guards, the mesh-reciprocity doctor verdicts, and the canonical-format + zero-diff guarantees).

Deployed-address registry — addresses/<chainId>.json (the default)

After a --broadcast deploy, every later script resolves the deployed addresses from the registry automatically — no export step. This now holds for all four artifacts: token, tokenPool, lockBox, and poolHooks (the last two previously had to be re-exported by hand). Resolution precedence (highest first), per role:

  1. Inline alias — TOKEN=0x... / TOKEN_POOL=0x... / LOCK_BOX=0x... / POOL_HOOKS=0x... on the command line
  2. Chain-scoped session export — ETHEREUM_SEPOLIA_TOKEN, MANTLE_SEPOLIA_TOKEN_POOL, ETHEREUM_SEPOLIA_LOCK_BOX, ...
  3. Address registryaddresses/<chainId>.jsonactive.<role> (the default path)

The registry is a schema-v2 file with two sub-stores: active (the single per-role pointer HelperConfig resolves) and deployments (uniquely-named entries whose key carries the pool's type and version — e.g. BnM-T_BurnMintTokenPool_2.0.0 — so distinct artifacts never collide in storage). One writer owns it: each deploy script makes a single DeploymentRecorder call that emits the script/deployments/** ledger and updates the registry, so the two never drift. active.<role> is single-valued: deploy two pools for the same symbol on one chain and the zero-export getters resolve the last one for both tokens (pass the other explicitly). See config-schema.md and deployed-addresses.md for the keying table and the two-store model.

The registry also guards against accidental redeploys: while it holds a live address under a deployments name, the corresponding deploy script refuses to run and prints the registered address. Set FORCE_REDEPLOY=true to deploy a replacement of the same name (the old address stays in the append-only script/deployments/ ledger; the registry itself is gitignored, so it is not in git history). Note active.tokenPool is what this repo last deployed, not proof of what CCIP routes through — the on-chain TokenAdminRegistry is the authority, and make doctor WARNs when they diverge.

Sharing addresses with your team

Both deployed-address stores are gitignored in this template. When you fork it into your own project, you MAY un-gitignore them to share addresses with colleagues and CI. The two stores warrant different advice — see deployed-addresses.md for the full two-store model.

  • Registry (addresses/<chainId>.json) — recommended for teams. It holds public addresses only, no secrets. Track it and every colleague plus CI resolves the same addresses on clone, with zero export.
  • History (script/deployments/) — optional. Be honest about what it is: it is write-only (nothing in this repo reads it), and it grows one file per deploy forever. Foundry's own broadcast/ directory already records every deploy with richer detail (and is itself gitignored, .gitignore:8). Track script/deployments/ only if you specifically want an in-repo, human-readable deploy log.

Guardrails (mandatory if you track the registry):

  1. Never commit local/anvil chains. Keep an explicit ignore for addresses/31337.json (and any other local chain id).
  2. The test suite writes real addresses/<chainId>.json files for scratch chain ids (e.g. 16602, and the 9000000xx throwaways). Today .gitignore hides them. If you un-ignore the registry, add explicit ignores for those scratch ids, or a stray test artifact gets committed. This is real: a leftover scratch registry file once bricked the local test suite while git status stayed clean.
  3. active is not authority. It records what this repo deployed most recently, not what is wired. The on-chain TokenAdminRegistry is the source of truth. Run make doctor CHAIN=<name> (in CI too) — it reconciles the registry pool against the wired pool and WARNs on divergence.
  4. Review registry diffs like config changes. Put addresses/ under CODEOWNERS, and gate mainnet chain-id files behind stricter approval.
  5. Do this in a single-deployment project, not a shared template clone where many developers push disposable fixtures.

Do not claim git gives an audit trail for the registry (it is gitignored), and do not tell people to trust the file over the on-chain TokenAdminRegistry.

Session exports — export VAR=0x... (overrides)

These are not stored in .env and are now optional: the registry covers the default flow. Use an export (or the chain-agnostic TOKEN / TOKEN_POOL inline aliases, see CLI inline vars) when you want to target a different contract than the registered one — e.g. an older deployment or a contract deployed outside this repo. Session exports last for the current terminal — values can always be recovered from addresses/<chainId>.json or script/deployments/.

Note: Do not add these to .env. An env var always beats the registry, so a stale .env value would silently target the wrong contract after a redeployment.

Token addresses — override after Step 1: Deploy Token:

Variable Chain
ETHEREUM_SEPOLIA_TOKEN Ethereum Sepolia
MANTLE_SEPOLIA_TOKEN Mantle Sepolia

Non-EVM destination token addresses — set before running Step 5: Apply Chain Updates when targeting a non-EVM chain. These are base58-encoded addresses, not 0x-prefixed:

Variable Chain
SOLANA_DEVNET_TOKEN Solana Devnet

Token pool addresses — override after Step 2: Deploy Token Pools:

Variable Chain
ETHEREUM_SEPOLIA_TOKEN_POOL Ethereum Sepolia
MANTLE_SEPOLIA_TOKEN_POOL Mantle Sepolia

Non-EVM destination token pool addresses — set before running Step 5: Apply Chain Updates when targeting a non-EVM chain. These are base58-encoded addresses, not 0x-prefixed:

Variable Chain
SOLANA_DEVNET_TOKEN_POOL Solana Devnet

CLI inline vars — VAR=value forge script ...

Prepended directly to a single forge script command. They apply to that one invocation only and do not affect the shell environment. Each script section documents its own inline vars in full — this table is an index.

Note: Do not add these to .env. The same stale-value problem applies: scripts check for zero/empty values to trigger fallback behavior, and a value sourced from .env would silently suppress that.

Variable Documented in Config file default
TOKEN_NAME, TOKEN_SYMBOL, TOKEN_DECIMALS, TOKEN_MAX_SUPPLY, TOKEN_PRE_MINT, TOKEN_PRE_MINT_RECIPIENT, CCIP_ADMIN_ADDRESS Step 1: Deploy Token script/input/token.json
ROLES_RECIPIENT Step 1: Deploy Token
TOKEN Chain-agnostic inline alias for {CHAIN}_TOKEN. Accepted by all scripts that resolve a deployed token address. Takes priority over the session export.
TOKEN_POOL Chain-agnostic inline alias for {CHAIN}_TOKEN_POOL. Accepted by all scripts that resolve a deployed pool address. Takes priority over the session export.
DEST_TOKEN_POOL Destination-chain pool alias used by ApplyChainUpdates. Takes priority over {DEST_CHAIN}_TOKEN_POOL.
DEST_TOKEN Destination-chain token alias used by ApplyChainUpdates. Takes priority over {DEST_CHAIN}_TOKEN.
POOL_HOOKS Burn & Mint Pool, Lock & Release Pool, Manage Allowlist, Manage Authorized Callers
LOCK_BOX Lock & Release Pool, Deposit to LockBox, Withdraw from LockBox, Manage Authorized Callers
DECIMALS Burn & Mint Pool, Lock & Release Pool
AUTHORIZED_CALLERS Lock & Release Pool, Deploy Advanced Pool Hooks script/input/advanced-pool-hooks.json (deploy only)
CCIP_ADMIN_ADDRESS Step 3: Claim Admin
DEST_CHAIN Step 5: Apply Chain Updates, Manage Remote Pools, Manage Rate Limiters, Manage Finality Config, Manage Token Transfer Fee Config
OUTBOUND_RATE_LIMIT_CAPACITY, OUTBOUND_RATE_LIMIT_RATE, OUTBOUND_RATE_LIMIT_ENABLED Step 5: Apply Chain Updates, Manage Rate Limiters, Manage Finality Config
INBOUND_RATE_LIMIT_CAPACITY, INBOUND_RATE_LIMIT_RATE, INBOUND_RATE_LIMIT_ENABLED Step 5: Apply Chain Updates, Manage Rate Limiters, Manage Finality Config
FAST_FINALITY Manage Rate Limiters, Manage Finality Config
AMOUNT Mint Tokens, Deposit to LockBox, Withdraw from LockBox script/input/token.json
MINT_RECEIVER Mint Tokens
RECIPIENT Withdraw from LockBox, Withdraw Fee Tokens
FEE_TOKENS Get Fee Token Balances, Withdraw Fee Tokens
ROUTER, RATE_LIMIT_ADMIN, FEE_ADMIN Manage Dynamic Config
BLOCK_DEPTH, WAIT_FOR_SAFE Manage Finality Config
REMOTE_POOL_ADDRESS Manage Remote Pools
ALLOWLIST, THRESHOLD_AMOUNT, POLICY_ENGINE Deploy Advanced Pool Hooks script/input/advanced-pool-hooks.json
NEW_HOOK Connect Advanced Pool Hooks to a Token Pool
ENTITY_TYPE Transfer Ownership
ADDRESS Transfer Ownership
NEW_OWNER Transfer Ownership
NEW_ADMIN Transfer Token Admin Role
ADD_ADDRESSES, REMOVE_ADDRESSES Manage Allowlist, Manage Authorized Callers
CHECK_ADDRESS Manage Allowlist
DEST_GAS_OVERHEAD, DEST_BYTES_OVERHEAD, FINALITY_FEE_USD_CENTS, FAST_FINALITY_FEE_USD_CENTS, FINALITY_TRANSFER_FEE_BPS, FAST_FINALITY_TRANSFER_FEE_BPS, DISABLE Manage Token Transfer Fee Config

Testing

Run the test suite with:

forge test

No configuration is needed: the fork tests default to public Ethereum Sepolia RPC endpoints (trying several in order, so a single unavailable provider does not fail the suite). To use a private or paid endpoint instead, set ETHEREUM_SEPOLIA_RPC_URL (the same variable used by the deployment scripts):

ETHEREUM_SEPOLIA_RPC_URL=<your-sepolia-rpc-url> forge test

The fork tests deploy the token and pool fixtures by running the repo's own deploy scripts, so they exercise the same code paths as the commands documented above.

About

Foundry scripts for deploying and managing cross-chain tokens using Chainlink CCIP

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors