# Audit report

> Third round on PegFeeHook (src/PegFeeHook.sol) and its deploy script (script/DeployPegHook.s.sol). Rounds one and two, and how each finding was resolved, are in audits/AUDIT-2026-10-10.md. Tests: forge test (offline arithmetic) and forge test --fork-url (everything, against the live mainnet PoolManager).
>
> What changed since round two: afterSwap no longer moves tokens. It mints the surcharge as ERC-6909 claims to the hook (poolManager.mint), balancing the positive delta it returns. Anyone calls sweep(currency), which unlocks the PoolManager and, in unlockCallback, burns the hook's claims and takes the tokens to the immutable Treasury. beforeAddLiquidity (new flag BEFORE_ADD_LIQUIDITY) refuses an add while the pool has no active liquidity unless the price is inside ±0.25% of $1. Flags now: BEFORE_INITIALIZE, BEFORE_ADD_LIQUIDITY, AFTER_SWAP, AFTER_SWAP_RETURNS_DELTA.
>
> Answer each:
> 1. Does the hook end every swap with no outstanding delta, in every case (exact-input and exact-output, both directions, both token orders), now that it mints claims instead of taking?
> 2. Can sweep or unlockCallback be abused: called by anyone but the PoolManager, re-entered, made to burn or take more than the hook holds, made to send anywhere but the Treasury, or used to block swaps?
> 3. Can any swap still revert because of the hook?
> 4. Can the empty-pool guard be bypassed (a first deposit at a moved price), or does it block a legitimate add in a way that matters: for example while the price is outside the LPs' range with no active liquidity, or after every position is withdrawn?
> 5. Anything else a hook holding claims, implementing unlockCallback, or using beforeAddLiquidity must do that this one does not.
>
> Report findings with a concrete reproduction. The hook is not deployed yet.

| | |
|---|---|
| Repository | https://github.com/fa11up/imdusd-peg-hook |
| Commit | `3df347e3a13c150c00cf091bb480200c1db5470f` |
| Job | `442fd649-f535-4fd4-b3d7-bcffafa490b9` |
| Judged | 2026-10-10 21:20 UTC |
| Findings | 1 medium · 3 low · 3 info |

Four agents audited the code as it is at `3df347e`, 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: Empty-pool guard is switched off by one wei of in-range liquidity: the first real deposit still lands at a moved price

`src/PegFeeHook.sol:163`

```
        if (poolManager.getLiquidity(id) == 0) {
```

beforeAddLiquidity runs its +-0.25% band check only while the pool's ACTIVE liquidity is exactly zero. Active liquidity is a quantity anyone can make non-zero for dust: a 1-wei position over [MIN_TICK, MAX_TICK] is accepted while the price is at $1 (the pool is in band), costs 1e6 wei of imdUSD and 1 wei of USDC, and is in range at every price, so getLiquidity(id) never reads 0 again. Through 1 wei of liquidity a swap moves the price anywhere for dust (to $0.90 for 1,054,103 wei imdUSD, about 1e-12 imdUSD, plus 1 wei USDC; the output rounds to 0 so no surcharge is minted either). From then on the band check never executes and the LP's documented first deposit ($0.95-$1.05, README and script/DeployPegHook.s.sol lines 25-26 and 37-39) lands at $0.90 instead of reverting: one-sided, all imdUSD, with its whole stable side sitting below $0.95 for the attacker to buy on the way back to $1, surcharge-free because that leg moves towards the peg. Measured against the live PoolManager bytecode for a 1e18-liquidity deposit: the LP pays 50,032.65 imdUSD and 0 USDC; the attacker then buys 25,927.82 imdUSD for 25,275.09 USDC, a gain of 652.74 USDC at par (about 1.3% of the deposit), scaling linearly with the seed. This is the state round-2 finding 3 was closed as preventing; the contract NatSpec (lines 45-48), the README ('the first deposit cannot land at a moved price') and the script comment all state the guarantee, and checkPrice() reads only slot0, so it says 'inside the band' right up to the block in which the attacker front-runs the add (the dust position can be parked long in advance; it also disarms the guard for any re-seeding after every LP has withdrawn). Only the LP's own amount maxima (PositionManager amount0Max/amount1Max) actually protect the deposit, and nothing in the repository says so. Reported by all four specialists (audit_math, audit_flow, audit_permissions, audit_economics); merged here. Fix: stop keying the check on liquidity == 0. Either apply the band check whenever active liquidity is below a floor sized well under the planned seed (e.g. `if (poolManager.getLiquidity(id) < MIN_SEED_LIQUIDITY)`; the attached proof passes with a 1e15 floor), which prices the attack rather than removing it, and/or apply it to every add that carries no explicit sqrt-price bounds in hookData (the adder may pass abi.encode(uint160 lo, uint160 hi) that the hook enforces instead), which makes an unbounded add unable to land off-peg at any liquidity. In every case correct the NatSpec, README and script so the guarantee is stated as what it is, and add the one-wei case to test_theFirstLiquidityLandsAtTheDollar.

