# Audit report

> Audit PegFeeHook, a Uniswap v4 hook, and the script that deploys it: src/PegFeeHook.sol and script/DeployPegHook.s.sol. Tests are in test/ and run against the live mainnet PoolManager (forge test --fork-url).
>
> What it is: the dynamic LP fee for one pool, imdUSD/USDC (imdUSD 18 decimals, USDC 6), on Ethereum mainnet. From the pool's price BEFORE each swap: within ±0.25% of $1, or for a trade that moves the price towards $1, the fee is 0.01%; a trade pushing it further away pays more, linearly up to 5% at $0.98 / $1.02, and 5% beyond. Only BEFORE_INITIALIZE and BEFORE_SWAP are used (fee returned with OVERRIDE_FEE_FLAG, zero delta). No owner, no settings, no storage. beforeInitialize accepts exactly one key, (imdUSD, USDC), DYNAMIC_FEE_FLAG, tick spacing 1, this hook, opening at exactly the $1 sqrtPrice. The deploy script mines the lowest CREATE2 salt whose address carries exactly the two flag bits, deploys through the canonical deployer 0x4e59b44847b379578588920cA78FbF26c0B4956C and initializes the pool.
>
> Answer each:
> 1. Can any swap revert because of the hook, at any price including the extremes of sqrtPrice and either token order? A revert would freeze the pool.
> 2. Is the fee direction right in both token orders (imdUSD as token0 and as token1)? Can a trade that pushes the price away from $1 ever pay the floor fee, other than the documented case of a single swap that starts inside the band?
> 3. Is pegSqrtPriceX96 exact for 18 vs 6 decimals in both orders, and is stablePrice() correct and monotone?
> 4. Can anyone open a pool with this hook other than the intended one, or open the intended one at another price, or call the hook directly to any effect?
> 5. Can the deployment be front-run or squatted: another contract at the mined address, the pool initialized first, a different salt or initcode reaching the same address?
> 6. Anything a Uniswap v4 hook must do that this one does not, or must not do that it does: return values, selectors, flag bits, dynamic-fee handling, fee bounds.
> 7. Economics: is there a way to trade through this pool that systematically pays less than intended (splitting a trade, sandwiching the band edge, routing through the price check), and what does it cost?
>
> Report findings with a concrete reproduction. The hook is not deployed yet; anything found can still be fixed.

| | |
|---|---|
| Repository | https://github.com/fa11up/imdusd-peg-hook |
| Commit | `efd89852ee7754ff38e3d2b5a711254a501f32cf` |
| Job | `5648bda5-e878-45f4-8dcd-b20102b4af0c` |
| Judged | 2026-10-10 19:13 UTC |
| Findings | 1 high · 1 medium · 3 low · 2 info |

Four agents audited the code as it is at `efd8985`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. High: Escalated fee is priced from the pre-swap price only: a floor-fee restore leg (or a swap that starts across the peg) lets any size be dumped at 0.01%

`src/PegFeeHook.sol:106`

```
        uint24 fee = feeFor(sqrtPriceX96, params.zeroForOne);
```

