# Audit report

> Re-audit PegFeeHook after a redesign, with 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). The previous audit and how each finding was resolved are in audits/AUDIT-2026-10-10.md.
>
> What changed: the escalated fee used to be set in beforeSwap from the pre-swap price, which a restore leg into the band followed by one large sale routed around. Now the pool's LP fee is a static 0.01% (key fee 100, tick spacing 1, opened only at exactly $1), and afterSwap reads the price where the swap ENDED: if the swap pushed the price away from $1 and ended beyond ±0.25%, it takes a surcharge in the swap's unspecified currency, rising linearly to 4.99% at $0.98 / $1.02, returns it as the afterSwap delta (flag AFTER_SWAP_RETURNS_DELTA) and sends it with take() to the imdUSD Treasury, as v4's FeeTakingHook does. Flags: BEFORE_INITIALIZE, AFTER_SWAP, AFTER_SWAP_RETURNS_DELTA. No owner, no settings, no storage.
>
> Answer each:
> 1. Is the afterSwap delta right in every case: sign, which currency, exact-input and exact-output, zeroForOne both ways, imdUSD as token0 and as token1? Does the swapper always pay exactly the surcharge, and the hook end the unlock with no outstanding delta?
> 2. Can afterSwap revert for any reachable swap: overflow, take() failing, a zero or dust amount, extreme prices? A revert freezes the pool. Note the Treasury may be unable to receive a token (a USDC blacklist): what then?
> 3. Is "away from $1" judged correctly, including swaps that cross $1 and swaps that end exactly on the band edge?
> 4. Can a trade still push the price far from $1 while paying less than the surcharge of where it ends: routing, splitting, exact-output with a tiny unspecified side, hookData, swaps made by the hook itself, liquidity added or removed around the swap?
> 5. Can anyone take or redirect the surcharge, or make the hook pay anything it did not take?
> 6. The deploy script's checks (chain, PoolManager, USDC, imdUSD decimals, the Treasury belonging to imdUSD's vault), mining, idempotent run() and checkPrice(): anything missing?
> 7. Anything a v4 hook returning an afterSwap delta must do that this one does not.
>
> 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 | `503cd9efb76dca0f1c0426cf0b17b4175fcaf0f6` |
| Job | `3d492b20-162b-4b2e-a680-f78b4ef7dcf4` |
| Judged | 2026-10-10 19:47 UTC |
| Findings | 2 medium · 2 low · 3 info |

Four agents audited the code as it is at `503cd9e`, 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. Medium: afterSwap pays the surcharge with take(): a Treasury that USDC has blacklisted makes every surcharged swap in USDC revert, permanently, with no owner or setting to recover

`src/PegFeeHook.sol:130`

```
        poolManager.take(currency, treasury, surcharge);
```

`poolManager.take(currency, treasury, surcharge)` is an immediate ERC-20 `transfer` from the PoolManager to the immutable Treasury, executed inside the swap. v4's `Currency.transfer` reverts when the token call fails, `Hooks.callHook` wraps that as `HookCallFailed`, and the whole swap reverts. USDC (FiatToken) refuses any transfer to a blacklisted account, so if Circle ever blacklists the Treasury, every exact-input sale of imdUSD that ends below $0.9975 and every exact-output purchase that ends above $1.0025 reverts, in every router, for the life of the pool: those are exactly the depeg-direction swaps the hook exists to price. Swaps inside the band and swaps towards $1 still work, so the pool becomes one-directional. The hook has no owner, no settable recipient and no fallback path, and the deploy script's check() verifies only that the Treasury has code and belongs to imdUSD's vault, not that USDC accepts transfers to it (`isBlacklisted`). The same applies if imdUSD itself ever refuses the Treasury. Reported by all four specialists; merged. Fix (keeps the economics): never let the recipient's transfer decide whether a swap executes. Credit the surcharge as an ERC-6909 claim, `poolManager.mint(address(this), currency.toId(), surcharge)` (writes only the manager's claim ledger, needs no balance and no transfer; the hook's delta still nets to zero), and add a permissionless `collect(Currency)` that unlocks, burns the claim and takes it to the Treasury, or `try take ... catch { mint }`. Verified: with `take` replaced by `mint(address(this), ...)` both attached proofs pass. Also add `require(!IUSDC(USDC).isBlacklisted(treasury))` to the script's `check()` as a pre-deploy guard.

**Reproduction**

