{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"c68a3cd2-9cbd-48d9-998e-757eff7d6aa7","kind":"audit","nodes":[{"acceptedSubmissionHash":"01816e4177b3fce5d8d2065a432b927931335140df9d263e0167d3b3290a8d54","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"76e7b6f0c5ede828510003c68bd89fc2914895c13ac957edb3cb266754f9c95d","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"47a492775689ecc52062cc35cf39f860bb9920f9c1cdc72aee72043fb8b339b5","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"44598576dcc126b76cb685f184ebc92685335cdf2b86874e59703f62fcde24ed","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"8870e7914933fa01f87aee270dfc34cd788317e5946c7f0a9e0721c40b74cc8c","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"The code is in the public repository https://github.com/JJ-ME55/jigsaw-contracts-audit: Solidity in src/ (Foundry; tests in test/, deploy scripts in script/, README.md describes every contract and every earlier round, including round 8's table), off-chain money paths in offchain/ (offchain/README.md). Round 9 is the check on round 8's fixes before a mainnet launch on Robinhood Chain (chain 4663). Round 8 (swarm-audit-report-8.md) found 1 high, 1 medium, 3 low, 5 info. VERIFY EACH FIX HOLDS AND HUNT FOR WHAT IT INTRODUCED (in earlier rounds the fixes themselves caused new issues):\n(1) High 1 fix: CoinFactory._excluded() now adds IAirdropPot(airdropRecipient).pot() as a fourth excluded address. Check the constructor's pot() probe and every launch path; any way the pot, or another protocol address that can never call CreatorCoin.claim() (AirdropClaim, the hook, flywheel modules such as HolderDrip, BuybackAndBurn, LPDeepening, RewardsForwarder, SweepAndBurn, PotVault), still earns rewards nobody can claim; what happens to rewards credited before an address is excluded.\n(2) Medium 2 fix: AirdropClaim.claimFor skips owners with code (owner.code.length != 0). Check constructor-time (code.length == 0 during construction) and EIP-7702 delegated EOAs, counterfactual smart wallets, griefing via a contract that later self-destructs or changes code, interaction with claim() and sweep(), and the off-chain side (offchain/rewards.mjs pushableIds now returns contractHeld; the keeper must never loop on them).\n(3) Low 3 fix: JigsawCollection.initialize reverts NothingToFreeze when freeze is set with an empty uri. (4) Low 5 fix: JigsawCollection.recoverOwnership(newOwner), callable only by Ownable(factory).owner() where factory is the msg.sender of initialize: check the factory address can never be spoofed (initialize is called only by JigsawCollectionFactory.create in the clone's creating transaction), what a compromised factory owner can now do to every clone, and Ownable2Step pending state.\n(5) Info 7 fix: CollectionLauncher.create reverts NothingToFreeze for freeze with revealLater. (6) Info 10 fix: RewardsForwarder.pull() with no new JIG calls coin.notifyReward(0) when the coin's carry > 0 and rewards are not paused: check it cannot restart or stretch a running stream (MIN_INTERVAL == DURATION), revert paths that would block pull(), and paused state.\n(7) Accepted (round 8 Low 4, Info 6, Info 9; reasons in README): say if you disagree.\nReport every finding with severity, file and line, a concrete exploit path and a minimal fix, and what you checked and cleared.","parentJobId":null,"planHash":"e1642d8043efb722183470c5de21536c0415d2bcf947dc110d5976523a33ce5d","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"c68a3cd2-9cbd-48d9-998e-757eff7d6aa7","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52138","feedbackHash":"a0e706acb71fac47cdaae3d3c11a1bb2df46a17e822c9f30154a7e7298ebba20","nodeKey":"audit_economics","submissionHash":"01816e4177b3fce5d8d2065a432b927931335140df9d263e0167d3b3290a8d54","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52170","feedbackHash":"f2c6f5fd292492078b63271e3b78bb9538c078e1b3a8767391a58f67c5e99b28","nodeKey":"audit_flow","submissionHash":"76e7b6f0c5ede828510003c68bd89fc2914895c13ac957edb3cb266754f9c95d","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52011","feedbackHash":"aae55a28129742eace3b03835c1b14cc411697b8402ad622d1e0be25f5446940","nodeKey":"audit_judge","submissionHash":"47a492775689ecc52062cc35cf39f860bb9920f9c1cdc72aee72043fb8b339b5","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52161","feedbackHash":"607191df2ef680aa8e086b7c372e85af95f84ae08db968b72ceabd6656823a81","nodeKey":"audit_math","submissionHash":"44598576dcc126b76cb685f184ebc92685335cdf2b86874e59703f62fcde24ed","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"53929","feedbackHash":"9899600efa0e84484b48d82658eef23251fe93c270c821630339958913ad84c1","nodeKey":"audit_permissions","submissionHash":"8870e7914933fa01f87aee270dfc34cd788317e5946c7f0a9e0721c40b74cc8c","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"d651d8c801190bf0d30997e81470b26c1317cb77affad4a2936487ced75e3528","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"62491201fe455e89","findings":[{"citation":"resolved","description":"The round 8 fix appends IAirdropPot(airdropRecipient).pot() to _excluded(). The constructor only probes pot()/factory() when airdropRecipient_ has code (line 110) and launch() only calls notify() when it has code (line 168): an EOA recipient is an accepted configuration. But _excluded() calls pot() unconditionally as a high-level call, and on an address with no code the ABI decoder finds no return data and reverts. _excluded() runs inside launch() (line 139, the coin constructor args) and inside coinInitCodeHash()/predictCoin() (line 264), so such a factory can never launch a coin and the vanity grinder (offchain/vanity.mjs reads coinInitCodeHash) fails too. airdropRecipient is immutable, and CreatorFeeHook.setFactory is once-only, so a factory deployed this way also wastes the hook it was bound to. Mainnet (script/go-day.sh) passes AirdropClaim, so the live plan is not affected; the defect is that an input the contract accepts yields a dead factory, and the existing test test_factory_refuses_an_airdrop_claim_bound_to_another_factory shows the team relies on the constructor to refuse recipients that would brick launches. Minimal fix: either refuse an EOA recipient in the constructor (require airdropRecipient_.code.length > 0), or guard the read: if (airdropRecipient.code.length == 0) return a 3-entry list (or e[3] = airdropRecipient) in _excluded(). Checked and cleared on the same fix: coinInitCodeHash() uses _excluded() so the grinder stays in step with launch(); AirdropClaim.pot is immutable so the excluded set cannot drift between prediction and launch; PotVault, AirdropClaim, the hook, the pool manager, the factory, DEAD and the coin are the only protocol addresses that hold coin across transactions (BuybackAndBurn takes and burns within one transaction and earns 0 seconds of stream; LPDeepening, SweepAndBurn, HolderDrip and RewardsForwarder hold only JIG/ETH/NFTs; the position manager settles coin straight into the pool manager); exclusion is fixed at construction so no reward can be credited to an address before it is excluded.","line":251,"path":"src/CoinFactory.sol","reproduction":"Input: new CoinFactory(posm, permit2, hook, jig, platform, makeAddr(\"eoaAirdrop\"), 0) succeeds (the EOA passes the constructor). Then predictCoin(col, \"Coin\", \"C\", bytes32(bytes20(col))) and launch({collection: col, creatorBps: 500, flywheel: 0, salt: bytes32(bytes20(col)), ...}) from col.creator(). Expected: both succeed, the EOA receives 50,000,000e18 coin without notify(). Actual: both revert (empty return data from pot() on an address with no code). test/scratch/EoaAirdropRecipient.t.sol (passes on this code, demonstrating the reverts with vm.expectRevert on both calls); every launch through such a factory reverts for good.","severity":"low","snippet":"        e[3] = IAirdropPot(airdropRecipient).pot();","title":"Round 8 High 1 fix: _excluded() makes a high-level pot() call, so a CoinFactory deployed with an EOA airdrop recipient (which the constructor and launch() explicitly allow) reverts on every launch(), "},{"citation":"resolved","description":"Round 8 Low 3 made JigsawCollection.initialize revert NothingToFreeze for freeze with an empty uri (src/JigsawCollection.sol:146). The Loom path takes the same (uri, revealLater, freeze) triple from the same operator back end, and round 8 Info 7 aligned it only for freeze && revealLater (line 72). With freeze = true, revealLater = false and uri = \"\", create() calls setBaseURI(\"\") then freezeMetadata(); reference/LoomCollection.sol:177 freezes whatever is set (its doc says an owner may intentionally freeze an empty URI, and it has no NothingToFreeze guard). The clone is then metadataFrozen with baseURI == \"\" and unrevealedURI == \"\": setBaseURI/setUnrevealedURI/setRevealSeed revert MetadataIsFrozen and tokenURI(id) is \"\" for every token for good, while minting stays open. Operator-only input, same class as round 8 Low 3, and the two launch paths disagree again. Minimal fix: in create(), `if (freeze && (revealLater || bytes(uri).length == 0)) revert NothingToFreeze();`.","line":79,"path":"src/CollectionLauncher.sol","reproduction":"State: CollectionLauncher bound to a Loom factory that clones reference/LoomCollection.sol and makes the launcher the owner (test/scratch/LoomFreezeEmptyUri.t.sol, MockLoomFactory). Input: operator calls create({name:\"N\",symbol:\"S\",maxSupply:100,mintPriceWei:0,maxPerWallet:10,creator,royaltyBps:500}, bytes32(1), \"\", false, true). Expected (as JigsawCollectionFactory.create with the same arguments): revert NothingToFreeze. Actual: create succeeds; after acceptOwnership, metadataFrozen() == true, baseURI() == \"\", setBaseURI(\"ipfs://x/\") reverts MetadataIsFrozen, and after mint{value: 0.0005 ether}(1) tokenURI(1) == \"\". The scratch test passes on this code, demonstrating the stuck state.","severity":"low","snippet":"        if (freeze && !revealLater) ILoomCollectionOwner(collection).freezeMetadata();","title":"Round 8 Low 3 was fixed on the phased path only: CollectionLauncher.create still freezes a Loom collection on an empty base URI (freeze = true, uri = \"\"), leaving every token with no metadata forever"},{"citation":"resolved","description":"Round 8 Medium 2 makes claimFor skip owners with code and rewards.pushableIds split those ids into contractHeld, which the keeper correctly never queues (checked: contractHeld is not read anywhere, and ids excludes them, so there is no resend loop on contract-held ids). The new gap is the completion rule: when the collection is complete (every id minted) and ids is empty, airdropDone[coin] is set permanently even though contractHeld may be non-empty. An id listed on an escrow marketplace or parked in a vault at that moment becomes pushable the moment it returns to an EOA wallet (claimFor would then push it), but the keeper never looks at the coin again. The holder keeps the self-claim path (claim() within the 30-day window; the site shows selfClaim only for the current owner having code, so a holder who delisted sees the claim button) and nothing is lost, so this is a delivery gap, not a loss. Fix: set airdropDone only when complete && ids.length === 0 && contractHeld.length === 0, or re-check contractHeld coins at a slower cadence until the window closes.","line":166,"path":"offchain/keeper.mjs","reproduction":"State: collection maxSupply 10, all 10 minted; ids 1..9 pushed; id 7 sits in an escrow contract. plan(): pushableIds returns { complete: true, ids: [], contractHeld: [7] } so line 166 sets airdropDone[coin] = true. Day 5: the escrow returns #7 to Alice's EOA. Expected: the keeper's next pass pushes #7 (claimFor(coin,[7]) would now transfer perNft to Alice). Actual: line 164's `!st.airdropDone?.[coin]` is false for the rest of the window; #7 is only paid if Alice calls claim() herself; after day 30 its share is swept to the pot.","severity":"info","snippet":"        if (!pa.open || (pa.complete && pa.ids.length === 0)) { (st.airdropDone ??= {})[coin] = true; return; }","title":"Keeper marks a coin's push airdrop done for good once the collection is complete and only contract-held ids remain, so an NFT that leaves an escrow or vault later in the 30-day window is never pushed"},{"citation":"resolved","description":"The code check that keeps the keeper from looping on contract-held ids fails open: `.catch(() => '0x')` classifies an owner whose getCode call errored as an EOA. The on-chain guard (AirdropClaim.claimFor line 112) still skips the id, so no coin is pushed into a contract, but claimFor returns normally with pushed == 0, the keeper's simulation at keeper.mjs line 300 passes, the transaction succeeds, onSuccess schedules the next batch 3-6 minutes later, and the same ids are sent again while getCode keeps failing (bounded by KEEPER_AIRDROP_TX_PER_DAY = 300 per coin). Each no-op costs the operating wallet gas for a batch that moves nothing, and airdropSpentEth grows, which delays the pay-as-earned gate for real pushes. Fix: fail closed (treat an unknown code status as contract-held for this pass, or skip the batch when any getCode fails), as kindOf() already does for module probes (returns 'unknown' instead of mislabelling).","line":99,"path":"offchain/rewards.mjs","reproduction":"State: a coin whose only unpushed ids are held by contracts (e.g. ids 3 and 8 in a marketplace escrow). Input: the RPC rejects eth_getCode for a pass (rate limit or outage) while ownerOf/claimed multicalls succeed. Expected: nothing is sent (no pushable id). Actual: code.get(owner) is false for both, ids = [3, 8], the keeper sends claimFor(coin, [3, 8]); on chain both are skipped at line 112, Pushed(coin, 0, 0) is emitted, the receipt is a success, and the next pass 3-6 minutes later repeats it until getCode works again.","severity":"info","snippet":"  for (const o of new Set(open.map((id) => owners[id]))) code.set(o, ((await p.getCode({ address: o }).catch(() => '0x')) ?? '0x') !== '0x');","title":"pushableIds treats an RPC failure of getCode as 'no code', so during an RPC outage contract-held ids re-enter the push batch and the keeper sends successful no-op claimFor transactions every 3-6 minut"},{"citation":"resolved","description":"Checked and cleared: the else branch cannot restart or stretch a running stream (the only notifier is the forwarder, the only notify is inside pull(), and MIN_INTERVAL == DURATION, so periodFinish <= lastPull + 1 day <= the next allowed pull; at exactly lastPull + DURATION `block.timestamp < periodFinish` is false and nothing is folded); it cannot block pull() (JIG is a plain OZ ERC20, transferFrom of 0 succeeds with zero allowance; the branch is skipped while paused and when no coin exists; hook.claim() never reverts for a zero balance); and it correctly folds carry into a fresh one-day stream when holders arrive later. The remaining nit: CreatorCoin.notifyReward sets carry = total - rewardRate * DURATION, i.e. total mod 86400, which is non-zero after practically every stream (< 86,400 wei of JIG). So for a coin with no fees the daily keeper pull calls notifyReward(0) with dust: rewardRate becomes 0, periodFinish is pushed to now + 1 day, and RewardAdded(0, 0, periodFinish) is emitted. offchain/rewards.mjs rewardsOf() then reports a live stream (endsAt in the future) with jigPerDay 0. No funds move and nothing is lost; the extra call costs the keeper ~30k gas per idle day per coin. Fix if wanted: only re-stream when carry >= DURATION (so the rate is non-zero), or skip the branch when the coin's eligibleSupply() is 0.","line":68,"path":"src/RewardsForwarder.sol","reproduction":"State: a coin that streamed 1,000e18 JIG once (carry becomes 1,000e18 mod 86,400 = 46,720 wei) and whose hook claimable for the forwarder is 0. Input: anyone calls pull() a day later. Expected per the fix's intent: nothing to re-stream (the dust cannot form a stream). Actual: jig.balanceOf == 0 so the else branch runs, carry() == 46,720 > 0, notifyReward(0) is called: rewardRate = 46,720 / 86,400 = 0, carry stays 46,720, periodFinish = now + 1 day, RewardAdded(0, 0, now + 1 day) is emitted; this repeats on every daily pull with no fees.","severity":"info","snippet":"            if (a != address(0) && CreatorCoin(a).carry() > 0 && !CreatorCoin(a).rewardsPaused()) CreatorCoin(a).notifyReward(0);","title":"Round 8 Info 10 fix: with no new JIG, pull() calls notifyReward(0) whenever carry > 0, which after any stream is almost always true (carry holds the division dust), so every empty daily pull emits Rew"}],"hash":"01816e4177b3fce5d8d2065a432b927931335140df9d263e0167d3b3290a8d54","nodeId":"455fbe3d-15a7-44e6-91ae-d37cdd112b3b","outcome":"completed","summary":"Findings file is valid and every snippet matches its line. Final recap:\n\n**Round 9 result: no high or medium. Two low findings, three info, all in `.imd-findings.json` with reproductions; two scratch tests under `test/scratch/` demonstrate the lows on this code.**\n\nFindings:\n- **Low, `src/CoinFactory.sol:251`.** The High 1 fix reads `pot()` with a high-level call. The constructor and `launch()` still accept an EOA airdrop recipient, but for one, `_excluded()` reverts on empty return data, so every launch, `predictCoin` and `coinInitCodeHash` revert. The factory and its once-only hook binding are wasted. Mainnet passes AirdropClaim, so the live plan is safe. Fix: refuse code-less recipients in the constructor, or skip the pot read when the recipient has no code.\n- **Low, `src/CollectionLauncher.sol:79`.** Low 3 was fixed only on the phased path. The Loom path still accepts freeze with an empty URI, and LoomCollection freezes it by design. Every token stays on an empty URI forever while minting is open. Fix: the same guard as JigsawCollection.initialize.\n- **Info, `offchain/keeper.mjs:166`.** With only contract-held ids left on a complete collection, the keeper marks the airdrop done for good. An NFT that leaves escrow later in the window is never pushed. Nothing is lost since the holder can still claim.\n- **Info, `offchain/rewards.mjs:99`.** A failed `getCode` is treated as an EOA. During an RPC outage the keeper resends successful no-op batches every few minutes, bounded by the daily cap.\n- **Info, `src/RewardsForwarder.sol:68`.** After any stream the carry holds division dust, so every idle daily pull calls `notifyReward(0)`, emits a zero reward event and restarts a zero-rate period. Harmless.\n\nVerified and cleared:\n- **Pot exclusion** holds on the only launch path. The grinder reads the contract's own init code hash, so it stays in step. No other protocol address holds coin across transactions: BuybackAndBurn takes and burns in one transaction, the other modules hold JIG, ETH or NFTs, and the position manager settles coin straight into the pool manager. Exclusion is fixed at construction, so no reward can be credited before an address is excluded.\n- **claimFor code check.** A contract under construction can only push to itself, which equals calling claim. EIP-7702 delegated EOAs and the off-chain `getCode` check agree. Post-Cancun self-destruct cannot change code across transactions. Counterfactual wallets receive the push and can move it after deployment. The keeper never queues `contractHeld` ids.\n- **Freeze guards** on initialize and the launcher's reveal-later case hold.\n- **recoverOwnership.** The factory address is set from the creating transaction and has no setter, so it cannot be spoofed on a registered clone. The call clears any pending Ownable2Step transfer. A compromised factory owner, the Safe, can take over every clone: pause, redirect unfrozen metadata, pick reveal seeds and reserve-mint. That is the trust assumption the design accepts.\n- **pull() with no new JIG** cannot restart or stretch a running stream because the forwarder is the only notifier and the interval equals the duration. No revert path blocks it, and the paused branch is skipped.\n- **Accepted items** Low 4, Info 6 and Info 9: I agree with the reasons given in the README.\n\nTooling note: `lib/` is not in the repository, so I cloned forge-std, OpenZeppelin 5.4 and v4-periphery outside the tree and pointed forge at them through environment overrides. All 56 non-fork tests pass that way. Fork tests were not run.","treeHash":null,"usage":{"cachedInputTokens":2081629,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":39483,"runtime":"claude","turns":39,"wallClockMs":870078}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ca075d17c94a854b","findings":[{"citation":"resolved","description":"The round 8 High 1 fix appends IAirdropPot(airdropRecipient).pot() to _excluded(), which runs inside launch() (the CreatorCoin constructor arguments) and inside coinInitCodeHash()/predictCoin(). The constructor still treats a recipient without code as valid: the pot()/factory() probes on lines 110-116 run only when airdropRecipient_.code.length > 0, and launch() line 168 sends the 5% to a no-code recipient without notify(), so a wallet recipient is a supported configuration by the contract's own text (DeployCoins.s.sol also has an `airdrop != pot` branch for a non-AirdropClaim recipient). With a wallet recipient a high-level call to pot() on an address with no code reverts (empty return data cannot be decoded as an address), so every launch(), every coinInitCodeHash() and every predictCoin() reverts; the factory is deployed but can never launch a coin, and the vanity grinder (offchain/vanity.mjs reads coinInitCodeHash) and launch page (jig.mjs predictCoin) fail with the same revert. The check the constructor was written to provide ('a wrong recipient would brick every launch', DeployCoins.s.sol:34) no longer covers the configuration it accepts. Minimal fix: in the constructor require airdropRecipient_.code.length > 0 (the pot() probe then always runs) and store the probed pot in an immutable `pot` read by _excluded(); or, if a wallet recipient must stay supported, in _excluded() only add pot() when airdropRecipient.code.length > 0 (a 3-entry list otherwise).","line":251,"path":"src/CoinFactory.sol","reproduction":"State: new CoinFactory(posm, permit2, hook, jig, platform, airdropRecipient = an EOA, 0) succeeds (constructor skips the probes because code.length == 0). Input: factory.predictCoin(collection, \"Coin\", \"C\", bytes32(bytes20(collection))) or a creator's factory.launch(p) with any valid LaunchParams. Expected: the prediction returns an address and the launch proceeds, sending the 5% to the wallet (launch() line 167-168 is written for exactly this recipient). Actual: both revert in _excluded() at line 251 (call to pot() on an address with no code returns no data; abi decoding reverts). Proof run: `forge test --match-path test/scratch/EoaRecipientBricksLaunch.t.sol` passes on this code and shows both reverts (the test asserts expectRevert on predictCoin and launch after a successful constructor).","severity":"low","snippet":"        e[3] = IAirdropPot(airdropRecipient).pot();","title":"CoinFactory accepts a plain wallet as airdropRecipient but _excluded() now calls pot() on it, so launch() and predictCoin() revert for good"},{"citation":"resolved","description":"The round 8 Medium 2 fix made pushableIds() classify every unclaimed id's owner with p.getCode(). It does so with one sequential `await` per distinct owner (no multicall, no batching: pub() uses http(c.rpc) without batch, and no cache). pushableIds() also deletes the owners cache first (line 91) and re-reads every ownerOf and every claimed flag. airdropFor() (line 72), which backs the public /coins/:coin/airdrop page for any account, calls pushableIds() on every request just to fill the `delivery` field, so one anonymous HTTP request on a 10,000-id collection with 5,000 distinct holders of unclaimed ids costs ~20 ownerOf multicalls + ~20 claimed multicalls + 5,000 serialized eth_getCode round trips (minutes at ~50 ms each), and the owners cache that was meant to make the per-account view cheap is invalidated for the next request. The keeper (keeper.mjs:165) pays the same cost per coin per 5-minute pass while any unclaimed id remains in the 30-day window. No funds are at risk; the effect is request latency, RPC quota exhaustion and a trivially triggered load amplification (one request -> thousands of RPC calls) on the server that also runs the keeper and its price checks. Minimal fix: cache the code check per address (an address's code status only changes on deployment or an EIP-7702 delegation, so a 60 s TTL like _owners is fine), fetch it in parallel chunks (Promise.all over slices) or via Multicall3 extcodesize-style aggregation, and do not call pushableIds() from airdropFor() (compute `delivery` from a short-TTL cache or a cheaper count); keep `_owners.delete` only in the keeper path.","line":99,"path":"offchain/rewards.mjs","reproduction":"State: a coin for a JigsawCollection with maxSupply 10,000, 10,000 ids minted to 5,000 distinct wallets, none pushed yet (push gate waiting for fees). Input: GET /coins/<coin>/airdrop?account=<any address> (airdropFor) twice in a row. Expected: a read of the account's own ids from the 60 s owners cache plus a few multicalls. Actual (by code, rewards.mjs lines 72, 91-100): each request runs pushableIds(), which deletes _owners[collection], re-fetches 10,000 ownerOf results (20 multicalls), 10,000 claimed flags (20 multicalls) and then issues 5,000 eth_getCode calls one after another (the `for ... await` at line 99); the second request repeats all of it because nothing from the first is cached. The keeper's airdrop step for that coin does the same every pass.","severity":"low","snippet":"  for (const o of new Set(open.map((id) => owners[id]))) code.set(o, ((await p.getCode({ address: o }).catch(() => '0x')) ?? '0x') !== '0x');","title":"Public airdrop status endpoint and keeper do one uncached eth_getCode per NFT holder per call and drop the owners cache every time: thousands of serialized RPC calls per request on a large collection"}],"hash":"44598576dcc126b76cb685f184ebc92685335cdf2b86874e59703f62fcde24ed","nodeId":"04c59e4e-a6e3-40ba-964d-2e001c1e4629","outcome":"completed","summary":"The review is complete. The findings file at the repository root holds two substantiated findings, both Low, and the seven round-8 fixes otherwise hold.\n\n**Findings written to `.imd-findings.json`**\n\n- **Low, `src/CoinFactory.sol:251`.** The High 1 fix calls `pot()` on the airdrop recipient inside `_excluded()`, which runs in every launch and in the coin-address prediction. The constructor still accepts a recipient with no code, since it only probes contracts. With a wallet recipient, the factory deploys fine but every launch, prediction and vanity-grinder read reverts for good. Fix: require code on the recipient and store the probed pot as an immutable, or skip the pot entry when the recipient has no code. Scratch test in test/scratch/EoaRecipientBricksLaunch.t.sol shows the successful construction followed by both reverts.\n- **Low, `offchain/rewards.mjs:99`.** The Medium 2 fix classifies each unclaimed id's holder with one sequential, uncached `eth_getCode`, after dropping the owners cache. The public airdrop status call runs this on every request, so one anonymous page hit on a large collection fires thousands of serialized RPC calls, and the keeper repeats it every pass. Fix: cache the code check, fetch in parallel chunks, and stop calling the push scan from the per-account status path.\n\n**Verified and cleared**\n\n- Pot exclusion: all excluded addresses are set in the constructor before the mint, so no rewards are ever credited before exclusion. Excluded-supply accounting cannot underflow. No flywheel module, the hook or the pool manager holds coin across transactions as an eligible address. Precision headroom of the reward math is safe at the fixed supply.\n- Zero-amount pull: with the pull interval equal to the stream duration, a stream can never be restarted or stretched. Dust carry below one day's worth of wei restreams daily at rate zero, harmlessly. A paused zero pull advances the pull clock without streaming, and the carry reaches holders on the next pull. All three cases pass in test/scratch/ForwarderTiming.t.sol.\n- Contract-owner skip: a contract calling during its own construction or a self-destructing contract can only strand its own share. Delegated EOAs and smart wallets claim themselves, and the keeper never queues contract-held ids.\n- Freeze guards in both launch paths behave as the report asked.\n- Recovery: the factory address cannot be spoofed on a real clone, the implementation is locked, and the OpenZeppelin two-step transfer clears any pending owner. A compromised factory owner can take every clone, which is the documented Safe trust assumption.\n- Accepted items Low 4, Info 6 and Info 9: I agree with the stated reasons.\n\nThe existing 56 non-fork tests pass with dependencies installed outside the repository. No repository file other than the findings file and the scratch tests was written, and the temporary dependency link was removed.","treeHash":null,"usage":{"cachedInputTokens":2765437,"inputTokens":674,"model":"claude-fable-5-1","outputTokens":38385,"runtime":"claude","turns":44,"wallClockMs":917336}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a406deaac63a93b0","findings":[{"citation":"resolved","description":"The round 8 fix appends IAirdropPot(airdropRecipient).pot() to _excluded(). The constructor probes pot()/factory() only when airdropRecipient_.code.length > 0 (lines 110-116), and launch() line 168 calls notify() only for a recipient with code, so a plain wallet recipient is an accepted configuration by the contract's own text (DeployCoins.s.sol also has a non-AirdropClaim branch). With such a recipient the high-level pot() call in _excluded() gets no return data and reverts while decoding the address. _excluded() runs inside launch() (line 139, the CreatorCoin constructor arguments) and inside coinInitCodeHash()/predictCoin() (lines 264, 269), so the factory can neither launch a coin nor predict an address; airdropRecipient is immutable and CreatorFeeHook.setFactory is once-only, so the hook bound to it is wasted too. The round 2 constructor guard exists precisely so that 'a wrong recipient would brick every launch' cannot happen; the round 8 fix reintroduces that class for the EOA case. No attacker: a deployment misconfiguration the constructor now silently accepts. The mainnet plan (script/go-day.sh) passes an AirdropClaim, so the live launch is not affected, hence Low. Checked and cleared on the same fix: coinInitCodeHash() shares _excluded() with launch() so the vanity grinder stays in step; AirdropClaim.pot is immutable so the excluded set cannot drift between prediction and launch; the pot, AirdropClaim, hook, pool manager, factory, DEAD and the coin are the only protocol addresses holding coin across transactions (BuybackAndBurn buys and burns in one call; LPDeepening, SweepAndBurn, HolderDrip and RewardsForwarder hold only JIG/ETH/NFTs; the position manager settles coin straight into the pool manager); exclusion is fixed in the coin constructor so no reward can be credited to an address before it is excluded, and earned()/_updateAccount() return 0/skip for excluded accounts. Minimal fix: either refuse an EOA recipient in the constructor (require airdropRecipient_.code.length > 0, so the pot() probe always runs) and store the probed pot in an immutable read by _excluded(); or, to keep wallet recipients, in _excluded() add pot() only when airdropRecipient.code.length > 0 and return a 3-entry list otherwise. Merged from four specialists (economics, permissions, math, flow).","line":251,"path":"src/CoinFactory.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {TickMath} from \"@uniswap/v4-core/src/libraries/TickMath.sol\";\nimport {CoinFactory} from \"src/CoinFactory.sol\";\n\n/// Answers every call with 32 zero bytes: stands in for the position manager, permit2, the fee hook and JIG so that\n/// CoinFactory.launch() runs its real coin creation and its real _excluded() list.\ncontract Yes {\n    fallback() external payable { assembly { mstore(0, 0) return(0, 32) } }\n}\n\ncontract SCollection {\n    address public creator;\n    uint256 public maxSupply = 100;\n    constructor(address c) { creator = c; }\n}\n\n/// Round 8 High 1 fix: _excluded() calls IAirdropPot(airdropRecipient).pot() on every launch and in every\n/// coinInitCodeHash()/predictCoin(). The constructor only probes pot() when the recipient has code, so an EOA\n/// recipient (a plain wallet, which launch() line 168 explicitly supports) is accepted and then every launch and\n/// every prediction reverts: the factory is dead on arrival and the hook, bound once by setFactory, with it.\ncontract EoaAirdropRecipientTest is Test {\n    address creator = makeAddr(\"creator\");\n\n    function test_eoa_airdrop_recipient_is_refused_or_launches_work() public {\n        Yes posm = new Yes(); Yes permit2 = new Yes(); Yes hook = new Yes(); Yes jig = new Yes();\n        address wallet = makeAddr(\"airdropWallet\"); // an EOA airdrop recipient\n        CoinFactory f;\n        try new CoinFactory(address(posm), address(permit2), address(hook), address(jig), makeAddr(\"platform\"), wallet, 0) returns (CoinFactory f_) {\n            f = f_;\n        } catch {\n            return; // the constructor refused the EOA recipient: consistent, nothing more to check\n        }\n        // the constructor accepted it, so the factory must be usable with it\n        SCollection col = new SCollection(creator);\n        bytes32 salt = bytes32(bytes20(address(col)));\n        (bool ok,) = address(f).staticcall(abi.encodeCall(f.predictCoin, (address(col), \"Coin\", \"C\", salt)));\n        assertTrue(ok, \"predictCoin() reverts for an EOA airdrop recipient: _excluded() calls pot() on an account without code\");\n        address predicted = f.predictCoin(address(col), \"Coin\", \"C\", salt);\n        bool coinIs0 = predicted < address(jig);\n        CoinFactory.LaunchParams memory p = CoinFactory.LaunchParams({\n            collection: address(col), name: \"Coin\", symbol: \"C\", feePips: 10_000, flywheel: address(0), creatorShareBps: type(uint16).max,\n            creatorBps: 0, devBuyJig: 0, devBuyMinCoin: 0, maxFeeWei: 0, salt: salt,\n            sqrtPriceX96: TickMath.getSqrtPriceAtTick(0), tickLower: coinIs0 ? int24(0) : int24(-200), tickUpper: coinIs0 ? int24(200) : int24(0)\n        });\n        vm.prank(creator);\n        (, address coinAddr) = f.launch(p);\n        assertEq(coinAddr, predicted);\n    }\n}","reproduction":"State: new CoinFactory(posm, permit2, hook, jig, platform, airdropRecipient = makeAddr('airdropWallet'), 0) succeeds (code.length == 0 skips lines 110-116). Input: factory.predictCoin(collection, 'Coin', 'C', bytes32(bytes20(collection))), then factory.launch(p) from collection.creator() with a valid LaunchParams. Expected: both succeed and the wallet receives 50,000,000e18 coin without notify() (line 167-168 is written for exactly this recipient), or the constructor reverts BadAirdropRecipient. Actual: predictCoin, coinInitCodeHash and launch all revert with empty data from the pot() call in _excluded(). Reproduced: forge test --match-path test/scratch/EoaAirdropRecipient.t.sol (the flow specialist's proof) fails on this code with 'predictCoin() reverts for an EOA airdrop recipient: _excluded() calls pot() on an account without code' (run in a mirror of the repo with lib/ installed; lib/ is not in the audit repo). It passes once the constructor refuses an EOA recipient or _excluded() guards the call.","severity":"low","snippet":"        e[3] = IAirdropPot(airdropRecipient).pot();","title":"Round 8 High 1 fix: _excluded() calls pot() on the airdrop recipient unconditionally, so an EOA recipient the constructor accepts makes every launch(), coinInitCodeHash() and predictCoin() revert"},{"citation":"resolved","description":"Round 8 Low 3 made JigsawCollection.initialize revert NothingToFreeze for freeze with an empty uri (src/JigsawCollection.sol:146), and Info 7 made CollectionLauncher.create revert for freeze with revealLater 'as JigsawCollection' (line 72). The empty-URI half was not mirrored: with freeze = true, revealLater = false and uri = '', create() calls setBaseURI('') and then freezeMetadata() on the Loom clone. reference/LoomCollection.sol:177-181 (the implementation behind every clone) freezes unconditionally ('An owner may intentionally freeze an empty URI'). The clone is then metadataFrozen with baseURI == '' and unrevealedURI == '': tokenURI(id) is '' for every token forever, setBaseURI/setUnrevealedURI/setRevealSeed revert MetadataIsFrozen, minting stays open, and neither the operator nor the Safe (reoffer only moves ownership) can undo it. Same operator-only input and same irreversible outcome as round 8 Low 3, on the other launch path; the two paths take the same (uri, revealLater, freeze) triple from the same back end (offchain/launch.mjs line 270 sends freeze = true on mainnet with uri = finalBase(pinned), so an empty pinned base URI reaches this line). Minimal fix: in create(), `if (freeze && (revealLater || bytes(uri).length == 0)) revert NothingToFreeze();`. Merged from three specialists.","line":79,"path":"src/CollectionLauncher.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Clones} from \"@openzeppelin/contracts/proxy/Clones.sol\";\nimport {CollectionLauncher} from \"src/CollectionLauncher.sol\";\nimport {LoomCollection} from \"reference/LoomCollection.sol\";\n\n/// Stands in for the Loom NFT factory: clones the live LoomCollection implementation and makes the caller its owner,\n/// which is what the launcher relies on (it then sets provenance and URI as owner).\ncontract MockLoomFactory {\n    address public immutable impl;\n    constructor(address impl_) { impl = impl_; }\n    function createCollection(string calldata name, string calldata symbol, uint256 maxSupply, uint256 mintPriceWei, uint256 maxPerWallet, address creator, uint96 royaltyBps) external payable returns (address c) {\n        c = Clones.clone(impl);\n        LoomCollection(c).initialize(name, symbol, maxSupply, mintPriceWei, maxPerWallet, creator, royaltyBps, msg.sender);\n    }\n}\n\n/// Round 8 Low 3 was fixed on the phased path only: CollectionLauncher.create(freeze = true, revealLater = false,\n/// uri = \"\") still freezes a Loom clone on an empty base URI, for good (LoomCollection.freezeMetadata has no guard).\n/// Fails on this code (\"next call did not revert as expected\"); passes once create() reverts NothingToFreeze for\n/// freeze with an empty uri, as JigsawCollection.initialize does.\ncontract LoomFreezeEmptyUriTest is Test {\n    address operator = makeAddr(\"operator\");\n    address safe = makeAddr(\"safe\");\n    address creator = makeAddr(\"creator\");\n    address minter = makeAddr(\"minter\");\n\n    function test_launcher_refuses_freeze_with_empty_uri_as_the_phased_path_does() public {\n        MockLoomFactory f = new MockLoomFactory(address(new LoomCollection(safe, safe)));\n        CollectionLauncher l = new CollectionLauncher(address(f), operator, safe);\n        CollectionLauncher.Collection memory c = CollectionLauncher.Collection({name: \"N\", symbol: \"S\", maxSupply: 10, mintPriceWei: 0, maxPerWallet: 1, creator: creator, royaltyBps: 0});\n        vm.prank(operator);\n        // on this code the call succeeds and the clone is metadataFrozen with baseURI == \"\" and tokenURI(id) == \"\"\n        // for every token, with setBaseURI reverting MetadataIsFrozen: nothing can ever give the tokens metadata\n        vm.expectRevert(CollectionLauncher.NothingToFreeze.selector);\n        l.create(c, bytes32(uint256(1)), \"\", false, true);\n    }\n}","reproduction":"State: a CollectionLauncher bound to a factory that clones reference/LoomCollection.sol and makes the caller its owner (MockLoomFactory in the proof). Input: operator calls create({name:'N',symbol:'S',maxSupply:10,mintPriceWei:0,maxPerWallet:1,creator,royaltyBps:0}, bytes32(uint256(1)), '', false, true). Expected: revert NothingToFreeze, as JigsawCollectionFactory.create does for the same arguments (test/Audit8_Rest.t.sol::test_low3_freeze_at_creation_needs_a_base_uri). Actual: create succeeds and emits Launched; after acceptOwnership, metadataFrozen() == true, baseURI() == '', setBaseURI('ipfs://x/') reverts MetadataIsFrozen, and after mint{value: 0.0005 ether}(1) tokenURI(1) == ''. Reproduced: forge test --match-path test/scratch/LoomFreezeEmptyUri.t.sol fails on this code with 'next call did not revert as expected'; a second scratch test asserting the stuck state (frozen, empty baseURI, MetadataIsFrozen on setBaseURI, empty tokenURI after a mint) passes on this code.","severity":"low","snippet":"        if (freeze && !revealLater) ILoomCollectionOwner(collection).freezeMetadata();","title":"Round 8 Low 3 was fixed on the phased path only: CollectionLauncher.create(freeze = true, revealLater = false, uri = '') still freezes a Loom collection with no metadata, for good"},{"citation":"resolved","description":"Since the round 8 Medium 2 fix, rewards.pushableIds() (offchain/rewards.mjs:100) splits unclaimed ids into `ids` (EOA owners) and `contractHeld`. The keeper correctly never queues contractHeld (it is not read anywhere, so there is no resend loop on contract-held ids while getCode works). But the completion rule still looks only at `ids`: when the collection is complete and `ids` is empty, airdropDone[coin] is set permanently (line 164 skips the whole block from then on) even when contractHeld is non-empty. Before round 8 those ids stayed in `ids`, so the keeper kept serving the coin. An id sitting in a marketplace escrow, vault or bridge at that moment becomes pushable the moment it returns to an EOA (claimFor would then pay it), but the keeper never looks at the coin again, and pushStatus() (rewards.mjs:138) reports 'done' to the site. Nothing is stolen: the holder keeps claim() for the rest of the window (the site shows the claim button for an EOA with unclaimed ids), and after day 30 the share is swept to the pot. It breaks the 'airdrop lands without holders doing anything' promise the push exists for, introduced by the fix. Fix: mark done only when `!pa.open || (pa.complete && pa.ids.length === 0 && pa.contractHeld.length === 0)`, re-checking coins with pending contract-held ids (cheaply, e.g. every hour) until the window closes, and report them in pushStatus instead of 'done'. Merged from two specialists.","line":166,"path":"offchain/keeper.mjs","reproduction":"State: collection maxSupply 3, all minted; ids 1 and 2 held by EOAs, id 3 by an escrow contract (code.length != 0); none claimed. Pass 1: pushableIds returns {open: true, ids: [1, 2], contractHeld: [3], complete: true}; the keeper pushes ids 1 and 2. Pass 2: pushableIds returns {open: true, ids: [], contractHeld: [3], complete: true}; line 166's condition `pa.complete && pa.ids.length === 0` is true, so airdropDone[coin] = true. Day 5: the escrow returns id 3 to EOA 0xB0B. Expected: a later pass pushes id 3 (claimFor(coin, [3]) would now transfer perNft to 0xB0B). Actual: line 164's `!st.airdropDone?.[coin]` is false for the rest of the window; the keeper never calls pushableIds for the coin again; pushStatus reports 'done'; on day 31 sweep() sends id 3's share to the pot unless 0xB0B calls claim(). Traced by code: keeper.mjs lines 164-168 and rewards.mjs lines 95-100 (contractHeld is returned but never consulted).","severity":"low","snippet":"        if (!pa.open || (pa.complete && pa.ids.length === 0)) { (st.airdropDone ??= {})[coin] = true; return; }","title":"Keeper marks a coin's push airdrop done for good while contract-held ids remain, so an NFT that leaves an escrow or vault later in the 30-day window is never pushed"},{"citation":"resolved","description":"The round 8 Medium 2 keeper change made pushableIds() classify every unclaimed id's owner with p.getCode(), one sequential `await` per distinct owner (line 99; no batching, no cache; pub() uses a plain http transport). pushableIds() also deletes the owners cache first (line 91) and re-reads every ownerOf and every claimed flag. airdropFor() (the per-account view behind the site's airdrop page) calls pushableIds() on every request at line 72 just to fill the `delivery` field, so one request on a 10,000-id collection with 5,000 distinct holders of unclaimed ids costs ~20 ownerOf multicalls + ~20 claimed multicalls + 5,000 serialized eth_getCode round trips, and leaves the owners cache (meant to make the per-account read cheap for 60 s) invalidated for the next request. The keeper pays the same cost per coin per 5-minute pass while any unclaimed id remains in the 30-day window. No funds at risk: latency, RPC quota and a one-request-to-thousands load amplification on the server that also runs the keeper and its price checks. Fix: cache the code check per address (an address's code only changes on deployment or an EIP-7702 delegation; a 60 s TTL like _owners is fine), fetch it in parallel chunks or via a Multicall3 getCodeSize-style aggregation, and do not call pushableIds() from airdropFor() (serve `delivery` from a short-TTL cache); keep `_owners.delete` to the keeper path. From the math specialist; verified by code trace.","line":72,"path":"offchain/rewards.mjs","reproduction":"State: a coin on a JigsawCollection with maxSupply 10,000, all minted to 5,000 distinct wallets, none pushed (push gate waiting for fees). Input: airdropFor(coin, anyAccount) twice in a row (the site's /coins/:coin/airdrop read). Expected: the account's ids from the 60 s owners cache plus one claimed multicall. Actual, by code: line 66 fills the owners cache; line 72 calls pushableIds(), which at line 91 deletes _owners[collection], at line 92 re-fetches 10,000 ownerOf results (20 multicalls of 500), at line 94 10,000 claimed flags (20 multicalls), and at line 99 issues 5,000 eth_getCode calls one after another; the second request repeats all of it because nothing from the first is cached. viem is not installed in this tree, so the trace is from the source; no on-chain state is involved.","severity":"low","snippet":"  try { const pa = await pushableIds(coin); delivery = open ? await pushStatus(coin, collection, pa.ids.length) : { status: 'closed' }; } catch (e) { delivery = { status: 'unknown', reason: e.shortMessage ?? e.message }; }","title":"Public airdrop status reads call pushableIds() on every request, which flushes the owners cache and does one serial eth_getCode per holder: thousands of RPC calls per anonymous request on a large coll"},{"citation":"resolved","description":"The code check that keeps the keeper from looping on contract-held ids fails open: `.catch(() => '0x')` classifies an owner whose getCode call threw (rate limit, timeout) as an EOA, so its ids land in `ids` instead of `contractHeld`. keeper.mjs line 168 takes the first KEEPER_AIRDROP_BATCH (15) of `ids` in id order; on chain AirdropClaim.claimFor (src/AirdropClaim.sol:112) skips the code-bearing owner without marking it, so the batch is a paid transaction that pushes nothing (Pushed(coin, 0, 0)); the keeper's simulation passes (no revert), the receipt is a success, airdropSpentEth grows (which delays the pay-as-earned gate for real pushes) and the same ids come back next pass while getCode keeps failing. Bounded by KEEPER_AIRDROP_TX_PER_DAY (300 per coin), the fee gate and the error lasting; cost is gas per pass. The brief's requirement that the keeper never loop on contract-held ids holds only while getCode succeeds. Fix: fail closed, as kindOf() already does for module probes: on a getCode failure leave the owner's ids out of both lists for this pass (`.catch(() => null)` and skip null), or abort pushableIds for the pass. Merged from two specialists.","line":99,"path":"offchain/rewards.mjs","reproduction":"State: a coin's drop with ids 1..15 owned by one staking contract and ids 16..30 by EOAs, none claimed; the RPC's eth_getCode throws for the staking contract's address while eth_call (ownerOf/claimed multicalls) succeeds. Input: keeper pass. Expected: ids 1..15 in contractHeld and the batch is ids 16..30 (or the pass is skipped). Actual, by code: line 99 maps the thrown getCode to '0x', so code.get(stakingContract) === false; line 100 returns ids = [1..30]; keeper.mjs line 168 takes batch = [1..15]; claimFor(coin, [1..15]) hits `owner_.code.length != 0` for every id and emits Pushed(coin, 0, 0); the receipt is a success, onSuccess schedules the next batch and the same ids are sent again while the read keeps failing.","severity":"info","snippet":"  for (const o of new Set(open.map((id) => owners[id]))) code.set(o, ((await p.getCode({ address: o }).catch(() => '0x')) ?? '0x') !== '0x');","title":"pushableIds treats a failed eth_getCode as 'no code', so during an RPC error contract-held ids re-enter the push batch and the keeper sends claimFor transactions that push nothing until the read works"},{"citation":"resolved","description":"CreatorCoin.notifyReward leaves carry = total - rewardRate * DURATION, i.e. total mod 86,400 wei (src/CreatorCoin.sol:136), which is non-zero after practically every stream. pull() therefore sees carry > 0 on every quiet day and calls notifyReward(0): total = carry < 86,400, rewardRate = 0, carry unchanged, lastUpdate and periodFinish reset, RewardAdded(0, 0, now + 1 day) emitted, and offchain/rewards.mjs rewardsOf() reports a live stream with jigPerDay 0. The intended case (a real carry from a stream with no eligible holders) is served; the condition just fires almost always, costing the keeper ~30k gas per idle day per coin. Checked and cleared: the branch cannot restart or stretch a running stream (MIN_INTERVAL == DURATION, so at the earliest allowed pull block.timestamp == periodFinish and `block.timestamp < periodFinish` is false: nothing is folded; holders' earned() is unchanged); it cannot block pull() (JIG is a plain OpenZeppelin ERC20, transferFrom of 0 with zero allowance succeeds; the branch is skipped while paused and when no coin exists; hook.claim() does not revert on a zero balance); and the amount > 0 branch reverts Paused while paused, which is the intended brake (fees wait in the hook; accepted Info 9). Fix if wanted: condition on `carry() >= CreatorCoin.DURATION` (the smallest carry that gives a non-zero rate), or on eligibleSupply() > 0 together with it. Merged from two specialists.","line":68,"path":"src/RewardsForwarder.sol","reproduction":"State (test/scratch/DustRestream.t.sol, passes on this code): coin with 100e18 eligible (alice) and the rest held by the excluded factory stand-in; the forwarder holds 1_000e18 + 12345 JIG and pull() runs at T: rewardRate = 11574074074074074, carry = 18745 wei. Input: pull() at T + 1 day with no new JIG. Expected per the fix's intent: a re-stream of carried reward, or no call when nothing can stream. Actual: notifyReward(0) runs; rewardRate == 0, carry still 18745, periodFinish == T + 2 days; alice's earned() == 999999999999999993600 before and after, and unchanged at T + 2 days (the whole first stream, confirming no stretch and nothing streamed); the dust is never paid.","severity":"info","snippet":"            if (a != address(0) && CreatorCoin(a).carry() > 0 && !CreatorCoin(a).rewardsPaused()) CreatorCoin(a).notifyReward(0);","title":"Round 8 Info 10 fix: after any stream the carry is division dust under 86,400 wei, so every zero-fee pull calls notifyReward(0), sets rewardRate to 0 and moves periodFinish for nothing"}],"hash":"47a492775689ecc52062cc35cf39f860bb9920f9c1cdc72aee72043fb8b339b5","nodeId":"68ccaefb-fc79-45e6-8d40-e989e6894b44","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds six findings, every snippet matches the tree, and no tracked file was changed.\n\n**Verdict on round 8.** All six fixes do what they claim: the pot is excluded, `claimFor` skips code-bearing owners, both `NothingToFreeze` guards fire, `recoverOwnership` is gated on the factory's owner and clears any pending owner, and the zero-JIG pull cannot stretch or restart a stream. Two of the fixes introduced regressions and one left a gap on the sibling path. Nothing critical, high or medium.\n\n**Findings kept (merged from the four specialists, each reproduced):**\n- **Low, CoinFactory.sol:251.** The new `pot()` read runs unconditionally, so an EOA airdrop recipient that the constructor accepts bricks every `launch`, `coinInitCodeHash` and `predictCoin`. The specialist's proof fails on this code and passes with either fix. Four specialists reported it; merged into one.\n- **Low, CollectionLauncher.sol:79.** Low 3 was fixed only on the phased path. `create(freeze, revealLater = false, uri = \"\")` freezes a Loom clone with no metadata forever. My proof fails on this code and passes with the one-line guard. Three specialists reported it.\n- **Low, keeper.mjs:166.** The keeper marks a coin's push done while contract-held ids remain, so an NFT that leaves an escrow later in the window is never pushed. Two specialists reported it.\n- **Low, rewards.mjs:72.** Every public airdrop status read calls `pushableIds`, flushing the owners cache and doing one serial `getCode` per holder. Code trace only, since viem is not in the tree.\n- **Info, rewards.mjs:99.** A failed `getCode` is treated as \"no code\", so during an RPC error the keeper sends no-op `claimFor` batches each pass. Two specialists reported it.\n- **Info, RewardsForwarder.sol:68.** After any stream the carry is sub-86,400-wei dust, so every idle pull calls `notifyReward(0)` and streams nothing. My test confirms rate 0, moved `periodFinish`, and no stretch.\n\n**Checked and cleared.** The constructor-time and EIP-7702 cases for `claimFor` harm only the caller, who can also use `claim()`. No other protocol module holds coin across transactions. The `factory` recorded by `initialize` cannot be spoofed into a registered clone. The specialists' remaining claims were duplicates and were merged, with none dropped.\n\n**Trust assumption, not a finding.** A compromised factory owner can now call `recoverOwnership` on every phased clone and pause minting or rewrite unfrozen metadata. That is the Safe by design, as the README states.\n\n**Accepted items.** I agree with keeping Low 4, Info 6 and Info 9 accepted for the stated reasons.\n\n**Method note.** The audit repo has no `lib/`, so I ran forge in a mirror outside the repo with OpenZeppelin v5.4.0, forge-std and v4-periphery installed. The repo's 56 non-fork tests pass there. The proofs live in `test/scratch/`.","treeHash":null,"usage":{"cachedInputTokens":2392568,"inputTokens":674,"model":"claude-fable-5-1","outputTokens":41906,"runtime":"claude","turns":42,"wallClockMs":992091}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fa99051b60a858d6","findings":[{"citation":"resolved","description":"The round 8 fix for High 1 appends IAirdropPot(airdropRecipient).pot() to _excluded(). The constructor (src/CoinFactory.sol:110-116) probes pot() and factory() only when airdropRecipient_ has code; an EOA recipient (a plain wallet, which launch() line 168 and the round 2 guard explicitly keep supporting, and which the deploy script allows via AIRDROP == POT) is accepted without any probe. A Solidity high-level call to an account without code reverts on the extcodesize check, so with an EOA recipient _excluded() reverts: launch() (line 139), coinInitCodeHash() (line 264) and predictCoin() (line 269) all revert for every input. The factory is unusable from deployment, and because CreatorFeeHook.setFactory is once-only (src/CreatorFeeHook.sol:166-170) the mined hook has to be redeployed with it. The round 2 constructor guard was added precisely so that 'a wrong recipient would brick every launch' cannot happen (script/DeployCoins.s.sol:34); the new fix reintroduces that class for the EOA case. No attacker; a deployment misconfiguration that the constructor was meant to refuse and now silently accepts. Minimal fix: in the constructor, require airdropRecipient_.code.length > 0 (the pot() probe then always runs) and cache pot() in an immutable, or in _excluded() only call pot() when airdropRecipient.code.length > 0 and otherwise return 3 entries.","line":251,"path":"src/CoinFactory.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {TickMath} from \"@uniswap/v4-core/src/libraries/TickMath.sol\";\nimport {CoinFactory} from \"src/CoinFactory.sol\";\n\n/// Answers every call with 32 zero bytes: stands in for the position manager, permit2, the fee hook and JIG so that\n/// CoinFactory.launch() runs its real coin creation and its real _excluded() list.\ncontract Yes {\n    fallback() external payable { assembly { mstore(0, 0) return(0, 32) } }\n}\n\ncontract SCollection {\n    address public creator;\n    uint256 public maxSupply = 100;\n    constructor(address c) { creator = c; }\n}\n\n/// Round 8 High 1 fix: _excluded() calls IAirdropPot(airdropRecipient).pot() on every launch and in every\n/// coinInitCodeHash()/predictCoin(). The constructor only probes pot() when the recipient has code, so an EOA\n/// recipient (a plain wallet, which launch() line 168 explicitly supports) is accepted and then every launch and\n/// every prediction reverts: the factory is dead on arrival and the hook, bound once by setFactory, with it.\ncontract EoaAirdropRecipientTest is Test {\n    address creator = makeAddr(\"creator\");\n\n    function test_eoa_airdrop_recipient_is_refused_or_launches_work() public {\n        Yes posm = new Yes(); Yes permit2 = new Yes(); Yes hook = new Yes(); Yes jig = new Yes();\n        address wallet = makeAddr(\"airdropWallet\"); // an EOA airdrop recipient\n        CoinFactory f;\n        try new CoinFactory(address(posm), address(permit2), address(hook), address(jig), makeAddr(\"platform\"), wallet, 0) returns (CoinFactory f_) {\n            f = f_;\n        } catch {\n            return; // the constructor refused the EOA recipient: consistent, nothing more to check\n        }\n        // the constructor accepted it, so the factory must be usable with it\n        SCollection col = new SCollection(creator);\n        bytes32 salt = bytes32(bytes20(address(col)));\n        (bool ok,) = address(f).staticcall(abi.encodeCall(f.predictCoin, (address(col), \"Coin\", \"C\", salt)));\n        assertTrue(ok, \"predictCoin() reverts for an EOA airdrop recipient: _excluded() calls pot() on an account without code\");\n        address predicted = f.predictCoin(address(col), \"Coin\", \"C\", salt);\n        bool coinIs0 = predicted < address(jig);\n        CoinFactory.LaunchParams memory p = CoinFactory.LaunchParams({\n            collection: address(col), name: \"Coin\", symbol: \"C\", feePips: 10_000, flywheel: address(0), creatorShareBps: type(uint16).max,\n            creatorBps: 0, devBuyJig: 0, devBuyMinCoin: 0, maxFeeWei: 0, salt: salt,\n            sqrtPriceX96: TickMath.getSqrtPriceAtTick(0), tickLower: coinIs0 ? int24(0) : int24(-200), tickUpper: coinIs0 ? int24(200) : int24(0)\n        });\n        vm.prank(creator);\n        (, address coinAddr) = f.launch(p);\n        assertEq(coinAddr, predicted);\n    }\n}","reproduction":"State: new CoinFactory(posm, permit2, hook, jig, platform, airdropRecipient = any EOA, 0) succeeds (no code, so lines 110-116 are skipped). Input: factory.predictCoin(collection, 'Coin', 'C', bytes32(bytes20(collection))) or factory.launch(p) for any valid p. Expected: either the constructor reverts BadAirdropRecipient for a recipient that cannot answer pot(), or predictCoin/launch work as they do for an EOA recipient on every other path (line 168 skips notify for an EOA). Actual: predictCoin, coinInitCodeHash and launch revert with empty data from the extcodesize check in _excluded(); not one coin can ever be launched. forge test --match-path test/scratch/EoaAirdropRecipient.t.sol fails on this code with 'predictCoin() reverts for an EOA airdrop recipient: _excluded() calls pot() on an account without code' and passes once the constructor refuses an EOA recipient or _excluded() guards the pot() call.","severity":"low","snippet":"        e[3] = IAirdropPot(airdropRecipient).pot();","title":"Round 8 High 1 fix: _excluded() calls pot() on the airdrop recipient unconditionally, so an EOA recipient the constructor accepts bricks every launch and every predictCoin"},{"citation":"resolved","description":"Round 8 Low 3 made JigsawCollection.initialize revert NothingToFreeze for freeze with an empty uri (src/JigsawCollection.sol:146) and Info 7 made CollectionLauncher.create revert for freeze with revealLater (line 72) so that the two launch paths agree. They still disagree on the empty-URI case: create() with freeze = true, revealLater = false and uri = '' calls setBaseURI('') and then freezeMetadata() on the Loom clone. The live LoomCollection (reference/LoomCollection.sol:177-181, Sourcify exact match of the implementation behind every clone) freezes unconditionally ('An owner may intentionally freeze an empty URI'), so the collection is metadataFrozen with baseURI == '' and unrevealedURI == '': tokenURI(id) returns '' for every token forever, setBaseURI/setUnrevealedURI/setRevealSeed revert MetadataIsFrozen, minting stays open, and neither the operator nor the Safe (reoffer only moves ownership) can undo it. Same operator-only input and same irreversible outcome as round 8 Low 3, on the other path; offchain/launch.mjs line 270 sends freeze = true on mainnet with uri = finalBase(pinned), so an empty pinned base URI reaches this line. Minimal fix: in create(), `if (freeze && (revealLater || bytes(uri).length == 0)) revert NothingToFreeze();`.","line":79,"path":"src/CollectionLauncher.sol","reproduction":"Input: operator calls CollectionLauncher.create(Collection{name:'N',symbol:'S',maxSupply:10,mintPriceWei:0,maxPerWallet:1,creator,royaltyBps:0}, provenance = bytes32(1), uri = '', revealLater = false, freeze = true) against the Loom factory. Expected: revert NothingToFreeze, as JigsawCollectionFactory.create does for the same arguments (test/Audit8_Rest.t.sol::test_low3). Actual (scratch test against reference/LoomCollection.sol cloned by a factory stub that initializes with the caller as owner, run in a mirror project): create succeeds and emits Launched; after acceptOwnership, metadataFrozen() == true, baseURI() == '', setBaseURI('ipfs://x/') reverts MetadataIsFrozen, and after mint{value: 0.0005 ether}(1) tokenURI(1) == ''. vm.expectRevert(NothingToFreeze) on the create call fails with 'next call did not revert as expected'.","severity":"low","snippet":"        if (freeze && !revealLater) ILoomCollectionOwner(collection).freezeMetadata();","title":"CollectionLauncher.create still freezes a Loom collection with an empty base URI: the round 8 Low 3 guard was added to the phased path only, and LoomCollection.freezeMetadata has no guard of its own"},{"citation":"resolved","description":"The round 8 Medium 2 keeper change classifies each unclaimed id's owner with getCode; a thrown read (rate limit, timeout) is mapped to '0x', i.e. EOA, so the id lands in `ids` instead of `contractHeld`. keeper.mjs line 168 takes the first KEEPER_AIRDROP_BATCH (15) of `ids` in id order; AirdropClaim.claimFor skips code-bearing owners without marking them (src/AirdropClaim.sol:112), so a batch made of such ids is a paid transaction that pushes nothing and the same ids come back on the next pass (ids are never marked, `complete && ids.length === 0` is never reached). The brief's requirement that the keeper never loop on contract-held ids holds when getCode succeeds; it does not hold while getCode fails. Bounded by the gate (fees must cover each batch) and by the error lasting; cost is gas per pass. Minimal fix: on a getCode failure treat the owner as unknown and leave its ids out of both lists for this pass (`.catch(() => null)` and skip null), or abort pushableIds for the pass.","line":99,"path":"offchain/rewards.mjs","reproduction":"State: a coin's drop with ids 1..15 owned by one staking contract and ids 16..30 by EOAs, none claimed; the RPC's eth_getCode fails (throws) for the staking contract's address while eth_call succeeds. Input: keeper pass. Expected: ids 1..15 are in contractHeld and the batch is ids 16..30 (or the pass is skipped). Actual: code.get(stakingContract) === false, ids = [1..30], batch = [1..15]; claimFor(coin, [1..15]) runs, every id hits the owner_.code.length != 0 skip, Pushed(coin, 0, 0); the next pass repeats the same batch while the read keeps failing.","severity":"info","snippet":"  for (const o of new Set(open.map((id) => owners[id]))) code.set(o, ((await p.getCode({ address: o }).catch(() => '0x')) ?? '0x') !== '0x');","title":"rewards.pushableIds treats a failed getCode as 'no code', so on an RPC error a contract-held id is queued and the keeper sends claimFor batches that push nothing, every pass, until the read succeeds"},{"citation":"resolved","description":"CreatorCoin.notifyReward leaves carry = total - rate * DURATION, which is total mod 86400 wei (src/CreatorCoin.sol:136) and is non-zero for almost every amount. pull() therefore sees carry > 0 on every quiet day and calls notifyReward(0): total = carry < 86400, rewardRate = 0, carry unchanged, periodFinish and lastUpdate reset, RewardAdded(0, 0, ...) emitted. The call is a no-op that costs gas on each quiet pull and cannot ever stream the dust (by design it is unclaimable), so the intended case (a large carry from a stream with no eligible holders) is served, but the condition fires almost always. No loss of funds and no stretch of a running stream (checked: MIN_INTERVAL == DURATION, a pull at exactly periodFinish adds no leftover, rewards earned before are unaffected). Minimal fix: use `carry() >= CreatorCoin.DURATION` (the smallest carry that yields a non-zero rate) as the condition, so the zero-amount notify runs only when it can stream something.","line":68,"path":"src/RewardsForwarder.sol","reproduction":"State (scratch test in a mirror project): coin with 100e18 eligible (alice) and the rest excluded; the forwarder holds 1_000e18 + 12345 JIG and pull() runs at T: rewardRate = 11574074074074074, carry = 18745 wei. Input: pull() at T + 1 day with no new JIG. Expected per the fix's intent: a re-stream of carried reward, or no call when nothing can stream. Actual: notifyReward(0) runs, rewardRate == 0, carry still 18745, periodFinish moved to T + 2 days; alice's earned() == 999999999999999993600 (the whole first stream, confirming no stretch) and the dust is never paid.","severity":"info","snippet":"            if (a != address(0) && CreatorCoin(a).carry() > 0 && !CreatorCoin(a).rewardsPaused()) CreatorCoin(a).notifyReward(0);","title":"Round 8 Info 10 fix: after any normal pull the carry is division dust under 86400 wei, so every later zero-fee pull calls notifyReward(0) that sets rewardRate to 0 and changes nothing"}],"hash":"76e7b6f0c5ede828510003c68bd89fc2914895c13ac957edb3cb266754f9c95d","nodeId":"f5cfe55c-bedc-4739-8319-bc756dd1e142","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds four findings. Only the findings file was written inside the repository; dependencies and all scratch tests live in a mirror project under `/tmp`, since `lib/` is not committed and must not be touched.\n\n**Round 8 fixes that hold**\n\n- High 1: the pot is in the coin's excluded list on every launch path. The grinder reads the hash from the factory's `coinInitCodeHash`, so predictions stay in step. No other protocol address holds a rewards coin: flywheel modules only hold JIG, BuybackAndBurn buys and burns in one transaction, exclusions are constructor-only so there are no pre-exclusion credits. The repo's PotStranded regression passes in the mirror.\n- Medium 2: `claimFor` skips code-bearing owners without marking them. Constructor-time or counterfactual wallets receiving a push only ever receive their own share. EIP-7702 delegated EOAs are treated as contracts and must claim themselves, which the site's `selfClaim` flag reflects. The keeper marks a drop done when `complete` and `ids` is empty, so it never loops on `contractHeld`.\n- Low 3, Info 7, Low 5: the `NothingToFreeze` guards hold on the phased path. The clone's `factory` can only be the deployer in the creating transaction. Self-made clones are unregistered. Ownable2Step's pending owner is cleared by `_transferOwnership`. A compromised factory owner can take over every clone, which is the documented Safe trust assumption.\n- Info 10: a pull at exactly `periodFinish` adds no leftover, so the stream is never stretched. A zero pull reverts only if paused, and that path is skipped.\n- Accepted items Low 4, Info 6 and Info 9: I agree with the reasons given.\n\n**Findings**\n\n- **Low, `src/CoinFactory.sol:251`.** The new `pot()` call in `_excluded()` runs unconditionally, but the constructor only probes `pot()` when the recipient has code. An EOA recipient is accepted and then every `launch`, `coinInitCodeHash` and `predictCoin` reverts, with the once-bound hook dead alongside. Proof test fails on this code.\n- **Low, `src/CollectionLauncher.sol:79`.** The Loom path still freezes with an empty base URI. The live LoomCollection's `freezeMetadata` has no guard, so the collection has empty metadata forever. Verified against `reference/LoomCollection.sol`.\n- **Info, `offchain/rewards.mjs:99`.** A failed `getCode` is treated as \"no code\", so during an RPC error the keeper sends `claimFor` batches that push nothing, every pass.\n- **Info, `src/RewardsForwarder.sol:68`.** Carry after a normal pull is division dust under 86400 wei, so every quiet-day pull calls `notifyReward(0)` with a zero rate. Harmless gas waste; the condition should be `carry >= DURATION`.","treeHash":null,"usage":{"cachedInputTokens":2195800,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":37223,"runtime":"claude","turns":38,"wallClockMs":634686}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"23617eab0c5242d6","findings":[{"citation":"resolved","description":"The constructor checks pot()/factory() only `if (airdropRecipient_.code.length > 0)` (line 110), and launch() calls notify() only `if (airdropRecipient.code.length > 0)` (line 168), so an EOA recipient is a deliberately accepted configuration. The new fourth exclusion in _excluded() calls pot() on the recipient unconditionally. On an address without code, Solidity 0.8.26 skips the extcodesize check for a call that returns data, gets empty returndata, and reverts while ABI-decoding the address. _excluded() runs inside launch() (line 139, the CreatorCoin constructor arguments) and inside coinInitCodeHash()/predictCoin() (line 264), which the off-chain vanity grinder uses, so the factory can launch nothing and cannot even predict addresses. The deploy scripts pass an AirdropClaim today, so this is a latent regression in a supported configuration, not a live mainnet break. The same call also re-queries pot() at every launch instead of using the value the constructor already probed.","line":251,"path":"src/CoinFactory.sol","reproduction":"Deploy CoinFactory(posm, permit2, hook, jig, platform, airdropRecipient = some EOA such as address(0xBEEF), 0). The constructor succeeds because the probe is skipped for code.length == 0. Call predictCoin(collection, \"N\", \"S\", salt) or launch(validParams). Expected: works as before round 8, with the EOA receiving the 5% and no notify. Actual: both revert with empty revert data while decoding pot() in _excluded(). Fix: read the pot once in the constructor into an immutable (`airdropPot = code.length > 0 ? decoded pot : address(0)`) and use `e[3] = airdropPot` (an excluded address(0) is harmless). Or drop EOA support explicitly by requiring code in the constructor.","severity":"low","snippet":"        e[3] = IAirdropPot(airdropRecipient).pot();","title":"Round-8 High 1 fix makes every CoinFactory.launch revert when airdropRecipient is an EOA, a configuration the constructor and launch() explicitly support"},{"citation":"resolved","description":"Round 8 fixed JigsawCollection.initialize to revert NothingToFreeze when freeze is set with an empty uri (Low 3), and the Info 7 fix states that CollectionLauncher.create should behave 'as JigsawCollection'. The launcher copied only the revealLater half. LoomCollection.freezeMetadata (reference/LoomCollection.sol:177) has no base-URI check, so create() with uri = \"\", revealLater = false, freeze = true calls setBaseURI(\"\") and then freezeMetadata(). The collection is minted and sold with tokenURI() == \"\" for every token, and setBaseURI, setUnrevealedURI and setRevealSeed all revert MetadataIsFrozen for good. The two launch paths still disagree on the same (uri, revealLater, freeze) input from the same back end.","line":72,"path":"src/CollectionLauncher.sol","reproduction":"Operator calls CollectionLauncher.create(c, provenance = bytes32(uint256(1)), uri = \"\", revealLater = false, freeze = true) with the factory fee. Expected (as JigsawCollectionFactory.create with the same arguments): revert NothingToFreeze. Actual: success. collection.metadataFrozen() == true and baseURI() == \"\". After a mint, tokenURI(1) == \"\", and after acceptOwnership the operator's setBaseURI(\"ipfs://x/\") reverts MetadataIsFrozen. Fix: `if (freeze && (revealLater || bytes(uri).length == 0)) revert NothingToFreeze();`","severity":"low","snippet":"        if (freeze && revealLater) revert NothingToFreeze(); // the reveal freezes it; as JigsawCollection (swarm round 8, Info 7)","title":"Round-8 Low 3 fix not mirrored on the Loom path: CollectionLauncher.create(freeze=true, revealLater=false, uri=\"\") still freezes a collection on no metadata for good"},{"citation":"resolved","description":"Since the Medium 2 fix, pushableIds() (offchain/rewards.mjs:100) splits unclaimed ids into `ids` (EOA owners) and `contractHeld`. The keeper's stop condition still looks only at `ids`. Once a collection is complete and every EOA-held id is pushed, airdropDone[coin] is set for good (the whole block is skipped while it is true) even if contractHeld is non-empty. Before round 8 those ids stayed in `ids`, so the keeper kept serving the coin. An NFT held by an escrow, marketplace or bridge contract at that moment and withdrawn or sold to an EOA later in the window is never pushed. Its share is swept to the pot after 30 days unless the new owner finds the claim button. pushStatus() (rewards.mjs:138) reports the coin as 'done' to the site in the same state. No funds are stolen; this breaks the 'airdrop lands without holders doing anything' promise the push exists for.","line":166,"path":"offchain/keeper.mjs","reproduction":"Collection maxSupply 3, all minted. Ids 1 and 2 are held by EOAs and id 3 is in an escrow contract (code.length != 0). Keeper pass 1 pushes ids 1 and 2. Pass 2: pushableIds returns {complete: true, ids: [], contractHeld: [3]}, so airdropDone[coin] = true. On day 5 the escrow releases id 3 to EOA 0xB0B. Expected: a later pass pushes id 3 to 0xB0B (claimFor would succeed). Actual: the keeper never looks at the coin again, and on day 31 sweep() sends id 3's share to the pot. Fix: mark done only when `!pa.open || (pa.complete && pa.ids.length === 0 && pa.contractHeld.length === 0)`, and keep re-checking (cheaply) while contractHeld is non-empty. Report pending contract-held ids in pushStatus instead of 'done'.","severity":"low","snippet":"        if (!pa.open || (pa.complete && pa.ids.length === 0)) { (st.airdropDone ??= {})[coin] = true; return; }","title":"Keeper marks a coin's airdrop done while contract-held ids are still unclaimed, so ids that later move to an EOA inside the 30-day window are never pushed"}],"hash":"8870e7914933fa01f87aee270dfc34cd788317e5946c7f0a9e0721c40b74cc8c","nodeId":"9dedb921-37fc-4c6d-9765-71ef298cf957","outcome":"completed","summary":"I found three Low findings and nothing Critical, High or Medium, so there is no Foundry proof file. None of the three was run as a test: this checkout has no `lib/`, so nothing compiles here. Each one is traced through the code, with a failing input and a fix in `.imd-findings.json`. I changed no repository files.\n\n**Findings (all Low):**\n1. **The High 1 fix breaks launches with an EOA airdrop recipient** (`src/CoinFactory.sol:251`). `_excluded()` now always calls `pot()` on the airdrop recipient. The constructor (line 110) and `launch()` (line 168) both deliberately allow an EOA there. Calling `pot()` on an EOA reverts, so every `launch()`, `predictCoin()` and `coinInitCodeHash()` fails. Your deploy scripts pass an AirdropClaim, so this is a latent regression, not a mainnet break. Fix: read the pot once in the constructor into an immutable (`address(0)` for an EOA).\n2. **The Low 3 fix was not copied to the Loom path** (`src/CollectionLauncher.sol:72`). `create(uri=\"\", revealLater=false, freeze=true)` still succeeds. `LoomCollection.freezeMetadata` has no base-URI check, so the collection is frozen with `tokenURI() == \"\"` permanently. The Info 7 fix says this path should behave \"as JigsawCollection\", but it only copied the reveal-later half of the check.\n3. **The keeper stops pushing an airdrop too early** (`offchain/keeper.mjs:166`). Since the Medium 2 fix, `pushableIds` puts contract-held ids in `contractHeld`. The keeper's stop condition still looks only at `ids`, so once a collection is complete it marks the airdrop done for good. An NFT that leaves an escrow contract for an EOA later in the 30-day window is never pushed, and the site shows \"done\".\n\n**Checked and cleared:**\n- **(1) Exclusions:** Exclusions are fixed in the coin's constructor before the mint, so no address earns rewards before it is excluded. The pot, AirdropClaim, hook, pool manager, factory, coin and dead address are all excluded. HolderDrip, RewardsForwarder, LPDeepening, SweepAndBurn and PotVault never hold coin. BuybackAndBurn burns what it buys in the same transaction. Coin someone sends straight to a module earns rewards nobody can claim, but only the sender loses.\n- **(2) `claimFor` skipping contracts:**\n  - EIP-7702 delegated EOAs have code, so they are skipped, but they can still call `claim()` themselves.\n  - Counterfactual wallets and contracts still under construction receive tokens they can move later.\n  - A contract that self-destructs or changes code just leaves its id unclaimed, to be pushed or claimed later; nobody can grief another id.\n  - The keeper does not loop: when only contract-held ids remain it returns without sending.\n- **(4) `recoverOwnership`:** `factory` is set only by `initialize`, inside the factory's creating call, and the implementation has initializers disabled, so it can't be spoofed. OpenZeppelin v5's `Ownable2StepUpgradeable._transferOwnership` clears the pending owner (I couldn't open the upgradeable library here because `lib/` is missing; this is from v5 itself). Trust assumption, not a defect: a compromised factory owner (the Safe) can take any clone at any time. That includes one already handed to a creator. They could then pause it, change its base URI before freeze, set the reveal seed or reset phases. This does not affect who can launch the coin, because `creator()` is fixed.\n- **(3) and (5) freeze checks:** Both hold on the paths they changed. The only gap is finding 2.\n- **(6) `notifyReward(0)`:** Only the forwarder can notify, and only from `pull()`. Since `MIN_INTERVAL == DURATION`, the previous stream has always ended when the next pull runs, so nothing restarts or stretches. A zero-amount `transferFrom` succeeds without allowance. The paused state is checked first. Dust carry under 86,400 wei just sets a zero rate.\n- **(7) Accepted items (Low 4, Info 6, Info 9):** I agree with all three, given that only your own 1-based collections are served and pauses are emergency-only.\n\n**Coverage:** I read the coin, f","treeHash":null,"usage":{"cachedInputTokens":1137414,"inputTokens":34,"model":"claude-opus-5-5","outputTokens":15554,"runtime":"claude","turns":18,"wallClockMs":175982}}],"verification":[]}