Enforce ACE policies on CCIP token transfers using Foundry

Guide Versions

This guide is available in multiple versions. Choose the one that matches your needs.

AdvancedPoolHooks can forward each transfer to a Chainlink ACE Policy Engine for evaluation before the source token pool locks or burns tokens (preflightCheck) and before the destination token pool releases or mints tokens (postflightCheck). If a policy rejects the call, the hook reverts and the transfer does not proceed.

This tutorial covers the ACE policy enforcement use case on a working CCT lane. For the sender allowlist use case instead, see Configure a sender allowlist with AdvancedPoolHooks. For how the hook, engine, extractor, and policies fit together, read the AdvancedPoolHooks concept page.

In this tutorial you will:

  1. Reuse an existing working CCT lane between Ethereum Sepolia and Arbitrum Sepolia.
  2. Deploy AdvancedPoolHooks on both chains with their Policy Engine addresses set, and attach them to both token pools.
  3. Point the hooks at their Policy Engines with setPolicyEngine when the engine was not set at deployment or needs to change.
  4. Complete the ACE Platform setup: target detection, contract-type assignment, policy creation, and extractor mappings.
  5. Demonstrate a source preflightCheck rejection using the from parameter.
  6. Demonstrate a successful unrestricted transfer.
  7. Demonstrate a destination postflightCheck rejection using the to parameter.
  8. Update the destination policy and manually execute the failed message.

Before You Begin

1 Set up your development environment
  1. Install Node.js and npm:

    • Make sure you have Node.js v22.10.0 or above installed. If not, install Node.js v22.10.0 using their documentation.
    • npm is bundled with Node.js. If npm is unavailable, reinstall or update Node.js.
  2. Install Foundry: If you haven't already, follow the instructions in the Foundry documentation to install Foundry.
    Verify the installation by running the following command:

Terminal
forge --version
  1. Install/Update Chainlink ccip-cli:
Terminal
npm install -g @chainlink/ccip-cli
ccip-cli --version
  1. Clone the repository and navigate to the project directory:
CCIP 2.0 template

Clone the CCIP 2.0 template for a smoother setup.

Terminal
git clone https://github.com/smartcontractkit/docs-cct-foundry.git
cd docs-cct-foundry
  1. Create an encrypted Foundry keystore, if you haven't already:
Terminal
cast wallet import your_keystore_name --interactive
  1. Create a .env file by copying the .env.example file, and fill in the required values:
Terminal
cp .env.example .env
.env
# Keystore name (created via `cast wallet import`)
KEYSTORE_NAME=<your_default_keystore_name>

# RPC URLs (add the ones you need)
ETHEREUM_SEPOLIA_RPC_URL=your_eth_sepolia_rpc
ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL=your_arbitrum_sepolia_rpc

# Etherscan API key (required only if you pass --verify to deployment scripts)
ETHERSCAN_API_KEY=your_etherscan_api_key

This tutorial uses a single wallet for every deployment, configuration update, and test transfer: the wallet stored in KEYSTORE_NAME.

  1. To make sure your terminal has access to these variables, run the following command:
Terminal
source .env
  1. Build the project:
Terminal
npm install && forge build

Tutorial

1 Confirm prerequisites, addresses, and permissions

This tutorial assumes you have already deployed tokens and token pools and configured a working lane between Ethereum Sepolia and Arbitrum Sepolia. If not, complete one of the registration tutorials first:

Export the addresses for both chains:

Terminal
export ETHEREUM_SEPOLIA_TOKEN=0x...
export ETHEREUM_SEPOLIA_TOKEN_POOL=0x...
export ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN=0x...
export ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN_POOL=0x...
export ETHEREUM_SEPOLIA_ROUTER=0x...
export UNRESTRICTED_RECEIVER=0x...
export DENIED_RECEIVER=0x...

Use two different recipient addresses. The destination policy will deny only DENIED_RECEIVER; you will use UNRESTRICTED_RECEIVER to confirm that transfers to other recipients still succeed.

You also need a Policy Engine on each chain. Create a policy engine, include Ethereum Sepolia and Arbitrum Sepolia, wait until the engine is Active, and copy its onchain address for each chain.

2 Deploy hooks on both chains and attach them

Deploy an AdvancedPoolHooks contract on each chain, then attach it to that chain's token pool. The allowlist stays empty in this tutorial: policy enforcement comes from ACE, not the allowlist. Pass the engine address at deploy time so the hooks connect to it in the same transaction: the constructor calls attach() on the engine, which starts ACE target detection.

DeployAdvancedPoolHooks.s.sol

View the hooks deployment script on GitHub.

