Enforce ACE policies on CCIP token transfers using Hardhat

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/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 Hardhat template for a smoother setup.

Terminal
git clone https://github.com/smartcontractkit/docs-cct-hardhat.git
cd docs-cct-hardhat
  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 `npx hardhat keystore set`)
KEYSTORE_NAME=<your_default_keystore_name>

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

# Etherscan API key (required only if you pass --verify to deployment tasks)
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 && npx hardhat compile
  1. Create an encrypted Hardhat keystore, if you haven't already:
Terminal
npx hardhat keystore set PRIVATE_KEY

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 ARBITRUM_SEPOLIA_TOKEN=0x...
export ARBITRUM_SEPOLIA_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.ts

View the hooks deployment task on GitHub.

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

Terminal
npx hardhat deployAdvancedPoolHooks \
  --authorizedcallers $ETHEREUM_SEPOLIA_TOKEN_POOL \
  --policyengine 0xYourSepoliaEngineAddress \
  --network sepolia

Export the source hooks address:

Terminal
export SOURCE_POOL_HOOKS=0xSourceHooksAddress

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

Terminal
npx hardhat deployAdvancedPoolHooks \
  --authorizedcallers $ARBITRUM_SEPOLIA_TOKEN_POOL \
  --policyengine 0xYourArbitrumSepoliaEngineAddress \
  --network arbitrumSepolia

Export the destination hooks address:

Terminal
export DEST_POOL_HOOKS=0xDestinationHooksAddress

3. Attach the source hooks to the source pool:

Terminal
npx hardhat updateAdvancedPoolHooks \
  --newhook $SOURCE_POOL_HOOKS \
  --network sepolia

4. Attach the destination hooks to the destination pool:

Terminal
npx hardhat updateAdvancedPoolHooks \
  --newhook $DEST_POOL_HOOKS \
  --network arbitrumSepolia
3 Set or update the policy engine

If you passed --policyengine 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 task. To connect hooks deployed without an engine, or to point them at a different engine, use the setPolicyEngine task.

setPolicyEngine.ts

View the policy engine task on GitHub.

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

Terminal
npx hardhat setPolicyEngine \
  --poolhooks $SOURCE_POOL_HOOKS \
  --policyengine 0xYourSepoliaEngineAddress \
  --network sepolia

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

[Step 1] Setting policy engine on Ethereum Sepolia
⏳ Tx: 0xaaabbbcccdddeeefff000111222333444555666777888999aaabbbcccdddeeff
✅ Policy engine set successfully!
========================================

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

Terminal
npx hardhat setPolicyEngine \
  --poolhooks $DEST_POOL_HOOKS \
  --policyengine 0xYourArbitrumSepoliaEngineAddress \
  --network arbitrumSepolia

3. Verify both connections with the read task:

Terminal
npx hardhat getPolicyEngine \
  --poolhooks $SOURCE_POOL_HOOKS \
  --network sepolia

npx hardhat getPolicyEngine \
  --poolhooks $DEST_POOL_HOOKS \
  --network arbitrumSepolia

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 hardhat:$KEYSTORE_NAME \
  --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \
  --rpc "$ARBITRUM_SEPOLIA_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 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 hardhat:$KEYSTORE_NAME \
  --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \
  --rpc "$ARBITRUM_SEPOLIA_RPC_URL"

Expected output (example):

Terminal
Fee: 130129888907619n = 0.000130129888907619 ETH
✔ Enter password for Hardhat 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 hardhat:$KEYSTORE_NAME \
  --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \
  --rpc "$ARBITRUM_SEPOLIA_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 "$ARBITRUM_SEPOLIA_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 hardhat:$KEYSTORE_NAME \
  --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \
  --rpc "$ARBITRUM_SEPOLIA_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.