Real PoolManager (v4-core at the vendored commit, deployed locally with an inline Owned; test/scratch/Explore.t.sol:test_blacklistedTreasuryFreezesSales): imdUSD(18)/USDC(6) pool at $1 with one position $0.95-$1.05, liquidity 1e18; mock USDC whose _update reverts 'Blacklistable: account is blacklisted' for a blacklisted account; usdc.blacklist(TREASURY). (1) Exact-input sale with the limit at $0.999 (inside the band): succeeds, surcharge 0. (2) Exact-input sale zeroForOne = stableIsToken0, amountSpecified = -1e27, sqrtPriceLimitX96 = sqrt price of $0.99. Expected: fills to $0.99, Treasury credited 21,385 pips of the 5,012,562,893 USDC-wei output (107,193,657). Actual: revert WrappedError(hook, afterSwap.selector, WrappedError(USDC, transfer.selector, Error('Blacklistable: account is blacklisted'), ERC20TransferFailed()), HookCallFailed()). (3) An exact-output sale (surcharge in imdUSD) still succeeds. Proof (no fork, manager stub with v4's take/mint semantics): forge test --match-path test/scratch/Proof_358dbd74eb60.t.sol fails with 'Blacklistable: account is blacklisted' on this tree and passes with take replaced by mint.

**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 {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId} from "v4-core/src/types/PoolId.sol";
import {Currency} from "v4-core/src/types/Currency.sol";
import {BalanceDelta, toBalanceDelta} from "v4-core/src/types/BalanceDelta.sol";
import {SwapParams} from "v4-core/src/types/PoolOperation.sol";
import {Hooks} from "v4-core/src/libraries/Hooks.sol";
import {PegFeeHook} from "src/PegFeeHook.sol";

/// @dev USDC-like token: `transfer` to a blacklisted account reverts, as FiatToken's `notBlacklisted` does.
contract BlacklistableToken {
    uint8 public immutable decimals;
    mapping(address => uint256) public balanceOf;
    mapping(address => bool) public isBlacklisted;

    constructor(uint8 d) {
        decimals = d;
    }

    function mint(address to, uint256 amount) external {
        balanceOf[to] += amount;
    }

    function blacklist(address a) external {
        isBlacklisted[a] = true;
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        require(!isBlacklisted[msg.sender] && !isBlacklisted[to], "Blacklistable: account is blacklisted");
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        return true;
    }
}

/// @dev The slice of the PoolManager an afterSwap surcharge touches, with v4's semantics: `extsload` serves
/// `getSlot0`, `take` transfers from the manager's own balance at once (and bubbles a failing transfer), and
/// `mint` credits an ERC-6909 claim that needs no balance and no transfer. It drives `afterSwap` as the manager.
contract ManagerStub {
    mapping(bytes32 => bytes32) public slots;
    mapping(address => mapping(uint256 => uint256)) public balanceOf; // ERC-6909 claims
    mapping(address => mapping(uint256 => int256)) public delta;

    function setSqrtPrice(PoolId id, uint160 sqrtPriceX96) external {
        // Pool.State is at _pools[id], slot 6 of the PoolManager; slot0 packs sqrtPriceX96 in its low 160 bits.
        slots[keccak256(abi.encode(id, uint256(6)))] = bytes32(uint256(sqrtPriceX96));
    }

    function extsload(bytes32 slot) external view returns (bytes32) {
        return slots[slot];
    }

    function take(Currency currency, address to, uint256 amount) external {
        delta[msg.sender][uint256(uint160(Currency.unwrap(currency)))] -= int256(amount);
        BlacklistableToken(Currency.unwrap(currency)).transfer(to, amount);
    }

    function mint(address to, uint256 id, uint256 amount) external {
        delta[msg.sender][id] -= int256(amount);
        balanceOf[to][id] += amount;
    }

    function burn(address from, uint256 id, uint256 amount) external {
        delta[msg.sender][id] += int256(amount);
        balanceOf[from][id] -= amount;
    }

    function sync(Currency) external {}

    function settle() external payable returns (uint256) {
        return 0;
    }

    function callAfterSwap(IHooks hook, PoolKey memory key, SwapParams memory params, BalanceDelta d)
        external
        returns (bytes4 sel, int128 hookDelta)
    {
        (sel, hookDelta) = hook.afterSwap(address(this), key, params, d, "");
        // the manager credits the hook its returned delta in the unspecified currency
        bool specifiedIs0 = params.amountSpecified < 0 == params.zeroForOne;
        Currency c = specifiedIs0 ? key.currency1 : key.currency0;
        delta[address(hook)][uint256(uint160(Currency.unwrap(c)))] += hookDelta;
    }
}

/// @notice Finding: `afterSwap` sends the surcharge with `take()`, an immediate ERC-20 transfer to the Treasury.
/// If USDC blacklists the Treasury, every exact-input sale of imdUSD that ends below the band (and every
/// exact-output purchase that ends above it) reverts, for as long as the hook lives: it has no owner and no
/// other path for the surcharge. Expected: the swap goes through and the swapper still pays the surcharge,
/// held as an ERC-6909 claim for the Treasury (or the hook) when the transfer cannot be made.
contract BlacklistedTreasuryTest is Test {
    address constant TREASURY = address(0x7EA5);

    ManagerStub pm;
    BlacklistableToken imdusd;
    BlacklistableToken usdc;
    PegFeeHook hook;
    PoolKey key;

    function setUp() public {
        pm = new ManagerStub();
        imdusd = new BlacklistableToken(18);
        usdc = new BlacklistableToken(6);
        hook = _deployHook();
        (address c0, address c1) = hook.stableIsToken0() ? (hook.stable(), hook.quote()) : (hook.quote(), hook.stable());
        key = PoolKey(Currency.wrap(c0), Currency.wrap(c1), 100, 1, IHooks(address(hook)));
        // The manager holds plenty of both currencies (mainnet: USDC from every v4 pool).
        usdc.mint(address(pm), 1e15);
        imdusd.mint(address(pm), 1e27);
    }

    function _deployHook() private returns (PegFeeHook h) {
        bytes memory init = abi.encodePacked(
            type(PegFeeHook).creationCode, abi.encode(IPoolManager(address(pm)), imdusd, usdc, TREASURY)
        );
        bytes32 initHash = keccak256(init);
        uint160 flags = Hooks.BEFORE_INITIALIZE_FLAG | Hooks.AFTER_SWAP_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG;
        for (uint256 salt;; ++salt) {
            address a = vm.computeCreate2Address(bytes32(salt), initHash, address(this));
            if (uint160(a) & Hooks.ALL_HOOK_MASK == flags) {
                h = new PegFeeHook{salt: bytes32(salt)}(IPoolManager(address(pm)), address(imdusd), address(usdc), TREASURY);
                require(address(h) == a);
                return h;
            }
        }
    }

    /// @dev sqrtPriceX96 at which imdUSD is worth `priceE18` USDC (1e18 = $1).
    function _sqrtAt(uint256 priceE18) private view returns (uint160) {
        uint256 rel = hook.stableIsToken0() ? priceE18 : 1e36 / priceE18;
        return uint160(uint256(hook.pegSqrtPriceX96()) * _sqrt(rel * 1e18) / 1e18);
    }

    function _sqrt(uint256 x) private pure returns (uint256 y) {
        y = x;
        uint256 z = (x + 1) / 2;
        while (z < y) (y, z) = (z, (x / z + z) / 2);
    }

    function test_exactInputSaleBelowTheBandSurvivesABlacklistedTreasury() public {
        usdc.blacklist(TREASURY);

        // An exact-input sale of 10,000 imdUSD that ended at $0.99 and produced 9,900 USDC.
        bool zeroForOne = hook.stableIsToken0();
        uint160 endSqrtPrice = _sqrtAt(0.99e18);
        pm.setSqrtPrice(key.toId(), endSqrtPrice);
        SwapParams memory params = SwapParams(zeroForOne, -int256(10_000e18), 0);
        BalanceDelta d = zeroForOne ? toBalanceDelta(-10_000e18, 9_900e6) : toBalanceDelta(9_900e6, -10_000e18);

        uint256 pips = hook.surchargeFor(endSqrtPrice, zeroForOne);
        assertGt(pips, 0, "the swap ended below the band, away from $1");
        uint256 expected = 9_900e6 * pips / 1_000_000;

        // Today: PoolManager.take -> USDC.transfer(TREASURY) -> "Blacklistable: account is blacklisted" -> the swap reverts.
        (bytes4 sel, int128 hookDelta) = pm.callAfterSwap(IHooks(address(hook)), key, params, d);

        assertEq(sel, IHooks.afterSwap.selector);
        assertEq(uint256(int256(hookDelta)), expected, "the swapper still pays exactly the surcharge");
        uint256 id = uint256(uint160(address(usdc)));
        uint256 held = usdc.balanceOf(TREASURY) + pm.balanceOf(TREASURY, id) + pm.balanceOf(address(hook), id);
        assertEq(held, expected, "the surcharge is held for the Treasury, as tokens or as a claim");
        assertEq(pm.delta(address(hook), id), 0, "the hook leaves no outstanding delta");
    }
}
```

### 2. Medium: Exact-output sales take the surcharge in imdUSD from the PoolManager's balance before the swapper settles: when the manager holds less imdUSD than the surcharge the swap reverts

`src/PegFeeHook.sol:130`

```
        poolManager.take(currency, treasury, surcharge);
```

For an exact-output swap the unspecified currency is the swap's INPUT. afterSwap runs inside PoolManager.swap, before the router settles that input (every v4 router settles after swap returns), so `take()` must be paid out of tokens the PoolManager already holds. For USDC the manager holds every other pool's USDC; for imdUSD it holds only this pool's reserve (plus any imdUSD in other v4 pools or claims, none for a new token), and that reserve shrinks to dust as the price rises through the LP range: above $1.05 the launch position is all USDC. Any exact-output sale of imdUSD that ends below the band while the surcharge exceeds the manager's imdUSD balance reverts with ERC20InsufficientBalance inside take(), wrapped as HookCallFailed, although the trade itself is fine and the identical exact-input sale succeeds. With the launch position (liquidity 1e18, $0.95-$1.05), every exact-output sale from above about $1.046 that ends at or below $0.97 reverts, and from above $1.05 every exact-output sale that ends below $0.9975; a bid-side-only liquidity posture (positions below the price, so the pool holds no imdUSD) fails for every exact-output sale beyond the band. Routers' exact-output paths (Universal Router SWAP_EXACT_OUT_SINGLE) fail in that state; no owner or fallback clears it until exact-input sales refill the reserve. The uniswap-v4-hooks reference supplied with this task names this failure for FeeTakingHook-style take in afterSwap. Reported by all four specialists; merged. Same root cause and same fix as the blacklist finding: credit the surcharge as an ERC-6909 claim (`poolManager.mint`) instead of `take`, and sweep it to the Treasury outside the swap; or take only when `currency.balanceOf(address(poolManager)) >= surcharge` and mint otherwise. Verified: both attached proofs pass with mint in place of take.

**Reproduction**

Real PoolManager deployed locally (test/scratch/Explore.t.sol:test_exactOutShortOfImdUsd and test_exactOutShortOfImdUsdFromAboveRange, both token orderings): pool at $1, one position $0.95-$1.05 with liquidity 1e18 (manager holds 24,056.03 imdUSD and 25,321.30 USDC). (A) Buy imdUSD exact-input with the limit at $1.049: manager imdUSD balance 421.15e18. Then sell exact-output: zeroForOne = stableIsToken0, amountSpecified = +1e27 (USDC out, bounded by the limit), sqrtPriceLimitX96 = sqrt price of $0.97. Expected: fills to $0.97, swapper pays the input plus a 4.99% surcharge (1,945.35 imdUSD) to the Treasury. Actual: revert WrappedError(hook, afterSwap, WrappedError(imdUSD, transfer, ERC20InsufficientBalance(poolManager, 421151902591758121461, 1945348713413656415836), ERC20TransferFailed()), HookCallFailed()). The same sale as exact input (-1e27, same limit) succeeds and pays 1,962,129,384 USDC-wei. (B) Buy to $1.06: manager imdUSD balance 2 wei; exact-output sale of +30,000e6 USDC with the limit at $0.99 reverts with ERC20InsufficientBalance(poolManager, 2, 622234156273878322156). Proof (no fork, manager stub): forge test --match-path test/scratch/Proof_e20e818a9201.t.sol fails with 'panic: arithmetic underflow or overflow' (transfer from a zero balance) on this tree and passes with take replaced by mint.

**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 {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId} from "v4-core/src/types/PoolId.sol";
import {Currency} from "v4-core/src/types/Currency.sol";
import {BalanceDelta, toBalanceDelta} from "v4-core/src/types/BalanceDelta.sol";
import {SwapParams} from "v4-core/src/types/PoolOperation.sol";
import {Hooks} from "v4-core/src/libraries/Hooks.sol";
import {PegFeeHook} from "src/PegFeeHook.sol";

/// @dev Plain ERC-20 (OpenZeppelin-style): a transfer beyond the balance reverts.
contract PlainToken {
    uint8 public immutable decimals;
    mapping(address => uint256) public balanceOf;

    constructor(uint8 d) {
        decimals = d;
    }

    function mint(address to, uint256 amount) external {
        balanceOf[to] += amount;
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        balanceOf[msg.sender] -= amount; // reverts when short
        balanceOf[to] += amount;
        return true;
    }
}

/// @dev The slice of the PoolManager an afterSwap surcharge touches, with v4's semantics: `extsload` serves
/// `getSlot0`, `take` transfers from the manager's own balance at once (before the swapper has settled), and
/// `mint` credits an ERC-6909 claim that needs no balance. It drives `afterSwap` as the manager.
contract ManagerStub {
    mapping(bytes32 => bytes32) public slots;
    mapping(address => mapping(uint256 => uint256)) public balanceOf; // ERC-6909 claims
    mapping(address => mapping(uint256 => int256)) public delta;

    function setSqrtPrice(PoolId id, uint160 sqrtPriceX96) external {
        slots[keccak256(abi.encode(id, uint256(6)))] = bytes32(uint256(sqrtPriceX96));
    }

    function extsload(bytes32 slot) external view returns (bytes32) {
        return slots[slot];
    }

    function take(Currency currency, address to, uint256 amount) external {
        delta[msg.sender][uint256(uint160(Currency.unwrap(currency)))] -= int256(amount);
        PlainToken(Currency.unwrap(currency)).transfer(to, amount);
    }

    function mint(address to, uint256 id, uint256 amount) external {
        delta[msg.sender][id] -= int256(amount);
        balanceOf[to][id] += amount;
    }

    function burn(address from, uint256 id, uint256 amount) external {
        delta[msg.sender][id] += int256(amount);
        balanceOf[from][id] -= amount;
    }

    function sync(Currency) external {}

    function settle() external payable returns (uint256) {
        return 0;
    }

    function callAfterSwap(IHooks hook, PoolKey memory key, SwapParams memory params, BalanceDelta d)
        external
        returns (bytes4 sel, int128 hookDelta)
    {
        (sel, hookDelta) = hook.afterSwap(address(this), key, params, d, "");
        bool specifiedIs0 = params.amountSpecified < 0 == params.zeroForOne;
        Currency c = specifiedIs0 ? key.currency1 : key.currency0;
        delta[address(hook)][uint256(uint160(Currency.unwrap(c)))] += hookDelta;
    }
}

/// @notice Finding: for an exact-output sale of imdUSD the surcharge is in imdUSD, the swap's INPUT, which the
/// swapper has not settled when `afterSwap` runs. `take()` therefore pays the Treasury out of imdUSD the
/// PoolManager already holds, which is only this pool's imdUSD reserve. When that reserve is below the
/// surcharge (liquidity only on the USDC side, or the price above every position), the transfer fails and
/// the swap reverts. Expected: the swap goes through and the surcharge is held as a claim.
contract ManagerShortOfImdUsdTest is Test {
    address constant TREASURY = address(0x7EA5);

    ManagerStub pm;
    PlainToken imdusd;
    PlainToken usdc;
    PegFeeHook hook;
    PoolKey key;

    function setUp() public {
        pm = new ManagerStub();
        imdusd = new PlainToken(18);
        usdc = new PlainToken(6);
        hook = _deployHook();
        (address c0, address c1) = hook.stableIsToken0() ? (hook.stable(), hook.quote()) : (hook.quote(), hook.stable());
        key = PoolKey(Currency.wrap(c0), Currency.wrap(c1), 100, 1, IHooks(address(hook)));
        // The manager holds USDC (every v4 USDC pool) but no imdUSD: the pool's only liquidity is USDC-side.
        usdc.mint(address(pm), 1e15);
    }

    function _deployHook() private returns (PegFeeHook h) {
        bytes memory init = abi.encodePacked(
            type(PegFeeHook).creationCode, abi.encode(IPoolManager(address(pm)), imdusd, usdc, TREASURY)
        );
        bytes32 initHash = keccak256(init);
        uint160 flags = Hooks.BEFORE_INITIALIZE_FLAG | Hooks.AFTER_SWAP_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG;
        for (uint256 salt;; ++salt) {
            address a = vm.computeCreate2Address(bytes32(salt), initHash, address(this));
            if (uint160(a) & Hooks.ALL_HOOK_MASK == flags) {
                h = new PegFeeHook{salt: bytes32(salt)}(IPoolManager(address(pm)), address(imdusd), address(usdc), TREASURY);
                require(address(h) == a);
                return h;
            }
        }
    }

    function _sqrtAt(uint256 priceE18) private view returns (uint160) {
        uint256 rel = hook.stableIsToken0() ? priceE18 : 1e36 / priceE18;
        return uint160(uint256(hook.pegSqrtPriceX96()) * _sqrt(rel * 1e18) / 1e18);
    }

    function _sqrt(uint256 x) private pure returns (uint256 y) {
        y = x;
        uint256 z = (x + 1) / 2;
        while (z < y) (y, z) = (z, (x / z + z) / 2);
    }

    function test_exactOutputSaleSurvivesAManagerShortOfImdUsd() public {
        // An exact-output sale: 10,000 USDC out, 10,110 imdUSD in, ending at $0.98.
        bool zeroForOne = hook.stableIsToken0();
        uint160 endSqrtPrice = _sqrtAt(0.98e18);
        pm.setSqrtPrice(key.toId(), endSqrtPrice);
        SwapParams memory params = SwapParams(zeroForOne, int256(10_000e6), 0);
        BalanceDelta d = zeroForOne ? toBalanceDelta(-10_110e18, 10_000e6) : toBalanceDelta(10_000e6, -10_110e18);

        uint256 pips = hook.surchargeFor(endSqrtPrice, zeroForOne);
        assertEq(pips, 49_900, "past the cap");
        uint256 expected = 10_110e18 * pips / 1_000_000;
        assertEq(imdusd.balanceOf(address(pm)), 0, "the manager holds no imdUSD before the swapper settles");

        // Today: PoolManager.take(imdUSD, TREASURY, 504.489e18) -> transfer from a zero balance -> the swap reverts.
        (bytes4 sel, int128 hookDelta) = pm.callAfterSwap(IHooks(address(hook)), key, params, d);

        assertEq(sel, IHooks.afterSwap.selector);
        assertEq(uint256(int256(hookDelta)), expected, "the swapper still pays exactly the surcharge");
        uint256 id = uint256(uint160(address(imdusd)));
        uint256 held = imdusd.balanceOf(TREASURY) + pm.balanceOf(TREASURY, id) + pm.balanceOf(address(hook), id);
        assertEq(held, expected, "the surcharge is held for the Treasury, as tokens or as a claim");
        assertEq(pm.delta(address(hook), id), 0, "the hook leaves no outstanding delta");
    }
}
```

### 3. Low: run() opens the pool empty, so anyone moves its price for free before the first liquidity; checkPrice() is a separate view that cannot close the window, and the comment's atomic 're-initialization' pa

`script/DeployPegHook.s.sol:105`

```
        if (sqrtP == 0) POOL_MANAGER.initialize(key, h.pegSqrtPriceX96());
```

run() initializes the pool at $1 and stops; liquidity is added later by hand after checkPrice(). In an empty pool a swap exchanges nothing but still walks slot0 to its sqrtPriceLimitX96 (Pool.swap steps through zero liquidity until the limit), both deltas are 0, and afterSwap charges 0 (abs = 0). So between checkPrice() (an off-chain view in an earlier block) and the mint transaction, any address can set the price anywhere at the cost of gas, including by front-running the mint in the same block. A position minted around $1 at a moved price is deposited single-sided and is immediately traded through: the trade that does so moves TOWARDS $1 and therefore pays no surcharge. The previous audit's resolution of its finding 5 ('Mitigated: checkPrice()') does not close the window, and the alternative the script's comment on lines 33-35 names, 'adds liquidity in the same transaction as any re-initialization through PositionManager's multicall', is unavailable: a pool can be initialized once (Pool.initialize reverts PoolAlreadyInitialized), and run() has already done it. With tight amount0Max/amount1Max the mint instead reverts, repeatably, for one 1-wei swap per attempt. Reported by three specialists; merged. Fix: make the first liquidity atomic with initialization. Either have run() not initialize and let the liquidity step do PositionManager.multicall([initializePool, mint]) in one transaction (beforeInitialize already guarantees $1), or have run() seed a position in the same broadcast as initialize through a small unlock-callback helper; make checkPrice() also require getLiquidity(poolId) > 0 before declaring it safe, and correct the comment. A hook-side complement is possible (revert in afterSwap when the swap exchanged nothing, delta == ZERO_DELTA) but is a design change.

**Reproduction**

Real PoolManager deployed locally (test/scratch/Explore.t.sol:ExploreEmpty.test_firstLiquidityRace): pool initialized at pegSqrtPriceX96 exactly as run() does, no liquidity; checkPrice() would pass. Attacker: swap zeroForOne = stableIsToken0, amountSpecified = -1, sqrtPriceLimitX96 = sqrt price of $0.90. Result: delta (0, 0), Treasury receives nothing, stablePrice(slot0) = 0.899999999999999998e18. LP (next tx): mint liquidity 1e18 in [$0.95, $1.05] expecting a balanced deposit; actual deposit 50,035.15 imdUSD and 0 USDC (price below the range: single-sided). Attacker then buys imdUSD exact-input with the limit at $1.00: receives 25,979.12 imdUSD for 25,323.83 USDC (average $0.9748), surcharge 0 (towards $1). Expected: the first liquidity cannot land at a price outside the band. Also: PM.initialize(key, peg) on the open pool reverts (test/scratch/Misc.t.sol:test_cannotReinitialize), so the comment's re-initialization route does not exist.

### 4. Low: 'Away from $1' is judged from the end price and direction alone: a swap that crosses $1 and lands closer to the peg than it started pays the full surcharge on its whole unspecified amount, contrary to

`src/PegFeeHook.sol:164`

```
        bool away = below ? sellsStable : !sellsStable;
```

surchargeFor looks only at where the swap ends and which token it sold: a sale ending below the band is 'away' wherever it started. A sale from $1.02 to $0.99 moves the deviation from +2.0% to -1.0% (net closer to $1, and most of its volume restored the peg) yet pays the $0.99 rate, 21,385 pips, on its entire output, including the part that moved the price from $1.02 to $1.00. Line 23 ('A swap that moves the price towards $1 pays no surcharge') and the README ('Swaps that move the price back towards $1 pay only the LP fee') say otherwise; line 29 covers a swap that crosses and 'lands far on the other side', not one that lands nearer. The effect is a cliff at the far band edge for restoring arbitrageurs: stop at $1.0025 and pay 0, overshoot by one tick and pay 2.1% on everything, which discourages the restoring flow the hook wants. It is a conservative error (over-collection, never under-collection) and not an extraction path, so low. Reported by two specialists; merged. Fix options: document it as intended ('ending beyond the band in the direction sold pays, wherever the swap started', so integrators set limits at the band edge), or record the pre-swap sqrtPrice in beforeSwap (transient storage, BEFORE_SWAP flag) and charge only the share of the unspecified amount traded beyond $1, which is a design change.

**Reproduction**

Real PoolManager deployed locally (test/scratch/Explore.t.sol:test_crossingNearerStillPays and ExploreEdge.test_crossingFromFarSideIntoBandPaysNothing): launch position, liquidity 1e18. (1) Buy imdUSD to $1.02 (price 1.019999999999999999e18); sell exact-input with the limit at $0.99: end price 0.989999999999999998e18, surchargeFor(end, zeroForOne) = 21,385 pips, Treasury takes 319,984,968 USDC-wei of the 14,963,056,728 gross output, although the deviation fell from 2.0% to 1.0%. (2) Sell to $0.97, then buy exact-input with the limit at $1.01: 21,385 pips, Treasury takes 434.31 imdUSD of 20,308.97 imdUSD output (deviation 3% -> 1%). (3) Sell from $1.03 to $0.9975: surcharge 0. Expected per line 23: a swap whose end is nearer $1 than its start pays nothing, or only on the part beyond $1.

### 5. Info: The 'at the cap the pool is never a cheaper exit than redemption' claim holds per swap, not per exit: a sale sliced to $0.98 pays about 2.25% instead of 4.98%, and the exact-output cap is 4.75% of wha

`src/PegFeeHook.sol:37`

```
/// fee is at its cap the pool is never a cheaper exit than redemption, and selling pressure in a depeg goes to
```

Each swap is charged at its own END rate on its WHOLE unspecified amount, so one large swap over-pays relative to the marginal schedule and a trader who slices the same exit (any router can batch the slices in one transaction, so the extra cost is gas only) pays about the path integral of the ramp: the first 0.25% is free, the ramp to $0.98 averages about 2.5%, and only the part below $0.98 pays 4.99%. The NatSpec on lines 30-31 states the slicing property, so this is a documentation precision note, not a defect: the sentence quoted (and the README's 5% total) is true of the marginal slice, not of an exit as a whole, and a sliced exit to $0.98 costs about 2.25% surcharge plus about 1% average slippage, under the 5% redemption fee. JIT liquidity, hookData, exact-output and crossing swaps give no further reduction (a swap's price path is monotonic, the rate is set by the end, and the base is one full leg of the trade). Separately, for exact-output swaps the surcharge is added to the input, so at the cap the swapper pays 1.0499x and the surcharge is 4.75% of the gross payment (4.76% with the LP fee), not 5%. Reported by three specialists; merged. If a per-exit floor is wanted it needs a path-dependent fee (start price from beforeSwap), otherwise reword lines 36-38 and the README.

**Reproduction**

Real PoolManager deployed locally (test/scratch/Explore.t.sol:test_splitVsSingle): launch position, liquidity 1e18, price $1. One exact-input sale with the limit at $0.98: gross output 10,050,506,338 USDC-wei, surcharge 501,520,266 (498 bps), end price 0.979999999999999999e18. Restore to $1, then 40 exact-input sales with limits at $1 - 0.0005*i: summed gross output 10,050,506,318, summed surcharge 226,145,845 (225 bps), same end price. Exact-output at the cap (test_allKinds): swapper's input delta = -(gross + 0.0499*gross), so surcharge / total paid = 0.0499 / 1.0499 = 4.75%.

### 6. Info: Every test skips without a mainnet fork, so the verifier's offline run proves nothing and none of the audit resolutions are exercised

`test/PegFeeHook.t.sol:83`

```
        if (block.chainid != 1 || address(PM).code.length == 0) vm.skip(true);
```

All tests call vm.skip unless on chain 1 with the live PoolManager, and the offline check runs with no network, so `forge test` reports 0 passed, 0 failed, 3 skipped: the restore-then-dump, crossing, exact-output, band-edge and script tests never run where the code is verified. The project cannot deploy a local PoolManager because lib/v4-core is vendored without solmate (ProtocolFees.sol imports solmate/src/auth/Owned.sol), which is also why the two attached proofs use a manager stub. A local suite (vendor solmate as ordinary files, or a copy of ProtocolFees with Owned inlined, then `new PoolManager(address(this))`) would have surfaced the exact-output take() failure above with one more test (buy past the range, then sell exact-output). Keep the fork run as an extra.

**Reproduction**

`forge test --match-path test/PegFeeHook.t.sol` with no --fork-url: PegFeeHookTest 'Suite result: ok. 0 passed; 0 failed; 1 skipped' (setUp skips the whole contract), DeployPegHookForkTest '0 passed; 0 failed; 2 skipped'. A test importing v4-core/src/PoolManager.sol fails to build: Source "solmate/src/auth/Owned.sol" not found. The review's own tests ran against a scratch copy of PoolManager with Owned inlined (test/scratch/pm/).

### 7. Info: The constructor dereferences both tokens' decimals(), so the creation code only deploys where both token addresses hold code: the supplied hook floor harness cannot build it

`src/PegFeeHook.sol:79`

```
        (uint8 sd, uint8 qd) = (IDecimals(stable_).decimals(), IDecimals(quote_).decimals());
```

Reading decimals on chain is the right resolution of the previous audit's finding 4, but it makes the hook's creation code deployable only where both token addresses already have code. The supplied floor suite (.imd/reads/protected/univ4_hook/Hook.protected.t.sol) deploys a hook from IMD_HOOK_CREATION_CODE after etching only the PoolManager and, optionally, one token probe; with the mainnet USDC address baked into the arguments, `IDecimals(quote_).decimals()` hits an address with no code, the constructor reverts and setUp fails with 'hook deployment reverted' before any check runs. Its test_initializesFromTheLaunchFactory would also fail, since beforeInitialize refuses every key but (imdUSD, USDC, 100, 1). For the mainnet script deployment this is harmless (both tokens exist, the single-pool rule is intended). If the hook must pass that harness, etch USDC at its address in the harness or let the constructor fall back to 18/6 when a token has no code; otherwise record that the floor suite does not apply to this deployment.

**Reproduction**

test/scratch/Misc.t.sol:test_constructorNeedsTokenCode: with 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 holding no code, `new PegFeeHook{salt}(poolManager, imdUsd, 0xA0b8..., treasury)` at a flag-carrying address reverts (the external call to a codeless address fails). Expected by the harness: a deployed hook; actual: revert in the constructor.

---

Judge's submission `dbac07d8d20c5405bef995c2c5f700e20c5df62b8bfa6e31c7517ea4c95cc8fb`, 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.
