{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"442fd649-f535-4fd4-b3d7-bcffafa490b9","kind":"audit","nodes":[{"acceptedSubmissionHash":"1120e9b6db9c17672fd141783d0f311240ddf879d79d204ad3fe8a9329c75c59","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"aaba893335b7a2a8b971087fcd9936efe0087c5c524ed8030e67339b7c8564f3","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"bb35f87833376f05f4b576d1b744f47f9946b62b71c61aff71037a20ad4121d5","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"49c2967cdd68ccf859fdab9ee1b80631aceef6a7b8bb73fd15a113d96076c0e8","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"0f7196b58c46d104a6b6e44b0d3386995f9e8654edfaa73d3c98901a60a6d53d","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"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).\n\nWhat 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.\n\nAnswer each:\n1. 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?\n2. 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?\n3. Can any swap still revert because of the hook?\n4. 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?\n5. Anything else a hook holding claims, implementing unlockCallback, or using beforeAddLiquidity must do that this one does not.\n\nReport findings with a concrete reproduction. The hook is not deployed yet.","parentJobId":null,"planHash":"db7d894eb4538456c477f76fd8023d271fdbe0a3d919ad11f7ad15b0ec9e64e0","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"442fd649-f535-4fd4-b3d7-bcffafa490b9","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52019","feedbackHash":"942149b0d8fa5c4661829879bbb9e58ef27790781b48e3942166a2d96d522d48","nodeKey":"audit_economics","submissionHash":"1120e9b6db9c17672fd141783d0f311240ddf879d79d204ad3fe8a9329c75c59","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51566","feedbackHash":"bc6fce3d45892f12c3abc42820053882f2c76c88ca93476ae3e6f2b5ff196700","nodeKey":"audit_flow","submissionHash":"aaba893335b7a2a8b971087fcd9936efe0087c5c524ed8030e67339b7c8564f3","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50994","feedbackHash":"1cfa13b872a31083bb4c8d539e8969ed316df72126eaee499944272a5b1aed93","nodeKey":"audit_judge","submissionHash":"bb35f87833376f05f4b576d1b744f47f9946b62b71c61aff71037a20ad4121d5","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52128","feedbackHash":"c811736ff46f34ae284f6530be715c684948e174b6e9039b1d2d107cbb7747bd","nodeKey":"audit_math","submissionHash":"49c2967cdd68ccf859fdab9ee1b80631aceef6a7b8bb73fd15a113d96076c0e8","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52120","feedbackHash":"585796f6c99e35c2b5c2b14bb1712de147ccfbf78c98f01bd2792f30e3129668","nodeKey":"audit_permissions","submissionHash":"0f7196b58c46d104a6b6e44b0d3386995f9e8654edfaa73d3c98901a60a6d53d","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"c8711e373d7a4bd7a9ea3c4bcee1547917e293a895ba401b4f391944eef350e0","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a5c5e95a2ed07117","findings":[{"citation":"resolved","description":"beforeAddLiquidity only applies the band check while active liquidity is exactly zero. Active liquidity is a quantity anyone can make non-zero for dust: a 1-wei full-range position passes the guard (the pool is at $1 when it is added), and with L = 1 wei a swap moves the price anywhere for a few wei of tokens, exactly as cheaply as in an empty pool. Once getLiquidity() is 1 the guard is off for everyone, so the honest LP's first deposit lands at whatever price the attacker chose, which is the outcome round 2 finding 3 was fixed to prevent ('the deposit reverts instead of landing at their price'). The attacker then trades the price back to $1 against the freshly deposited position; that leg moves towards the peg so it pays no surcharge. Measured on a mainnet fork against the live PoolManager with 18/6-decimal tokens: moving the pool from $1.00 to $1.04 through 1 wei of liquidity cost the attacker 5,999,999,981,550 units at $1 valuation (0.000006 USD); the LP's $0.95-$1.05 deposit of 1e18 liquidity (49,762 USD at $1) was accepted; selling the stable back to $1.00 then netted the attacker 382.6 USD and the LP's position is worth 382.6 USD less than what it deposited. Loss scales with the deposit and with how far the price was moved inside the LP's range; a move beyond the range makes the deposit single-sided instead. Any LP who relies on the hook's guard (as the README and the contract header invite: 'the first deposit cannot land at a moved price') and does not set tight amount maxima is exposed. Fix: the guard cannot be made airtight by testing liquidity against zero. Either require a meaningful minimum active liquidity before the band check is skipped (e.g. getLiquidity(id) >= MIN_ACTIVE_LIQUIDITY, with the minimum set so that moving the price 0.25% costs more than the gas to do it), or make the band check unconditional on the first N adds, and in all cases document that LPs must bound amount0Max/amount1Max (PositionManager) because the hook cannot protect a deposit by itself.","line":163,"path":"src/PegFeeHook.sol","reproduction":"Fork test against the live PoolManager (0x000000000004444c5dc75cB358380D2e3dE08A90), tokens Tok(18) and Tok(6), hook mined for its flags, pool initialized at pegSqrtPriceX96, no liquidity. (1) attacker: modifyLiquidity(key, {MIN_TICK, MAX_TICK, liquidityDelta: 1}) -> succeeds, getLiquidity(id) == 1. (2) attacker: swap(zeroForOne = !stableIsToken0, amountSpecified = -1e27, sqrtPriceLimit = sqrt at $1.04) -> price ends at 1.040000000000000001e18; attacker's holdings valued at $1 fell by 5,999,999,981,550 wei-dollars (0.000006 USD). (3) LP: modifyLiquidity(key, {tick($0.95), tick($1.05), liquidityDelta: 1e18}) -> EXPECTED per the round-2 fix: revert EmptyPoolOffPeg; ACTUAL: succeeds, deposit worth 49,761.97 USD lands at $1.04. (4) attacker: swap(zeroForOne = stableIsToken0, -1e27, limit = pegSqrtPriceX96) -> no surcharge (towards the peg); attacker gains 382.64 USD at $1 valuation, LP's position removed now is worth 382.64 USD less than deposited. The same sequence with the attacker's step (1) omitted reverts at step (3), showing step (1) alone switches the guard off.","severity":"medium","snippet":"        if (poolManager.getLiquidity(id) == 0) {","title":"Empty-pool guard is defeated by 1 wei of liquidity: the first real deposit can still be landed at a moved price"},{"citation":"resolved","description":"The guard turns a moved price into a revert of the LP's add, and the documented remedy is 'restore it (a swap through an empty pool costs nothing) and add again'. The attacker's move costs exactly as little: a swap with amountSpecified = -1 and a far price limit exchanges no tokens (delta 0,0), pays no surcharge (surcharge is a share of a zero unspecified amount), and moves the empty pool outside the band. Front-running each add attempt with one such swap makes every add revert, 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 deploy script and README describe a two-step process (checkPrice() then add) that the attacker sits between. No funds are lost, but the launch cannot seed liquidity while the griefer keeps it up. Fix: ship the seeding path as a single transaction (a small periphery contract, or a script step, that unlocks, swaps the price to pegSqrtPriceX96 if needed, then adds the liquidity in the same callback), and say so where the two-step process is described.","line":166,"path":"src/PegFeeHook.sol","reproduction":"Fork test, pool initialized at $1 with no liquidity. Loop 5 times: attacker swap(zeroForOne = stableIsToken0, amountSpecified = -1, sqrtPriceLimit = sqrt at $0.99) -> attacker's token balances unchanged (delta 0,0); LP modifyLiquidity(key, {tick($0.95), tick($1.05), 1e18}) -> reverts (WrappedError around EmptyPoolOffPeg 0x5f921419); LP swap(!stableIsToken0, -1, pegSqrtPriceX96) restores the price for nothing. After 5 rounds getLiquidity(id) is still 0. EXPECTED: an honest LP can eventually add; ACTUAL: every attempt that is front-run reverts and the attacker's cost is gas only.","severity":"low","snippet":"            if ((p > 1e18 ? p - 1e18 : 1e18 - p) > BAND_BPS * 1e14) revert EmptyPoolOffPeg();","title":"While the pool is empty, a free swap front-runs every add: the first liquidity can be blocked indefinitely at gas cost"},{"citation":"resolved","description":"Active liquidity is zero not only before the first deposit but whenever the price sits outside every position's range. The LPs are told to use $0.95-$1.05. A seller who takes all the USDC in that range continues to move the price for free below $0.95 (there is no liquidity to trade against), so the pool ends at, say, $0.90 with getLiquidity(id) == 0 and the guard active. Then every add reverts: a market maker who wants to quote around the current price ($0.85-$0.95), and the existing LPs who want to top up their own $0.95-$1.05 range. Unlike the empty-pool case, restoring is not free here: the price has to be bought up through the $0.95-$0.9975 part of the LP range, which on the fork cost the restorer 24,274.49 USDC for a 1e18-liquidity range. Until someone does that, the pool has no way to regain depth exactly when it has none. The same state arises if all positions are withdrawn while the price is outside the band. This is the design tradeoff of keying the guard on active liquidity instead of on 'no position has ever been minted'; report it so it is a conscious choice. Fix options: track in the hook whether the pool has ever held liquidity (a bool set in beforeAddLiquidity on the first accepted add) and apply the band check only before that, accepting that the guard then only protects the launch; or keep the current rule and document that a depeg beyond the LP range freezes adds until the price is bought back.","line":155,"path":"src/PegFeeHook.sol","reproduction":"Fork test. LP adds {tick($0.95), tick($1.05), 1e18}. Attacker swap(zeroForOne = stableIsToken0, amountSpecified = -1e30, sqrtPriceLimit = sqrt at $0.90) -> price 0.90e18, getLiquidity(id) == 0. LP modifyLiquidity({tick($0.85), tick($0.95), 1e18}) -> EXPECTED: accepted (the pool has positions, the add covers the current price); ACTUAL: reverts with EmptyPoolOffPeg. LP modifyLiquidity({tick($0.95), tick($1.05), 1e18}) (topping up the existing range) -> also reverts. Attacker swap(!stableIsToken0, -1e30, sqrt at $0.998) spends 24,274,489,278 USDC units (24,274.49 USDC) to bring the price inside the band; only then does the $0.85-$0.95 add succeed.","severity":"low","snippet":"    /// @notice While the pool has no active liquidity, refuse an add unless the price is inside the band.","title":"Once a sale pushes the price past the LP range, nobody can add liquidity anywhere until someone buys the stable back into the band"},{"citation":"resolved","description":"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).","line":132,"path":"script/DeployPegHook.s.sol","reproduction":"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).","severity":"info","snippet":"        require(dev <= 25e14, \"outside the band: an add to the empty pool would revert; restore it to $1 first\");","title":"checkPrice() refuses adds the hook would accept: it ignores whether the pool already has active liquidity"},{"citation":"resolved","description":"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.","line":14,"path":"README.md","reproduction":"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).","severity":"info","snippet":"salt (the address must carry exactly the BEFORE_INITIALIZE, AFTER_SWAP and AFTER_SWAP_RETURNS_DELTA bits), deploys","title":"README omits BEFORE_ADD_LIQUIDITY from the flag bits the address must carry"}],"hash":"0f7196b58c46d104a6b6e44b0d3386995f9e8654edfaa73d3c98901a60a6d53d","nodeId":"1ae014f6-bea8-4f55-b416-26010c884a5a","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds 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.\n\n**Findings**\n\n- **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.\n- **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.\n- **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.\n- **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.\n- **Info, `README.md:14`.** The flag list omits BEFORE_ADD_LIQUIDITY. An address mined from the README's list is rejected by the constructor.\n\n**Questions cleared with evidence**\n\n- 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.\n- Q2: `unlockCallback` is reachable only through the hook's own `sweep`. Claims of any currency handed to the hook go only to the Treasury. A reentered `sweep` hits AlreadyUnlocked. Nothing in `sweep` sits on the swap path.\n- Q3: no hook-caused swap revert found, including the saturation paths at the price extremes.\n- Q5: nothing missing beyond the guard weaknesses above.\n\n**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.","treeHash":null,"usage":{"cachedInputTokens":2220294,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":47667,"runtime":"claude","turns":31,"wallClockMs":771060}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"be3be4cc237417f9","findings":[{"citation":"resolved","description":"beforeAddLiquidity only applies the ±0.25% rule while poolManager.getLiquidity(id) == 0. Active liquidity is a quantity the attacker controls. While the price is inside the band (right after initialize, or at any later moment, since adds are never restricted when the price is in band) anyone can add 1 wei of liquidity over [MIN_TICK, MAX_TICK]. That position is in range at every price, so getLiquidity never reads 0 again and the guard is permanently off. With 1 wei of liquidity a swap moves the price anywhere for dust: the output rounds to 0, so no surcharge is minted either. The deposit the round-2 fix exists to protect (test_theFirstLiquidityLandsAtTheDollar) then lands at the attacker's price, and the attacker trades back towards $1 surcharge-free and keeps the difference. The dust can be pre-positioned months earlier while real liquidity exists; it takes effect whenever the pool is otherwise empty (launch, or after every LP has withdrawn). The documented deploy flow (checkPrice() in one transaction, the add in another) cannot see a front-run. Harm measured on the etched mainnet PoolManager: a full-range deposit of 1,054,092.55 imdUSD + 948,683.30 USDC made at $0.90 lets the attacker buy 54,092.55 imdUSD for 51,321.83 USDC on the way back to $1.00 (+2,770.72 USDC at the peg); a $0.95-$1.05 deposit worth 1,000,703 USDC at the peg is entirely imdUSD at $0.90, and the attacker buys 519,582.40 imdUSD for 506,476.55 USDC (+13,105.85 USDC, 1.31% of the deposit). Fix that keeps the design: stop keying the check on getLiquidity(). Apply the band rule to every add unless the adder supplies its own sqrt-price bounds in hookData (abi.encode(uint160 lo, uint160 hi)), which the hook enforces instead; the deployer's first add passes peg ±0.25%, an LP who wants to add off-peg passes its bounds, and an add with no bounds can never land off-peg. The attached proof passes under exactly that change (verified). Replacing `== 0` with a liquidity floor does not fix it: moving the price through one's own liquidity costs only the surcharge on the output, about 2.6 USDC for a $1 -> $0.90 move through a 1e15 full-range floor (~$1,000 per side).","line":163,"path":"src/PegFeeHook.sol","reproduction":"Pool initialized at pegSqrtPriceX96 with no liquidity (imdUSD 18 dp, USDC 6 dp, real PoolManager bytecode etched at 0x000000000004444c5dc75cB358380D2e3dE08A90). 1) Attacker: modifyLiquidity(MIN_TICK, MAX_TICK, +1): allowed (price in band); getLiquidity == 1; cost 1,000,000 wei imdUSD (1e-12) and 1 wei USDC. 2) Attacker: swap(zeroForOne = stableIsToken0, amountSpecified = -1e9, sqrtPriceLimit at $0.90): price ends at 0.90 (±0.1%); total spent < 1e9 wei imdUSD and < 10 wei USDC; surcharge minted 0. 3) Victim: modifyLiquidity(MIN_TICK, MAX_TICK, +1e18) with empty hookData. Expected (the hook's stated rule and test_theFirstLiquidityLandsAtTheDollar): revert EmptyPoolOffPeg. Actual: succeeds; 1,054,092.55 imdUSD and 948,683.30 USDC land at $0.90. 4) Attacker: swap(!zeroForOne, -1e27, limit = pegSqrtPriceX96): buys 54,092.55 imdUSD for 51,321.83 USDC with no surcharge. Proof: test/scratch/EmptyPoolGuardBypass.t.sol, forge test --match-path test/scratch/EmptyPoolGuardBypass.t.sol fails on this code with 'next call did not revert as expected'.","severity":"medium","snippet":"        if (poolManager.getLiquidity(id) == 0) {","title":"Empty-pool guard is defeated by 1 wei of full-range liquidity: a deposit still lands at a moved price"},{"citation":"resolved","description":"getLiquidity(id) == 0 is also the state of a pool that has 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 from then on every add reverts: a $0.90-$0.95 position that would be in range at the current price, and the re-add of the $0.95-$1.05 position alike. The contract's own rationale ('its price can be moved for free') does not hold in this state: the stretch from $0.95 to the band edge is held by the LPs' liquidity, and bringing the price back into the band so an add is allowed cost 24,323.23 USDC of buying in a ~$1M-per-side pool. During a depeg this blocks exactly the liquidity that would defend the peg until an arbitrageur chooses to act. The hookData price-bounds route from the medium finding resolves this too: an LP who passes bounds may add anywhere; an add without bounds keeps the band rule.","line":166,"path":"src/PegFeeHook.sol","reproduction":"Etched mainnet PoolManager, pool at $1. 1) modifyLiquidity(tick($0.95), tick($1.05), +1e18). 2) swap(zeroForOne = stableIsToken0, -1e27, limit at $0.94): price 0.94, getLiquidity == 0. 3) modifyLiquidity(tick($0.90), tick($0.95), +1e18): expected to be accepted (a position that would be active at the current price, in a pool that is not empty); actual: reverts with EmptyPoolOffPeg wrapped in the PoolManager's WrappedError(hook, 0x259982e5, 0x5f921419, 0xa9e35b2f). 4) modifyLiquidity(tick($0.95), tick($1.05), +1e18): same revert. 5) swap(!zeroForOne, -1e27, limit at $0.998) costs 24,323,226,870 (24,323.23 USDC); only then step 4 succeeds. Scratch test: test/scratch/Review.t.sol test_q4_priceOutsideEveryPositionBlocksEveryAdd.","severity":"low","snippet":"            if ((p > 1e18 ? p - 1e18 : 1e18 - p) > BAND_BPS * 1e14) revert EmptyPoolOffPeg();","title":"Guard locks out every add whenever the price leaves all positions' ranges, where restoring is not free"},{"citation":"resolved","description":"While the pool has no active liquidity a swap moves its price for free: an exact-input of 1 wei with a price limit lands at the limit and exchanges nothing (the griefer's balances are unchanged). The documented flow is checkPrice() in one transaction and the liquidity add in another, so a watcher who front-runs the add with such a swap makes it revert with EmptyPoolOffPeg, every time, paying gas only. The contract's 'restore it ... and add again' is the same two-step race. The same actor profits rather than griefs once combined with the medium finding. Fix: make the restore and the add one unlock in the deployer's router (swap towards the peg with limit pegSqrtPriceX96 only when the price differs from it, then modifyLiquidity, then assert slot0 in the callback), or, with the hookData bounds from the medium finding, let the add itself carry the assertion so a moved price makes it revert instead of landing but can never be used to park the deposit elsewhere.","line":132,"path":"script/DeployPegHook.s.sol","reproduction":"Etched mainnet PoolManager, pool at $1 with no liquidity. 1) checkPrice() passes (price in band). 2) Griefer: swap(zeroForOne = stableIsToken0, amountSpecified = -1, limit at $0.90): griefer's imdUSD balance before == after (0 cost), price 0.90. 3) 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.","severity":"low","snippet":"        require(dev <= 25e14, \"outside the band: an add to the empty pool would revert; restore it to $1 first\");","title":"The first deposit can be made to revert indefinitely at no cost: checkPrice and the add are separate transactions"},{"citation":"resolved","description":"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% to the Treasury. The only defence is the victim's router slippage, and it must budget for the surcharge rather than for price impact alone; a 0.3% tolerance would have rejected the trade below, a 0.75% one would have paid it. Mitigation within the chosen design: state the surface in the contract docs 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.","line":142,"path":"src/PegFeeHook.sol","reproduction":"Etched mainnet PoolManager; liquidity 4e19 in $0.95-$1.05 (about $1M per side); stable is token0. Alone: victim swap(true, -40_000e18, no limit) ends at $0.998003, surcharge 0. Sandwiched: attacker swap(true, -1e27, limit at $0.9976) sells 48,091.38 imdUSD, surcharge 0; the same victim swap now ends at $0.995610 and the hook mints 214,766,764 (214.77 USDC, 0.54% of the output) of claims; attacker swap(false, +48_091.38e18 exact-out, limit at $1.00) pays no surcharge, ends with 86.25 USDC more than it started with. Scratch test: test/scratch/Review.t.sol test_econ_frontRunIntoTheBandMakesTheVictimPay.","severity":"low","snippet":"        uint256 pips = surchargeFor(sqrtPriceX96, params.zeroForOne);","title":"End-price surcharge is sandwichable: a front-run parked just inside the band makes a surcharge-free swap pay the ramp"},{"citation":"resolved","description":"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.","line":14,"path":"README.md","reproduction":"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.","severity":"info","snippet":"salt (the address must carry exactly the BEFORE_INITIALIZE, AFTER_SWAP and AFTER_SWAP_RETURNS_DELTA bits), deploys","title":"README's flag list omits BEFORE_ADD_LIQUIDITY"},{"citation":"resolved","description":"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.","line":98,"path":"src/PegFeeHook.sol","reproduction":"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.","severity":"info","snippet":"        (uint8 sd, uint8 qd) = (IDecimals(stable_).decimals(), IDecimals(quote_).decimals());","title":"Constructor needs code at both token addresses, so the admission floor cannot even deploy the hook on a bare chain"}],"hash":"1120e9b6db9c17672fd141783d0f311240ddf879d79d204ad3fe8a9329c75c59","nodeId":"54e1bbf2-2619-4f3b-b3cb-5cde2161b2a5","outcome":"completed","summary":"Review complete. The findings file is written at the repository root with six entries, and no tracked file was changed.\n\n**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.\n\n**Answers to the five questions**\n\n1. **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 `afterSwap` and the credit the PoolManager applies afterward are always in the same currency.\n2. **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 `sweep` called inside someone else's unlock reverts with `AlreadyUnlocked` while the enclosing swap carries on. Burning more than held is impossible since the amount is read in the same transaction.\n3. **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.\n4. **Empty-pool guard.** Bypassable and over-reaching, see findings below.\n5. **Anything missing.** Nothing I could substantiate beyond the findings. The hook's runtime passes the floor harness's opcode scan.\n\n**Findings**\n\n- **Medium, `src/PegFeeHook.sol:163`.** The guard keys on `getLiquidity() == 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 in `hookData`.\n- **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.\n- **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.\n- **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.\n- **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.\n\n**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.","treeHash":null,"usage":{"cachedInputTokens":3820948,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":105044,"runtime":"claude","turns":53,"wallClockMs":1460454}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9c6767b941fcfedc","findings":[{"citation":"resolved","description":"beforeAddLiquidity only runs its band check while the pool's ACTIVE liquidity is exactly zero. Round 2 added this so that the first deposit cannot land at a price an attacker moved for free. But the guard keys off a quantity the attacker controls: adding liquidityDelta = 1 at $1 (in band, so allowed) makes getLiquidity return 1, and against one wei of liquidity the price can be dragged anywhere for dust (moving $1 -> $0.85 against L = 1 consumed 1,084,670 wei of imdUSD, i.e. 1e-12 imdUSD, and 1 wei of USDC when replayed against the real v4 PoolManager). From then on the band check never executes, and the LP's planned first deposit ($0.95-$1.05, as README and the deploy script describe) goes through at $0.85: one-sided (all imdUSD), with the position's whole stable side sitting below $0.95 for anyone to buy from $0.95 upwards, so the LP exchanges the extra imdUSD for USDC at an average of ~$0.975 instead of $1. The guard therefore does not deliver what the contract comment (lines 45-48), the script comment (lines 37-39) and README line 8 ('the first deposit cannot land at a moved price') promise; checkPrice() passes as well, because it looks only at the price, which the attacker can leave in band until the LP's transaction is in the mempool and then move in the same block. Only the LP's own amount bounds (PositionManager amount0Max/amount1Max) actually protect the deposit, and nothing in the repository says so. Fix options, in order of preference: (a) drop the claim and document that the first add must be done through PositionManager with tight amount maxima (or atomically after a restoring swap), since a hook cannot distinguish dust liquidity from real liquidity; or (b) keep the guard but apply the band check whenever active liquidity is below a threshold sized so that moving the price out of band costs real money (e.g. a MIN_LIQUIDITY constant on the order of the planned seed), accepting that this also constrains legitimate adds while liquidity is thin. Either way the test test_theFirstLiquidityLandsAtTheDollar should gain the one-wei case.","line":163,"path":"src/PegFeeHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {PoolId} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\nimport {Hooks} from \"v4-core/src/libraries/Hooks.sol\";\nimport {PegFeeHook} from \"src/PegFeeHook.sol\";\n\ncontract Tok is ERC20 {\n    uint8 private immutable _d;\n\n    constructor(string memory n, uint8 d) ERC20(n, n) {\n        _d = d;\n        _mint(msg.sender, 1e40);\n    }\n\n    function decimals() public view override returns (uint8) {\n        return _d;\n    }\n}\n\n/// @dev Stands in for the PoolManager: answers `extsload` (all the hook reads through StateLibrary) from a\n/// map, and calls the hook's `beforeAddLiquidity` as the real manager does on every add.\ncontract ManagerStandIn {\n    mapping(bytes32 => bytes32) private _s;\n\n    function store(bytes32 slot, bytes32 v) external {\n        _s[slot] = v;\n    }\n\n    function extsload(bytes32 slot) external view returns (bytes32) {\n        return _s[slot];\n    }\n\n    function add(PegFeeHook hook, PoolKey calldata key, ModifyLiquidityParams calldata p) external returns (bytes4) {\n        return hook.beforeAddLiquidity(msg.sender, key, p, \"\");\n    }\n}\n\n/// @notice Round-3 finding: the empty-pool guard (`beforeAddLiquidity` refuses an add while `getLiquidity == 0`\n/// and the price is off the band) is switched off by ONE wei of in-range liquidity, so the first real deposit\n/// can still land at a moved price — the exact thing the guard was added for in round 2.\n///\n/// Reproduced against the real v4 PoolManager (compiled with its solmate dependency vendored, which this\n/// repository's lib/ lacks, so the state is replayed here through a stand-in that serves the same storage):\n///   1. pool opened at $1, no liquidity; attacker adds liquidityDelta = 1, full range  -> allowed (in band)\n///   2. attacker swaps zeroForOne exact-in with sqrtPriceLimit at $0.85 against that 1 wei of liquidity:\n///      spent 1,084,670 wei imdUSD (1e-12 imdUSD) and 1 wei USDC; pool price now $0.85, getLiquidity == 1\n///   3. the LP's planned first deposit, $0.95–$1.05 range, liquidityDelta 1e18: `beforeAddLiquidity` sees\n///      liquidity 1, skips the band check, and the deposit lands one-sided (all imdUSD) at $0.85.\n/// Expected: the add in step 3 is refused (as it is when liquidity is exactly 0). Actual: it goes through.\ncontract EmptyPoolGuardBypassTest is Test {\n    address constant TREASURY = address(0x7EA5);\n\n    ManagerStandIn pm;\n    PegFeeHook hook;\n    PoolKey key;\n    bytes32 stateSlot;\n\n    function setUp() public {\n        pm = new ManagerStandIn();\n        Tok imdusd = new Tok(\"imdUSD\", 18);\n        Tok usdc = new Tok(\"USDC\", 6);\n        hook = _deployHook(address(imdusd), address(usdc));\n        (address c0, address c1) =\n            hook.stableIsToken0() ? (address(imdusd), address(usdc)) : (address(usdc), address(imdusd));\n        key = PoolKey(Currency.wrap(c0), Currency.wrap(c1), 100, 1, IHooks(address(hook)));\n        PoolId id = key.toId();\n        stateSlot = keccak256(abi.encodePacked(PoolId.unwrap(id), StateLibrary.POOLS_SLOT));\n    }\n\n    function _deployHook(address stable, address quote) private returns (PegFeeHook h) {\n        bytes memory init = abi.encodePacked(type(PegFeeHook).creationCode, abi.encode(pm, stable, quote, TREASURY));\n        bytes32 initHash = keccak256(init);\n        uint160 flags = Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_ADD_LIQUIDITY_FLAG | Hooks.AFTER_SWAP_FLAG\n            | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG;\n        for (uint256 salt;; ++salt) {\n            address a = vm.computeCreate2Address(bytes32(salt), initHash, address(this));\n            if (uint160(a) & Hooks.ALL_HOOK_MASK == flags) {\n                h = new PegFeeHook{salt: bytes32(salt)}(IPoolManager(address(pm)), stable, quote, TREASURY);\n                require(address(h) == a);\n                return h;\n            }\n        }\n    }\n\n    function _sqrtAt(uint256 priceE18) private view returns (uint160) {\n        uint256 rel = hook.stableIsToken0() ? priceE18 : 1e36 / priceE18;\n        uint256 x = rel * 1e18;\n        uint256 y = x;\n        uint256 z = (x + 1) / 2;\n        while (z < y) (y, z) = (z, (x / z + z) / 2);\n        return uint160(hook.pegSqrtPriceX96() * y / 1e18);\n    }\n\n    /// @dev Pool state after step 2 above: price $0.85, active liquidity `liq`.\n    function _poolAt(uint256 priceE18, uint128 liq) private {\n        pm.store(stateSlot, bytes32(uint256(_sqrtAt(priceE18)))); // slot0: sqrtPriceX96 in the low 160 bits\n        pm.store(bytes32(uint256(stateSlot) + StateLibrary.LIQUIDITY_OFFSET), bytes32(uint256(liq)));\n    }\n\n    function test_oneWeiOfLiquiditySwitchesTheEmptyPoolGuardOff() public {\n        int24 lower = TickMath.getTickAtSqrtPrice(_sqrtAt(hook.stableIsToken0() ? 0.95e18 : 1.05e18));\n        int24 upper = TickMath.getTickAtSqrtPrice(_sqrtAt(hook.stableIsToken0() ? 1.05e18 : 0.95e18));\n        ModifyLiquidityParams memory firstRealDeposit = ModifyLiquidityParams(lower, upper, 1e18, 0);\n\n        // Sanity: with NO liquidity the guard works — $0.85 is refused.\n        _poolAt(0.85e18, 0);\n        vm.expectRevert(PegFeeHook.EmptyPoolOffPeg.selector);\n        pm.add(hook, key, firstRealDeposit);\n\n        // The attacker's 1 wei of liquidity (added at $1, then dragged to $0.85 for ~1e-12 imdUSD): the pool\n        // is as empty as makes no difference, the price is 15% off the peg, and the add must still be refused.\n        _poolAt(0.85e18, 1);\n        (bool ok,) = address(pm).call(abi.encodeCall(ManagerStandIn.add, (hook, key, firstRealDeposit)));\n        assertFalse(ok, \"1 wei of active liquidity let the first real deposit land at $0.85\");\n    }\n}","reproduction":"Against the real PoolManager (local deployment, no fork): (1) initialize the pool at pegSqrtPriceX96, no liquidity. (2) Attacker: modifyLiquidity(MIN_TICK, MAX_TICK, liquidityDelta = 1) -> accepted, getLiquidity == 1. (3) Attacker: swap(zeroForOne = stableIsToken0, amountSpecified = -1e18, sqrtPriceLimitX96 = sqrt price of $0.85) -> stops at the limit having consumed 1,084,670 wei imdUSD and 1 wei USDC; stablePrice(slot0) == 0.849999999999999999e18. (4) LP: modifyLiquidity(tick($0.95), tick($1.05), liquidityDelta = 1e18). Expected (the guard's stated purpose, and what happens when liquidity is 0): revert EmptyPoolOffPeg. Actual: beforeAddLiquidity returns its selector, the deposit lands one-sided at $0.85 (imdUSD balance of the LP falls, USDC balance unchanged). The attached proof replays steps 3-4's state (price $0.85, liquidity 1) through an extsload stand-in because this repository's lib/ cannot compile PoolManager (missing solmate).","severity":"medium","snippet":"        if (poolManager.getLiquidity(id) == 0) {","title":"Empty-pool guard is switched off by one wei of in-range liquidity; the first real deposit can still land at a moved price"},{"citation":"resolved","description":"While active liquidity is zero, a swap exchanges nothing yet still sets the pool price to its sqrtPriceLimitX96. Such a swap costs its sender 0 tokens (the 1-wei exact-input swap in test_theFirstLiquidityLandsAtTheDollar and in my replay left the sender's balances unchanged) plus gas. So whoever watches the mempool can front-run every add to the empty pool with a swap to, say, $0.90, and the add reverts with EmptyPoolOffPeg. The deploy script's documented procedure (run checkPrice(), then add liquidity in a separate transaction) and the Router pattern in the tests (one unlock per action) are both non-atomic and therefore blockable indefinitely at the cost of gas. The same applies after every position has been withdrawn: re-seeding is blocked until the price is moved back, and the mover can be out-run again. Fix: document that the first add (and any add while liquidity is 0) must be done atomically with a price-restoring swap in the same transaction, e.g. Universal Router V4_SWAP to pegSqrtPriceX96 followed by the PositionManager mint, or a one-off seeding contract that swaps and adds inside one unlock; remove the 'restore it and add again' two-step from the comments.","line":166,"path":"src/PegFeeHook.sol","reproduction":"Local PoolManager, pool initialized at $1 with no liquidity (or all liquidity removed). Griefer: swap(zeroForOne = stableIsToken0, amountSpecified = -1, sqrtPriceLimitX96 = sqrt price of $0.90): succeeds, griefer's token balances unchanged (spent 0). LP (separate transaction): modifyLiquidity(MIN_TICK, MAX_TICK, 1e18) -> reverts (EmptyPoolOffPeg wrapped by Hooks.callHook). Repeat before each retry. Expected: an honest LP can seed the pool; actual: they cannot while a gas-paying griefer is active.","severity":"low","snippet":"            if ((p > 1e18 ? p - 1e18 : 1e18 - p) > BAND_BPS * 1e14) revert EmptyPoolOffPeg();","title":"Any 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 reverts"},{"citation":"resolved","description":"getLiquidity is the pool's in-range liquidity, not 'has anyone ever deposited'. With LPs in $0.95-$1.05 as planned, a sale that pushes the price to $0.94 leaves active liquidity at 0 while the positions still exist. The guard then refuses every add whose moment the LPs most want: supporting liquidity below $0.95, or any re-add, is rejected with EmptyPoolOffPeg until the price is back inside +/-0.25%, and from $0.94 that means buying imdUSD through the existing range from $0.95 to $0.9975 with real USDC. Moving the price down is free, moving it up costs; so during a depeg, exactly when exit liquidity matters, the pool cannot receive it. There is a workaround (withdraw every position so the pool is truly empty, free-swap to $1, add the new range out of range, let the price fall back), but it is multi-step, not documented, and itself exposed to the griefing in the previous finding. Fix: either scope the guard to the pool's lifetime (e.g. only before the first ever add, tracked in one storage bool, or when no position exists at all) rather than to in-range liquidity, or document the workaround and the atomic path.","line":163,"path":"src/PegFeeHook.sol","reproduction":"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).","severity":"low","snippet":"        if (poolManager.getLiquidity(id) == 0) {","title":"Guard blocks legitimate adds once the price has traded out of every position's range (active liquidity 0 while positions exist)"},{"citation":"resolved","description":"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.","line":14,"path":"README.md","reproduction":"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 != FLAGS` check reverts HookAddressMismatch. Expected from the README: a valid hook address; actual: refused.","severity":"info","snippet":"salt (the address must carry exactly the BEFORE_INITIALIZE, AFTER_SWAP and AFTER_SWAP_RETURNS_DELTA bits), deploys","title":"README lists three flag bits; the hook's address must carry four (BEFORE_ADD_LIQUIDITY omitted)"}],"hash":"49c2967cdd68ccf859fdab9ee1b80631aceef6a7b8bb73fd15a113d96076c0e8","nodeId":"0b38a6f8-6028-4baa-be9d-9abf6e3e16eb","outcome":"completed","summary":"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.\n\n**Answers to the five questions**\n\n1. **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.\n2. **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.\n3. **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.\n4. **The empty-pool guard is the weak point.** This is where the findings are.\n5. Nothing else required for a claims-holding hook is missing.\n\n**Findings written to .imd-findings.json**\n\n- **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's `lib` lacks the solmate dependency the real PoolManager needs.\n- **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.\n- **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.\n- **Info:** README line 14 still names three flag bits; the constructor requires four.\n\n**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.","treeHash":null,"usage":{"cachedInputTokens":1307273,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":48797,"runtime":"claude","turns":29,"wallClockMs":727505}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d0653dc91b6e2259","findings":[{"citation":"resolved","description":"beforeAddLiquidity only checks the price while the pool's ACTIVE liquidity is exactly zero. Active liquidity is made non-zero by anyone, for dust: a 1-wei full-range position (ticks MIN..MAX, liquidityDelta 1) is accepted at $1, costs 1 wei of each token, and is in range at every price. A swap through that 1 wei then moves the price anywhere for dust (54,103 wei of imdUSD, 0 USDC received, moves it from $1 to $0.90), and from then on getLiquidity(id) == 1, so the guard never runs again. The LP's first real deposit, the $0.95-$1.05 seed the script documents, is therefore accepted at $0.90: entirely one-sided (50,032.65 imdUSD and 0 USDC for liquidityDelta 1e18 in the local repro, instead of ~25k/25k at $1), after which the attacker buys ~25,928 imdUSD out of it for ~25,275 USDC (average $0.975, no surcharge because the swap moves towards $1) and redeems or resells at $1. The loss scales linearly with the seed. This is exactly 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 script/DeployPegHook.s.sol lines 37-39 all state the guarantee, and checkPrice() reads only slot0, so it says 'inside the band' right up to the front-running block. The existing test test_theFirstLiquidityLandsAtTheDollar covers only the truly-empty pool. Fix options, which can be combined: (a) treat active liquidity below a floor as empty (e.g. `< MIN_SEED_LIQUIDITY`, a constant well under the planned seed), which raises the attacker's cost from dust to a budget you choose and makes the attached proof pass; (b) stop relying on the hook for the seed and have the seed transaction bound its amounts itself (PositionManager amount0Max/amount1Max computed at $1, or a hookData opt-in that makes beforeAddLiquidity enforce the band regardless of liquidity, which is checkPrice() made atomic); (c) correct the NatSpec, README and script comments so the guarantee is stated as what it is. Note no floor can make the guard a guarantee on its own: with a floor of L the attacker's cost to move the price is proportional to L, so (b) is the real protection and (a) only prices the attack.","line":163,"path":"src/PegFeeHook.sol","reproduction":"State: pool initialized at $1, no positions. 1) Attacker: modifyLiquidity(key, {MIN_TICK, MAX_TICK, liquidityDelta: 1, salt 0}) -> accepted (price in band). 2) Attacker: swap(key, {zeroForOne: stableIsToken0, amountSpecified: -1e6, sqrtPriceLimitX96: sqrt at $0.90}) -> price now $0.90, cost 54,103 wei imdUSD. 3) LP: modifyLiquidity(key, {tick($0.95), tick($1.05), liquidityDelta: 1e18, salt 0}) with empty hookData. Expected: revert EmptyPoolOffPeg (the deposit must not land at a moved price). Actual: succeeds; 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.82 imdUSD for 25,275.09 USDC. Run: forge test --match-path test/scratch/EmptyPoolGuardBypass.t.sol (no fork; the mainnet PoolManager runtime code is etched at its address). Also reproducible on the fork suite by inserting steps 1-2 before the add in test_theFirstLiquidityLandsAtTheDollar.","severity":"medium","snippet":"        if (poolManager.getLiquidity(id) == 0) {","title":"Empty-pool guard is defeated by one wei of full-range liquidity: the first real deposit still lands at a moved price"},{"citation":"resolved","description":"The guard keys on pool-wide active liquidity, not on whether this is the first deposit, and it ignores the range being added. When a sell-off pushes the price below the LPs' $0.95-$1.05 range (or above it), active liquidity is 0 while every position still exists, and the price is outside the band, so EVERY add reverts: support liquidity at $0.90-$0.95 placed around the current price, a one-sided top-up of the main range, even a position at $1.10-$1.20 that cannot be 'landed on' by the current price. Unlike the truly empty pool, the price cannot be restored for free: moving it from $0.95 back inside the band means buying imdUSD through the LPs' positions, which during a real depeg (imdUSD not redeemable at par net of the redemption fee) nobody will do. The pool then stays one-way tradeable with no way for LPs to add depth where the market is, until the price recovers on its own. This is a design tradeoff the author may accept; if not, narrow the guard to the case it was written for: refuse only when the range being added contains the peg tick (the depositor meant to be two-sided around $1) and the price is outside the band, and/or let hookData carry an explicit opt-out for a depositor who knows the price.","line":166,"path":"src/PegFeeHook.sol","reproduction":"State: pool at $1 with one LP position, ticks for $0.95..$1.05, liquidity 1e18. 1) swap(key, {zeroForOne: stableIsToken0, amountSpecified: -1e27, limit: sqrt at $0.94}) -> price $0.94, getLiquidity(id) == 0. 2) modifyLiquidity(key, {tick($0.90), tick($0.95), +1e18}) -> reverts EmptyPoolOffPeg. 3) modifyLiquidity(key, {tick($0.95), tick($1.05), +1e18}) -> reverts. 4) modifyLiquidity(key, {tick($1.10), tick($1.20), +1e18}) -> reverts. Expected for a guard meant to protect the first deposit: 2-4 succeed (the position is already seeded and these adds cannot land 'at a moved price' in the sense the guard protects against). Actual: all revert. Shown by test_depegBelowTheRangeBlocksSupportLiquidity in test/scratch/Scenarios.t.sol (no fork).","severity":"low","snippet":"            if ((p > 1e18 ? p - 1e18 : 1e18 - p) > BAND_BPS * 1e14) revert EmptyPoolOffPeg();","title":"Guard blocks every add, at any range, whenever the price sits outside all positions and outside the band (a depeg below the LPs' range)"},{"citation":"resolved","description":"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.","line":14,"path":"README.md","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 the README describes. Expected per README: deploys. Actual: constructor reverts HookAddressMismatch because FLAGS also includes BEFORE_ADD_LIQUIDITY_FLAG (0x800).","severity":"info","snippet":"salt (the address must carry exactly the BEFORE_INITIALIZE, AFTER_SWAP and AFTER_SWAP_RETURNS_DELTA bits), deploys","title":"README lists three flag bits; the hook and script now require four (BEFORE_ADD_LIQUIDITY missing)"}],"hash":"aaba893335b7a2a8b971087fcd9936efe0087c5c524ed8030e67339b7c8564f3","nodeId":"9df4856d-342b-45b4-8194-592d2941bd99","outcome":"completed","summary":"Review complete. The findings file is written, and the source tree is unchanged.\n\n**Answers to the five questions**\n\n1. **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.\n\n2. **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.\n\n3. **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.\n\n4. **The empty-pool guard is bypassable and also over-blocks.** Both are reported below.\n\n5. **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.\n\n**Findings**\n\n- **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.\n- **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.\n- **Info, README line 14.** The README still names three flag bits. The hook and script require four.\n\nReproductions live under test/scratch, which is not kept. The deploy script itself had no new defects beyond its comment restating the bypassed guarantee.","treeHash":null,"usage":{"cachedInputTokens":1653264,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":45211,"runtime":"claude","turns":33,"wallClockMs":614137}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"60ffae3cc413a0c6","findings":[{"citation":"resolved","description":"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.","line":163,"path":"src/PegFeeHook.sol","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.","severity":"medium","snippet":"        if (poolManager.getLiquidity(id) == 0) {","title":"Empty-pool guard is switched off by one wei of in-range liquidity: the first real deposit still lands at a moved price"},{"citation":"resolved","description":"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.","line":166,"path":"src/PegFeeHook.sol","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.","severity":"low","snippet":"            if ((p > 1e18 ? p - 1e18 : 1e18 - p) > BAND_BPS * 1e14) revert EmptyPoolOffPeg();","title":"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"},{"citation":"resolved","description":"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.","line":47,"path":"src/PegFeeHook.sol","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.","severity":"low","snippet":"/// makes the deposit revert instead of landing at their price; restore it (a swap through an empty pool costs","title":"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"},{"citation":"resolved","description":"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.","line":142,"path":"src/PegFeeHook.sol","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.","severity":"low","snippet":"        uint256 pips = surchargeFor(sqrtPriceX96, params.zeroForOne);","title":"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"},{"citation":"resolved","description":"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.","line":132,"path":"script/DeployPegHook.s.sol","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.","severity":"info","snippet":"        require(dev <= 25e14, \"outside the band: an add to the empty pool would revert; restore it to $1 first\");","title":"checkPrice() refuses adds the hook would accept: it applies the band unconditionally and ignores whether the pool already has active liquidity"},{"citation":"resolved","description":"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.","line":14,"path":"README.md","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.","severity":"info","snippet":"salt (the address must carry exactly the BEFORE_INITIALIZE, AFTER_SWAP and AFTER_SWAP_RETURNS_DELTA bits), deploys","title":"README lists three flag bits; the hook's address must carry four (BEFORE_ADD_LIQUIDITY omitted)"},{"citation":"resolved","description":"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.","line":98,"path":"src/PegFeeHook.sol","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).","severity":"info","snippet":"        (uint8 sd, uint8 qd) = (IDecimals(stable_).decimals(), IDecimals(quote_).decimals());","title":"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"}],"hash":"bb35f87833376f05f4b576d1b744f47f9946b62b71c61aff71037a20ad4121d5","nodeId":"45421362-3345-452d-a220-312234652ae2","outcome":"completed","summary":"Review complete. The findings file is written and every kept finding was reproduced against the live PoolManager bytecode etched locally.\n\n**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.json` and `test/scratch/` were created.\n\n**Answers to the five questions**\n\n1. **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.\n2. **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.\n3. **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.\n4. **The empty-pool guard is the weak point.** It can be bypassed and it blocks legitimate adds.\n5. **Nothing else missing** for a claim-holding hook beyond the documentation items below.\n\n**Findings kept**\n\n- **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.\n- **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.\n- **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.\n- **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.\n- **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.\n\n**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.\n\nOne note on tooling: the vendored v4-core cannot compile `PoolManager` because 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.","treeHash":null,"usage":{"cachedInputTokens":4824611,"inputTokens":1154,"model":"claude-fable-5-1","outputTokens":63359,"runtime":"claude","turns":52,"wallClockMs":1163816}}],"verification":[]}