beforeSwap reads slot0 once and prices the whole swap from that price and the swap's direction; params.sqrtPriceLimitX96 and the amount are ignored, so nothing bounds where a floor-fee swap may end. Two rules then combine against the design: a trade towards $1 always pays BASE_FEE, and a trade that starts inside the +/-0.25% band pays BASE_FEE however far it pushes (the NatSpec's 'known limit'). The NatSpec claims 'every following trade in the same direction pays the escalated fee'; it does not. A seller at a depegged price first buys imdUSD with a price limit at the peg (towards: 100 pips), which lands the pool exactly inside the band, then sells what they bought plus the real sale in one swap (starts inside the band: 100 pips). The restore leg is unwound by the dump leg, so its only cost is 0.01% of its turnover plus one swap of gas; with v4 flash accounting both legs sit in one unlock and need no extra capital. Any router or aggregator finds this path because it is simply the better quote. The same root cause lets one swap that starts on the far side of the peg (e.g. at $0.99, buying imdUSD) cross $1 and end 5% above it at the floor, although a buy starting just above the band pays the ramp for the same path. Impact: the hook's only purpose (make selling into a depeg cost up to 5% so pressure goes to redemption and LPs holding the peg are paid for it; NatSpec: 'once the fee is at its cap the pool is never a cheaper exit than redemption') does not hold against any informed seller; the pool stays the cheapest exit at ~0.02% total and LPs earn nothing from the escalation. Measured with the deploy script's intended range (liquidity 4e19 in [pegTick-488, pegTick+488], ~$1M per side), both token orders identical: dumping 100,000 imdUSD at $0.99 directly pays 21,485 pips and nets 96,637.766229 USDC; restore-then-dump pays 100 pips on both legs and nets 98,704.597571 USDC, +2,066.83 USDC (2.1%) taken from LP fee income, ending at the same price ($0.9851). At $0.97 the saving is the whole 5%. Fix (design decision, both keep no owner/no storage): (a) also price the swap at its end bound, fee = max(feeFor(slot0, dir), feeFor(params.sqrtPriceLimitX96, dir)), so a swap allowed to end beyond the far band edge pays for it and honest arbitrage sets its limit at or before the peg (routers passing MIN/MAX limits then pay the escalated fee on towards-trades: the trade-off); or (b) add AFTER_SWAP + AFTER_SWAP_RETURNS_DELTA and charge the escalated part on the realised end price (changes the flag set and the mined address). Either way correct the NatSpec 'known limit'. Variant (a) was checked against the attached test: all four cases pass with it.

**Reproduction**