**Reproduction**

State: the mainnet PoolManager runtime bytecode (cast code 0x000000000004444c5dc75cB358380D2e3dE08A90) etched at its address, imdUSD (18 dp) and USDC (6 dp) test tokens, hook mined for its four flags, pool initialized at pegSqrtPriceX96, no positions. 1) Attacker: modifyLiquidity(key, {MIN_TICK, MAX_TICK, liquidityDelta: 1, salt 0}) -> accepted (price in band); getLiquidity(id) == 1. 2) Attacker: swap(key, {zeroForOne: stableIsToken0, amountSpecified: -1e9, sqrtPriceLimitX96: sqrt price at $0.90}) -> price ends at 0.900000000000000000e18; attacker spent 1,054,103 wei imdUSD and 1 wei USDC. 3) LP: modifyLiquidity(key, {tick($0.95), tick($1.05), liquidityDelta: 1e18, salt 0}) with empty hookData. Expected (the guard's stated purpose, and what happens when step 1 is omitted: EmptyPoolOffPeg wrapped by the PoolManager): revert. Actual: beforeAddLiquidity returns its selector; the LP pays 50,032,650,868,794,292,966,147 wei imdUSD and 0 USDC. 4) Attacker: swap(key, {!stableIsToken0, -1e27, limit pegSqrtPriceX96}) buys 25,927,824,898,757,416,014,082 wei imdUSD for 25,275,089,845 USDC units, no surcharge: +652.74 USDC at par. Run: forge test --match-path test/scratch/Proof_EmptyPoolGuardOneWeiBypass.t.sol fails with 'next call did not revert as expected'; the same test against a copy of the hook whose check reads `getLiquidity(id) < 1e15` passes. The specialists' stand-in proof (.imd/reads/proofs/Proof_023fc08f5b56.t.sol) also fails on this code for the same reason.

### 2. Low: Guard blocks every add, at any range, whenever the price sits outside all positions and outside the band (a depeg past the LPs' range), where restoring is not free

`src/PegFeeHook.sol:166`

```
            if ((p > 1e18 ? p - 1e18 : 1e18 - p) > BAND_BPS * 1e14) revert EmptyPoolOffPeg();
```

getLiquidity(id) == 0 is also the state of a pool that holds real positions but whose price sits outside all of them. With LPs only in $0.95-$1.05, as the deploy plan says, one sale that pushes the price to $0.94 leaves active liquidity 0 and the price outside the band, so EVERY add reverts with EmptyPoolOffPeg: support liquidity at $0.90-$0.95 placed around the current price, a top-up of the existing $0.95-$1.05 range, even a $1.10-$1.20 position the current price cannot land on. Unlike the truly empty pool, the contract's rationale ('its price can be moved for free') does not hold here: bringing the price back inside the band means buying imdUSD through the LPs' $0.95-$0.9975 stretch with real USDC (24,274.49 USDC for a 1e18-liquidity range in the reproduction), which during a real depeg nobody may do. Moving the price down past the range is free; moving it back costs; so exactly when the pool has no depth it cannot receive any. The multi-step workaround (withdraw every position so the pool is truly empty, free-swap to $1, add, let the price fall back) is undocumented and exposed to the free grief in the next finding. Reported by all four specialists; merged. Fix options: scope the guard to the pool's lifetime (a storage bool set on the first accepted add, or 'no position has ever been minted') rather than to in-range liquidity; or let hookData carry explicit price bounds that the hook enforces instead of the band, so an LP who knows the price may add anywhere; or keep the rule and document that a depeg beyond the LP range freezes adds until the price is bought back.

**Reproduction**

Etched mainnet PoolManager, pool at $1. 1) LP: modifyLiquidity(key, {tick($0.95), tick($1.05), +1e18}). 2) Trader: swap(key, {zeroForOne: stableIsToken0, amountSpecified: -1e27, limit: sqrt at $0.94}) -> price 0.94e18, getLiquidity(id) == 0. 3) LP: modifyLiquidity(key, {tick($0.90), tick($0.95), +1e18}) -> reverts (EmptyPoolOffPeg wrapped by the PoolManager's hook-call error). 4) LP: modifyLiquidity(key, {tick($0.95), tick($1.05), +1e18}) -> reverts. 5) LP: modifyLiquidity(key, {tick($1.10), tick($1.20), +1e18}) -> reverts. 6) Trader: swap(key, {!stableIsToken0, -1e27, limit at $0.998}) spends 24,274,489,278 USDC units (24,274.49 USDC); only then does step 3 succeed. Expected for a guard written for the first deposit: steps 3-5 accepted (the pool is seeded; these adds cannot land 'at a moved price' in the sense the guard protects against). Actual: all three revert. Scratch test: test/scratch/Judge.t.sol test_q4_lockoutBelowRange.

### 3. Low: While the pool has no active liquidity, a free swap front-runs every add: the first liquidity can be blocked indefinitely at gas cost, and the documented 'restore and add again' remedy is the same rac

`src/PegFeeHook.sol:47`

```
/// makes the deposit revert instead of landing at their price; restore it (a swap through an empty pool costs
```

The guard turns a moved price into a revert of the LP's add, and the documented remedy (contract lines 47-48, script lines 37-39, README line 16: run checkPrice(), then add in a separate transaction) is non-atomic. The attacker's move costs exactly as little as the restore: while active liquidity is 0 a swap with amountSpecified = -1 and a far price limit exchanges nothing (delta 0,0, the griefer's balances are unchanged), pays no surcharge (the surcharge is a share of a zero unspecified amount), and sets the pool price to its limit. Front-running each add attempt with one such swap makes every add revert with EmptyPoolOffPeg, for the gas of one swap per attempt, with no token at risk. The only defence is to restore and add atomically in one unlock, which the PositionManager alone cannot do and nothing in the repository provides. The same actor profits instead of griefing once combined with the one-wei bypass above. No funds are lost, but the launch cannot seed liquidity while the griefer keeps it up, and re-seeding after every position is withdrawn is blockable the same way. Reported by audit_math, audit_permissions and audit_economics; merged. Fix: ship the seeding path as one transaction (a small periphery contract or script step that unlocks, swaps the price to pegSqrtPriceX96 if it differs, then modifyLiquidity in the same callback and asserts slot0), or let the add itself carry price bounds in hookData so a moved price makes it revert but can never be used to park the deposit elsewhere; and remove the two-step 'restore it and add again' instruction from the comments.

**Reproduction**

Etched mainnet PoolManager, pool initialized at $1 with no liquidity. Loop 3 times: griefer swap(key, {zeroForOne: stableIsToken0, amountSpecified: -1, limit: sqrt at $0.90}) -> griefer's imdUSD and USDC balances before == after (cost 0), price 0.90e18; LP modifyLiquidity(key, {tick($0.95), tick($1.05), 1e18}) -> reverts (EmptyPoolOffPeg); LP swap(!stableIsToken0, -1, pegSqrtPriceX96) restores the price for nothing. After the loop getLiquidity(id) is still 0. Expected: an honest LP can seed the pool; actual: every attempt that is front-run reverts and the griefer's cost is gas only. Scratch test: test/scratch/Judge.t.sol test_q4_freeGrief.

### 4. Low: End-price surcharge is sandwichable: a front-run parked just inside the band makes a swap that alone paid nothing pay the ramp on its whole output

`src/PegFeeHook.sol:142`

```
        uint256 pips = surchargeFor(sqrtPriceX96, params.zeroForOne);
```

The surcharge rate depends only on where the swap ENDS, and the transaction before it chooses the starting point. An attacker sells to just inside the band (its own swap ends inside: surcharge 0); the victim's identical swap, which alone ended inside the band and paid nothing, now ends past it and pays the ramp on its entire output; the attacker buys back towards the peg (surcharge 0) and keeps the ordinary sandwich spread, while the victim additionally loses up to 4.99% of its output to the Treasury. Measured: alone, a 40,000 imdUSD sale into a ~$1M-per-side $0.95-$1.05 range ends at $0.998003 and pays 0; sandwiched by a sale to $0.9976, the same swap ends at $0.995610 and the hook mints 214,766,764 USDC units (214.77 USDC, 0.54% of the output) of claims; the attacker closes with +86.25 USDC. The only defence is the victim's router slippage, which must budget for the surcharge rather than for price impact alone (a 0.3% amountOutMinimum would have rejected this trade; a 0.75% one pays it). This is a consequence of the round-1 end-price decision rather than a bug in its implementation; it is reported so integrators are told. Reported by audit_economics; reproduced. Mitigation within the chosen design: state the surface in the contract and README and require integrators to set amountOutMinimum / amountInMaximum net of a surcharge of up to 4.99% of the unspecified amount. A design alternative (charging the end-price rate only on the deviation the swap itself added, end minus start, floored at 0) removes the surface but changes the round-1 decision and should be weighed separately.

**Reproduction**

Etched mainnet PoolManager; liquidity 4e19 in [tick($0.95), tick($1.05)]; pool at $1. Alone: victim swap(key, {zeroForOne: stableIsToken0, amountSpecified: -40_000e18, no limit}) ends at 998,003,195,406,221,891 (1e18 = $1); hook's USDC claims unchanged (surcharge 0). Sandwiched (from the same snapshot): attacker swap(stableIsToken0, -1e27, limit sqrt at $0.9976) sells 48,091.38 imdUSD, surcharge 0; the same victim swap now ends at 995,610,376,008,994,690 and the hook mints 214,766,764 USDC units of claims; attacker swap(!stableIsToken0, +48_091.38e18 exact-out, limit at $1.00) pays no surcharge; attacker's imdUSD balance is unchanged and its USDC balance is +86,252,910 units. Expected by a victim who simulated alone: output net of 0 surcharge; actual: 214.77 USDC less. Scratch test: test/scratch/Judge.t.sol test_econ_sandwichIntoBand.

### 5. Info: checkPrice() refuses adds the hook would accept: it applies the band unconditionally and ignores whether the pool already has active liquidity

`script/DeployPegHook.s.sol:132`

```
        require(dev <= 25e14, "outside the band: an add to the empty pool would revert; restore it to $1 first");
```

The hook applies the band check only while getLiquidity(id) == 0; the script's pre-add check applies it always. With liquidity present and the price at, for instance, $0.99 after a sale, the hook accepts an add but checkPrice() reverts and tells the operator the add 'would revert' and to 'restore it to $1 first', which with liquidity present is a real trade, not a free swap. The advice is wrong in that state. Reported by audit_permissions; reproduced by running the script against the etched mainnet PoolManager, CREATE2 deployer and a token at the USDC address. Fix: mirror the hook: read getLiquidity for the pool id and only require the band when it is zero, or print the two cases separately.

**Reproduction**

Mainnet PoolManager, the canonical CREATE2 deployer and a 6-decimal token at the USDC address etched locally, chainid 1, IMDUSD/TREASURY/SALT env set from mine(). 1) s.run() deploys the hook and opens the pool at $1. 2) Router adds full-range liquidity 1e18. 3) s.checkPrice() passes ('inside the band'). 4) swap(stableIsToken0, -1e27, limit sqrt at $0.99) -> price 989,999,999,999,999,998; getLiquidity(id) > 0. 5) s.checkPrice() -> reverts 'outside the band: an add to the empty pool would revert; restore it to $1 first'. 6) The same router adds another 1e18 full-range: the hook accepts (the getLiquidity(id) == 0 branch is skipped). Expected: checkPrice agrees with the hook; actual: it refuses an add the hook allows and gives advice that costs a real trade. Scratch test: test/scratch/Judge2.t.sol test_checkPriceDisagreesWithTheHookWhenLiquidityExists.

