# Enforce ACE policies on CCIP token transfers using Hardhat
Source: https://docs.chain.link/ccip/evm/tutorials/cross-chain-tokens/enforce-ace-policies-hardhat
Last Updated: 2026-09-26

> For the complete documentation index, see [llms.txt](/llms.txt).

## Guide Versions

- [Foundry](/ccip/evm/tutorials/cross-chain-tokens/enforce-ace-policies-foundry)

- [Hardhat](/ccip/evm/tutorials/cross-chain-tokens/enforce-ace-policies-hardhat)

[`AdvancedPoolHooks`](https://github.com/smartcontractkit/chainlink-ccip/tree/contracts-ccip-v2.0.0/chains/evm/contracts/pools/AdvancedPoolHooks.sol) can forward each transfer to a [Chainlink ACE](/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](/ccip/evm/tutorials/cross-chain-tokens/configure-sender-allowlist-advanced-pool-hooks-hardhat). For how the hook, engine, extractor, and policies fit together, read the [AdvancedPoolHooks concept page](/ccip/concepts/cross-chain-token/advanced-pool-hooks).

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.

> **NOTE: What this tutorial does not repeat**
>
> - Token and pool deployment, admin registration, and lane configuration: complete a registration tutorial first (see [Confirm prerequisites](#confirm-prerequisites-addresses-and-permissions)).
> - The ACE Platform workflow (engine creation, target detection, contract-type assignment, policy instances,
>   protections, extractor mappings): follow [Protect CCIP Token Pools with ACE](/ace/guides/policy-manager/ccip-token-pools) for each step.

## Before You Begin

> **CAUTION: ACE Beta access required**
>
> ACE is currently available through the Beta program. [Contact us](https://chain.link/contact) to request
> access or schedule a demo. After ACE is enabled for your organization, complete the [ACE account
> setup](/ace/getting-started/account-setup) before continuing.

### 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](https://nodejs.org/en/download/).
   - `npm` is bundled with Node.js. If `npm` is unavailable, reinstall or update Node.js.

2. Install/Update Chainlink `ccip-cli`:

```bash filename="Terminal"
npm install -g @chainlink/ccip-cli
ccip-cli --version
```

3. **Clone the repository and navigate to the project directory**:

[CCIP 2.0 template](https://github.com/smartcontractkit/docs-cct-hardhat)

Clone the CCIP 2.0 Hardhat template for a smoother setup.

```bash filename="Terminal"
git clone https://github.com/smartcontractkit/docs-cct-hardhat.git
cd docs-cct-hardhat
```

4. Create a `.env` file by copying the `.env.example` file, and fill in the required values:

```bash filename="Terminal"
cp .env.example .env
```

```bash filename=".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`.

5. To make sure your terminal has access to these variables, run the following command:

```bash filename="Terminal"
source .env
```

6. Build the project:

```bash filename="Terminal"
npm install && npx hardhat compile
```

7. Create an encrypted Hardhat keystore, if you haven't already:

```bash filename="Terminal"
npx hardhat keystore set PRIVATE_KEY
```

> **CAUTION: Private key format**
>
> When prompted for your private key, include the `0x` prefix.

> **NOTE: Note**
>
> 1. Hardhat prompts for a keystore password: set it on first use, or enter it to unlock on subsequent uses. Then it
>    asks you to enter your private key as hidden text.
> 2. The name you choose (e.g. `PRIVATE_KEY`) is what you set as `KEYSTORE_NAME` in `.env`.
> 3. Use `npx hardhat keystore list` to check previously created keystores.

## 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:

- [Burn & Mint (Hardhat)](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-burn-mint-hardhat)
- [Lock & Mint (Hardhat)](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-hardhat)

Export the addresses for both chains:

```bash filename="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.

> **NOTE: ACE access required**
>
> If ACE is not yet enabled for your Chainlink Platform organization, [request access](https://chain.link/contact) and complete the [ACE account
> setup](/ace/getting-started/account-setup) before creating a Policy Engine.

You also need a Policy Engine on each chain. [Create a policy engine](/ace/guides/policy-manager/manage-engines#create-a-policy-engine), include Ethereum Sepolia and Arbitrum Sepolia, wait until the engine is **Active**, and copy its onchain address for each chain.

> **CAUTION: Funding requirements**
>
> - **Sepolia ETH**: the wallet `KEYSTORE_NAME` needs ETH on Sepolia to pay gas for hook
>   deployment and configuration.
> - **Arbitrum Sepolia ETH**: the same wallet needs ETH on Arbitrum Sepolia for the destination hook
>   deployment, configuration, and the manual execution transaction.
> - **Your token on Sepolia**: the same wallet needs some `ETHEREUM_SEPOLIA_TOKEN` to send the
>   test transfers.

> **NOTE: One-wallet assumption**
>
> This tutorial assumes the wallet stored in `KEYSTORE_NAME` is the owner of both token pools and will be the
> owner of both deployed `AdvancedPoolHooks` contracts. These roles can be distributed across multiple
> wallets if needed.

> **NOTE: The engine must be on the same chain as the hook**
>
> ACE evaluates policies onchain. The source hook must point at the engine deployed on Ethereum Sepolia, and the
> destination hook at the engine deployed on Arbitrum Sepolia. A single Policy Engine can cover both chains if you
> included both when creating it; in that case use the engine's per-chain onchain addresses.

### 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](https://github.com/smartcontractkit/docs-cct-hardhat/blob/main/tasks/configure/allowlist/deployAdvancedPoolHooks.ts)

View the hooks deployment task on GitHub.

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

```bash filename="Terminal"
npx hardhat deployAdvancedPoolHooks \
  --authorizedcallers $ETHEREUM_SEPOLIA_TOKEN_POOL \
  --policyengine 0xYourSepoliaEngineAddress \
  --network sepolia
```

Export the source hooks address:

```bash filename="Terminal"
export SOURCE_POOL_HOOKS=0xSourceHooksAddress
```

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

```bash filename="Terminal"
npx hardhat deployAdvancedPoolHooks \
  --authorizedcallers $ARBITRUM_SEPOLIA_TOKEN_POOL \
  --policyengine 0xYourArbitrumSepoliaEngineAddress \
  --network arbitrumSepolia
```

Export the destination hooks address:

```bash filename="Terminal"
export DEST_POOL_HOOKS=0xDestinationHooksAddress
```

> **NOTE: Deployment address recovery**
>
> Deployment addresses are saved to `deployments/advanced-pool-hooks/` per chain. If you lose the session
> export, retrieve the address from the JSON file under the `POOL_HOOKS` key.

**3. Attach the source hooks to the source pool:**

```bash filename="Terminal"
npx hardhat updateAdvancedPoolHooks \
  --newhook $SOURCE_POOL_HOOKS \
  --network sepolia
```

**4. Attach the destination hooks to the destination pool:**

```bash filename="Terminal"
npx hardhat updateAdvancedPoolHooks \
  --newhook $DEST_POOL_HOOKS \
  --network arbitrumSepolia
```

> **NOTE: Why --authorizedcallers at deploy time**
>
> Passing the pool as an authorized caller at deploy time authorizes it in the same transaction the hooks are created
> in, so the first hook call cannot revert for a missing caller. If you deployed without it, add the pool with `updateAuthorizedCallers` (`--poolhooks <hooks> --add <pool>`) before attaching.

### 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](https://github.com/smartcontractkit/docs-cct-hardhat/blob/main/tasks/configure/policy-engine/setPolicyEngine.ts)

View the policy engine task on GitHub.

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

```bash filename="Terminal"
npx hardhat setPolicyEngine \
  --poolhooks $SOURCE_POOL_HOOKS \
  --policyengine 0xYourSepoliaEngineAddress \
  --network sepolia
```

Your output should look something like this:

```bash filename="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):**

```bash filename="Terminal"
npx hardhat setPolicyEngine \
  --poolhooks $DEST_POOL_HOOKS \
  --policyengine 0xYourArbitrumSepoliaEngineAddress \
  --network arbitrumSepolia
```

**3. Verify both connections with the read task:**

```bash filename="Terminal"
npx hardhat getPolicyEngine \
  --poolhooks $SOURCE_POOL_HOOKS \
  --network sepolia

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

Expected output for each chain:

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

✅ Policy Engine:
   0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9
========================================
```

> **NOTE: Replacing or disconnecting an engine**
>
> - `setPolicyEngine` detaches the old engine first and reverts `PolicyEngineDetachReverted` if
>   the old engine's `detach()` reverts. The on-chain recovery path is
>   [`setPolicyEngineAllowFailedDetach`](/ccip/evm/api-reference/v2.0.0/advanced-pool-hooks#policy-engine) on the hook
>   itself.
> - `--policyengine 0x0000000000000000000000000000000000000000` disconnects the engine and stops policy
>   checks.

### 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](/ace/guides/policy-manager/ccip-token-pools). 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](/ace/guides/policy-manager/ccip-token-pools#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](/ace/guides/policy-manager/ccip-token-pools#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](/ace/guides/policy-manager/ccip-token-pools#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](/ace/guides/policy-manager/ccip-token-pools#protect-a-hook-function).

> **NOTE: Confirm the chain on every protection**
>
> The Platform creates a protection only on chains where the target and policy instance are both deployed. Confirm the
> selected chain matches the hook deployment: Ethereum Sepolia for the source protection, Arbitrum Sepolia for the
> destination protection.

### 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.

```bash filename="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](/ace/guides/policy-manager/manage-policies#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.

```bash filename="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):

```bash filename="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.

```bash filename="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.

> **NOTE: Why the source transaction succeeds**
>
> `postflightCheck` runs on the destination chain, after the source transaction has already committed. A
> destination rejection cannot roll the source back; it leaves the message unexecuted until the condition is resolved.
> This is the difference between the two hooks: preflight stops the transfer at its source, postflight enforces the
> destination's own policy.

### 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](/ace/guides/policy-manager/manage-policies#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:**

```bash filename="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:**

```bash filename="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](/ccip/concepts/manual-execution).

> **CAUTION: Educational Example Disclaimer**
>
> Please note, this page contains community examples only — these are not Chainlink products or services and are not
> supported or maintained by Chainlink. This code represents an example of using a Chainlink product or service, and is
> intended for demonstration and educational purposes only. It is provided "AS IS" and "AS AVAILABLE" without warranties
> of any kind, may not have been audited, and may omit checks or error handling. Each party intending to use this
> example code does so entirely at their own risk and must perform its own audits, security and code review, key
> management, and testing before any production deployment and ensure the operation and performance of such code matches
> expectations. Neither Chainlink Labs nor the Chainlink Foundation deploys, operates, monitors, maintains or endorses
> any deployment of this code. Note that this is not a Chainlink product, feature or service, and there are no
> commitments made with respect to the code, including compatibility with future Chainlink releases. You should not rely
> on this code without first conducting your own technical, engineering, and security review. This code is also outside
> the scope of any Chainlink bug bounty programs. Neither Chainlink Labs, the Chainlink Foundation, nor Chainlink node
> operators are responsible for outcomes due to errors in this example or how it is deployed or operated, or liable for
> any resulting claims or damages. Use of the Chainlink Network is subject to the Chainlink Foundation [Terms of
> Service](https://chain.link/terms), which provides important information and disclosures. By using this code, you
> acknowledge and agree to these terms.