{"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":"4e9b1481-d807-4a37-ab7e-f5b3d3a7e066","kind":"audit","nodes":[{"acceptedSubmissionHash":"395caf6f84513812df5b93e41792c5f433863c58d9472faf9a0c715bb4410919","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"1336d9221e0d682ac9da18a57825eb4b38452c9cfbb3bbf4462dba0d654de519","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"fe7175cbef9cd0de4d39e453a06aeadc99409444948a851a0a88cd29b86e07f6","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"fdffd98b3c8b7170a7c988bffd9b95893db23121d140f775175f7aca4e8eb988","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"d76fb5baefa60fdce8b0ec96c73a9f1b0d9982ad4b0c6c55f40e035a4f4596ac","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Project: PepesFamily, Pepes World vault\nRepo: github.com/0xtenang/PepesFamily (commit 6d550a2)\nScope: contracts/src/world/PepesWorldVault.sol (about 150 lines). Tests: contracts/test/PepesWorld.t.sol and contracts/test/PepesWorld.fork.t.sol\nChain: Robinhood Chain (4663)\n\nWhat it does\nA lifetime access pass for Pepes World, a browser game. A wallet can play if it holds at least 1 $EARN (one Pepes Earn IMD NFT, 0xf2363c208B1772C3c9dB7a3fe84d75Bf2881fc20) or if it entered through this vault.\n\nenter() / enterFor(player): the caller deposits passPrice $PEPES (0xE2C46c7068566740A33A4C93f5445B07BCfE5644, 50,000 at launch). It's one-time and non-refundable, and the pass never expires. Fee-on-transfer amounts are rejected.\ncanPlay(player): true if the player has a pass or holds 1 $EARN or more.\nOwner only:\ngrantPass: free passes.\nsetPassPrice: future passes only; existing passes are never revoked.\nclaimRewards(to): claims the IMD that the deposited $PEPES earns as a dividend token, and sends it on.\nwithdraw(token, to, amount): any token.\nTwo-step ownership transfer.\nIntended trust model: deposits belong to the team (the owner). Players get only the pass.\n\nPlease check\n\nCan anyone except the owner move tokens out of the vault, including IMD rewards?\nCan a player's approval to the vault be used to take more than passPrice, or tokens from another wallet?\nCan anyone get a pass without paying (other than grantPass), or pay without getting one?\nCan anything lock the vault: enter reverting for honest users, or claimRewards / withdraw getting stuck?\nInteraction with $PEPES (PadTokenV1): plain transfers have no fee, the vault counts as a normal dividend holder, and claim() pays IMD to msg.sender. Any reentrancy or accounting issue in claimRewards?\nIs the canPlay check via $EARN.balanceOf >= 1e18 correct for DN404 (NFT = whole token)?\nAny problem with passPrice = 0, or with changing the price between a player's approve and their enter?\nOut of scope: the game website (access is checked in the browser only, and the game has no prizes). $PEPES and $EARN are already audited.","parentJobId":null,"planHash":"cdb8b5153329c3fc65ca3c880c34dc96d6c78228861b87da25a638481a43d45a","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"4e9b1481-d807-4a37-ab7e-f5b3d3a7e066","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":"51423","feedbackHash":"700a942adbfcedf09c45d4b81d120eca07f9e74d4332f9ea8bec7a957f251e7d","nodeKey":"audit_economics","submissionHash":"395caf6f84513812df5b93e41792c5f433863c58d9472faf9a0c715bb4410919","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51227","feedbackHash":"c42266bc7e4eeace54d093413da2ca9c4360f46a5405699e567f9d556d7567c1","nodeKey":"audit_flow","submissionHash":"1336d9221e0d682ac9da18a57825eb4b38452c9cfbb3bbf4462dba0d654de519","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51442","feedbackHash":"263e05567739c08a9df5a1da8c870f93150166ec84520a9f0d8cd71a2ceef4ab","nodeKey":"audit_judge","submissionHash":"fe7175cbef9cd0de4d39e453a06aeadc99409444948a851a0a88cd29b86e07f6","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51141","feedbackHash":"163d0389860b2f7bc5cc957155f62eb12119e43bece8754a89051b90e6e09f43","nodeKey":"audit_math","submissionHash":"fdffd98b3c8b7170a7c988bffd9b95893db23121d140f775175f7aca4e8eb988","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51035","feedbackHash":"889f3933aa93325c636e5734a3d3e831d447119878e48a89c62b7efe23b3d097","nodeKey":"audit_permissions","submissionHash":"d76fb5baefa60fdce8b0ec96c73a9f1b0d9982ad4b0c6c55f40e035a4f4596ac","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"a950c355feffab0d36add9ed91fc6db98c49116ba477e51e4cef2e5d6ade56c6","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"11e4593175677f7e","findings":[{"citation":"resolved","description":"_enter reads passPrice at execution time and pulls exactly that much from msg.sender; the player has no way to state the price they agreed to. Two outcomes follow when setPassPrice lands between the player's approve and their enter (same block or a few blocks apart): (1) a player who approved exactly the displayed price, which is what web/world/index.html does, has their enter revert with SafeTransfer.TransferFailed (PadTokenV1's InsufficientAllowance is swallowed by the low-level call), so they lose the gas and must approve again; (2) a player who granted an unlimited approval (the pattern the repo's own unit test uses at contracts/test/PepesWorld.t.sol:62, and the default of many wallets) pays whatever passPrice is at execution, with no revert and no warning. The owner is trusted and setPassPrice is a documented power, so this is a trust-assumption gap rather than a bypass, but it is the only path by which a player's approval can be used to take more than the price they saw. The fix keeps the design: add a maxPrice argument, `enter(uint256 maxPrice)` / `enterFor(address player, uint256 maxPrice)`, and `if (amount > maxPrice) revert PriceTooHigh();` before the transferFrom (the no-arg overloads can stay as `type(uint256).max` wrappers if the website needs them). Verified against the real PadTokenV1 with a scratch Foundry test: approve 50_000e18, setPassPrice(60_000e18), enter -> revert TransferFailed; approve type(uint256).max, setPassPrice(400_000e18), enter -> alice's balance drops by 400_000e18.","line":89,"path":"contracts/src/world/PepesWorldVault.sol","reproduction":"State: vault deployed with passPrice = 50_000e18 over PadTokenV1 $PEPES; alice holds 500_000e18 $PEPES. (a) alice: pepes.approve(vault, type(uint256).max). owner: vault.setPassPrice(400_000e18). alice: vault.enter(). Expected: alice pays the 50_000e18 she was shown, or the call reverts. Actual: call succeeds and alice's $PEPES balance is 100_000e18 (400_000e18 taken). (b) alice: pepes.approve(vault, 50_000e18). owner: vault.setPassPrice(60_000e18). alice: vault.enter(). Expected: a clear revert naming the allowance. Actual: revert SafeTransfer.TransferFailed(); alice loses the gas and must re-approve.","severity":"low","snippet":"        uint256 amount = passPrice;","title":"enter/enterFor have no caller-side price bound: a setPassPrice between approve and enter is paid at the new price (unlimited approval) or reverts (exact approval)"},{"citation":"resolved","description":"The constructor only checks the three addresses for zero. Two deploy-time mistakes slip through silently and are irreversible because pepes, earn and imd are immutable and there is no deploy script for the vault yet (contracts/script/ has none). (1) PadTokenV1.quote() is address(0) for ETH-paired tokens. If such a token were passed, imd would be address(0); PadTokenV1.claim() would then try to push ETH to the vault, which has no receive/fallback, so IPepesToken(pepes).claim() reverts and claimRewards can never succeed. The real $PEPES (0xE2C4...5644) is IMD-quoted, so the launch deployment is unaffected, but the guard costs one line. (2) $EARN is a DN404 with two addresses: the token (0xf236...fc20) and its mirror (0x0e4b...5489 in deployments/robinhood-earn.json). The mirror's balanceOf returns the NFT count, so with earn_ = mirror, a wallet with one NFT reads 1 and canPlay's `>= 1e18` is false for every holder; with earn_ as an address without code, canPlay reverts for everyone. Fix: in the constructor `if (imd == address(0)) revert ZeroAddress();` (or a dedicated error), and sanity-check earn_ with a read that only the token satisfies, e.g. `IBalanceOf(earn_).balanceOf(address(0)) == 0` plus `earn_.code.length != 0`, or a `totalSupply() == 2_000e18` read. The hardcoded 1e18 threshold itself is correct for this $EARN: DN404._unit() is not overridden by PepesEarnToken, so one NFT = 1e18 and a marketplace NFT transfer moves exactly 1e18 ERC20 units.","line":62,"path":"contracts/src/world/PepesWorldVault.sol","reproduction":"State: deploy `new PepesWorldVault(PEPES, 0x0e4bf5b83740F9E93ED739b2064165561CE75489 /* the mirror */, team, 50_000e18)`. A wallet that owns one Pepes Earn IMD NFT (mirror.balanceOf == 1, token.balanceOf == 1e18). Call vault.canPlay(wallet). Expected: true. Actual: false (1 < 1e18); the vault must be redeployed since earn is immutable. Second state: deploy with an ETH-quoted PadTokenV1 (quote() == address(0)): vault.imd() == address(0) and owner's claimRewards(team) reverts with SafeTransfer.TransferFailed once any dividend is claimable, forever.","severity":"info","snippet":"        imd = IPepesToken(pepes_).quote();","title":"Constructor does not validate the dependency addresses: an ETH-quoted pad token makes claimRewards permanently revert, and passing the $EARN mirror (NFT) address makes canPlay false for every NFT hold"},{"citation":"resolved","description":"The suite covers the happy paths well (14 passing tests) but every entry test runs with alice holding a type(uint256).max approval, which hides the behaviour the brief asks about: a price raise between approve and enter is only visible with an exact approval (revert) or shows up as a silent overpayment with the unlimited one, and neither is asserted. Also untested: passPrice = 0 (entry succeeds with no approval at all, and enterFor can then mint passes for arbitrary addresses: harmless, but it should be a stated expectation), claimRewards when claim() returns 0 and the vault holds no IMD (works, transferOut returns early on amount 0), withdraw of a token the vault does not hold (reverts TransferFailed), transferOwnership(address(0)) as a cancel, and enter by a wallet that already holds $EARN (pays for a redundant pass). The unit tests run against MockPepesToken rather than src/v1/PadTokenV1.sol, although PadTokenV1 can be deployed directly in a test (the test contract becomes `pad` and only needs an empty `flush(address)`); the only coverage against the real token and the real $EARN is PepesWorld.fork.t.sol, which is skipped unless FORK_RPC is set and so does not run in CI. There are no fuzz tests. A scratch test written during this review confirmed all of the above behaviours against PadTokenV1 and can be reproduced from the description.","line":62,"path":"contracts/test/PepesWorld.t.sol","reproduction":"Run `forge test --match-path test/PepesWorld.t.sol`: 14 tests pass, none exercises passPrice == 0, none changes passPrice between approve and enter, none calls claimRewards with zero claimable, none uses PadTokenV1. Concrete missing assertions: (1) setPassPrice(0); vm.prank(bob) without any approve; vault.enter() -> expected hasPass(bob) == true and totalDeposited == 0. (2) approve exactly 50_000e18; setPassPrice(60_000e18); enter -> expected revert SafeTransfer.TransferFailed. (3) approve type(uint256).max; setPassPrice(400_000e18); enter -> alice's balance drops by 400_000e18.","severity":"info","snippet":"        pepes.approve(address(vault), type(uint256).max);","title":"Unit tests use an unlimited approval and mocks, and leave the asked-about edges untested: zero passPrice, price raise between approve and enter, claimRewards with nothing claimable, and the real PadTo"}],"hash":"1336d9221e0d682ac9da18a57825eb4b38452c9cfbb3bbf4462dba0d654de519","nodeId":"7a7aef18-8b66-4e8d-a08b-f61c2930e5ee","outcome":"completed","summary":"The findings file is in place and valid, the scratch test is removed, and the working tree has no stray files. Only the build cache from the test runs remains, and that is already ignored.\n\n**Result: no critical, high or medium defects in PepesWorldVault.** The contract does what the brief describes. The three entries in `.imd-findings.json` are one low and two informational.\n\n**Answers to the questions asked**\n\n- **Moving tokens out:** only the owner can. Both exits are owner-gated, there is no receive or fallback, and PadTokenV1 pays `claim()` only to its caller, so nobody else can redirect the vault's IMD.\n- **Approvals:** the vault pulls exactly `passPrice` from `msg.sender` only, so a player's approval cannot be used against another wallet. The one gap is the low finding: no caller-side price bound, so a price raise landing between approve and enter either reverts an exact approval or silently charges an unlimited approval the new price. The suggested fix is a `maxPrice` argument, which keeps the owner's power intact.\n- **Pass without paying or paying without a pass:** not possible. The pass flag is set and the transfer happens atomically, and the exact-delta check rejects fee-on-transfer and over-delivering tokens alike.\n- **Lock-ups:** none found. Zero-amount claims and withdrawals return early, two-step ownership cannot end at the zero address, and nothing in `enter` depends on external state that a third party can poison.\n- **$PEPES interaction:** the vault is a normal dividend holder, PadTokenV1 has no transfer hooks, and `claimRewards` holds no state across its external calls, so there is no reentrancy or accounting issue.\n- **DN404 check:** `balanceOf >= 1e18` is correct. PepesEarnToken does not override the unit, so one NFT equals exactly 1e18 ERC20 units, including on marketplace transfers.\n- **Zero price:** works as a free-entry mode with no approval needed. Anyone can then mint passes for arbitrary addresses, which is harmless but worth stating as intended.\n\n**Trust assumptions to document** rather than defects: the owner can withdraw every token, set the price to any value including a prohibitive one that effectively pauses entries, and grant passes freely. All of this matches the stated design.\n\n**Coverage and limits:** I read the vault, SafeTransfer, PadTokenV1, PepesEarnToken, the pad's flush path, both test files and the web entry flow, and ran the unit suite plus a scratch test against the real PadTokenV1. The fork test did not run because it needs a network RPC. No static analyser ran.","treeHash":null,"usage":{"cachedInputTokens":1478525,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":23009,"runtime":"claude","turns":34,"wallClockMs":372829}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"56e50117311155be","findings":[{"citation":"resolved","description":"PadTokenV1 credits holder fees at distribution time, not at trade time: fees from trades through routers other than the v1 routers sit in PepesFamily v1 `pendingHolderFees[pepes]` until someone calls `flush`, and `PadTokenV1.claim()` flushes first and then pays whoever holds $PEPES at that moment (AUDIT.md section 8, 'Fee timing on other routers'). `withdraw(pepes, to, amount)` moves the vault's $PEPES out without claiming first. Any fee share that was earned by the vault's balance but not yet flushed is therefore credited to the remaining holders when the next flush happens, and a later `claimRewards` returns 0 for it. The NatSpec (lines 18-20) promises the owner 'the deposited $Pepes ... and the IMD rewards they earn'; the two owner functions only deliver that if called in the order claimRewards then withdraw, which nothing enforces or documents, and the unit test `test_withdrawKeepsPasses` cannot see it because the mock token has no pending-fee stage. Fix that keeps the design: in `withdraw`, when `token == pepes`, call `IPepesToken(pepes).claim()` before `transferOut` (the IMD stays in the vault for `claimRewards`/`withdraw(imd)`), or at least document the required ordering in the NatSpec and the Admin page.","line":128,"path":"contracts/src/world/PepesWorldVault.sol","reproduction":"State: real PadTokenV1 with holders alice 150,000, bob 200,000, carol 1,000,000 and the vault 50,000 $PEPES (alice entered at passPrice 50,000e18); 14e18 IMD of holder fees pending in the launchpad, not yet flushed (`withdrawableDividendOf(vault) == 0`). Sequence A: owner calls `withdraw(pepes, team, 50_000e18)` then `claimRewards(team)`. Expected (per NatSpec): about 0.5e18 IMD (50,000/1,400,000 of 14e18). Actual: `claimRewards` returns 0; the 0.5e18 was distributed to alice, bob and carol. Sequence B with the same state but `claimRewards(team)` first then `withdraw`: returns 0.5e18 minus 1 wei of dust. Confirmed with a Foundry test that deploys `src/v1/PadTokenV1.sol` behind a stub pad whose `flush` forwards the pending IMD and calls `distribute()`.","severity":"low","snippet":"        token.transferOut(to, amount);","title":"withdraw() of the deposited $PEPES before claimRewards() forfeits the vault's share of holder fees still pending in the v1 launchpad"},{"citation":"resolved","description":"`_enter` reads `passPrice` from storage at execution time and the player passes no ceiling. The website approves exactly the displayed price, so for that path a price increase between approve and enter makes `enter()` revert (SafeTransfer.TransferFailed wrapping PadTokenV1.InsufficientAllowance) and nothing is taken. But wallets let users edit the approval to a larger or unlimited amount (the project's own unit test `setUp` approves `type(uint256).max`), and for those wallets a `setPassPrice` that lands between the approval and the `enter` transaction, or that is simply newer than the price the page displayed, pulls the new, larger amount. The owner is trusted per the brief, so this is an owner power to document rather than a bypass, but it is the one place a player's approval can be used to take more than the price the player agreed to. Fix that keeps the design: add an `enter(uint256 maxPrice)` / `enterFor(address player, uint256 maxPrice)` variant (or make the existing ones take `maxPrice`) and revert if `passPrice > maxPrice`; the website then passes the price it displayed.","line":89,"path":"contracts/src/world/PepesWorldVault.sol","reproduction":"State: passPrice = 50_000e18; alice holds 200_000e18 $PEPES and has approved the vault for `type(uint256).max` (or any amount >= 150_000e18). Owner calls `setPassPrice(150_000e18)`. Alice's `enter()` (sent when she saw 50,000) succeeds. Expected by the player: 50,000 $PEPES taken, or a revert. Actual: alice's balance goes from 200_000e18 to 50_000e18; 150,000 $PEPES taken. With an exact 50_000e18 allowance and passPrice set to 50_000e18 + 1, `enter()` reverts with `TransferFailed()` instead. Both confirmed with a Foundry test against the real PadTokenV1.","severity":"low","snippet":"        uint256 amount = passPrice;","title":"enter()/enterFor() charge whatever passPrice is at execution time; a player whose allowance exceeds the price they saw can be charged a higher price set in between"},{"citation":"resolved","description":"The constructor rejects a zero `pepes_`, `earn_` and `owner_` but not a zero `imd`. PadTokenV1 and PadToken use `quote == address(0)` for ETH-paired launches, and `claim()` then pays ETH with `to.call{value: amount}(\"\")`. The vault has no `receive()` or `fallback()`, so that call reverts, `claim()` reverts, and `claimRewards()` can never complete; `withdraw(address(0), ...)` cannot recover anything because no ETH ever arrives. Rewards earned by the deposits are then stuck in the token forever. The deployed $PEPES (0xE2C4...5644) is IMD-paired and the fork test asserts `vault.imd() == IMD`, so this is a deployment-configuration guard, not a live bug: it matters if the vault is ever redeployed for another token or with a wrong constructor argument. Fix: `if (imd == address(0)) revert ZeroAddress();` after reading `quote()` (or, if ETH-paired tokens are meant to be supported, add `receive()` and let `SafeTransfer.transferOut(address(0), ...)` handle the ETH path, which it already does).","line":62,"path":"contracts/src/world/PepesWorldVault.sol","reproduction":"State: a PadTokenV1 deployed with `quote_ = address(0)`; `new PepesWorldVault(token, earn, team, 50_000e18)` succeeds and `vault.imd()` is address(0). alice enters (50,000 tokens); 1 ether of holder fees is flushed to the token so `withdrawableDividendOf(vault) > 0`. Owner calls `claimRewards(team)`. Expected: the vault's ETH share is forwarded to `team`. Actual: revert with `TransferFailed()` from PadTokenV1.claim's ETH send to the vault; `address(vault).balance` stays 0 and the rewards remain unclaimable by any function. Confirmed with a Foundry test.","severity":"low","snippet":"        imd = IPepesToken(pepes_).quote();","title":"Constructor accepts a $Pepes-style token whose quote() is address(0) (ETH-paired), after which claimRewards() always reverts"},{"citation":"resolved","description":"Nothing rejects a zero price. With `passPrice == 0`, `_enter` calls `transferFrom(msg.sender, vault, 0)`, which PadTokenV1 allows with no allowance and no balance (`allowed < 0` is never true), so any wallet gets a pass for free and any bot can call `enterFor` for as many addresses as it likes, inflating `passes` while `totalDeposited` stays unchanged. This is consistent with the brief ('future passes only') and may be the intended way to run a free period, so it is reported as behaviour to be aware of, not a defect: `passes` stops being a count of paid entries and `grantPass` becomes redundant while the price is 0. If a free period is not intended, `setPassPrice` should reject 0 (or `_enter` should require `amount != 0`).","line":112,"path":"contracts/src/world/PepesWorldVault.sol","reproduction":"Owner calls `setPassPrice(0)`. A fresh address with 0 $PEPES and no approval calls `enter()`. Expected if zero is unintended: revert. Actual: `hasPass(nobody) == true`, `passes` incremented, `totalDeposited` unchanged. Confirmed with a Foundry test against the real PadTokenV1.","severity":"info","snippet":"        passPrice = passPrice_;","title":"passPrice = 0 turns enter()/enterFor() into open enrollment, including unlimited free passes for arbitrary addresses"},{"citation":"resolved","description":"`PepesWorld.t.sol` tests the vault against `MockPepesToken`, whose `claim()` mints a preset amount; it has no eligible-supply, pending-fee or distribution stage, so it cannot show that withdrawing the $PEPES forfeits pending fees (finding 1), that `claimRewards` returns 0 when nothing is owed, or that fees distributed before a withdraw survive it. The only test against the real token is `PepesWorld.fork.t.sol`, which is skipped unless `FORK_RPC` is set and claims before it withdraws. Also untested: `passPrice == 0`, `enterFor` by a third party while the player's own `enter` is pending, `withdraw` of IMD or an unrelated token, `transferOwnership(address(0))` as a cancel, and `test_enterNeedsBalanceAndApproval` uses a bare `vm.expectRevert()` that would also pass on an unrelated revert. `src/v1/PadTokenV1.sol` is in the repository and deploys standalone (pad = msg.sender), so these can be unit-tested without a fork by stubbing `flush`.","line":166,"path":"contracts/test/PepesWorld.t.sol","reproduction":"Run `forge test --match-path test/PepesWorld.t.sol`: 14 tests pass. Replace `MockPepesToken` with the real `PadTokenV1` behind a stub pad whose `flush` forwards pending IMD and calls `distribute()`, add 14e18 pending fees, call `withdraw(pepes, team, PRICE)` then `claimRewards(team)`: the claim returns 0 while the same calls in the opposite order return about 0.5e18. None of the current tests would fail if `withdraw` lost rewards this way.","severity":"info","snippet":"    function test_withdrawKeepsPasses() public {","title":"Unit tests never exercise the real $PEPES dividend path, so the withdraw-before-claim ordering, zero-reward claims and a zero price are untested"}],"hash":"395caf6f84513812df5b93e41792c5f433863c58d9472faf9a0c715bb4410919","nodeId":"3def07ae-818c-485f-8dff-5784d9101a44","outcome":"completed","summary":"Review complete. The findings file holds five entries, none critical or high, so no proof tests were attached.\n\n**Verdict on the questions asked**\n\n- **Only the owner can move tokens out.** Both outflow functions are owner-gated, the vault never approves anyone, and a third party calling the token's claim gets only its own share. Confirmed with the real v1 token code.\n- **Approvals pull only from the caller.** `enterFor` charges the caller, never the player. A griefer who front-runs a player's entry pays for the player's pass himself.\n- **No pass without paying, no paying without a pass.** State is set before the pull and the whole call is atomic.\n- **The $EARN check is right.** DN404 uses a unit of 1e18 and the token does not override it, so one NFT equals the threshold.\n- **No reentrancy in the rewards claim.** IMD is a plain token and the balance is read after the claim returns.\n- **The vault earns as a normal holder.** The fork test passed against live Robinhood Chain during this review.\n\n**Findings written to .imd-findings.json**\n\n- **Low: withdraw before claim loses pending fees.** Holder fees from third-party-router trades sit in the v1 launchpad until a flush. Withdrawing the $PEPES before calling claimRewards hands the vault's share of those to the other holders. A minimal fix is to claim inside `withdraw` when the token is $PEPES.\n- **Low: no price ceiling on entry.** The website approves exactly the shown price, so it reverts safely on a raise. A wallet that set a larger allowance pays whatever the price is at execution. An owner power to document, or add a maxPrice argument.\n- **Low: constructor accepts an ETH-quoted token.** The vault cannot receive ETH, so rewards would be stuck forever. The deployed $PEPES is IMD-quoted, so this is a guard for redeployments.\n- **Info: price zero means open enrollment.** Works as the brief allows, but anyone can mint passes for any address at no cost.\n- **Info: test gaps.** The unit suite never runs the real dividend path, so the ordering issue above is invisible to it.\n\nAll reproductions were confirmed with ten scratch tests in `contracts/test/scratch/`, which is the only working-tree change besides the findings file.","treeHash":null,"usage":{"cachedInputTokens":1291630,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":30704,"runtime":"claude","turns":38,"wallClockMs":477962}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"8472a2af9a9cf974","findings":[{"citation":"resolved","description":"_enter reads passPrice at execution time and pulls exactly that from msg.sender, with no caller-supplied maximum. The amount a player ends up paying is therefore whatever passPrice is when the transaction executes, bounded only by the player's allowance and balance. This is the answer to the brief's questions 'can a player's approval be used to take more than passPrice' and 'changing the price between approve and enter': with an allowance equal to the advertised price (which is what web/world/index.html does: it approves exactly the price it read, then calls enter()), a raise makes enter() revert and nothing moves; a cut leaves a harmless residual allowance that only the player can spend. But a wallet that holds a larger or unlimited allowance to the vault (the repo's own test setUp approves type(uint256).max, and wallets routinely grant max approvals) is charged the raised price silently. Only the owner can raise the price, so this is a documented trust assumption on the team rather than a permission bypass; it is reported because the brief asked for it and because it is cheap to close. Access control, enterFor, canPlay, grantPass, withdraw, claimRewards and the two-step ownership transfer were all checked and found correct: nobody but the owner can move $PEPES or IMD out, _enter only ever pulls from msg.sender, passes are set before the pull so paying without a pass or getting one without paying (other than grantPass or an owner-set passPrice of 0) is impossible, PadTokenV1 plain transfers have no fee so the WrongAmountReceived check never trips for honest users, IMD has no transfer hooks so claimRewards has no reentrancy surface, and DN404's unit is 1e18 so canPlay's balanceOf >= 1e18 is exactly one whole $EARN. passPrice = 0 was exercised: enter() succeeds with a zero transferFrom and no allowance, which is the intended free-entry mode. Fix that preserves the design: add a maxPrice argument, e.g. enter(uint256 maxPrice) / enterFor(address player, uint256 maxPrice) that reverts with a PriceAboveMax error when passPrice > maxPrice, and have the site pass the price it displayed.","line":89,"path":"contracts/src/world/PepesWorldVault.sol","reproduction":"State: vault deployed with passPrice = 50_000e18; alice holds 1_000_000e18 $PEPES and has approved the vault for type(uint256).max (as test/PepesWorld.t.sol setUp does). Sequence: owner calls setPassPrice(1_000_000e18); then alice calls enter(). Expected (from the player's point of view, who saw 50,000 on the site): pay 50,000 $PEPES or revert. Actual: enter() succeeds, pepes.balanceOf(alice) == 0 and pepes.balanceOf(vault) == 1_000_000e18, i.e. the whole wallet balance went in for one pass. Control: with alice's allowance set to exactly 50_000e18 and the owner setting the price to 50_000e18 + 1, enter() reverts (SafeTransfer.TransferFailed wrapping PadTokenV1.InsufficientAllowance) and no tokens move. Both cases were run in a Foundry scratch test against src/world/PepesWorldVault.sol and src/v1/PadTokenV1.sol.","severity":"low","snippet":"        uint256 amount = passPrice;","title":"enter()/enterFor() have no price ceiling: a setPassPrice that lands first charges a player with excess allowance the new price"},{"citation":"resolved","description":"The constructor takes whatever quote() returns and never checks it. For a PadToken paired with ETH, quote() is address(0): imd becomes address(0), which SafeTransfer treats as native ETH. PadTokenV1.claim() pays dividends with quote.transferOut(msg.sender, amount), i.e. a plain ETH call to the vault, and the vault has no receive() or fallback(), so once the vault has any withdrawable dividend claim() reverts, and claimRewards() with it. withdraw(address(0), ...) cannot help because the ETH never arrives, and moving the $PEPES out with withdraw() does not move the dividend already accrued to the vault's address, so that amount is unrecoverable. This is not reachable for the stated deployment: the real $PEPES (0xE2C46c70...) is IMD-quoted and the fork test asserts vault.imd() == IMD. It is reported because the contract's own comment says 'IMD, the asset $Pepes rewards are paid in' and nothing enforces that at deploy time. Fix: revert in the constructor when imd == address(0) (preserves the design), or add receive() and keep the address(0) path if ETH-quoted tokens are ever meant to be supported.","line":62,"path":"contracts/src/world/PepesWorldVault.sol","reproduction":"State: deploy PadTokenV1 with quote_ = address(0) (ETH-paired), give alice 1_000_000e18, deploy PepesWorldVault(ethToken, earn, team, 50_000e18): vault.imd() == address(0) and the constructor does not revert. alice approves 50_000e18 and calls enter(). Send 1 ether to the token and call distribute() so withdrawableDividendOf(vault) > 0. Owner calls claimRewards(team). Expected: the owner receives the vault's dividend. Actual: the call reverts (claim() -> transferOut(vault, amount) -> ETH call to a contract with no receive -> TransferFailed), and it reverts on every later attempt as well. Run as a Foundry scratch test against src/world/PepesWorldVault.sol and src/v1/PadTokenV1.sol.","severity":"info","snippet":"        imd = IPepesToken(pepes_).quote();","title":"Constructor accepts an ETH-quoted PadToken; claimRewards then reverts forever and accrued rewards are stranded"},{"citation":"resolved","description":"The unit suite covers the happy paths and the owner guards (14 tests, all passing on this commit) but leaves the edges the brief asks about untested: (1) passPrice = 0, where enter() must grant a pass with a zero transferFrom and no allowance; (2) setPassPrice landing between a player's approve and their enter, in both the exact-allowance case (must revert) and the excess-allowance case (currently charges the new price, see the low finding); (3) a PadToken whose quote() is address(0), where the constructor should reject or claimRewards must keep working; (4) transferOwnership(address(0)) as a cancellation of a pending transfer; (5) claimRewards when claim() pays nothing and the vault holds no IMD (must return 0 without reverting). All five were run in a scratch test during this review and behave as described above; adding them to the suite pins that behaviour.","line":129,"path":"contracts/test/PepesWorld.t.sol","reproduction":"Concrete inputs with no test on this commit: new PepesWorldVault(pepes, earn, team, 0) followed by enter() from a wallet with no allowance (expected: hasPass true, totalDeposited 0); approve(vault, type(uint256).max) then setPassPrice(1_000_000e18) then enter() (currently pulls 1_000_000e18); approve(vault, 50_000e18) then setPassPrice(50_000e18 + 1) then enter() (expected revert); PadTokenV1 with quote_ = address(0) passed to the constructor (currently accepted, claimRewards later reverts); transferOwnership(bob) then transferOwnership(address(0)) then acceptOwnership() from bob (expected revert NotOwner); claimRewards(team) on a fresh vault (expected 0, no revert).","severity":"info","snippet":"    function test_priceChangeKeepsOldPasses() public {","title":"Tests do not exercise passPrice = 0, a price raise against an excess allowance, or the ETH-quoted constructor path"}],"hash":"d76fb5baefa60fdce8b0ec96c73a9f1b0d9982ad4b0c6c55f40e035a4f4596ac","nodeId":"dd0b07b8-e374-4f29-ad4b-6928a998e856","outcome":"completed","summary":"The review is complete. The findings are in `.imd-findings.json` at the repository root, and the scratch tests were removed so the tree is unchanged apart from that file.\n\n**Outcome: no critical, high or medium defects in the vault.** The permission model holds under every question in the brief. Only the owner can move $PEPES or IMD out, through `withdraw` and `claimRewards`. The entry path pulls exactly `passPrice` from `msg.sender` only, sets the pass before the pull, and reverts atomically, so nobody can pay without a pass or get one without paying except through `grantPass` or an owner-set price of zero. PadTokenV1 plain transfers carry no fee, so the received-amount check never trips for honest users. IMD has no transfer hooks and `claim()` carries its own lock, so there is no reentrancy surface in `claimRewards`. DN404's unit is 1e18, so the `balanceOf >= 1e18` test is exactly one whole $EARN. A price of zero grants free passes with a zero transfer and no allowance, which is the intended free mode. Two-step ownership works, and passing the zero address cancels a pending transfer.\n\nThree findings were recorded, each confirmed by a Foundry scratch test I ran and then deleted:\n\n- **Low: no price ceiling on entry.** `enter()` reads `passPrice` at execution. A wallet with an allowance above the advertised price is charged whatever the owner has just set. The site approves the exact price, so there a raise only reverts. Fix: add a `maxPrice` argument.\n- **Info: constructor accepts an ETH-quoted token.** With `quote()` returning the zero address, `claimRewards` reverts forever once dividends accrue, because the vault cannot receive ETH. Not reachable with the real IMD-quoted $PEPES. Fix: reject a zero `imd` in the constructor.\n- **Info: untested edges.** Zero price, price raise against excess allowance, the ETH-quote path, cancelling a pending owner, and claiming with nothing to claim have no tests.\n\nThe existing 14 unit tests pass on this commit. The fork test was not run since it needs network access. Coverage passes applied: access control, trust gap, asymmetry, boundary, execution trace, invariants, economic and first principles from the supplied guides. No proof files were attached because nothing reached high severity.","treeHash":null,"usage":{"cachedInputTokens":1314890,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":23500,"runtime":"claude","turns":31,"wallClockMs":446262}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"7d454e6cee88c390","findings":[{"citation":"resolved","description":"The constructor stores whatever quote() returns without checking it. PadTokenV1 uses address(0) as its quote for ETH-paid launches, and PadTokenV1.claim() pays such dividends with a raw ETH call to msg.sender. PepesWorldVault has no receive() or fallback, so when imd == address(0) the claim() call inside claimRewards fails and SafeTransfer wraps it into TransferFailed. The vault is a dividend-eligible holder (PadTokenV1 does not exclude it), so the rewards keep accruing to it in the token contract and nothing else in the vault can reach them: withdraw() only moves balances the vault already holds. The real $Pepes (0xE2C4...5644) is IMD-quoted, so this only triggers if the vault is ever deployed against an ETH-quoted pad token (a mis-deployment, or a future vault for another PepesFamily token). Fix: revert in the constructor when quote() returns address(0), e.g. `if (imd == address(0)) revert ZeroAddress();`, or add a receive() if ETH-quoted tokens are meant to be supported.","line":62,"path":"contracts/src/world/PepesWorldVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PepesWorldVault} from \"src/world/PepesWorldVault.sol\";\nimport {PadTokenV1} from \"src/v1/PadTokenV1.sol\";\n\n/// @dev Stands in for $EARN: balanceOf only.\ncontract QuoteEarn {\n    mapping(address => uint256) public balanceOf;\n}\n\n/// @notice PepesWorldVault built on a PadTokenV1 whose quote() is address(0) (dividends paid in ETH).\n///         The test contract is the \"pad\" (PadTokenV1 sets pad = msg.sender): it holds the supply and answers flush.\ncontract WorldVaultEthQuoteTest is Test {\n    address team = makeAddr(\"team\");\n    uint256 constant PRICE = 50_000e18;\n\n    function flush(address) external {}\n\n    /// Fails on the current code: the constructor stores imd = address(0), the vault has no receive(), so\n    /// PadTokenV1.claim() cannot pay the vault and claimRewards reverts with TransferFailed while rewards are\n    /// owed. Passes once the constructor rejects a token whose quote() is address(0), or the vault can take ETH.\n    function test_ethQuotedPepesBricksClaimRewards() public {\n        PadTokenV1 pepes =\n            new PadTokenV1(\"Pepes\", \"PEPES\", \"\", address(0), address(this), makeAddr(\"router\"), makeAddr(\"pm\"));\n        QuoteEarn earn = new QuoteEarn();\n        PepesWorldVault vault;\n        try new PepesWorldVault(address(pepes), address(earn), team, PRICE) returns (PepesWorldVault v) {\n            vault = v;\n        } catch {\n            return; // the constructor rejects an ETH-quoted token: fixed\n        }\n        assertEq(vault.imd(), address(0), \"quote is ETH\");\n\n        // the vault holds one deposit; trades pay 1 ETH of holder fees to the token, which distributes them\n        pepes.transfer(address(vault), PRICE);\n        (bool ok,) = address(pepes).call{value: 1 ether}(\"\");\n        assertTrue(ok);\n        pepes.distribute();\n        uint256 owed = pepes.withdrawableDividendOf(address(vault));\n        assertGt(owed, 0.99 ether, \"the vault is owed about 1 ETH\");\n\n        vm.prank(team);\n        uint256 got = vault.claimRewards(team); // current code: revert TransferFailed()\n        assertEq(got, owed);\n        assertEq(team.balance, owed);\n    }\n}","reproduction":"State: a PadTokenV1 with quote == address(0); vault = new PepesWorldVault(token, earn, team, 50_000e18) succeeds and vault.imd() == address(0). token.transfer(vault, 50_000e18); send 1 ether to the token and call distribute(): withdrawableDividendOf(vault) is about 1e18. Owner calls vault.claimRewards(team). Expected: team receives the owed ETH (or the constructor had rejected the token). Actual: revert TransferFailed() from SafeTransfer, because PadTokenV1.claim() cannot send ETH to a contract with no receive(); the owed amount stays locked in the token contract. claimRewards only succeeds while nothing is owed (amount == 0 skips the transfer).","severity":"low","snippet":"        imd = IPepesToken(pepes_).quote();","title":"Constructor accepts a $Pepes whose quote() is address(0); claimRewards then reverts forever"},{"citation":"resolved","description":"With passPrice set to 0, _enter() calls pepes.transferFrom(msg.sender, vault, 0). PadTokenV1 accepts this for a wallet with zero balance and zero allowance (`allowed < amount` is 0 < 0, false), the balance-delta check is 0 == 0, and the pass is granted. Through enterFor() the same caller can set hasPass[x] = true for any address x. Nothing of value is lost (a pass is the only effect, and a pre-granted pass only makes that address's own later enter()/grantPass() revert with AlreadyEntered), so this is consistent with the documented design of a team-set price: 0 simply means a free period. Recorded because the brief asked about the zero-price case. If free entry is ever meant to go only through grantPass, _enter should revert when amount == 0.","line":89,"path":"contracts/src/world/PepesWorldVault.sol","reproduction":"State: owner calls setPassPrice(0). Input: bob (0 $Pepes, 0 allowance) calls vault.enter() and vault.enterFor(carol). Expected if a price of zero were meant to pause sales: revert. Actual: both succeed, hasPass[bob] and hasPass[carol] are true, passes == 2, totalDeposited == 0 (verified against the real PadTokenV1 code in a Foundry test).","severity":"info","snippet":"        uint256 amount = passPrice;","title":"passPrice == 0 lets anyone take a pass, and give passes to arbitrary addresses, with no balance or approval"},{"citation":"resolved","description":"enter() has no maximum-price argument; it pulls the current passPrice from msg.sender. A wallet that approved exactly passPrice is protected by the token's allowance check (a raise makes the transfer fail and enter() reverts with TransferFailed, a cut just charges less), and the game front end (web/world/index.html, enterVault) approves exactly `price`. But a wallet that granted the vault an unlimited or oversized allowance, for example via a wallet's default 'unlimited' prompt, pays the raised price without having seen it. Only the owner can trigger this, so it is not a permission bypass; it is the one place where the owner's setPassPrice power reaches a player's wallet beyond the deposit they agreed to. A minimal mitigation that keeps the design is an `enter(uint256 maxPrice)` / `enterFor(player, maxPrice)` variant that reverts when passPrice > maxPrice; the zero-argument functions can stay for exact-approval callers. Separately, the wrapped TransferFailed error hides the token's InsufficientAllowance / InsufficientBalance reason from the user.","line":112,"path":"contracts/src/world/PepesWorldVault.sol","reproduction":"State: alice approves the vault for exactly 50_000e18; bob approves type(uint256).max; both hold 200_000e18 $Pepes. Owner calls setPassPrice(150_000e18) before their enter() transactions mine. alice.enter(): expected and actual revert (TransferFailed wrapping InsufficientAllowance), she keeps her tokens. bob.enter(): expected from what he saw when approving, pay 50_000e18; actual, 150_000e18 leaves his wallet (balance 50_000e18 afterwards). Verified against the real PadTokenV1 code in a Foundry test.","severity":"info","snippet":"        passPrice = passPrice_;","title":"Owner trust assumption: a wallet that approved more than passPrice pays whatever the price is when enter() mines"},{"citation":"resolved","description":"The unit suite covers the happy paths and the fee-on-transfer rejection but never exercises the boundaries the Math Precision / Boundary guides ask for: (1) passPrice == 0 (free entry with no allowance); (2) a price raise between an exact approve and enter(), which must revert rather than charge more; (3) a $Pepes whose quote() is address(0) (constructor accepted, claimRewards bricked); (4) claimRewards when claim() pays nothing and the vault holds no IMD (should return 0 and not revert, which it does today via SafeTransfer's early return); (5) withdraw(address(0), to, amount) for forced ETH; (6) transferOwnership(address(0)) as a cancel. The existing price-change test only checks a raise after a max approval, so it cannot tell the two approval cases apart. The fork test silently skips when FORK_RPC is unset, so a CI run without it reports green with no fork coverage.","line":129,"path":"contracts/test/PepesWorld.t.sol","reproduction":"Run `forge test --match-path test/PepesWorld.t.sol`: 14 tests pass; none sets passPrice to 0, none approves exactly PRICE then raises the price, none constructs the vault on a token whose quote() is address(0). Adding the ETH-quoted case fails today (see the low finding); the other cases pass and would pin the intended behaviour.","severity":"info","snippet":"    function test_priceChangeKeepsOldPasses() public {","title":"Untested edges: zero price, exact-approval price raise, ETH-quoted token, zero-reward claim, native withdraw"}],"hash":"fdffd98b3c8b7170a7c988bffd9b95893db23121d140f775175f7aca4e8eb988","nodeId":"c5d9c975-9722-4ae5-bc75-08fbbd995028","outcome":"completed","summary":"The review is complete and the findings file is written. Here is the recap.\n\n**Outcome.** The vault is sound on every question the brief asked. I found one low-severity defect, two design notes recorded as informational, and one test-coverage gap. No critical, high or medium issues. The findings are in `.imd-findings.json` at the repository root, and the scratch tests are removed, so the tree is clean.\n\n**Questions from the brief, answered**\n\n- **Moving tokens out.** Only the owner can. Every outbound path is behind onlyOwner, and the $PEPES router's allowance bypass only pulls from its own caller, so no third party can drain the vault's deposit.\n- **Approvals.** Entry always pulls exactly the current price from msg.sender. Nothing lets a caller name another payer or a different amount.\n- **Pass without paying.** Only with a price of zero, which is the owner's choice. The checks-effects-interactions order and the balance-delta check make \"pay without a pass\" impossible.\n- **Locking.** The one way claimRewards can stick is deploying against an ETH-quoted pad token, since the vault has no receive function. That is the low finding, with a Foundry proof that fails today and passes once the constructor rejects a zero quote address.\n- **Reentrancy and accounting.** The token's claim has its own lock, the vault pays IMD to the recipient by sweeping its full balance, and no vault state depends on the external calls. No issue.\n- **DN404 check.** Correct. The $EARN unit is the default 1e18 and holding an NFT implies a balance of at least one whole token. Note that a contract wallet with skip-NFT on passes the check while holding zero NFTs, which only matters to the game's character rendering, which is out of scope.\n- **Price edge cases.** A price of zero gives free passes to anyone, including to arbitrary third-party addresses, with no allowance at all. A price raise between approve and enter is safe for exact approvals, which the website uses, but a wallet with an unlimited allowance pays the new price. Both are owner-controlled, so I filed them as trust assumptions, with an optional maximum-price argument as the mitigation.\n\n**Coverage.** The Math Precision, Boundary and Numerical Gap passes found no arithmetic to fault: the contract has no division and only checked additions. I also ran the access-control, boundary, and entry-point inventories. The existing unit suite passes. The fork test was not run because it needs a live RPC and was out of the proof rules.","treeHash":null,"usage":{"cachedInputTokens":1121312,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":31984,"runtime":"claude","turns":34,"wallClockMs":500196}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2b9b0095482c54e6","findings":[{"citation":"resolved","description":"_enter reads passPrice from storage at execution time and pulls exactly that amount from msg.sender; the player cannot state the price they agreed to. The site (web/world/index.html, enterVault) approves exactly the displayed price, so for that path a raise makes enter() revert with SafeTransfer.TransferFailed (PadTokenV1's InsufficientAllowance is swallowed by the low-level call) and nothing moves; the player loses gas and must re-approve. But a wallet holding a larger or unlimited allowance to the vault (the default of many wallet prompts, and what the repo's own test setUp at contracts/test/PepesWorld.t.sol:62 does) is charged whatever passPrice is when its transaction executes, with no revert. Only the owner can raise the price, so this is a documented owner power rather than a permission bypass; it is the one path by which a player's approval can be used to take more than the price the player saw, which the brief asked about. Everything else checked for the brief holds: only the owner can move $PEPES or IMD out; _enter pulls only from msg.sender; hasPass is set before the pull inside one transaction, so nobody pays without a pass or gets one without paying (other than grantPass or a zero price); PadTokenV1 plain transfers have no fee so WrongAmountReceived never trips for honest users; IMD has no transfer hooks and claimRewards is owner-only, so there is no reentrancy surface; DN404._unit() is not overridden by PepesEarnToken, so balanceOf >= 1e18 is exactly one whole $EARN. Fix that keeps the design: add a maxPrice argument, e.g. enter(uint256 maxPrice) / enterFor(address player, uint256 maxPrice), and revert with a PriceAboveMax error when passPrice > maxPrice; the site passes the price it displayed. The no-arg overloads can stay as type(uint256).max wrappers.","line":89,"path":"contracts/src/world/PepesWorldVault.sol","reproduction":"Against the real src/v1/PadTokenV1.sol (test contract as pad). State: vault with passPrice = 50_000e18; bob holds 200_000e18 $PEPES and has approved the vault for type(uint256).max. Owner calls setPassPrice(150_000e18); bob calls enter(). Expected (from what bob saw when approving): 50_000e18 taken, or a revert. Actual: the call succeeds and bob's balance is 50_000e18 (150_000e18 taken). Control: alice approves exactly 50_000e18, owner sets price to 50_000e18 + 1, alice calls enter(): revert SafeTransfer.TransferFailed(), balance unchanged, no pass. Both run in a scratch Foundry test (test/scratch/Judge.t.sol, test_unlimitedAllowancePaysRaisedPrice and test_exactAllowanceRevertsOnRaise).","severity":"low","snippet":"        uint256 amount = passPrice;","title":"enter()/enterFor() have no caller-side price ceiling: a setPassPrice that lands between approve and enter charges a player with excess allowance the new price"},{"citation":"resolved","description":"PadTokenV1 credits holder fees at distribution time, not trade time: fees from swaps through routers other than the v1 router sit in PepesFamily v1 pendingHolderFees[token] until flush (AUDIT.md section 8, 'Fee timing on other routers'), and PadTokenV1.claim() calls pad.flush first, which distributes to whoever holds $PEPES at that moment. withdraw(pepes, to, amount) moves the vault's $PEPES out without claiming first, so any fee share earned by the vault's balance but not yet flushed is credited to the remaining holders at the next flush, and a later claimRewards returns 0 for it. The NatSpec (lines 18-20) promises the owner both the deposits and the IMD they earn; the two owner functions only deliver that when called in the order claimRewards then withdraw, which nothing enforces or documents. The fork test happens to claim before it withdraws, and the unit test test_withdrawKeepsPasses uses a mock with no pending-fee stage, so neither can see it. Fix that keeps the design: in withdraw, when token == pepes, call IPepesToken(pepes).claim() before transferOut (the claimed IMD stays in the vault for claimRewards or withdraw(imd)); at minimum document the required ordering in the NatSpec and the admin page.","line":128,"path":"contracts/src/world/PepesWorldVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PepesWorldVault} from \"src/world/PepesWorldVault.sol\";\nimport {PadTokenV1} from \"src/v1/PadTokenV1.sol\";\n\n/// @dev Minimal IMD stand-in.\ncontract QuoteToken {\n    mapping(address => uint256) public balanceOf;\n\n    function mint(address to, uint256 amt) external {\n        balanceOf[to] += amt;\n    }\n\n    function transfer(address to, uint256 amt) external returns (bool) {\n        balanceOf[msg.sender] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\n/// @dev Stands in for $EARN: balanceOf only.\ncontract QuoteEarn {\n    mapping(address => uint256) public balanceOf;\n}\n\n/// @dev Stands in for PepesFamily v1: holder fees from other routers sit pending until `flush`, which forwards\n///      them to the token and distributes to whoever holds at that moment (PadTokenV1.claim() calls flush first).\ncontract StubPad {\n    QuoteToken public imd;\n    PadTokenV1 public token;\n    uint256 public pending;\n\n    function init(QuoteToken imd_) external {\n        imd = imd_;\n    }\n\n    function launch(address router, address pm) external returns (PadTokenV1 t) {\n        t = new PadTokenV1(\"Pepes\", \"PEPES\", \"\", address(imd), address(this), router, pm);\n        token = t;\n    }\n\n    function give(address to, uint256 amt) external {\n        token.transfer(to, amt);\n    }\n\n    function addPending(uint256 amt) external {\n        imd.mint(address(this), amt);\n        pending += amt;\n    }\n\n    function flush(address) external {\n        if (pending == 0) return;\n        uint256 amt = pending;\n        pending = 0;\n        imd.transfer(address(token), amt);\n        token.distribute();\n    }\n}\n\n/// @notice Fails on the current code: `withdraw(pepes, ...)` moves the deposit out without claiming first, so the\n///         holder fees still pending in the launchpad are distributed to the other holders and `claimRewards`\n///         returns 0. Passes once `withdraw` claims the vault's dividend before moving $Pepes out.\ncontract WorldVaultWithdrawOrderTest is Test {\n    QuoteToken imd = new QuoteToken();\n    QuoteEarn earn = new QuoteEarn();\n    StubPad pad = new StubPad();\n    PadTokenV1 pepes;\n    PepesWorldVault vault;\n    address team = makeAddr(\"team\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address carol = makeAddr(\"carol\");\n    uint256 constant PRICE = 50_000e18;\n\n    function setUp() public {\n        pad.init(imd);\n        pepes = pad.launch(makeAddr(\"router\"), makeAddr(\"pm\"));\n        vault = new PepesWorldVault(address(pepes), address(earn), team, PRICE);\n        pad.give(alice, 200_000e18);\n        pad.give(bob, 200_000e18);\n        pad.give(carol, 1_000_000e18);\n        vm.startPrank(alice);\n        pepes.approve(address(vault), PRICE);\n        vault.enter();\n        vm.stopPrank();\n    }\n\n    function test_withdrawThenClaimKeepsVaultShare() public {\n        // 14 IMD of holder fees earned while the vault holds 50k of 1.4M eligible supply, not yet flushed\n        pad.addPending(14e18);\n        assertEq(pepes.withdrawableDividendOf(address(vault)), 0, \"nothing flushed yet\");\n\n        vm.startPrank(team);\n        vault.withdraw(address(pepes), team, PRICE);\n        uint256 got = vault.claimRewards(team);\n        vm.stopPrank();\n\n        // expected: the vault's 50k/1.4M share of 14 IMD, about 0.5 IMD, reaches the team either way\n        assertApproxEqAbs(got, 0.5e18, 2, \"the vault's share of pending fees\");\n        assertApproxEqAbs(imd.balanceOf(team), 0.5e18, 2, \"team received it\");\n        assertEq(pepes.balanceOf(team), PRICE, \"deposit withdrawn\");\n    }\n}","reproduction":"Against the real src/v1/PadTokenV1.sol behind a stub pad whose flush forwards pending IMD to the token and calls distribute(). State: holders alice 150,000, bob 200,000, carol 1,000,000 and the vault 50,000 $PEPES (alice entered at 50_000e18); 14e18 IMD pending in the pad, withdrawableDividendOf(vault) == 0. Sequence A: owner calls withdraw(pepes, team, 50_000e18) then claimRewards(team). Expected: about 0.5e18 IMD (50,000/1,400,000 of 14e18). Actual: claimRewards returns 0 and carol's withdrawable dividend is positive; the share went to the other holders. Sequence B, same state, claimRewards(team) then withdraw: returns 0.5e18 minus dust. The attached proof (test/scratch/WithdrawOrder.t.sol) fails on this commit with '0 !~= 500000000000000000'.","severity":"low","snippet":"        token.transferOut(to, amount);","title":"withdraw() of the deposited $PEPES before claimRewards() forfeits the vault's share of holder fees still pending in the launchpad"},{"citation":"resolved","description":"The constructor only zero-checks pepes_, earn_ and owner_; pepes, earn and imd are immutable, so a wrong argument is irreversible and there is no deploy script for the vault in contracts/script. Two mistakes slip through silently. (1) PadTokenV1 (and PadToken) use quote == address(0) for ETH-paired launches, and claim() pays such dividends with a raw ETH call to msg.sender. The vault stores imd = address(0), has no receive() or fallback(), so once any dividend is withdrawable, IPepesToken(pepes).claim() reverts with SafeTransfer.TransferFailed and claimRewards can never complete; withdraw(address(0), ...) cannot help because the ETH never arrives, and moving the $PEPES out does not move the dividend already accrued to the vault's address, so it is stranded in the token. (2) $EARN is a DN404 with two addresses: the token 0xf236...fc20 and its mirror 0x0e4b...5489 (deployments/robinhood-earn.json). DN404Mirror.balanceOf returns the NFT count (lib/dn404/src/DN404Mirror.sol:140), so with earn_ = mirror a wallet with one NFT reads 1 and canPlay's >= 1e18 is false for every holder; with an earn_ without code, canPlay reverts for everyone. The live $PEPES (0xE2C4...5644) is IMD-quoted and the fork test asserts vault.imd() == IMD, so the launch deployment is unaffected; this matters for a redeploy, another PepesFamily token, or a wrong argument. Fix: after reading quote(), `if (imd == address(0)) revert ZeroAddress();` (or add receive() if ETH-quoted tokens are meant to be supported, since SafeTransfer.transferOut already handles the address(0) path), and sanity-check earn_ with a read only the token satisfies (e.g. earn_.code.length != 0 and totalSupply() == 2_000e18).","line":62,"path":"contracts/src/world/PepesWorldVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PepesWorldVault} from \"src/world/PepesWorldVault.sol\";\nimport {PadTokenV1} from \"src/v1/PadTokenV1.sol\";\n\n/// @dev Stands in for $EARN: balanceOf only.\ncontract QuoteEarn {\n    mapping(address => uint256) public balanceOf;\n}\n\n/// @notice PepesWorldVault built on a PadTokenV1 whose quote() is address(0) (dividends paid in ETH).\n///         The test contract is the \"pad\" (PadTokenV1 sets pad = msg.sender): it holds the supply and answers flush.\ncontract WorldVaultEthQuoteTest is Test {\n    address team = makeAddr(\"team\");\n    uint256 constant PRICE = 50_000e18;\n\n    function flush(address) external {}\n\n    /// Fails on the current code: the constructor stores imd = address(0), the vault has no receive(), so\n    /// PadTokenV1.claim() cannot pay the vault and claimRewards reverts with TransferFailed while rewards are\n    /// owed. Passes once the constructor rejects a token whose quote() is address(0), or the vault can take ETH.\n    function test_ethQuotedPepesBricksClaimRewards() public {\n        PadTokenV1 pepes =\n            new PadTokenV1(\"Pepes\", \"PEPES\", \"\", address(0), address(this), makeAddr(\"router\"), makeAddr(\"pm\"));\n        QuoteEarn earn = new QuoteEarn();\n        PepesWorldVault vault;\n        try new PepesWorldVault(address(pepes), address(earn), team, PRICE) returns (PepesWorldVault v) {\n            vault = v;\n        } catch {\n            return; // the constructor rejects an ETH-quoted token: fixed\n        }\n        assertEq(vault.imd(), address(0), \"quote is ETH\");\n\n        // the vault holds one deposit; trades pay 1 ETH of holder fees to the token, which distributes them\n        pepes.transfer(address(vault), PRICE);\n        (bool ok,) = address(pepes).call{value: 1 ether}(\"\");\n        assertTrue(ok);\n        pepes.distribute();\n        uint256 owed = pepes.withdrawableDividendOf(address(vault));\n        assertGt(owed, 0.99 ether, \"the vault is owed about 1 ETH\");\n\n        vm.prank(team);\n        uint256 got = vault.claimRewards(team); // current code: revert TransferFailed()\n        assertEq(got, owed);\n        assertEq(team.balance, owed);\n    }\n}","reproduction":"(1) Deploy PadTokenV1 with quote_ = address(0) (test contract as pad, empty flush); new PepesWorldVault(token, earn, team, 50_000e18) succeeds and vault.imd() == address(0). Transfer 50_000e18 to the vault, send 1 ether to the token, call distribute(): withdrawableDividendOf(vault) is about 1e18. Owner calls claimRewards(team). Expected: team receives the owed ETH, or the constructor had rejected the token. Actual: revert TransferFailed() (trace: PadTokenV1.claim -> PepesWorldVault::receive reverts -> TransferFailed), on every later attempt as well. The attached proof (the audit_math specialist's test, run here) fails on this commit with exactly that error. (2) new PepesWorldVault(PEPES, 0x0e4bf5b83740F9E93ED739b2064165561CE75489, team, 50_000e18) with a wallet owning one Pepes Earn IMD NFT (mirror.balanceOf == 1, token.balanceOf == 1e18): canPlay(wallet) expected true, actual false, and earn is immutable so the vault must be redeployed.","severity":"low","snippet":"        imd = IPepesToken(pepes_).quote();","title":"Constructor does not validate its dependencies: an ETH-quoted pad token makes claimRewards revert forever, and the $EARN mirror address makes canPlay false for every NFT holder"},{"citation":"resolved","description":"Nothing rejects a zero price. With passPrice == 0, _enter calls transferFrom(msg.sender, vault, 0), which PadTokenV1 accepts with no allowance and no balance (allowed < 0 is never true, and 0 <= balance), the balance-delta check is 0 == 0, and the pass is granted. Any wallet then gets a pass for free, and any caller can set hasPass[x] = true for any address x through enterFor, so passes stops being a count of paid entries while totalDeposited stays unchanged, and grantPass is redundant during that period. Nothing of value is lost (a pre-granted pass only makes that address's later enter()/grantPass() revert with AlreadyEntered), and it is consistent with the brief's team-set price, so this is behaviour to be aware of rather than a defect: zero means a free period. If free entry is meant to go only through grantPass, setPassPrice should reject 0 or _enter should require amount != 0.","line":94,"path":"contracts/src/world/PepesWorldVault.sol","reproduction":"Against the real PadTokenV1. Owner calls setPassPrice(0). A fresh address with 0 $PEPES and no approval calls enter() and then enterFor(carol). Expected if a zero price were meant to pause sales: revert. Actual: both succeed, hasPass(nobody) and hasPass(carol) are true, passes == 2, totalDeposited == 0 (test/scratch/Judge.t.sol, test_zeroPriceOpenEnrollment).","severity":"info","snippet":"        pepes.transferFrom(msg.sender, address(this), amount);","title":"passPrice = 0 turns enter()/enterFor() into open enrollment, including free passes for arbitrary addresses"},{"citation":"resolved","description":"The 14 unit tests cover the happy paths, the owner guards and the fee-on-transfer rejection, but every entry test runs with alice holding a type(uint256).max approval and against MockPepesToken, whose claim() mints a preset amount with no eligible-supply, pending-fee or distribution stage. So the suite cannot show: a price raise between approve and enter (exact approval must revert, excess approval currently overpays); passPrice == 0 (free entry, no allowance); withdraw before claimRewards forfeiting pending fees; claimRewards returning 0 without reverting when nothing is owed; a PadToken whose quote() is address(0); transferOwnership(address(0)) as a cancel; withdraw of IMD or an unrelated token. test_enterNeedsBalanceAndApproval uses a bare vm.expectRevert() that also passes on an unrelated revert. The only coverage against the real token and the real $EARN is PepesWorld.fork.t.sol, which skips itself when FORK_RPC is unset, so CI reports green with no fork coverage. src/v1/PadTokenV1.sol deploys standalone (pad = msg.sender) and can be unit-tested behind a stub flush, as the scratch tests for this review do. There are no fuzz tests.","line":62,"path":"contracts/test/PepesWorld.t.sol","reproduction":"Run `forge test --match-path test/PepesWorld.t.sol`: 14 pass, none sets passPrice to 0, none changes passPrice between an approve and an enter, none calls claimRewards with nothing claimable, none uses PadTokenV1, none constructs the vault on a token whose quote() is address(0). Concrete missing assertions: approve exactly 50_000e18, setPassPrice(50_000e18 + 1), enter -> expected revert SafeTransfer.TransferFailed; approve type(uint256).max, setPassPrice(150_000e18), enter -> balance drops by 150_000e18; setPassPrice(0) then enter from a wallet with no allowance -> hasPass true, totalDeposited 0; claimRewards(team) on a fresh vault -> 0, no revert. All verified in test/scratch/Judge.t.sol during this review.","severity":"info","snippet":"        pepes.approve(address(vault), type(uint256).max);","title":"Unit tests run only with an unlimited approval and mock tokens; the edges the brief asks about are untested"}],"hash":"fe7175cbef9cd0de4d39e453a06aeadc99409444948a851a0a88cd29b86e07f6","nodeId":"2b804b86-7049-46a6-b17d-0edf18be8ff0","outcome":"completed","summary":"The review is complete. The findings file at the repository root holds 5 judged findings, down from the 15 the four specialists reported.\n\n**What reproduced.** Every specialist claim held up when run against the real PadTokenV1 in scratch Foundry tests. No finding was dropped for failing to reproduce. The duplicates were the same four mechanisms reported by several specialists each, and I merged them.\n\n**Findings kept, ranked:**\n\n- **Low, no price ceiling on enter.** A wallet with an unlimited allowance pays whatever passPrice is when its transaction mines. Reproduced: bob approves max, owner raises the price to 150k, bob's enter succeeds and 150k leaves his wallet. With an exact approval the call reverts instead, so the website's exact-approve path is safe. Owner power, not a bypass, but it answers the brief's approval question directly. Fix: a maxPrice argument.\n- **Low, withdraw before claimRewards forfeits pending fees.** PadTokenV1 distributes launchpad-pending fees at flush time, so withdrawing the $PEPES first hands the vault's share to the other holders. Reproduced with a stub pad: withdraw then claim returns 0, claim then withdraw returns about 0.5 IMD. Proof attached, fails on this commit.\n- **Low, constructor validation.** An ETH-quoted pad token stores imd as address(0), and the vault cannot receive ETH, so claimRewards reverts forever once anything is owed. Passing the $EARN mirror address makes canPlay false for every NFT holder, since DN404Mirror's balanceOf returns an NFT count. The live $PEPES is IMD-quoted, so this is a redeploy guard. The audit_math specialist's proof fails for its stated reason and is attached.\n- **Info, passPrice of zero is open enrollment** including free passes for arbitrary addresses through enterFor. Consistent with a team-set price, recorded because the brief asked.\n- **Info, test gaps.** The unit suite uses an unlimited approval and mocks with no dividend stage, so none of the above edges is exercised, and the fork test silently skips without FORK_RPC.\n\n**Checked and found correct** for the rest of the brief: only the owner moves tokens out, _enter pulls only from msg.sender, passes are granted atomically with payment, no fee-on-transfer or reentrancy surface in claimRewards, and the 1e18 threshold matches DN404's un-overridden unit.\n\nScratch tests live under contracts/test/scratch and nothing else in the tree was changed.","treeHash":null,"usage":{"cachedInputTokens":761804,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":15916,"runtime":"claude","turns":19,"wallClockMs":318727}}],"verification":[]}