{"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":"145af84f-02c5-4db7-8dc3-3ec3a300b8f1","kind":"audit","nodes":[{"acceptedSubmissionHash":"c7f1964ba977e0b52aa7823582336a19e98dd0ee200075db05d7c1f13a535354","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":"af953a978aab09bc82126740b0370588a73965915cd635e3d311ae4d999c57d8","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":"5a110d421ebe6ebd52955c9a70e8c082bbe6780191563d965ba25cfa76201f80","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":"b6cd0c6899f70dbaf188d8161fd116eefa21acdcf96501aa5239359a0204af41","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":"090646d81775ff589d75eb42501d6ab702343e985100288b764610d6c373d4ca","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":"Audit Courier Companions: an ERC-1155 collection of in-game companions for Courier World (https://courierworld.fun), sold only for $STAMP on Robinhood Chain (chain 4663). Contracts review only: no deployment.\n\nRepo: github.com/AdamNakamoto/courierworld, commit bbe1784eabeeb6a0fe4d0746958a72ad3977c652\nIn scope: contracts/src/CourierCompanions.sol (and its use in contracts/script/DeployCompanions.s.sol)\nTests: contracts/test/CourierCompanions.t.sol, contracts/test/CourierCompanions.invariant.t.sol (forge test --match-contract CourierCompanions)\nDependencies: OpenZeppelin Contracts 5.4.0. $STAMP is contracts/src/StampToken.sol (ERC20 + ERC20Permit + ERC20Burnable, no transfer tax), deployed at 0xBaB43AF01C32a952ff14B395D026cf1874932Bc5.\n\nWhat it does: each companion kind is a token id with a $STAMP price, a max supply, a per-wallet limit (0 = none) and an open flag. buy(id, quantity, maxCost) and buyWithPermit(...) take price x quantity $STAMP straight from the buyer to the treasury (the contract never holds tokens) and mint the companions. 1 to 10 per buy. Resales pay an ERC-2981 royalty (5% to the treasury at deploy, owner can change it up to 10%).\n\nPromises to verify:\n- Nothing can be minted without paying the current price; the owner has no free mint and no way to take anyone's tokens.\n- A buyer never pays more than maxCost, and pays exactly price x quantity, all to the treasury.\n- A kind's supply can only shrink, never below what's been minted; minted never exceeds maxSupply.\n- Wallet limits count lifetime purchases, not balances; transfers don't reset them.\n- No reentrancy through onERC1155Received or the $STAMP transfer; a failed mint reverts the whole buy (no $STAMP moves).\n- A front-run permit doesn't break buyWithPermit.\n- Safe casts (uint128 price, uint32 supply/minted/perWallet) and no overflow.\n- Owner powers are only: setKind, setTreasury, setBase, setRoyalty (max 10%), two-step ownership transfer.\n- ERC-1155 and ERC-2981 compliance; name/symbol/uri work on OpenSea.\n- The deploy script sets up the lineup as intended.\n\nPlease report findings by severity with suggested fixes; don't push to the repository, and I'll fix and ask for a re-check.","parentJobId":null,"planHash":"5a2678ccceb4fa249e4fd083bb8faa3aaaad5700bf78a33757084c4a2c65615b","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"145af84f-02c5-4db7-8dc3-3ec3a300b8f1","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":"51494","feedbackHash":"6b3aee5531ee3eef4942d55292160ce77f65d9f4cb1c5fc52b20197f30b23f82","nodeKey":"audit_economics","submissionHash":"c7f1964ba977e0b52aa7823582336a19e98dd0ee200075db05d7c1f13a535354","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51514","feedbackHash":"4d28eb057a29a881eff02826ab06b03a66b8a193a959dcdba9b7f5b942927d6c","nodeKey":"audit_flow","submissionHash":"af953a978aab09bc82126740b0370588a73965915cd635e3d311ae4d999c57d8","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52396","feedbackHash":"79f6a0219ad073863131e1bc22084652272e72bb5dd2f26890c2205cf0b498b2","nodeKey":"audit_judge","submissionHash":"5a110d421ebe6ebd52955c9a70e8c082bbe6780191563d965ba25cfa76201f80","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52628","feedbackHash":"a05cc8147cb5a732923d772122c120757609b729b21299f5a631d64b34d7aff9","nodeKey":"audit_math","submissionHash":"b6cd0c6899f70dbaf188d8161fd116eefa21acdcf96501aa5239359a0204af41","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51481","feedbackHash":"8e8bc01473771ef35cdb4a0e35cf43988a60852b05f87cb5564121cd922d5bd1","nodeKey":"audit_permissions","submissionHash":"090646d81775ff589d75eb42501d6ab702343e985100288b764610d6c373d4ca","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"f96a4cdd55b3a362875b423319daa148de193908fd028d9868eee79e236174e3","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3f91b58cf7cd2d45","findings":[{"citation":"resolved","description":"The constructor couples the two destinations (treasury_ receives $STAMP and is also set as the default royalty receiver via _setDefaultRoyalty(treasury_, 500)), and the deploy script documents TREASURY as 'where the $STAMP goes, and the 5% royalty'. setTreasury only rewrites `treasury`; the royalty receiver stays at the previous address. Asymmetry between constructor and setter: after rotating the treasury (e.g. moving to a multisig, or because the old key is compromised) every marketplace resale keeps paying royalties to the old address until the owner separately calls setRoyalty. If the old treasury is the compromised/lost key, royalties are lost until noticed. The existing test test_newTreasuryGetsTheNextSales checks $STAMP only, not royaltyInfo. Fix (keeps design): in setTreasury also call `_setDefaultRoyalty(treasury_, _currentBps)` (store the bps, or read via royaltyInfo(0, 10_000)), or document that setTreasury does not move royalties and that setRoyalty must be called too.","line":148,"path":"contracts/src/CourierCompanions.sol","reproduction":"State: shop deployed with treasury T1 (royalty receiver = T1, 500 bps). Owner calls setTreasury(T2). Then royaltyInfo(31, 1000e18) returns (T1, 50e18). Expected: (T2, 50e18). Scratch test test_setTreasuryLeavesRoyaltyAtOldTreasury fails with 'royalty receiver should follow the treasury: <T1> != <T2>'.","severity":"low","snippet":"        treasury = treasury_;","title":"setTreasury moves sale proceeds but leaves the ERC-2981 royalty receiver at the old treasury"},{"citation":"resolved","description":"The brief lists the owner powers as setKind, setTreasury, setBase, setRoyalty and a two-step ownership transfer. Inheriting Ownable2Step also exposes Ownable.renounceOwnership(), which is single-step and sets owner to address(0) immediately. After it, no kind can be closed, repriced, or have its supply shrunk, the treasury and royalty receiver can never change, and base/uri can never be updated; sales keep running at the frozen prices forever. One mis-click (or a UI that offers 'renounce') is unrecoverable. Not an unprivileged bypass, but an unlisted irreversible power that contradicts the 'two-step' promise. Fix: override `renounceOwnership() public view override onlyOwner { revert(); }` (or make it an explicit, documented power).","line":22,"path":"contracts/src/CourierCompanions.sol","reproduction":"Owner calls shop.renounceOwnership(). Expected (per the listed powers / two-step promise): revert or a pending step. Actual: owner() == address(0) and shop.setKind(31, 2500e18, 30, 1, false) from the former owner reverts OwnableUnauthorizedAccount; scratch tests test_renounceOwnershipIsOneStep (expectRevert fails: 'next call did not revert') and test_afterRenounceNothingIsConfigurable (passes, showing the frozen state).","severity":"low","snippet":"contract CourierCompanions is ERC1155, ERC2981, Ownable2Step, ReentrancyGuard {","title":"renounceOwnership is a live one-step, irreversible owner power outside the listed set"},{"citation":"resolved","description":"Trust assumption, not a bypass: setKind only requires price > 0 and perWallet may be 0, and there is no delay between a price change and buys. The owner (or anyone who becomes owner) can atomically setKind(id, 1, ..., 0, true), buy(id, 10, 10) repeatedly (10 per tx), and setKind back. Other buyers see the 1-wei price only if they front-run inside the same block. Since the owner is the treasury wallet, the economic effect is minting scarce companions for ~0 $STAMP, diluting the limited supplies the contract promises. If a hard guarantee is wanted: a minimum price (e.g. MIN_PRICE = 1e18), a timelock on setKind price decreases, or an owner-cannot-buy rule. Otherwise document it.","line":130,"path":"contracts/src/CourierCompanions.sol","reproduction":"Owner (holding $STAMP) calls setKind(31, 1, 30, 0, true); approve(shop, 10); buy(31, 10, 10); setKind(31, 2500e18, 30, 1, true). Result: owner holds 10 of kind 31 (a 30-supply, 1-per-wallet, 2500-$STAMP companion) and the treasury received 10 wei. Scratch test test_ownerMintsForOneWei passes on current code.","severity":"info","snippet":"        if (price == 0 || price > type(uint128).max) revert BadPrice();","title":"'No free mint' holds only nominally: the owner can price a kind at 1 wei, buy, and restore the price"},{"citation":"resolved","description":"The only rejected treasury is address(0). If the owner sets treasury = address(shop) (a plausible copy-paste mistake with the deployed address), every buy still succeeds and the contract that 'never holds tokens' accumulates $STAMP, with no sweep/rescue function. Owner misconfiguration only; cheap guard: also revert when treasury_ == address(this) (and in the constructor).","line":147,"path":"contracts/src/CourierCompanions.sol","reproduction":"Owner calls setTreasury(address(shop)); alice approves and calls buy(31, 1, 2500e18). Actual: stamp.balanceOf(address(shop)) == 2500e18 and is unrecoverable. Expected: revert. Scratch test test_treasuryCanBeTheShopItself passes on current code.","severity":"info","snippet":"        if (treasury_ == address(0)) revert ZeroAddress();","title":"setTreasury accepts the shop's own address, stranding $STAMP with no recovery path"},{"citation":"resolved","description":"The limit does what the brief says (lifetime purchases per address, unaffected by transfers), but it only limits one address: a buyer with N wallets buys N x perWallet and can consolidate by safeTransferFrom. For the 1-per-wallet legendaries (ids 31-32, 41-43) one actor can buy the whole 25-30 supply in a single block from fresh EOAs. Inherent to address-based limits; no fix inside the contract short of allowlists. Worth stating in the game's copy so 'one per wallet' is not read as 'one per player'.","line":114,"path":"contracts/src/CourierCompanions.sol","reproduction":"Deploy lineup kind 31 (perWallet 1). Wallets W1..W30 each call buy(31, 1, 2500e18) then safeTransferFrom(Wi, W1, 31, 1, ''). W1 holds all 30; bought[W1][31] == 1.","severity":"info","snippet":"        if (k.perWallet != 0 && already + quantity > k.perWallet) revert WalletLimit();","title":"Per-wallet limits are per address and trivially split across wallets"},{"citation":"resolved","description":"royaltyInfo is correct and supportsInterface advertises ERC-2981, but nothing enforces it on transfer. The sibling CourierSkins deploy configures an OpenSea creator-fee transfer validator (SKINS_VALIDATOR in launch.env); Companions has no validator hook and no contractURI (ERC-7572), so the collection name/description/royalty shown on OpenSea depends on marketplace settings. 'Resales pay a 5% royalty' therefore holds only on marketplaces that honour ERC-2981 voluntarily. Fine if intended; otherwise add a contractURI() getter and, if desired, the same validator pattern as Skins.","line":17,"path":"contracts/src/CourierCompanions.sol","reproduction":"Any holder calls safeTransferFrom(holder, buyer, id, 1, '') after an off-chain or non-compliant-marketplace sale: no royalty is paid and nothing reverts. Compare CourierSkins which routes through a validator.","severity":"info","snippet":"///         ERC-1155, with an ERC-2981 royalty on resales (5% to start, at most 10%).","title":"Royalty is advisory only: no transfer validator or contractURI, unlike CourierSkins"},{"citation":"resolved","description":"The lineup itself is correct (verified by broadcasting the script on a local Anvil node: owner = broadcaster, treasury and 5% royalty set, 13 kinds open with ids 1-3 @100, 11-13 @250, 21-22 @500/limit 2, 31-32 @2500/limit 1, 41-43 @3000/limit 1, uri = base + id + '.json'). But the script comment says 'The game's companions.js draws each id' and web/ has no companions.js, no companions/ metadata directory and no reference to 'companions' at all, so at the default base every uri(id) 404s and OpenSea shows nothing until the files are published. Also note setKind for a new kind emits no URI event (only setBase does), so indexers that key on URI events will not see the new kinds' metadata until setBase is called again. Pre-deploy checklist item: publish web/companions/{1,2,3,11,12,13,21,22,31,32,41,42,43}.json (and the drawing code) before running deploy-companions.sh, or set COMPANIONS_BASE to where they already live.","line":45,"path":"contracts/script/DeployCompanions.s.sol","reproduction":"grep -ri companions web/ returns nothing; after deploy, uri(1) == 'https://courierworld.fun/companions/1.json', which the site does not serve.","severity":"info","snippet":"        string memory base = vm.envOr(\"COMPANIONS_BASE\", string(\"https://courierworld.fun/companions/\"));","title":"Deploy script points at metadata and a companions.js that are not in the tree yet"},{"citation":"resolved","description":"Edges the suite does not exercise: (1) royaltyInfo after setTreasury (would have caught the receiver asymmetry); (2) renounceOwnership; (3) buyWithPermit with an expired deadline / wrong-value signature falling back to a pre-existing allowance, and the residual allowance (maxCost - cost) left on the shop; (4) setKind lowering perWallet below a wallet's existing bought (should revert the next buy, and does); (5) setTreasury(address(shop)). The invariant handler never transfers, so invariant_boughtMatchesBalances only re-states the handler's own behaviour rather than the 'limits survive transfers' promise. Suggest adding these as unit tests.","line":311,"path":"contracts/test/CourierCompanions.t.sol","reproduction":"Run forge test --match-contract CourierCompanions: no test calls royaltyInfo after setTreasury, renounceOwnership, or buyWithPermit with a stale permit and no allowance.","severity":"info","snippet":"    function test_newTreasuryGetsTheNextSales() public {","title":"Test coverage gaps around the owner surface"}],"hash":"090646d81775ff589d75eb42501d6ab702343e985100288b764610d6c373d4ca","nodeId":"f5e533c1-ea50-4129-81e9-b933720f543e","outcome":"completed","summary":"The review is complete and the findings file is written. Nothing in the repository was modified; only the scratch tests under `contracts/test/scratch/` and `.imd-findings.json` are new.\n\n**Outcome: no critical, high or medium defects.** The buy path holds every promise I was asked to verify. I traced each one against the code and the shipped suite (24 unit tests plus 3 invariants, all passing), and added my own checks where the suite was silent.\n\n**Verified promises**\n- Minting happens only inside `_buy`, after the price check and the `$STAMP` pull, so nothing mints unpaid and the owner has no mint entry point or transfer power over holders' tokens.\n- Cost is exactly price times quantity, reverts above `maxCost`, and goes straight to the treasury. The contract never holds tokens.\n- Supply can only shrink and never below `minted`, and `minted` never exceeds `maxSupply`. The invariant run confirms this across 128k calls.\n- Wallet limits use the lifetime `bought` counter, so transfers do not reset them.\n- `nonReentrant` covers both buy entry points. State is settled before the `$STAMP` transfer and the ERC-1155 receiver hook, and a failed mint reverts atomically.\n- A front-run permit is caught and the buy proceeds on the allowance it left. A stranger calling `buyWithPermit` with someone else's signature only buys for themselves.\n- Casts are bounded by the `setKind` checks. The ten-per-buy cap keeps `minted` and `cost` from overflowing.\n- ERC-1155, ERC-2981 and ERC-165 are OpenZeppelin 5.4.0 implementations. The 10% royalty cap and zero-receiver rejection hold.\n- The deploy script was broadcast against a local Anvil node. It produced the intended owner, treasury, 5% royalty, uri format and all thirteen kinds with the stated prices, supplies and limits.\n\n**Findings reported (two low, six info)**\n- **Low:** `setTreasury` moves sale proceeds but not the royalty receiver, so resales keep paying the old treasury until `setRoyalty` is called separately.\n- **Low:** `renounceOwnership` is a live one-step, irreversible power not in the listed owner set, which contradicts the two-step promise.\n- **Info:** the \"no free mint\" guarantee is only nominal, since the owner can price a kind at 1 wei, buy, and restore the price in one block.\n- **Info:** `setTreasury` accepts the shop's own address, which would strand `$STAMP` with no sweep.\n- **Info:** per-wallet limits are per address and split trivially across wallets.\n- **Info:** the royalty is advisory. There is no transfer validator or `contractURI`, unlike Courier Skins.\n- **Info:** the script's default metadata base and the referenced `companions.js` do not exist in the web tree yet, so uris will 404 at deploy.\n- **Info:** test coverage gaps around the owner surface, including royalty after treasury rotation and stale-permit fallbacks.\n\nEach finding carries a concrete input or state and a suggested fix that keeps the agreed design. No proofs were attached because none of the findings reach high severity.","treeHash":null,"usage":{"cachedInputTokens":1139750,"inputTokens":358,"model":"claude-fable-5-1","outputTokens":92067,"runtime":"claude","turns":26,"wallClockMs":1198823}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"60ffae3cc413a0c6","findings":[],"hash":"3d75e28abbe5c2ee995dab9f06be0b544f13b575ec3a6266cdd8a04980ae83c6","nodeId":"d645208f-eba3-46b6-94c1-728b7b8fc420","outcome":"failed","summary":"submodules did not fetch: Submodule 'contracts/lib/forge-std' (https://github.com/foundry-rs/forge-std) registered for path 'contracts/lib/forge-std'\nSubmodule 'contracts/lib/openzeppelin-contracts' (https://github.com/OpenZeppelin/openzeppelin-contracts) registered for path 'contracts/lib/openzeppelin-contracts'\nSubmodule 'contracts/lib/v4-core' (https://github.com/Uniswap/v4-core) registered for path 'contracts/lib/v4-co","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":null,"outputTokens":0,"runtime":"claude","turns":0,"wallClockMs":71749}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a11137177fc72d8b","findings":[{"citation":"resolved","description":"The constructor binds both destinations to one address: sales go to `treasury` and `_setDefaultRoyalty(treasury_, 500)` makes it the royalty receiver. The deploy script documents TREASURY as 'where the $STAMP goes, and the 5% royalty' and the brief says the royalty is '5% to the treasury'. setTreasury only rewrites `treasury`; the royalty receiver stays on the previous address until the owner separately calls setRoyalty and re-supplies the bps. A treasury rotation (move to a multisig, lost or compromised key) therefore keeps routing every marketplace royalty to the abandoned address, and nothing (event, revert, test) signals the split. Reported by all four specialists; merged. Fix (keeps the design): in setTreasury, carry the royalty over when it still points at the old treasury, e.g. `(address r, uint256 fee) = royaltyInfo(0, _feeDenominator()); if (r == treasury) _setDefaultRoyalty(treasury_, uint96(fee));` before assigning, or document in setTreasury's NatSpec and the runbook that setRoyalty must be called alongside it, and add a test asserting royaltyInfo after setTreasury.","line":148,"path":"contracts/src/CourierCompanions.sol","reproduction":"Deploy with treasury T (royaltyInfo(1, 1000e18) == (T, 50e18)). Owner calls setTreasury(N), N != T. Expected (per '5% to the treasury'): royaltyInfo(1, 1000e18) == (N, 50e18). Actual: treasury() == N and the next buy pays N, but royaltyInfo(1, 1000e18) still returns (T, 50e18). Reproduced in test/scratch/Judge.t.sol::test_setTreasuryLeavesRoyaltyReceiver (passes on current code, asserting the stale receiver). The existing test_newTreasuryGetsTheNextSales never reads royaltyInfo.","severity":"low","snippet":"        treasury = treasury_;","title":"setTreasury rotates sale proceeds but leaves the ERC-2981 royalty receiver at the old treasury"},{"citation":"resolved","description":"The brief lists the owner powers as setKind, setTreasury, setBase, setRoyalty and a two-step ownership transfer. Ownable2Step still inherits Ownable.renounceOwnership(), which is not overridden: a single call with no acceptance step sets owner to address(0) permanently. Afterwards no kind can be closed, repriced or shrunk, the treasury and royalty receiver can never be rotated, and the base URI can never change, while every open kind keeps selling at its frozen price to the frozen treasury forever. A mis-click, an ops script that renounces (the project's other deploys do), or a compromised owner key does this in one transaction, defeating the reason Ownable2Step was chosen. Not an unprivileged bypass; it is a deviation from the stated owner-power list with an unrecoverable outcome. Reported by all four specialists; merged. Fix: `function renounceOwnership() public view override onlyOwner { revert(); }` (or a custom error) plus a test that it reverts; or, if renouncing is wanted, list it as an intended power and decide what lineup state it should leave behind.","line":22,"path":"contracts/src/CourierCompanions.sol","reproduction":"State: fresh deploy, kind 1 open at 100e18. Owner calls renounceOwnership(). Expected per the stated powers: revert or a pending step. Actual: owner() == address(0); the former owner's setKind(1, 100e18, 300, 0, false) reverts OwnableUnauthorizedAccount(owner); alice can still approve and buy(1, 1, 100e18), receives one companion, and 100e18 STAMP goes to the now-unchangeable treasury. Reproduced in test/scratch/Judge.t.sol::test_renounceFreezes. No existing test touches renounceOwnership.","severity":"low","snippet":"contract CourierCompanions is ERC1155, ERC2981, Ownable2Step, ReentrancyGuard {","title":"renounceOwnership is a live one-step, irreversible owner power outside the stated set, and it freezes the lineup"},{"citation":"resolved","description":"The promise is that nothing is minted without paying the current price and the owner has no free mint. Literally true: the owner has no mint function. But setKind accepts any price >= 1 wei, any perWallet (0 = none), and takes effect immediately, so the owner can atomically set a scarce kind to 1 wei, buy 10 per call, and restore the price, taking legendaries for 1e-17 STAMP each. Separately, script/deploy-companions.sh says 'Sign with the treasury wallet: it becomes the owner', so in the intended deployment owner == treasury, and _buy's safeTransferFrom(buyer, treasury, cost) is then a self-transfer: every owner purchase is free at the listed price, no price change needed. Neither is a permission bypass (the owner role is the agreed design), but both contradict the stated guarantee and the 'limited stays limited' economics, since minted supply of a 25-30 unit kind can be taken by the owner at no cost. Reported by three specialists as trust-assumption/low; merged and kept at low because the deploy runbook makes the free path the default configuration. Fix options that preserve the owner role: (a) state the trust assumption in README/AUDIT.md and the NatSpec instead of 'the owner has no free mint'; (b) add a MIN_PRICE floor (e.g. 1e18) and/or a delay on price cuts for existing kinds; (c) deploy with a treasury distinct from the owner.","line":130,"path":"contracts/src/CourierCompanions.sol","reproduction":"Path 1: owner holds >= 10 wei STAMP; kind 31 at 2500e18, supply 30, perWallet 1. Owner calls setKind(31, 1, 30, 0, true); approve(shop, 10); buy(31, 10, 10); setKind(31, 2500e18, 30, 1, true). Actual: owner STAMP balance drops by exactly 10 wei, balanceOf(owner, 31) == 10, kinds(31).price == 2500e18, minted == 10. Path 2: deploy with treasury_ == owner_; owner approves and calls buy(31, 10, type(uint256).max) at 2500e18. Actual: owner STAMP balance unchanged, owner holds 10 of kind 31. Expected per promise: the owner cannot obtain companions without paying the going price. Reproduced in test/scratch/Judge.t.sol::test_ownerNearFreeMint and ::test_ownerIsTreasuryBuysFree.","severity":"low","snippet":"        if (price == 0 || price > type(uint128).max) revert BadPrice();","title":"'No free mint' holds only nominally: the owner can price a kind at 1 wei and buy, and with owner == treasury (as the deploy runbook directs) owner buys are free at any price"},{"citation":"resolved","description":"Two related behaviours, neither a loss on this code. (1) The permit is signed for maxCost (the buyer's ceiling) but _buy pulls only price x quantity, so whatever slack the front end adds stays approved to the shop indefinitely. The shop only ever calls safeTransferFrom(msg.sender, ...), so nobody else can spend it today, but wallets show a standing approval and it would become live exposure if a future version added any pull path keyed on an address other than msg.sender. (2) The blanket try/catch is what makes a front-run permit harmless (good, and verified by the existing test_permitUsedFirstStillBuys), but it also swallows a bad, expired or wrong-value signature, so the buyer sees ERC20InsufficientAllowance with no hint the signature was the problem. The 'never pays more than maxCost' and 'pays exactly price x quantity' promises hold. Reported by three specialists; merged. Optional fixes: have the front end sign the permit for the exact quoted cost when it does not need slack and document the standing approval; after the catch, if stamp.allowance(msg.sender, address(this)) < cost revert with a dedicated PermitFailed error (or emit an event in the catch branch).","line":103,"path":"contracts/src/CourierCompanions.sol","reproduction":"(1) Kind 1 at 100e18; alice signs permit(alice, shop, 150e18, deadline) and calls buyWithPermit(1, 1, 150e18, deadline, v, r, s). Actual: treasury +100e18, one companion minted, stamp.allowance(alice, shop) == 50e18 afterwards. (2) alice signs a permit for 100e18 + 1 but calls buyWithPermit(1, 1, 100e18, ...) with no prior allowance. Actual: revert ERC20InsufficientAllowance(shop, 0, 100e18), nonce unconsumed; expected: a permit-specific error. Reproduced in test/scratch/Judge.t.sol::test_permitLeftoverAllowance and ::test_badPermitMaskedAsAllowance.","severity":"info","snippet":"        try IERC20Permit(address(stamp)).permit(msg.sender, address(this), maxCost, deadline, v, r, s) {} catch {}","title":"buyWithPermit leaves a residual allowance of maxCost - cost on the shop, and hides permit failures behind an allowance error"},{"citation":"resolved","description":"The only rejected treasury is address(0). If the owner mis-pastes the deployed address into setTreasury (or the constructor), every buy still succeeds and the contract that 'never holds tokens' accumulates $STAMP with no sweep or rescue function, so it is locked forever. Likewise any ERC-20 sent to the shop by mistake is unrecoverable. Owner misconfiguration only. Reported by two specialists; merged. Fix: also revert when treasury_ == address(this) in setTreasury and the constructor; optionally add an owner-only rescue(IERC20, to) limited to the shop's own balance (it never legitimately holds anything, so this adds no power over user funds).","line":147,"path":"contracts/src/CourierCompanions.sol","reproduction":"Owner calls setTreasury(address(shop)); alice approves and calls buy(1, 1, 100e18). Actual: buy succeeds, stamp.balanceOf(address(shop)) == 100e18 and no function can move it. Expected: setTreasury reverts for the shop's own address. Reproduced in test/scratch/Judge.t.sol::test_treasuryCanBeShop.","severity":"info","snippet":"        if (treasury_ == address(0)) revert ZeroAddress();","title":"setTreasury accepts the shop's own address, and the contract has no way to recover tokens sent to it"},{"citation":"resolved","description":"royaltyInfo is correct and supportsInterface advertises ERC-2981, but ERC-2981 only reports a royalty; safeTransferFrom and safeBatchTransferFrom are the stock OpenZeppelin implementations with no transfer hook, so any peer-to-peer sale or any marketplace that ignores royaltyInfo pays the treasury nothing. The sibling CourierSkins collection enforces its 5% through an OpenSea creator-fee transfer validator (SKINS_VALIDATOR in launch.env, validateTransfer in CourierSkins.sol), so the two collections sold under one brand carry different royalty guarantees. There is also no contractURI() (ERC-7572), so the collection-level name, description and royalty shown on OpenSea depend on marketplace settings. Fine if intended; the brief's 'Resales pay an ERC-2981 royalty' is the standard phrasing for an advisory royalty. Reported by two specialists; merged. Options: add a contractURI() getter; if enforcement is wanted, add the ERC-1155 equivalent of the Skins validator pattern (owner-replaceable validator consulted in _update for non-mint transfers); otherwise say in README/AUDIT.md that enforcement depends on the marketplace.","line":82,"path":"contracts/src/CourierCompanions.sol","reproduction":"alice buys kind 31 for 2500e18; treasury STAMP balance = T. bob pays alice 10_000e18 STAMP directly (stamp.transfer), then alice calls shop.safeTransferFrom(alice, bob, 31, 1, ''). Actual: bob holds kind 31, treasury balance still == T, nothing reverts. royaltyInfo(31, 10_000e18) returns (treasury, 500e18) but nothing acts on it. Reproduced in test/scratch/Judge.t.sol::test_royaltyAdvisory.","severity":"info","snippet":"        _setDefaultRoyalty(treasury_, 500);","title":"The resale royalty is advisory only: no transfer validator (unlike CourierSkins) and no contractURI"},{"citation":"resolved","description":"Both mechanisms do exactly what the brief says (lifetime purchases per address, unaffected by transfers; 1 to 10 per buy). Trust assumption, recorded so the lineup is not marketed as 'one per player': a buyer with N wallets buys N x perWallet and consolidates with safeTransferFrom, so the 1-per-wallet legendaries (ids 31, 32, 41-43) can be bought out by one actor from fresh EOAs in a block; and for the perWallet = 0 kinds (ids 1-3, 11-13) a contract can loop buy(id, 10, ...) and take an entire 300-unit supply in a single transaction at launch. 'Limited' kinds are limited in total, not in distribution. Reported by three specialists; merged. No contract fix short of allowlists; if fair distribution matters, give every kind a perWallet (accepting the sybil caveat) or state the limit's meaning in the game copy.","line":114,"path":"contracts/src/CourierCompanions.sol","reproduction":"(a) Kind 1 open at 100e18, maxSupply 300, perWallet 0. A contract holding 30_000e18 STAMP approves the shop and calls buy(1, 10, max) thirty times inside one external call (its onERC1155Received returns the selector). Actual: it holds 300 of kind 1 after one transaction and alice's next buy(1, 1, 100e18) reverts SoldOut. (b) Kind 31 perWallet 1: alice buys 1, wallet a2 buys 1 and transfers it to alice. Actual: balanceOf(alice, 31) == 2 while bought[alice][31] == 1. Reproduced in test/scratch/Judge.t.sol::test_wholeKindSweptInOneTx and ::test_walletLimitSybil.","severity":"info","snippet":"        if (k.perWallet != 0 && already + quantity > k.perWallet) revert WalletLimit();","title":"Per-wallet limits bound purchases per address, and MAX_PER_BUY bounds a call, so neither limits distribution: one actor can sweep a kind"},{"citation":"resolved","description":"The lineup itself matches the brief: broadcasting the script on a local Anvil node (StampToken deployed first, DEV_CHAIN=true) produced owner == broadcaster, treasury == TREASURY, royaltyInfo(1, 10000) == (TREASURY, 500), kindCount 13, and ids 1-3 at 100e18/300/0, 11-13 at 250e18/150/0, 21-22 at 500e18/50/2, 31-32 at 2500e18/30/1, 41-43 at 3000e18/25/1, all open, uri(41) == base + '41.json'. Two flow gaps remain before it is broadcast for real: (1) the script's comment says 'The game's companions.js draws each id', but web/ has no companions.js, no companions/ directory and no reference to companions at all (export-web.sh is already wired to merge deployments/robinhood-companions.json), so at the default base every uri(id) 404s and OpenSea shows nothing until the 13 JSON files and the drawing code are published; since the script opens every kind immediately, buyers could pay STAMP for ids the game cannot show. (2) setKind for a new kind emits KindSet but no ERC-1155 URI event (only setBase loops them), so indexers keyed on URI events will not pick up new kinds' metadata until setBase is called again. Not contract defects. Reported by two specialists; merged. Pre-deploy items: publish web/companions/{1,2,3,11,12,13,21,22,31,32,41,42,43}.json and companions.js (or point COMPANIONS_BASE where they already live) before running deploy-companions.sh, or deploy with open = false and open kinds once the game reads them; optionally emit URI(uri(id), id) in setKind when a kind is first added.","line":45,"path":"contracts/script/DeployCompanions.s.sol","reproduction":"`grep -ri companion web/` returns nothing and `ls web/companions` fails with 'No such file or directory' at commit bbe1784. Broadcasting DeployCompanions on Anvil and calling uri(1) returns 'https://courierworld.fun/companions/1.json', a path the site does not serve, while kinds(1).open == true so buy(1, 1, 100e18) succeeds immediately.","severity":"info","snippet":"        string memory base = vm.envOr(\"COMPANIONS_BASE\", string(\"https://courierworld.fun/companions/\"));","title":"Deploy script lineup is correct, but it points at metadata and a companions.js that are not in the tree, and new kinds emit no URI event"},{"citation":"resolved","description":"The suites are strong on the buy path (exact payment, maxCost, supply shrink, reentrancy via onERC1155Received, failed-mint atomicity, permit front-run, fuzz on price x quantity; 24 unit tests and 3 invariants pass, 128k handler calls). Edges left unexercised: (1) test_newTreasuryGetsTheNextSales never reads royaltyInfo after setTreasury (it would have caught the receiver asymmetry); (2) no test calls renounceOwnership; (3) no test passes a wrong-value, wrong-spender or expired permit to buyWithPermit with no allowance, nor checks the residual allowance; (4) no test calls setTreasury(address(shop)) or setRoyalty(address(0), x) (OZ reverts ERC2981InvalidDefaultRoyaltyReceiver); (5) no safeBatchTransferFrom / balanceOfBatch coverage; (6) the invariant handler never transfers, so invariant_boughtMatchesBalances only restates the handler's own behaviour, and handler.configure wraps setKind in try/catch with supply bounded to 1..500, so SupplyCanOnlyShrink reverts are swallowed (0 configure reverts across 63k calls); (7) DeployCompanions.run() is never executed in a test, so the 13-kind lineup is only checked by eye. Reported by all four specialists; merged. Suggested additions: assert royaltyInfo after setTreasury; a renounceOwnership test (reverting once the override lands); malformed-permit and residual-allowance tests; a transfer action in the handler with the invariant restated as bought[b][id] >= balance obtained by purchase; assert configure revert reasons; a script test that deploys a StampToken, sets STAMP/TREASURY via vm.setEnv and runs the lineup (note: run() must be called without an active prank, since startBroadcast and pranks are incompatible).","line":311,"path":"contracts/test/CourierCompanions.t.sol","reproduction":"`forge test --match-contract CourierCompanions`: 25 tests pass. `grep -n 'renounceOwnership\\|royaltyInfo\\|allowance(\\|safeBatchTransferFrom\\|DeployCompanions' test/CourierCompanions.t.sol test/CourierCompanions.invariant.t.sol` shows royaltyInfo only in test_uriRoyaltyAndInterfaces (never after setTreasury) and no hits for the others. The scratch tests in test/scratch/Judge.t.sol exercise each listed edge and show behaviour the current suite does not pin down.","severity":"info","snippet":"    function test_newTreasuryGetsTheNextSales() public {","title":"Test gaps: royalty after setTreasury, renounceOwnership, malformed permits, treasury == shop, transfers in the invariant handler, and the deploy script are untested"}],"hash":"5a110d421ebe6ebd52955c9a70e8c082bbe6780191563d965ba25cfa76201f80","nodeId":"10a15c1b-fcf0-40d5-bb17-bb56f1981727","outcome":"completed","summary":"The review is complete and the judged report is in `.imd-findings.json` at the repository root.\n\n**Outcome: no critical or high findings.** The contract holds every buy-path promise. Payment is exact and goes only to the treasury, maxCost is honored, supply only shrinks and never below minted, wallet limits count purchases, the reentrancy guard refuses a nested buy, a failed mint reverts the whole buy, a front-run permit still buys, the narrowing casts are range-checked, and the deploy script produces the intended lineup. I broadcast the script on a local Anvil node and read back owner, treasury, the 5% royalty and all 13 kinds, which matched the brief exactly.\n\n**What I kept, after reproducing each claim and merging the 28 specialist items into 9:**\n\n- **Low, setTreasury leaves the royalty receiver behind.** After a rotation, royaltyInfo still names the old treasury. All four specialists reported this.\n- **Low, renounceOwnership is a live one-step power** outside the stated list, and it freezes prices, treasury and URI forever.\n- **Low, \"no free mint\" is nominal.** The owner can price a kind at 1 wei and buy, and because the deploy runbook makes the treasury wallet the owner, owner buys are self-transfers and free at any price. Three specialists reported this at mixed severities. I kept low because the runbook makes it the default configuration.\n- **Info, buyWithPermit** leaves a residual allowance and masks bad signatures as an allowance error. Two items merged.\n- **Info, setTreasury accepts the shop's own address** with no recovery path.\n- **Info, royalty is advisory**, unlike CourierSkins, which uses a transfer validator. No contractURI.\n- **Info, limits don't bound distribution.** A contract swept all 300 of a kind in one transaction. Sybil wallets bypass per-wallet limits. Two items merged.\n- **Info, deploy flow.** The lineup is correct, but no companions.js or metadata exists in the tree, and new kinds emit no URI event.\n- **Info, test gaps**, merged from all four specialists.\n\n**Dropped:** the setBase gas loop. I measured about 6,400 gas per kind, so the base URI only freezes past roughly 4,700 kinds, which is not a realistic lineup.\n\nEvery snippet was checked against its line in the tree. The reproductions live in `contracts/test/scratch/Judge.t.sol`, which is not part of the submission. No repository files were changed. The requester's next step is to fix the three low items and ask for a re-check.","treeHash":null,"usage":{"cachedInputTokens":982153,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":23625,"runtime":"claude","turns":18,"wallClockMs":495558}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"4ebee02fc506f27c","findings":[{"citation":"resolved","description":"The brief says the royalty is '5% to the treasury'. The constructor sets both `treasury` and the default royalty receiver to `treasury_`, but `setTreasury` only rewrites `treasury`. After a treasury rotation (compromised key, new multisig) every marketplace keeps paying royalties to the old address until the owner also remembers to call `setRoyalty(newTreasury, bps)`. The contract's own doc ('Where the $STAMP goes') and the brief treat treasury as one destination, so the split is a silent footgun rather than a design choice. Fix: in `setTreasury`, also call `_setDefaultRoyalty(treasury_, currentBps)` (read bps via `royaltyInfo(0, 10_000)` or store it), or document that royalty receiver must be rotated separately and have the deploy/ops runbook do both.","line":146,"path":"contracts/src/CourierCompanions.sol","reproduction":"Deploy with treasury T. Owner calls setTreasury(N). royaltyInfo(1, 1000e18) still returns (T, 50e18) while treasury() == N. Expected: receiver N (or an explicit, documented split). Verified in test/scratch/CompanionsLeads.t.sol::test_setTreasuryLeavesRoyaltyReceiver.","severity":"low","snippet":"    function setTreasury(address treasury_) external onlyOwner {\n        if (treasury_ == address(0)) revert ZeroAddress();\n        treasury = treasury_;\n        emit TreasurySet(treasury_);\n    }","title":"setTreasury does not move the ERC-2981 royalty receiver: resale royalties keep flowing to the old treasury"},{"citation":"resolved","description":"The brief lists the owner powers as setKind, setTreasury, setBase, setRoyalty and two-step ownership transfer. OpenZeppelin Ownable2Step also inherits `renounceOwnership()` (one step, no confirmation). If it is called (deliberately or by a fat-fingered ops script; the project's other deploys do renounce owners), every open kind stays on sale forever at its current price, closed kinds can never reopen, the base URI and treasury can never be fixed, and royalties can never be changed. Nothing in the contract or tests covers this path. Fix: either override `renounceOwnership` to revert (keeping the two-step model the brief describes), or document it as an intended, irreversible power and decide the lineup state it should leave behind.","line":22,"path":"contracts/src/CourierCompanions.sol","reproduction":"owner calls renounceOwnership(); owner() == address(0); setKind(1, 100e18, 300, 0, false) reverts OwnableUnauthorizedAccount; alice can still buy(1,1,100e18) and receives a companion with 100e18 STAMP going to the frozen treasury. Verified in test/scratch/CompanionsLeads.t.sol::test_renounceFreezes.","severity":"low","snippet":"contract CourierCompanions is ERC1155, ERC2981, Ownable2Step, ReentrancyGuard {","title":"renounceOwnership is an undocumented owner power that freezes the lineup forever"},{"citation":"resolved","description":"The blanket try/catch is what makes a front-run permit harmless (good), but it also hides a bad or expired signature, a permit signed for a different value, or a permit for a different spender. The user then gets `ERC20InsufficientAllowance(shop, 0, cost)` with no hint that the signature was the problem, and the front end cannot distinguish 'permit already used' from 'permit malformed'. Fix (optional): after the catch, check `stamp.allowance(msg.sender, address(this)) >= cost` and revert with a dedicated error (e.g. `PermitFailed`) when the permit failed and the allowance is short; or emit an event in the catch branch.","line":103,"path":"contracts/src/CourierCompanions.sol","reproduction":"alice signs a permit for value 100e18+1 but calls buyWithPermit(1, 1, 100e18, deadline, v, r, s). Actual: revert ERC20InsufficientAllowance(shop, 0, 100e18). Expected: a permit-specific error. Verified in test/scratch/CompanionsLeads.t.sol::test_badPermitMaskedAsAllowance.","severity":"info","snippet":"        try IERC20Permit(address(stamp)).permit(msg.sender, address(this), maxCost, deadline, v, r, s) {} catch {}","title":"buyWithPermit swallows every permit failure, so a wrong signature surfaces as an allowance error"},{"citation":"resolved","description":"The permit approves `maxCost` but `_buy` only pulls `price * quantity`. Whatever slack the buyer added (or a price cut mined before the buy) stays approved to the shop indefinitely. Since `_buy` always debits `msg.sender` and the contract is immutable, this cannot be spent by anyone else, so it is a hygiene issue only (wallet UIs show a standing approval; a future owner cannot abuse it). Fix (optional): have the front end sign the permit for the exact cost (price x quantity) rather than maxCost, since the spender cannot reset an allowance it was granted; otherwise document the standing approval.","line":104,"path":"contracts/src/CourierCompanions.sol","reproduction":"Price 100e18; alice signs permit for 150e18 and calls buyWithPermit(1, 1, 150e18, ...). After the call stamp.allowance(alice, shop) == 50e18. Verified in test/scratch/CompanionsLeads.t.sol::test_permitLeftoverAllowance.","severity":"info","snippet":"        _buy(msg.sender, id, quantity, maxCost);","title":"buyWithPermit leaves a residual STAMP allowance to the shop when the price is below maxCost"},{"citation":"resolved","description":"The promise 'nothing can be minted without paying the current price; the owner has no free mint' holds literally, but the owner sets the current price with no floor other than 1 wei and no delay. The owner can therefore set a kind to price 1, buy up to 10 per call with perWallet 0, and restore the price in the same block, acquiring scarce legendaries for 10 wei of STAMP. Buyers are protected from the inverse (price hikes) by maxCost, but nothing protects the rarity of a kind from the owner. This is the agreed design (an owner-run shop), so it is reported as a trust assumption, not a defect. If stronger guarantees are wanted: a minimum price per kind that can only rise, or a timelock on setKind for existing kinds.","line":130,"path":"contracts/src/CourierCompanions.sol","reproduction":"owner: setKind(1, 1, 300, 0, true); approve 10; buy(1, 10, 10); setKind(1, 100e18, 300, 0, true). owner holds 10 companions, treasury received 10 wei. Verified in test/scratch/CompanionsLeads.t.sol::test_ownerNearFreeMint.","severity":"info","snippet":"        if (price == 0 || price > type(uint128).max) revert BadPrice();","title":"Trust assumption: the owner can mint for 1 wei by lowering the price, so 'no free mint' depends on the owner key"},{"citation":"resolved","description":"The contract promises it 'never holds tokens' but has no sweep. If the owner mis-pastes and calls setTreasury(address(shop)), every subsequent sale's STAMP is transferred to the shop and locked forever (safeTransferFrom to self succeeds). Likewise any STAMP or other ERC-20 sent to the shop by mistake is unrecoverable. Fix: reject `treasury_ == address(this)` alongside the zero check; optionally add an owner-only `rescue(IERC20, to)` limited to the shop's own balance (it never legitimately holds anything, so this adds no power over user funds).","line":147,"path":"contracts/src/CourierCompanions.sol","reproduction":"owner calls setTreasury(address(shop)); alice buys 1 of kind 1 for 100e18; stamp.balanceOf(shop) == 100e18 with no function able to move it. Expected: setTreasury reverts for the shop's own address.","severity":"info","snippet":"        if (treasury_ == address(0)) revert ZeroAddress();","title":"setTreasury accepts the shop's own address, and the contract has no way to recover tokens sent to it"},{"citation":"resolved","description":"`kindIds` only grows (setKind never removes) and only the owner grows it, so this is owner-induced gas, not an attack. Still, each iteration does a string concat plus a log; with a few thousand kinds `setBase` would exceed the block gas limit and the base URI could never be changed again. Thirteen kinds today is fine. Fix (optional): bound the lineup, or emit a single `BaseSet` and let indexers re-read `uri(id)`.","line":156,"path":"contracts/src/CourierCompanions.sol","reproduction":"Owner adds ~3000 kinds via setKind, then calls setBase('ipfs://x/'): gas grows linearly (~5-6k gas per kind plus the concat) and the call eventually fails with out-of-gas on a 30M-gas block; the base is then frozen.","severity":"info","snippet":"        for (uint256 i; i < kindIds.length; ++i) emit URI(uri(kindIds[i]), kindIds[i]);","title":"setBase emits a URI event per kind in an unbounded loop over kindIds"},{"citation":"resolved","description":"The suite is strong on the buy path but leaves the following edges unexercised: (1) test_newTreasuryGetsTheNextSales never asserts royaltyInfo after setTreasury (it would have surfaced the receiver mismatch); (2) no test calls renounceOwnership; (3) no test passes a permit with a wrong value/spender/expired deadline to buyWithPermit; (4) no test moves companions with safeBatchTransferFrom or checks balanceOfBatch; (5) DeployCompanions.run() is never executed in a test, so the 13-kind lineup (ids, prices x 1e18, supplies, per-wallet limits, open flag) is only checked by eye; (6) the invariant handler never transfers tokens, so invariant_boughtMatchesBalances only re-asserts the no-transfer assumption rather than testing that limits survive transfers.","line":311,"path":"contracts/test/CourierCompanions.t.sol","reproduction":"Add: assert royaltyInfo receiver after setTreasury; a test that sets env STAMP/TREASURY with vm.setEnv and runs new DeployCompanions().run(), then asserts kindCount()==13 and e.g. kinds(41) == (3000e18, 25, 0, 1, true, true); a test that buyWithPermit with a wrong-value signature reverts; a batch transfer followed by a WalletLimit revert.","severity":"info","snippet":"    function test_newTreasuryGetsTheNextSales() public {","title":"Test gaps: treasury rotation vs royalty, renounceOwnership, malformed permit, batch transfers and the deploy script are untested"}],"hash":"af953a978aab09bc82126740b0370588a73965915cd635e3d311ae4d999c57d8","nodeId":"d437f4cc-da92-4234-a2b0-928312bb5262","outcome":"completed","summary":"The review is complete and the findings are in `.imd-findings.json` at the repository root.\n\n**Outcome: no critical, high or medium defects in CourierCompanions.sol.** Every promise in the brief holds against the code as written, and the project's 25 tests (24 unit/fuzz plus the 3-invariant suite, 128k handler calls) pass on this tree. I wrote two low and six informational findings, each with a concrete reproduction, and verified five of them with a scratch Foundry test under `contracts/test/scratch/` (all five reproduce as described).\n\n**Promises checked and confirmed**\n\n- Minting only through `_buy`, which checks exists, open, quantity 1 to 10, supply, wallet limit and `cost <= maxCost` before any state change. The owner has no mint path and no transfer or burn path over user tokens.\n- Buyer pays exactly price x quantity via `safeTransferFrom(buyer, treasury, cost)`. The shop never holds tokens.\n- Supply can only shrink and never below minted. All downcasts are bounds-checked in `setKind`, and `minted += uint32(quantity)` cannot exceed `maxSupply`, so no overflow.\n- Wallet limits use the lifetime `bought` mapping, not balances.\n- State is settled before the STAMP transfer and the mint. OpenZeppelin 5.4.0 updates balances before the receiver callback, and `nonReentrant` blocks re-entry into both buy functions. A rejected receiver reverts the whole transaction, so no STAMP moves.\n- A front-run permit is caught by the try/catch and the buy proceeds on the allowance it left.\n- ERC-1155, ERC-1155 metadata and ERC-2981 interfaces are reported; `name`, `symbol` and `uri` return OpenSea-usable values.\n- The deploy script creates the 13-kind lineup with the intended ids, prices x 1e18, supplies and per-wallet limits, all open, and the web export script picks up the deployment file.\n\n**Findings reported**\n\n- **Low.** `setTreasury` does not update the ERC-2981 royalty receiver, so after a treasury rotation resale royalties keep going to the old address.\n- **Low.** `renounceOwnership` is inherited from Ownable2Step and is not among the brief's listed owner powers. Calling it freezes the lineup, base URI, treasury and royalty forever while open kinds keep selling.\n- **Info.** The permit try/catch masks malformed or expired signatures as an allowance error. A permit signed for `maxCost` leaves a residual allowance when the price is lower. The owner can effectively mint for 1 wei by lowering the price, which is the agreed design but worth stating as a trust assumption. `setTreasury` accepts the shop's own address and there is no token rescue. `setBase` loops over an owner-growable array. Several edges are untested, notably the deploy script itself and royalty after treasury rotation.\n\n**Scope notes**\n\nThe review covered CourierCompanions.sol and DeployCompanions.s.sol in full, with the OpenZeppelin ERC1155, ERC2981 and Ownable code read where behaviour depended on it. No static analyzer ran, since none is provided on this machine. Nothing in the repository was modified. The scratch test file is in the disposable `contracts/test/scratch/` directory and the findings file is at the repository root.","treeHash":null,"usage":{"cachedInputTokens":591553,"inputTokens":258,"model":"claude-fable-5-1","outputTokens":19356,"runtime":"claude","turns":15,"wallClockMs":799574}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"764cd99b4c628a47","findings":[{"citation":"resolved","description":"The brief lists the owner's powers as setKind, setTreasury, setBase, setRoyalty and the two-step ownership transfer. Ownable2Step also inherits Ownable.renounceOwnership(), which is not overridden, so the owner has a sixth, one-step, irreversible power. After renouncing, owner() is address(0) and every onlyOwner function reverts for good: no kind can be closed or repriced, supply can no longer be shrunk, the treasury cannot be rotated if its key is lost, and the royalty cannot be changed. Buying keeps working at the frozen prices. This is not an exploit by outsiders, but it is a deviation from the stated owner-power set and a single mis-click (or a compromised owner key) turns the shop into an unmanageable open sale. Suggested minimal fix (keeps the agreed design): `function renounceOwnership() public view override onlyOwner { revert(\"renounce disabled\"); }` (or revert with a custom error), and add a test that it reverts.","line":22,"path":"contracts/src/CourierCompanions.sol","reproduction":"State: shop deployed with owner O, kind 1 open at 100e18. Input: O calls renounceOwnership(). Expected (per the promise list): no such power, or a revert. Actual: owner() == address(0); O (and anyone) calling setKind(1, 100e18, 300, 0, false) reverts OwnableUnauthorizedAccount(O); setTreasury(x) reverts the same way; alice can still buy(1, 1, 100e18) and gets minted. Confirmed with a Foundry test (test_renounceFreezesConfig) against commit bbe1784.","severity":"low","snippet":"contract CourierCompanions is ERC1155, ERC2981, Ownable2Step, ReentrancyGuard {","title":"Owner power outside the stated list: renounceOwnership() freezes the shop's configuration forever"},{"citation":"resolved","description":"The constructor binds both roles to the same address: sales go to `treasury` and `_setDefaultRoyalty(treasury_, 500)`. setTreasury only updates the first. After a rotation, marketplaces keep paying royalties to the previous treasury until the owner separately calls setRoyalty(newTreasury, bps). If the treasury is rotated because the old key was lost or compromised, resale royalties keep flowing to that address. The deploy script's own comment describes TREASURY as 'where the $STAMP goes, and the 5% royalty', so a single setTreasury call is likely to be read as moving both. If keeping the two independent is intentional, document it in the setTreasury NatSpec; otherwise the minimal fix is to carry the current numerator over in setTreasury: read `(address r, uint256 fee) = royaltyInfo(0, 10_000);` and, if `r == treasury` (the old one), call `_setDefaultRoyalty(treasury_, uint96(fee))`.","line":148,"path":"contracts/src/CourierCompanions.sol","reproduction":"State: shop deployed with treasury T (royalty 500 bps to T). Input: owner calls setTreasury(N) with N != T. Expected (if 'treasury' means the royalty recipient too): royaltyInfo(1, 1000e18) returns (N, 50e18). Actual: royaltyInfo(1, 1000e18) returns (T, 50e18) while the next buy's $STAMP goes to N. Confirmed with a Foundry test (test_setTreasuryLeavesRoyaltyReceiver) against commit bbe1784.","severity":"low","snippet":"        treasury = treasury_;","title":"setTreasury rotates where sales go but leaves the ERC-2981 royalty receiver at the old treasury"},{"citation":"resolved","description":"The permit is signed for maxCost, but the buy only pulls price x quantity. Whatever headroom the buyer allowed (so that a small price change does not revert) stays approved to the shop after the transaction. The shop can only ever spend it inside _buy on the buyer's own call, so there is no loss on this code, but it is dormant allowance the buyer's wallet UI will show and that would matter if the contract ever gained another transferFrom path. Not a math defect; the 'never pays more than maxCost' and 'pays exactly price x quantity' promises hold. Optional hardening: after the transfer, if `maxCost > cost`, nothing can be done on-chain without the buyer's signature, so the realistic mitigation is in the front end (sign maxCost close to the quoted cost) or in NatSpec.","line":103,"path":"contracts/src/CourierCompanions.sol","reproduction":"State: kind 1 at 100e18. Input: alice signs a permit for 1_000e18 and calls buyWithPermit(1, 1, 1_000e18, deadline, v, r, s). Expected: treasury +100e18. Actual: treasury +100e18 (correct) and stamp.allowance(alice, shop) == 900e18 afterwards. Confirmed with a Foundry test (test_permitResidualAllowance).","severity":"info","snippet":"        try IERC20Permit(address(stamp)).permit(msg.sender, address(this), maxCost, deadline, v, r, s) {} catch {}","title":"buyWithPermit leaves a residual allowance of maxCost - cost approved to the shop"},{"citation":"resolved","description":"The limit counts lifetime purchases by msg.sender, exactly as promised, and transfers do not reset it. It does not, and cannot, stop one person from buying from several wallets and consolidating. This is the expected limit of the mechanism and is recorded here as a trust assumption so the lineup's '1 per wallet' kinds (31, 32, 41-43) are not marketed as '1 per player'. No code change recommended.","line":114,"path":"contracts/src/CourierCompanions.sol","reproduction":"State: kind 31 with perWallet 1. Input: alice buys 1 from wallet A, then 1 from wallet A2, and A2 transfers it to A. Expected per the promise: A's bought[A][31] stays 1 (it does). Actual holdings: balanceOf(A, 31) == 2. Confirmed with a Foundry test (test_walletLimitSybil).","severity":"info","snippet":"        if (k.perWallet != 0 && already + quantity > k.perWallet) revert WalletLimit();","title":"Per-wallet limit is per address, so it bounds purchases per wallet, not per person"}],"hash":"b6cd0c6899f70dbaf188d8161fd116eefa21acdcf96501aa5239359a0204af41","nodeId":"fa1d54b8-305a-45b7-9b14-5e95b18fd6ff","outcome":"completed","summary":"The review is complete. The findings file is in place, the scratch test directory is removed, and no repository file was changed.\n\n**Outcome: the math is sound. No critical, high or medium defects.** I found two low-severity deviations from the stated promises and two informational trust assumptions, each confirmed with a Foundry test run against commit bbe1784 before being recorded.\n\n**What I verified on the math, boundary and numerical-gap side**\n\n- Casts are all guarded before narrowing: price is rejected above the uint128 maximum, supply and per-wallet above the uint32 maximum, so no truncation is reachable.\n- Cost is computed in uint256 as price × quantity with quantity capped at 10, so it cannot overflow even at the maximum price.\n- The minted counter is bounded by maxSupply before the uint32 add, so it cannot wrap.\n- Every boundary is inclusive where it should be: quantity 10 passes and 11 fails, cost equal to maxCost passes, buying up to exactly perWallet passes, shrinking supply to exactly minted passes, royalty of exactly 1000 bps passes.\n- Supply shrink is one-way and never below minted. Wallet limits count purchases, and transfers do not reset them.\n- State is settled before the token pull and the mint. Both buy paths are guarded, so the receiver hook cannot re-enter. A rejected receiver reverts the whole buy with no $STAMP moved.\n- A front-run permit is swallowed by the try/catch and the buy proceeds on the allowance it left.\n- ERC-1155, ERC-1155 metadata and ERC-2981 interface ids are reported. Royalty math rounds down as the standard expects.\n- The deploy script's lineup matches its own comment: 13 kinds, prices scaled by 1e18, supplies and per-wallet limits consistent, and the owner is the broadcaster.\n- The existing 24 unit tests plus the 3 invariants pass, with the invariant run covering 128,000 handler calls.\n\n**Findings recorded**\n\n- **Low: renounceOwnership is an unlisted owner power.** It is inherited and not overridden. One call permanently freezes every setting while sales continue at the frozen prices. Fix: override it to revert.\n- **Low: setTreasury does not move the royalty receiver.** After rotating the treasury, sales go to the new address but ERC-2981 royalties keep going to the old one. Fix: carry the current royalty over in setTreasury, or document that the two are independent.\n- **Info: buyWithPermit leaves maxCost minus cost approved** to the shop. Harmless on this code, since only the buyer's own call can spend it.\n- **Info: per-wallet limits are per address**, so a second wallet plus a transfer gets around the 1-per-wallet kinds. Expected limit of the mechanism, recorded as a trust assumption.\n\n**Not covered.** No external static analyser ran, since none is provided. The review did not fork Robinhood Chain, so the deployed $STAMP address was not read on-chain and is taken as stated.","treeHash":null,"usage":{"cachedInputTokens":509350,"inputTokens":258,"model":"claude-fable-5-1","outputTokens":16715,"runtime":"claude","turns":14,"wallClockMs":656653}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"3a40eaafbd83a6bc","findings":[{"citation":"resolved","description":"The promise is that nothing is minted without paying the current price and that the owner has no free mint. On-chain this holds only nominally. setKind accepts any price >= 1 wei with no floor and no delay, so the owner can set a legendary kind to 1 wei, buy 10 of it, and restore the 2500 STAMP price in the same block, paying 10 wei (1e-17 STAMP) for companions the public pays 25,000 STAMP for. Separately, script/deploy-companions.sh instructs signing with the treasury wallet so that it becomes the owner; when owner == treasury, _buy's safeTransferFrom(buyer, treasury, cost) moves STAMP from the owner to itself, so every owner buy is free at any price. Neither is a permission bypass, but both contradict the stated guarantee and the 'limited stays limited' economics (minted supply can be taken by the owner at no cost). Suggested fix, preserving the owner role: add a MIN_PRICE constant (e.g. 1e18) or a minimum relative to the current price, and either a short timelock on price cuts for existing kinds or an explicit 'the owner can buy at cost from itself' trust note in the README/AUDIT.md. If a free owner mint is in fact acceptable, change the promise text rather than the code.","line":130,"path":"contracts/src/CourierCompanions.sol","reproduction":"State: kind 31 (MECHA) set at price 2500e18, maxSupply 30, perWallet 1, owner holds >= 10 wei STAMP. Owner calls setKind(31, 1, 30, 0, true); approve(shop, 10); buy(31, 10, 10); setKind(31, 2500e18, 30, 1, true). Actual: owner STAMP balance drops by exactly 10 wei, owner balanceOf(31) == 10, kinds(31).price == 2500e18 again, minted == 10 of 30. Expected per promise: owner cannot obtain companions without paying the going price. Second state: shop deployed with treasury_ == owner_ (as deploy-companions.sh directs); owner approves and calls buy(31, 10, type(uint256).max) at 2500e18: owner STAMP balance is unchanged afterwards and owner holds 10 of kind 31. Verified in test/scratch/CompanionsRepro.t.sol (test_ownerNearFreeMint, test_ownerIsTreasuryBuysFree).","severity":"low","snippet":"        if (price == 0 || price > type(uint128).max) revert BadPrice();","title":"Owner has an effectively free mint: price floor is 1 wei and owner == treasury round-trips every payment"},{"citation":"resolved","description":"The brief lists the owner powers as setKind, setTreasury, setBase, setRoyalty and two-step ownership transfer. Ownable2Step keeps Ownable.renounceOwnership(), which is single-step (no acceptance) and sets owner to address(0) permanently. After it, no kind can be closed, repriced or shrunk, the treasury and royalty receiver cannot be rotated, and open kinds keep selling at their last price to the last treasury forever. A mis-click or a compromised owner key can do this in one transaction, defeating the reason Ownable2Step was chosen. Suggested fix: override renounceOwnership() to revert (e.g. `function renounceOwnership() public view override onlyOwner { revert(); }`) and add a test asserting it, or document it as an accepted power and add it to the list.","line":22,"path":"contracts/src/CourierCompanions.sol","reproduction":"State: fresh deploy, kind 1 open at 100e18. Owner calls renounceOwnership(). Actual: owner() == address(0); owner's subsequent setKind(1, 100e18, 300, 0, false) and setTreasury(bob) revert OwnableUnauthorizedAccount; alice can still approve and buy(1, 1, 100e18) and 100e18 STAMP goes to the original treasury. Expected per stated powers: no single-step way to give up or lose control. Verified in test/scratch/CompanionsRepro.t.sol (test_renounceIsOneStepAndFreezes). Existing suite has no test touching renounceOwnership.","severity":"low","snippet":"contract CourierCompanions is ERC1155, ERC2981, Ownable2Step, ReentrancyGuard {","title":"renounceOwnership is an unlisted, one-step, irreversible owner power that freezes the lineup"},{"citation":"resolved","description":"The brief states 'Resales pay an ERC-2981 royalty (5% to the treasury)'. ERC-2981 only reports a royalty; nothing in this contract enforces it. safeTransferFrom/safeBatchTransferFrom are the stock OpenZeppelin implementations with no transfer hook, so any peer-to-peer sale, or any marketplace that ignores royaltyInfo, pays the treasury nothing. The sibling collection CourierSkins (contracts/src/CourierSkins.sol, lines 30-32 and 300-318) enforces its 5% through an OpenSea creator-fee transfer validator set at deploy; Companions silently drops that protection, so the two collections sold under the same brand have different royalty guarantees. Suggested fix: either (a) add the ERC-1155 equivalent of the Skins validator (ICreatorToken getTransferValidator/setTransferValidator and a validateTransfer call in _update for non-mint transfers, owner-replaceable, with the same 'nothing stops you moving your own tokens' escape as Skins), or (b) change the promise to 'ERC-2981 royalty is reported; enforcement depends on the marketplace' in the README and AUDIT.md.","line":82,"path":"contracts/src/CourierCompanions.sol","reproduction":"State: alice bought kind 31 for 2500e18; treasury STAMP balance = T. Bob pays alice 10_000e18 STAMP directly (stamp.transfer), then alice calls shop.safeTransferFrom(alice, bob, 31, 1, \"\"). Actual: bob holds kind 31, treasury balance still == T, no royalty paid. Expected per brief: resale pays 5% (500e18) to the treasury. Verified in test/scratch/CompanionsRepro.t.sol (test_royaltyIsAdvisoryOnly). royaltyInfo(31, 10_000e18) does return (treasury, 500e18) but nothing acts on it.","severity":"low","snippet":"        _setDefaultRoyalty(treasury_, 500);","title":"Resale royalty is advisory only: no transfer validator, unlike CourierSkins, so a resale can pay 0"},{"citation":"resolved","description":"The constructor binds both the sale destination and the default royalty receiver to treasury_, matching the brief's '5% to the treasury'. setTreasury only updates the sale destination; the royalty receiver stays on the previous address until the owner separately calls setRoyalty, which also requires re-supplying the bps. A treasury migration (key rotation, move to a multisig, or a compromised treasury) therefore keeps routing marketplace royalties to the abandoned or compromised address, with no event hinting at the split. Suggested fix: in setTreasury, also refresh the default royalty when the current receiver equals the old treasury (`(address r, uint256 fee) = royaltyInfo(0, _feeDenominator()); if (r == old) _setDefaultRoyalty(treasury_, uint96(fee));`), or at minimum document in the function's natspec and the runbook that setRoyalty must be called alongside setTreasury, and add a test.","line":148,"path":"contracts/src/CourierCompanions.sol","reproduction":"State: deployed with treasury = A; royaltyInfo(1, 1000e18) == (A, 50e18). Owner calls setTreasury(B). Actual: treasury() == B, buys now pay B, but royaltyInfo(1, 1000e18) still returns (A, 50e18). Expected per '5% to the treasury': royalty receiver follows the treasury, or the mismatch is documented. Verified in test/scratch/CompanionsRepro.t.sol (test_treasuryRotationLeavesRoyaltyBehind). The existing test_newTreasuryGetsTheNextSales does not check royaltyInfo after rotation.","severity":"low","snippet":"        treasury = treasury_;","title":"setTreasury rotates sales revenue but leaves the ERC-2981 royalty receiver at the old treasury"},{"citation":"resolved","description":"The permit is signed for maxCost (the buyer's ceiling), while _buy pulls only cost = price x quantity. Whenever maxCost > cost (which the front end will do to tolerate price movement), the difference remains as an open STAMP allowance from the buyer to the shop. The shop only ever calls safeTransferFrom(msg.sender, ...), so today nobody can spend it on the buyer's behalf, but it is invisible residual approval that the 'one signature, one transaction' wording does not mention, and it would become live exposure if a future version added any pull path keyed on an address other than msg.sender. Suggested fix: document it, have the front end sign permits for the exact cost when maxCost == cost, or (optionally) call stamp.approve-reset semantics are not available from the spender side, so documentation plus a front-end rule is the practical fix.","line":103,"path":"contracts/src/CourierCompanions.sol","reproduction":"State: kind 1 at 100e18. Alice signs permit(alice, shop, 1_000e18, deadline) and calls buyWithPermit(1, 1, 1_000e18, deadline, v, r, s). Actual: 100e18 moves to the treasury, 1 companion minted, stamp.allowance(alice, shop) == 900e18 afterwards. Expected by the natspec's reading ('a signed permit for maxCost instead of an approval'): no lingering approval beyond the purchase. Verified in test/scratch/CompanionsRepro.t.sol (test_permitLeavesResidualAllowance). Also verified: a permit for less than cost with no prior allowance reverts the whole call and leaves the nonce unconsumed (test_permitBelowCostConsumesNonceThenReverts), and a front-run permit still buys (existing test_permitUsedFirstStillBuys).","severity":"info","snippet":"        try IERC20Permit(address(stamp)).permit(msg.sender, address(this), maxCost, deadline, v, r, s) {} catch {}","title":"buyWithPermit approves maxCost but spends price x quantity, leaving a standing allowance to the shop"},{"citation":"resolved","description":"MAX_PER_BUY bounds a single call, not a transaction or a wallet, and the deploy lineup gives pets (1-3) and space buddies (11-13) perWallet = 0. A buyer contract can therefore loop buy(id, 10, ...) and take the entire 300-unit supply of a kind in one transaction at launch, and perWallet on the other kinds is per-address so fresh wallets bypass it. This matches the literal promise ('1 to 10 per buy') and is listed only so the economics are understood: 'limited' kinds are limited in total, not in distribution. Suggested handling: if fair distribution matters, give every kind a perWallet in the lineup (and accept the sybil caveat) or add a per-block/per-tx cap; otherwise say so in the README.","line":37,"path":"contracts/src/CourierCompanions.sol","reproduction":"State: kind 1 (cat) open at 100e18, maxSupply 300, perWallet 0. A contract holding 30_000e18 STAMP approves the shop and calls buy(1, 10, max) thirty times in one external call (its own onERC1155Received returns the selector). Actual: after one transaction it holds 300 of kind 1 and kinds(1).minted == maxSupply; everyone else gets SoldOut. Verified in test/scratch/CompanionsRepro.t.sol (test_wholeKindSweptInOneTx).","severity":"info","snippet":"    uint256 public constant MAX_PER_BUY = 10;","title":"MAX_PER_BUY and perWallet are not anti-whale limits: one transaction can sweep an unlimited kind"},{"citation":"resolved","description":"The script's 13-kind lineup (ids 1-3, 11-13, 21-22, 31-32, 41-43; prices 100/250/500/2500/3000 STAMP; supplies 300/150/50/30/25; perWallet 0/0/2/1/1) is internally consistent, converts whole-STAMP prices with * 1e18, opens every kind, sets owner = broadcaster and reads STAMP/TREASURY from deployments/robinhood.json and launch.env, and export-web.sh already merges deployments/robinhood-companions.json. However the comment says 'The game's companions.js draws each id' and no companions.js (or any companion rendering/reading code) exists under web/, and the metadata base https://courierworld.fun/companions/ has no files in the repo. If the script is broadcast as-is, buyers pay STAMP for token ids the game cannot show and marketplaces cannot render. Not a contract defect; a flow gap to close before deployment: ship companions.js and the 13 JSON metadata files (ids 1..43 as named) first, or deploy with open = false and open kinds once the game reads them.","line":24,"path":"contracts/script/DeployCompanions.s.sol","reproduction":"State: repository at bbe1784. `ls web/` shows no companions.js and `grep -ri companion web/` returns nothing; web/deployments/robinhood.json has no companions entry. Running DeployCompanions.run() would set 13 kinds open = true immediately, so buy(1, 1, 100e18) succeeds on-chain while uri(1) == https://courierworld.fun/companions/1.json points at content that does not exist in the tree. Expected: lineup is deployed only once the game and metadata can display it.","severity":"info","snippet":"    /// mecha guardian and a colossus (31-32) and three anime-style companions (41-43). (The game's companions.js","title":"Deploy script lineup references game code (companions.js) that does not exist in the repo yet"},{"citation":"resolved","description":"The suites are good on the buy path (exact payment, maxCost, supply shrink, reentrancy via onERC1155Received, failed-mint atomicity, permit front-run, fuzz on price x quantity) but leave these edges unexercised: (1) the invariant handler never calls safeTransferFrom, so invariant_boughtMatchesBalances is tautological and the 'limits count purchases, not balances' property is only covered by one unit test; (2) handler.configure wraps setKind in try/catch and bounds supply to 1..500, so SupplyCanOnlyShrink reverts are swallowed and never asserted (table shows 0 configure reverts across 64k calls); (3) no test for renounceOwnership, setRoyalty with receiver == address(0) (OZ reverts ERC2981InvalidDefaultRoyaltyReceiver), royaltyInfo after setTreasury, the residual permit allowance, or buyWithPermit with a bad signature and no allowance; (4) no test that a kind added after setBase gets the right uri. Suggested fix: add a transfer action to the handler and change the invariant to bought >= balance-received-by-purchase (track per-buyer purchases in the handler), assert configure revert reasons, and add unit tests for the items in (3) and (4).","line":105,"path":"contracts/test/CourierCompanions.invariant.t.sol","reproduction":"Run `forge test --match-contract CourierCompanions -vv`: 25 tests pass, but grep for 'renounceOwnership', 'ERC2981InvalidDefaultRoyaltyReceiver', 'allowance(' and 'safeTransferFrom' in the invariant file returns no hits (only the unit test transfers once). Adding `shop.renounceOwnership()` or `shop.setTreasury(next); shop.royaltyInfo(1, 1e18)` assertions as in test/scratch/CompanionsRepro.t.sol shows behaviour the current suite does not pin down.","severity":"info","snippet":"                assertEq(shop.bought(b, id), shop.balanceOf(b, id)); // (the handler never transfers)","title":"Test gaps: invariant handler never transfers, owner-power edges (renounce, royalty vs treasury, residual allowance) untested"}],"hash":"c7f1964ba977e0b52aa7823582336a19e98dd0ee200075db05d7c1f13a535354","nodeId":"d645208f-eba3-46b6-94c1-728b7b8fc420","outcome":"completed","summary":"Review complete. The findings file is written and verified, and no repository source files were changed.\n\n**Outcome.** CourierCompanions holds up on every core promise I could test: no mint without a current-price payment, exact price × quantity straight to the treasury, maxCost ceiling, supply only shrinks and never below minted, lifetime wallet counts, checks-effects-interactions plus nonReentrant across the STAMP pull and the ERC-1155 receiver hook, whole-buy atomicity when the mint fails, a front-run permit still buying, bounded casts with no overflow path, ERC-1155 and ERC-2981 interface support, and a deploy script whose lineup is internally consistent and wired into export-web.sh. The existing 25 tests pass, including the 128k-call invariant run.\n\n**Findings written to `.imd-findings.json`** (8 total, none critical or high, each with a reproduction confirmed by a scratch Foundry test):\n\n- **Low: owner effectively has a free mint.** The price floor is 1 wei with no delay, so the owner can reprice a legendary to 1 wei, buy 10, and restore the price. And since the deploy runbook makes the treasury the owner, every owner purchase round-trips to itself. Suggested: a minimum price or a documented trust assumption.\n- **Low: `renounceOwnership` is an unlisted one-step power.** It is inherited, irreversible, and freezes every kind and the treasury forever. Suggested: override it to revert.\n- **Low: royalty is advisory only.** No transfer validator, unlike CourierSkins, so a peer-to-peer resale pays the treasury nothing. Suggested: add the ERC-1155 validator pattern or soften the promise.\n- **Low: `setTreasury` does not move the royalty receiver.** Rotation leaves royalties at the old address silently.\n- **Info:** residual permit allowance when maxCost exceeds cost; MAX_PER_BUY is per call so one transaction sweeps an unlimited kind; the deploy script references a `companions.js` and metadata that do not exist in the tree yet; test gaps in the invariant handler and owner-edge cases.\n\n**Verification.** All 8 snippets match the quoted file lines. Scratch reproductions live in `contracts/test/scratch/CompanionsRepro.t.sol` and pass against the current code. Slither and fork tests did not run; all twelve specialty passes were applied as review lenses rather than as independent agents.","treeHash":null,"usage":{"cachedInputTokens":987787,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":24181,"runtime":"claude","turns":16,"wallClockMs":782547}}],"verification":[]}