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:
- 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.feerounds 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
_mintFeereturns early (QuoteFeeHook.sol:3920). - The attack is therefore even cheaper than Phase 1 stated.
- Phase 1 said each dust swap pays a one-raw-unit fee. That is true for an exact-input buy, because
- 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.computeQuoteFeeHook.currentFeeDynamicFee.initializeB20Bootstrap.createB20Bootstrap.assertSafetyLiquidityLocker._claimQuoteFeeHook.initializePoolPoolLauncher._validatePoolsEthDevBuy.executePoolLauncher.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 |
Info | [both] |
| I-02 | claimQuote / settleAndClaim can't be called from inside another PoolManager unlock |
Info | [phase1: AMM/Uniswap v4 hooks] |
| I-03 | A starting tick of MAX_TICK passes validation, then reverts in PoolManager.initialize |
Info | [phase1: boundary / precision] |
| I-04 | The EthDevBuy refund branch can never run, and WETH sent to the contract directly is stuck |
Info | [both] |
| I-05 | Admin-mode "locked" liquidity gives no protection against the issuer's mint power | Info | [both] |
| I-06 | B20LaunchCreatedV3 emits a duplicated field and a hard-coded one |
Info | [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
mintin all four swap cases. - The
PartialFillequality 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
// 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 hitsmaxLpFee.
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
nmore swaps inside the window,acc = 200·nand the variable fee is20·n²pips. Atn ≈ 49the rate is pinned at 50,000 pips (5%), about 16× the intended rate.
Exploit path (a creator taking value from takers):
- The creator holds the 90% creator reward share (
LiquidityLocker.sol:5011-5015credits each slotcumulative·bps/1e4). They launch a dynamic-fee pool. - The creator moves the price
d ≥ 1tick away fromreferenceTickwith a small trade. They get 90% of that trade's fee back. - Within one
unlockcallback (soelapsed = 0), or across blocks less thanreferenceTickFilterPeriodapart, they sendkspam swaps. Each one runscompute()throughbeforeSwap(QuoteFeeHook.sol:3878-3881) and addsd, soacc = k·d. The cheapest spam swap is an exact-input sell of one raw token unit:- This is the
buy != exactInputpath, so the fee is charged inafterSwapon the quote output. - The output is 0, so
QuoteFee.fee(0, …) = 0. _mintFeethen exits immediately:// 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.
- This is the
- Every later taker pays
maxLpFeeinstead of roughlybaseFee. The extra fee is minted to the locker, and 90% of it is credited to the creator. - One spam swap every
< referenceTickFilterPeriodseconds 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.
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
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:
- M-01 inflates the accumulator.
- Anyone then sends a zero-fee spam swap (a one-raw-unit exact-input sell, see M-01 step 3) every
< referenceTickFilterPeriodseconds. - Each spam swap rewrites
lastTimestamp, so neither the decay branch nor the reset branch ever runs. - The fee stays at
maxLpFeeindefinitely, 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
// 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
multicallwrapper; - a plural
grantRoles; setRoleAdminmaking an admin-held role the admin ofOPERATOR_ROLE.
assertSafety re-checks multiplier(), the pause state and the policies, but it never checks hasRole(OPERATOR_ROLE, …):
// 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
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 == 0orfeeControlNumerator == 0: the pool is silently static atbaseFee, yetLaunchPoolCreatedV3still labels itFeeMode.Dynamic.- A very large
feeControlNumerator: a one-tick move jumps straight tomaxLpFee.
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
// 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
// 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
startingTickisMAX_TICK,getSqrtPriceAtTickreturnsMAX_SQRT_PRICE. That happens withtickIfToken0IsB20 = MAX_TICKwhen the token sorts ascurrency0, or withMIN_TICKwhen the sign is flipped. PoolManager.initializerejectsMAX_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
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) == inputandpoolManager.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
// 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:
totalSupplyandliquiditySupplyboth receiveconfig.liquiditySupply.extensionSupplyis hard-coded to0.
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/afterSwapreturn delta cancelspoolManager.mint(locker, quote, fee)(QuoteFeeHook.sol:3921). ThePartialFillequality (QuoteFeeHook.sol:3910) compares against the pool's pre-hookswapDelta. 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 inafterSwap. The hook makes no reentrant external calls. - Pool binding. Only the launcher can call
initializePool(QuoteFeeHook.sol:3804), andbeforeInitializerequiressender == address(this)(QuoteFeeHook.sol:3867). - Locker solvency. Per pool, the total credited is
Σ floor(cum·bps/1e4) ≤ cum(LiquidityLocker.sol:5011)._claimzeroes 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 atcompleteLaunch(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), andassertSafetyruns 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 withsync/settle(EthDevBuy.sol:3380-3382).
Severity counts: Critical: 0, High: 0, Medium: 1, Low: 2, Informational: 6