1. Deploy hooks on Ethereum Sepolia (source chain):

Terminal
AUTHORIZED_CALLERS=$ETHEREUM_SEPOLIA_TOKEN_POOL \
POLICY_ENGINE=0xYourSepoliaEngineAddress \
forge script \
script/configure/allowlist/DeployAdvancedPoolHooks.s.sol \
--rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast \
--verify

Export the source hooks address:

Terminal
export SOURCE_POOL_HOOKS=0xSourceHooksAddress

2. Deploy hooks on Arbitrum Sepolia (destination chain):

Terminal
AUTHORIZED_CALLERS=$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_TOKEN_POOL \
POLICY_ENGINE=0xYourArbitrumSepoliaEngineAddress \
forge script \
script/configure/allowlist/DeployAdvancedPoolHooks.s.sol \
--rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast \
--verify

Export the destination hooks address:

Terminal
export DEST_POOL_HOOKS=0xDestinationHooksAddress

3. Attach the source hooks to the source pool:

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

4. Attach the destination hooks to the destination pool:

Terminal
NEW_HOOK=$DEST_POOL_HOOKS \
forge script \
script/configure/allowlist/UpdateAdvancedPoolHooks.s.sol \
--rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast
3 Set or update the policy engine

If you passed POLICY_ENGINE when deploying the hooks, they are already connected to the engine and ACE target detection has started. Skip the commands below and verify the connections with the read script. To connect hooks deployed without an engine, or to point them at a different engine, use the SetPolicyEngine.s.sol script.

SetPolicyEngine.s.sol

View the policy engine script on GitHub.

1. Set the engine on the source hooks (Ethereum Sepolia):

Terminal
POOL_HOOKS=$SOURCE_POOL_HOOKS \
POLICY_ENGINE=0xYourSepoliaEngineAddress \
forge script \
script/configure/policy-engine/SetPolicyEngine.s.sol \
--rpc-url $ETHEREUM_SEPOLIA_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast

Your output should look something like this:

Terminal
========================================
🔗 Set Policy Engine
========================================
Chain:        Ethereum Sepolia
Pool Hooks:   0xA11c0FFeE0bAdA55CAfEBaBeDeaDBeEfF00DBABE
Action:       Set policy engine
========================================

Current Policy Engine: 0x0000000000000000000000000000000000000000
New Policy Engine:     0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9

⏳ SENDING (unconfirmed): point the pool hooks at policy engine 0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9
========================================

2. Set the engine on the destination hooks (Arbitrum Sepolia):

Terminal
POOL_HOOKS=$DEST_POOL_HOOKS \
POLICY_ENGINE=0xYourArbitrumSepoliaEngineAddress \
forge script \
script/configure/policy-engine/SetPolicyEngine.s.sol \
--rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL \
--account $KEYSTORE_NAME \
--broadcast

3. Verify both connections with the read script:

Terminal
POOL_HOOKS=$SOURCE_POOL_HOOKS \
forge script \
script/configure/policy-engine/GetPolicyEngine.s.sol \
--rpc-url $ETHEREUM_SEPOLIA_RPC_URL

POOL_HOOKS=$DEST_POOL_HOOKS \
forge script \
script/configure/policy-engine/GetPolicyEngine.s.sol \
--rpc-url $ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL

Expected output for each chain:

Terminal
========================================
🔎 Get Policy Engine
========================================
Chain:        Ethereum Sepolia
Pool Hooks:   0xA11c0FFeE0bAdA55CAfEBaBeDeaDBeEfF00DBABE
Action:       View policy engine
========================================

✅ Policy Engine:
   0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9
========================================
4 Complete the ACE Platform setup

The hook is now attached to the engine onchain. Complete the Platform-side configuration once per engine by following Protect CCIP Token Pools with ACE. This tutorial does not repeat those steps; it links the exact section for each task:

  1. Verify the detected targets: each hooks address appears as a detected target under its engine. Assign the built-in CCIP-AdvancedPoolHooks contract type to each target. See Verify the detected target.
  2. Create a policy instance on each chain: use the reject policy type. On the source engine, configure the instance's denylist with the address you will send from. On the destination engine, configure the instance's denylist with $DENIED_RECEIVER. See Create a policy instance.
  3. Protect the hook functions: on the source target, protect preflightCheck and map the policy parameter Account (address) to the extractor output from. On the destination target, protect postflightCheck and map Account (address) to to. Read Restrict source senders first: the payload sender is the token pool, not the user, so the from output is what evaluates the transfer sender. See Protect a hook function.
5 Demonstrate a preflight rejection (from)

