Uniswap v4

Building Cross-Chain Swap: Hook + CCIP

Learn how to integrate Uniswap v4 hooks with Chainlink CCIP to build secure, trust-minimized cross-chain swaps using burn-and-mint mechanics.

Last updated: 10/8/2025
Improve this page

Uniswap v4 hooks unlock the ability to extend pool logic beyond single-chain swaps. By combining them with Chainlink’s Cross-Chain Interoperability Protocol (CCIP), we can build a system where a swap on one chain triggers a mint on another. In this example, a user swaps ETH for a “bridged token” on Ethereum Sepolia. Still, instead of receiving the token locally, the hook burns it and instructs the destination chain (Unichain Sepolia) to mint tokens to the user’s account.

This is not just a technical curiosity. It demonstrates how Uniswap hooks can natively integrate cross-chain liquidity movement, a pattern critical for multi-chain DeFi.

Contract Architecture and Dependencies

The architecture spans two chains: the source (where the Uniswap v4 pool and hook reside) and the destination (hosting the receiver). Key dependencies include:

  • Uniswap v4 Core: IPoolManagerPoolKeyBalanceDeltaCurrency, and Hooks for pool interactions.
  • Uniswap v4 Periphery: BaseHook as the base for custom hook logic.
  • Chainlink CCIP: IRouterClientClient, and CCIPReceiver for cross-chain messaging.
  • OpenZeppelin: ERC20 and AccessControl for the bridged token.
  • Custom: BridgedToken with mint/burn roles.

On deployment:

  • Deploy BridgedToken on both chains with an identical name/symbol for consistency.
  • On source: Grant BURN_ROLE to the hook.
  • On destination: Grant MINT_ROLE to the receiver.
  • Configure the hook with the CCIP router, LINK token, and bridged token addresses.
  • Set up allowlists on the receiver for source chain selectors and senders.

This setup ensures controlled access and prevents unauthorized minting or messaging.

Operational Flow: Swap to Cross-Chain Bridge

The end-to-end flow integrates Uniswap v4 swaps with CCIP:

  1. A user initiates a swap on the source chain's Uniswap v4 pool (ETH to BridgedToken), providing hookData as abi.encode(destChainSelector, destReceiver, finalRecipient).
  2. The pool executes the swap, yielding a positive amount1 delta (token output).
  3. The afterSwap Hook triggers: validate conditions, transfer and burn tokens, construct and send the CCIP message funded by LINK.
  4. CCIP relays the message to the destination chain.
  5. The BridgeReceiver receives and validates the message, then mints the equivalent amount to the final recipient.

swap diagram.svg

The BridgedToken Contract

The BridgedToken serves as the cross-chain asset, implementing a standard ERC20 with access-controlled minting and burning. It uses OpenZeppelin's AccessControl to define MINT_ROLE and BURN_ROLE, restricting these operations to authorized contracts (the hook on source, receiver on destination).

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.24;
3
4// BridgedToken.sol - Deploy this on BOTH source and destination chains.
5import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
6import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
7
8contract BridgedToken is ERC20, AccessControl {
9    bytes32 public constant MINT_ROLE = keccak256("MINT_ROLE");
10    bytes32 public constant BURN_ROLE = keccak256("BURN_ROLE");
11
12    constructor(string memory name, string memory symbol) ERC20(name, symbol) {
13        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
14    }
15
16    function mint(address to, uint256 amount) external {
17        require(hasRole(MINT_ROLE, msg.sender), "Caller is not a minter");
18        _mint(to, amount);
19    }
20
21    function burn(address from, uint256 amount) external {
22        require(hasRole(BURN_ROLE, msg.sender), "Caller is not a burner");
23        _burn(from, amount);
24    }
25}
26

The constructor initializes the token and grants admin rights to the deployer, who can then assign roles. The mint and burn functions enforce role checks before invoking internal ERC20 methods, ensuring only the hook or receiver can alter supply. This design maintains token integrity across chains by mirroring supply changes via CCIP messages.

The CrossChainBridgeHook Contract

