# Audit report

> 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.
>
> miyamints 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.
>
> Answer each numbered question, including the ones where nothing is wrong, and report anything else you find in scope.
>
> 1. 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?
> 2. 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)?
> 3. 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?
> 4. 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?
> 5. 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?
> 6. 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?
> 7. PasswordTierGatingModule (src/gating/): the internal audit could not verify M5. Does the module gate anything as wired (the deploy leaves it off by default)?
> 8. 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?

| | |
|---|---|
| Repository | https://github.com/fa11up/miyamints-contracts |
| Commit | `a991e8c9208f3ccad5372db430633b235abc20ad` |
| Job | `d602a348-d29b-4607-86ed-b7a82b72b672` |
| Judged | 2026-11-04 08:30 UTC |
| Findings | 3 medium · 5 low · 4 info |

Four agents audited the code as it is at `a991e8c`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Medium: 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

`src/factories/erc721/ERC721AuctionInstance.sol:309`

```
try this.safeMintWinner(winner, tokenId) {}
```

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.

**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.

### 2. Medium: 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

`src/factories/erc721/ERC721AuctionFactory.sol:119`

```
instance = ICreateX(CREATEX).deployCreate3(salt, initCode);
```

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.

**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).

### 3. Medium: 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

`src/gating/PasswordTierGatingModule.sol:99`

```
uint256 tier = passwordHash == bytes32(0) ? 0 : _tierByPasswordHash[msg.sender][passwordHash];
```

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.

**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.

### 4. Low: ERC1155Instance.mint ignores GatingScope.FREE_MINT_ONLY, so paid buys are gated contrary to the irreversible scope the creator set

`src/factories/erc1155/ERC1155Instance.sol:426`

```
if (address(gatingModule) != address(0)) {
```

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)`.

**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().

### 5. Low: claimFreeMint forwards caller-chosen gatingData, so the caller supplies the openTime used by a TIME_BASED tier lock

`src/factories/erc1155/ERC1155Instance.sol:265`

```
(bool allowed,) = gatingModule.canMint(msg.sender, 1, gatingData);
```

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)).

**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.

### 6. Low: ERC1155Instance.withdraw is owner-only, so the protocol and alignment cuts are delivered only when the artist chooses, unlike the Drop

`src/factories/erc1155/ERC1155Instance.sol:468`

```
if (msg.sender != owner()) revert Unauthorized();
```

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().

**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.

### 7. Low: 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

`src/factories/erc721/ERC721AuctionInstance.sol:274`

```
if (auction.endTime - block.timestamp < timeBuffer) {
```

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.

**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.

### 8. Low: Vault cuts parked below MIN_CONTRIBUTION can become permanently undeliverable, because a flush reverts and later splits never merge the parked amount

`src/factories/erc721drop/ERC721DropInstance.sol:258`

```
vault.receiveContribution{ value: pending }(Currency.wrap(address(0)), pending, address(this));
```

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.

**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.

### 9. Info: ERC1155Factory accepts any approved component as gatingModule, including DynamicPricingModule, which the deploy approves, and that bricks all paid mints of the instance

`src/factories/erc1155/ERC1155Factory.sol:71`

```
if (!registry.isApprovedComponent(params.gatingModule)) revert UnapprovedComponent();
```

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.

**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.

### 10. Info: ERC1155 mint paths skip the ERC-1155 receiver hook: a spec deviation, not exploitable

`src/factories/erc1155/ERC1155Instance.sol:445`

```
balanceOf[msg.sender][editionId] += amount;
```

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.

**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.

### 11. Info: 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

`src/registry/MiyaRegistry.sol:190`

```
if (uint256(protocolFeeBps_) + minAlignmentBps_ > BPS) revert InvalidAlignment();
```

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.

**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.

### 12. Info: 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)

`src/factories/erc721drop/ERC721DropInstance.sol:325`

```
if (last + ownerReserve - ownerMinted > maxSupply) revert SoldOut();
```

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.

**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.

---

Judge's submission `36e65c7c7527f2ac00e9ef32061f6e5367b6d8e41a4448ef13ae8996e7eaf771`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
