Uniswap v4 memperkenalkan arsitektur singleton di mana seluruh pool berada dalam satu kontrak tunggal (PoolManager). Arsitektur ini menggunakan mekanisme flash accounting: alih-alih mentransfer token secara langsung pada setiap operasi, PoolManager melacak perubahan saldo bersih melalui nilai BalanceDelta. Jika custom hook memodifikasi alur swap melalui callback seperti beforeSwap atau afterSwap tanpa sinkronisasi state internal yang presisi, hook tersebut rentan terhadap state invariant leak—kondisi di mana delta token pool dan status akuntansi internal hook tidak lagi seimbang.

Artikel ini membahas metodologi verifikasi hook Uniswap v4 menggunakan Foundry, mencakup konfigurasi testing harness, invariant testing, deteksi kebocoran delta balance, dan penanganan dependensi tick.

Arsitektur Eksekusi Hook dan Risiko Invariant Leak

Pada Uniswap v4, hook mengeksekusi logika kustom pada titik-titik tertentu dari siklus hidup pool (seperti swap, modifikasi likuiditas, atau inisialisasi). Hook dapat mengembalikan BeforeSwapDelta untuk memotong atau mengambil alih sejumlah token dari swap standar.

Kebocoran invariansi (invariant leak) terjadi ketika:

  • Hook memodifikasi state internal (misalnya mencatat volume fee atau memproses order prediksi) di beforeSwap, namun swap gagal di-settle sepenuhnya atau mengalami revert parsial.
  • Hook mengklaim telah menerima delta tetapi gagal melakukan settle ke PoolManager sebelum eksekusi unlock selesai.
  • State internal hook terdesinkronisasi akibat manipulasi tick atau pembulatan (rounding error) pada perhitungan delta.

Setup Test Harness Uniswap v4 di Foundry

Pengujian hook memerlukan lingkungan yang menyimulasikan IPoolManager, deployment token tiruan, dan pendaftaran hook dengan bitflag permission yang valid pada alamat kontrak. Gunakan helper dari v4-core dan v4-periphery.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {Test} from "forge-std/Test.sol";
import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol";
import {PoolManager} from "@uniswap/v4-core/src/PoolManager.sol";
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol";
import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol";

contract HookFixture is Test, Deployers {
    using CurrencyLibrary for Currency;

    PoolKey poolKey;

    function setUpPoolEnvironment(address hookAddress) internal {
        // Deploy PoolManager dan token mock bawaan Deployers
        deployFreshManagerAndRouters();
        (currency0, currency1) = deployMintAndApprove2Currencies();

        poolKey = PoolKey({
            currency0: currency0,
            currency1: currency1,
            fee: 3000,
            tickSpacing: 60,
            hooks: Hooks(hookAddress)
        });

        manager.initialize(poolKey, SQRT_PRICE_1_1);
    }
}

Implementasi Hook Target dengan State Tracking

Berikut adalah contoh hook sederhana yang melacak akumulasi saldo fee internal yang dipotong saat beforeSwap:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {BaseHook} from "@uniswap/v4-periphery/src/base/hooks/BaseHook.sol";
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol";
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
import {BeforeSwapDelta, toBeforeSwapDelta} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol";

contract FeeAccountingHook is BaseHook {
    mapping(address => uint256) public accruedFees;
    uint256 public totalAccruedFees;

    constructor(IPoolManager _manager) BaseHook(_manager) {}

    function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
        return Hooks.Permissions({
            beforeInitialize: false,
            afterInitialize: false,
            beforeAddLiquidity: false,
            afterAddLiquidity: false,
            beforeRemoveLiquidity: false,
            afterRemoveLiquidity: false,
            beforeSwap: true,
            afterSwap: true,
            beforeDonate: false,
            afterDonate: false,
            beforeSwapReturnDelta: false,
            afterSwapReturnDelta: false,
            afterAddLiquidityReturnDelta: false,
            afterRemoveLiquidityReturnDelta: false
        });
    }

    function _beforeSwap(
        address sender,
        PoolKey calldata,
        IPoolManager.SwapParams calldata params,
        bytes calldata
    ) internal override returns (bytes4, BeforeSwapDelta, uint24) {
        uint256 fee = uint256(params.amountSpecified > 0 ? params.amountSpecified : -params.amountSpecified) / 1000;
        accruedFees[sender] += fee;
        totalAccruedFees += fee;
        return (BaseHook.beforeSwap.selector, toBeforeSwapDelta(0, 0), 0);
    }

    function _afterSwap(
        address,
        PoolKey calldata,
        IPoolManager.SwapParams calldata,
        BalanceDelta,
        bytes calldata
    ) internal override returns (bytes4, int128) {
        return (BaseHook.afterSwap.selector, 0);
    }
}

