{"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":"81819cdd-0f33-4246-8cef-87b6b7e4a418","kind":"audit","nodes":[{"acceptedSubmissionHash":"8603fd7daab7804fb97d6b63afaac666831360555575c01318604fced5fcece3","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"5f3989ee4c7e9a4d5a179266cd38e0a72101618529d71d87c826205401d4d040","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"0c24bcb45f96a8d5bf0072f983f5c8e3c1705a830a912c03bbce4a0663ff896a","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","subagents":4,"tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"23fd2ed283078506c725c4ffa6ea6724ae342f3d7d3094b140d4e5a754bc1648","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"23d38abf4398093a037ccaecd3cdd1ae379e73e4d0639594c690b95a075237b0","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"}],"objective":"Weir Townies: AUDIT ONLY. Review src/WeirTownies.sol from this repository as it arrives (do not edit src/, test/, foundry.toml or lib/) and report findings; do NOT deploy anything, do NOT create a token, pool, distributor or website. The owner deploys from script/Deploy.s.sol with their own key after findings at medium or above are fixed. Context: an ERC-721 (OpenZeppelin v5.5.0: ERC721, ERC721Enumerable, ERC2981, Ownable2Step, MerkleProof, ReentrancyGuard) for 3,333 tokens on Robinhood Chain (chainId 4663, Arbitrum Orbit, cancun). Token ids are laid out by the owner before the sale (setLayout, sealPools): an owner-minted reserve, then role pools each with its own Merkle root and a one-claim-per-wallet rule, then the general range sold first to a Merkle allowlist and then publicly at an ETH price with a per-wallet cap of 3. Metadata is off-chain at baseURI + id and the contract emits ERC-4906 events from an updater address. Money: msg.value must equal price x qty; withdraw() sends the balance to the treasury and anyone may call it. Please look hardest at: the id layout arithmetic (ranges never overlap, never exceed MAX_SUPPLY, no id minted twice), the Merkle leaf encoding (double keccak of abi.encode(address)) and proof misuse across pools, the per-wallet caps, reentrancy through _safeMint receivers, owner powers after the sale (nothing should touch sold tokens), and the withdraw path. Build: forge build && forge test (20 tests incl. fuzz and a reentrant receiver). Report any finding at medium or above and block on high or critical. Do not claim an independent audit.","parentJobId":null,"planHash":"a15286f9d302b38b9bb3b8add8350503f514f1a340c6eb81498127369111a873","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"81819cdd-0f33-4246-8cef-87b6b7e4a418","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":"52480","feedbackHash":"c4c8ba55ca0b710f3f9e1cb8be2cfde2ee47f0151f22c329128d4276e72780f4","nodeKey":"audit_economics","submissionHash":"8603fd7daab7804fb97d6b63afaac666831360555575c01318604fced5fcece3","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"54230","feedbackHash":"e6e9b77231bc78c4d9e6402c63fa1b3e8bda4d66f920b214128e4d8954ff72b5","nodeKey":"audit_flow","submissionHash":"5f3989ee4c7e9a4d5a179266cd38e0a72101618529d71d87c826205401d4d040","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51064","feedbackHash":"debf3b28f7a4549a673846d728a9df640372fdb7e8bc1768c0c4745d34ff273d","nodeKey":"audit_judge","submissionHash":"0c24bcb45f96a8d5bf0072f983f5c8e3c1705a830a912c03bbce4a0663ff896a","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"53917","feedbackHash":"3ce3ea8a492cf2e9d9cc2ba293ff55a56dcd8f84ad7d2d81f075a1ef1cf18478","nodeKey":"audit_math","submissionHash":"23fd2ed283078506c725c4ffa6ea6724ae342f3d7d3094b140d4e5a754bc1648","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52176","feedbackHash":"07b4b991ba934ea7ebd7a687754d6d748c0707018298cf397e74012a79f8b833","nodeKey":"audit_permissions","submissionHash":"23d38abf4398093a037ccaecd3cdd1ae379e73e4d0639594c690b95a075237b0","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"243efcf06616f8f1a26d0d9eadc358af5e783a219e86e35191033de7c432b89c","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"63274cc86a9400de","findings":[{"citation":"resolved","description":"The storage comment on `treasury` (line 52) says it 'receives withdrawals and royalties' and the constructor couples the two with _setDefaultRoyalty(treasury_, 500). setTreasury() rewrites only the `treasury` slot and never refreshes ERC2981's default receiver, so royaltyInfo() keeps naming the previous address until the owner separately calls setRoyalty(bps), which re-reads `treasury`. Neither the README deploy steps nor script/Configure.s.sol (which has no royalty action) include that second call, so the documented operational flow leaves the two sinks diverged after any rotation, with no on-chain signal (TreasurySet fires, no royalty event does). Impact: after a treasury rotation, which is most likely exactly when the old key is lost or compromised, every marketplace that honours ERC-2981 keeps paying the 5% secondary-sale royalty to the retired address while withdraw() pays the new one. Owner-recoverable with one extra setRoyalty call, so medium rather than high. Minimal fix that preserves the design: keep the royalty fraction in storage (set by setRoyalty, default 500) and in setTreasury call _setDefaultRoyalty(t, royaltyBps) after `treasury = t;` (or read the current fraction via royaltyInfo(0, _feeDenominator())). Merged from four specialists (math, permissions, economics, flow) who all reported the same mechanism; severity taken from the permissions specialist because the compromised-key scenario leaks revenue for an unbounded period.","line":280,"path":"src/WeirTownies.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {WeirTownies} from \"src/WeirTownies.sol\";\n\n/// setTreasury() rotates the withdraw destination but leaves the ERC-2981 default royalty receiver at the old\n/// treasury. The contract documents `treasury` as the address that \"receives withdrawals and royalties\".\ncontract RoyaltyReceiverDriftTest is Test {\n    WeirTownies t;\n    address owner = makeAddr(\"owner\");\n    address oldTreasury = makeAddr(\"oldTreasury\");\n    address newTreasury = makeAddr(\"newTreasury\");\n\n    function setUp() public {\n        t = new WeirTownies(owner, oldTreasury, 100, keccak256(\"prov\"), 0, 0.001 ether);\n    }\n\n    function test_setTreasury_movesRoyaltyReceiver() public {\n        vm.prank(owner);\n        t.setTreasury(newTreasury);\n        assertEq(t.treasury(), newTreasury);\n        (address receiver, uint256 amount) = t.royaltyInfo(1, 1 ether);\n        assertEq(amount, 0.05 ether, \"royalty fraction unchanged\");\n        // Expected: royalties follow the treasury. Actual on current code: receiver == oldTreasury.\n        assertEq(receiver, newTreasury, \"royalty receiver still points at the old treasury\");\n    }\n}","reproduction":"Deploy WeirTownies(owner, T1, 100, prov, 0, 0.001 ether). royaltyInfo(1, 1 ether) == (T1, 0.05 ether). Owner calls setTreasury(T2). Expected per line 52: treasury() == T2 and royaltyInfo(1, 1 ether) == (T2, 0.05 ether). Actual: treasury() == T2 but royaltyInfo(1, 1 ether) == (T1, 0.05 ether). Only a subsequent setRoyalty(500) moves the receiver. Reproduced in test/scratch/Judge.t.sol::test_royaltyDrift (passes on current code asserting the stale receiver) and by the attached proof, which fails on the current code with 'royalty receiver still points at the old treasury' and passes once setTreasury also refreshes the default royalty receiver.","severity":"medium","snippet":"        treasury = t;","title":"setTreasury() rotates the withdraw destination but leaves the ERC-2981 royalty receiver at the old treasury"},{"citation":"resolved","description":"Ownable2Step only makes transferOwnership/acceptOwnership two-step. OpenZeppelin's Ownable.renounceOwnership() (lib/openzeppelin-contracts/contracts/access/Ownable.sol) remains exposed, onlyOwner, and sets owner to address(0) in one transaction with no confirmation. The contract has no other admin role, so after a mistaken or compromised-key renounce nothing can ever call setPhase, ownerMint, setAllowlistRoot, setPoolRoot, setPrices, setTreasury, setRoyalty, setBaseURI, setRenderer or setUpdater. If it happens while phase == Closed the sale can never open; mid-sale the phase can never be closed, the remaining reserve can never be minted, and a treasury that later rejects ETH (see the withdraw note) would strand the balance permanently. The file header lists the owner's powers and does not mention giving up ownership, so this is a documented-intent gap, not a design choice. Fix: `function renounceOwnership() public view override onlyOwner { revert(); }`, or gate it behind a sentinel (phase == Closed and reserveMinted == reserveSize) if a deliberate end-of-life freeze is wanted, and state the policy in the NatSpec. Merged from the permissions (low) and flow (info) specialists.","line":28,"path":"src/WeirTownies.sol","reproduction":"State: layout sealed, phase Public. Owner calls renounceOwnership(). Actual: owner() == address(0) in one call; owner then calling setPhase(Closed) reverts OwnableUnauthorizedAccount(owner). Expected from a reader of 'Ownable2Step' and the header's enumerated powers: no single call that removes all configuration ability. Reproduced in test/scratch/Judge.t.sol::test_renounceOneStep.","severity":"low","snippet":"contract WeirTownies is ERC721, ERC721Enumerable, ERC2981, Ownable2Step, ReentrancyGuard {","title":"renounceOwnership() is inherited single-step and permanently freezes every owner-gated step of the sale"},{"citation":"resolved","description":"generalMintedBy[msg.sender] and qty are both uint8, so the sum is evaluated in checked uint8 arithmetic. For a wallet with m prior general mints (1..3), any qty with m + qty > 255 reverts with an arithmetic panic before the comparison runs. The cap is never bypassed (checked arithmetic reverts rather than wraps, and the sum is only wrong when it already exceeds 3), so this is a revert-reason inconsistency that affects front-end error decoding only. The same checked-narrow-add pattern in setLayout (line 118 `reserveSize_ + 1`, line 123 `next += sizes[i]`) also only ever reverts. Optional fix: `if (uint256(generalMintedBy[msg.sender]) + qty > WALLET_CAP) revert WalletCapReached();`. Merged from three specialists.","line":209,"path":"src/WeirTownies.sol","reproduction":"Phase Public, publicPrice 0.001 ether. Wallet E calls mintPublic{value: 0.003 ether}(3). E calls mintPublic(252): reverts WalletCapReached (3 + 252 = 255 fits). E calls mintPublic(253): expected WalletCapReached, actual Panic(0x11). E still owns exactly 3 tokens. Reproduced in test/scratch/Judge.t.sol::test_uint8Panic.","severity":"info","snippet":"        if (generalMintedBy[msg.sender] + qty > WALLET_CAP) revert WalletCapReached();","title":"Wallet-cap check adds two uint8 values, so an over-cap qty near 255 reverts with Panic(0x11) instead of WalletCapReached"},{"citation":"resolved","description":"The owner's configuration script reads these values as uint256 and narrows them with bare uint32(...) casts (lines 26, 27, 41). Solidity 0.8 checked arithmetic does not cover explicit narrowing casts, so a value above 2^32-1 is reduced modulo 2^32 and passed to setLayout/ownerMint as a plausible small number; the contract's BadLayout check cannot catch it because the truncated value fits under MAX_SUPPLY. Because the `layout` action calls sealPools() in the same broadcast (line 28), a truncated pool size becomes immutable with no chance to inspect pools() first. The contract itself is unaffected by correctly typed input, and a value of exactly 2^32+k is an unlikely typo, so this is an operational note. Fix: bound-check before casting (require(sizesU[i] <= type(uint32).max) or SafeCast.toUint32), print pools and generalStart, and consider a separate ACTION for sealPools. Merged from the math and permissions specialists.","line":26,"path":"script/Configure.s.sol","reproduction":"Env: MAX_SUPPLY=3333, RESERVE_SIZE=33, POOL_SIZES='1,120,4294967297,80'. uint32(4294967297) == 1 (asserted in test/scratch/Judge.t.sol::test_uint32Truncation), so setLayout(33, [1,120,1,80], roots) succeeds and sealPools() freezes an Auditor pool of size 1 instead of 500; the general range is 499 ids larger than planned. Expected: the script refuses the out-of-range value.","severity":"info","snippet":"            for (uint256 i = 0; i < sizesU.length; i++) sizes[i] = uint32(sizesU[i]);","title":"Configure script silently truncates POOL_SIZES, RESERVE_SIZE and RESERVE_QTY to uint32 and seals the layout in the same run"},{"citation":"resolved","description":"Recorded per the brief, not bypasses. All are Ownable2Step-gated. (1) setRoyalty(bps) forwards to ERC2981._setDefaultRoyalty whose only bound is bps <= 10000, so the owner can set the advisory royalty to 100% of secondary sales at any time; the header's list of post-sale powers does not mention royalties. Optional: cap it (e.g. bps <= 1000). (2) setRenderer / setBaseURI fully control tokenURI for sold tokens; a renderer with no code or a reverting tokenURI makes tokenURI() revert for every token until the owner resets it to address(0). The provenance hash commits the art but nothing on chain ties served metadata to it. Optional: a one-way freezeMetadata(). (3) setPoolRoot may change any pool's eligibility list after sealPools (documented: 'a corrected snapshot'), including pointing it at owner wallets to take unclaimed pool ids; ranges stay frozen. (4) setPhase, setPrices and setAllowlistRoot may change at any time; because mints require msg.value == price * qty exactly, a price change in flight makes pending mints revert rather than over/under-pay. (5) ownerMint is callable in any phase but only within ids 1..reserveSize. Verified absent: no burn, no forced transfer, no transfer pause, no upgradeability, no ETH path other than mints and withdraw(). Merged from permissions (two), economics and flow specialists.","line":285,"path":"src/WeirTownies.sol","reproduction":"Any state. Owner calls setRoyalty(10000): royaltyInfo(1, 1 ether) returns (treasury, 1 ether). Owner calls setRenderer(EOA) after token 8 is minted: tokenURI(8) reverts for all callers; setRenderer(address(0)) restores '8'. Both reproduced in test/scratch/Judge.t.sol::test_royalty100 and ::test_rendererRevert. setPoolRoot after seal is covered by the project's own test_setPoolRootAfterSeal.","severity":"info","snippet":"        _setDefaultRoyalty(treasury, bps);","title":"Trust assumptions: owner powers that remain live after sealing and after the sale (none touches sold tokens or balances)"},{"citation":"resolved","description":"Sale proceeds can only leave through treasury.call with the full balance and all remaining gas. Two operational consequences of the intended 'anyone may call, only the treasury receives' design: (a) if the treasury is a contract whose receive()/fallback reverts or runs out of gas, every withdraw() reverts TransferFailed and ETH stays in the contract until the owner calls setTreasury; recoverable while an owner exists (see the renounceOwnership finding). (b) setTreasury() cannot be used to protect a balance already in the contract: anyone, including the holder of a leaked treasury key, can call withdraw() before the rotation is included, so the owner's rotation power is effectively the power to redirect the full balance only from that block onward. Operational mitigations: withdraw promptly after each phase; rotate before funds accumulate under a suspect key. Minor: with a zero balance withdraw() succeeds from any address and emits Withdrawn(treasury, 0), harmless indexer noise; optional `if (amount == 0) revert ZeroQuantity();`. No reentrancy path: withdraw and all mint functions share one ReentrancyGuard and state is updated before _safeMint. Merged from permissions, economics and flow specialists.","line":291,"path":"src/WeirTownies.sol","reproduction":"(a) Owner calls setTreasury(R) where R.receive() reverts; E mints 1 at 0.001 ether; withdraw() from any account reverts TransferFailed and the balance stays 0.001 ether; after setTreasury(T2), withdraw() succeeds and T2 receives 0.001 ether (test/scratch/Judge.t.sol::test_withdrawRejecting). (b) E mints 3 at 0.001 ether; E calls withdraw() before owner's setTreasury(T2): T1 receives 0.003 ether and the subsequent setTreasury protects nothing (::test_withdrawRace).","severity":"info","snippet":"        (bool ok,) = treasury.call{value: amount}(\"\");","title":"withdraw() is permissionless and all-or-nothing to the current treasury: a rejecting treasury blocks payout until rotation, and rotation cannot protect a balance already held"},{"citation":"resolved","description":"The cap is enforced on msg.sender only, and _safeMint accepts any contract implementing onERC721Received. One actor can deploy N minimal receivers in a single transaction, mint 3 per receiver, and transfer all tokens to one wallet. This is inherent to any per-address cap and standard for ERC-721 sales; recorded because the brief names the cap as a guarantee to examine. If concentration matters the usual options are a tx.origin == msg.sender check (excludes smart wallets), per-block limits, or documenting the cap as soft. Distinct mechanism from the uint8 panic finding at the same line.","line":209,"path":"src/WeirTownies.sol","reproduction":"Phase Public, publicPrice 0.001 ether. E calls a factory that deploys 10 Buyer contracts, each calling mintPublic{value: 0.003 ether}(3) and transferring its 3 tokens to E, all in one transaction. Result: balanceOf(E) == 30 and generalMintedBy[E] == 0. Reproduced in test/scratch/Judge.t.sol::test_capBypassViaReceivers.","severity":"info","snippet":"        if (generalMintedBy[msg.sender] + qty > WALLET_CAP) revert WalletCapReached();","title":"WALLET_CAP is per address and is bypassed by fresh contract receivers or EOAs; it limits per-address UX, not concentration"},{"citation":"resolved","description":"ownerMint is limited to ids 1..reserveSize. Pool ids can only be minted by a member of that pool's current root, one each, while the phase is open; general ids only via mintAllowlist/mintPublic while the phase is open. There is no owner sweep for leftovers, so the 3,333 provenance-committed ids may never be fully issued; the owner's only routes are to re-point a pool root at owner-controlled wallets (one id per wallet) or to keep Public open indefinitely. Design gap, not an exploit: no funds or sold tokens are affected. If every id should eventually exist, add an owner sweep gated on phase == Closed that mints the remaining pool/general ids sequentially; otherwise document that the final collection may be smaller. From the economics specialist.","line":169,"path":"src/WeirTownies.sol","reproduction":"Layout reserve 1..5, pool 0 = ids 6..7 with a single-wallet root (E). E claims id 6. Owner sets phase Closed. mintRole(0, ...) reverts WrongPhase; ownerMint reverts SoldOut once the 5 reserve ids are minted; ownerOf(7) reverts ERC721NonexistentToken. Id 7 is unmintable by any call while Closed. Reproduced in test/scratch/Judge.t.sol::test_strandedPoolIds.","severity":"info","snippet":"    function ownerMint(address to, uint32 qty) external onlyOwner nonReentrant {","title":"Unclaimed role-pool ids and unsold general ids have no mint path once the phase is Closed"},{"citation":"resolved","description":"The leaf encoding matches OpenZeppelin StandardMerkleTree ['address'] and the double hash prevents second-preimage use of internal nodes. Because the leaf carries no pool index or list identifier, a proof is bound only to the root. Pools with distinct roots are isolated (verified: a pool-1 proof fails on pool 0 with NotOnList). A proof crosses lists only if the owner sets the same root on two pools, or on a pool and the allowlist; then a wallet on the shared list claims one id per pool plus its allowlist quota, which is consistent with the stated one-claim-per-pool rule. Single-leaf trees (root == leaf, empty proof) work for a one-wallet pool such as Mayor. No code change needed; the root-generation tooling should never reuse a snapshot root across lists by accident. From the flow specialist.","line":220,"path":"src/WeirTownies.sol","reproduction":"Owner sets pool 0 root = keccak256(bytes.concat(keccak256(abi.encode(E)))) and setAllowlistRoot to the same value, phase Allowlist. E calls mintRole(0, []) and receives id 6; E calls mintAllowlist(1, []) and receives id 8; both with the same empty proof. After setAllowlistRoot(leaf(T2)), E's mintAllowlist reverts NotOnList. Reproduced in test/scratch/Judge.t.sol::test_sharedRoot and test/scratch/Layout.t.sol::test_everyIdMintedOnce.","severity":"info","snippet":"        bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account))));","title":"Merkle leaf is the bare address, so any two lists given the same root accept the same proof (root-management note)"}],"hash":"0c24bcb45f96a8d5bf0072f983f5c8e3c1705a830a912c03bbce4a0663ff896a","nodeId":"92409ba0-183a-43b9-8e05-f635d2287661","outcome":"completed","summary":"Review complete. The findings file holds 9 merged findings, and nothing at high or critical, so the deploy is not blocked on this review.\n\n**Verdict on the code.** The brief's hardest areas all hold up. Layout tiling never overlaps or exceeds MAX_SUPPLY (fuzzed 3000 runs over reserve and three pool sizes). Every id across the reserve, pool and general paths is minted exactly once to a sold-out collection. Pool proofs do not cross pools with distinct roots. Reentrancy through `_safeMint` is blocked by the shared guard with state written before the external call. Owner powers never reach a sold token. The project's own 21 tests pass.\n\n**What I kept, after reproducing each claim in test/scratch:**\n\n- **Medium: setTreasury leaves the ERC-2981 royalty receiver at the old treasury.** Four specialists reported this one mechanism. The storage comment and constructor couple the two sinks, but the setter rewrites only one, and neither the README nor the configure script includes the setRoyalty call that re-syncs them. The specialist proof fails on the current code for the stated reason and is attached. Fix is two lines inside setTreasury.\n- **Low: renounceOwnership is single-step** and permanently freezes phase, reserve mints, treasury rotation and every other owner action. The header's list of owner powers does not mention it.\n- **Info, seven items.** The uint8 cap arithmetic panics instead of reverting with the custom error (cap never bypassed). The configure script truncates sizes to uint32 and seals in the same run. Owner trust assumptions after the sale: royalty up to 100%, full tokenURI control, pool roots changeable after seal. Withdraw path: a rejecting treasury blocks payout until rotation, and rotation cannot protect a balance already held. The per-address cap is bypassed by fresh receivers. Unclaimed pool and general ids are stranded once Closed. Shared roots across lists accept the same proof.\n\n**Merging.** The royalty drift (4 reports), the uint8 panic (3), the script truncation (2), the renounce note (2), and the owner-power and withdraw trust notes (3 each) were each collapsed into one finding. Nothing a specialist reported failed to reproduce, so nothing was dropped.\n\n**Severity call worth flagging.** Three specialists rated the royalty drift low and one medium. I kept medium because the trigger is the documented rotation flow itself, the leak lasts until noticed with no on-chain signal, and the rotation is most likely in exactly the compromised-key scenario where it matters.\n\n**Not done.** The attached proof was run against the current code only. I did not apply the fix to confirm it passes afterwards, since src/ is out of bounds for this review. Slither was not run, per the task rules. This was a review of one commissioned audit, not an independent audit.","treeHash":null,"usage":{"cachedInputTokens":660448,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":20588,"runtime":"claude","turns":20,"wallClockMs":318611}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ee2cc12e7dfe571a","findings":[{"citation":"resolved","description":"The constructor binds royalties to the treasury with _setDefaultRoyalty(treasury_, 500) and the storage comment on `treasury` says it \"receives withdrawals and royalties\". setTreasury() only rewrites `treasury`; it never calls _setDefaultRoyalty, so royaltyInfo() keeps returning the previous address. Only setRoyalty(bps) re-syncs the receiver, and only if the owner happens to call it again after a rotation. Asymmetry: constructor (treasury_ -> both sinks) vs setTreasury (treasury only). Trust-gap seam access x asymmetry: the owner's rotation moves ETH withdrawals to the new address while secondary-market royalties paid by marketplaces keep flowing to the old one. If the rotation was made because the old treasury key was lost or compromised, every royalty payment after the rotation goes to an address the project no longer controls, and nothing on chain signals the mismatch. Fix: in setTreasury, after `treasury = t;` also refresh the default royalty with the current fraction, e.g. `(, uint256 bps) = royaltyInfo(0, _feeDenominator()); _setDefaultRoyalty(t, uint96(bps));` (or keep the bps in a state variable), so both sinks always agree. Alternatively make setRoyalty take the receiver explicitly and document that the two are independent.","line":280,"path":"src/WeirTownies.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {WeirTownies} from \"src/WeirTownies.sol\";\n\n/// setTreasury() rotates the withdraw destination but leaves the ERC-2981 default royalty receiver at the old\n/// treasury. The contract documents `treasury` as the address that \"receives withdrawals and royalties\".\ncontract RoyaltyReceiverDriftTest is Test {\n    WeirTownies t;\n    address owner = makeAddr(\"owner\");\n    address oldTreasury = makeAddr(\"oldTreasury\");\n    address newTreasury = makeAddr(\"newTreasury\");\n\n    function setUp() public {\n        t = new WeirTownies(owner, oldTreasury, 100, keccak256(\"prov\"), 0, 0.001 ether);\n    }\n\n    function test_setTreasury_movesRoyaltyReceiver() public {\n        vm.prank(owner);\n        t.setTreasury(newTreasury);\n        assertEq(t.treasury(), newTreasury);\n        (address receiver, uint256 amount) = t.royaltyInfo(1, 1 ether);\n        assertEq(amount, 0.05 ether, \"royalty fraction unchanged\");\n        // Expected: royalties follow the treasury. Actual on current code: receiver == oldTreasury.\n        assertEq(receiver, newTreasury, \"royalty receiver still points at the old treasury\");\n    }\n}","reproduction":"State: fresh deployment with treasury = A. Owner calls setTreasury(B). Expected: treasury() == B and royaltyInfo(1, 1 ether) == (B, 0.05 ether). Actual: treasury() == B but royaltyInfo(1, 1 ether) == (A, 0.05 ether). The scratch test test/scratch/RoyaltyReceiverDrift.t.sol fails on the current code with \"royalty receiver still points at the old treasury\" and passes once setTreasury also refreshes the default royalty receiver.","severity":"medium","snippet":"        treasury = t;","title":"setTreasury() rotates the withdraw destination but leaves the ERC-2981 royalty receiver at the old treasury"},{"citation":"resolved","description":"Ownable2Step only protects transferOwnership/acceptOwnership. OpenZeppelin's Ownable.renounceOwnership() (lib/openzeppelin-contracts/contracts/access/Ownable.sol:76) is still exposed, onlyOwner, and sets owner to address(0) in one call with no confirmation. The contract has no other admin, so after a mistaken or compromised-key renounce nothing can ever call setPhase, ownerMint, setAllowlistRoot, setPoolRoot, setPrices, setTreasury, setRoyalty, setBaseURI, setRenderer or setUpdater. If it happens while phase == Closed the sale can never open; if it happens mid-sale the phase can never be closed, the reserve can never be minted and the treasury can never be rotated. The NatSpec at the top of the file lists what the owner can do and does not mention giving up ownership, so this is a documented-intent gap rather than a design choice. Fix: `function renounceOwnership() public view override onlyOwner { revert(); }` (or require a sentinel such as phase == Closed and reserveMinted == reserveSize if a deliberate freeze is wanted) and state the policy in the NatSpec.","line":28,"path":"src/WeirTownies.sol","reproduction":"State: layout sealed, phase == Closed. Owner calls renounceOwnership(). Then owner() == address(0) and owner calling setPhase(Public), ownerMint(owner, 1) or setTreasury(x) all revert with OwnableUnauthorizedAccount(owner). Verified in test/scratch/OwnerPowers.t.sol::test_renounceIsSingleStepAndFreezesSale on the current code. Expected per the contract's own description of owner powers: no single call that permanently removes all configuration ability.","severity":"low","snippet":"contract WeirTownies is ERC721, ERC721Enumerable, ERC2981, Ownable2Step, ReentrancyGuard {","title":"renounceOwnership() is inherited single-step and immediately freezes every owner-gated step of the sale"},{"citation":"resolved","description":"withdraw() is intentionally permissionless and always pays the current treasury; that design is sound against outsiders (no one can redirect funds). The consequence worth documenting is that setTreasury() cannot be used to protect a balance already in the contract: anyone, including the holder of a leaked treasury key, can call withdraw() in the same block as, or before, the owner's setTreasury() transaction. On Robinhood Chain the sequencer is first-come-first-served, so the window is simply 'any time before the rotation is included'. No code change is required; the operational mitigation is to withdraw promptly after each sale phase and to rotate the treasury before, not after, any funds accumulate under a key that is suspected to be compromised. If the owner wants withdraw() to be rotation-safe, restrict it to owner or treasury.","line":291,"path":"src/WeirTownies.sol","reproduction":"State: phase Public, attacker mints 3 at 0.001 ether each so the contract holds 0.003 ether, treasury A's key is compromised. Attacker calls withdraw() before the owner's setTreasury(B) is included. Actual: A.balance == 0.003 ether, contract balance 0, setTreasury(B) then succeeds but protects nothing. Shown in test/scratch/OwnerPowers.t.sol::test_publicWithdrawRacesTreasuryRotation.","severity":"info","snippet":"        (bool ok,) = treasury.call{value: amount}(\"\");","title":"Trust assumption: ETH held by the contract belongs to whoever controls the current treasury, so a compromised treasury key can sweep it via the public withdraw() before a rotation lands"},{"citation":"resolved","description":"setRoyalty(bps) forwards to OpenZeppelin ERC2981._setDefaultRoyalty, whose only bound is bps <= 10000. ERC-2981 is advisory and enforced by marketplaces, so this cannot move tokens, but it is an owner power over sold tokens that the file header's list ('close the phase, change prices, roots, the base URI, the updater and the treasury, and nothing else') does not mention. Document it, or cap it (e.g. revert if bps > 1000) so holders have an on-chain upper bound.","line":285,"path":"src/WeirTownies.sol","reproduction":"State: any. Owner calls setRoyalty(10000). royaltyInfo(1, 1 ether) returns (treasury, 1 ether). Expected per header: royalty not listed among post-sale owner powers; actual: unbounded up to the OZ denominator.","severity":"info","snippet":"        _setDefaultRoyalty(treasury, bps);","title":"Trust assumption: owner can set the default royalty to any value up to 100% at any time after the sale"},{"citation":"resolved","description":"By design (live traits, future on-chain art) the owner may repoint metadata after mint. The provenance hash commits the art but nothing on chain ties the served metadata to it. Two consequences for holders: (1) the owner, or a compromised owner key, can change what every sold token displays; (2) if the renderer contract reverts or runs out of gas, tokenURI() reverts for all tokens until the owner calls setRenderer(address(0)). Both are recoverable by the owner and are consistent with the stated design; they are recorded here so the trust model is explicit. If a stronger guarantee is wanted later, add a one-way freezeMetadata() that disables both setters.","line":228,"path":"src/WeirTownies.sol","reproduction":"State: token 1 minted, owner calls setRenderer(R) where R.tokenURI reverts. tokenURI(1) reverts for every caller. Owner calls setRenderer(address(0)); tokenURI(1) returns baseURI + '1' again. No unprivileged trigger exists; owner-only.","severity":"info","snippet":"        if (address(renderer) != address(0)) return renderer.tokenURI(tokenId);","title":"Trust assumption: tokenURI for sold tokens is fully owner-controlled through setBaseURI and setRenderer, and a reverting renderer makes tokenURI revert for every token"},{"citation":"resolved","description":"generalMintedBy[msg.sender] (uint8) + qty (uint8) is evaluated in uint8 and is checked, so when the sum exceeds 255 the call reverts with an arithmetic panic before the comparison runs. The cap itself is still enforced (no path mints more than 3), so this is cosmetic: front-ends decoding custom errors will see a panic instead of WalletCapReached. Fix: widen one operand, e.g. `uint256(generalMintedBy[msg.sender]) + qty > WALLET_CAP`.","line":209,"path":"src/WeirTownies.sol","reproduction":"State: phase Public, wallet W has generalMintedBy == 3. W calls mintPublic{value: 0}(253). Expected: revert WalletCapReached(). Actual: revert Panic(0x11). W still owns exactly 3 tokens. Shown in test/scratch/OwnerPowers.t.sol::test_capOverflowPanicsInsteadOfCustomError.","severity":"info","snippet":"        if (generalMintedBy[msg.sender] + qty > WALLET_CAP) revert WalletCapReached();","title":"Wallet-cap check uses uint8 arithmetic, so an over-cap request can revert with Panic(0x11) instead of WalletCapReached"},{"citation":"resolved","description":"A POOL_SIZES or RESERVE_SIZE value above 2^32-1 is silently truncated before setLayout, and `layout` immediately calls sealPools() so there is no chance to read back pools()/generalStart before the ranges become immutable. With MAX_SUPPLY = 3333 any real typo above 3333 is rejected by the contract's BadLayout check, so the realistic failure is a value such as 4294967297 (-> 1). Out of the contract's scope, operational only. Suggested: require sizesU[i] <= type(uint32).max in the script, and/or print the pools and generalStart and gate sealPools() behind a separate ACTION so the layout can be inspected on chain first (the README already advises a rehearsal deploy).","line":26,"path":"script/Configure.s.sol","reproduction":"State: ACTION=layout, POOL_SIZES=\"1,4294967297,500,80\". Expected: script aborts. Actual: setLayout receives sizes [1,1,500,80], the pools are sealed with the Judge pool of size 1, and no further layout change is possible.","severity":"info","snippet":"            for (uint256 i = 0; i < sizesU.length; i++) sizes[i] = uint32(sizesU[i]);","title":"Configure script truncates POOL_SIZES to uint32 and seals the layout in the same transaction batch as it sets it"}],"hash":"23d38abf4398093a037ccaecd3cdd1ae379e73e4d0639594c690b95a075237b0","nodeId":"c0449386-a492-4f11-ab97-55619914b298","outcome":"completed","summary":"The review is complete. The findings file is in place and no source, test, config or library file was modified. Nothing blocks deployment: there is no high or critical finding.\n\n**Result: one medium, one low, five informational.** Build and all 21 project tests pass on forge 1.8.5 with the vendored OpenZeppelin 5.5.0.\n\n**Medium: `setTreasury` leaves the ERC-2981 royalty receiver at the old treasury.** The constructor binds royalties to the treasury, and the storage comment says the treasury receives withdrawals and royalties, but the setter only rewrites the withdraw destination. After a rotation, marketplace royalties keep flowing to the old address, which is exactly the address the owner wanted to abandon if the key was lost or compromised. The scratch test at `test/scratch/RoyaltyReceiverDrift.t.sol` fails on the current code and is attached as proof. The fix is to refresh the default royalty inside the setter with the current fraction.\n\n**Low: `renounceOwnership` is inherited, single-step, and freezes the whole sale.** Ownable2Step only guards the transfer path. One call sets the owner to zero, after which phase, reserve mint, roots, treasury and metadata can never change. Overriding it to revert matches the file's own description of owner powers.\n\n**Informational items, recorded as trust assumptions rather than defects:**\n- Public `withdraw` pays the current treasury, so a compromised treasury key can sweep the balance before a rotation lands. Withdraw promptly and rotate before funds accumulate.\n- `setRoyalty` is unbounded up to 100 percent and is not in the header's list of post-sale owner powers.\n- Metadata for sold tokens is owner-controlled through the base URI and renderer hook. A reverting renderer makes every `tokenURI` revert until reset.\n- The wallet-cap check does uint8 arithmetic, so an over-cap request can panic instead of reverting with the custom error. The cap itself holds.\n- The Configure script truncates pool sizes to uint32 and seals in the same run, leaving no on-chain inspection window.\n\n**Areas checked in depth that came back clean.** Id layout arithmetic tiles exactly with no overlap or gap, confirmed by a 256-run fuzz of my own over reserve, four pools and MAX_SUPPLY. Reserve, pool and general counters each stay inside their range. The Merkle leaf is the standard OpenZeppelin single-address encoding, a zero root always rejects, and a proof only crosses pools if the owner reuses the identical root. Every mint path updates state before `_safeMint`, and every mint plus `withdraw` is guarded by the reentrancy lock. No inherited or owner function can transfer, burn or re-mint a sold token. Only ownership was reviewed in the assigned permissions area; the math, economics and control-flow passes were applied as my own secondary read, and no helpers ran.","treeHash":null,"usage":{"cachedInputTokens":707940,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":28350,"runtime":"claude","turns":30,"wallClockMs":394515}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1eba9b0f789fcc77","findings":[{"citation":"resolved","description":"The `treasury` field is documented (line 52: `address public treasury; // receives withdrawals and royalties`) as the receiver of both withdrawals and royalties, but the two are stored separately. `setTreasury` updates only the withdraw destination; the ERC-2981 default royalty receiver set in the constructor (`_setDefaultRoyalty(treasury_, 500)`, line 107) is untouched until the owner separately calls `setRoyalty(bps)` (line 284-286), which re-reads `treasury`. After a treasury rotation (lost key, new multisig), every marketplace that honours ERC-2981 keeps sending the 5% royalty on secondary sales to the retired address. There is no on-chain signal that the two diverged: `TreasurySet` fires, no royalty event does. This is an owner-operated path, so the impact is operational (royalties misdirected to an address the team may no longer control) rather than an exploit. Minimal fix that preserves the design: in `setTreasury`, after `treasury = t;` call `(, uint256 num) = royaltyInfo(0, _feeDenominator());` to read the current bps and then `_setDefaultRoyalty(t, uint96(num));`, or store the bps in a state variable set by `setRoyalty` and re-apply it in `setTreasury`.","line":280,"path":"src/WeirTownies.sol","reproduction":"Deploy WeirTownies(owner, T1, 100, 0, 0, 0). As owner call setTreasury(T2). Then royaltyInfo(1, 1 ether) returns (T1, 0.05 ether) while treasury() returns T2. Expected per the field comment: (T2, 0.05 ether). Scratch test test_setTreasuryLeavesRoyaltyReceiver in test/scratch/Math.t.sol asserts the stale receiver and passes on the current code.","severity":"low","snippet":"        treasury = t;","title":"setTreasury leaves the ERC-2981 royalty receiver pointing at the old treasury"},{"citation":"resolved","description":"`generalMintedBy[msg.sender]` is uint8 and `qty` is uint8, so the sum is computed in uint8 with checked arithmetic. For a wallet that has already minted m tokens (1..3), any qty with m + qty > 255 overflows and the call reverts with Panic(0x11) rather than the custom `WalletCapReached` error. The outcome is still a revert and no state changes, so there is no supply or payment impact; the only effect is a less informative error for front-ends decoding reverts. The same pattern (checked narrow-type addition reverting with a panic rather than BadLayout) exists in `setLayout` line 118 (`reserveSize_ + 1`) and line 123 (`next += sizes[i]`) when the inputs approach type(uint32).max; also harmless because every overflow path reverts. Optional fix: widen the compare, e.g. `if (uint256(generalMintedBy[msg.sender]) + qty > WALLET_CAP)`.","line":209,"path":"src/WeirTownies.sol","reproduction":"Layout sealed, phase Public, publicPrice 0. Wallet W calls mintPublic(1) (succeeds). W calls mintPublic(254): reverts WalletCapReached (1 + 254 = 255 fits). W calls mintPublic(255): expected WalletCapReached, actual Panic(uint256) 0x11 because 1 + 255 overflows uint8. Scratch test test_uint8CapOverflowPanicsInsteadOfWalletCap in test/scratch/Math.t.sol reproduces this.","severity":"info","snippet":"        if (generalMintedBy[msg.sender] + qty > WALLET_CAP) revert WalletCapReached();","title":"Wallet-cap check does uint8 arithmetic: qty near 255 reverts with Panic(0x11) instead of WalletCapReached"},{"citation":"resolved","description":"The owner's configuration script reads POOL_SIZES, RESERVE_SIZE and RESERVE_QTY as uint256 from the environment and narrows them with bare `uint32(...)` casts (lines 26, 27 and 41). Solidity 0.8 checked arithmetic does not cover explicit narrowing casts, so a mistyped value above 2^32-1 is reduced modulo 2^32 and passed to `setLayout`/`ownerMint` as a plausible small number. Because the `layout` action calls `sealPools()` in the same run (line 28), a truncated pool size is frozen permanently; the contract's own `BadLayout` check cannot catch it because the truncated value still fits under MAX_SUPPLY. The contract itself is not affected on-chain by a correctly typed input; this is a tooling guard missing on the one irreversible owner action. Fix: bound-check before casting, e.g. `require(sizesU[i] <= type(uint32).max, \"size\")`, or use SafeCast.toUint32 from the vendored OpenZeppelin.","line":26,"path":"script/Configure.s.sol","reproduction":"Env: MAX_SUPPLY=3333, RESERVE_SIZE=33, POOL_SIZES=\"1,120,4294967297,80\" (the third entry is 2^32 + 1, a corrupted 500). uint32(4294967297) = 1, so the Auditor pool is sealed with size 1 instead of 500 and the general range is 499 ids larger than planned. Expected: the script refuses the out-of-range value. Actual: `setLayout(33, [1,120,1,80], roots)` succeeds and `sealPools()` freezes it in the same broadcast; the console log 'general starts at 236' is the only hint. Same for RESERVE_SIZE=4294967329 (sealed as 33) and RESERVE_QTY=4294967297 (mints 1).","severity":"low","snippet":"            for (uint256 i = 0; i < sizesU.length; i++) sizes[i] = uint32(sizesU[i]);","title":"Configure script silently truncates pool sizes, reserve size and reserve qty to uint32"}],"hash":"23fd2ed283078506c725c4ffa6ea6724ae342f3d7d3094b140d4e5a754bc1648","nodeId":"7112f573-ae1b-4e37-95f6-6f5ddd382d68","outcome":"completed","summary":"The review is complete. The findings file is valid, all three snippets match their lines, and the full suite plus my scratch tests pass (29 tests, 0 failures).\n\n**Outcome: no finding at medium or above in the assigned math area. Nothing blocks deployment.**\n\n**What I verified in the id layout arithmetic** (Math Precision, Boundary, Numerical Gap passes over `src/WeirTownies.sol`):\n\n- **Ranges tile exactly.** A fuzz over random reserve sizes and up to 8 pool sizes confirms that setLayout either reverts or leaves reserve, pools and general range contiguous with no overlap, the last id never above MAX_SUPPLY, and generalSize equal to MAX_SUPPLY + 1 - generalStart.\n- **No id minted twice, none skipped.** A full mint-out of a 40-token layout (reserve 4, pools 1/2/3, general 30) produces every id 1..40 exactly once, totalSupply equal to MAX_SUPPLY, and SoldOut / PoolExhausted / AlreadyClaimed on every further attempt. The ERC721 mint itself is a second guard against a duplicate id.\n- **Edges hold.** An empty general range (layout consuming all of MAX_SUPPLY), MAX_SUPPLY at the uint32 ceiling, and every uint32 overflow path revert cleanly. All narrow-type arithmetic is checked, so overflow always reverts rather than wraps. There is no division, so no rounding direction or zero-rounding issue exists.\n- **Payment and caps.** msg.value must equal price times qty in uint256, so no truncation or excess. The per-wallet cap is checked before the supply check and the state is written before the external _safeMint call, so a reentering receiver cannot double-mint. Merkle leaves are the OpenZeppelin StandardMerkleTree encoding and a proof against one pool's root does not verify against another.\n\n**Findings recorded in `.imd-findings.json`:**\n\n- **Low, setTreasury leaves the royalty receiver stale.** After setTreasury(T2), royaltyInfo still returns the constructor treasury until setRoyalty is called again. The field comment says the treasury receives royalties, so this is a documented-intent mismatch. Reproduced in a scratch test.\n- **Info, uint8 cap arithmetic.** A wallet that has minted one token and calls mintPublic(255) reverts with Panic(0x11) instead of WalletCapReached. Harmless, since every path reverts.\n- **Low, Configure script truncates to uint32 without a bounds check.** POOL_SIZES containing 4294967297 is sealed as a pool of size 1 in the same broadcast that calls sealPools, so the mistake is irreversible. This is in the owner tooling, not the contract.\n\n**Trust assumptions noted, not findings:** the owner can reopen the Allowlist phase after Public, change prices and roots at any time, and swap tokenURI for all tokens through setRenderer or setBaseURI. These match the stated design and do not touch token ownership.\n\n**Limits.** This is one reviewer's pass over the assigned math guides plus a read of the surrounding entry points, not an independent audit. Slither was not run, and no helpers were used.","treeHash":null,"usage":{"cachedInputTokens":472864,"inputTokens":258,"model":"claude-fable-5-1","outputTokens":23116,"runtime":"claude","turns":19,"wallClockMs":362016}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"aa333626543c4dee","findings":[{"citation":"resolved","description":"The `treasury` field is documented (line 52) as the address that 'receives withdrawals and royalties', and the constructor sets both `treasury` and the ERC-2981 default receiver to the same address. `setTreasury` only rewrites `treasury`; the royalty receiver stored in ERC2981 stays at the previous address until the owner separately calls `setRoyalty(bps)` (which re-reads `treasury`). Nothing in the contract or in script/Configure.s.sol couples the two, and there is no `royalty` action in the configure script at all. After a treasury rotation (lost key, multisig migration, compromised wallet) every marketplace that honours ERC-2981 continues to pay secondary-sale royalties to the stale address. Withdrawals are unaffected. Owner-recoverable, so low. Fix: in setTreasury, re-point the royalty receiver, e.g. `(, uint256 bps) = royaltyInfo(0, _feeDenominator()); _setDefaultRoyalty(t, uint96(bps));` or keep the royalty bps in a storage variable and call `_setDefaultRoyalty(t, royaltyBps)`.","line":280,"path":"src/WeirTownies.sol","reproduction":"Deploy with treasury = T1. Owner calls setTreasury(T2). Expected: royaltyInfo(1, 1 ether) returns (T2, 0.05 ether). Actual: returns (T1, 0.05 ether); t.treasury() returns T2. Verified by test test_royaltyReceiverStaysOldAfterSetTreasury in test/scratch/Leads.t.sol.","severity":"low","snippet":"        treasury = t;","title":"setTreasury does not move the ERC-2981 royalty receiver; royalties keep going to the old treasury"},{"citation":"resolved","description":"`generalMintedBy[msg.sender]` and `qty` are both uint8, so the sum is evaluated in uint8 with checked arithmetic. Once a wallet holds 3 general mints, any qty >= 253 makes the sum exceed 255 and the call reverts with a Panic(0x11) overflow rather than the custom error. The cap itself is never bypassed (the revert still happens, and the sum is only wrong when it would exceed the cap anyway), so this is a revert-reason inconsistency that affects front-end error decoding, not supply. Fix: widen before adding, e.g. `if (uint256(generalMintedBy[msg.sender]) + qty > WALLET_CAP) revert WalletCapReached();`.","line":209,"path":"src/WeirTownies.sol","reproduction":"Phase Public, publicPrice 0.001 ether. Wallet E calls mintPublic{value: 0.003 ether}(3), then mintPublic{value: 0}(255). Expected: revert WalletCapReached(). Actual: revert Panic(0x11). A fresh wallet calling mintPublic(255) gets WalletCapReached as expected. Verified by test_capCheckPanicsOnLargeQty in test/scratch/Leads.t.sol.","severity":"low","snippet":"        if (generalMintedBy[msg.sender] + qty > WALLET_CAP) revert WalletCapReached();","title":"Wallet-cap check adds two uint8 values before comparing, so large qty reverts with Panic(0x11) instead of WalletCapReached"},{"citation":"resolved","description":"The leaf encoding is correct for OpenZeppelin StandardMerkleTree ['address'] and the double hash prevents second-preimage use of internal nodes. Because the leaf does not include the pool index or a list identifier, a proof is bound only to the root. Pools with distinct roots are isolated (verified: an auditor-pool proof fails against the mayor root). The only way a proof crosses lists is if the owner sets the same root on two pools, or on a pool and the allowlist; in that case a wallet on the shared list claims one token per pool plus its allowlist quota, which matches the stated one-claim-per-pool rule. Single-leaf trees (root == leaf, empty proof) work as expected for a one-wallet pool such as Mayor. No change needed; noted so that the root-generation tooling never reuses a snapshot root across lists by accident.","line":220,"path":"src/WeirTownies.sol","reproduction":"Owner sets pool 0 root = keccak256(bytes.concat(keccak256(abi.encode(E)))) and setAllowlistRoot to the same value. E calls mintRole(0, []) in phase Allowlist: receives id 6. E calls mintAllowlist(1, []): receives id 8. Both succeed with the same empty proof. Verified by test_singleLeafRoot in test/scratch/Leads.t.sol.","severity":"info","snippet":"        bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account))));","title":"Merkle leaf is the bare address, so two lists that share a root accept the same proof (operational note on root management)"},{"citation":"resolved","description":"Sale proceeds can only leave through treasury.call with all remaining gas. If the treasury is a contract whose receive() reverts (or the contract itself, or an account that later becomes a reverting proxy), every withdraw() call reverts with TransferFailed and the ETH stays in the contract until the owner calls setTreasury. This is recoverable as long as an owner exists; combined with renounceOwnership (see separate note) it would become permanent. Because anyone may call withdraw(), the zero-balance case also lets any address emit a Withdrawn(treasury, 0) event, which is harmless noise for indexers. No reentrancy path exists: withdraw, ownerMint, mintRole, mintAllowlist and mintPublic share one ReentrancyGuard, and a treasury re-entering any of them reverts. Optional hardening: `if (amount == 0) revert ZeroQuantity();` and keep setTreasury as the recovery path.","line":291,"path":"src/WeirTownies.sol","reproduction":"Owner calls setTreasury(R) where R's receive() reverts. E mints 1 at 0.001 ether. E calls withdraw(): expected per spec 'sends the whole ETH balance to the treasury'; actual revert TransferFailed, balance remains 0.001 ether. Owner calls setTreasury(T2); withdraw() then succeeds and T2 receives 0.001 ether. Separately, with a zero balance, withdraw() from any address succeeds and emits Withdrawn(treasury, 0). Verified by test_withdrawRevertsOnRejectingTreasury and test_withdrawZeroEmits in test/scratch/Leads.t.sol.","severity":"info","snippet":"        (bool ok,) = treasury.call{value: amount}(\"\");","title":"withdraw() reverts for everyone while treasury rejects ETH, and emits Withdrawn(treasury, 0) on an empty balance"},{"citation":"resolved","description":"Documented for the owner, not defects. (1) Ownable2Step only makes transferOwnership two-step; renounceOwnership from Ownable is still a single call. After it, phase, prices, roots, base URI, renderer, updater and treasury are frozen forever and ownerMint of any unminted reserve ids is impossible. (2) After the sale the owner can still set phase back to Allowlist, replace allowlistRoot or any pool root, set prices to 0 and mint remaining general or pool ids to chosen wallets; it can also mint the remaining reserve at any time. None of these touch an already-minted token: there is no burn, no forced transfer, no per-token admin path, and tokenURI only reads. (3) setRenderer points tokenURI at an arbitrary address; a renderer with no code or a reverting tokenURI makes every tokenURI call revert until the owner resets it to address(0). All verified against the code; the layout itself (reserveSize, pool ranges, generalStart) is immutable once sealed, so no owner action can overlap ranges or exceed MAX_SUPPLY after the sale opens.","line":28,"path":"src/WeirTownies.sol","reproduction":"Owner calls renounceOwnership(): expected by a reader of 'Ownable2Step' that a second confirming step is needed; actual owner() becomes address(0) in one transaction. Verified by test_renounceIsOneStep in test/scratch/Leads.t.sol. Renderer case: owner calls setRenderer(EOA); tokenURI(1) reverts (high-level call to an address without code).","severity":"info","snippet":"contract WeirTownies is ERC721, ERC721Enumerable, ERC2981, Ownable2Step, ReentrancyGuard {","title":"Owner powers after the sale (trust assumptions): one-step renounceOwnership, phase/price/root changes, renderer control"}],"hash":"5f3989ee4c7e9a4d5a179266cd38e0a72101618529d71d87c826205401d4d040","nodeId":"2319e95b-62db-45de-9a3b-bb5aa74dfb5c","outcome":"completed","summary":"The review of the control-flow area (Execution Trace, Periphery, First Principles) is complete. The findings file holds 5 entries, none at medium or above, so nothing blocks deployment from this pass.\n\n**Outcome.** No high, critical or medium defect was found in `src/WeirTownies.sol`. The build is clean and all 21 project tests pass, plus 8 scratch checks I wrote under test/scratch/ to confirm each lead. Only src/, test/, foundry.toml and lib/ were left untouched; the only files written are `.imd-findings.json` and `test/scratch/Leads.t.sol`.\n\n**What was checked in depth and held up:**\n- Id layout arithmetic. Reserve, pools and general are tiled sequentially from counters that are frozen by sealPools before any mint is possible. The sum check keeps every range at or below MAX_SUPPLY, and OpenZeppelin's `_mint` would additionally revert on any repeated id. A layout that exactly fills the supply leaves an empty general range that reverts SoldOut.\n- Merkle leaf and cross-pool proofs. The leaf matches the StandardMerkleTree address encoding. A proof is bound to a root only, so pools with distinct roots are isolated; single-leaf trees with empty proofs work.\n- Per-wallet caps and payment. The general cap of 3 is shared across both phases, state is written before each `_safeMint`, and exact payment is enforced.\n- Reentrancy. All five state-changing entry points share one ReentrancyGuard. A receiver re-entering withdraw or any mint during `_safeMint` reverts; it can only transfer its own freshly minted token, which is standard behaviour.\n- Owner powers after the sale. No burn, forced transfer or per-token admin path exists. The layout is immutable after sealing.\n- Deployment target. Chain 4663 reports ArbOS version 61 via the ArbSys precompile, so the cancun EVM target is supported.\n\n**Findings recorded (all low or info):**\n1. Low: `setTreasury` does not move the ERC-2981 royalty receiver, so royalties keep flowing to the old treasury after a rotation until `setRoyalty` is called again.\n2. Low: the wallet-cap check adds two uint8 values, so a wallet at the cap sending qty 253 or more gets a Panic overflow instead of WalletCapReached. The cap is never bypassed.\n3. Info: lists that share a root accept the same proof, an operational note for root tooling.\n4. Info: a treasury that rejects ETH blocks withdraw until the owner re-points it; zero-balance withdraw emits a noise event.\n5. Info: trust assumptions, including that renounceOwnership stays one-step under Ownable2Step and that a bad renderer makes tokenURI revert until reset.\n\n**Not covered.** The nine specialties outside the assigned control-flow group (math, permissions, economics) were applied only as a light pass while tracing; their own reviewers should still report. This is not an independent audit.\n\nSources used for the chain lookup: [Robinhood Chain deploy docs](https://docs.robinhood.com/chain/deploy-smart-contracts), [NodeFlare Robinhood RPC](https://nodeflare.app/chains/robinhood).","treeHash":null,"usage":{"cachedInputTokens":503570,"inputTokens":226,"model":"claude-fable-5-1","outputTokens":22149,"runtime":"claude","turns":21,"wallClockMs":367227}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b0b4e7bbc84f9d80","findings":[{"citation":"resolved","description":"The contract documents `treasury` as the address that receives withdrawals and royalties, and the constructor couples the two by calling _setDefaultRoyalty(treasury_, 500). setTreasury() rewrites only the `treasury` slot; ERC2981._defaultRoyaltyInfo.receiver is not updated, so royaltyInfo() keeps naming the previous treasury until the owner separately calls setRoyalty(). Broken state coupling (Invariant guide, state couplings): withdraw() pays the new address while every marketplace royalty keeps flowing to the old one. This matters most in the scenario setTreasury exists for: rotating away from a lost or compromised treasury key. Owner-correctable with one extra call, so low. Fix: in setTreasury, re-set the default royalty to the new receiver with the current fraction, e.g. store the bps set by setRoyalty (default 500) and call _setDefaultRoyalty(t, royaltyBps) inside setTreasury; or document that setRoyalty must be called after every treasury change.","line":280,"path":"src/WeirTownies.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {WeirTownies} from \"src/WeirTownies.sol\";\n\n/// setTreasury() changes where withdraw() pays but leaves the ERC-2981 default royalty receiver at the old address.\n/// The contract documents `treasury` as the address that \"receives withdrawals and royalties\", so after\n/// setTreasury(newT) royaltyInfo must name newT. On the current code it still names the constructor treasury.\ncontract RoyaltyReceiverTest is Test {\n    address owner = makeAddr(\"owner\");\n    address oldTreasury = makeAddr(\"oldTreasury\");\n    address newTreasury = makeAddr(\"newTreasury\");\n\n    function test_royaltyReceiverFollowsTreasury() public {\n        WeirTownies t = new WeirTownies(owner, oldTreasury, 3333, bytes32(0), 0, 0.01 ether);\n        (address recvBefore, uint256 amtBefore) = t.royaltyInfo(1, 1 ether);\n        assertEq(recvBefore, oldTreasury);\n        assertEq(amtBefore, 0.05 ether);\n\n        vm.prank(owner);\n        t.setTreasury(newTreasury);\n        assertEq(t.treasury(), newTreasury);\n\n        (address recvAfter, uint256 amtAfter) = t.royaltyInfo(1, 1 ether);\n        // Expected: the royalty receiver moves with the treasury (and the 5% fraction is kept).\n        assertEq(recvAfter, newTreasury, \"ERC-2981 receiver still the old treasury after setTreasury\");\n        assertEq(amtAfter, 0.05 ether);\n    }\n}","reproduction":"Deploy WeirTownies(owner, oldT, 3333, 0, 0, 0.01 ether). royaltyInfo(1, 1 ether) returns (oldT, 0.05 ether). Owner calls setTreasury(newT). treasury() now returns newT, but royaltyInfo(1, 1 ether) still returns (oldT, 0.05 ether). Expected: (newT, 0.05 ether). test/scratch/RoyaltyReceiver.t.sol fails on the current code with `ERC-2981 receiver still the old treasury after setTreasury`.","severity":"low","snippet":"        treasury = t;","title":"setTreasury() leaves the ERC-2981 royalty receiver at the old treasury"},{"citation":"resolved","description":"ownerMint() is limited to ids 1..reserveSize. Role-pool ids can only be minted by a wallet in that pool's Merkle tree, one each; general ids only by mintAllowlist/mintPublic while the phase is open. There is no owner function to mint or retire leftovers, so the 3,333 provenance-committed ids may never be fully issued. The owner's only route to the leftovers is to re-point a pool root (setPoolRoot) at a tree of owner-controlled wallets and claim one id per wallet, or to keep the Public phase open indefinitely. This is a design gap rather than an exploit: no funds or sold tokens are affected. If the intent is that every id eventually exists (e.g. for a sweep to the treasury), add an owner sweep gated on phase == Closed that mints remaining pool/general ids sequentially, keeping the one-claim rule intact; if the intent is a possibly smaller final collection, document it.","line":169,"path":"src/WeirTownies.sol","reproduction":"Layout reserve=0, one pool of size 500 with a root of 300 wallets, MAX_SUPPLY=3333. All 300 wallets claim (pool.minted = 300). Owner sets phase Closed. Ids 301..500 can never be minted by any call: mintRole reverts WrongPhase (and would need a tree member anyway), ownerMint reverts SoldOut because reserveSize is 0, _mintGeneral starts at generalStart=501. totalSupply() is capped below MAX_SUPPLY for the lifetime of the contract unless the owner rewrites the pool root to their own wallets.","severity":"info","snippet":"    function ownerMint(address to, uint32 qty) external onlyOwner nonReentrant {","title":"Unclaimed role-pool ids and unsold general ids have no mint path once the sale closes"},{"citation":"resolved","description":"WALLET_CAP is enforced on msg.sender only. _safeMint accepts any contract implementing onERC721Received, so one actor can deploy N minimal receivers (or use N EOAs) and mint 3 per receiver in a single transaction via a factory, then transfer the tokens to one wallet. This is inherent to any per-address cap and is standard for ERC-721 sales; it is listed because the brief names the cap as a guarantee to look at. Economic effect: the cap limits per-transaction UX, not concentration. If concentration matters, the usual mitigations are a tx.origin == msg.sender check (excludes smart wallets, trade-off), per-block mint limits, or accepting the cap as a soft limit and documenting it.","line":209,"path":"src/WeirTownies.sol","reproduction":"Phase Public, publicPrice P. A factory contract in one tx deploys 10 receivers; each calls mintPublic{value: 3*P}(3) and transfers the three tokens to the deployer. Result: 30 general tokens in one wallet, generalMintedBy[deployer] still 0. Each individual call passes the check at line 209 because its msg.sender has minted 0.","severity":"info","snippet":"        if (generalMintedBy[msg.sender] + qty > WALLET_CAP) revert WalletCapReached();","title":"Per-wallet cap of 3 is per address and is bypassed by fresh contract receivers"},{"citation":"resolved","description":"withdraw() sends the entire balance to `treasury` with a low-level call and reverts on failure. If the treasury is a contract whose receive()/fallback reverts, runs out of gas, or is paused, no ETH can leave until the owner calls setTreasury. Funds are not lost (setTreasury is always available to the owner and there is no other ETH path), so this is operational. Trust note: because anyone may call withdraw(), the owner's setTreasury power is effectively the power to redirect the full balance in the same block; that is the documented owner power, recorded here as a trust assumption.","line":292,"path":"src/WeirTownies.sol","reproduction":"Owner calls setTreasury(R) where R is a contract whose receive() reverts. Any mintPublic deposits ETH. Every withdraw() call from any account reverts TransferFailed; address(this).balance stays non-zero until the owner calls setTreasury with a payable address, after which withdraw() succeeds.","severity":"info","snippet":"        if (!ok) revert TransferFailed();","title":"withdraw() is all-or-nothing to a single treasury: a reverting treasury blocks payout until the owner rotates it"},{"citation":"resolved","description":"Recorded per the brief as trust assumptions; none touches sold tokens or balances. (1) setPoolRoot(poolIndex, root) may change any pool's eligibility list after sealPools, including swapping in a list of owner wallets to take unclaimed pool ids; ranges are frozen. (2) setAllowlistRoot, setPrices and setPhase may change at any time; because mints require msg.value == price * qty exactly, a price change in flight makes pending mints revert rather than over- or under-pay, so there is no race extraction. (3) setRenderer may point tokenURI at any contract, which can revert or return arbitrary metadata for all tokens; address(0) restores the base URI. (4) setRoyalty accepts any bps up to 10000 (OZ bound) and setTreasury redirects withdrawals (and, after the low finding is fixed, royalties). (5) ownerMint is callable in any phase but only within ids 1..reserveSize. Verified absent: no burn, no forced transfer, no pause on transfers, no upgradeability, no ETH path other than mints and withdraw().","line":139,"path":"src/WeirTownies.sol","reproduction":"After sealPools and a completed sale: owner calls setPoolRoot(2, newRoot) and mintRole(2, proof) succeeds for members of newRoot while ids remain; owner calls setRenderer(R) where R.tokenURI reverts and every tokenURI(id) reverts until setRenderer(address(0)). Both are owner-only (Ownable2Step) and are the documented operator powers.","severity":"info","snippet":"    function setPoolRoot(uint256 poolIndex, bytes32 root) external onlyOwner {","title":"Owner powers that remain live after sealing and after the sale (trust assumptions, not bypasses)"}],"hash":"8603fd7daab7804fb97d6b63afaac666831360555575c01318604fced5fcece3","nodeId":"fe000f71-5885-4acb-8600-42be7ebaa68d","outcome":"completed","summary":"Review complete. No finding at medium or above, so nothing blocks deployment from this pass. The findings file holds one low and four informational items, each with a concrete state and exact source snippet verified against the tree.\n\n**What I checked, under the Economic Security, Invariant and Flow Gap guides**\n\n- **Id layout arithmetic.** Reserve, pools and general range tile exactly with no overlap and never exceed MAX_SUPPLY. All ids come from monotonic counters, and minting requires the sealed layout, so no id can be minted twice. A fuzz probe over random reserve and pool sizes confirmed this.\n- **Merkle leaves and proof reuse.** The leaf encoding matches OpenZeppelin's StandardMerkleTree for a single address. A proof only works against the root it was built for. A root reused across pools permits one claim per pool, which matches the one-claim-per-wallet rule as written.\n- **Caps and payment.** The general cap is shared across phases and the exact msg.value check means an owner price change in flight makes pending mints revert rather than over- or under-pay. No refund path exists and none is needed.\n- **Reentrancy.** All mint paths and withdraw are nonReentrant and state is written before _safeMint. A probe re-entering mintRole and withdraw from a receiver callback was blocked.\n- **Withdraw path.** Only the treasury ever receives. Anyone may call it, which is intended.\n\n**Findings written to .imd-findings.json**\n\n- **Low:** setTreasury does not move the ERC-2981 royalty receiver, so royalties keep flowing to the old treasury until setRoyalty is called again. Proof test at test/scratch/RoyaltyReceiver.t.sol fails on current code.\n- **Info:** unclaimed pool ids and unsold general ids have no mint path once the phase is closed, so the full 3,333 may never be issued.\n- **Info:** the per-wallet cap of 3 is per address and is bypassed by fresh contract receivers.\n- **Info:** a reverting treasury blocks withdraw until the owner rotates it. Owner-correctable, funds not lost.\n- **Info:** owner powers that stay live after sealing, recorded as trust assumptions: setPoolRoot, setRenderer, setPrices and setPhase, setRoyalty and setTreasury.\n\n**Test results**\n\n| Suite | Result |\n|---|---|\n| test/WeirTownies.t.sol | 21 passed |\n| test/scratch/Probe.t.sol | 4 passed |\n| test/scratch/RoyaltyReceiver.t.sol | 1 failed, as intended for the proof |\n\nNo files outside .imd-findings.json and test/scratch/ were changed. This covered the assigned economics guides and the brief's focus areas in depth. The math, permissions and control-flow passes were only applied as far as they overlapped with those areas, and this is not an independent audit.","treeHash":null,"usage":{"cachedInputTokens":606569,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":23685,"runtime":"claude","turns":22,"wallClockMs":387714}}],"verification":[]}