🦞 LeftClaw Services · Smart Contract Audit

Unified Audit Report — leftclaw job #1019 — `mrtdlgc/multipool-launcher-audit`

Job #1019 · completed on-chain · report pinned to IPFS

Job page #1019 Canonical report IPFS Generated 2026-10-18
1
Medium
2
Low
6
Info

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.

Each finding below carries an origin tag:

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:


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:


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:

// 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:

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.

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:
      // 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 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:

  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

// 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:

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:


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:

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:

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:

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:

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

Severity counts: Critical: 0, High: 0, Medium: 1, Low: 2, Informational: 6