Send a transfer from the address on the source denylist. The source preflightCheck runs before the token pool locks or burns tokens, so the source transaction reverts and the transfer never starts.

Terminal
ccip-cli send \
  --source ethereum-testnet-sepolia \
  --router $ETHEREUM_SEPOLIA_ROUTER \
  --dest ethereum-testnet-sepolia-arbitrum-1 \
  --transfer-tokens $ETHEREUM_SEPOLIA_TOKEN=1.23 \
  --receiver $UNRESTRICTED_RECEIVER \
  --wallet foundry:$KEYSTORE_NAME \
  --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \
  --rpc "$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL"

Expected result: the send transaction reverts. The Policy Engine returns PolicyRunRejected; its reject reason identifies the underlying policy rejection, and the hook call propagates that revert.

If this step does not fail as expected, check:

  • The source hooks point at the engine (GetPolicyEngine.s.sol shows the engine address).
  • The source target uses the CCIP-AdvancedPoolHooks contract type.
  • The protection maps Account (address) to the from output on preflightCheck.
  • The denylisted address is the one sending the transfer.
6 Send an unrestricted transfer (expected success)

Remove the sending address from the source policy's denylist (see Update policy configuration). Wait for the configuration transaction to confirm on Ethereum Sepolia, then send the same transfer again. With no policy rejecting it, the transfer executes end to end.

Terminal
ccip-cli send \
  --source ethereum-testnet-sepolia \
  --router $ETHEREUM_SEPOLIA_ROUTER \
  --dest ethereum-testnet-sepolia-arbitrum-1 \
  --transfer-tokens $ETHEREUM_SEPOLIA_TOKEN=1.23 \
  --receiver $UNRESTRICTED_RECEIVER \
  --wallet foundry:$KEYSTORE_NAME \
  --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \
  --rpc "$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL"

Expected output (example):

Terminal
Fee: 130129888907619n = 0.000130129888907619 ETH
✔ Enter password for Foundry keystore 'PRIVATE_KEY'
🚀 Sending message to 0xE23Fc63F47F08F58B9d7448d4CCE0bCDcc96d7F3 @ ethereum-testnet-sepolia-arbitrum-1 , tx => 0x3dc40bea29f3e3fc93ff8fce0dda45fd7f55a07ace5089646e018874c8b6745e , messageId => 0x4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293
CCIP Explorer: https://ccip.chain.link/msg/0x4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293

This confirms the lane and both hooks work when no policy rejects the transfer.

7 Demonstrate a postflight rejection (to)

Send a transfer whose recipient is on the destination denylist. The source preflightCheck passes (the sender is not denied), the message travels to Arbitrum Sepolia, and the destination postflightCheck rejects before the token pool releases or mints tokens. The tokens remain unreleased, and the message stays unexecuted.

Terminal
ccip-cli send \
  --source ethereum-testnet-sepolia \
  --router $ETHEREUM_SEPOLIA_ROUTER \
  --dest ethereum-testnet-sepolia-arbitrum-1 \
  --transfer-tokens $ETHEREUM_SEPOLIA_TOKEN=1.23 \
  --receiver $DENIED_RECEIVER \
  --wallet foundry:$KEYSTORE_NAME \
  --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \
  --rpc "$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL"

The send transaction succeeds on Sepolia. On Arbitrum Sepolia, execution fails: the CCIP Explorer shows the message in a failure state with the rejecting policy in the error data.

8 Update the destination policy and manually execute

Resolve the rejection condition, then complete the message through manual execution.

1. Remove $DENIED_RECEIVER from the destination policy's denylist (see Update policy configuration). Wait for the configuration transaction to confirm on Arbitrum Sepolia before continuing. The next evaluation of postflightCheck passes.

2. Confirm the message is ready for manual execution:

Terminal
ccip-cli show <messageId> \
  --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \
  --rpc "$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL"

Use the bare 0x... message ID (or the source transaction hash) as the argument, not the full CCIP Explorer URL. The status shows the failure state and readyForManualExecution.

3. Manually execute the message on Arbitrum Sepolia:

Terminal
ccip-cli manual-exec <messageId> \
  --wallet foundry:$KEYSTORE_NAME \
  --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \
  --rpc "$ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1_RPC_URL"

Manual execution is a destination-chain transaction: the wallet needs Arbitrum Sepolia ETH for gas. After the transaction confirms, the recipient receives the tokens and the message status becomes Success.

For the full recovery flow, including gas overrides and diagnosing other failure causes, read CCIP Manual Execution.

What's next

Get the latest Chainlink content straight to your inbox.