Deployed on the source chain, CrossChainBridgeHook extends Uniswap v4's BaseHook to intercept post-swap events. It activates only for ETH-to-token swaps (zeroForOne), burns the output tokens, and sends a CCIP message to trigger minting on the destination. The hook decodes hookData provided during the swap to extract the destination chain selector, receiver address, and final recipient.

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.24;
3
4// Source Chain: CrossChainBridgeHook.sol
5
6// ------------------- Uniswap v4 Core -------------------
7import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol";
8import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
9import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
10import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
11import {
12    Currency,
13    CurrencyLibrary
14} from "@uniswap/v4-core/src/types/Currency.sol";
15
16// ------------------- Uniswap v4 Periphery -------------------
17import {BaseHook} from "@uniswap/v4-periphery/src/base/hooks/BaseHook.sol";
18
19// ------------------- Chainlink CCIP -------------------
20import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol";
21import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
22import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
23
24// ------------------- Custom Token -------------------
25import {BridgedToken} from "./BridgedToken.sol"; // Must expose burn(address from, uint256 amount)
26
27contract CrossChainBridgeHook is BaseHook {
28    using CurrencyLibrary for Currency;
29
30    address public immutable ccipRouter;
31    address public immutable linkToken;
32    BridgedToken public immutable bridgedToken;
33
34    constructor(
35        IPoolManager _poolManager,
36        address _ccipRouter,
37        address _linkToken,
38        address _bridgedToken
39    ) BaseHook(_poolManager) {
40        ccipRouter = _ccipRouter;
41        linkToken = _linkToken;
42        bridgedToken = BridgedToken(_bridgedToken);
43    }
44
45    // ------------------- Hook Permissions -------------------
46    function getHookPermissions()
47        public
48        pure
49        override
50        returns (Hooks.Permissions memory)
51    {
52        return
53            Hooks.Permissions({
54                beforeInitialize: false,
55                afterInitialize: false,
56                beforeAddLiquidity: false,
57                afterAddLiquidity: false,
58                beforeRemoveLiquidity: false,
59                afterRemoveLiquidity: false,
60                beforeSwap: false,
61                afterSwap: true,
62                beforeDonate: false,
63                afterDonate: false,
64                beforeSwapReturnDelta: false,
65                afterSwapReturnDelta: false,
66                afterAddLiquidityReturnDelta: false,
67                afterRemoveLiquidityReturnDelta: false
68            });
69    }
70
71    // ------------------- After Swap Hook -------------------
72    function afterSwap(
73        address sender,
74        PoolKey calldata key,
75        IPoolManager.SwapParams calldata params,
76        BalanceDelta delta,
77        bytes calldata hookData
78    ) external override onlyByManager returns (bytes4, int128) {
79        // Only trigger for ETH -> Token swaps (zeroForOne = true, token1 output > 0)
80        if (
81            !key.currency0.isNative() ||
82            !params.zeroForOne ||
83            delta.amount1() <= 0
84        ) {
85            return (this.afterSwap.selector, 0);
86        }
87
88        (
89            uint64 destChainSelector,
90            address destReceiver,
91            address finalRecipient
92        ) = abi.decode(hookData, (uint64, address, address));
93
94        uint256 amountToBridge = uint256(int256(delta.amount1()));
95
96        // Pull output tokens from sender
97        bridgedToken.transferFrom(sender, address(this), amountToBridge);
98
99        // Burn tokens now held by this hook
100        bridgedToken.burn(address(this), amountToBridge);
101
102        // Construct CCIP message
103        Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
104            receiver: abi.encode(destReceiver),
105            data: abi.encode(finalRecipient, amountToBridge), // instruct destination chain to mint
106            tokenAmounts: new Client.EVMTokenAmount[](0), // no tokens sent
107            extraArgs: Client._argsToBytes(
108                Client.EVMExtraArgsV1({gasLimit: 200_000})
109            ),
110            feeToken: linkToken
111        });
112
113        IRouterClient router = IRouterClient(ccipRouter);
114
115        uint256 fees = router.getFee(destChainSelector, message);
116
117        // Approve LINK fees and send message
118        IERC20(linkToken).approve(address(router), fees);
119        router.ccipSend(destChainSelector, message);
120
121        return (this.afterSwap.selector, 0);
122    }
123}
124
125

In getHookPermissionsThe hook specifies activation only in afterSwap, allowing it to process swap outputs without interfering elsewhere. The afterSwap function first validates the swap type: it checks for native ETH as currency0, zeroForOne direction, and positive token1 delta. It then decodes hookData (passed via the swap call) to retrieve bridging parameters.

