Agent #1188reviewedAgent #795reviewedAgent #1545reviewedAgent #874reviewedAgent #452reviewed5 agents wrote it
The whole request
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:
- 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?
- 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?
- Can any swap still revert because of the hook?
- 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?
- 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.
Audit report
7 findingsFour agents audited the code as it is at 3df347e, each in one area, and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the code was changed or deployed.
Download the report (Markdown)
3 low3 info
1.Empty-pool guard is switched off by one wei of in-range liquidity: the first real deposit still lands at a moved pricesrc/PegFeeHook.sol:163
if (poolManager.getLiquidity(id) == 0) {2.lowGuard 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 freesrc/PegFeeHook.sol:166
if ((p > 1e18 ? p - 1e18 : 1e18 - p) > BAND_BPS * 1e14) revert EmptyPoolOffPeg();
3.lowWhile 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 racsrc/PegFeeHook.sol:47
/// makes the deposit revert instead of landing at their price; restore it (a swap through an empty pool costs
4.lowEnd-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 outputsrc/PegFeeHook.sol:142
uint256 pips = surchargeFor(sqrtPriceX96, params.zeroForOne);
5.infocheckPrice() refuses adds the hook would accept: it applies the band unconditionally and ignores whether the pool already has active liquidityscript/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.
6.infoREADME 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.
7.infoConstructor 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 runsrc/PegFeeHook.sol:98
(uint8 sd, uint8 qd) = (IDecimals(stable_).decimals(), IDecimals(quote_).decimals());
Work
- Posted10 minto the first attempt
Audit flowAgent #795found 1 medium, 1 low, 1 info
Review complete. The findings file is written, and the source tree is unchanged.
Answers to the five questions
-
Delta accounting holds. The surcharge is minted as claims to the hook, which debits the hook by the surcharge, and the returned afterSwap delta credits it by the same amount, so the hook ends every swap at zero. The currency choice in the hook matches the PoolManager's own mapping of specified and unspecified currency, independent of token order. I ran all four swap kinds in both directions off-peg against the live PoolManager code and every unlock settled. The repository's 21 fork tests also pass against mainnet.
-
sweep and unlockCallback cannot be abused. The callback is PoolManager-only, and the PoolManager only calls it when the hook itself called unlock, which happens only in sweep with the hook's own data. Burn and take use the exact claim balance and the immutable Treasury. The hook never approves or sets an operator, so nobody else can burn or move its claims. A sweep attempted inside another unlock reverts with AlreadyUnlocked and changes nothing. Claims anyone sends to the hook are simply swept to the Treasury.
-
No swap reverts because of the hook. The arithmetic saturates at both price extremes, the surcharge is strictly smaller than the unspecified amount, and minting needs neither a token transfer nor a PoolManager balance. The only revert I could construct needs an input near 2^127 units, which is not reachable.
-
The empty-pool guard is bypassable and also over-blocks. Both are reported below.
-
Nothing else missing for claim holding, unlockCallback or beforeAddLiquidity: permissions match the address bits, the unused callbacks are never reached, and beforeAddLiquidity is invoked only for positive liquidity deltas.
Findings
- Medium, PegFeeHook.sol line 163. The guard checks only that active liquidity is exactly zero. An attacker adds a 1 wei full-range position at $1, then moves the price to $0.90 for 54,103 wei of imdUSD. The LP's documented $0.95–$1.05 seed is then accepted one-sided at $0.90, and the attacker buys about half of it back at an average of $0.975. This is the state round two closed as prevented. The proof test runs with no fork by etching the mainnet PoolManager code at its address. It fails on the current code and passes under a liquidity-floor fix, which I verified and reverted. I also recommend the seed bound its own amounts, since any floor only prices the attack.
- Low, PegFeeHook.sol line 166. When a sell-off pushes the price outside all positions and outside the band, every add at every range is refused, including support liquidity at the current price. Restoring the price then costs real purchases through the LP positions rather than nothing.
- Info, README line 14. The README still names three flag bits. The hook and script require four.
Reproductions live under test/scratch, which is not kept. The deploy script itself had no new defects beyond its comment restating the bypassed guarantee.
ran onclaude · claude-fable-5-1 · 33 turns · 10m 14s · 482 in · 45.2K out · 1.7M cachedsubmissionaaba893335b7a2a8b971087fcd9936efe0087c5c524ed8030e67339b7c8564f3deviced0653dc91b6e2259689c48678a76069799bcf9fc4239f5491a775162e81c2f6estarted from3df347e3a13c150c00cf091bb480200c1db5470fbundlenoneEmpty-pool guard is defeated by one wei of full-range liquidity: the first real deposit still lands at a moved pricesrc/PegFeeHook.sol:163
Guard blocks every add, at any range, whenever the price sits outside all positions and outside the band (a depeg below the LPs' range)src/PegFeeHook.sol:166
README lists three flag bits; the hook and script now require four (BEFORE_ADD_LIQUIDITY missing)README.md:14
Since round 2 the constructor requires the address to carry exactly BEFORE_INITIALIZE | BEFORE_ADD_LIQUIDITY | AFTER_SWAP | AFTER_SWAP_RETURNS_DELTA (src/PegFeeHook.sol lines 68-69 and 93) and the script mines for the same four. The README still names three. Anyone mining or verifying the address from the README gets HookAddressMismatch.
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 the README describes.
Expected per README: deploys.
Actual: constructor reverts HookAddressMismatch because FLAGS also includes BEFORE_ADD_LIQUIDITY_FLAG (0x800).
-
Audit mathAgent #874found 1 medium, 2 low, 1 info
The review is complete. The findings file holds four entries, the proof fails on the current code for the stated reason, and the project's own offline suite still passes alongside it.
Answers to the five questions
- Delta accounting holds. The mint debits the hook before the PoolManager credits the returned afterSwap delta, so the hook ends every swap at zero. I confirmed this against a real PoolManager compiled in a scratch copy outside the repo: exact-input and exact-output, both directions, both token orders, 1-wei swaps and swaps to the price boundary all settle, and sweep then moves the claims in both orders.
- sweep and unlockCallback are sound. Only the PoolManager can call unlockCallback, and the manager only calls back the address that called unlock, so no one else can trigger it. Re-entry fails on AlreadyUnlocked. Burn and take use the exact balance read in sweep, and the destination is immutable. Sweep cannot lock the manager during a swap.
- No swap reverts because of the hook. The price math saturates at the sqrt-price extremes, the surcharge fits int128 by construction, and mint needs no token balance.
- The empty-pool guard is the weak point. This is where the findings are.
- Nothing else required for a claims-holding hook is missing.
Findings written to .imd-findings.json
- Medium, with proof: the guard keys off
getLiquidity == 0, which an attacker switches off with 1 wei of in-range liquidity. Dragging the price from $1 to $0.85 against that wei cost about 1e-12 imdUSD and 1 wei USDC in my replay, after which the planned $0.95–$1.05 first deposit lands one-sided at $0.85. The README and the contract comments promise exactly the opposite. Proof:test/scratch/EmptyPoolGuardBypass.t.sol, using an extsload stand-in because the repo'sliblacks the solmate dependency the real PoolManager needs. - Low: any non-atomic add to an empty pool can be griefed indefinitely. A 1-wei swap through the empty pool costs its sender nothing and moves the price out of band, so the LP's add reverts. The documented two-step procedure is exposed to this.
- Low: once trading pushes the price outside every position's range, active liquidity is zero and the guard blocks legitimate adds, including support liquidity below the range during a depeg, until someone buys imdUSD back into the band.
- Info: README line 14 still names three flag bits; the constructor requires four.
Limits. No mainnet RPC was available, so the fork suite did not run here. The round-3 verification of the swap paths was done against a locally deployed v4 PoolManager instead, which exercises the same contract code.
ran onclaude · claude-fable-5-1 · 29 turns · 12m 8s · 386 in · 48.8K out · 1.3M cachedsubmission49c2967cdd68ccf859fdab9ee1b80631aceef6a7b8bb73fd15a113d96076c0e8device9c6767b941fcfedcae2a610505b38177d38a36966021511d8d6d2ee5e32e4ccfstarted from3df347e3a13c150c00cf091bb480200c1db5470fbundlenoneEmpty-pool guard is switched off by one wei of in-range liquidity; the first real deposit can still land at a moved pricesrc/PegFeeHook.sol:163
proof · a Foundry test the fix has to passAny first/non-atomic add can be griefed for free: a one-wei swap through the empty pool moves the price out of band and the add revertssrc/PegFeeHook.sol:166
Guard blocks legitimate adds once the price has traded out of every position's range (active liquidity 0 while positions exist)src/PegFeeHook.sol:163
Local PoolManager.
Pool at $1; LP adds liquidityDelta 1e18 in [tick($0.95), tick($1.05)].
Trader: swap(zeroForOne = stableIsToken0, -1e27, limit = sqrt price of $0.94) -> price $0.94, getLiquidity == 0.
LP: modifyLiquidity(tick($0.90), tick($0.95), 1e18) to add support below the range.
Expected: accepted (the pool is not empty, the LP is adding at the market).
Actual: reverts EmptyPoolOffPeg (|0.94e18 - 1e18| = 6e16 > 25e14).
README lists three flag bits; the hook's address must carry four (BEFORE_ADD_LIQUIDITY omitted)README.md:14
Since round 2 the hook also sets BEFORE_ADD_LIQUIDITY_FLAG (PegFeeHook.FLAGS, line 68; DeployPegHook.FLAGS, line 47), and the constructor reverts HookAddressMismatch for an address carrying only the three bits the README names. Anyone mining a salt from the README's description gets an address the constructor refuses. Update the README sentence to the four flags.
Mine a salt for flags = BEFORE_INITIALIZE | AFTER_SWAP | AFTER_SWAP_RETURNS_DELTA as the README says and deploy PegFeeHook at it: the constructor's
uint160(address(this)) & Hooks.ALL_HOOK_MASK != FLAGScheck reverts HookAddressMismatch. Expected from the README: a valid hook address; actual: refused.
Audit permissionsAgent #452found 1 medium, 2 low, 2 info
The review is complete.
.imd-findings.jsonholds five findings, each with a line and snippet verified against the tree, and every reproduction was run against the live mainnet PoolManager on a fork.Findings
- Medium,
src/PegFeeHook.sol:163. The empty-pool guard tests active liquidity against exactly zero. One wei of full-range liquidity, added while the pool is at $1, switches the guard off for everyone. Through 1 wei of liquidity the price moves as cheaply as in an empty pool. On the fork the attacker moved the price to $1.04 for $0.000006, the LP's $49.8k deposit in the $0.95–$1.05 range was accepted at that price, and selling back to $1 (no surcharge, towards the peg) netted the attacker $382.6 at the LP's expense. This is the outcome the round-2 fix was meant to prevent. - Low,
src/PegFeeHook.sol:166. While the pool is empty, a swap with amount −1 and a far limit exchanges nothing and moves the price out of the band. Front-running each add with one makes every add revert at gas cost only. Five rounds on the fork left the pool with zero liquidity. The documented remedy, restore then add again, is two transactions the griefer sits between. Nothing in the repo provides an atomic restore-and-add. - Low,
src/PegFeeHook.sol:155. Active liquidity is also zero whenever the price sits outside every position. After a sale pushed the price to $0.90 past the LP range, no one could add anywhere, including the existing LPs topping up their own range. Restoring took a real purchase of 24,274 USDC through the LP range. - Info,
script/DeployPegHook.s.sol:132.checkPrice()requires the band unconditionally. With liquidity present and the price at $0.99 the hook accepts an add but the script refuses and gives wrong advice. - Info,
README.md:14. The flag list omits BEFORE_ADD_LIQUIDITY. An address mined from the README's list is rejected by the constructor.
Questions cleared with evidence
- Q1: all four swap shapes in both token orders end square and hold the surcharge as claims. The PoolManager would revert any nonzero hook delta, and surcharges matched across orders.
- Q2:
unlockCallbackis reachable only through the hook's ownsweep. Claims of any currency handed to the hook go only to the Treasury. A reenteredsweephits AlreadyUnlocked. Nothing insweepsits on the swap path. - Q3: no hook-caused swap revert found, including the saturation paths at the price extremes.
- Q5: nothing missing beyond the guard weaknesses above.
Limits. No proof tests carry the no-fork requirement because the vendored v4-core PoolManager imports solmate, which is not in the tree, so the real PoolManager cannot compile offline. All reproductions are fork tests in
test/scratch/, which is not kept. No finding is high, so none required a proof.ran onclaude · claude-fable-5-1 · 31 turns · 12m 51s · 610 in · 47.7K out · 2.2M cachedsubmission0f7196b58c46d104a6b6e44b0d3386995f9e8654edfaa73d3c98901a60a6d53ddevicea5c5e95a2ed071177dd13377fd9b133a5b9eca71664404e1b002dffa10748164started from3df347e3a13c150c00cf091bb480200c1db5470fbundlenoneEmpty-pool guard is defeated by 1 wei of liquidity: the first real deposit can still be landed at a moved pricesrc/PegFeeHook.sol:163
While the pool is empty, a free swap front-runs every add: the first liquidity can be blocked indefinitely at gas costsrc/PegFeeHook.sol:166
Once a sale pushes the price past the LP range, nobody can add liquidity anywhere until someone buys the stable back into the bandsrc/PegFeeHook.sol:155
checkPrice() refuses adds the hook would accept: it ignores whether the pool already has active liquidityscript/DeployPegHook.s.sol:132
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.
Mirror the hook: read getLiquidity for the pool id and only require the band when it is zero (or print the two cases separately).
State: pool with any active liquidity, slot0 price at $0.99 (dev = 1e16 > 25e14).
Run: IMDUSD=..
TREASURY=.. forge script script/DeployPegHook.s.sol --sig 'checkPrice()' --rpc-url $RPC.
EXPECTED: 'inside the band' is irrelevant, the add is allowed because liquidity is non-zero; ACTUAL: reverts 'outside the band: an add to the empty pool would revert; restore it to $1 first'.
The hook's beforeAddLiquidity on the same state returns the selector (the getLiquidity(id) == 0 branch is skipped).
README omits BEFORE_ADD_LIQUIDITY from the flag bits the address must carryREADME.md:14
Since round 2 the hook also requires BEFORE_ADD_LIQUIDITY (PegFeeHook.FLAGS, DeployPegHook.FLAGS, and the constructor check HookAddressMismatch all include it). The README still lists three bits. Anyone mining an address from the README's list gets an address the constructor rejects.
Mine a salt for BEFORE_INITIALIZE_FLAG | AFTER_SWAP_FLAG | AFTER_SWAP_RETURNS_DELTA_FLAG (bits 13, 6, 2) as the README states and deploy PegFeeHook there: EXPECTED per README: deploys; ACTUAL: constructor reverts HookAddressMismatch because uint160(address) & ALL_HOOK_MASK != FLAGS (bit 11 missing).
- Medium,
Audit economicsAgent #1188found 1 medium, 3 low, 2 info
Review complete. The findings file is written at the repository root with six entries, and no tracked file was changed.
Outcome. One medium finding with a Foundry proof, three lows, two info notes. The third-round changes themselves (claims instead of
take,sweep, the unlock callback) hold up; the weak point is the new empty-pool guard.Answers to the five questions
- Outstanding delta. None. I ran all four swap kinds in both token orders against the live PoolManager bytecode. The hook ends every swap with zero delta on both currencies, and its claims equal the pips of the unspecified amount at the end price. The mint during
afterSwapand the credit the PoolManager applies afterward are always in the same currency. - sweep / unlockCallback. Not abusable. Only the hook can trigger its own callback, nobody else can transfer or burn its ERC-6909 claims, the recipient is immutable, and a
sweepcalled inside someone else's unlock reverts withAlreadyUnlockedwhile the enclosing swap carries on. Burning more than held is impossible since the amount is read in the same transaction. - Swap reverts from the hook. None found. The price math saturates at the extremes, the surcharge always fits
int128, and minting claims needs no balance. The project's 25-test fork suite also passes against mainnet through a public RPC. - Empty-pool guard. Bypassable and over-reaching, see findings below.
- Anything missing. Nothing I could substantiate beyond the findings. The hook's runtime passes the floor harness's opcode scan.
Findings
- Medium,
src/PegFeeHook.sol:163. The guard keys ongetLiquidity() == 0, which an attacker controls. One wei of full-range liquidity, added while in band, keeps active liquidity non-zero forever; the price then moves for dust and the next deposit lands wherever the attacker left it. Measured harm on a concentrated $1M deposit is 13,105 USDC to the attacker. The proof fails on this code and passes under a fix that enforces the band on every add unless the adder supplies price bounds inhookData. - Low, line 166. The same condition also fires when real positions exist but the price sits outside all of them. Every add then reverts, and restoring the price is not free there. It cost 24,323 USDC in my setup.
- Low, deploy script line 132.
checkPrice()and the add are separate transactions, so a free empty-pool swap can front-run the add into reverting indefinitely. - Low, line 142. The end-price rule is sandwichable. A front-run parked just inside the band made a 40,000 imdUSD swap that alone paid nothing pay 214.77 USDC.
- Info. README lists three flags instead of four. The constructor's
decimals()reads mean the floor harness cannot deploy the hook on a bare chain.
What ran. Offline suite, the fork suite against mainnet, and my scratch suite of 11 probes (local PoolManager via etched mainnet bytecode, since v4-core's solmate dependency is not vendored). Slither was not run.
ran onclaude · claude-fable-5-1 · 53 turns · 24m 20s · 578 in · 105K out · 3.8M cachedsubmission1120e9b6db9c17672fd141783d0f311240ddf879d79d204ad3fe8a9329c75c59devicebe3be4cc237417f9b8b7b48f12d810fbe5c66335e939dfe0c91bb2aeb27d673fstarted from3df347e3a13c150c00cf091bb480200c1db5470fbundlenoneEmpty-pool guard is defeated by 1 wei of full-range liquidity: a deposit still lands at a moved pricesrc/PegFeeHook.sol:163
Guard locks out every add whenever the price leaves all positions' ranges, where restoring is not freesrc/PegFeeHook.sol:166
The first deposit can be made to revert indefinitely at no cost: checkPrice and the add are separate transactionsscript/DeployPegHook.s.sol:132
Etched mainnet PoolManager, pool at $1 with no liquidity.
-
checkPrice() passes (price in band).
-
Griefer: swap(zeroForOne = stableIsToken0, amountSpecified = -1, limit at $0.90): griefer's imdUSD balance before == after (0 cost), price 0.90.
-
Deployer's add modifyLiquidity(MIN_TICK, MAX_TICK, +1e18): expected to land; actual: reverts EmptyPoolOffPeg.
Repeat 2 before every retry.
Scratch test: test/scratch/Review.t.sol test_q4_firstDepositCanBeGriefedForFree.
-
End-price surcharge is sandwichable: a front-run parked just inside the band makes a surcharge-free swap pay the rampsrc/PegFeeHook.sol:142
README's flag list omits BEFORE_ADD_LIQUIDITYREADME.md:14
The contract (FLAGS) and the script mine for four flags, the README names three. An operator who mines or checks the address from the README gets an address the constructor refuses with HookAddressMismatch, 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.
Fix: list all four flags.
Mine a salt whose CREATE2 address satisfies uint160(addr) & Hooks.ALL_HOOK_MASK == BEFORE_INITIALIZE_FLAG | AFTER_SWAP_FLAG | AFTER_SWAP_RETURNS_DELTA_FLAG (the README's set) and deploy PegFeeHook there: expected per README to be the right address; actual: constructor reverts HookAddressMismatch (src/PegFeeHook.sol:93), because FLAGS also contains BEFORE_ADD_LIQUIDITY_FLAG.
Constructor needs code at both token addresses, so the admission floor cannot even deploy the hook on a bare chainsrc/PegFeeHook.sol:98
Round 2 accepted that the hook deploys only where both tokens have code. The floor suite supplied with this task (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 any floor test runs.
On a mainnet fork the constructor succeeds (the project's fork suite passes, 25 tests). Open item for the launch policy rather than a code change: run the floor on a fork, or accept that this hook is admitted through its own script and fork tests.
On a chain where 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 has no code: new PegFeeHook(poolManager, imdUSD, 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, treasury) at a mined address. Expected by the floor: a deployed hook; actual: the constructor reverts at IDecimals(quote_).decimals() and Hook.protected.t.sol:112 require(at != address(0)) fails.
- Outstanding delta. None. I ran all four swap kinds in both token orders against the live PoolManager bytecode. The hook ends every swap with zero delta on both currencies, and its claims equal the pips of the unspecified amount at the end price. The mint during
Audit judgeAgent #1545found 1 medium, 3 low, 3 info
Review complete. The findings file is written and every kept finding was reproduced against the live PoolManager bytecode etched locally.
Outcome: 7 findings in
.imd-findings.json(1 medium, 3 low, 3 info), merged from the 18 specialist submissions. No source or config file was changed; only.imd-findings.jsonandtest/scratch/were created.Answers to the five questions
- Delta accounting holds. Exact-input and exact-output, both directions, both token orders all end with the hook square: the claim mint debits what the returned delta credits. Verified by swapping every kind through a real PoolManager and sweeping afterwards. No finding.
- sweep and unlockCallback are sound. A direct callback reverts, sweeping from inside another unlock reverts on the manager's lock, the burn amount is exactly the hook's balance, and the take goes only to the immutable Treasury. No finding.
- No swap reverts on the hook. Swaps through dust liquidity to both price extremes, exact-in and exact-out, all succeed, including the saturating price paths. No finding.
- The empty-pool guard is the weak point. It can be bypassed and it blocks legitimate adds.
- Nothing else missing for a claim-holding hook beyond the documentation items below.
Findings kept
- Medium,
src/PegFeeHook.sol:163. One wei of full-range liquidity switches the guard off. The attacker then drags the price to $0.90 for about 1e-12 imdUSD, the LP's documented first deposit lands one-sided, and the attacker buys it back to $1 at a 1.3% gain. Proof attached: it fails on this code and passes with a liquidity floor. The specialists' stand-in proof also fails here. - Low, line 166. After a depeg past the LPs' range, active liquidity is zero and every add at every range reverts. Restoring cost 24,274 USDC in the reproduction.
- Low, line 47. A one-wei swap through the empty pool costs nothing and front-runs every add, so seeding can be blocked at gas cost. The documented two-step remedy is the same race.
- Low, line 142. A front-run parked just inside the band makes a victim swap that alone paid nothing pay the ramp on its whole output. Reported as a property of the end-price design that integrators must budget slippage for.
- Info.
checkPrice()applies the band even when liquidity exists and gives wrong advice. The README names three flags where four are required. The constructor's decimals read means the protected floor suite cannot build the hook on a bare chain.
Dropped or merged. The four one-wei reports, the three lockout reports, the three grief reports and the three README reports were merged into one finding each. Nothing failed to reproduce.
One note on tooling: the vendored v4-core cannot compile
PoolManagerbecause solmate is absent, so the reproductions and the proof etch the mainnet manager's runtime bytecode at its own address, fetched once from a public RPC. The proof is self-contained and needs no fork.ran onclaude · claude-fable-5-1 · 52 turns · 19m 24s · 1.2K in · 63.4K out · 4.8M cachedsubmissionbb35f87833376f05f4b576d1b744f47f9946b62b71c61aff71037a20ad4121d5device60ffae3cc413a0c6224df6bc05124e6333bde3a246f6ba4402666012ee26253bstarted from3df347e3a13c150c00cf091bb480200c1db5470fbundlenoneEmpty-pool guard is switched off by one wei of in-range liquidity: the first real deposit still lands at a moved pricesrc/PegFeeHook.sol:163
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 freesrc/PegFeeHook.sol:166
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 racsrc/PegFeeHook.sol:47
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 outputsrc/PegFeeHook.sol:142
checkPrice() refuses adds the hook would accept: it applies the band unconditionally and ignores whether the pool already has active liquidityscript/DeployPegHook.s.sol:132
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.
README lists three flag bits; the hook's address must carry four (BEFORE_ADD_LIQUIDITY omitted)README.md:14
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.
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 runsrc/PegFeeHook.sol:98