### 6. Info: README lists three flag bits; the hook's address must carry four (BEFORE_ADD_LIQUIDITY omitted)

`README.md:14`

```
salt (the address must carry exactly the BEFORE_INITIALIZE, AFTER_SWAP and AFTER_SWAP_RETURNS_DELTA bits), deploys
```

Since round 2 the hook also sets BEFORE_ADD_LIQUIDITY_FLAG (PegFeeHook.FLAGS, lines 68-69; DeployPegHook.FLAGS, lines 47-48) and the constructor (line 93) reverts HookAddressMismatch for an address carrying only the three bits the README names. Anyone mining or verifying a salt from the README's description gets an address the constructor refuses; and a hook that somehow sat at an address with only those three bits would never receive beforeAddLiquidity, leaving the empty-pool rule silently off. Reported by all four specialists; merged. Fix: list all four flags in the README sentence.

**Reproduction**

Deploy PegFeeHook at an address whose low 14 bits equal BEFORE_INITIALIZE_FLAG | AFTER_SWAP_FLAG | AFTER_SWAP_RETURNS_DELTA_FLAG (0x2000 | 0x40 | 0x4), as README.md line 14 describes. Expected per the README: a valid hook address. Actual: the constructor's `uint160(address(this)) & Hooks.ALL_HOOK_MASK != FLAGS` check (src/PegFeeHook.sol:93) reverts HookAddressMismatch because FLAGS also contains BEFORE_ADD_LIQUIDITY_FLAG (0x800). test_addressMustCarryTheFlags in test/PegFeeHook.t.sol shows the same revert for any unmined address.