Invariant Testing Harness: Deteksi Balance Delta Leak

Invariansi fundamental sistem adalah: Total fee yang dicatat internal hook harus selalu didukung oleh saldo token fisik atau klaim delta yang valid, dan tidak boleh ada delta tak terselesaikan di PoolManager setelah siklus swap berakhir.

Gunakan pola Handler-based invariant testing di Foundry:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {HookFixture} from "./HookFixture.sol";
import {FeeAccountingHook} from "../src/FeeAccountingHook.sol";
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";

contract HookHandler is HookFixture {
    FeeAccountingHook public hook;
    uint256 public ghost_expectedTotalFees;
    bool public failedDeltaSettlement;

    constructor(FeeAccountingHook _hook) {
        hook = _hook;
        setUpPoolEnvironment(address(_hook));
    }

    function executeSwap(int256 amountSpecified, bool zeroForOne) external {
        // Batasi rentang input fuzzing agar tidak meluap
        amountSpecified = bound(amountSpecified, -100 ether, 100 ether);
        if (amountSpecified == 0) return;

        uint256 expectedFee = uint256(amountSpecified > 0 ? amountSpecified : -amountSpecified) / 1000;

        try swapRouter.swap(
            poolKey,
            IPoolManager.SwapParams({
                zeroForOne: zeroForOne,
                amountSpecified: amountSpecified,
                sqrtPriceLimitX96: zeroForOne ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT
            }),
            PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
            ""
        ) {
            ghost_expectedTotalFees += expectedFee;
        } catch {
            // Swap gagal: pastikan rollback tidak meninggalkan delta phantom
        }
    }
}

Tulis test contract utama yang mengevaluasi invariansi:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {Test} from "forge-std/Test.sol";
import {HookHandler} from "./HookHandler.sol";
import {FeeAccountingHook} from "../src/FeeAccountingHook.sol";
import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol";

contract FeeAccountingHookInvariantTest is Test {
    HookHandler handler;
    FeeAccountingHook hook;

    function setUp() public {
        // Deploy hook pada alamat dengan permission flag beforeSwap & afterSwap
        address hookAddress = address(uint160(Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG));
        deployCodeTo("FeeAccountingHook.sol:FeeAccountingHook", abi.encode(address(0)), hookAddress);
        hook = FeeAccountingHook(hookAddress);

        handler = new HookHandler(hook);
        targetContract(address(handler));
    }

    // Invariant: Akumulasi state internal hook harus sinkron dengan eksekusi nyata
    function invariant_hookInternalAccountingMustMatchGhost() public view {
        assertEq(
            hook.totalAccruedFees(),
            handler.ghost_expectedTotalFees(),
            "CRITICAL: State invariant leaked across swaps"
        );
    }

    // Invariant: PoolManager tidak boleh menyimpan sisa delta settlement yang belum selesai
    function invariant_noDanglingReservesInManager() public view {
        assertEq(
            handler.manager().getReserves(handler.currency0()),
            0,
            "Reserves token0 tidak nol setelah unlock callback"
        );
        assertEq(
            handler.manager().getReserves(handler.currency1()),
            0,
            "Reserves token1 tidak nol setelah unlock callback"
        );
    }
}

Isolasi Flaky Tests pada Manipulasi Tick Pool

Saat menjalankan invariant test pada pool Uniswap v4, pengujian sering menghasilkan kegagalan semu (flaky tests) akibat batasan tick matematis:

  • Tick Out of Bounds: Fuzzing acak mendorong swap melampaui MIN_TICK (-887272) atau MAX_TICK (887272), menyebabkan revert yang tidak berhubungan dengan integritas hook.
  • Slippage Revert vs State Leak: Swap yang me-revert akibat batas sqrtPriceLimitX96 harus dipastikan tidak mengubah state persistent hook.

Gunakan konfigurasi invariant Foundry di foundry.toml untuk membatasi kedalaman run dan mengisolasi edge case:

[profile.default]
src = "src"
test = "test"
out = "out"
libs = ["lib"]

[invariant]
runs = 256
depth = 32
fail_on_revert = false
shrink_run_limit = 5000

Pastikan fail_on_revert = false diaktifkan jika harness menangani revert secara sadar via blok try/catch, sehingga fuzzer fokus mencari kegagalan assertion invariansi (seperti desinkronisasi delta) daripada menghentikan eksekusi ketika swap standar mencapai batas harga.