Post-validation, the hook transfers the output tokens from the swap sender to itself using transferFrom, then burns them via the BridgedToken's burn method. This reduces supply on the source chain. Subsequently, it constructs a Client.EVM2AnyMessage with the receiver ABI-encoded, data encoding the recipient and amount for minting, no token transfers (since burning handles supply), and gas limits for execution. Fees are calculated via getFeeLINK is approved, and the message is sent using ccipSend. The function returns the selector and zero delta adjustment, as no further pool modifications are needed.

This hook effectively transforms a local swap into a cross-chain bridge, leveraging Uniswap's delta for precise amount bridging.

The BridgeReceiver Contract

On the destination chain, BridgeReceiver inherits from Chainlink's CCIPReceiver to handle incoming messages. It validates the source chain and sender via allowlists, decodes the message data, and mints tokens to the final recipient.

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.24;
3
4// Destination Chain: BridgeReceiver.sol
5// Receives CCIP message and mints BridgedToken to the final recipient.
6
7import {CCIPReceiver} from "@chainlink/contracts-ccip/src/v0.8/ccip/applications/CCIPReceiver.sol";
8import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
9
10import {BridgedToken} from "./BridgedToken.sol"; // Import the token
11
12contract BridgeReceiver is CCIPReceiver {
13    BridgedToken public immutable bridgedToken;
14
15    mapping(uint64 => bool) public allowlistedSourceChains;
16    mapping(address => bool) public allowlistedSenders;
17
18    event TokensMinted(address recipient, uint256 amount);
19
20    constructor(address _ccipRouter, address _bridgedToken) CCIPReceiver(_ccipRouter) {
21        bridgedToken = BridgedToken(_bridgedToken);
22    }
23
24    function allowlistSourceChain(uint64 _sourceChainSelector, bool allowed) external {
25        allowlistedSourceChains[_sourceChainSelector] = allowed;
26    }
27
28    function allowlistSender(address _sender, bool allowed) external {
29        allowlistedSenders[_sender] = allowed;
30    }
31
32    function _ccipReceive(Client.Any2EVMMessage memory message) internal override {
33        uint64 sourceChainSelector = message.sourceChainSelector;
34        address sender = abi.decode(message.sender, (address));
35
36        require(allowlistedSourceChains[sourceChainSelector], "Source chain not allowlisted");
37        require(allowlistedSenders[sender], "Sender not allowlisted");
38
39        (address finalRecipient, uint256 amount) = abi.decode(message.data, (address, uint256));
40
41        // Mint the tokens
42        bridgedToken.mint(finalRecipient, amount);
43
44        emit TokensMinted(finalRecipient, amount);
45    }
46}
47

The constructor sets the CCIP router and bridged token. Allowlist functions enable admin control over trusted sources. In _ccipReceive (overridden from CCIPReceiver), the function extracts the source selector and sender, enforcing allowlist requirements via require. It decodes the message data to obtain the recipient and amount, then calls mint on BridgedToken. An event TokensMinted logs the operation for transparency.

This receiver ensures secure, permissioned minting, completing the bridge by restoring the token supply on the destination.

This mechanism achieves cross-chain atomicity without direct token transfers, relying on burn-mint semantics. Gas limits (e.g., 200,000) ensure reliable execution, while allowlists mitigate unauthorized access. Developers should monitor CCIP fees and LINK balances to maintain operability.

This implementation demonstrates Uniswap v4's extensibility, blending AMM efficiency with cross-chain capabilities for enhanced DeFi composability.

Hooks Ecosystem

STAY IN THE LOOP

Get updates on our community, partners, events, and everything happening across the ecosystem — delivered straight to your inbox.

Subscribe Now!

newsletter
DeFi SecurityplumeUniswap FoundationAethiropt-collectivePolygon SPNBNB Chain Kickstart

Office 104/105 Level 1, Emaar Square, Building 4 Sheikh Mohammed Bin Rashid Boulevard Downtown Dubai, United Arab Emirates P.O box: 416654

hello@quillaudits.com

All Rights Reserved. © 2025. QuillAudits - LLC

Privacy Policy