> Audit target: https://github.com/mrtdlgc/multipool-launcher-audit @ commit `14a60d2b75592ead8fce8231f5129693ef1020c8` > Produced by the leftclaw host-auditor (read-only static audit). # Unified Audit Report — leftclaw job #1019 — `mrtdlgc/multipool-launcher-audit` **Scope:** every contract in the repo, as the client asked. There are four flattened files under `src/`. Only the first-party sections were audited; the inlined OpenZeppelin, Uniswap v4 and Permit2 code was read only where the project code depends on it. | File | First-party sections | |---|---| | `PoolLauncher.sol` | `B20Bootstrap` (`PoolLauncher.sol:2843`), `PoolLauncher` (`PoolLauncher.sol:3757`) | | `QuoteFeeHook.sol` | `QuoteFee` (`QuoteFeeHook.sol:1830`), `DynamicFee` (`QuoteFeeHook.sol:2334`), `QuoteFeeHook` (`QuoteFeeHook.sol:3773`) | | `LiquidityLocker.sol` | `LiquidityLocker` (`LiquidityLocker.sol:4866`) | | `EthDevBuy.sol` | `EthDevBuy` (`EthDevBuy.sol:3326`) | `README.md`, `docs/DESIGN_ASSUMPTIONS.md` and `docs/KNOWN_ISSUES.md` were also read, as requested. Findings that only restate a documented design choice or known issue are left out. The exception is where the docs make a claim the code does not back up; those are reported. **Method:** two independent passes, then this reconciliation. - **Phase 1 (breadth):** the ethskills checklist covering general, precision-math, ERC20, AMM/Uniswap v4 hooks, access control, DoS and MEV. - **Phase 2 (depth):** a blind pashov attacker pass covering math, access control, economics, execution trace, invariants, boundary, trust gap and flow gap. Each finding below carries an origin tag: - `[both]`: found by both passes independently, so it is corroborated. - `[phase1: …]` or `[phase2: …]`: found by one pass only. Each of these was re-read in source during reconciliation and confirmed. This is a static review. None of the repository's code was built or run. ## Reconciliation summary **Overlap:** 6 findings in both phases · **Phase-1-only:** 2 · **Phase-2-only:** 1 · **Re-examined leads kept:** 3, demoted: 0 Two disagreements between the phases were settled against the source: 1. **The cost of a pinning swap.** - Phase 1 said each dust swap pays a one-raw-unit fee. That is true for an exact-input *buy*, because `QuoteFee.fee` rounds up. - Phase 2 said an exact-input *sell* of one raw token unit pays **zero** hook fee. Confirmed: the fee is computed on the quote output, which rounds to 0, and `_mintFee` returns early (`QuoteFeeHook.sol:3920`). - The attack is therefore even cheaper than Phase 1 stated. 2. **Severity of the dynamic-fee config validation.** Phase 1 rated it Informational and Phase 2 rated it Low. It is reported as **Informational**: only the creator can set the parameters, every result stays capped by `maxLpFee`, and no third party can trigger a bad combination. Completeness: 10 unique (Contract, function) across both phases, 10 covered in unified: - `DynamicFee.compute` - `QuoteFeeHook.currentFee` - `DynamicFee.initialize` - `B20Bootstrap.create` - `B20Bootstrap.assertSafety` - `LiquidityLocker._claim` - `QuoteFeeHook.initializePool` - `PoolLauncher._validatePools` - `EthDevBuy.execute` - `PoolLauncher.launchB20` --- ## Summary | ID | Title | Severity | Origin | |---|---|---|---| | M-01 | The dynamic-fee volatility accumulator adds the tick distance again on every swap, so the fee ratchets to `maxLpFee`, and anyone can pin it there for gas alone | Medium | [both] | | L-01 | Decay and reset are timed from the last swap, so any swap stops the accumulator from decaying | Low | [both] | | L-02 | The admin-mode init-call screen is a selector denylist, and the "no operator grant at launch" guarantee is never checked after creation | Low | [both] | | I-01 | `DynamicFee.initialize` accepts parameter combinations that make no sense | Informational | [both] | | I-02 | `claimQuote` / `settleAndClaim` can't be called from inside another `PoolManager` unlock | Informational | [phase1: AMM/Uniswap v4 hooks] | | I-03 | A starting tick of `MAX_TICK` passes validation, then reverts in `PoolManager.initialize` | Informational | [phase1: boundary / precision] | | I-04 | The `EthDevBuy` refund branch can never run, and WETH sent to the contract directly is stuck | Informational | [both] | | I-05 | Admin-mode "locked" liquidity gives no protection against the issuer's mint power | Informational | [both] | | I-06 | `B20LaunchCreatedV3` emits a duplicated field and a hard-coded one | Informational | [phase2: agent periphery/flow-gap] | No Critical or High issues were found. Both passes independently traced these invariants and found that they hold: - Hook fee deltas net to zero against the ERC-6909 `mint` in all four swap cases. - The `PartialFill` equality holds for the two precharged cases. - Locker credits never exceed the claims minted. - A claim burns and takes inside one unlock that is bound to a payload hash. - Positions can't be withdrawn, approved or subscribed. - The launch is atomic and conserves supply. --- ## M-01 — The dynamic-fee accumulator adds up on every swap; the fee ratchets to `maxLpFee` and can be held there for gas alone `[both]` **Severity:** Medium **Location:** - `DynamicFee.compute`, `QuoteFeeHook.sol:2376-2401` - Called from `QuoteFeeHook.beforeSwap`, `QuoteFeeHook.sol:3878-3881` - View mirror: `QuoteFeeHook.currentFee`, `QuoteFeeHook.sol:3833-3851` ```solidity // QuoteFeeHook.sol:2378-2399 uint256 elapsed = block.timestamp - state.lastTimestamp; uint256 accumulator = state.volatilityAccumulator; if (elapsed >= config.resetPeriod) { accumulator = 0; state.referenceTick = observedTick; } else if (elapsed >= config.referenceTickFilterPeriod) { accumulator = (accumulator * config.decayFilterBps) / BPS_DENOMINATOR; state.referenceTick = observedTick; } uint256 distance = _absDiff(observedTick, state.referenceTick); accumulator += distance; ... uint256 variableFee = (uint256(config.feeControlNumerator) * accumulator * accumulator) / FEE_CONTROL_DENOMINATOR; ... state.volatilityAccumulator = accumulator.toUint32(); state.lastTimestamp = uint64(block.timestamp); ``` **Root cause.** The stored `volatilityAccumulator` already includes the previous swap's `distance`. Inside the filter window (`elapsed < referenceTickFilterPeriod`), each swap adds the full `|tick − referenceTick|` to that stored total again. The model is the Trader Joe Liquidity Book volatility accumulator. That design keeps a fixed reference volatility `v_r`, snapshotted only when the reference tick is refreshed, and recomputes `v_a = v_r + |ref − active|` from it on each swap. This code never stores `v_r`. As a result: - The accumulator grows with the **number of swaps**, not with how far the price has moved. - The fee is `baseFee + feeControlNumerator·acc²/1e6`, so it grows with the **square** of the swap count until it hits `maxLpFee`. **Worked numbers** (from Phase 2). Take a pool with `baseFee = 3000`, `maxLpFee = 50000`, `feeControlNumerator = 500`, `referenceTickFilterPeriod = 30 s` and `resetPeriod = 600 s`. A real trade moves the price 200 ticks away from `referenceTick`. - **Intended:** `acc = 200`, so the variable fee is 20 pips and the rate is **3,020 pips (0.302%)**. - **Actual:** after `n` more swaps inside the window, `acc = 200·n` and the variable fee is `20·n²` pips. At `n ≈ 49` the rate is pinned at **50,000 pips (5%)**, about 16× the intended rate. **Exploit path (a creator taking value from takers):** 1. The creator holds the 90% creator reward share (`LiquidityLocker.sol:5011-5015` credits each slot `cumulative·bps/1e4`). They launch a dynamic-fee pool. 2. The creator moves the price `d ≥ 1` tick away from `referenceTick` with a small trade. They get 90% of that trade's fee back. 3. Within one `unlock` callback (so `elapsed = 0`), or across blocks less than `referenceTickFilterPeriod` apart, they send `k` spam swaps. Each one runs `compute()` through `beforeSwap` (`QuoteFeeHook.sol:3878-3881`) and adds `d`, so `acc = k·d`. The cheapest spam swap is an exact-input **sell** of one raw token unit: - This is the `buy != exactInput` path, so the fee is charged in `afterSwap` on the quote output. - The output is 0, so `QuoteFee.fee(0, …) = 0`. - `_mintFee` then exits immediately: ```solidity // QuoteFeeHook.sol:3919-3920 function _mintFee(PoolId id, PoolInfo storage info, uint256 fee, uint8 swapCase, uint24 rate) private { if (fee == 0) return; ``` - The spammer gives up one raw token unit and gas. An exact-input buy instead costs one raw quote unit, because of the round-up in `QuoteFeeHook.sol:1842`. 4. Every later taker pays `maxLpFee` instead of roughly `baseFee`. The extra fee is minted to the locker, and 90% of it is credited to the creator. 5. One spam swap every `< referenceTickFilterPeriod` seconds holds the rate there indefinitely (see L-01). A third party with no fee share can run the same steps to grief a competing pool. With no attacker at all, any pool that trades more than once per filter period drifts toward `maxLpFee` during calm, range-bound trading. That contradicts the documented property "The dynamic rate trails the market" (`docs/DESIGN_ASSUMPTIONS.md`). **Why Medium, not High:** - The rate is capped by the creator-chosen `maxLpFee`, and that cap is itself at most 10% (`QuoteFeeHook.sol:3823`). - The current rate is visible through `currentFee`. - Takers' min-out / max-in slippage limits still apply. - No funds already held by the system are at risk. The impact is systematic overcharging of takers against the published fee model. It can be deliberate, and the person who profits is the creator. **Fix, Option A (reference-volatility snapshot, as in Liquidity Book).** This is from Phase 2, verbatim. Phase 1 independently recommended the same `volatilityReference` approach. ```diff struct State { Config config; uint64 lastTimestamp; int24 referenceTick; uint32 volatilityAccumulator; + uint32 volatilityReference; bool initialized; } ... - uint256 accumulator = state.volatilityAccumulator; - if (elapsed >= config.resetPeriod) { - accumulator = 0; - state.referenceTick = observedTick; - } else if (elapsed >= config.referenceTickFilterPeriod) { - accumulator = (accumulator * config.decayFilterBps) / BPS_DENOMINATOR; - state.referenceTick = observedTick; - } - uint256 distance = _absDiff(observedTick, state.referenceTick); - accumulator += distance; + if (elapsed >= config.resetPeriod) { + state.volatilityReference = 0; + state.referenceTick = observedTick; + } else if (elapsed >= config.referenceTickFilterPeriod) { + state.volatilityReference = uint32(uint256(state.volatilityAccumulator) * config.decayFilterBps / BPS_DENOMINATOR); + state.referenceTick = observedTick; + } + uint256 accumulator = uint256(state.volatilityReference) + _absDiff(observedTick, state.referenceTick); ``` **Fix, Option B:** time the filter from the last reference update instead of the last swap. This is covered under L-01, and it should be applied together with Option A. `currentFee` (`QuoteFeeHook.sol:3833-3851`) copies the same logic and must be changed at the same time. --- ## L-01 — Decay and reset are timed from the last swap, so any swap stops the accumulator from decaying `[both]` **Severity:** Low **Location:** `QuoteFeeHook.sol:2378`, `QuoteFeeHook.sol:2381`, `QuoteFeeHook.sol:2384`, `QuoteFeeHook.sol:2399` ```solidity uint256 elapsed = block.timestamp - state.lastTimestamp; ... if (elapsed >= config.resetPeriod) { ... } else if (elapsed >= config.referenceTickFilterPeriod) { ... state.lastTimestamp = uint64(block.timestamp); ``` Phase 1 reported this separately. Phase 2 found it as the second root cause of M-01. `elapsed` measures the time since the *last swap*, not since the reference was set. So decay (`elapsed >= referenceTickFilterPeriod`) and reset (`elapsed >= resetPeriod`) only happen after a quiet gap with no trades. **Exploit path:** 1. M-01 inflates the accumulator. 2. Anyone then sends a zero-fee spam swap (a one-raw-unit exact-input sell, see M-01 step 3) every `< referenceTickFilterPeriod` seconds. 3. Each spam swap rewrites `lastTimestamp`, so neither the decay branch nor the reset branch ever runs. 4. The fee stays at `maxLpFee` indefinitely, at a cost of gas plus one raw token unit per swap. This is separate from M-01's double counting. Even with Option A applied, a busy pool would never refresh its reference tick. **Recommendation:** add a separate `referenceTimestamp` that is set only when `referenceTick` changes, and compute `elapsed` from it. --- ## L-02 — The admin-mode init-call screen is a selector denylist; the "no operator grant" guarantee is never checked afterwards `[both]` **Severity:** Low **Location:** `B20Bootstrap.create`, `PoolLauncher.sol:2871-2883`; `B20Bootstrap.assertSafety`, `PoolLauncher.sol:2892-2903` ```solidity // PoolLauncher.sol:2876-2883 bytes4 selector = bytes4(data[:4]); if (selector == MULTIPLIER) revert IB20LaunchTypes.RebaseMultiplierNotWad(); if (selector == GRANT) { (bytes32 role,) = abi.decode(data[4:], (bytes32, address)); if (role == keccak256("OPERATOR_ROLE")) revert IB20LaunchTypes.OperatorRoleNotAllowed(); } ... calls = b20.initCalls; ``` `docs/DESIGN_ASSUMPTIONS.md` states that the launch checks include "no operator grant or multiplier update in init calls". The only enforcement is an exact match on the first four bytes of `grantRole(bytes32,address)`. Any other B20 entry point with the same effect would pass, for example: - a batch or `multicall` wrapper; - a plural `grantRoles`; - `setRoleAdmin` making an admin-held role the admin of `OPERATOR_ROLE`. `assertSafety` re-checks `multiplier()`, the pause state and the policies, but it never checks `hasRole(OPERATOR_ROLE, …)`: ```solidity // PoolLauncher.sol:2894 if (b20.multiplier() != 1e18) revert IB20LaunchTypes.RebaseMultiplierNotWad(); ``` So the postcondition itself is never verified. **Exploit path, if the B20 ABI exposes such a route:** an admin-mode creator includes a batch call wrapping `grantRole(OPERATOR_ROLE, self)` in `initCalls`. It passes the screen, and the token launches with an operator already granted. That contradicts the stated launch-time guarantee and any indexer that relies on it. The B20 ABI is not in this repo, so whether such a route exists could not be confirmed. Impact is limited, because the admin can grant the role one block later anyway, and that is a disclosed issuer power. It is rated Low because a documented guarantee is weaker than it reads. **Recommendation, either of:** - **Option A:** use an allowlist of init-call selectors in admin mode, as immutable mode already does at `PoolLauncher.sol:2866`. - **Option B:** in `assertSafety`, assert `!hasRole(OPERATOR_ROLE, x)` for the admin, the launcher and the factory. --- ## I-01 — `DynamicFee.initialize` accepts parameter combinations that make no sense `[both]` **Location:** `QuoteFeeHook.sol:2360-2367` ```solidity if ( config.baseFee == 0 || config.baseFee > config.maxLpFee || config.maxLpFee > LPFeeLibrary.MAX_LP_FEE || config.decayFilterBps > BPS_DENOMINATOR ) { ``` No relationship between the parameters is enforced: - `referenceTickFilterPeriod >= resetPeriod`: the reset branch is tested first, so the decay branch can never run. - `resetPeriod == 0` or `feeControlNumerator == 0`: the pool is silently static at `baseFee`, yet `LaunchPoolCreatedV3` still labels it `FeeMode.Dynamic`. - A very large `feeControlNumerator`: a one-tick move jumps straight to `maxLpFee`. Phase 2 rated this Low. It is reported here as Informational because only the creator chooses these values and `maxLpFee` caps every outcome. **Recommendation:** in dynamic mode, require `resetPeriod > 0`, `feeControlNumerator > 0` and `referenceTickFilterPeriod < resetPeriod`. ## I-02 — Claims can't be called from inside an active unlock `[phase1: AMM/Uniswap v4 hooks]` **Location:** `LiquidityLocker._claim`, `LiquidityLocker.sol:5051-5061` ```solidity // LiquidityLocker.sol:5059 poolManager.unlock(data); ``` `PoolManager.unlock` reverts with `AlreadyUnlocked` when it is already unlocked. A recipient contract or router therefore can't claim from inside its own unlock callback, for example to claim and swap atomically. This was confirmed on re-read. No funds are at risk; the claim has to be a separate call. ## I-03 — A starting tick of `MAX_TICK` passes validation, then reverts later `[phase1: boundary / precision]` **Location:** `PoolLauncher.sol:3905`, `QuoteFeeHook.sol:3808-3809`, `QuoteFeeHook.sol:3827` ```solidity // QuoteFeeHook.sol:3808-3809 || config.tickIfToken0IsB20 < TickMath.MIN_TICK || config.tickIfToken0IsB20 > TickMath.MAX_TICK ... // QuoteFeeHook.sol:3827 poolManager.initialize(key, TickMath.getSqrtPriceAtTick(startingTick)); ``` **How it fails:** - Both checks allow equality. - If the effective `startingTick` is `MAX_TICK`, `getSqrtPriceAtTick` returns `MAX_SQRT_PRICE`. That happens with `tickIfToken0IsB20 = MAX_TICK` when the token sorts as `currency0`, or with `MIN_TICK` when the sign is flipped. - `PoolManager.initialize` rejects `MAX_SQRT_PRICE`, so the whole launch reverts atomically with an unclear error. **Recommendation:** bound the tick to `(MIN_TICK, MAX_TICK)`, or better, to the usable ticks for the tick spacing. ## I-04 — The `EthDevBuy` refund branch can never run; stray WETH is stuck `[both]` **Location:** `EthDevBuy.execute`, `EthDevBuy.sol:3358-3365`; `EthDevBuy.unlockCallback`, `EthDevBuy.sol:3379-3382` ```solidity uint256 beforeWeth = weth.balanceOf(address(this)); weth.deposit{value: msg.value}(); ... uint256 refund = weth.balanceOf(address(this)) - beforeWeth; if (refund != 0) IERC20(address(weth)).safeTransfer(creator, refund); ``` The swap is exact-input for the full `msg.value`, and two checks rule out a partial fill: - A partial fill reverts in the hook (`QuoteFeeHook.sol:3910`). - The callback requires `uint256(-quoteDelta) == input` and `poolManager.settle() == input` (`EthDevBuy.sol:3379`, `EthDevBuy.sol:3382`). So `refund` is always 0. Because `beforeWeth` is subtracted, WETH sent to the contract directly is never refunded, and the contract has no sweep. That same snapshot is what stops such a balance from being misattributed. This is not exploitable. Remove the branch, or document it and add a sweep. ## I-05 — Admin-mode "locked" liquidity gives no protection against the issuer's mint power `[both]` **Location:** `PoolLauncher.sol:2871-2883`, `PoolLauncher.sol:2900-2902` In `AdminMode.Admin` the init calls pass through unchanged (`calls = b20.initCalls;`, `PoolLauncher.sol:2883`). The `supplyCap` check in `assertSafety` applies only when `mode == IB20LaunchTypes.AdminMode.Immutable` (`PoolLauncher.sol:2900`). After launch, the admin can mint without limit and sell into every launch pool, draining the quote liquidity traders paid in. The admin can also pause transfers, which freezes the `PoolManager`'s balance of the token. `DESIGN_ASSUMPTIONS.md` discloses these as issuer powers. They are restated here because "liquidity locked forever" only protects traders in `Immutable` launches, and front-ends should flag admin-mode tokens clearly. ## I-06 — `B20LaunchCreatedV3` emits a duplicated field and a hard-coded one `[phase2: agent periphery/flow-gap]` **Location:** `PoolLauncher.sol:3872-3874`; event declaration at `PoolLauncher.sol:3407-3411` ```solidity // PoolLauncher.sol:3874 config.liquiditySupply, config.liquiditySupply, 0, info.primaryPoolId, uint8(budgets.length), keccak256(abi.encode(rewards)), uint64(block.timestamp)); ``` The event declares `totalSupply`, `liquiditySupply` and `extensionSupply`: - `totalSupply` and `liquiditySupply` both receive `config.liquiditySupply`. - `extensionSupply` is hard-coded to `0`. On re-read this is consistent with the code: the supply check at `PoolLauncher.sol:3868` forces total supply to equal the liquidity supply. So the event is accurate, but two of its fields carry no information. This is harmless, but indexers may be misled. Drop the fields or document them. --- ## Areas checked by both phases with no finding - **Hook fee accounting.** In all four swap cases, the hook's `BeforeSwapDelta` / `afterSwap` return delta cancels `poolManager.mint(locker, quote, fee)` (`QuoteFeeHook.sol:3921`). The `PartialFill` equality (`QuoteFeeHook.sol:3910`) compares against the pool's pre-hook `swapDelta`. A precharged fee never exceeds the specified amount, and fees always round up (`QuoteFeeHook.sol:1842`). - **Transient fee context.** It is scoped by pool id, written in `beforeSwap` (`QuoteFeeHook.sol:3883`), then read and cleared in `afterSwap`. The hook makes no reentrant external calls. - **Pool binding.** Only the launcher can call `initializePool` (`QuoteFeeHook.sol:3804`), and `beforeInitialize` requires `sender == address(this)` (`QuoteFeeHook.sol:3867`). - **Locker solvency.** Per pool, the total credited is `Σ floor(cum·bps/1e4) ≤ cum` (`LiquidityLocker.sol:5011`). `_claim` zeroes the balance before unlocking, and the callback is bound to the payload hash (`LiquidityLocker.sol:5064-5069`). - **Position custody.** The Permit2 allowance expires in the same block and is revoked afterwards (`LiquidityLocker.sol:4945`, `LiquidityLocker.sol:4952`). Ownership and liquidity are re-checked at `completeLaunch` (`LiquidityLocker.sol:4985-4998`). - **Launch atomicity and supply.** The predicted token address is enforced (`PoolLauncher.sol:3838`). After the extensions run, the launcher must hold no tokens and supply must be conserved (`PoolLauncher.sol:3868`), and `assertSafety` runs again. - **EthDevBuy.** Only the launcher can call it, only while the launch is `Bootstrapping`, only for the matching creator and only on a registered WETH pool. Minimum output is enforced, and settlement is checked with `sync` / `settle` (`EthDevBuy.sol:3380-3382`). **Severity counts:** Critical: 0, High: 0, Medium: 1, Low: 2, Informational: 6