### 7. Info: Constructor needs code at both token addresses: on a bare chain the hook cannot be built at all, so the protected hook floor fails before any of its tests run

`src/PegFeeHook.sol:98`

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

Round 2 accepted (its finding 8) that the hook deploys only where both tokens have code. The consequence for admission is new: the floor suite supplied with this task (.imd/reads/protected/univ4_hook/Hook.protected.t.sol) builds the attested creation code on a fresh chain with at most one token probe etched (IMD_TOKEN_PROBE). USDC at 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 has no code there, the external call to decimals() reverts on the extcodesize check, and deployAtFlags fails with 'hook deployment reverted' before test_permissionsMatchTheDeclaredFlags or test_callbacksRefuseCallersOtherThanThePoolManager run. On a mainnet fork the constructor succeeds and the project's suite passes. Reported by audit_economics; reproduced. This is an open item for the launch policy rather than a code change: run the floor on a fork (or with a token etched at the quote address), or accept that this hook is admitted through its own script and fork tests. If a code change is preferred, the constructor could fall back to constructor-supplied decimals only when the quote has no code, at the cost of reopening round-1 finding 4.

**Reproduction**

On a chain where the quote address has no code (any fresh anvil/forge VM): mine a salt for new PegFeeHook(poolManager, imdUSD, <quote with no code>, treasury) so the address carries the four flags, then deploy it. Expected by the floor suite: a deployed hook. Actual: the constructor reverts at IDecimals(quote_).decimals() (empty revert data: the call to an address without code fails the extcodesize check), so CREATE2 returns address(0) and Hook.protected.t.sol:112 require(at != address(0), 'hook deployment reverted') fails in setUp. Scratch test: test/scratch/Judge2.t.sol test_constructorRevertsWithoutCodeAtQuote (vm.expectRevert around the mined-salt deployment passes).

---

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