Pool (imdUSD 18 dec, USDC 6 dec) opened at pegSqrtPriceX96 with liquidity 4e19 in [pegTick-488, pegTick+488]; sell imdUSD with limit at $0.99 so stablePrice = 0.99e18. Direct: swap(sell imdUSD, exact-in 100_000e18, limit MIN_SQRT_PRICE+1) -> fee 21485, USDC out 96637766229, end price 0.98520. Same start, detour: swap(buy imdUSD, exact-in 1e30, limit = hook.pegSqrtPriceX96()) -> fee 100, buys 201,512.61 imdUSD for 200,522.57 USDC; then swap(sell imdUSD, exact-in 201512610368483049251144 + 100_000e18, limit MIN) -> fee 100, 299,227.17 USDC out; net 98704597571 USDC, end price 0.98509. Expected: the detour nets at most what the direct sale nets (its dump leg pays >= 21485 pips). Actual: it pays 100 pips and nets 2,066.83 USDC more, in both token orders. Cross-through: from $0.99, swap(buy imdUSD, exact-in 1e30, limit = sqrt for $1.05) -> fee 100 for the whole path, 1,186,580.75 USDC paid; split at $1.003 the far half pays 1240 pips (token0 order) / 1525 (token1 order) and the two halves cost 1,187,637.81 USDC for the same imdUSD. Run: forge test --match-path test/scratch/PegFeeBypass.t.sol -vv (4 failures on the current code; the harness is v4-core's own Pool library, since the vendored v4-core cannot compile PoolManager without solmate).

### 2. Medium: Hook does not implement getHookPermissions(); the hook admission floor (Hook.protected.t.sol) reverts before checking the flags

`src/PegFeeHook.sol:39`

```
contract PegFeeHook is IHooks {
```

PegFeeHook implements IHooks directly and validates its address with a raw mask in the constructor, but declares no getHookPermissions() and has no fallback. The supplied floor suite .imd/reads/protected/univ4_hook/Hook.protected.t.sol states a launch hook may not omit it ('the address is mined for the declared flags, and asking the implementation is the only way to know it agrees with them') and calls IHookPermissions(hook).getHookPermissions() in test_permissionsMatchTheDeclaredFlags and test_callbacksRefuseCallersOtherThanThePoolManager. A call to a missing selector on a contract without a fallback reverts, so both tests fail with EvmError: Revert before asserting anything, and the hook cannot be admitted although its address bits and callbacks are otherwise consistent. v4 itself never calls the function, so trading is unaffected. Fix: add `function getHookPermissions() public pure returns (Hooks.Permissions memory)` returning beforeInitialize = true, beforeSwap = true and the twelve others false, and replace the constructor's raw mask check with Hooks.validateHookPermissions(IHooks(address(this)), getHookPermissions()) so the declaration and the address can never drift (update both if the fee fix adds afterSwap permissions).

**Reproduction**

Deploy PegFeeHook at a mined address carrying exactly BEFORE_INITIALIZE_FLAG | BEFORE_SWAP_FLAG (0x2080), any constructor arguments. (bool ok, bytes memory ret) = address(hook).staticcall(abi.encodeWithSignature("getHookPermissions()")). Expected: ok == true and ret decodes to Hooks.Permissions with beforeInitialize and beforeSwap set, equal to uint160(address(hook)) & ALL_HOOK_MASK. Actual: ok == false, empty return data. Equivalent floor run: IMD_HOOK_CREATION_CODE=<PegFeeHook creation code with args> IMD_HOOK_FLAGS=8320 forge test --match-contract HookProtectedTest -> test_permissionsMatchTheDeclaredFlags and test_callbacksRefuseCallersOtherThanThePoolManager fail with EvmError: Revert at the getHookPermissions() call. All three specialist proofs for this (Proof_10b090433d1a, Proof_b96ae0f24d2f, Proof_efd976a01091) were run and fail for this reason.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

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

import {Test} from "forge-std/Test.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {Hooks} from "v4-core/src/libraries/Hooks.sol";
import {PegFeeHook} from "src/PegFeeHook.sol";

/// @dev What the admission floor (Hook.protected.t.sol) asks every hook: not part of IHooks, so a hook that
/// omits it still trades, but the floor cannot admit it.
interface IHookPermissions {
    function getHookPermissions() external pure returns (Hooks.Permissions memory);
}

/// @notice PegFeeHook does not implement getHookPermissions(); the call reverts, and with it the floor's
/// test_permissionsMatchTheDeclaredFlags and test_callbacksRefuseCallersOtherThanThePoolManager.
/// Fails on the current code (the call reverts). Passes once the hook declares the permissions its address
/// carries: beforeInitialize and beforeSwap true, everything else false.
contract PegFeeHookPermissionsTest is Test {
    IPoolManager pm;
    PegFeeHook hook;

    function setUp() public {
        pm = IPoolManager(address(0xBEEF)); // the hook only stores it; nothing here swaps
        address stable = address(0x1000);
        address quote = address(0x2000);
        bytes memory init =
            abi.encodePacked(type(PegFeeHook).creationCode, abi.encode(pm, stable, uint8(18), quote, uint8(6)));
        bytes32 initHash = keccak256(init);
        uint160 flags = Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG;
        for (uint256 salt;; ++salt) {
            address a = vm.computeCreate2Address(bytes32(salt), initHash, address(this));
            if (uint160(a) & Hooks.ALL_HOOK_MASK == flags) {
                hook = new PegFeeHook{salt: bytes32(salt)}(pm, stable, 18, quote, 6);
                require(address(hook) == a);
                break;
            }
        }
    }

    function test_declaresThePermissionsItsAddressCarries() public view {
        (bool ok, bytes memory ret) = address(hook).staticcall(abi.encodeCall(IHookPermissions.getHookPermissions, ()));
        assertTrue(ok, "getHookPermissions() reverts: the hook does not declare its permissions");
        assertEq(ret.length, 14 * 32, "getHookPermissions() must return Hooks.Permissions (14 bools)");
        Hooks.Permissions memory p = abi.decode(ret, (Hooks.Permissions));

        uint160 implemented;
        if (p.beforeInitialize) implemented |= Hooks.BEFORE_INITIALIZE_FLAG;
        if (p.afterInitialize) implemented |= Hooks.AFTER_INITIALIZE_FLAG;
        if (p.beforeAddLiquidity) implemented |= Hooks.BEFORE_ADD_LIQUIDITY_FLAG;
        if (p.afterAddLiquidity) implemented |= Hooks.AFTER_ADD_LIQUIDITY_FLAG;
        if (p.beforeRemoveLiquidity) implemented |= Hooks.BEFORE_REMOVE_LIQUIDITY_FLAG;
        if (p.afterRemoveLiquidity) implemented |= Hooks.AFTER_REMOVE_LIQUIDITY_FLAG;
        if (p.beforeSwap) implemented |= Hooks.BEFORE_SWAP_FLAG;
        if (p.afterSwap) implemented |= Hooks.AFTER_SWAP_FLAG;
        if (p.beforeDonate) implemented |= Hooks.BEFORE_DONATE_FLAG;
        if (p.afterDonate) implemented |= Hooks.AFTER_DONATE_FLAG;
        if (p.beforeSwapReturnDelta) implemented |= Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG;
        if (p.afterSwapReturnDelta) implemented |= Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG;
        if (p.afterAddLiquidityReturnDelta) implemented |= Hooks.AFTER_ADD_LIQUIDITY_RETURNS_DELTA_FLAG;
        if (p.afterRemoveLiquidityReturnDelta) implemented |= Hooks.AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA_FLAG;

        assertEq(implemented, uint160(address(hook)) & Hooks.ALL_HOOK_MASK, "declared permissions differ from the address bits");
        assertEq(implemented, Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG);
    }
}
```

### 3. Low: run() initializes the pool unconditionally: anyone can open the (correct) pool first, after which the deploy script reverts with PoolAlreadyInitialized

`script/DeployPegHook.s.sol:74`

```
        POOL_MANAGER.initialize(poolKey(imdUsd, hook), h.pegSqrtPriceX96());
```

The hook address is public once plan() has run (deterministic lowest salt, canonical deployer, initcode anyone can rebuild from the repo), anyone can deploy the identical initcode through 0x4e59...956C first (harmless: same code at the same address, and run() already tolerates it via the hook.code.length == 0 check), and beforeInitialize accepts the one key at the one price from any sender (it ignores the sender argument). So a third party can call PoolManager.initialize(poolKey, pegSqrtPriceX96) before the operator. run() does not check whether the pool exists: Pool.initialize (lib/v4-core/src/libraries/Pool.sol:101) reverts PoolAlreadyInitialized, and the script fails after the deploy transaction (if in the same broadcast) was mined; a second run() after a successful one fails the same way, and plan() reports only the hook's status, not the pool's. No funds or authority are at stake (the squatted pool is exactly the intended pool, no owner), and no other front-run has an effect: a different salt or initcode cannot reach the mined address and no other contract can be placed there. This is the only squatting/front-run with a consequence, and it breaks the documented launch procedure. Fix: read slot0 for the pool id (StateLibrary.getSlot0 or extsload of keccak256(abi.encode(poolId, 6))) and skip initialize when sqrtPriceX96 != 0, asserting it equals pegSqrtPriceX96; print the pool's status in plan().

**Reproduction**

State: plan() printed salt S and hook H for IMDUSD. Third party: (1) call 0x4e59b44847b379578588920cA78FbF26c0B4956C with S || initCode(imdUsd) -> H has code; (2) from any EOA call POOL_MANAGER.initialize(poolKey(imdUsd, H), PegFeeHook(H).pegSqrtPriceX96()) -> succeeds: beforeInitialize only checks msg.sender == PoolManager, the key and the price (reproduced on the local Pool-library harness, test/scratch/Explore.t.sol test_secondCopyAndAnyoneInitializes: initialize from address(0xBAD) succeeds, a second initialize reverts PoolAlreadyInitialized). Operator then runs IMDUSD=.. SALT=S forge script script/DeployPegHook.s.sol --sig run() --broadcast: the hook.code.length == 0 branch is skipped, line 74 reverts PoolAlreadyInitialized(), run() fails. Expected: run() recognises the already-open pool at $1 and finishes, as it already does for an already-deployed hook. Same failure for a second run() after a successful first.

### 4. Low: Deploy script hardcodes 18/6 decimals and mainnet addresses without checking them: a wrong IMDUSD opens the pool at a 'peg' off by 10^12, irreversibly for that initcode

`script/DeployPegHook.s.sol:30`

```
        return abi.encodePacked(type(PegFeeHook).creationCode, abi.encode(POOL_MANAGER, imdUsd, uint8(18), USDC, uint8(6)));
```

initCode() bakes uint8(18) and uint8(6) and the mainnet USDC and PoolManager addresses into the hook's constructor arguments for whatever IMDUSD is passed. plan() and run() only check imdUsd.code.length != 0 and that the CREATE2 deployer has code: not block.chainid, not that POOL_MANAGER and USDC have code, not IERC20Metadata(imdUsd).decimals() == 18 and USDC.decimals() == 6. The hook trusts its constructor (it cannot read decimals itself) and derives pegSqrtPriceX96 from the constants, and beforeInitialize accepts exactly that price. If IMDUSD points at a token with other decimals (a wrong env value, a proxy admin, a test token, another stablecoin), everything passes and run() opens a pool whose '$1' is 10^(18-d) times off; the hook address and pool id are fixed by those arguments, so the wrong pool exists forever with this hook attached and the hook refuses the correct price forever (no owner, no settings). Run on another chain where the CREATE2 deployer exists (e.g. chainid 8453), the hook deploys with dead immutables and then reverts at initialize, leaving a useless contract at the mined address. Fix: require block.chainid == 1, POOL_MANAGER.code.length != 0, USDC.code.length != 0, and IERC20Metadata(imdUsd).decimals() == 18 && IERC20Metadata(USDC).decimals() == 6 in plan() and run() (both have an RPC), and print the token symbols in plan().

**Reproduction**

test/scratch/Explore.t.sol test_scriptAcceptsSixDecimalToken: deploy a 6-decimal ERC-20 T, vm.setEnv("IMDUSD", T), DeployPegHook.plan() -> succeeds and prints a salt, hook and poolId ('status: not deployed'); the last 160 bytes of initCode(T) decode to (POOL_MANAGER, T, 18, USDC, 6) while T.decimals() == 6. On a mainnet fork, IMDUSD=0xdAC17F958D2ee523a2206206994597C13D831ec7 (USDT, 6 decimals, has code) SALT=<plan> run() deploys and initializes without a revert at pegSqrtPriceX96 = 2^96 * 1e6 (USDT is token1 there), i.e. 1 token0 unit = 1e12 token1 units, '$1' = $1,000,000 for a 6/6 pair, while the correct $1 sqrt price is 2^96. Expected: the script refuses a token whose decimals() differ from the 18/6 it encodes.

### 5. Low: The $1 opening price is not durable: between initialize and the first liquidity add a 1-wei swap moves the empty pool to any price for free, so the first LP deposit can be single-sided and sold below

`script/DeployPegHook.s.sol:14`

```
/// initialize the pool at $1. Liquidity is added separately, in a $0.95–$1.05 range.
```

run() opens the pool and stops; liquidity comes in a later transaction. In a v4 pool with zero liquidity, Pool.swap walks the price to sqrtPriceLimitX96 exchanging nothing (every computeSwapStep with liquidity 0 has amountIn = feeAmount = 0 and moves to the next target), and the hook cannot object: beforeSwap only sets a fee, which at $1 is the floor. So anyone can set the pool to, say, $0.90 for the price of gas before the LP transaction lands. An LP that then mints the planned $0.95-$1.05 position gets a single-sided position (all imdUSD when the price is below the range; a PositionManager mint with both amount maxes set does not notice), and the attacker buys that imdUSD from $0.95 upward, classified 'towards' at 0.01%, at an average of about $0.976 against a $1 redemption value: the LP loses ~2.4% of the deposit. This is v4 behaviour, not a hook bug, but the deploy flow is what exposes it. Fix: open and seed the pool in one transaction (initialize followed by modifyLiquidity in one unlock, or PositionManager's initializePool + mint multicall), or have the seeding transaction assert slot0.sqrtPriceX96 == pegSqrtPriceX96 before minting.

**Reproduction**

test/scratch/Explore.t.sol test_emptyPoolPriceMovesForFree (Pool-library harness): initialize at pegSqrtPriceX96, liquidity 0; swap(zeroForOne = true, amountSpecified = -1, sqrtPriceLimitX96 = peg * sqrt(0.90)) -> succeeds with delta (0, 0), fee 100, slot0 now at $0.90 (stablePrice 899999999999999998). modifyLiquidity(pegTick-488, pegTick+488, 4e19) then requires 1,952,191 imdUSD and 0 USDC. Next swap buying imdUSD with limit at the peg: fee 100 (towards), 989,949.80 imdUSD received for 966,138.10 USDC. Expected: the first liquidity meets the pool at $1 as the README and script promise.

### 6. Info: Band and cap boundaries are shifted by whole-basis-point truncation: the floor applies up to 0.26% from $1 (exclusive) and the ramp steps in 285-pip increments

`src/PegFeeHook.sol:128`

```
        uint256 devBps = (below ? 1e18 - p : p - 1e18) / 1e14;
```

devBps truncates the deviation to whole basis points and the band test is devBps <= 25, so the effective floor region is the open interval ($0.9974, $1.0026), one basis point wider than the documented +/-0.25%; the ramp then moves in 1-bps steps of 285 pips (100, 385, 670, ..., 49,714, 50,000), and MAX_FEE applies only at devBps >= 200, i.e. exactly <= $0.98 / >= $1.02 (at $0.98009 the fee is 4.9714%). Not a safety issue. If +/-0.25% is a contractual number, compare the un-truncated 1e18 deviation (dev < BAND_BPS * 1e14) or use devBps < BAND_BPS. Also checked here and found exact: pegSqrtPriceX96 is 79228162514264337593543 (2^96/1e6, truncated by 0.95, relative error 1.2e-23) with imdUSD as token0 and 2^96*1e6 exactly as token1; stablePrice(pegSqrtPriceX96) is exactly 1e18 in both orders; stablePrice is monotone over a 400-point sweep of MIN..MAX sqrt price in both orders; feeFor at MIN_SQRT_PRICE and MAX_SQRT_PRICE-1 returns 50,000 away and 100 towards in both orders and never reverts.

**Reproduction**

test/scratch/Explore.t.sol test_bandEdgeValues, imdUSD as token0: sqrtP = peg * sqrt(0.99740001) -> stablePrice(sqrtP) = 997400009999999999 (25.9999 bps below), feeFor(sqrtP, sell) == 100 (expected by the README: > 100, the price is more than 0.25% off); feeFor at $0.9973 == 670; at $0.98009 == 49714; at $0.98 == 50000. Same values with imdUSD as token1.

### 7. Info: The 'exactly one pool' guarantee is per hook instance: the same initcode at another flag-matching salt opens a parallel imdUSD/USDC pool

`src/PegFeeHook.sol:93`

```
                || address(key.hooks) != address(this)
```

beforeInitialize binds the hook to one PoolKey and no second pool can use this hook address, as documented. But the initcode is public, the CREATE2 deployer is permissionless and about one salt in 16,384 yields an address with exactly the two flag bits, so anyone can deploy an identical PegFeeHook at another address and initialize (imdUSD, USDC, DYNAMIC_FEE_FLAG, 1, thatHook) at $1. The copy behaves identically and has no authority over the canonical pool, so this is not an exploit; it only means the README/NatSpec claim that nobody can 'open a second one beside it' holds for the hook address, not for the pair, and integrations must pin the canonical hook address / poolId from the deploy log rather than discover a pool by tokens and fee. No code change required; worth stating in the README.

**Reproduction**

test/scratch/Explore.t.sol test_secondCopyAndAnyoneInitializes: with the hook H at salt 12590 (local deployer), mine the next salt whose address & ALL_HOOK_MASK == 0x2080 (25907 locally), deploy the same initcode to get H2, then from the pool manager call H2.beforeInitialize(_, PoolKey(imdUSD, USDC, 0x800000, 1, H2), H2.pegSqrtPriceX96()). Expected per README: refused as a second pool beside the first. Actual: returns IHooks.beforeInitialize.selector, so PoolManager.initialize on that key succeeds.

---

Judge's submission `6520471ae488b8034a953a0ef0cd58fb1435f4ea76ef8ddec011358b7ee64d9c`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
