{"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":"d602a348-d29b-4607-86ed-b7a82b72b672","kind":"audit","nodes":[{"acceptedSubmissionHash":"a12fa54e9aa3e75d8472c1a825b282e8aefe19671269aadf2a9e9f8d1638d1d7","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":"cbf6493852ffe756c7408956a52a06ac4c1c178674e69f383e4c5e3910a51018","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":"36e65c7c7527f2ac00e9ef32061f6e5367b6d8e41a4448ef13ae8996e7eaf771","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":"740f5f8f26fc8e200524407c8d0f85d58a6243fbe096fdee4300f88d215d5878","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":"fed172e6733032fcb226633c126465a3d48d43051905865fda3e83710078fa6d","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":"Audit the registry and the collection factories: src/registry/, src/factories/ (erc721drop, erc721, erc1155), src/gating/, src/libraries/ and src/shared/ (RevenueSplitLib computes the mint split), at the pinned commit, for a mainnet launch. Read whatever else they depend on, but report on this scope.\n\nmiyamints is an NFT launchpad on Ethereum (later Robinhood Chain 4663) built on ms2fun-contracts. MiyaRegistry (src/registry/MiyaRegistry.sol) is a slim, non-upgradeable registry of factories, collections and alignment vaults; it holds the treasury and the fee terms each collection snapshots at launch: a 1% protocol fee to the treasury, a collection-chosen alignment share (minimum 10%) sent to a Uniswap v4 alignment vault, the rest to the artist. Factories: ERC721 fixed-price drops, ERC721 auctions, ERC1155 editions (also used for music drops) with an optional dynamic pricing module. Vaults (src/vaults/uni/) buy the alignment token with collected ETH through zRouter and add full-range v4 liquidity; benefactors claim LP fees pro rata. MiyaPool (src/pools/) is an NFTX-v2-style zero-fee pool: deposit an NFT for 1e18 pool tokens, redeem TARGETED (pick the id) or RANDOM (commit-reveal, ERC721 only), fixed per pool at creation. The deployment is script/DeployMiyamints.s.sol (runbook DEPLOY.md, per-chain config script/config/). An internal audit and its fixes are already in: AUDIT-NOTES.md lists every finding with its status, including M5, which stays unfixed and off at deploy. A finding already listed counts only if its fix is wrong or incomplete.\n\nAnswer each numbered question, including the ones where nothing is wrong, and report anything else you find in scope.\n\n1. Fee split on every sale path (drop mint/withdraw, auction settle, 1155 mint incl. dynamic price and free mints): can the protocol, alignment or artist share be skipped, rounded away, redirected, or exceed what was paid? Is the 10% alignment floor enforced at creation and impossible to lower afterwards?\n2. MiyaRegistry access control: who can register factories, collections and vaults; can a non-registered factory or a malicious vault get a collection to send it ETH; what can the owner change for existing collections (fees are meant to be snapshotted)?\n3. Drop instance: phases, per-wallet limits (counters reset on setPhases), supply cap, exact-payment check, permissionless withdraw and the pending vault cut flush. Any path to mint above supply/limits, lock funds, or grief withdraw?\n4. Auctions: bidding, refunds by force-send, settlement with escrow for winners that reject the mint (claimEscrowedToken), auction lines. Can a bidder freeze a line, steal a refund, or settle twice?\n5. ERC1155Instance after the fixes (supportsInterface/uri/balanceOfBatch, transfers to address(0) revert, renounce blocked, claimFreeMint respects openTime): any mint path that skips the fee split, and is the missing receiver hook on mint exploitable?\n6. The auction and 1155 factories deploy instances through CreateX CREATE3 with guarded salts, and ERC721DropFactory uses plain `new`: can a front-runner squat or brick a creator's address, and can anyone deploy an instance the registry accepts with arbitrary parameters?\n7. PasswordTierGatingModule (src/gating/): the internal audit could not verify M5. Does the module gate anything as wired (the deploy leaves it off by default)?\n8. Drop additions the internal audit did not cover (section in AUDIT-NOTES.md). Prove or break: phases can never sell the unused ownerReserve, ownerMint can never exceed it, and totalSupply never exceeds maxSupply, in any order of mints and setPhases calls. Is Phase.supplyCap counted correctly, and is what setPhases can reset limited to what the notes state? Can mintTo get around the allowlist or per-wallet limit in a way the notes don't state, or mint without the fee split? Can a locked royalty change by any path? Do the EIP-4906 and ERC-1155 URI events and the 0x49064906 interface id meet their specs?","parentJobId":null,"planHash":"921c97ebea40f6477b11b64e65d99bffb7ec4917dad8c558148d5f95021199b7","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"d602a348-d29b-4607-86ed-b7a82b72b672","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":"51875","feedbackHash":"1b116feb9b996fcae5f1999731a7bfb3b026c5bead62c952205878e314b778ad","nodeKey":"audit_economics","submissionHash":"a12fa54e9aa3e75d8472c1a825b282e8aefe19671269aadf2a9e9f8d1638d1d7","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51488","feedbackHash":"ea7031d57173ce5bb1fbabb603e124a58bb10802dbb976e50ff94cf614b6a7a0","nodeKey":"audit_flow","submissionHash":"cbf6493852ffe756c7408956a52a06ac4c1c178674e69f383e4c5e3910a51018","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51492","feedbackHash":"e4caef90d809a30bae29519918c923f00664537f91c53867b691a4af51c780e9","nodeKey":"audit_judge","submissionHash":"36e65c7c7527f2ac00e9ef32061f6e5367b6d8e41a4448ef13ae8996e7eaf771","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52173","feedbackHash":"5ca696c99f1f3ed83a900d6488593e5c108184116edc98dc254cc0b3d8f99904","nodeKey":"audit_math","submissionHash":"740f5f8f26fc8e200524407c8d0f85d58a6243fbe096fdee4300f88d215d5878","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50953","feedbackHash":"6e59118442526cd5e3b79c60933b5ba2de48138c0fe07523fb5f4bea2dfc8a5f","nodeKey":"audit_permissions","submissionHash":"fed172e6733032fcb226633c126465a3d48d43051905865fda3e83710078fa6d","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"61851d6d299416f64e70a0187193ead9539f17d30d7d1383f9630ff43bd57441","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"be85220723d9ce9d","findings":[{"citation":"resolved","description":"Q4. The M6 fix catches a receiver that reverts, but not one that consumes the gas it is given. The self-call gets 63/64 of remaining gas (EIP-150), and Solady's _checkOnERC721Received forwards gas() to the winner. Everything after the catch has to run on what is left: _mint to the instance, the escrowedWinner write, the owner deposit refund, the treasury transfer, vault.receiveContribution, the artist payout and _advanceLine. I estimate that at 250-300k gas (estimated from opcode costs, not measured). Two budgets apply. (a) If the hook simply burns everything, the self-call frame then reverts cheaply and returns its reserve, so settleAuction keeps about G*127/4096, roughly G/32. Settlement then needs about 8-10M gas. (b) If the hook burns most of its gas and then reverts with a large payload, the self-call frame runs out of gas in Solady's returndatacopy bubble-up, because it pays for the memory expansion from only a 1/64 reserve. settleAuction then keeps only G/64, so settlement needs about 16-19M gas. Mainnet's EIP-7825 cap is 16,777,216 gas per transaction, which leaves about 261k after the try. That sits inside the estimated requirement, so in case (b) settlement may only just fit in a cap-sized transaction or may not fit at all. The specialists' figures assumed G/64 for a plain burner; the plain burner actually keeps about twice that. I do not claim a proven permanent freeze. Until someone pays for that transaction, the line cannot advance (_advanceLine runs only from settleAuction or reclaimUnsold, and reclaimUnsold refuses auctions with bids), and the artist's proceeds, deposit and vault cut stay locked. The bid is at risk only if settlement truly cannot fit; if it fits, the winner can still pull the token with claimEscrowedToken, so griefing costs the winner nothing extra. The hook can also be toggled by the winner, which allows a ransom. Fix: cap the hook, e.g. `try this.safeMintWinner{gas: 300_000}(winner, tokenId) {}`, so a winner needing more falls to the existing escrow path. The cap also bounds the returndata cost.","line":309,"path":"src/factories/erc721/ERC721AuctionInstance.sol","reproduction":"Static trace; not executed, because forge could not run (the shell sandbox failed to start). State: 1-line auction, token 1 queued with queuePiece{value: 0.1 ether}. Contract W bids createBid{value: 1 ether}(1, \"\") and implements onERC721Received as `while (gasleft() > 400_000) {} assembly { revert(0, 400000) }`. After endTime, anyone calls settleAuction(1) with 1M, 5M or 10M gas. Expected: settlement completes at normal gas (about 300-500k) with the token escrowed, which is what a plainly reverting receiver gets. Actual per the gas accounting: settleAuction keeps only G/64 after the try (15.6k, 78k, 156k), runs out of gas in the catch path or just after it, and the whole transaction reverts with all gas spent. Only a transaction near or above 16.7M gas can succeed. With `while (true) {}` as the hook the threshold is about 8-10M gas.","severity":"medium","snippet":"try this.safeMintWinner(winner, tokenId) {}","title":"M6 fix incomplete: settleAuction forwards all gas to the winner's onERC721Received, so a gas-burning winner forces settlement to use about 8-16M+ gas, up to the mainnet per-transaction cap"},{"citation":"resolved","description":"Q6. Both CREATE3 factories (this line and src/factories/erc1155/ERC1155Factory.sol:87) forward a caller-chosen salt unchanged. There are two mechanisms. (1) With the factory-prefixed salt the repo's helpers use (factory || 0x00 || 11 bytes), CreateX guards on the factory only, so the address depends on (factory, salt) and not on the creator. Anyone who calls createInstance first with that salt gets an instance they own, registered in MiyaRegistry, at the address the creator computed and may have announced. The creator's call then reverts with FailedContractCreation. Users or allowlists pointed at the announced address end up paying the attacker's collection, and the registry reports it as genuine. (2) With any other salt (e.g. bytes32(uint256(1)) or keccak256(name)), CreateX's _guard falls through to keccak256(abi.encode(salt)), which is the same for every caller. Anyone can call CreateX.deployCreate3 directly with the same salt to occupy the slot; that contract is not registered, but the creator's deployment still reverts. computeInstanceAddress always assumes the prefixed-0x00 form, so for these salts, and for 0x01-flag salts, it returns an address the deployment never lands on. test_computeInstanceAddress in ERC721AuctionFactory.t.sol uses bytes32(uint256(1)) and only asserts a non-zero result, so it does not catch this. Limits: no funds are lost in the reverted transaction and the creator can retry with a new salt. Nobody can get arbitrary parameters registered: createInstance requires msg.sender == params.creator, a registered vault and the alignment floor, and only the factory's fixed creation code is deployed. ERC721DropFactory uses plain `new` and has no salt, so it is unaffected. UniAlignmentVaultFactory.deployVault has the same shape but is outside this scope. Fix: derive the salt inside the factory, e.g. bytes32(abi.encodePacked(address(this), bytes1(0), bytes11(keccak256(abi.encode(msg.sender, salt))))), and expose computeInstanceAddress(creator, salt) using the same derivation.","line":119,"path":"src/factories/erc721/ERC721AuctionFactory.sol","reproduction":"Static trace; not executed, because forge could not run. The specialists' proofs .imd/reads/proofs/Proof_1cb456ed38f6.t.sol (auction) and Proof_9bac9c08b1fe.t.sol (1155) encode this scenario but were not run, so neither is attached. State: real CreateX bytecode at CREATEX, a registered vault and a registered factory F. Case A: salt = bytes32(abi.encodePacked(address(F), bytes1(0x00), bytes11(keccak256(\"creator-vanity\")))). The attacker calls F.createInstance(salt, params{creator: attacker, vault: registeredVault, alignmentBps: 1000, ...}), then the creator calls F.createInstance(salt, params{creator: creator, ...}). Expected: the creator's instance deploys at the address it previewed. Actual: the attacker's instance sits at F.computeInstanceAddress(salt) with registry.factoryOf(addr) == F and owner == attacker, and the creator's call reverts in CreateX with FailedContractCreation. Case B: salt = bytes32(uint256(1)). The attacker calls ICreateX(CREATEX).deployCreate3(salt, hex\"6000\") directly, and the creator's F.createInstance(salt, ...) reverts the same way. Even with no attacker, the instance would land at computeCreate3Address(keccak256(abi.encode(salt)), CREATEX), not at F.computeInstanceAddress(salt).","severity":"medium","snippet":"instance = ICreateX(CREATEX).deployCreate3(salt, initCode);","title":"CREATE3 salt is not bound to the creator in ERC721AuctionFactory and ERC1155Factory: a creator's address can be squatted and their deployment forced to revert, and computeInstanceAddress mispredicts n"},{"citation":"resolved","description":"Q7. M5 is confirmed, but the note understates the problem, and wiring configureFor into a factory (the fix it implies) would not make the module gate anything. (1) M5 as stated: the first configureFor must come from a registered factory (line 62), no factory calls it, and the instance owner is rejected until configured[instance] is true. _configs[instance] therefore stays zeroed, which is VOLUME_CAP with no tiers. (2) A bytes32(0) password is tier 0, and tier 0 is the least restricted tier in both modes: VOLUME_CAP gives cap = type(uint256).max (line 103) and TIME_BASED skips the lock because of its `tier > 0` guard (line 105). Presenting a valid password can only add a cap or a delay. mint() lets the caller choose gatingData, so everyone passes zero. (3) Even if tier 0 were closed, there is no secret. The credential compared on chain is the stored hash, not a preimage: it is what the caller passes, and it is readable from getConfig(instance).passwordHashes and from any earlier mint's calldata. (4) The existing tests test_canMint_openTier_noPassword and test_timeBased_tier0_alwaysOpen assert the open-tier behaviour. Impact: none at launch, since deployGatingModule is false in script/config/1.json and 4663.json and createInstance rejects unapproved modules. If it is enabled, a creator who selects the module gets a collection anyone can mint, and a gated free-mint allocation can be drained by anyone. Fix before enabling: have the factory call configureFor with creator-supplied tiers; add a way to require a credential (e.g. reject bytes32(0) once tiers exist); replace the hash-as-password scheme with a per-wallet Merkle proof or signature, because a shared on-chain secret cannot work. Otherwise withdraw the module.","line":99,"path":"src/gating/PasswordTierGatingModule.sol","reproduction":"Static trace, consistent with the repo's own tests named above; not executed. (a) Registered factory F calls configureFor(I, TierConfig{tierType: VOLUME_CAP, passwordHashes: [keccak256(\"p1\")], volumeCaps: [100], tierUnlockTimes: []}). Then as I: canMint(user, 1_000_000, abi.encode(bytes32(0), 0)). Expected for a password gate: refused. Actual: returns (true, false). canMint(user, 101, abi.encode(keccak256(\"p1\"), 0)) reverts VolumeCapExceeded, so holding the password is worse than not. (b) TIME_BASED with tierUnlockTimes [1 hours], at block.timestamp == openTime: data abi.encode(bytes32(0), openTime) is allowed, and data abi.encode(keccak256(\"p1\"), openTime) reverts TierTimeLocked. (c) Anyone reads getConfig(I).passwordHashes[0] and calls I.mint(1, 1, thatHash, \"\", 0) with no password. (d) Without factory wiring: the owner's configureFor(I, cfg) reverts Unauthorized, and any wallet's I.mint{value: price}(1, 1, bytes32(0), \"\", 0) succeeds.","severity":"medium","snippet":"uint256 tier = passwordHash == bytes32(0) ? 0 : _tierByPasswordHash[msg.sender][passwordHash];","title":"PasswordTierGatingModule gates nothing even if M5 is wired: the no-password tier is unrestricted, the credential is the public hash itself, and configureFor is unreachable"},{"citation":"resolved","description":"Q5. IGatingModule.sol defines FREE_MINT_ONLY as 'gates free mint claims only; paid buys are open', and the factory stores it once via initializeFreeMint. claimFreeMint honours its half of the enum (line 264 skips the module for PAID_ONLY), but mint() consults the module whenever one is set. A FREE_MINT_ONLY collection therefore has its paid sale gated, and neither the scope nor the module can be changed afterwards. Latent at launch because no gating module is approved. Fix: `if (address(gatingModule) != address(0) && gatingScope != GatingScope.FREE_MINT_ONLY)`.","line":426,"path":"src/factories/erc1155/ERC1155Instance.sol","reproduction":"Static trace; not executed. Owner calls registry.setComponent(M, true), where M's canMint returns (false, false) (MockRejectGatingModule in test/factories/erc1155/ERC1155Factory.t.sol). ERC1155Factory.createInstance with gatingModule = M and freeMint = {allocation: 5, scope: GatingScope.FREE_MINT_ONLY}. Owner calls addEdition(\"a\", 0.01 ether, 0, \"ipfs://x\", UNLIMITED, 0, 0). A buyer calls mint{value: 0.01 ether}(1, 1, bytes32(0), \"\", 0). Expected: success, since only free claims are gated. Actual: revert GatingCheckFailed().","severity":"low","snippet":"if (address(gatingModule) != address(0)) {","title":"ERC1155Instance.mint ignores GatingScope.FREE_MINT_ONLY, so paid buys are gated contrary to the irreversible scope the creator set"},{"citation":"resolved","description":"Q5/Q7. mint() builds the module payload itself as abi.encode(gatingData, edition.openTime), while claimFreeMint passes raw caller bytes. The module decodes them as (bytes32 passwordHash, uint256 openTime), so a claimer sets openTime = 0 and every TIME_BASED unlock is already in the past. The two entry points also disagree on format: a 32-byte hash accepted by mint() makes claimFreeMint revert in abi.decode. Latent today, since no instance can be configured (M5). A fix for M5 and the open tier would still leave this bypass, so it needs its own fix. Fix: claimFreeMint takes a bytes32 passwordHash and calls canMint(msg.sender, 1, abi.encode(passwordHash, edition.openTime)).","line":265,"path":"src/factories/erc1155/ERC1155Instance.sol","reproduction":"Static trace; not executed. PasswordTierGatingModule is configured for I by a registered factory with TIME_BASED, passwordHashes [keccak256(\"vip\")], tierUnlockTimes [1 days]. I has freeMintAllocation 10, scope BOTH, and edition 1 has openTime = T. At block.timestamp = T: I.mint(1, 1, keccak256(\"vip\"), \"\", 0) reverts TierTimeLocked. Expected: the free claim is locked the same way until T + 1 days. Actual: I.claimFreeMint(1, abi.encode(keccak256(\"vip\"), uint256(0))) computes unlockAt = 0 + 1 days, which is below block.timestamp, and succeeds.","severity":"low","snippet":"(bool allowed,) = gatingModule.canMint(msg.sender, 1, gatingData);","title":"claimFreeMint forwards caller-chosen gatingData, so the caller supplies the openTime used by a TIME_BASED tier lock"},{"citation":"resolved","description":"Q1. ERC721DropInstance.withdraw says it is 'Callable by anyone, so neither cut can be withheld', and auction settlement is also permissionless. In the 1155 instance, withdraw(amount) is the only path that moves mint proceeds to the treasury and vault, and only the owner can call it. Open editions sell over long periods, so unsplit balances can grow. An artist who stops caring, or loses the key, leaves 11%+ owed to the protocol and vault undelivered for good. The artist forgoes their own share to do this, so it is a delivery gap and not a profitable bypass; the split itself cannot be changed. Fix: make withdraw permissionless over totalProceeds - totalWithdrawn. Every recipient is fixed at launch and the artist's share still goes to owner().","line":468,"path":"src/factories/erc1155/ERC1155Instance.sol","reproduction":"Static trace; not executed. Create an instance and add an UNLIMITED edition at 1 ether; 10 buyers mint, so totalProceeds is 10 ether. The treasury or a third party calls withdraw(10 ether). Expected, by parity with the Drop: 0.1 ether reaches the treasury and at least 1 ether reaches the vault. Actual: revert Unauthorized, and the cuts stay in the instance until the owner acts.","severity":"low","snippet":"if (msg.sender != owner()) revert Unauthorized();","title":"ERC1155Instance.withdraw is owner-only, so the protocol and alignment cuts are delivered only when the artist chooses, unlike the Drop"},{"citation":"resolved","description":"Q4. createBid has no cap on total extension, no check that msg.sender differs from highBidder, and the increment is an absolute value of at least 1 wei. A high bidder re-bidding by bidIncrement gets their previous bid refunded in the same call, so they keep one bid at stake and pay only the increment and gas per round. While they continue, settleAuction reverts AuctionNotEnded, and proceeds and queued pieces on that line wait. Anyone can outbid, but the griefer can respond each time. It costs the griefer gas every timeBuffer window, so this is delay and not theft or a permanent freeze; it is cheapest on an L2 such as chain 4663. Fix: bound total extension (endTime <= startTime + baseDuration + MAX_EXTENSION), or require a percentage minimum increment.","line":274,"path":"src/factories/erc721/ERC721AuctionInstance.sol","reproduction":"Static trace; not executed. baseDuration 1 days, timeBuffer 10 minutes, bidIncrement 1 wei, lines 1, pieces 1 and 2 queued. B bids minBid on token 1. At endTime - 1 second, B calls createBid{value: highBid + 1}(1, \"\"): B's old bid is refunded and endTime becomes now + 10 minutes. B repeats inside each window. Expected: the auction ends a bounded time after the base duration. Actual: settleAuction(1) reverts AuctionNotEnded for as long as B repeats, and piece 2 never starts. B's net cost is 1 wei plus gas per round.","severity":"low","snippet":"if (auction.endTime - block.timestamp < timeBuffer) {","title":"Auction anti-snipe extension is unbounded and the high bidder can outbid themselves, so a line can be held open for only the increment plus gas per extension"},{"citation":"resolved","description":"Q1/Q3. UniAlignmentVault._trackBenefactorContribution reverts below MIN_CONTRIBUTION (0.001 ETH). All three sale paths catch that and park the cut in pendingVaultCut: Drop withdraw, ERC1155Instance withdraw and auction settleAuction. The retry functions (flushPendingVaultCut, and retryVaultContribution on the 1155) send only the parked amount, and no later split merges it in, so a parked total under 0.001 ETH can never be delivered unless further small cuts top it up past the minimum. Any Drop or auction sale, or 1155 withdraw, whose vault cut is under 0.001 ETH hits this: at the 10% floor, that is a split amount under 0.01 ETH. Drop withdraw is also permissionless, so anyone can split after every mint and turn each cut into dust, though that is delay and not loss until the last residue. The loss is bounded at under 0.001 ETH per collection and the ETH stays in the instance, excluded from every later split. It does break 'never redirected, always deliverable' for the alignment share. Fix: in the split path, include pendingVaultCut in the contribution when the combined amount reaches the minimum, or let the retry send the parked amount together with new proceeds.","line":258,"path":"src/factories/erc721drop/ERC721DropInstance.sol","reproduction":"Static trace; not executed. Drop: phase price 0.005 ether, alignmentBps 1000, real UniAlignmentVault. One mint, then anyone calls withdraw(): the vault cut of 0.0005 ether reverts ContributionBelowMinimum, so pendingVaultCut = 0.0005 ether. Then flushPendingVaultCut(). Expected: the alignment share reaches the vault. Actual: it reverts, and it keeps reverting if the drop sells no more. A later withdraw with a cut of at least 0.001 ether goes direct and leaves the 0.0005 ether parked. Auction: one piece settled at a 0.005 ether bid gives the same result via its flushPendingVaultCut.","severity":"low","snippet":"vault.receiveContribution{ value: pending }(Currency.wrap(address(0)), pending, address(this));","title":"Vault cuts parked below MIN_CONTRIBUTION can become permanently undeliverable, because a flush reverts and later splits never merge the parked amount"},{"citation":"resolved","description":"Q2/Q5. isApprovedComponent has no notion of component type. script/DeployMiyamints.s.sol:260 always approves DynamicPricingModule, so it is accepted as gatingModule even at the default launch config. It has no canMint, and gatingModule cannot be changed after creation, so every paid mint of that instance reverts forever. This is creator or frontend error with no third-party victim, and proceeds are not at risk because none can accrue. Fix: check the component's type, e.g. with a per-category registry mapping or an ERC-165 check for IGatingModule.","line":71,"path":"src/factories/erc1155/ERC1155Factory.sol","reproduction":"Static trace; not executed. On a default deployment, ERC1155Factory.createInstance(salt, params{gatingModule: dynamicPricingModule, ...}) succeeds. Owner adds an edition. A buyer calls mint{value: price}(1, 1, bytes32(0), \"\", 0). Expected: rejected at creation. Actual: creation succeeds and every mint reverts at gatingModule.canMint because the function does not exist.","severity":"info","snippet":"if (!registry.isApprovedComponent(params.gatingModule)) revert UnapprovedComponent();","title":"ERC1155Factory accepts any approved component as gatingModule, including DynamicPricingModule, which the deploy approves, and that bricks all paid mints of the instance"},{"citation":"resolved","description":"Q5. mint() and claimFreeMint() credit msg.sender without calling onERC1155Received, while transfers do call it. The recipient is always the caller (there is no 1155 mintTo), so nothing can be forced on a third party. Proceeds accounting is unaffected: mint adds totalCost to totalProceeds, and claimFreeMint moves no ETH. The only effect is that a contract which mints without being able to move tokens strands its own tokens. Optional fix: call _doSafeTransferAcceptanceCheck(msg.sender, address(0), msg.sender, editionId, amount, \"\") at the end of both functions; the guard makes that safe.","line":445,"path":"src/factories/erc1155/ERC1155Instance.sol","reproduction":"Static trace; not executed. Contract K has no onERC1155Received and calls I.mint{value: 0.01 ether}(1, 1, bytes32(0), \"\", 0) on an edition priced at 0.01 ether. Expected under the spec: revert. Actual: balanceOf[K][1] == 1 and TransferSingle is emitted. No other account is affected.","severity":"info","snippet":"balanceOf[msg.sender][editionId] += amount;","title":"ERC1155 mint paths skip the ERC-1155 receiver hook: a spec deviation, not exploitable"},{"citation":"resolved","description":"Q1/Q2. For an existing collection the floor cannot be lowered: all three factories call checkAlignmentBps at creation, and alignmentBps, the protocol bps and the treasury are immutables in every instance, so nothing the registry owner does changes a deployed split. Who can call what: only the owner sets factories, vault factories, components, treasury, fees and keeper; only registered vault factories register vaults; only registered factories register instances. A non-registered factory cannot register anything. These are the remaining owner powers. (1) _setFees has no lower bound, unlike MAX_PROTOCOL_FEE_BPS, so setFees(100, 0) removes the floor for future launches. The 1000 value comes only from script/config. (2) registerInstance does not require isVault(vault). All shipped factories check it, so this only matters for a new factory, which is an owner decision. (3) The Treasury snapshot means a compromised treasury cannot be replaced on deployed instances. Because safeTransferETH to the treasury runs before the artist payout, a treasury that starts rejecting ETH would block Drop withdraw, 1155 withdraw, settleAuction and reclaimUnsold permanently. The mainnet treasury currently accepts plain ETH (Etherscan verified source shows a payable receive; read from the page, no transaction was sent). (4) Vault deployment is permissionless. The vault code is a fixed clone and its token must be in an active target, but deployVault accepts an arbitrary priceValidator and the result counts as a registered vault. I found no way for that validator to take ETH, since it is only consulted in keeper-run conversions, so a collection can send ETH only to a vault its creator chose; creators should still prefer the default vault. Optional hardening: a floor constant in _setFees and an isVault check in registerInstance.","line":190,"path":"src/registry/MiyaRegistry.sol","reproduction":"Static trace; not executed. As registry owner, setFees(100, 0) succeeds, and createDrop(params, vault, 0) then creates a drop with alignmentBps 0. Conversely, after setFees(100, 5000), an existing drop's alignmentBps() is unchanged. From registered factory F2, registerInstance(X, address(0xdead), creator) succeeds although 0xdead is not a vault, so vaultOf[X] == 0xdead. From an unregistered address, the same call reverts NotFactory.","severity":"info","snippet":"if (uint256(protocolFeeBps_) + minAlignmentBps_ > BPS) revert InvalidAlignment();","title":"Trust assumptions: the 10% floor applies per launch and the owner can lower it for future launches; registerInstance relies on factories to check the vault; instance treasuries cannot be rotated"},{"citation":"resolved","description":"Recorded so the questions with a negative answer have one. Q8 invariant: let T = totalSupply, R = ownerReserve, M = ownerMinted, with T + R - M <= maxSupply. It holds at construction (R <= maxSupply). A phase mint checks it directly. ownerMint adds q to both T and M, leaving T + R - M unchanged, and requires M + q <= R. M <= R gives T <= maxSupply. setPhases writes none of T, M, R, so these hold in any order of calls. Phase.supplyCap is counted correctly per (epoch, phaseId) across mint and mintTo. setPhases only bumps phasesEpoch, resetting the per-wallet and per-phase counts as the notes state, and nothing else. mintTo: allowlist and limit are read from msg.sender and not the recipient, as the notes state, and I found no unstated bypass. Payment is the same exact msg.value check, and the ETH stays for withdraw, so the fee split still applies. Royalty: of Solady's internal royalty setters, the instance calls only _setDefaultRoyalty, from the constructor and from setRoyalty, and setRoyalty checks royaltyLocked. Events: BatchMetadataUpdate(uint256,uint256) and 0x49064906 match EIP-4906. URI(string,uint256 indexed) matches ERC-1155 and fires on the only path that changes an existing edition's URI; emitting at addEdition is optional. Q1: split() sums exactly to amount and rounds both cuts down, so dust goes to the artist; 100 wei or less per split is less than the gas needed to exploit it. A tiny dynamic basePrice can round to zero cost (e.g. basePrice 99, rate 100 bps, one token), but that is owner-chosen and worth wei only. Q3: exact-payment check holds and withdraw cannot underflow, because parked ETH stays in the balance. Q4: refunds use forceSafeTransferETH after state updates and under nonReentrant, settled is set before external calls, and claimEscrowedToken deletes the entry first and pays only escrowedWinner. Q5: the M7, L1 and L3 fixes are present and correct; transferOwnership(0) reverts in Solady, so renounce cannot be bypassed that way.","line":325,"path":"src/factories/erc721drop/ERC721DropInstance.sol","reproduction":"Static trace; not executed. The repo's own test_phasesCannotSellTheReserve and testFuzz_reserveAlwaysClaimable cover part of this but were not run. Example: maxSupply 10, ownerReserve 3. Phases stop at T = 7 (8 + 3 - 0 > 10 reverts SoldOut). ownerMint(3) then gives T = 10, M = 3; a further phase mint gives 11 + 3 - 3 > 10 and reverts; ownerMint(1) reverts ReserveExceeded. Calling setPhases between any of these steps changes none of T, M or R.","severity":"info","snippet":"if (last + ownerReserve - ownerMinted > maxSupply) revert SoldOut();","title":"No defect found: Drop reserve, supply and cap invariants, mintTo, royalty lock and metadata events all hold (Q8), and neither auctions nor the fee split allow theft or double settlement (Q1, Q3, Q4)"}],"hash":"36e65c7c7527f2ac00e9ef32061f6e5367b6d8e41a4448ef13ae8996e7eaf771","nodeId":"78db29f0-61b2-408f-bdc8-f52f1b65ed85","outcome":"completed","summary":"I finished the review and wrote 12 findings to `.imd-findings.json`, but **nothing was executed, so the acceptance criterion that every finding be reproduced is only met by code tracing.** Bash failed on every attempt because the sandbox couldn't create its bridge sockets, so I couldn't run `forge`, or a JSON parser.\n\n- **Reproductions:** every one is a code trace and is labelled that way. Gas figures are my estimates from opcode costs, not measurements.\n- **Proofs:** I couldn't run the specialists' two proofs, so neither is attached. No finding is critical or high, so none requires one.\n- **File check:** a regex confirmed all 12 entries have the required shape, and I copied snippets from the files I read. I changed no other files.\n\n## Findings\n| Severity | Finding | Location |\n|---|---|---|\n| Medium | M6 fix is incomplete: a gas-burning winner pushes settlement to about 8–16M+ gas | `ERC721AuctionInstance.sol:309` |\n| Medium | CREATE3 salt isn't bound to the creator, so an announced address can be squatted; `computeInstanceAddress` also mispredicts non-prefixed salts | `ERC721AuctionFactory.sol:119`, `ERC1155Factory.sol:87` |\n| Medium | Gating module gates nothing, even with M5 wired (off at deploy, so no launch impact) | `PasswordTierGatingModule.sol:99` |\n| Low | `mint()` ignores `FREE_MINT_ONLY` | `ERC1155Instance.sol:426` |\n| Low | `claimFreeMint` lets the caller supply `openTime` | `ERC1155Instance.sol:265` |\n| Low | 1155 `withdraw` is owner-only, so cuts arrive only when the artist chooses | `ERC1155Instance.sol:468` |\n| Low | Anti-snipe extension is unbounded, and the increment can be as low as 1 wei | `ERC721AuctionInstance.sol:274` |\n| Low | Parked vault cuts under 0.001 ETH can become undeliverable | `ERC721DropInstance.sol:258` |\n| Info | `DynamicPricingModule` is accepted as a gating module, which bricks paid mints | `ERC1155Factory.sol:71` |\n| Info | 1155 mints skip the receiver hook; not exploitable | `ERC1155Instance.sol:445` |\n| Info | Owner trust assumptions | `MiyaRegistry.sol:190` |\n| Info | Record of the checks that found no defect | `ERC721DropInstance.sol:325` |\n\n## Answers to your questions\n1. **Fee split:** no share can be redirected or exceed what was paid, and the split always sums to the amount paid. Three gaps remain: the 1155 cuts wait on the owner, cuts under 0.001 ETH can strand, and the owner can lower the floor for future launches only. It can't be lowered for an existing collection.\n2. **Registry:** only the owner sets factories, and only registered factories and vault factories can register. An unregistered factory can't get a collection to send it ETH. The owner can't change existing splits. `deployVault` does accept an arbitrary `priceValidator`, but I found no way for it to take ETH.\n3. **Drop:** I found no way to mint above supply or limits, and exact payment holds. `withdraw` can't be blocked by a third party, though a caller can delay the vault cut by parking it (see the 0.001 ETH finding).\n4. **Auctions:** I found no way to steal a refund or settle twice. A line can be delayed by the anti-snipe loop or made very expensive to settle through the gas burn. I did not prove a permanent freeze, because the worst case sits right around mainnet's 16.7M per-transaction gas cap.\n5. **ERC1155Instance:** the M7, L1 and L3 fixes are correct. No mint path skips the split, and the missing hook isn't exploitable.\n6. **Factories:** squatting is possible on both CREATE3 factories, but no one can get arbitrary parameters registered. `ERC721DropFactory` is unaffected.\n7. **Gating:** M5 is confirmed but understated. Wiring `configureFor` wouldn't help, because the no-password tier is unrestricted and the \"password\" is a hash anyone can read on chain.\n8. **Drop additions:** I found no way to break any of these:\n   - The reserve and supply invariants hold, shown by an algebraic argument over all call orders.\n   - `supplyCap` is counted correctly, and `setPhases` resets only what the notes state.\n   - `mintT","treeHash":null,"usage":{"cachedInputTokens":1265916,"inputTokens":20,"model":"claude-opus-5-5","outputTokens":54578,"runtime":"claude","turns":48,"wallClockMs":566672}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"20e8e883cb88740a","findings":[],"hash":"4fc713230d4cff46916b4d67d880e7cdaa38a3ef1c3eeb1ed1fb7543dd1a12a8","nodeId":"19298e5a-e3f2-4f0d-a162-6d7f81e72f02","outcome":"failed","summary":"API Error: Your account can't receive replies from Claude right now. You can find the next step in the Anthropic Verification Portal: https://portal.anthropic.com/…","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":null,"outputTokens":0,"runtime":"claude","turns":1,"wallClockMs":2492}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c2e167266d13dcf4","findings":[{"citation":"resolved","description":"ERC721AuctionFactory.createInstance (and ERC1155Factory.createInstance at src/factories/erc1155/ERC1155Factory.sol:87) forward the caller-supplied `salt` to CreateX.deployCreate3 unchanged. CreateX only binds the salt to msg.sender (the factory) when the salt's first 20 bytes equal the factory address and byte 20 is 0x00 or 0x01. For every other salt (first 20 bytes zero with byte 20 = 0x00, or a random prefix) CreateX's `_guard` falls into its last branch and uses `keccak256(abi.encode(salt))`, which is independent of the deployer. The CREATE3 address then depends only on CreateX and that hash, so any account calling CreateX.deployCreate3 directly with the same raw salt lands on the same address. Nothing in either factory checks the salt prefix. Two consequences: (1) a front-runner who sees a creator's pending createInstance(salt, ...) with an unprefixed salt deploys any contract at that address first; the creator's transaction reverts in CreateX with FailedContractCreation (the CREATE2 proxy address is taken), including the creation fee transfer, and the creator must pick a new salt; if the address had been announced (allowlists, marketing, pool pre-wiring) it is now occupied by attacker code. (2) `computeInstanceAddress(salt)` (line 147) always assumes the factory-prefixed branch, `keccak256(abi.encodePacked(uint256(uint160(address(this))), salt))`, so for an unprefixed salt it returns an address that the deployment will never use. The repo's own test `test_computeInstanceAddress` passes `bytes32(uint256(1))`, exactly the unguarded form. The 'guarded salt' property in the design therefore holds only when the frontend builds the salt as `abi.encodePacked(factory, 0x00, bytes11(...))` (as the test helpers do); the contract does not enforce it. No funds are lost (the whole creation tx reverts), so this is a griefing / correctness issue. Fix: in both factories require `address(bytes20(salt)) == address(this) && (salt[20] == 0x00 || salt[20] == 0x01)` (or derive the salt in-contract as `bytes32(abi.encodePacked(address(this), bytes1(0), bytes11(userSalt)))`), and make computeInstanceAddress use the same branch (include block.chainid when byte 20 is 0x01).","line":119,"path":"src/factories/erc721/ERC721AuctionFactory.sol","reproduction":"State: CreateX at 0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed, factory F registered. Creator prepares createInstance(salt = bytes32(uint256(1)), params). Expected: the instance deploys at F.computeInstanceAddress(bytes32(1)) and only F can deploy there. Actual: CreateX parses bytes32(1) as (ZeroAddress, RedeployProtectionFlag.False) and uses guardedSalt = keccak256(abi.encode(bytes32(1))); the deploy address is CreateX.computeCreate3Address(keccak256(abi.encode(bytes32(1))), CREATEX), which differs from F.computeInstanceAddress(bytes32(1)) = computeCreate3Address(keccak256(abi.encodePacked(uint256(uint160(F)), bytes32(1))), CREATEX). Attacker A (any EOA) front-runs with CreateX.deployCreate3(bytes32(1), hex\"6000\" /* any initcode */): A's contract now occupies the address. Creator's createInstance(bytes32(1), params) then reverts inside CreateX (FailedContractCreation) because the CREATE3 proxy CREATE2 collides. Same sequence for ERC1155Factory.createInstance. With a correctly prefixed salt (abi.encodePacked(F, uint8(0), bytes11(n))) the attacker's direct call is hashed with A's own address and cannot collide, which is the behaviour the factory should enforce.","severity":"low","snippet":"        instance = ICreateX(CREATEX).deployCreate3(salt, initCode);","title":"CREATE3 salt is not bound to the factory: an unprefixed salt lets anyone squat the creator's instance address, and computeInstanceAddress previews the wrong address for it"},{"citation":"resolved","description":"The paid path builds the gating payload itself: `abi.encode(gatingData, edition.openTime)` (line 427), so PasswordTierGatingModule.canMint decodes the edition's real openTime and enforces `block.timestamp >= openTime + tierUnlockTimes[tier-1]` for TIME_BASED configs. The free-mint path passes the caller's `gatingData` bytes straight through, so the caller supplies both the password hash and the `openTime` word the module decodes. Setting openTime = 0 makes `unlockAt = tierUnlockTimes[tier-1]`, a timestamp in 1970, so the tier is always unlocked. The two entry points therefore enforce different rules for the same tier. Today this is latent because no factory calls configureFor (internal finding M5) and the module is not approved at deploy; it becomes live as soon as M5 is fixed by wiring configureFor, which the notes leave to the external review. Fix: have claimFreeMint build the payload the same way as mint(): take a `bytes32 passwordHash` parameter and call `canMint(msg.sender, 1, abi.encode(passwordHash, edition.openTime))`.","line":265,"path":"src/factories/erc1155/ERC1155Instance.sol","reproduction":"State: ERC1155Instance I with gatingModule = PasswordTierGatingModule, gatingScope = BOTH, freeMintAllocation = 5, and (once configureFor is reachable) a TIME_BASED config with passwordHashes = [keccak256(\"vip\")], tierUnlockTimes = [86400]. Edition 1 has openTime = T. At block.timestamp = T + 10: I.mint(1, 1, keccak256(\"vip\"), \"\", 0) reverts TierTimeLocked because unlockAt = T + 86400. Expected: I.claimFreeMint(1, data) under the same tier is also locked until T + 86400. Actual: I.claimFreeMint(1, abi.encode(keccak256(\"vip\"), uint256(0))) succeeds at T + 10: the module decodes openTime = 0, computes unlockAt = 86400, and block.timestamp >= 86400, so the free mint of the gated edition is granted a day early.","severity":"low","snippet":"            (bool allowed,) = gatingModule.canMint(msg.sender, 1, gatingData);","title":"claimFreeMint hands the user's raw bytes to the gating module while mint() injects the edition openTime, so a TIME_BASED tier lock can be bypassed on the free-mint path"},{"citation":"resolved","description":"ERC1155Factory.createInstance deploys the instance with `gatingModule` set but never calls PasswordTierGatingModule.configureFor, and nothing else does. For an unconfigured instance `configured[instance]` is false, so configureFor demands a registered factory as msg.sender; the instance owner's call reverts Unauthorized and no factory exposes a passthrough. The instance is therefore permanently stuck on the zero-value TierConfig: tierType = VOLUME_CAP (enum 0), empty arrays. In canMint, passwordHash = bytes32(0) yields tier 0 and cap = type(uint256).max, so every mint and every free claim is allowed with no cap; any non-zero passwordHash reverts InvalidPassword because `_tierByPasswordHash` is empty. A creator who selects the module gets an open collection and a UI that cannot accept passwords. At the pinned deploy config (`deployGatingModule: false`) the module is not deployed or approved, so createInstance with it reverts UnapprovedComponent and the defect is unreachable; it must stay that way until configureFor is wired (e.g. ERC1155Factory calls `PasswordTierGatingModule(params.gatingModule).configureFor(instance, config)` inside createInstance when params.gatingModule != 0). This confirms the status in AUDIT-NOTES.md; it is recorded here because the task asked for the verification.","line":62,"path":"src/gating/PasswordTierGatingModule.sol","reproduction":"State: registry.setComponent(module, true); creator calls ERC1155Factory.createInstance(salt, params{gatingModule: module, freeMint.scope: BOTH}) -> instance I; owner adds edition 1 (price 0.01 ETH). Expected: a password-gated edition. Actual: (a) any user U calls I.mint{value: 0.01 ether}(1, 1, bytes32(0), \"\", 0): module.canMint(U, 1, abi.encode(bytes32(0), 0)) returns (true,false) because tier = 0 and cap = type(uint256).max, mint succeeds; (b) U calls I.mint with gatingData = keccak256(\"anything\"): reverts InvalidPassword; (c) creator calls module.configureFor(I, config): reverts Unauthorized since configured[I] == false and the creator is not a registered factory; the factory never calls it.","severity":"info","snippet":"            if (!registry.isFactoryRegistered(msg.sender)) revert Unauthorized();","title":"M5 confirmed: as wired, PasswordTierGatingModule gates nothing. An unconfigured instance lets every caller through with gatingData = bytes32(0), and the creator can never configure it"},{"citation":"resolved","description":"Both cuts truncate and the remainder absorbs the dust, so every split favours the artist by at most 2 wei (one per cut) and the three shares always sum exactly to `amount` (no share can exceed what was paid, and protocolBps + alignmentBps <= 10000 is enforced in the registry and re-checked in every instance constructor, so remainder cannot underflow). The only caller that chooses the split amount is ERC1155Instance.withdraw(amount) (owner only); Drop.withdraw() splits the whole balance and the auction splits the whole winning bid. An owner could withdraw in sub-100-wei pieces to pay zero protocol fee, but each piece costs at least a 21,000-gas transaction, so the fee saved (max 1 wei per cut) is orders of magnitude below the cost on any chain. No change is required; if the protocol wants the dust it can round the protocol cut up (`mulDivUp`) and leave the vault cut rounding down, keeping remainder = amount - cuts.","line":20,"path":"src/shared/libraries/RevenueSplitLib.sol","reproduction":"protocolBps = 100, alignmentBps = 1000. split(99): protocolCut = 99*100/10000 = 0, vaultCut = 99*1000/10000 = 9, remainder = 90 (artist gets 90.9% instead of 89%). split(9): protocolCut = 0, vaultCut = 0, remainder = 9. split(0.05 ether): protocolCut = 5e14, vaultCut = 5e15, remainder = 4.45e16, sum = 5e16 exactly. ERC1155Instance.withdraw(99) by the owner therefore sends 0 to the treasury and 9 wei to the vault (which then reverts ContributionBelowMinimum and parks the 9 wei in pendingVaultCut); extracting the fee this way costs >= 21,000 gas per 99 wei withdrawn.","severity":"info","snippet":"        s.protocolCut = (amount * protocolBps) / BPS;\n        s.vaultCut = (amount * alignmentBps) / BPS;\n        s.remainder = amount - s.protocolCut - s.vaultCut;","title":"RevenueSplitLib rounds both cuts down and gives the dust to the artist; with the 1155 owner choosing the withdraw amount, amounts under 100 wei carry no protocol fee and under 10 wei no vault share (u"}],"hash":"740f5f8f26fc8e200524407c8d0f85d58a6243fbe096fdee4300f88d215d5878","nodeId":"3bc00fc6-42bd-466f-850f-85c0201fbece","outcome":"completed","summary":"Review complete. The findings file is written with four entries. No critical or high defects in the assigned scope.\n\n**Test limitation.** The shell sandbox failed to start for the whole session, so forge could not run. Every conclusion below is from reading the code and its dependencies (solady, CreateX, the vault) and working the arithmetic by hand. No proof tests were produced; none of the findings reach the severity that requires one.\n\n## Findings written to `.imd-findings.json`\n\n- **Low. CREATE3 salt not bound to the factory.** Both CREATE3 factories pass the caller's salt to CreateX unchecked. A salt that does not start with the factory address falls into CreateX's `keccak256(abi.encode(salt))` branch, which anyone can reproduce by calling CreateX directly. A front-runner occupies the address and the creator's transaction reverts. The preview function also returns a wrong address for such salts. The repo's own test uses `bytes32(1)`, the unguarded form. Fix is a prefix check in `createInstance`.\n- **Low. Free-mint gating payload is caller-controlled.** The paid mint encodes the edition's real openTime into the gating data, but `claimFreeMint` forwards the user's raw bytes. A user can pass openTime 0 and defeat a TIME_BASED tier lock on the free-mint path. Latent until M5 is wired.\n- **Info. M5 confirmed.** Unconfigured, the password module admits everyone with a zero hash, rejects every real password, and the creator can never call `configureFor`. Unreachable at the deploy config because the component is not approved.\n- **Info. Split rounding.** Both cuts round down, dust goes to the artist, bounded to 2 wei per split. The 1155 owner chooses the withdraw amount, but sub-100-wei withdrawals cost far more gas than the fee they skip.\n\n## Answers to the numbered questions\n\n1. **Fee split.** Shares always sum exactly to the amount paid, so none can exceed it. Nothing skips the split: drop proceeds leave only through the permissionless `withdraw`, auction bids only through `settleAuction`, 1155 proceeds only through the owner's `withdraw`, and free mints move no ETH. Rounding is the info item above. The 10% floor is checked at creation by the registry and stored as an immutable in all three instances. The registry owner can lower the minimum for future launches only.\n2. **Registry.** Only the owner registers factories and vault factories. Only registered factories register collections and only registered vault factories register vaults. Collections accept a vault only if `isVault` is true, and the vault factory's `initialize` rejects any token not in an active owner-curated target, so a creator cannot route the alignment share to a token they control. The owner cannot change any existing collection's fees, treasury or vault. One trust note outside my scope: `deployVault` is permissionless and accepts any `priceValidator` address, which the vault reviewer should check.\n3. **Drop.** Exact payment uses checked multiplication. Per-wallet and per-phase counters are keyed by the epoch bumped in `setPhases`. The withdraw math excludes `pendingVaultCut` correctly. The only withdraw blockers are a reverting treasury or a vault rejection, and the vault cut is parked and retried. Fragmenting vault cuts below the vault's 0.001 ETH minimum by calling `withdraw` after each mint only delays the contribution.\n4. **Auctions.** Refunds use force-send, so a bidder cannot block the next bid. `settled` is set before any external call and the function is reentrancy guarded, so no double settlement. A rejecting winner is escrowed, and the line advances regardless. Only the owner refusing to reclaim an unsold piece can stall a line, and that holds no bidder funds.\n5. **ERC1155.** No mint path skips the split. The missing receiver hook on mint only affects a caller that mints to its own contract address, so it is not exploitable against others.\n6. **Factories.** Squatting is the low finding above and applies only to unprefixed salts. The drop factory's","treeHash":null,"usage":{"cachedInputTokens":1243137,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":45875,"runtime":"claude","turns":55,"wallClockMs":732153}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"672c29f92850efe0","findings":[{"citation":"resolved","description":"createInstance(salt, params) forwards the caller-chosen salt to CreateX.deployCreate3 with the factory as msg.sender. CreateX's guard only mixes msg.sender (the factory) into the salt, so the resulting address is a function of (factory, salt) alone; nothing ties it to params.creator. computeInstanceAddress(salt) documents this format (factory address || 0x00 || 11 bytes) as the one creators should use, and the repository's own tests build salts that way. Any account that sees a creator's salt (mempool, a pre-announced vanity address, or simply by guessing the 11-byte label) can call createInstance first with its own params: the impostor collection is deployed at exactly the address the creator computed, registered in MiyaRegistry under the attacker as owner/creator, and the creator's transaction then reverts inside CreateX with FailedContractCreation because the CREATE2 proxy for that guarded salt already exists. The creator cannot ever obtain the address they announced. Salts whose first 20 bytes are not the factory are worse: CreateX hashes them unpermissioned (keccak256(abi.encode(salt))), so a direct call to CreateX with any init code also occupies the address, and computeInstanceAddress returns a wrong prediction for them. Fix: derive the salt from the creator inside the factory, e.g. bytes32 inner = keccak256(abi.encode(msg.sender, salt)); bytes32 guarded = bytes32(abi.encodePacked(address(this), bytes1(0x00), bytes11(inner))); and pass `guarded` to deployCreate3; make computeInstanceAddress(creator, salt) use the same derivation. (UniAlignmentVaultFactory.deployVault has the same shape, outside this scope.)","line":119,"path":"src/factories/erc721/ERC721AuctionFactory.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport { Test } from \"forge-std/Test.sol\";\nimport { MiyaRegistry } from \"src/registry/MiyaRegistry.sol\";\nimport { IMiyaRegistry } from \"src/registry/interfaces/IMiyaRegistry.sol\";\nimport { ERC721AuctionFactory } from \"src/factories/erc721/ERC721AuctionFactory.sol\";\nimport { ERC721AuctionInstance } from \"src/factories/erc721/ERC721AuctionInstance.sol\";\nimport { CREATEX } from \"src/shared/CreateXConstants.sol\";\nimport { CREATEX_BYTECODE } from \"createx-forge/script/CreateX.d.sol\";\n\n/// @dev Minimal registered vault: the auction factory only probes `supportsCapability` at creation.\ncontract SquatVault {\n    function supportsCapability(bytes32) external pure returns (bool) {\n        return true;\n    }\n\n    receive() external payable { }\n}\n\n/// @notice ERC721AuctionFactory.createInstance derives the CREATE3 address from (factory, salt) only.\n///         Any caller who reuses a creator's salt lands on the creator's pre-computed address first, gets a\n///         registered collection there under their own ownership, and the creator's own deployment with that\n///         salt reverts (CreateX: FailedContractCreation). Fails on the current code; passes once the factory\n///         binds the salt to msg.sender (the creator).\ncontract AuctionFactorySaltSquatTest is Test {\n    MiyaRegistry registry;\n    ERC721AuctionFactory factory;\n    SquatVault vault;\n\n    address treasury = makeAddr(\"treasury\");\n    address creator = makeAddr(\"creator\");\n    address attacker = makeAddr(\"attacker\");\n\n    function setUp() public {\n        vm.etch(CREATEX, CREATEX_BYTECODE);\n        registry = new MiyaRegistry(address(this), treasury, 100, 1000);\n        vault = new SquatVault();\n        registry.setVaultFactory(address(this), true); // this test acts as the vault factory\n        registry.registerVault(address(vault));\n        factory = new ERC721AuctionFactory(IMiyaRegistry(address(registry)), address(0xBEEF));\n        registry.setFactory(address(factory), true);\n    }\n\n    function _params(address who, string memory name_) internal view returns (ERC721AuctionFactory.CreateParams memory) {\n        return ERC721AuctionFactory.CreateParams({\n            name: name_,\n            metadataURI: \"ipfs://meta\",\n            creator: who,\n            vault: address(vault),\n            alignmentBps: 1000,\n            symbol: \"ART\",\n            lines: 1,\n            baseDuration: 1 hours,\n            timeBuffer: 5 minutes,\n            bidIncrement: 0.01 ether\n        });\n    }\n\n    function test_creatorSaltCanBeSquattedByAnyCaller() public {\n        // The creator picks the salt format the factory's computeInstanceAddress expects and announces the address.\n        bytes32 salt = bytes32(abi.encodePacked(address(factory), bytes1(0x00), bytes11(keccak256(\"creator-vanity\"))));\n\n        // Front-runner reuses the public salt with its own parameters.\n        vm.prank(attacker);\n        address squat = factory.createInstance(salt, _params(attacker, \"Impostor\"));\n        assertTrue(registry.factoryOf(squat) == address(factory), \"impostor collection is registered\");\n\n        // The creator's own deployment with the same salt must still succeed and be theirs.\n        // On the current code this call reverts with CreateX.FailedContractCreation (same CREATE3 address).\n        vm.prank(creator);\n        address mine = factory.createInstance(salt, _params(creator, \"Artist Collection\"));\n        assertEq(ERC721AuctionInstance(payable(mine)).owner(), creator, \"creator owns their collection\");\n        assertEq(registry.factoryOf(mine), address(factory), \"creator collection registered\");\n        assertTrue(mine != squat, \"creator and attacker must not share an address\");\n    }\n}","reproduction":"State: registry with a registered vault and the auction factory registered. Input: creator computes salt = bytes32(abi.encodePacked(address(factory), bytes1(0x00), bytes11(keccak256(\"creator-vanity\")))) and announces factory.computeInstanceAddress(salt). Attacker calls factory.createInstance(salt, params{creator: attacker, vault: registeredVault, alignmentBps: 1000, ...}). Expected: the attacker cannot land on the creator's address and the creator's later createInstance(salt, ...) succeeds. Actual: attacker's instance is deployed at the announced address and registered (registry.factoryOf(addr) == factory, owner == attacker); the creator's createInstance(salt, ...) reverts with CreateX FailedContractCreation(0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed). Test: test/scratch/AuctionFactorySaltSquat.t.sol.","severity":"medium","snippet":"        instance = ICreateX(CREATEX).deployCreate3(salt, initCode);","title":"ERC721AuctionFactory: CREATE3 salt is bound to the factory, not the creator, so anyone can squat a creator's pre-computed instance address and make the creator's deployment revert"},{"citation":"resolved","description":"Same mechanism as in ERC721AuctionFactory: the caller-chosen salt goes straight to CreateX.deployCreate3 with the factory as msg.sender, so the CREATE3 address depends only on (factory, salt) and not on params.creator. A front-runner (or anyone who guesses the 11-byte label of a factory-prefixed salt) can call createInstance with the same salt first, obtaining a registered ERC1155 collection, owned by them, at the address the creator computed with computeInstanceAddress(salt); the creator's own call then reverts in CreateX with FailedContractCreation. For non-prefixed salts CreateX hashes the salt unpermissioned, so a direct CreateX call with arbitrary init code also takes the address, and computeInstanceAddress mispredicts. Fix: bind the salt to msg.sender in the factory (e.g. bytes32 inner = keccak256(abi.encode(msg.sender, salt)); deployCreate3(bytes32(abi.encodePacked(address(this), bytes1(0x00), bytes11(inner))), initCode)) and expose computeInstanceAddress(creator, salt) with the same derivation.","line":87,"path":"src/factories/erc1155/ERC1155Factory.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport { Test } from \"forge-std/Test.sol\";\nimport { MiyaRegistry } from \"src/registry/MiyaRegistry.sol\";\nimport { IMiyaRegistry } from \"src/registry/interfaces/IMiyaRegistry.sol\";\nimport { ERC1155Factory } from \"src/factories/erc1155/ERC1155Factory.sol\";\nimport { ERC1155Instance } from \"src/factories/erc1155/ERC1155Instance.sol\";\nimport { FreeMintParams } from \"src/interfaces/IFactoryTypes.sol\";\nimport { GatingScope } from \"src/gating/IGatingModule.sol\";\nimport { CREATEX } from \"src/shared/CreateXConstants.sol\";\nimport { CREATEX_BYTECODE } from \"createx-forge/script/CreateX.d.sol\";\n\n/// @dev Minimal registered vault; nothing on it is called at creation.\ncontract SquatVault1155 {\n    receive() external payable { }\n}\n\n/// @notice ERC1155Factory.createInstance derives the CREATE3 address from (factory, salt) only, so any caller who\n///         reuses a creator's salt takes the creator's pre-computed address with a registered collection of their\n///         own, and the creator's deployment with that salt then reverts (CreateX: FailedContractCreation).\n///         Fails on the current code; passes once the factory binds the salt to msg.sender (the creator).\ncontract ERC1155FactorySaltSquatTest is Test {\n    MiyaRegistry registry;\n    ERC1155Factory factory;\n    SquatVault1155 vault;\n\n    address treasury = makeAddr(\"treasury\");\n    address creator = makeAddr(\"creator\");\n    address attacker = makeAddr(\"attacker\");\n\n    function setUp() public {\n        vm.etch(CREATEX, CREATEX_BYTECODE);\n        registry = new MiyaRegistry(address(this), treasury, 100, 1000);\n        vault = new SquatVault1155();\n        registry.setVaultFactory(address(this), true); // this test acts as the vault factory\n        registry.registerVault(address(vault));\n        factory = new ERC1155Factory(IMiyaRegistry(address(registry)), address(0xBEEF));\n        registry.setFactory(address(factory), true);\n    }\n\n    function _params(address who, string memory name_) internal view returns (ERC1155Factory.CreateParams memory) {\n        return ERC1155Factory.CreateParams({\n            name: name_,\n            metadataURI: \"ipfs://meta\",\n            creator: who,\n            vault: address(vault),\n            alignmentBps: 1000,\n            styleUri: \"\",\n            gatingModule: address(0),\n            freeMint: FreeMintParams({ allocation: 0, scope: GatingScope.BOTH })\n        });\n    }\n\n    function test_creatorSaltCanBeSquattedByAnyCaller() public {\n        bytes32 salt = bytes32(abi.encodePacked(address(factory), bytes1(0x00), bytes11(keccak256(\"creator-vanity\"))));\n\n        // Front-runner reuses the creator's public salt with its own parameters.\n        vm.prank(attacker);\n        address squat = factory.createInstance(salt, _params(attacker, \"Impostor\"));\n        assertTrue(registry.factoryOf(squat) == address(factory), \"impostor collection is registered\");\n\n        // The creator's own deployment with the same salt must still succeed and be theirs.\n        // On the current code this call reverts with CreateX.FailedContractCreation (same CREATE3 address).\n        vm.prank(creator);\n        address mine = factory.createInstance(salt, _params(creator, \"Artist Collection\"));\n        assertEq(ERC1155Instance(payable(mine)).owner(), creator, \"creator owns their collection\");\n        assertEq(registry.factoryOf(mine), address(factory), \"creator collection registered\");\n        assertTrue(mine != squat, \"creator and attacker must not share an address\");\n    }\n}","reproduction":"State: registry with a registered vault and the ERC1155 factory registered. Input: creator's salt = bytes32(abi.encodePacked(address(factory), bytes1(0x00), bytes11(keccak256(\"creator-vanity\")))). Attacker calls factory.createInstance(salt, params{creator: attacker, vault: registeredVault, alignmentBps: 1000, gatingModule: 0, freeMint: {0, BOTH}}). Expected: the attacker cannot take the creator's address and the creator's later createInstance(salt, ...) succeeds. Actual: the attacker's instance is deployed at factory.computeInstanceAddress(salt) and registered; the creator's createInstance(salt, ...) reverts with CreateX FailedContractCreation. Test: test/scratch/ERC1155FactorySaltSquat.t.sol.","severity":"medium","snippet":"        instance = ICreateX(CREATEX).deployCreate3(salt, _buildInitCode(params, treasury, protocolFeeBps));","title":"ERC1155Factory: CREATE3 salt is bound to the factory, not the creator, so anyone can squat a creator's pre-computed instance address and make the creator's deployment revert"},{"citation":"resolved","description":"GatingScope (IGatingModule.sol) defines FREE_MINT_ONLY as 'gates free mint claims only; paid buys are open', and the factory stores the creator's choice via initializeFreeMint. claimFreeMint honours the scope (it skips the module for PAID_ONLY), but mint() consults the module whenever one is set, with no scope check. A collection created with gatingModule != 0 and scope FREE_MINT_ONLY therefore gates every paid mint, contrary to the documented and irreversible setting, and whatever the module decides (a revert, a volume cap, a time lock) blocks buyers who were promised an open sale. Fix: wrap the paid-mint gating block in `if (address(gatingModule) != address(0) && gatingScope != GatingScope.FREE_MINT_ONLY)`.","line":426,"path":"src/factories/erc1155/ERC1155Instance.sol","reproduction":"State: ERC1155Factory.createInstance with params.gatingModule = an approved gating module whose canMint returns (false, false) for paid buys (or PasswordTierGatingModule with a non-zero passwordHash), params.freeMint = {allocation: 10, scope: GatingScope.FREE_MINT_ONLY}; owner adds edition 1 at 0.01 ether. Input: buyer calls mint{value: 0.01 ether}(1, 1, bytes32(0), \"\", 0). Expected: the mint succeeds because paid buys are open under FREE_MINT_ONLY. Actual: revert GatingCheckFailed() (or the module's own revert), because line 426 calls the module regardless of gatingScope.","severity":"low","snippet":"        if (address(gatingModule) != address(0)) {","title":"ERC1155Instance.mint ignores gatingScope: FREE_MINT_ONLY still gates paid buys"},{"citation":"resolved","description":"mint() builds the module payload itself as abi.encode(passwordHash, edition.openTime) so that PasswordTierGatingModule.canMint can enforce TIME_BASED unlocks relative to the edition's open time. claimFreeMint passes the caller's gatingData untouched. The module decodes it as (bytes32 passwordHash, uint256 openTime) and checks block.timestamp >= openTime + tierUnlockTimes[tier-1]; with openTime chosen by the claimer (0), every tier is unlocked immediately. The two entry points also disagree on encoding (a 32-byte password hash that works for mint() makes claimFreeMint revert in abi.decode). Today this is only reachable if M5 is closed (no factory calls configureFor, so no instance has a TIME_BASED config); the fix for M5 must therefore also encode (gatingData, edition.openTime) in claimFreeMint, or it reintroduces the bypass. Fix: in claimFreeMint take a bytes32 passwordHash and call canMint(msg.sender, 1, abi.encode(passwordHash, edition.openTime)).","line":265,"path":"src/factories/erc1155/ERC1155Instance.sol","reproduction":"State: PasswordTierGatingModule configured for the instance (via a registered factory) with TierType.TIME_BASED, passwordHashes = [keccak256(\"vip\")], tierUnlockTimes = [1 days]; instance freeMintAllocation = 10, scope BOTH; edition 1 with openTime = T; block.timestamp = T. Input: user calls claimFreeMint(1, abi.encode(keccak256(\"vip\"), uint256(0))). Expected: revert TierTimeLocked() until T + 1 days, as mint(1, 1, keccak256(\"vip\"), \"\", 0) does. Actual: canMint computes unlockAt = 0 + 1 days < block.timestamp and the free claim succeeds immediately.","severity":"low","snippet":"            (bool allowed,) = gatingModule.canMint(msg.sender, 1, gatingData);","title":"ERC1155Instance.claimFreeMint forwards raw user bytes to the gating module, so a TIME_BASED tier's openTime is attacker-supplied"},{"citation":"resolved","description":"The M6 fix wraps _safeMint in a self-call so a rejecting receiver is caught and the token is escrowed. The self-call forwards 63/64 of the remaining gas (EIP-150) and Solady's _checkOnERC721Received forwards all of it to the winner's hook. A winner contract whose onERC721Received loops until out of gas burns that whole budget; the catch branch then has only 1/64 of the original gas for _mint to the instance, the deposit refund, the treasury and vault transfers, the artist payout and _advanceLine (roughly 250k-350k gas with the production UniAlignmentVault). Any settleAuction call with less than about 16-22M gas therefore reverts out-of-gas after burning all of it, and the winner pays nothing extra. The line stays stalled (no next piece starts, the artist and vault receive nothing) until someone is willing to submit a ~20M-gas transaction, which only works at all where the block gas limit allows it. Fix: cap the hook budget, e.g. `try this.safeMintWinner{gas: 300_000}(winner, tokenId) {}` (a legitimate smart-wallet winner that needs more falls into escrow and uses claimEscrowedToken), or mint to escrow unconditionally for contract winners.","line":309,"path":"src/factories/erc721/ERC721AuctionInstance.sol","reproduction":"State: an auction whose high bidder is a contract with `function onERC721Received(address,address,uint256,bytes) external pure returns (bytes4) { while (true) {} }`, auction ended. Input: anyone calls settleAuction{gas: 3_000_000}(tokenId). Expected: settlement completes with the token escrowed for the winner. Actual: the hook consumes ~2.95M gas, the catch branch starts with ~46k gas and reverts out-of-gas in _mint; the transaction fails and all 3M gas is paid by the settler. Repeating with 30M gas succeeds, so settlement cost is dictated by the winner.","severity":"low","snippet":"        try this.safeMintWinner(winner, tokenId) {}","title":"ERC721AuctionInstance.settleAuction forwards unbounded gas to the winner's onERC721Received, letting a contract winner make every settlement attempt below ~16M gas revert"},{"citation":"resolved","description":"The first configureFor for an instance must come from a registered factory, but neither ERC1155Factory (the only factory that accepts a gatingModule) nor any other factory calls configureFor, and the instance owner is only accepted once `configured[instance]` is true. So for every instance _configs[instance] stays empty: tierType defaults to VOLUME_CAP with no password hashes. canMint then returns allowed for passwordHash == bytes32(0) (tier 0, cap = type(uint256).max) and reverts InvalidPassword for any non-zero hash. Net effect: a collection that selects the module is fully open to anyone passing bytes32(0) and cannot ever be tightened. The deploy script leaves the module undeployed unless DEPLOY_GATING_MODULE=true and the app passes gatingModule = 0, so nothing is at risk at launch; the module must not be approved (setComponent) until a factory path calls configureFor with creator-supplied tiers at creation. Also note ERC1155Factory accepts any approved component as gatingModule, including DynamicPricingModule, which has no canMint and would make every paid mint of that instance revert (creator self-inflicted).","line":62,"path":"src/gating/PasswordTierGatingModule.sol","reproduction":"State: gating module deployed and approved; creator calls ERC1155Factory.createInstance with gatingModule = module. Input: owner calls module.configureFor(instance, config). Expected: the creator can configure tiers for their collection. Actual: revert Unauthorized() (owner is not a registered factory and configured[instance] is false). Input 2: any wallet calls instance.mint{value: price}(1, 1, bytes32(0), \"\", 0). Actual: succeeds; the module imposes no restriction.","severity":"info","snippet":"            if (!registry.isFactoryRegistered(msg.sender)) revert Unauthorized();","title":"M5 confirmed: PasswordTierGatingModule cannot be configured for any collection and gates nothing as wired"},{"citation":"resolved","description":"ERC-1155 requires that minting to a contract calls onERC1155Received and reverts if the recipient does not accept. mint() and claimFreeMint() only ever credit msg.sender, who chose to call, so nothing can be forced onto a third party and no accounting is affected; the only consequence is that a contract caller without the hook holds tokens it may not be able to move, and that transfers (which do call the hook) behave differently from mints. Not exploitable; listed for completeness of question 5. Optional fix: after crediting, run _doSafeTransferAcceptanceCheck(msg.sender, address(0), msg.sender, editionId, amount, \"\") when msg.sender has code.","line":445,"path":"src/factories/erc1155/ERC1155Instance.sol","reproduction":"State: edition 1 at 0.01 ether. Input: a contract with no onERC1155Received calls mint{value: 0.01 ether}(1, 1, bytes32(0), \"\", 0). Expected per ERC-1155: revert because the recipient does not implement the receiver. Actual: balanceOf[contract][1] == 1 and TransferSingle is emitted; the same contract calling safeTransferFrom to itself would revert ERC1155RejectedTokens.","severity":"info","snippet":"        balanceOf[msg.sender][editionId] += amount;","title":"ERC1155Instance mint paths credit a contract recipient without the ERC-1155 receiver hook (spec deviation, not exploitable)"}],"hash":"a12fa54e9aa3e75d8472c1a825b282e8aefe19671269aadf2a9e9f8d1638d1d7","nodeId":"8ebce24e-312e-4261-bbe2-989648b46ce8","outcome":"completed","summary":"Review complete. Findings are in `.imd-findings.json` (7 entries: 2 medium, 3 low, 2 info). One limitation up front: the Bash sandbox never initialized in this session (every call failed with \"Failed to create bridge sockets\"), so I could not run `forge` on the two proof tests or the existing suite. The proofs under `test/scratch/` were written and checked statically against the contracts, Solady and the repo's own CreateX test harness, but they are unexecuted.\n\n## Findings\n\n- **Medium, ERC721AuctionFactory and ERC1155Factory (Q6).** The CREATE3 salt is bound to the factory only, never to the creator. Anyone who sees or guesses a creator's salt can call `createInstance` first, land a registered collection they own at the creator's pre-computed address, and the creator's own deployment with that salt then reverts inside CreateX. Non-prefixed salts are worse: CreateX hashes them unpermissioned, so a direct CreateX call also takes the address, and `computeInstanceAddress` mispredicts for them. Fix is to mix `msg.sender` into the salt in the factory. Proof tests: `test/scratch/AuctionFactorySaltSquat.t.sol` and `test/scratch/ERC1155FactorySaltSquat.t.sol`.\n- **Low, ERC1155Instance.mint (Q5/Q7).** The `FREE_MINT_ONLY` gating scope is not honoured: paid buys call the module whenever one is set.\n- **Low, ERC1155Instance.claimFreeMint (Q7).** It forwards raw caller bytes to the gating module, while `mint` encodes the edition's `openTime`. A TIME_BASED tier would be bypassable on free claims with a caller-supplied `openTime`. Unreachable until M5 is wired, but the M5 fix must include this.\n- **Low, ERC721AuctionInstance.settleAuction (Q4).** The escrow self-call forwards unbounded gas to the winner's `onERC721Received`. A winner that loops until out of gas makes every settlement attempt below roughly 16 to 22M gas revert, so the line stalls until someone pays for a near-block-sized transaction. Cap the gas on the self-call.\n- **Info.** M5 confirmed: no factory calls `configureFor`, the owner path is unreachable, so the module gates nothing and can never be tightened. Keep it unapproved until a factory configures at creation.\n- **Info.** ERC-1155 mint paths skip the receiver hook. Spec deviation only, not exploitable, since only `msg.sender` is credited.\n\n## Answers to the numbered questions\n\n1. **Fee split.** All three paths pay protocol + vault + artist = exactly the amount split. Cuts round down and dust goes to the artist (at most 1 wei per cut per withdraw). The 1155 owner picks the withdraw amount, so sub-10-wei withdraws pay no fee, which is economically meaningless. The 10% floor and `protocol + alignment <= 100%` are checked by `checkAlignmentBps` in every factory and stored as immutables, so they cannot be lowered later. Vault cuts the vault rejects (including the vault's 0.001 ETH minimum) park in `pendingVaultCut` and are never redirected.\n2. **Registry.** Only the owner registers factories, vault factories and components. Only registered factories register instances and only registered vault factories register vaults. Factories only accept `isVault` vaults, so a collection can only pay a `UniAlignmentVault` clone from the registered vault factory. Treasury and fee changes affect future launches only, since instances snapshot them as immutables. Trust assumptions worth documenting: the owner can register an arbitrary factory, the factory owners can change `weth` for future instances, and `UniAlignmentVaultFactory.deployVault` is permissionless with a caller-chosen price validator (outside this scope, no extraction path found).\n3. **Drop.** No path above supply or limits found. Per-wallet and per-phase counters are epoch-scoped and reset only by `setPhases`, as documented. Exact payment, permissionless withdraw and the pending flush are sound; withdraw cannot be griefed because the treasury send is to a Safe, the owner send falls back to WETH, and the vault send is try/catch.\n4. **Auctions.** Refunds are force-sent, settlemen","treeHash":null,"usage":{"cachedInputTokens":2846303,"inputTokens":674,"model":"claude-fable-5-1","outputTokens":65219,"runtime":"claude","turns":65,"wallClockMs":880219}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"5835e48821d8827d","findings":[{"citation":"resolved","description":"settleAuction wraps the safe mint in try/catch so a rejecting winner cannot block settlement (M6 fix), but the self-call forwards all but 1/64 of the remaining gas (EIP-150). A winner contract whose onERC721Received burns every unit of gas it is given does not revert the settlement, it only leaves the caller with 1/64 of the gas it had before the call. Everything after the catch (mint to self + escrowedWinner store, forceSafeTransferETH to the artist which may deploy a selfdestruct contract, treasury transfer, vault.receiveContribution which pushes a new conversionParticipant and writes four slots, smartTransferETH to the artist, _advanceLine starting the next piece) needs on the order of 250k-300k gas, so the settler must supply roughly 16M-20M gas for the transaction to succeed. With Fusaka's 16,777,216 per-transaction cap (EIP-7825) on mainnet that is at or above the limit, and on Robinhood Chain it depends on the chain's per-tx cap. Until someone pays that, the line is stuck: _advanceLine only runs from settleAuction or reclaimUnsold, and reclaimUnsold refuses auctions with bids. The attacker loses nothing: they paid the bid they would have paid anyway and still get the token via claimEscrowedToken. The M6 fix is therefore incomplete: it handles a reverting receiver but not a gas-consuming one.","line":309,"path":"src/factories/erc721/ERC721AuctionInstance.sol","reproduction":"1. Artist queues a piece on a 1-line instance (queuePiece{value: 0.1 ether}). 2. Attacker deploys contract W with `function onERC721Received(address,address,uint256,bytes) external returns (bytes4) { while (true) {} }` and calls createBid{value: 1 ether}(1, \"\") from W. 3. After endTime anyone calls settleAuction(1) with a normal gas limit (e.g. 1,000,000): the self-call consumes 63/64, the catch branch runs with ~15k gas and the transaction reverts out-of-gas; expected: settlement succeeds and the token is escrowed. 4. Retrying with 5M, 10M gas still fails; it only succeeds with ~16M-20M gas, more than the EIP-7825 per-tx cap. Fix: bound the hook gas, e.g. `try this.safeMintWinner{gas: 150_000}(winner, tokenId) {}` (any winner that needs more gets the escrow path), and keep the rest unchanged.","severity":"medium","snippet":"        try this.safeMintWinner(winner, tokenId) {}","title":"Auction settlement forwards unbounded gas to the winner's onERC721Received; a gas-burning winner makes settleAuction need ~64x the post-hook gas and can freeze the line"},{"citation":"resolved","description":"canMint treats passwordHash == bytes32(0) as tier 0. In VOLUME_CAP mode tier 0 gets cap = type(uint256).max (line 103); in TIME_BASED mode tier 0 skips the unlock check entirely (`tier > 0` guard, line 105). So a minter who supplies no password is never limited, while a minter who presents a valid password gets a *lower* cap or a *later* unlock (unlockAt = openTime + delay, and ERC1155Instance.mint already enforces block.timestamp >= edition.openTime before calling the module, so a password can only delay, never grant early access). The module is a password-protected bonus restriction, not a gate: as wired it cannot stop any paid mint or free claim. Independently, M5 is confirmed: configureFor requires a registered factory for the first configuration (lines 61-62) and no factory ever calls it, and the instance owner is rejected until then, so on a live deployment _configs[instance] is empty, which again means tier 0 for everyone. A third mechanism in the same path: claimFreeMint (ERC1155Instance.sol:265) passes the caller's raw gatingData to canMint, so for free claims the caller chooses the `openTime` the TIME_BASED check uses, while mint() encodes it from edition.openTime; the two entry points disagree on the data format. Wiring configureFor (the M5 fix the notes anticipate) would not make the module gate anything; the open tier must be removable (e.g. a configurable tier-0 cap / 'password required' flag) or the module must be withdrawn.","line":103,"path":"src/gating/PasswordTierGatingModule.sol","reproduction":"Deploy module with a registry where address F is a registered factory. From F call configureFor(I, TierConfig{tierType: VOLUME_CAP, passwordHashes: [keccak256('p1')], volumeCaps: [100], tierUnlockTimes: []}). Then: vm.prank(I); module.canMint(user, 1_000_000, abi.encode(bytes32(0), 0)) returns allowed = true (expected for a password-gated collection: not allowed or capped), while module.canMint(user, 101, abi.encode(keccak256('p1'), 0)) reverts VolumeCapExceeded. Same with TierType.TIME_BASED and tierUnlockTimes [1 hours]: at block.timestamp == openTime, data abi.encode(bytes32(0), openTime) is allowed immediately and data abi.encode(keccak256('p1'), openTime) reverts TierTimeLocked. Through ERC1155Instance.mint(editionId, 1_000_000, bytes32(0), '', 0) the gated collection mints with no restriction. Preconditions on a live deployment: the registry owner approves the module (setComponent) and a creator passes it as gatingModule; the deploy config leaves it off (deployGatingModule=false), so this is latent until enabled.","severity":"medium","snippet":"            uint256 cap = tier == 0 ? type(uint256).max : config.volumeCaps[tier - 1];","title":"PasswordTierGatingModule never restricts anyone: the open tier (bytes32(0)) has an unlimited cap and no time lock, so every password tier is opt-in; and M5 stands (nothing can configure it)"},{"citation":"resolved","description":"Both CREATE3 factories (this line and ERC1155Factory.sol:87) pass the user's salt straight to CreateX. CreateX binds the salt to msg.sender only when the salt's first 20 bytes equal msg.sender (here the factory) and byte 21 is 0x00/0x01; any other salt is 'random' and is guarded as keccak256(abi.encode(salt)), which is identical for every CreateX caller on the chain. So (a) with an ordinary salt, anyone can call CreateX.deployCreate3(salt, <any init code>) directly ahead of the creator and consume the address, after which the creator's createInstance reverts with CreateX FailedContractCreation; (b) even with a factory-prefixed salt, any other creator can call createInstance on the same factory with the same salt first, since the factory does not mix msg.sender in. The registry only accepts instances the factory deploys, so the squatter cannot register a fake collection at the address; the impact is a revert/griefing and a pre-announced address that ends up belonging to someone else. Finally computeInstanceAddress (lines 146-149) hashes (factory, salt) unconditionally, which matches CreateX only for the prefixed-0x00 format; for a random salt it returns an address that the deployment will not land on, so a UI that pre-publishes it misleads users. ERC721DropFactory uses plain `new` and is not affected.","line":119,"path":"src/factories/erc721/ERC721AuctionFactory.sol","reproduction":"Setup: real CreateX bytecode etched at 0xba5Ed0...a5Ed (as test/helpers/CreateXTestHelper.sol does). Creator prepares salt S = bytes32(uint256(1)) and reads factory.computeInstanceAddress(S) = A. Attacker, from any EOA, calls ICreateX(CREATEX).deployCreate3(S, hex'6000600055') (any init code) first; the proxy for keccak256(abi.encode(S)) is now deployed. Creator calls factory.createInstance(S, params): CreateX's CREATE2 of the proxy fails and the call reverts (expected: instance deployed at the previewed address). Also observe that even before the attack the actual deployment address for S differs from A because CreateX guards S as keccak256(abi.encode(S)) rather than keccak256(abi.encodePacked(uint256(uint160(factory)), S)). Fix: have the factory derive the CreateX salt itself, e.g. `bytes32 guarded = bytes32(abi.encodePacked(address(this), uint8(0x00), bytes11(keccak256(abi.encode(msg.sender, salt)))));` so the address is unique per (factory, creator, salt), and make computeInstanceAddress use the same derivation.","severity":"low","snippet":"        instance = ICreateX(CREATEX).deployCreate3(salt, initCode);","title":"CREATE3 salts are caller-chosen and not bound to the factory or creator: anyone can occupy a creator's predicted instance address first and make their createInstance revert; computeInstanceAddress is "},{"citation":"resolved","description":"GatingScope (src/gating/IGatingModule.sol) defines FREE_MINT_ONLY as 'gates free mint claims only; paid buys are open'. claimFreeMint honours its half of the enum (it skips the module when scope == PAID_ONLY, line 264), but mint() consults the module whenever one is set, regardless of gatingScope. With the current PasswordTierGatingModule the effect is invisible because that module allows everything (see the gating finding), but for any module that can return false or revert, a creator who chose FREE_MINT_ONLY gets paid buys blocked or rate-limited contrary to the scope they set at creation (which is irreversible).","line":426,"path":"src/factories/erc1155/ERC1155Instance.sol","reproduction":"Create an ERC1155 instance via ERC1155Factory with gatingModule = M (an approved component whose canMint returns (false,false)) and freeMint.scope = GatingScope.FREE_MINT_ONLY. Owner adds an edition (addEdition('a', 0.01 ether, 0, 'ipfs://x', UNLIMITED, 0, 0)). A buyer calls mint{value: 0.01 ether}(1, 1, bytes32(0), '', 0): actual: revert GatingCheckFailed; expected per the enum: the paid buy succeeds because only free claims are gated. Fix: `if (address(gatingModule) != address(0) && gatingScope != GatingScope.FREE_MINT_ONLY) {`.","severity":"low","snippet":"        if (address(gatingModule) != address(0)) {","title":"ERC1155Instance.mint ignores GatingScope.FREE_MINT_ONLY and gates paid buys whenever a module is set"},{"citation":"resolved","description":"ERC721DropInstance.withdraw() is callable by anyone 'so neither cut can be withheld', and AUDIT-NOTES relies on that for the 1% protocol fee and the alignment share. ERC1155Instance.withdraw(amount) is restricted to the owner and is the only path that moves mint proceeds to the treasury and the vault. An artist who never calls it (or who stops once they no longer care about the remaining balance) keeps the protocol and vault shares of every sale parked in the instance forever; the fee is not skipped on paper but it is never delivered, and nobody else can trigger it. The 1155 instance also sells editions over long periods (open editions), so the un-split balance can be large. Same owner-only pattern is not present in the auction instance (settlement is permissionless).","line":468,"path":"src/factories/erc1155/ERC1155Instance.sol","reproduction":"Create an ERC1155 instance, add an UNLIMITED edition at 1 ether, 10 buyers mint (totalProceeds = 10 ether, instance balance 10 ether). The treasury or any third party calls withdraw(10 ether): actual: revert Unauthorized; the protocol cut (0.1 ether) and alignment cut (>= 1 ether) stay in the contract until the owner chooses to withdraw. Expected (parity with the Drop): anyone can trigger the split of accumulated proceeds. Fix: make withdraw permissionless over `totalProceeds - totalWithdrawn` (keep the amount argument or split everything), since every recipient is fixed at launch and the artist share goes to owner() regardless of caller.","severity":"low","snippet":"        if (msg.sender != owner()) revert Unauthorized();","title":"ERC1155Instance.withdraw is owner-only, so the artist can withhold the protocol and alignment cuts indefinitely (Drop made withdraw permissionless for exactly this reason)"},{"citation":"resolved","description":"Question 1 asks whether the 10% alignment floor is enforced at creation and impossible to lower afterwards. At creation it is: every factory calls registry.checkAlignmentBps(alignmentBps) (>= minAlignmentBps and alignmentBps + protocolFeeBps <= 10000) and each instance stores alignmentBps, protocolBps and the treasury as immutables, so for a deployed collection nothing the registry owner does changes its split. The floor itself, however, only comes from script/config/*.json (minAlignmentBps = 1000) and _setFees accepts any value from 0 up to 10000 - protocolFeeBps; there is no MIN_ALIGNMENT_FLOOR constant comparable to MAX_PROTOCOL_FEE_BPS = 500. So the owner multisig can remove the floor for all later launches. The same owner can register additional factories and vault factories (any contract) and re-point `treasury`, which new instances snapshot and which UniAlignmentVault.protocolTreasury() follows live; deployed instances keep the old treasury as an immutable, so a treasury rotation (e.g. after a key compromise) cannot be applied to them. All of this is owner trust, documented here rather than as a bypass.","line":190,"path":"src/registry/MiyaRegistry.sol","reproduction":"As registry owner call setFees(100, 0): succeeds (expected if the 10% floor were a code invariant: revert). Then a creator calls ERC721DropFactory.createDrop(params, vault, 0) and the drop is created with alignmentBps = 0 and the vault receives nothing from its sales. Conversely, after setFees(100, 5000) existing drops still split with their snapshotted alignmentBps. If the floor is meant to be a protocol guarantee, add `uint16 public constant MIN_ALIGNMENT_FLOOR_BPS = 1000;` and revert in _setFees when minAlignmentBps_ < MIN_ALIGNMENT_FLOOR_BPS.","severity":"info","snippet":"        if (uint256(protocolFeeBps_) + minAlignmentBps_ > BPS) revert InvalidAlignment();","title":"The 10% alignment floor is a deploy parameter, not a code invariant: the registry owner may set minAlignmentBps to 0 for future launches; existing collections keep their snapshot"},{"citation":"resolved","description":"Answer to question 5: mint() and claimFreeMint() credit msg.sender directly and emit TransferSingle from address(0) without the ERC-1155 receiver acceptance check that safeTransferFrom performs. Because the recipient is always msg.sender (there is no mintTo), the only party that can end up with tokens in a contract that cannot handle them is that contract's own operator, and no third party's balance, proceeds or fee split is affected. Both paths also record proceeds correctly: mint() adds exactly totalCost to totalProceeds (which withdraw splits) and refunds the excess; claimFreeMint moves no ETH. The omission is a spec/compat deviation (OpenZeppelin's _mint performs the check; marketplaces and pools such as MiyaPool deposit via safeTransferFrom, which is checked) rather than a defect with a victim.","line":445,"path":"src/factories/erc1155/ERC1155Instance.sol","reproduction":"Deploy a contract C with no onERC1155Received. From C call instance.mint{value: price}(1, 1, bytes32(0), '', 0): succeeds, balanceOf(C, 1) == 1, and C can never move the token unless it has transfer logic. Expected under OZ semantics: revert ERC1155RejectedTokens. No other account is affected; no fix is required for safety, optionally add `_doSafeTransferAcceptanceCheck(msg.sender, address(0), msg.sender, editionId, amount, '')` after the balance update for parity with transfers.","severity":"info","snippet":"        balanceOf[msg.sender][editionId] += amount;","title":"ERC1155 mint paths do not call onERC1155Received; not exploitable, only the minting contract itself can strand its tokens"},{"citation":"resolved","description":"calculatePrice/calculateBatchCost compute r^minted in WAD with Solady rpow, which reverts on overflow. For r = 1e18 + rate*1e14 the power overflows at roughly minted > (256 - 60) / log2(r/1e18): rate = 10000 (doubling) fails after 196 mints, rate = 1000 (+10%) after about 1420 mints, rate = 100 (+1%) after about 13,700. addEdition accepts any rate and any supply, so an edition whose supply exceeds that bound stops selling at the overflow point: mint() reverts inside calculateMintCost for every amount, claimFreeMint still works (no price), and the owner cannot change pricing. Funds are not at risk and the owner set the parameters, so this is informational; a bound in addEdition (e.g. require calculatePrice(basePrice, rate, supply - 1) not to revert, or cap rate) would prevent a creator from configuring an edition that cannot sell out.","line":43,"path":"src/factories/erc1155/DynamicPricingModule.sol","reproduction":"addEdition('x', 1 wei, 300, 'u', LIMITED_DYNAMIC, 10000, 0). Mint 196 tokens one at a time (prices 1, 2, 4, ... wei, affordable). The 197th mint(1, 1, bytes32(0), '', 0) reverts in FixedPointMathLib.rpow (overflow) although 104 pieces remain; expected: a price (or an explicit cap at creation).","severity":"info","snippet":"        uint256 result = FixedPointMathLib.rpow(multiplierWad, minted, 1e18);","title":"LIMITED_DYNAMIC editions with a high priceIncreaseRate become unmintable once rpow overflows (owner-chosen parameters, bricks the remaining supply)"}],"hash":"cbf6493852ffe756c7408956a52a06ac4c1c178674e69f383e4c5e3910a51018","nodeId":"19298e5a-e3f2-4f0d-a162-6d7f81e72f02","outcome":"completed","summary":"Review complete. The findings file holds 8 entries (2 medium, 3 low, 3 info). One limitation first: the shell sandbox failed to initialize for every command in this session, so I could not run forge or any test. All findings rest on reading the code and the vendored libraries (Solady, CreateX). No finding reached high or critical, so no proof tests were required, and none are attached.\n\n**Answers to the numbered questions**\n\n1. **Fee split.** Every sale path routes through `RevenueSplitLib.split` with immutable bps snapshotted at launch, both cuts round down and the artist absorbs dust, so nothing exceeds what was paid. The Drop splits its whole balance permissionlessly, the auction splits each winning bid at settlement, and the 1155 instance adds exactly the computed cost to `totalProceeds` and refunds excess. Free mints move no ETH. The floor is enforced at creation and immutable per collection, but the 10% figure itself is only a deploy parameter: the registry owner can set it to zero for future launches (info finding).\n2. **Registry.** Only the owner registers factories, vault factories and components. Only registered factories register instances and only registered vault factories register vaults. A non-registered factory cannot register anything, and factories refuse any vault the registry does not know. The owner cannot change anything in a deployed collection. Trust points: the owner can register arbitrary factory or vault-factory contracts and re-point the treasury for future launches.\n3. **Drop.** Exact payment is enforced, supply and reserve are consistent (proof in point 8), withdraw is permissionless and the vault cut is parked and retried rather than redirected. I found no way to lock funds or grief withdraw short of the trusted treasury or vault reverting.\n4. **Auctions.** Refunds are force-sent, settlement is single-shot and escrow handles rejecting winners. The remaining gap is a gas-burning winner: the self-call forwards all but 1/64 of gas, so a winner whose receiver hook burns everything makes settlement need roughly 64 times the post-hook gas and stalls the line until someone pays it (medium finding, with the one-line gas cap fix).\n5. **1155.** No mint path skips the split. The missing receiver hook on mint only affects a contract that mints for itself and is not exploitable against others (info). Two related defects: paid mints ignore the `FREE_MINT_ONLY` scope, and the owner-only withdraw lets an artist withhold the protocol and alignment cuts indefinitely, unlike the Drop (both low).\n6. **CREATE3.** Caller-chosen salts are not bound to the factory or creator, so anyone can occupy a creator's predicted address ahead of them and make their creation revert. The address preview function is wrong for salts without the factory prefix. Nobody can get the registry to accept an instance the factories did not deploy (low).\n7. **Gating.** The module gates nothing. M5 is confirmed as unfixed, and wiring `configureFor` would not help: the no-password tier always has an unlimited cap and no time lock, so every restriction is opt-in by the minter, and password tiers can only delay beyond the edition open time (medium, latent while the deploy leaves it off).\n8. **Drop additions.** The invariant `totalSupply + (ownerReserve - ownerMinted) <= maxSupply` holds by induction over mint, ownerMint and setPhases, so phases never sell the unused reserve and ownerMint never exceeds it. `supplyCap` is counted per phase list exactly as the notes state, and setPhases resets only the per-wallet and per-phase counters. `mintTo` applies the allowlist and limit to the payer as documented and pays into the same balance. The locked royalty has no path to change, since Solady's other royalty setters are not exposed. The 0x49064906 id, the `BatchMetadataUpdate` signature and the ERC-1155 `URI` event match their specs.\n\n**Not run:** forge tests, Slither, and the proof-test workflow, all because the command sandbox was unavailable. The findi","treeHash":null,"usage":{"cachedInputTokens":1277850,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":47222,"runtime":"claude","turns":42,"wallClockMs":662740}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"aad1250d716d3f82","findings":[{"citation":"resolved","description":"Q7. The module never restricts a caller. passwordHash == bytes32(0) maps to tier 0, and tier 0 is the least restricted tier in both modes: in VOLUME_CAP mode line 103 sets cap = type(uint256).max, and in TIME_BASED mode line 105 only checks the unlock time when tier > 0. A password therefore only ever adds a restriction (a cap or a lock) on top of an unrestricted default; it never grants access that a caller without a password lacks. ERC1155Instance.mint (line 412) lets the minter choose gatingData freely, so every minter simply passes bytes32(0). The same holds for an instance that was never configured (the M5 case): _configs[instance] is the zero struct, tierType defaults to VOLUME_CAP, tier 0 gives an unlimited cap, so canMint returns true for everyone. M5 in AUDIT-NOTES.md says a collection that picks the module 'could not be configured'; the actual behaviour is that the module allows every mint whether configured or not. Enabling it (DEPLOY_GATING_MODULE=true, registry.setComponent) would give creators a gate that reads as password-protected in the wizard (configType 'password-tier-gating') but blocks nobody. Fix: make tier 0 the restricted default (e.g. revert InvalidPassword when passwordHash == bytes32(0) and the instance has at least one tier configured, or give tier 0 an explicit cap/unlock time in TierConfig), and have ERC1155Factory.createInstance call configureFor with a creator-supplied TierConfig so the M5 path exists. Also delete the never-written _userTierUnlocked mapping.","line":99,"path":"src/gating/PasswordTierGatingModule.sol","reproduction":"State: registry with a registered factory F; module M = new PasswordTierGatingModule(registry); F calls M.configureFor(I, TierConfig{tierType: TIME_BASED, passwordHashes: [keccak256('vip')], volumeCaps: [], tierUnlockTimes: [24 hours]}). Input: at block.timestamp = openTime (0 seconds after open), instance I calls M.canMint(user, 1, abi.encode(bytes32(0), openTime)). Expected (a tiered password gate): user without the 'vip' password is locked or refused. Actual: tier = 0, the `tier > 0` branch is skipped, canMint returns (true, false). Same with VOLUME_CAP: configure caps [10]; canMint(user, 1_000_000, abi.encode(bytes32(0), 0)) returns true because cap = type(uint256).max. Same for an unconfigured instance (configured[I] == false): canMint(user, anyAmount, abi.encode(bytes32(0), 0)) returns true. The existing test test_canMint_openTier_noPassword and test_timeBased_tier0_alwaysOpen assert exactly this behaviour, i.e. the tests encode the defect.","severity":"medium","snippet":"        uint256 tier = passwordHash == bytes32(0) ? 0 : _tierByPasswordHash[msg.sender][passwordHash];","title":"PasswordTierGatingModule gates nothing: the zero password hash is an always-open tier, so every configured tier (volume cap or time lock) is bypassed by passing bytes32(0)"},{"citation":"resolved","description":"Q5/Q7. The paid path (mint, line 427) builds the module data itself as abi.encode(gatingData, edition.openTime), so the instance supplies the time reference. The free-mint path passes the raw calldata bytes through, so the claimer supplies both the password hash and openTime. With PasswordTierGatingModule in TIME_BASED mode a claimer who holds a tier-2 password (24h unlock) encodes openTime = 0 and the lock check `block.timestamp < 0 + 24 hours` is false on any real chain, so the lock is skipped. Independently of the bytes32(0) issue above, this means even a fixed module would be bypassable on claimFreeMint. Fix: in claimFreeMint decode the password hash from gatingData and re-encode with edition.openTime, exactly as mint does: `gatingModule.canMint(msg.sender, 1, abi.encode(abi.decode(gatingData,(bytes32)), edition.openTime))`.","line":265,"path":"src/factories/erc1155/ERC1155Instance.sol","reproduction":"State: ERC1155Instance I deployed with gatingModule = PasswordTierGatingModule M, freeMint.allocation = 10, scope = BOTH; M configured for I (by a registered factory) with TIME_BASED tiers [keccak256('t2')] and tierUnlockTimes [24 hours]; edition 1 added with openTime = block.timestamp. Input: in the same block, user calls I.claimFreeMint(1, abi.encode(keccak256('t2'), uint256(0))). Expected: revert TierTimeLocked (24h have not passed since the edition opened). Actual: unlockAt = 0 + 24 hours < block.timestamp, canMint returns true, the free token is minted. The same call through I.mint(1, 1, keccak256('t2'), '', 0) correctly reverts TierTimeLocked because mint encodes edition.openTime itself.","severity":"low","snippet":"            (bool allowed,) = gatingModule.canMint(msg.sender, 1, gatingData);","title":"claimFreeMint forwards caller-chosen gatingData, so the TIME_BASED unlock reference (openTime) is attacker-controlled on the free-mint path"},{"citation":"resolved","description":"Q5. GatingScope (IGatingModule.sol) documents FREE_MINT_ONLY as 'gates free mint claims only; paid buys are open' and the factory stores it immutably via initializeFreeMint. claimFreeMint honours the scope (line 264 skips the module for PAID_ONLY) but mint consults the module unconditionally, so a FREE_MINT_ONLY collection has its paid mints gated too. With the only approved module (PasswordTierGatingModule) this is masked because it allows everything, but any module that can reject, or a future fixed password module, would block paid buyers that the creator intended to leave open. Fix: `if (address(gatingModule) != address(0) && gatingScope != GatingScope.FREE_MINT_ONLY)` in mint.","line":426,"path":"src/factories/erc1155/ERC1155Instance.sol","reproduction":"State: ERC1155Factory.createInstance with gatingModule = an approved module whose canMint returns (false,false) (test/factories/erc1155/ERC1155Factory.t.sol defines MockRejectGatingModule), freeMint = {allocation: 5, scope: GatingScope.FREE_MINT_ONLY}; creator adds edition 1 (UNLIMITED, basePrice 0.01 ether). Input: buyer calls mint{value: 0.01 ether}(1, 1, bytes32(0), '', 0). Expected per the scope definition: paid buy succeeds, only claimFreeMint is gated. Actual: revert GatingCheckFailed().","severity":"low","snippet":"        if (address(gatingModule) != address(0)) {","title":"ERC1155Instance.mint ignores GatingScope.FREE_MINT_ONLY: paid buys are gated whenever a module is set, contrary to the scope the creator chose at launch"},{"citation":"resolved","description":"Q6. CreateX._guard only binds the salt to msg.sender (the factory) when salt[0:20] == address(factory) and salt[20] == 0x00 (guardedSalt = keccak256(factory ‖ salt)) or 0x01 (guardedSalt = keccak256(abi.encode(factory, chainid, salt))). For every other salt (first 20 bytes neither the factory nor zero) the guard is keccak256(abi.encode(salt)), which is the same for every caller. The factory accepts any bytes32 and never validates or derives the prefix, so a creator who submits an arbitrary salt (the app or an integrator picking e.g. keccak256(name)) gets a CREATE2 proxy address that an observer can claim first by calling CREATEX.deployCreate3(sameSalt, anyInitCode) from their own EOA, or by calling createInstance with the same salt and themselves as creator. The creator's transaction then reverts inside CreateX (FailedContractCreation, proxy create2 collision). The creator must choose a new salt, losing the announced/pre-shared address. computeInstanceAddress (line 158) hard-codes the 0x00-prefixed form, so for a non-prefixed salt it returns an address that is not where the instance will land, and for a 0x01-prefixed salt it omits chainid. The ERC721AuctionFactory has the identical issue at src/factories/erc721/ERC721AuctionFactory.sol:119 (and UniAlignmentVaultFactory.deployVault at src/vaults/uni/UniAlignmentVaultFactory.sol:73). Nothing lets a squatter register an instance the registry accepts with foreign parameters: createInstance requires msg.sender == params.creator, registry.isVault(vault) and the alignment floor, and ERC721DropFactory uses plain `new` so it has no salt at all. Fix: derive the salt inside the factory, e.g. `bytes32 s = bytes32(abi.encodePacked(address(this), bytes1(0), bytes11(keccak256(abi.encodePacked(msg.sender, userSalt)))))`, and compute the preview from the same derivation; this both binds the address to the factory and to the creator.","line":87,"path":"src/factories/erc1155/ERC1155Factory.sol","reproduction":"State: mainnet CreateX at 0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed, registered ERC1155Factory F. Creator C intends createInstance(salt = keccak256('my-collection') (first 20 bytes are neither F nor zero), params{creator: C, ...}) and has published F.computeInstanceAddress(salt) as the collection address. Input: attacker A front-runs with CREATEX.deployCreate3(keccak256('my-collection'), hex'00') from A's EOA (guardedSalt = keccak256(abi.encode(salt)) for both A and F). Expected: C's deployment lands at the predicted address. Actual: A's proxy occupies the CREATE2 slot at keccak256(abi.encode(salt)); C's createInstance reverts with CreateX FailedContractCreation; and even without A, the instance would have been deployed at computeCreate3Address(keccak256(abi.encode(salt))) rather than the published computeInstanceAddress(salt) = computeCreate3Address(keccak256(F ‖ salt)). With a salt of the form abi.encodePacked(F, 0x00, 11 bytes) (the form the tests use via _nextSalt) neither problem occurs.","severity":"low","snippet":"        instance = ICreateX(CREATEX).deployCreate3(salt, _buildInitCode(params, treasury, protocolFeeBps));","title":"ERC1155Factory passes the creator's salt to CreateX unchanged, so any salt not prefixed with the factory address can be squatted by anyone calling CreateX directly (creator's createInstance reverts) a"},{"citation":"resolved","description":"Q6. Identical mechanism to the ERC1155Factory finding: salts whose first 20 bytes are not address(this) with byte 21 == 0x00 are guarded by CreateX as keccak256(abi.encode(salt)), reproducible by any caller, so the CREATE2 proxy slot can be taken first and computeInstanceAddress (line 146) mispredicts. Fix as for the 1155 factory: derive the salt in the factory from (address(this), 0x00, keccak(msg.sender, userSalt)) and preview with the same derivation.","line":119,"path":"src/factories/erc721/ERC721AuctionFactory.sol","reproduction":"State: registered ERC721AuctionFactory F on a chain with CreateX. Creator C submits createInstance(bytes32(uint256(1)), params{creator: C, vault: registeredVault, alignmentBps: 1000, lines: 1, baseDuration: 1 days, timeBuffer: 10 minutes, bidIncrement: 0.01 ether, ...}) after publishing F.computeInstanceAddress(bytes32(uint256(1))) (the existing test test_computeInstanceAddress uses exactly this salt). Input: attacker calls CREATEX.deployCreate3(bytes32(uint256(1)), hex'00') first. Expected: C's instance deploys at the published address. Actual: C's call reverts in CreateX (proxy create2 collision); absent the attacker, the instance would deploy at computeCreate3Address(keccak256(abi.encode(bytes32(1)))) which differs from the published computeCreate3Address(keccak256(F ‖ bytes32(1))).","severity":"low","snippet":"        instance = ICreateX(CREATEX).deployCreate3(salt, initCode);","title":"ERC721AuctionFactory passes the creator's salt to CreateX unchanged (same squatting and wrong-preview issue as ERC1155Factory)"},{"citation":"resolved","description":"Q4. createBid has no check that msg.sender != auction.highBidder and no cap on cumulative extension. The previous bid is refunded to the previous bidder, so when the high bidder outbids themselves by exactly bidIncrement their earlier bid comes straight back; their net locked capital is one bid, and each extension costs bidIncrement (creator-chosen, any value >= 1 wei) plus gas. Repeating this inside every timeBuffer window keeps endTime moving, so settleAuction and reclaimUnsold stay blocked (AuctionNotEnded), the artist's proceeds and vault cut never arrive, and every queued piece behind it on that line (round-robin, lineQueueHead) never starts. A non-attacking rival can end it only by paying more than the griefer is willing to keep re-bidding, which is the normal auction, so the harm is the unbounded delay of the line, not theft. Fix: bound total extensions (e.g. endTime <= startTime + baseDuration + MAX_EXTENSION) or reject bids from the current highBidder (a two-wallet griefer then needs two bids of capital and loses the increment each round).","line":274,"path":"src/factories/erc721/ERC721AuctionInstance.sol","reproduction":"State: ERC721AuctionInstance with baseDuration = 1 days, timeBuffer = 10 minutes, bidIncrement = 1 wei, lines = 1; owner queues pieces 1 and 2 (piece 2 waits in the queue). Bidder B bids minBid on token 1. Input: at endTime - 1 second B calls createBid{value: highBid + 1}(1, ''); B's previous bid is refunded to B in the same call and endTime becomes now + 10 minutes. B repeats every <10 minutes. Expected: auction ends at most some bounded time after the base duration. Actual: endTime never arrives while B keeps bidding (cost to B: 1 wei + ~60k gas per round); settleAuction(1) reverts AuctionNotEnded forever, piece 2 never starts, owner's minBid deposit and the sale proceeds stay locked.","severity":"low","snippet":"        if (auction.endTime - block.timestamp < timeBuffer) {","title":"Auction anti-snipe extension is unbounded and the high bidder may outbid themselves, so one bidder can hold a line open indefinitely at a cost of bidIncrement + gas per timeBuffer"},{"citation":"resolved","description":"Q4. The M6 fix routes the mint through an external self-call so a reverting receiver falls back to escrow. The self-call gets 63/64 of the remaining gas (EIP-150) and _safeMint forwards it all to winner.onERC721Received. A winner contract that burns everything it is given makes the try branch consume 63/64 of the settler's gas; the catch branch (mint to self, deposit refund, treasury transfer, vault.receiveContribution with several cold SSTOREs, owner transfer, _advanceLine) needs roughly 300k-400k gas, so the settler must start with about 64 x that, around 20-25M gas, or the transaction runs out of gas after the try. The winner pays nothing extra: they already won, and the settler (typically the artist, or anyone) carries the ~0.2-0.3 ETH at 10 gwei. It is griefing of the settler, not a freeze (a 30M-gas tx still settles), so Low. Fix: cap the gas of the self-call, e.g. `try this.safeMintWinner{gas: 300_000}(winner, tokenId) {}` (any honest receiver hook fits comfortably), or mint to self first and let the winner pull via claimEscrowedToken always.","line":309,"path":"src/factories/erc721/ERC721AuctionInstance.sol","reproduction":"State: auction for token 1 won by contract W whose onERC721Received is `while (true) {}` (or `assembly { invalid() }` which consumes all gas). Input: settler calls settleAuction(1) with 3,000,000 gas. Expected: settlement completes with the token escrowed to W (the M6 design). Actual: the try call burns ~2.95M gas, the catch branch starts with ~46k gas and reverts out-of-gas in _mint/receiveContribution; the whole settlement reverts. Only a call with roughly 20M+ gas succeeds. With a receiver that simply reverts (no gas burn) settlement succeeds with ~500k gas, which shows the cost is the forwarded-gas policy, not the escrow path.","severity":"low","snippet":"        try this.safeMintWinner(winner, tokenId) {}","title":"settleAuction forwards all gas to the winner's onERC721Received inside try, so a contract winner can force the settler to supply ~25M gas (64x the fallback path) to settle at all"},{"citation":"resolved","description":"Q5. ERC-1155 requires the receiver hook rules to be applied to mints to contracts, which mint and claimFreeMint skip (the M7 fix added them only to the transfer functions). Exploitability: none against third parties. Only the minter is affected: a contract without onERC1155Received that calls mint gets a balance it cannot move if it has no generic transfer path, and the ETH it paid is correctly recorded in totalProceeds and split on withdraw. No fee-split path is skipped: mint records totalCost in totalProceeds; claimFreeMint moves no ETH; withdraw splits only totalProceeds - totalWithdrawn; force-fed ETH via receive() is excluded from the split by design. Since mint is nonReentrant, adding the acceptance check after the state update is safe. Fix: call _doSafeTransferAcceptanceCheck(msg.sender, address(0), msg.sender, editionId, amount, '') at the end of mint and claimFreeMint.","line":445,"path":"src/factories/erc1155/ERC1155Instance.sol","reproduction":"State: ERC1155Instance I with edition 1 (UNLIMITED, 0.01 ether). Input: contract K with no onERC1155Received and no outbound call helper executes I.mint{value: 0.01 ether}(1, 1, bytes32(0), '', 0). Expected per ERC-1155: revert (receiver rejected). Actual: succeeds; balanceOf[K][1] == 1 and K cannot transfer it; a later I.safeTransferFrom(K, ...) from K itself would need K to make that call.","severity":"info","snippet":"        balanceOf[msg.sender][editionId] += amount;","title":"ERC1155Instance mint paths never call onERC1155Received on contract recipients (ERC-1155 mint rule), so tokens minted by a non-receiver contract are stuck in it"},{"citation":"resolved","description":"Q2. All three shipped factories check registry.isVault before deploying, so today no instance can bind a non-registered vault. The registry itself accepts any vault address from any registered factory, so the invariant 'every registered instance sends its alignment cut to a registered vault' is enforced in three places instead of one, and a future factory added with setFactory that forgets the check (or a malicious one, which is an owner-trust matter) registers instances whose vaultOf is arbitrary; the indexer and MiyaPoolFactory read vaultOf as trusted. Who can do what: only the owner registers factories, vault factories and components, and sets treasury/fees/keeper; only registered vault factories register vaults (UniAlignmentVaultFactory.deployVault is permissionless but the alignment token must be in an active target and the vault is a factory-owned clone of the fixed implementation, so a 'malicious vault' is not reachable); only registered factories register instances. The owner cannot change anything on an existing instance: treasury, protocolBps/protocolFeeBps and alignmentBps are immutables snapshotted at launch; setFees only affects future launches (and can lower minAlignmentBps to 0 for them, which is an intended owner power to document). Fix (defense in depth): `if (!isVault[vault]) revert InvalidAddress();` in registerInstance.","line":171,"path":"src/registry/MiyaRegistry.sol","reproduction":"State: owner calls setFactory(F2, true) where F2 is any contract. Input: F2 calls registry.registerInstance(X, 0xdead, creator) with 0xdead not registered as a vault. Expected under the stated model ('must use a vault registered in MiyaRegistry'): revert. Actual: succeeds; vaultOf[X] == 0xdead and InstanceRegistered is emitted.","severity":"info","snippet":"        vaultOf[instance] = vault;","title":"MiyaRegistry.registerInstance does not require isVault(vault): the vault binding relies entirely on each registered factory validating it"},{"citation":"resolved","description":"Q1/Q3. The split itself is correct on every path: RevenueSplitLib rounds both cuts down and the artist takes the dust, sum of cuts == amount (testFuzz_splitConservesValue), and every ETH entering a drop except pendingVaultCut goes through split(), so nothing can be redirected or exceed what was paid; the 10% floor is checked by checkAlignmentBps at creation and alignmentBps is immutable. The only way the vault's share is delayed is the real vault's minimum: UniAlignmentVault._trackBenefactorContribution reverts ContributionBelowMinimum under 0.001 ETH, the drop catches it and parks the cut. Because withdraw() is callable by anyone, a third party can call it after each mint so each per-call vault cut is tiny (e.g. price 0.005 ETH x 10% = 0.0005 ETH), and every such cut is parked; flushPendingVaultCut succeeds once the parked total reaches 0.001 ETH, so this is delay, not loss, except for a final residue below 0.001 ETH after the last mint, which can never be delivered. The same applies to ERC1155Instance.withdraw(amount) (owner chooses amount) and to auction settlements of small bids. Fix: none needed for correctness; optionally let withdraw() skip the vault call and accumulate pendingVaultCut when s.vaultCut + pendingVaultCut < 0.001 ether, flushing in one call later.","line":245,"path":"src/factories/erc721drop/ERC721DropInstance.sol","reproduction":"State: drop with phase price 0.005 ETH, alignmentBps 1000, real UniAlignmentVault. Input: after each 1-token mint, anyone calls withdraw(). Expected: vault credited 0.0005 ETH per mint. Actual: vault.receiveContribution reverts ContributionBelowMinimum, pendingVaultCut grows by 0.0005 ETH each time, VaultContributionFailed is emitted; after the second mint flushPendingVaultCut() delivers 0.001 ETH. If the drop ends with pendingVaultCut == 0.0005 ETH, flushPendingVaultCut() reverts forever (ContributionBelowMinimum) and that residue stays in the drop and is excluded from every later withdraw().","severity":"info","snippet":"                pendingVaultCut += s.vaultCut;","title":"Permissionless withdraw() lets anyone split after every single mint; vault cuts below UniAlignmentVault.MIN_CONTRIBUTION (0.001 ETH) are then parked in pendingVaultCut until enough accumulates, and a "}],"hash":"fed172e6733032fcb226633c126465a3d48d43051905865fda3e83710078fa6d","nodeId":"5889f819-c548-4b3e-bd7f-a7aef30af38d","outcome":"completed","summary":"Nothing further is needed. The findings file is written and verified against the source, so the remaining step is the report.\n\n**Review complete. The findings are in `.imd-findings.json` (1 medium, 6 low, 3 info). No critical or high defects were found in scope.**\n\nOne limitation first. The Bash sandbox failed to initialize on every attempt, so forge never ran. No proof tests were written, and none of the findings carry a proof. Each finding instead gives exact state and inputs with expected versus actual behaviour. Since no finding reached high severity, no proof was required by the brief.\n\n**Answers to the numbered questions**\n\n1. **Fee split.** Correct on every path. RevenueSplitLib rounds both cuts down and the artist absorbs dust, so cuts never exceed the amount paid. Drops split the whole balance minus the parked vault cut. Auctions split the winning bid. The 1155 instance splits only recorded proceeds, which excludes force-fed ETH. Free mints move no ETH. The 10% floor is enforced by the registry at creation and alignmentBps is immutable afterwards. The only delay is the vault's 0.001 ETH minimum contribution, which parks small cuts in pendingVaultCut (info finding).\n\n2. **Registry access control.** Only the owner registers factories, vault factories and components. Only registered vault factories register vaults, and the vault factory only deploys clones of a fixed implementation bound to an active target. Only registered factories register instances, and all three check isVault first. The owner cannot change treasury or fees on an existing instance because they are immutables. The owner can lower minAlignmentBps to zero for future launches, which is a documented trust assumption. The registry itself does not check isVault in registerInstance (info finding).\n\n3. **Drop instance.** No path mints above supply or limits. Exact payment is enforced. Withdraw cannot be griefed because the vault call is in try/catch and the owner payout falls back to WETH. The mainnet treasury has a verified `receive() external payable {}` on Blockscout, so treasury transfers cannot block withdraw or settlement.\n\n4. **Auctions.** Refunds use force-send, so a bidder cannot freeze a line via a rejecting receiver. Settlement sets `settled` before external calls under nonReentrant, so no double settle. Two low findings: the high bidder can outbid themselves inside the time buffer forever, holding a line open at the cost of one bid increment per round, and a winner contract that burns gas in its receiver hook forces the settler to supply roughly 25M gas.\n\n5. **ERC1155Instance.** No mint path skips the split. The missing receiver hook on mint is not exploitable against third parties and only strands tokens in the minter's own contract (info). Two low findings: `mint` gates paid buys even under FREE_MINT_ONLY scope, and `claimFreeMint` lets the claimer supply the openTime used by time-based tiers.\n\n6. **CREATE3 deployment.** Nobody can register an instance with foreign parameters, since createInstance requires msg.sender to equal the creator and checks the vault and alignment floor. ERC721DropFactory uses plain `new` and has no salt. Both CreateX factories pass the creator's salt unchanged, and CreateX only binds a salt to the factory when its first 20 bytes equal the factory address with byte 21 zero. Any other salt hashes identically for every caller, so a front-runner can occupy the address and the preview function mispredicts it (two low findings).\n\n7. **PasswordTierGatingModule.** The module gates nothing, configured or not. The zero password hash maps to tier 0, which has an unlimited cap and no time lock, and the minter chooses the hash. Passwords only ever add restrictions on top of an open default. The M5 note says the module cannot be configured. The real defect is that it allows every mint regardless (medium finding). The existing tests assert this behaviour.\n\n8. **Drop additions.** The reserve invariant holds in every order. Each batch m","treeHash":null,"usage":{"cachedInputTokens":1206164,"inputTokens":52,"model":"claude-fable-5-1","outputTokens":64845,"runtime":"claude","turns":57,"wallClockMs":930597}}],"verification":[]}