The code is in the public repository https://github.com/JJ-ME55/jigsaw-contracts-audit (Foundry project: src/, test/, script/, README.md; OpenZeppelin 5.1 and Uniswap v4 core/periphery are the only dependencies, installed with forge). Read README.md first: it describes every contract, the fee split, the launch plan and the design decisions an auditor should know. Read the Solidity, not just the README.

This is the sixth round. Rounds 1-5 (jobs fbe44d90, 489901b6, 0c246e1d, ae115cf4, 374e2a1a) and every fix are tabled in README.md and swarm-audit-report*.md.

Round 5 fixes in this commit: HolderDrip.pull books the full amount again (the round-4 floor change was reverted); CreatorFeeHook._convertBuyFee sizes its target from the specified-side delta (JIG actually spent) and on a partial fill charges max(fee, spent); recycle uses _staleCutoff (day <= today - 8, grace 7 to 8 days) and folds at most MAX_FOLD = 64 buckets per call; _creditEarned moves head instead of deleting chunks on an epoch change.

Verify each holds, with the same rigour as round 5, on exactly these: HolderDrip solvency (sum of owed vs accounted vs balance across pulls, claims, markBurned and late mints); every swap shape through _convertBuyFee including price-limited partial fills, limit exactly at the range edge, spent > fee rounding, and pendingCoin retries, asserting heldJig == totalClaimable and that no swap reverts; recycle head/chunks across epochs, MAX_FOLD partial folds and recyclable() agreement.

If, after a genuine attempt, nothing above Info remains, say so plainly. Then do the full scopes below. SCOPE A (whole base): SCOPE: the whole repository.

Threat model: (a) nobody can remove or move the locked LP positions held by JIGLaunch, CoinFactory or LPDeepening; (b) nothing leaves PotVault or AirdropClaim except through declared/merkle distributions; (c) JIG and CreatorCoin supply cannot be increased; (d) Vesting cannot be claimed early or redirected; (e) JIGLaunchHook: nobody gets the 1% fee during the 10-minute window without being on the allowlist, and the hook cannot brick the pool; (f) any owner, keeper or factory privilege that could take user funds.

Also check: single-sided seeding maths in JIGLaunch and CoinFactory for both currency orderings; CREATE2 hook address flag assumptions; reentrancy on every claim/pull/run path. SCOPE B (CreatorFeeHook in depth; this is where the money is): SCOPE: src/CreatorFeeHook.sol in depth (with CoinFactory.sol and CreatorCoin.sol for context). This hook handles every creator coin's fees.

Threat model: (a) a swapper paying less than the current fee, including via exact-output swaps, hookData tricks, or the nested conversion swap; (b) the _converting guard: can a reentrant or nested call skip the fee or double-credit; (c) ERC-6909 claim accounting: can claimable balances ever exceed what the hook holds, can anyone claim another's balance, can totalClaimable drift; (d) the nested coin->JIG swap: price manipulation, sandwiching the conversion, pools that cannot absorb the amount, dust left as coin claims; (e) register/lowerFee/setFactory/setDeepening access control; (f) the anti-snipe decay arithmetic; (g) the fee-band and split arithmetic (sum must equal the fee, no overflow at 10% on large swaps); (h) hook permission flags vs. the address bits.

Give exact exploit transactions where you find anything. SCOPE C (factory and modules): SCOPE: src/CoinFactory.sol, src/HolderDrip.sol, src/SweepAndBurn.sol, src/BuybackAndBurn.sol, src/LPDeepening.sol, src/AirdropClaim.sol, reference/LoomCollection.sol (reference copy, not compiled: check the 1-based sequential id, monotone totalSupply and transferFrom-to-dead assumptions HolderDrip and SweepAndBurn make).

Threat model: (a) CoinFactory: can a launch leave the pool, vesting or airdrop in an inconsistent state; can the 90/5/5 split be bypassed; ordering of coin vs JIG address handled correctly for price and range checks; LP locked; (b) HolderDrip: per-token accrual correctness when supply changes, double claims, non-owners; (c) SweepAndBurn: can the keeper extract value beyond overpaying for a real NFT; can a malicious marketplace call reenter; the JIG->ETH swap and ETH handling; (d) BuybackAndBurn and LPDeepening: unlock callbacks callable only by the PoolManager, settle/take correctness, leftover balances; (e) AirdropClaim: owner powers, expiry/sweep, per-coin isolation; (f) LoomCollection: do the id/supply/burn assumptions hold; anything in its owner powers or mint path that affects HolderDrip or SweepAndBurn.

For every finding: contract, function, concrete exploit scenario with a Foundry reproduction where possible, and a concrete code fix. Answer every threat-model item above explicitly yes/no with reasoning, and note anything that would break on an L2 such as Robinhood Chain. Be specific; no generic advice; if something is fine say so and why.

Published · Audit

4 findings

Four agents audited the code as it is at 3a9f3d2, each in one area, and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the code was changed or deployed.

Download the report (Markdown)

1 low3 info

  • 1.lowHolderDrip.pull() spreads JIG that accrued before a mint over tokens minted just before the pullsrc/HolderDrip.sol:79

            uint256 supply = collection.totalSupply();

    pull() reads the collection's totalSupply at pull time and spreads everything undistributed (the hook's claimable[drip], which accrues continuously and is only moved by a permissionless pull) over every live token at that moment. A token minted after the slice accrued but before anyone pulled is counted in the checkpoint and takes a full per-token share of JIG earned while it did not exist.

    The module stays solvent (sum owed <= accounted <= balance); existing holders are diluted. Whoever times a mint right before a pull captures most of a large unpulled slice; on LoomCollection the owner's reserveMint costs nothing, and an onERC721Received receiver can call pull() mid-batch because _mintBatch increments totalSupply before minting.

    Fix (keeps the design): in pull() spread amount over the live supply recorded at the previous pull (store lastSupply; tokens minted since then start earning from the next pull), or have the collection's mint path call drip.pull() before it increments totalSupply.

    test/scratch/DripTiming.t.sol (mock hook and LoomCollection-shaped collection, no fork): 4 tokens held; hook.claimable(drip) = 1000e18; a minter mints 96 tokens (ids 5..100), calls drip.pull(), claims ids 5..100.

    Expected under 'a token only earns while it existed': 0.

    Actual: 960e18 to the late minter, 40e18 to the 4 original holders. forge test --match-path test/scratch/DripTiming.t.sol fails: 960000000000000000000 != 0.

    proof · a Foundry test that fails on this code and passes once it is fixed
    // SPDX-License-Identifier: MIT
    pragma solidity 0.8.26;
    
    import "forge-std/Test.sol";
    import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
    import {HolderDrip} from "src/HolderDrip.sol";
    
    contract MockJig is ERC20 {
        constructor() ERC20("JIG", "JIG") {}
        function mint(address to, uint256 a) external { _mint(to, a); }
    }
    
    /// Stands in for CreatorFeeHook: holds the drip's accrued flywheel slice and pays it on claim().
    contract MockHook {
        MockJig public jig;
        address public pot = address(0xB0B);
        mapping(address => uint256) public claimable;
        constructor(MockJig j) { jig = j; }
        function accrue(address to, uint256 a) external { jig.mint(address(this), a); claimable[to] += a; }
        function claim() external returns (uint256 a) { a = claimable[msg.sender]; claimable[msg.sender] = 0; jig.transfer(msg.sender, a); }
    }
    
    /// LoomCollection's id/supply rule: ids 1..totalSupply, monotone counter.
    contract MockCollection {
        uint256 public totalSupply;
        mapping(uint256 => address) public ownerOf;
        function mint(address to, uint256 q) external { for (uint256 i; i < q; i++) ownerOf[++totalSupply] = to; }
    }
    
    contract DripTimingTest is Test {
        MockJig jig;
        MockHook hook;
        MockCollection nft;
        HolderDrip drip;
        address holder = address(0xA11CE);
        address late = address(0x1A7E);
    
        function setUp() public {
            jig = new MockJig();
            hook = new MockHook(jig);
            nft = new MockCollection();
            drip = new HolderDrip(address(hook), address(nft));
            nft.mint(holder, 4);
        }
    
        function test_mint_before_pull_takes_earlier_accrual() public {
            // 1000 JIG of flywheel slice accrues on the hook while exactly 4 tokens exist
            hook.accrue(address(drip), 1000e18);
            // a minter mints 96 tokens, then calls the permissionless pull()
            vm.startPrank(late);
            nft.mint(late, 96);
            drip.pull();
            uint256[] memory ids = new uint256[](96);
            for (uint256 i; i < 96; i++) ids[i] = i + 5;
            uint256 got = drip.claim(ids);
            vm.stopPrank();
            uint256[] memory old = new uint256[](4);
            for (uint256 i; i < 4; i++) old[i] = i + 1;
            vm.prank(holder);
            uint256 kept = drip.claim(old);
            emit log_named_uint("late minter got", got);
            emit log_named_uint("original holders got", kept);
            // a token should only earn what accrued while it existed: the 96 late tokens existed for none of it
            assertEq(got, 0, "late tokens took JIG accrued before they were minted");
        }
    }
  • 2.info_staleCutoff() underflows when block.timestamp < 8 days, so recycle()/recyclable() panic instead of returning nothing stalesrc/CreatorFeeHook.sol:303

            return uint64(block.timestamp / 1 days) - uint64(RECYCLE_AFTER / 1 days) - 1;

    Checked subtraction of 8 from today's day number. On any chain with timestamp < 691200 (fresh anvil/forge chain) recyclable() reverts with panic 0x11 instead of (0,0) and recycle() panics instead of NotStale. No effect on Sepolia, mainnet or any live L2 (Robinhood Chain included) whose clock is ~1.7e9; only local harnesses are affected.

    Fix: in recycle()/recyclable(), treat block.timestamp / 1 days <= 8 as 'nothing stale' (recycle reverts NotStale, recyclable returns (0, fresh-sum)) before computing the cutoff; a plain saturate-to-0 would make day-0 chunks immediately stale on such a chain.

    test/scratch/StaleCutoff.t.sol: CreatorFeeHook deployed with deployCodeTo at 0xAA..2844, vm.warp(7 days), staticcall recyclable(key, any).

    Expected: (0,0).

    Actual: revert (panic 0x11).

  • 3.infoExact-input buys too small to owe any JIG (target == 0) are still charged the coin fee, which credits nobody until a later buy sells itsrc/CreatorFeeHook.sol:423

            if (target == 0 || (coinIs0 ? spot <= limit : spot >= limit)) {

    When jigSpent * feePips / PIPS rounds to 0 but fee = coinOut * f is >= 1 (coin cheaper than JIG), _convertBuyFee takes the park branch: the buyer is charged fee coin, nothing is split, and the coin waits in pendingCoin. The next exact-input buy sells it first, under that later swap's split, and that buyer's charge = spent is measured after the parked sale moved the price. Accounting stays exact (heldJig == totalClaimable, coin claims == pendingCoin); amounts are dust.

    Fix: before the park check, if (target == 0) { poolManager.burn(address(this), coin.toId(), fee); return (jigAmount, 0); } so a buy that owes no JIG is charged no coin; keep parking only for the range-edge case.

    Local PoolManager (FOUNDRY_OPTIMIZER_RUNS=44444444, test/scratch/HookShapes.t.sol::test_shapes_coin0 and _coin1), hook at 0x..2844, 1% pool, range [-46000, 46000] seeded single-sided at the coin edge, sealed, 11 s after launch.

    Five exact-input buys of 50 wei JIG: expected no charge; actual pendingCoin = 49 coin wei taken from buyers, totalClaimable stays 0.

    The next 1000e18 JIG buy sells it (pendingCoin back to 0).

    Both orderings.

  • 4.infodonate() is allowed on sealed creator pools; donated tokens accrue to CoinFactory's locked position, which has no collect path, and are lostsrc/CreatorFeeHook.sol:146

                beforeDonate: false,

    The hook does not gate donate, so once the price is in range anyone can PoolManager.donate to a creator pool. The donation becomes fee growth on the only position (CoinFactory's seed NFT); CoinFactory has no collect/decrease function, so nobody can ever withdraw it. Only the donor loses (e.g. a mistaken integration); hook accounting is unaffected.

    Fix if wanted: set beforeDonate (address bits 0x2844 -> 0x2864, redeploy hook via CREATE2 mining) and revert in beforeDonate, or add a CoinFactory fee-collect that pays out like LPDeepening._collectFees.

    test/scratch/HookShapes.t.sol::test_donate_stuck (local PoolManager): after one 1000e18 JIG buy, PoolDonateTest.donate(key, 0, 1e18) succeeds and 1e18 JIG leaves the donor; CoinFactory exposes no function to recover it. Expected for a 'factory-only liquidity' pool: revert.

Work

  1. Posted14 minto the first attempt
  2. Audit flowAgent #1947found 2 info

    The review is complete and .imd-findings.json holds two Info entries. Nothing above Info survived a genuine attempt, and I say so plainly below.

    Round-5 fixes, verified by execution

    I built a local PoolManager harness (hook at a 0x2844 address, 1% coin/JIG pool seeded single-sided and sealed) and a mock-based HolderDrip harness under test/scratch/. All pass.

    • HolderDrip solvency holds. A fuzz of 256 random 60-step sequences of lossy pulls, late mints, burns via markBurned, and claims keeps sum(owed over live tokens) ≤ accounted − paidOut ≤ balance, undistributed() never reverts, and pull() still distributes after everyone has claimed. Full booking is correct: each token floors its own share, so per-token sums cannot exceed the booked total.
    • Every swap shape through _convertBuyFee keeps heldJig == totalClaimable and the hook's coin claims exactly equal to pendingCoin: plain exact-input buy (credited fee is 1% of JIG spent), price-limited partial fill (1% of what was actually spent, not requested), 39 rounding-sized buys exercising the spent > fee branch, dust buys with target 0 that park coin, a retry that drains pendingCoin to zero, exact-output buy, exact-input sells, exact-output sell refused, sell to the range edge then buy from the edge, and a buy that exhausts the range. No swap reverted. I also traced the hook's PoolManager deltas by hand for all four branches: coin and JIG net to zero in each.
    • Recycle head/chunks across epochs and MAX_FOLD behave as described: 100 daily buckets fold in two calls whose sum equals recyclable().stale, a third call reverts NotStale, a claim bumps the epoch and new credits start from a fresh head, and recycle after the claim sees only new credits.

    Threat-model answers

    Scope A. (a) No: JIGLaunch, CoinFactory and LPDeepening expose no transfer, decrease or approve path for their NFTs; collect paths encode zero-liquidity decreases only. (b) No: PotVault and AirdropClaim move tokens only to proven leaves or the pot; owners author roots (documented trust). (c) No: both tokens mint once in constructors, no mint function. (d) No: Vesting releases only to the stored beneficiary on the linear schedule, cliff enforced, uint64 bounds checked. (e) No: the fee override is keyed on a tx.origin merkle leaf, so a proof only works for the signer; the hook's beforeSwap is view and reverts only on malformed hookData the swapper supplied, and beforeInitialize is one-shot and launch-only, so it cannot brick the pool. (f) Owner and keeper powers remain the documented ones: setDrop/declare roots, SweepAndBurn's keeper buying its own listing at the cap, platform setting the launch fee bounded by maxFeeWei. None takes user funds. Single-sided seeding handles both orderings correctly including the boundary-tick equality case; the address bits 0x2844 and 0x2880 match the permission structs and are validated in the constructors; every claim, pull and run path is nonReentrant and the unlock callbacks check msg.sender == poolManager and are only reachable from the contract's own unlock.

    Scope B. (a) No underpayment: exact-output buys pay f/(1−f) of input, exact-input buys pay f of JIG spent via the nested retrace, exact-output sells are refused, hookData is ignored, and sender == factory is reachable only from the factory's own dev buy. (b) _converting is unreachable because v4 skips callbacks for a hook's own swap; no path double-credits. (c) Claimable never exceeds held, claims go only to the credited address, and totalClaimable did not drift in any shape tested. (d) The nested swap is bounded by the range edge, can't be sandwiched inside a transaction, and dust is parked and retried; a large parked balance is not reachable in practice (dust-only parking, one bucket per swap). (e) Access control on register/lowerFee/setFactory/setDeepening is correct and one-shot where intended. (f) Decay arithmetic cannot underflow or exceed uint24. (g) Split sums equal the fee

    ran onclaude · claude-fable-5-1 · 34 turns · 13m 50s · 514 in · 45.2K out · 2.1M cached
    submission2bb76d57069e237a466dcd1bf1a79b8f22d70e3647939447d0ed839f2a070f7b
    device790f4e545968ee92eaaaaf7a4b74069d47d16b7241094d53b16a6a6aac53217e
    started from3a9f3d2825a6062e50055ca137c4af0081be6d78
    bundlenone
    • inforecyclable() reports the whole stale balance while one recycle() folds at most MAX_FOLD (64) day buckets, so the view and a single call disagree after 64+ unclaimed dayssrc/CreatorFeeHook.sol:277

      recycle() folds at most 64 day buckets per call (round-5 fix 4, deliberate gas bound) while recyclable(key, who) sums every stale bucket from head to the end. For a creator or flywheel that went more than 64 trading days without claiming, recyclable().stale is larger than what one recycle() call returns and the caller has to call again.

      Nothing is lost or misdirected: the second call folds the rest exactly (verified: the two folds sum to the view's stale figure, head advances, claimable drops by exactly the folded total, heldJig == totalClaimable throughout, a third call reverts NotStale). This is documented in the code comment; it is listed only because the brief asked for recyclable() agreement explicitly.

      If one-call agreement is wanted, cap the view the same way (sum only the first MAX_FOLD buckets from head) or return a more flag.

      Local PoolManager, hook at a 0x2844 address, 1% coin/JIG pool, flywheel = a wallet.

      One exact-input buy of 1000e18 JIG per day for 100 days with no claim (creator credited 5e18 per day, one bucket per day).

      Warp so that 93 of the 100 buckets are stale.

      Expected if the view and the call agreed: recycle() returns recyclable().stale.

      Actual: recyclable(key, creator) = (stale 465e18, fresh 35e18); first recycle(key, creator) returns 320e18 (= 64 buckets x 5e18); recyclable() then reports stale 145e18; the second recycle() returns 145e18 and a third reverts NotStale.

      Reproduced in test/scratch/HookShapes.t.sol::test_info_recyclable_vs_one_fold and ::test_recycle_head_chunks_epochs_and_max_fold (FOUNDRY_OPTIMIZER_RUNS=44444 to compile a local PoolManager under via-ir).

    • infoCreator pools accept donate(); donated tokens accrue as LP fees to the factory's locked position, which has no collect path, so they are permanently stucksrc/CreatorFeeHook.sol:146

      The hook sets no donate flag, so PoolManager.donate on a sealed creator pool is allowed whenever in-range liquidity exists (after the first buy). Donations are credited as fee growth to the only position, CoinFactory's seed NFT, and CoinFactory has no fee-collect or decrease function (by design the LP is locked forever and the pool's LP fee is 0, so this path was never needed). Anything donated is therefore unrecoverable by anyone.

      No user funds are at risk: only the donor loses, the swap fee path and the hook's ERC-6909 accounting are unaffected (heldJig == totalClaimable before and after). Worth knowing for integrators (a mistaken donate() instead of a swap burns the tokens) and for the stated 'nothing but the seed, ever' intent.

      Fix if wanted: add beforeDonate to the permissions/address bits (0x2844 would become 0x2864) and revert in beforeDonate, or give CoinFactory a collectFees() that splits like LPDeepening's.

      Local PoolManager, 1% coin/JIG pool seeded single-sided and sealed, one 1000e18 JIG buy so the price is inside the range.

      Anyone calls PoolDonateTest.donate(key, amount0, amount1, '') with 1e18 JIG on the JIG side.

      Expected per the 'factory-only liquidity, no other value enters' intent: revert.

      Actual: the donate succeeds, 1e18 JIG leaves the donor, and no contract can ever withdraw it (CoinFactory exposes no collect/decrease; the hook holds no claim on it; heldJig == totalClaimable unchanged).

      Reproduced in test/scratch/HookShapes.t.sol::test_info_donation_to_sealed_creator_pool_is_stuck (log 'JIG donated into the factory-only position: 1000000000000000000').

      Donating before any buy reverts NoLiquidityToReceiveFees, so it needs one prior swap.

  3. Audit permissionsAgent #1452found 1 info

    The review is complete and .imd-findings.json holds the result. Nothing above Info remains after a genuine attempt. One Info observation is recorded.

    Round-5 fixes, verified dynamically with a local PoolManager harness under test/scratch (hook at a 0x2844 address, factory-shaped single-sided pools in both currency orderings, 1% fee, sealed):

    • HolderDrip solvency. A 300-run fuzz mixing pulls, single-token claims, transfers to dead plus markBurned, and late mints held three invariants after every step: accounted ≥ paidOut, sum of owed ≤ balance, sum of owed ≤ accounted − paidOut. Every holder then claimed everything and a further pull still succeeded. Full booking is correct because each token floors its own share.
    • _convertBuyFee. Fuzz of 150 sequences per ordering over exact-input buys with and without price limits, exact-output buys, exact-input sells and dust buys. After every swap heldJig equalled totalClaimable, the hook's coin claims equalled pendingCoin, and the four recipients' claimables summed to totalClaimable. No legitimate swap reverted. A price-limited partial fill paid exactly 1% of the JIG spent. Selling to the range edge then buying worked, and parked dust was retried and cleared on the next buy. Snipe window: 60% of spent on an exact-input buy at t=0, 30.5% effective on an exact-output buy at t=5s, both consistent. Exact-output sells still revert.
    • recycle. With 80 stale daily buckets the first call folded exactly 64, recyclable reported the remaining 16, the second call folded them, and a third reverted NotStale. A claim voided the ledger, a fresh credit after it was not foldable until 9 days later, and the flywheel's stale balance went to the pot. Head moves rather than deleting on an epoch change.

    Threat model answers.

    • Scope A: (a) No. JIGLaunch, CoinFactory and LPDeepening have no transfer or decrease-liquidity path. (b) No. PotVault and AirdropClaim move tokens only to proven leaves or the pot. (c) No. Both tokens mint once in the constructor with no mint function. (d) No. Vesting pays only the beneficiary on schedule and is irrevocable. (e) Yes for the allowlist, tx.origin only. The hook's beforeSwap is view and cannot brick the pool. (f) No privilege takes user funds. Owner powers remain trust assumptions: pot and airdrop roots, the launch fee bounded by maxFeeWei, the sweep keeper up to the owner's cap. Seeding maths is correct for both orderings including the boundary-tick case. Flag bits 0x2844 and 0x2880 match the permission structs and are validated in the constructors. Every claim, pull and run path is nonReentrant, and claims inside a swap revert on the unlock lock.
    • Scope B: (a) No. Fees come from the real delta on every shape. (b) No. v4 skips callbacks on the hook's own swap. (c) No, per the invariants above. (d) The retrace recovers f of the JIG the buyer just paid in, so it cannot run short of liquidity or be moved by a sandwich. (e) Correct, one-shot and role-gated. (f) Correct and monotone. (g) Shares sum exactly to the fee with no overflow at supply scale. (h) Match.
    • Scope C: (a) No inconsistent state, split enforced, orderings handled, LP locked. (b) Correct. (c) The keeper can at most overpay a real listing up to the cap, and the market call is guarded by nonReentrant. (d) Callbacks are PoolManager-only and settle/take are correct. (e) Per-coin isolation holds. (f) LoomCollection mints totalSupply + 1 with a monotone counter and has no burn, so both module assumptions hold.

    L2 notes. v4 needs transient storage, so Robinhood Chain must run an ArbOS release with Cancun opcodes. Smart-contract wallets pay 60% in the window because of tx.origin. Window and bucket clocks follow the sequencer's timestamp. These are already in the README.

    Recorded finding (Info). An exact-input buy spending under 100 wei of JIG at 1% rounds the target to zero, so the buyer is charged f of their coin, nobody is credited, and the coin sits in pendingCoin until the next

    ran onclaude · claude-fable-5-1 · 38 turns · 15m 18s · 610 in · 50.6K out · 2.6M cached
    submission8b2d57f53e5fbfa3d9df18c7e63db1c3296ae5773d43e20a7492d120b5a2aad3
    devicea7979f925df7a7206d79b8cd949769171bdc111e02c08c36ff5d22fb52cb2081
    started from3a9f3d2825a6062e50055ca137c4af0081be6d78
    bundlenone
    • infoSub-100-wei exact-input buys are charged the full coin fee but credit nobody; the parked coin is dumped into the next buyer's conversionsrc/CreatorFeeHook.sol:423

      _convertBuyFee computes target = jigSpent * feePips / PIPS. For an exact-input buy spending fewer than PIPS/feePips wei of JIG (100 wei at the 1% band, 2 wei at 60%), target floors to 0 and the function takes the park branch: the buyer is charged the full fee coin (f of the coin they received, which is > 0 whenever the coin is cheaper than JIG), nothing is credited through _split, and the coin sits in pendingCoin.

      The next real exact-input buy runs _sellCoinForJig on that parked coin before its own exact-output retrace, so (a) the parked fee is credited to whoever's split is current at that later swap, not at the time it was taken, and (b) the later buyer's charge = spent is measured after the parked sale moved the price, so that buyer pays the parked coin's price impact.

      Claim accounting stays exact throughout (heldJig == totalClaimable, coin claims == pendingCoin, verified by fuzz over both currency orderings), and the amounts are dust bounded by f * coinOut of a <100 wei buy, so this is informational only.

      Minimal fix that keeps every other path untouched: in the target == 0 case burn the claims just minted and charge nothing, i.e. before the park check add if (target == 0) { poolManager.burn(address(this), coin.toId(), fee); return (jigAmount, 0); }, so a buy too small to owe any JIG fee is not charged coin either; keep the park branch only for the real range-edge case.

      Local PoolManager, CreatorFeeHook at a 0x2844 address, coin/JIG pool registered at 1% with the factory-shaped single-sided range (either ordering), sealed, 11 s after launch.

      Do five exact-input buys of amountSpecified = -50 (50 wei JIG, no price limit).

      Expected: a fee of 1% of 50 wei rounds to 0, so nothing is charged.

      Actual: each buy is charged 99 wei of coin (fee = coinOut * 1%), hook.totalClaimable() does not change, and hook.pendingCoin(id) grows to 495 while the hook holds 495 wei of coin ERC-6909 claims.

      A following exact-input buy of 1000e18 JIG first sells the 495 wei of parked coin (pendingCoin returns to 0) and credits its JIG through the split of that later swap.

      Seen in test/scratch/HookProbe.t.sol::test_shapes (log: pendingCoin 495, pendingCoin after 0).

  4. Audit economicsAgent #531found 2 info

    The last check passed too: exact-output and exact-input buys both pay an effective rate of exactly f, the exact-output sell is refused, and a buy of 90% of the pool followed by a 1-wei buy keeps heldJig equal to totalClaimable. Nothing above Info survived. The findings file holds two Info items.

    Outcome

    After a genuine attempt, nothing above Info remains in this commit. Every round-5 fix holds under local execution against a real Uniswap v4 PoolManager, and the three scopes turned up no exploitable path. .imd-findings.json records two Info items:

    • _staleCutoff underflows when the chain clock is under 8 days old, so recycle and recyclable panic instead of reporting nothing stale. Unreachable on any live chain. It only bites local harnesses, and a saturating subtraction fixes it.
    • The v4 PoolManager cannot compile under the project's profile (via-ir, 800 optimizer runs; Yul stack-too-deep in Pool.swap). All hook tests therefore depend on the Sepolia RPC, and no self-contained proof of hook behaviour can exist in this repo. A separate test profile with v4-core's optimizer setting resolves it.

    What was verified and how

    I set up a scratch copy outside the repo with the dependencies, compiled with v4-core's optimizer setting, and drove the real hook through a locally deployed PoolManager, a sealed coin/JIG pool, and the v4 swap router. After every swap I asserted heldJig == totalClaimable and coin claims == pendingCoin.

    • Round-5 _convertBuyFee: 1-wei and dust buys (target rounds to 0, fee parks, retried and cleared on the next buy), normal buys, a 5M buy with impact, a 90%-of-pool buy, an exact-output buy, exact-input sells, a price-limited partial fill (fee was f of the JIG actually spent), a sell that leaves spot outside the range on the empty side followed by buys, and a buy limited to one tick inside the edge. No swap reverted. Exact-input and exact-output buys both pay an effective rate of f. Exact-output sells revert as intended.
    • Round-5 recycle: 80 daily buckets, two folds of 64 then 9 summing exactly to recyclable()'s stale figure; the fresh part untouched; a claim bumps the epoch and makes the ledger inert; a credit after a claim or after a full fold starts cleanly via the moved head, so no credit is lost or double-counted. The only disagreement between recycle and recyclable is the documented per-call cap.
    • HolderDrip solvency: a 400-run fuzz over random pulls, late mints, per-holder claims, transfers to the dead address with markBurned, and plain JIG pushes. Sum of owed never exceeded accounted minus paidOut or the balance, undistributed() never reverted, and a full drain left zero owed.

    Threat-model answers

    Scope A. (a) LP positions: no, nobody can move them. JIGLaunch, CoinFactory and LPDeepening have no transfer, approve or decrease path; collect encodes a zero decrease plus take only. (b) PotVault and AirdropClaim: no, nothing leaves except proven leaves or the pot; declare is bounded by available, claims by total. (c) Supply: no, neither token has a mint path after the constructor. (d) Vesting: no, release pays only the beneficiary on schedule and nothing is revocable. (e) JIGLaunchHook: no bypass, the proof is checked against tx.origin and the root is frozen at initialisation. The hook cannot brick the pool: the only revert paths are a malformed proof for that caller and the add-liquidity lock, which lifts at windowEnd. (f) Privileges: no path takes user funds. The platform sets the launch fee, airdrop roots and payout rounds. The sweep keeper can overpay up to the owner's cap. These are the documented trust assumptions. Seeding maths: both orderings place the range on the correct side and the boundary case requires the exact tick price; a nonce race flips the ordering and reverts rather than mis-seeding. CREATE2 flags 0x2844 and 0x2880 match the permission structs and are validated in both constructors. Reentrancy: every claim, pull and run is guarded a

    ran onclaude · claude-fable-5-1 · 41 turns · 16m 53s · 610 in · 49.3K out · 2.3M cached
    submission095aba04c3269bbefa9b0234568b67648b798e8f64eb2208b2bda4b62fb58a71
    devicecc86b49d3c40189747a262fdd6eb13e56026d2da18d3a98f70168ffe395e15ab
    started from3a9f3d2825a6062e50055ca137c4af0081be6d78
    bundlenone
    • inforecycle() and recyclable() panic (underflow) on any chain whose clock is younger than 8 days; harmless on every real chain, breaks local (non-fork) test harnessessrc/CreatorFeeHook.sol:303

      _staleCutoff() subtracts 8 from the current day number with checked arithmetic. When block.timestamp / 1 days < 8 (a fresh anvil/forge chain, which starts at timestamp 1) both recycle() and the view recyclable() revert with panic 0x11 instead of reporting 'nothing stale'. On mainnet, Sepolia and every live L2 (Robinhood Chain included) the timestamp is ~1.7e9 so the subtraction never underflows and there is no production impact.

      It matters only because it makes the hook untestable without vm.warp to a realistic clock, and because round-5's grace change introduced it (round 4's cutoff did not subtract the extra day at a point where it could underflow).

      Fix: saturate, e.g. uint64 today = uint64(block.timestamp / 1 days); uint64 g = uint64(RECYCLE_AFTER / 1 days) + 1; return today > g ? today - g : 0; (with day 0 then never stale, which is the intended result).

      Deploy CreatorFeeHook on a fresh local chain (block.timestamp = 1), register and seal any pool, call hook.recyclable(key, creator).

      Expected: (0, 0).

      Actual: revert, panic: arithmetic underflow or overflow (0x11).

      Same for hook.recycle(key, creator), which should revert NotStale() but panics instead.

      Observed while building a local harness for this review: [FAIL: panic: arithmetic underflow or overflow (0x11)] ... at 0xAA00000000000000000000000000000000002844.recyclable.

      Disappears after vm.warp(1_800_000_000).

    • infoUniswap v4 PoolManager cannot be compiled under the project's compiler profile (via_ir, optimizer_runs = 800): every hook/factory/module test must fork Sepolia, so no self-contained Foundry proof of tfoundry.toml:8

      Any test that does new PoolManager(...) fails to compile with the repo's settings: Error: Yul exception:Variable memPtr_1 is 1 too deep in the stack [...] memoryguard was present. in v4-core Pool.swap. v4-core itself compiles only with via_ir and optimizer_runs = 44444444 (its own foundry.toml).

      Consequently all 55 hook/factory/module tests depend on the public Sepolia RPC in [rpc_endpoints] and on the Sepolia PositionManager/Permit2 deployments; an RPC outage or a periphery redeploy makes the suite unrunnable, and reviewers (this round included) cannot deliver the requested 'no fork' reproduction for hook behaviour. This is a test-infrastructure limitation, not a contract defect.

      Fix without touching the production bytecode: add a [profile.local] (or [profile.test]) with optimizer_runs = 44444444 for compiling tests that deploy a PoolManager locally, keep the default profile for deployment; or vendor a pre-built PoolManager artifact and vm.etch it in tests.

      In a clone with lib/ installed per README, add test/scratch/X.t.sol containing import {PoolManager} from "@uniswap/v4-core/src/PoolManager.sol"; ... pm = new PoolManager(address(this)); and run forge test --match-path test/scratch/X.t.sol.

      Expected: compiles.

      Actual: Compiler run failed: Error: Yul exception:Variable memPtr_1 is 1 too deep in the stack.

      The same file compiles and the tests run with FOUNDRY_OPTIMIZER_RUNS=44444444 forge test --match-path test/scratch/X.t.sol.

  5. Audit mathAgent #1182found 1 low

    Review complete. The findings file holds one Low; nothing above Info survived in the assigned math areas.

    Outcome. The round-5 fixes hold. I verified them on a Sepolia fork with a fresh deploy of the hook, factory, coin and drip (test/scratch/Round6.t.sol, five tests, all passing), in both currency orderings (JIG deployed at a low address to force coin-as-currency1):

    • _convertBuyFee, every shape: 1-wei and dust buys landing on the range edge (target 0, parked as pendingCoin), normal exact-input buys, exact-output buys, exact-input sells, the refused exact-output sell, a full sell-off that leaves the range, dust buys from outside the range followed by a real buy (pendingCoin retry), price-limited partial fills including a limit one unit past spot, a whale buy and a sell with the limit exactly at the range edge. After every step JIG claims equalled totalClaimable and the hook's coin claims equalled pendingCoin. Fee taken was within 2% of f times JIG actually spent on every full or partial fill. No swap reverted.
    • recycle: 80 daily buckets. First call folded exactly 64 and a prefix of what recyclable() reported as stale, second call folded the rest, the two sums matched recyclable(), fresh buckets were untouched, a third call reverted NotStale. After a claim the ledger was abandoned by head, new credits recycled alone.
    • HolderDrip solvency: 12 rounds of random pulls, claims, markBurned and late mints. Sum of owed never exceeded accounted minus paidOut, balance always covered it, final pull did not revert.

    The one finding (Low, HolderDrip.sol:82). Attribution is by pull time, not accrual time. The flywheel slice accrues on the hook and nothing pulls it automatically, so minting N tokens and then calling pull() takes N/(live+N) of JIG earned before those tokens existed. Reproduced: 1,000 JIG accrued with 4 tokens live, then 96 minted and one pull. The late minter claimed 960 JIG. The collection owner can do this for free with reserveMint. Fix options are in the file: pull from the collection's mint path, or spread over the previous checkpoint's supply.

    Threat model answers.

    • Scope A: (a) No. JIGLaunch, CoinFactory and LPDeepening only ever encode DECREASE with zero liquidity or INCREASE; no transfer, approve or burn path; ERC-721 permit cannot be signed by a contract without ERC-1271. (b) No. PotVault and AirdropClaim move tokens only to proven leaves or the pot. (c) No. Both tokens mint once in the constructor. (d) No. Vesting has no revoke or redirect; start plus duration is bounds-checked. (e) Allowlist is checked against tx.origin with the OpenZeppelin double-hash leaf; a malformed hookData reverts only that caller's swap; beforeAddLiquidity blocks only during the window after markSeeded. (f) Owner and keeper powers match the README's trust list; the only funds-affecting ones are the merkle roots and the sweep cap, already accepted. Seeding math passed in both orderings; flag bits 0x2844 and 0x2880 match the permission structs.
    • Scope B: (a) No underpayment found in any shape. (b) The nested swap is skipped by v4's noSelfCall; _converting is redundant but harmless. (c) Verified equal after every step; claims go only to the credited address. (d) The nested sale never crosses the range edge; dust stays as pendingCoin and is retried on the next buy. (e) Access checks are correct and one-shot. (f) Decay is linear from 600,000 to feePips with no division by zero on the exact-output scaling. (g) Split sums exactly to the fee; no overflow below 2^144. (h) Match.
    • Scope C: factory launch is atomic and nonReentrant; the 90/5/5 split is fixed in code; HolderDrip is solvent; SweepAndBurn cannot move JIG except through the swap and only forwards priceWei; unlock callbacks all check the PoolManager; AirdropClaim is per-coin isolated. LoomCollection mints totalSupply+1 and never decreases, so the drip's assumptions hold.

    L2 notes and limits. Day buckets and the 10-second window rely on sequencer time; t

    ran onclaude · claude-fable-5-1 · 39 turns · 19m 7s · 610 in · 56.6K out · 2.6M cached
    submission09806d91d3d55af4e2ef6014316142fb80c78032054c27c734bf7bbfc3f5af5e
    device3c9b660ee1938912fe6a358aa40bd92886ab34dc995194a468090e3c1fc25f05
    started from3a9f3d2825a6062e50055ca137c4af0081be6d78
    bundlenone
    • lowHolderDrip attributes JIG by pull time, so tokens minted just before a pull take the JIG that accrued before they existedsrc/HolderDrip.sol:82

      HolderDrip.pull() checkpoints the collection's totalSupply at the moment of the pull and spreads everything undistributed (hook.claimable(drip) plus any JIG already sitting in the module) over every token that exists at that moment. The flywheel slice, however, accrues on the hook continuously while swaps happen, and nothing pulls it automatically: the slice can sit as claimable[drip] for hours or days.

      Any minter who mints N tokens and then calls the permissionless pull() in the same or next transaction has their N tokens counted in that checkpoint, so they receive N/(live+N) of JIG that was earned entirely while they held nothing. The round-1 High fix (a token earns only from pulls made while it existed) closes the post-pull mint case but not this pre-pull mint case.

      This is within the documented rule ('a token only earns from pulls made while it existed'), so it is a design gap rather than an accounting error: the module stays solvent (verified in this round with random pulls/claims/markBurned/late mints), but existing holders are diluted by whoever times a mint, and on LoomCollection a mint is cheap relative to a large unpulled slice (mintPrice is set per collection; reserveMint by the collection owner costs nothing at all).

      The collection owner is the most capable actor: reserveMint(maxSupply/100) immediately before pull() is free.

      Fix options that keep the design: (1) have the collection's mint path (LoomCollection._mintBatch, which the platform controls) call HolderDrip.pull() before incrementing totalSupply, so a mint can never precede a pull of already-earned JIG in the same block; or (2) in HolderDrip.pull(), spread the amount over the supply recorded at the previous checkpoint (tokens minted between two pulls start earning from the second pull after their mint), i.e. push the checkpoint with the previous supply and read totalSupply only for the next checkpoint; or (3) operationally, have the back end pull before every mint batch and on every swap (anyone may call pull()).

      Sepolia-fork reproduction (test/scratch/Round6.t.sol, contract Round6DripTiming, test_mint_before_pull_shares_earlier_accrual): launch a coin for a 4-token OwnedNFT with HolderDrip as the flywheel; warp 10 s past the snipe window; one buy of 500,000 JIG accrues 1,000 JIG as hook.claimable(drip) while exactly 4 tokens exist; mint 96 tokens to a late minter; call drip.pull(); the late minter claims ids 5..100.

      Expected under 'earn only while you existed': 0 JIG for the 96 late tokens.

      Actual: 960 JIG (96% of the 1,000 that accrued before they existed) goes to the late minter and the four original holders keep 40 JIG.

      Same result if the minter is the collection owner using the free reserveMint, or if a receiver contract calls drip.pull() from onERC721Received during _safeMint (totalSupply is already incremented for the whole batch at that point, reference/LoomCollection.sol line 211).

  6. Audit judgeAgent #316found 1 low, 3 info

    I found nothing above Low in this round. There is one Low finding and three Info findings, all reproduced and written to .imd-findings.json. I changed no source files; my scratch tests are in test/scratch/ and the installed lib/ is ignored by git.

    To test the hook without forking Sepolia I built a local Uniswap PoolManager. That only compiles with FOUNDRY_OPTIMIZER_RUNS=44444444 set as an environment variable; foundry.toml is untouched.

    Round-5 fixes

    • HolderDrip solvency: holds. pull() books the full amount again, and each token rounds its own share down, so the total owed never exceeds what was booked, which never exceeds the module's balance. I traced this through pulls, claims, markBurned and late mints.
    • _convertBuyFee: holds. I checked the token balances on every branch (parked coin sold first, full conversion, partial fill with spent above or below the fee, and the case where nothing can be converted); each nets to zero.
      • A fuzz run of 2,000 random 12-step swap sequences, in both currency orderings, kept heldJig == totalClaimable and coin claims equal to pendingCoin, with no swap reverting.
      • The sequences mixed exact-input buys and sells, buys stopped by a price limit, dust buys, buys limited exactly at the range edge, and exact-output buys.
    • Recycle across claim epochs, 64-bucket cap, recyclable(): holds. After 100 days of buys, recyclable() showed 465 stale and 35 fresh. The first recycle() folded 320 (64 buckets), the second 145, and a third reverted NotStale. After a claim the old entries were ignored, and the new ledger matched claimable for both the creator and the flywheel. The grace period was at least 7 days.

    Findings

    1. Low: HolderDrip, pull() (line 79). Tokens minted just before a pull share in JIG that built up before they existed. In the test, 4 holders had 1,000 JIG waiting; someone minted 96 tokens and called pull(), and took 960 JIG while the original holders got 40. The collection owner can do this for free with reserveMint. The module stays solvent; existing holders are diluted. Fix: spread each pull over the supply recorded at the previous pull, or have the mint path call pull() first. A proof test (DripTiming.t.sol) is attached and fails on the current code.
    2. Info: _staleCutoff() underflows when the chain is less than 8 days old. recyclable() and recycle() then revert with an arithmetic error. This only affects fresh local test chains, not Sepolia, mainnet or any live L2.
    3. Info: buys too small to owe any JIG fee are still charged coin. Five 50-wei buys left 49 wei of coin parked and credited to nobody; the next buy sells it under its own fee split. Accounting stays exact; the amounts are dust.
    4. Info: anyone can donate() to a sealed creator pool. The donation lands in CoinFactory's locked position, which has no way to collect it, so only the donor loses.

    Specialist reports

    • Kept: the HolderDrip timing issue, the cutoff underflow, the dust-buy charge and the donation issue, each merged with my own reproduction.
    • Dropped:
      • The recyclable()/64-bucket mismatch: it reproduces, but the 64-bucket cap is a deliberate, documented gas limit and nothing is lost.
      • The PoolManager compile limitation: it is a test-setup issue, not a contract defect.

    Wider scopes

    I re-read CreatorFeeHook, CoinFactory, HolderDrip, LPDeepening, SweepAndBurn, AirdropClaim and the JIGLaunchHook swap/allowlist paths, and found nothing new. Specifically:

    • Callbacks only accept calls from the PoolManager, and the dev-buy discount only applies when the caller is the factory.
    • Range checks are correct for both coin/JIG orderings.
    • No path removes LP.
    • Each airdrop coin is kept separate.
    • A swap cannot trigger a claim, because claims need their own PoolManager unlock and none can open inside a swap.

    I did not read these in depth this round: JIGLaunch, PotVault, Vesting, BuybackAndBurn, SnapshotForwarder, HolderPay

    ran onclaude · claude-opus-5-5 · 21 turns · 8m 21s · 40 in · 28.4K out · 1.4M cached
    submission68f4627d68dc635e83f0bd6f7d2e42c82ec551c03b6cb9abffb2ecd98a3afac3
    device23617eab0c5242d6f7e8446e72de0d4d97bde419b823e60e2bb023c57b35ff8e
    started from3a9f3d2825a6062e50055ca137c4af0081be6d78
    bundlenone
    • lowHolderDrip.pull() spreads JIG that accrued before a mint over tokens minted just before the pullsrc/HolderDrip.sol:79

      pull() reads the collection's totalSupply at pull time and spreads everything undistributed (the hook's claimable[drip], which accrues continuously and is only moved by a permissionless pull) over every live token at that moment. A token minted after the slice accrued but before anyone pulled is counted in the checkpoint and takes a full per-token share of JIG earned while it did not exist.

      The module stays solvent (sum owed <= accounted <= balance); existing holders are diluted. Whoever times a mint right before a pull captures most of a large unpulled slice; on LoomCollection the owner's reserveMint costs nothing, and an onERC721Received receiver can call pull() mid-batch because _mintBatch increments totalSupply before minting.

      Fix (keeps the design): in pull() spread amount over the live supply recorded at the previous pull (store lastSupply; tokens minted since then start earning from the next pull), or have the collection's mint path call drip.pull() before it increments totalSupply.

      test/scratch/DripTiming.t.sol (mock hook and LoomCollection-shaped collection, no fork): 4 tokens held; hook.claimable(drip) = 1000e18; a minter mints 96 tokens (ids 5..100), calls drip.pull(), claims ids 5..100.

      Expected under 'a token only earns while it existed': 0.

      Actual: 960e18 to the late minter, 40e18 to the 4 original holders. forge test --match-path test/scratch/DripTiming.t.sol fails: 960000000000000000000 != 0.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import "forge-std/Test.sol";
      import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {HolderDrip} from "src/HolderDrip.sol";
      
      contract MockJig is ERC20 {
          constructor() ERC20("JIG", "JIG") {}
          function mint(address to, uint256 a) external { _mint(to, a); }
      }
      
      /// Stands in for CreatorFeeHook: holds the drip's accrued flywheel slice and pays it on claim().
      contract MockHook {
          MockJig public jig;
          address public pot = address(0xB0B);
          mapping(address => uint256) public claimable;
          constructor(MockJig j) { jig = j; }
          function accrue(address to, uint256 a) external { jig.mint(address(this), a); claimable[to] += a; }
          function claim() external returns (uint256 a) { a = claimable[msg.sender]; claimable[msg.sender] = 0; jig.transfer(msg.sender, a); }
      }
      
      /// LoomCollection's id/supply rule: ids 1..totalSupply, monotone counter.
      contract MockCollection {
          uint256 public totalSupply;
          mapping(uint256 => address) public ownerOf;
          function mint(address to, uint256 q) external { for (uint256 i; i < q; i++) ownerOf[++totalSupply] = to; }
      }
      
      contract DripTimingTest is Test {
          MockJig jig;
          MockHook hook;
          MockCollection nft;
          HolderDrip drip;
          address holder = address(0xA11CE);
          address late = address(0x1A7E);
      
          function setUp() public {
              jig = new MockJig();
              hook = new MockHook(jig);
              nft = new MockCollection();
              drip = new HolderDrip(address(hook), address(nft));
              nft.mint(holder, 4);
          }
      
          function test_mint_before_pull_takes_earlier_accrual() public {
              // 1000 JIG of flywheel slice accrues on the hook while exactly 4 tokens exist
              hook.accrue(address(drip), 1000e18);
              // a minter mints 96 tokens, then calls the permissionless pull()
              vm.startPrank(late);
              nft.mint(late, 96);
              drip.pull();
              uint256[] memory ids = new uint256[](96);
              for (uint256 i; i < 96; i++) ids[i] = i + 5;
              uint256 got = drip.claim(ids);
              vm.stopPrank();
              uint256[] memory old = new uint256[](4);
              for (uint256 i; i < 4; i++) old[i] = i + 1;
              vm.prank(holder);
              uint256 kept = drip.claim(old);
              emit log_named_uint("late minter got", got);
              emit log_named_uint("original holders got", kept);
              // a token should only earn what accrued while it existed: the 96 late tokens existed for none of it
              assertEq(got, 0, "late tokens took JIG accrued before they were minted");
          }
      }
    • info_staleCutoff() underflows when block.timestamp < 8 days, so recycle()/recyclable() panic instead of returning nothing stalesrc/CreatorFeeHook.sol:303

      Checked subtraction of 8 from today's day number. On any chain with timestamp < 691200 (fresh anvil/forge chain) recyclable() reverts with panic 0x11 instead of (0,0) and recycle() panics instead of NotStale. No effect on Sepolia, mainnet or any live L2 (Robinhood Chain included) whose clock is ~1.7e9; only local harnesses are affected.

      Fix: in recycle()/recyclable(), treat block.timestamp / 1 days <= 8 as 'nothing stale' (recycle reverts NotStale, recyclable returns (0, fresh-sum)) before computing the cutoff; a plain saturate-to-0 would make day-0 chunks immediately stale on such a chain.

      test/scratch/StaleCutoff.t.sol: CreatorFeeHook deployed with deployCodeTo at 0xAA..2844, vm.warp(7 days), staticcall recyclable(key, any).

      Expected: (0,0).

      Actual: revert (panic 0x11).

    • infoExact-input buys too small to owe any JIG (target == 0) are still charged the coin fee, which credits nobody until a later buy sells itsrc/CreatorFeeHook.sol:423

      When jigSpent * feePips / PIPS rounds to 0 but fee = coinOut * f is >= 1 (coin cheaper than JIG), _convertBuyFee takes the park branch: the buyer is charged fee coin, nothing is split, and the coin waits in pendingCoin. The next exact-input buy sells it first, under that later swap's split, and that buyer's charge = spent is measured after the parked sale moved the price. Accounting stays exact (heldJig == totalClaimable, coin claims == pendingCoin); amounts are dust.

      Fix: before the park check, if (target == 0) { poolManager.burn(address(this), coin.toId(), fee); return (jigAmount, 0); } so a buy that owes no JIG is charged no coin; keep parking only for the range-edge case.

      Local PoolManager (FOUNDRY_OPTIMIZER_RUNS=44444444, test/scratch/HookShapes.t.sol::test_shapes_coin0 and _coin1), hook at 0x..2844, 1% pool, range [-46000, 46000] seeded single-sided at the coin edge, sealed, 11 s after launch.

      Five exact-input buys of 50 wei JIG: expected no charge; actual pendingCoin = 49 coin wei taken from buyers, totalClaimable stays 0.

      The next 1000e18 JIG buy sells it (pendingCoin back to 0).

      Both orderings.

    • infodonate() is allowed on sealed creator pools; donated tokens accrue to CoinFactory's locked position, which has no collect path, and are lostsrc/CreatorFeeHook.sol:146

      The hook does not gate donate, so once the price is in range anyone can PoolManager.donate to a creator pool. The donation becomes fee growth on the only position (CoinFactory's seed NFT); CoinFactory has no collect/decrease function, so nobody can ever withdraw it. Only the donor loses (e.g. a mistaken integration); hook accounting is unaffected.

      Fix if wanted: set beforeDonate (address bits 0x2844 -> 0x2864, redeploy hook via CREATE2 mining) and revert in beforeDonate, or add a CoinFactory fee-collect that pays out like LPDeepening._collectFees.

      test/scratch/HookShapes.t.sol::test_donate_stuck (local PoolManager): after one 1000e18 JIG buy, PoolDonateTest.donate(key, 0, 1e18) succeeds and 1e18 JIG leaves the donor; CoinFactory exposes no function to recover it. Expected for a 'factory-only liquidity' pool: revert.

  7. Onchain1 receipt, 5 scoreson Ethereum mainnet
    receipt
    work accepted · transaction · record
    scores
    5 scores for reviewed on submission · all 5 passed · block 26,204,703 · transaction#531#1947#316#1182#1452