{"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":"e6eda4d8-f50d-47cd-9464-9a272283ccd3","kind":"audit","nodes":[{"acceptedSubmissionHash":"00f999777a6f5c5f2fe5e83d7bee589b48260abe93d37bea0b884f6903597287","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":"86f759b58dbd93417b9694424ed1b3b15005dfc6f01825cd0c1c93b90f784bf3","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":"2f104a270c5ce2a6c58113d932ea9285ae533d519cd891949f31184b82609996","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":"5bf562f9e8a4958db68136b43db8bb3823a8d1ee789765917fc1ca06314dca48","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":"7271bd2428d32750f0721b8bda912884722be77b5ba140b38c13e9fc43b6e405","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit request: Pepes Earn IMD (NFT collection with IMD holder rewards)\n\nRepository: https://github.com/0xtenang/PepesFamily\nCommit: 9c00fa216b38dda7b6a05936d47468d19637e586\nChain: Robinhood Chain (chain ID 4663), Uniswap v4\nStatus: not deployed yet; this audit is before deployment\n\nScope (new code)\n\ncontracts/src/earn/PepesEarnIMD.sol: pool owner and Uniswap v4 hook\ncontracts/src/earn/PepesEarnToken.sol: $EARN, a DN404 base token with holder rewards, 30-day expiry and $Pepes buyback\ncontracts/src/earn/PepesEarnMirror.sol: the ERC-721 side (DN404 mirror) with an ERC-2981 royalty\ncontracts/src/earn/PepesEarnRenderer.sol and LibEarnString.sol: on-chain SVG art, JSON and Base64\nContext (already audited, reused unchanged): PepesFamilyRouter.sol, PepesFamilyEthRouter.sol, lib/SafeTransfer.sol, and the hook logic from PepesFamily.sol (v3). DN404 is the upstream library at contracts/lib/dn404, commit 3397cb1. Please review how we integrate with it, not DN404 itself.\n\nTests: contracts/test/PepesEarn.t.sol (22 tests) and contracts/test/PepesEarn.fork.t.sol, a mainnet-fork lifecycle test. Run the fork test with FORK_RPC=https://robinhood.drpc.org forge test --mc PepesEarnForkTest.\n\nWhat it does\n\nSupply: 2,000 $EARN tokens. Each whole token shows as one on-chain NFT (DN404). Wallets get NFTs; contracts don't. EIP-7702 delegated wallets count as wallets.\nPool: PepesEarnIMD.openPool(token) (owner, one-time) puts the whole supply into a $EARN/IMD Uniswap v4 pool as single-sided liquidity owned by the hook, which has no way to remove it.\nFee: every swap pays 4% of its IMD side, the same hook as PepesFamily v3: 1% to feeRecipient and 3% to $EARN holders pro rata. Holders claim manually with claim().\nMarketplaces: NFTs can be traded on marketplaces. The mirror reports a 4% ERC-2981 royalty paid to PepesEarnIMD in ETH. convertRoyalties(minOut) (owner) swaps it to IMD on the IMD/ETH pool and splits it 1% / 3% in the same way.\nExpiry: if a wallet neither claims nor moves any $EARN for 30 days, its unclaimed rewards expire, except what it earned during those 30 days. Anyone can call recycle(holder) to move expired rewards into buybackReserve.\nBuyback: the reserve can only be spent through buybackAndBurnPepes(imdIn, minOut, deadline) (owner), which buys $Pepes through the PepesFamily v1 router and sends all of it to 0x…dEaD.\nNo owner on the token: owner() returns address(0). DN404's default infinite Permit2 allowance is disabled.\nTrust model (please confirm or break)\n\nThe PepesEarnIMD owner can only:\nchange feeRecipient;\ntransfer ownership (two-step);\nopen the pool once;\nconvert royalties, choosing the minimum output;\ntime buybacks, choosing the minimum output.\nThe owner must never be able to take holders' tokens, rewards, the buyback reserve, the pool liquidity, or royalty IMD meant for holders, or change fees.\nNobody but a holder can claim that holder's rewards. Recycling can only move rewards that have expired, and only into the reserve.\nPlease look hardest at\n\nDN404 integration with reward accounting. Rewards are updated in _moved(), called after _transfer (ERC-20 side) and _transferFromNFT (NFT side, which changes balances directly).\nIs any other path able to change balances without updating corrections, eligibleSupply or lastActive? Consider mirror operations, setSkipNFT, initialisation, and transfers to and from excluded addresses.\nCan any sequence make eligibleSupply or the corrections inconsistent, or let rewards be claimed twice?\nExpiry maths (expiredRewardsOf, recycle, magAt, _checkpoint).\nThe design assumes a holder's balance is unchanged since lastActive, because every balance change updates it. Can that assumption be broken?\nCan recycling ever take rewards earned in the last 30 days, or more than the holder's withdrawable amount? Check rounding: \"recent\" rounds up in the holder's favour.\nIs the checkpoint array (one entry per second with distributions, binary search) correct and safe from gas problems over years of use?\nAccepted by design: sending someone a dust amount resets their timer. That only delays expiry.\nAccounting invariant. The token's IMD balance should always be at least accountedBalance + buybackReserve, and distribute() must never hand out the reserve. Check every path: claim, recycle, buyback, royalties, donations, and the first buy before any holder exists.\nFlash-borrowed pool tokens (the v3 audit finding).\nWhile the PoolManager is unlocked, only the hook may distribute.\nflush mid-unlock only distributes when called by our routers.\nconvertRoyalties distributes inside the hook's own unlock. We believe no one else can hold borrowed $EARN there; please verify.\nBuyback and burn. Owner-only, limited to the reserve, approval reset to 0 afterwards, burned amount measured by balance difference, nonReentrant. The $Pepes token and v1 router addresses are fixed at deployment. Any way to misuse or redirect the reserve?\nRoyalty conversion. receive() accepts any ETH. Check the ETH settlement amount, the 1/4 vs 3/4 split, minOut, and whether stray ETH could break anything.\nHook and pool. It's a copy of v3 with a single token and openPool validation (the token's hook, routers, PoolManager and IMD must match, and the full supply must be held by the hook). Anything new compared with v3?\nNFT gas. Buying N whole tokens mints N NFTs in one transaction. Is there a buy or sell size, or a sequence, that runs out of gas or traps funds? Is the router path (transferFrom and take with DN404) safe?\nThe _skipNFTDefault override: the EIP-7702 check (code.length == 23 && bytes3(code) == 0xef0100). Any address type it misclassifies in a harmful way?\nRenderer. tokenURI gas is about 2.8M typical and about 9M maximum. There's no user input, so no injection. Please confirm the Base64 assembly is memory-safe.\nKnown and accepted (no need to report unless you see more impact)\n\nPartial fill: the hook charges 4% of the requested amount when a third-party swap with a tight price limit only partly fills (same as v3, medium finding). Our routers always fill fully.\nFirst-buy rebate: the first buyer's own 3% waits in the token and goes to whoever holds at the next distribution, which can be that buyer (same as v3, info).\nOptional royalties: marketplace royalties depend on the marketplace; only pool trades are guaranteed to pay 4%.\nNo editable OpenSea collection page: owner() is zero on the token and mirror, so nobody can claim the collection page on OpenSea. This is intentional.\nPlanned deployment parameters\n\nPoolManager 0x8366a39CC670B4001A1121B8F6A443A643e40951\nIMD 0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127\nIMD/ETH pool: fee 10000, tick spacing 100, no hooks\nStarting market cap: about 2,000 IMD (1 IMD per NFT)\n$Pepes 0xE2C46c7068566740A33A4C93f5445B07BCfE5644\nPepesFamily v1 router 0xA73604EA3C393B47573986ff9Ce5A9EAb61883dC\nFee recipient and owner: 0x3c8A4d94B3219F6633F2cC94094f4765b30c691C\nWhat we'd like back\n\nA plain-language answer: Can anyone, including the owner, take holders' NFTs, tokens or rewards? Can the expiry ever take rewards earned in the last 30 days?\nAll findings with severity, a reproduction and a suggested fix.\nAnything that should change before deployment, since the contracts can't be changed afterwards.","parentJobId":null,"planHash":"d15228a27904ce99f3f51095ed85bfab2834903d663721fab0fdcf938c524859","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"e6eda4d8-f50d-47cd-9464-9a272283ccd3","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":"51023","feedbackHash":"87fdba090a487b51b628fe4cbc2db2a8f112431c759fc7ab9ac8979e1289569c","nodeKey":"audit_economics","submissionHash":"00f999777a6f5c5f2fe5e83d7bee589b48260abe93d37bea0b884f6903597287","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51018","feedbackHash":"fa451be9ecde1c9ca288abdd68e1d4a437b66b4bec611e1c8d5ce66f38c198bd","nodeKey":"audit_flow","submissionHash":"86f759b58dbd93417b9694424ed1b3b15005dfc6f01825cd0c1c93b90f784bf3","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51504","feedbackHash":"5231a6671a659b36e51631039ea4c07dd01835375bba1d01a048678c663e992c","nodeKey":"audit_judge","submissionHash":"2f104a270c5ce2a6c58113d932ea9285ae533d519cd891949f31184b82609996","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50957","feedbackHash":"42b3475f945c94f352a8dbee3b913eaa64b373bb93596aa652b0d7c0fd091364","nodeKey":"audit_math","submissionHash":"5bf562f9e8a4958db68136b43db8bb3823a8d1ee789765917fc1ca06314dca48","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50939","feedbackHash":"5c01dec21b456e097faf34e45529e340cce0956f4afe3779c2ca11672364bfbd","nodeKey":"audit_permissions","submissionHash":"7271bd2428d32750f0721b8bda912884722be77b5ba140b38c13e9fc43b6e405","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"e7e60080cadc6240b58c1bd544e6aab07ef5cd46c1fbb0cecb9039e531ce8c31","state":"completed","submissions":[{"artifacts":[],"attempt":2,"bundleHash":null,"device":"ca080fd306399669","findings":[{"citation":"resolved","description":"convertRoyalties() swaps the hook's whole ETH balance on the IMD/ETH pool in one swap with price limit MIN_SQRT_PRICE+1, and the only price protection is minImdOut, which the caller (the owner) picks. The stated trust model says the owner may 'convert royalties, choosing the minimum output' but 'must never be able to take ... royalty IMD meant for holders'. Those two statements conflict: with minImdOut = 0 the owner can, in one transaction (owner = a contract, or back-to-back transactions on a FIFO sequencer), (1) buy IMD with ETH on the same IMD/ETH pool to push the IMD price up, (2) call convertRoyalties(0) so the royalty ETH buys IMD at the inflated price, (3) sell the IMD from step 1 back into the pool, now deepened by the royalty ETH. The royalty ETH ends up with the owner; holders receive 3/4 of a fraction of the fair IMD amount. The only cost is the IMD/ETH pool's 1% LP fee on legs 1 and 3, so it pays whenever the accumulated royalty ETH exceeds about 1% of the pool's virtual ETH depth (about 72 ETH virtual at the pinned fork state, i.e. roughly 0.7 ETH of royalties), and the owner alone decides how long royalties accumulate. If the owner is also an LP in that pool the fee comes back and the threshold falls further. Who loses: $EARN holders (3/4 of the royalty) and feeRecipient (1/4). Who gains: the owner. Fix that keeps the design (owner times the conversion, output only to feeRecipient and holders): bound what one call can lose to price manipulation instead of trusting the caller's minimum. For example cap ethIn per call to a small fraction of the pool's virtual ETH reserve read inside the unlock (getLiquidity * 2^96 / sqrtPriceX96; at 0.5% with a 1% fee pool a sandwich captures at most half of what it pays in fees for any amount of manipulation) and add a minimum interval between calls; larger balances then convert over several calls. Trade-off: slower conversion and a parameter fixed at deployment. Alternatively make conversion permissionless in such capped chunks, which also removes the dependency on the owner being alive.","line":370,"path":"contracts/src/earn/PepesEarnIMD.sol","reproduction":"Local (repo's unit-test setup: IMD/ETH pool fee 10000, tick spacing 100, price 1:1, liquidity 10,000e18 full range; alice holds $EARN): vm.deal(hook, 500 ether). As owner in one transaction: PoolSwapTest.swap{value: 7000 ether}(imdEthKey, SwapParams(true, -7000 ether, MIN_SQRT_PRICE+1)) ; hook.convertRoyalties(0) ; swap back all IMD received in the first step (SwapParams(false, -got, MAX_SQRT_PRICE-1)). Expected: holders are credited about 3/4 of the fair ~471 IMD (~353 IMD) and the owner gains nothing. Actual: convertRoyalties returned 167.79 IMD (holders 125.8, feeRecipient 41.9) and the owner finished with +211.82 ETH and an unchanged IMD balance. Fork of Robinhood Chain at the planned addresses (real PoolManager, IMD and IMD/ETH pool): vm.deal(hook, 5 ether). Honest convertRoyalties(0) returns 1,925.75 IMD. With a 60 ETH front-run and the matching back-run around convertRoyalties(0) it returns 521.02 IMD (holders 390.76 instead of 1,444.31) and the owner nets +2.66 ETH out of the 5 ETH of royalties.","severity":"medium","snippet":"        if (imdOut < minImdOut) revert Slippage();","title":"Owner can take most of the holders' royalty ETH by sandwiching convertRoyalties (minImdOut is chosen by the owner, nothing bounds the execution price)"},{"citation":"resolved","description":"buybackAndBurnPepes() spends up to the whole reserve in a single v1-router buy whose only price protection is minPepesOut, supplied by the owner. The requirement is that the owner 'must never be able to take ... the buyback reserve'. With minPepesOut = 0 the owner buys $Pepes through the same v1 router first, runs the buyback at the inflated price, then sells the $Pepes back: the reserve's IMD stays in the Pepes/IMD pool and is withdrawn by the owner's sell, and far fewer $Pepes are burned. The cost is the v1 hook's 4% on each of the owner's two legs, of which 1% goes to the v1 feeRecipient, which at the planned parameters is the same address as the PepesEarnIMD owner (0x3c8A...691C), and 3% to $Pepes holders, among whom the $Pepes creator is again that address. It pays once the reserve is more than a few percent of the Pepes pool's virtual IMD depth (about 4,020 IMD on the fork), and the owner alone decides how large the reserve grows before a buyback. Who loses: the burn (expired holder rewards are meant to be burned as $Pepes). Fix preserving the design: cap imdIn per call to a small fraction of the Pepes pool's virtual IMD reserve (below the 8% round-trip fee the sandwich pays, e.g. 2%) with a minimum interval between buybacks, so a sandwich always costs more than it captures; the owner still times buybacks and sets minPepesOut. Trade-off: large reserves take several calls.","line":298,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"Fork of Robinhood Chain with the planned $Pepes (0xE2C4...5644) and v1 router (0xA736...83dC); buybackReserve = 500 IMD (500 IMD held by the token and buybackReserve set to 500e18). Honest: buybackAndBurnPepes(500e18, 0, now) burns 17,023,022 $Pepes. Owner sandwich in one transaction: v1Router.buy(PEPES, 3000e18, 0, now) ; earn.buybackAndBurnPepes(500e18, 0, now) ; v1Router.sell(PEPES, <all bought>, 0, now). Expected: about 17.0M $Pepes burned and no gain for the owner. Actual: 6,047,619 $Pepes burned (64% fewer); the owner's IMD balance ends +73.60 IMD higher than it started, after paying all fees, and v1 pad.collectProtocolFees(IMD) then pays a further 91.59 IMD to the v1 feeRecipient, which is the same address.","severity":"medium","snippet":"        IPepesRouter(pepesRouter).buy(pepes, imdIn, minPepesOut, deadline);","title":"Owner can extract the buyback reserve by sandwiching buybackAndBurnPepes (minPepesOut chosen by the owner)"},{"citation":"resolved","description":"PepesEarnMirror.royaltyInfo names the hook as royalty receiver for every sale, whatever the sale currency. Marketplaces pay ERC-2981 royalties in the currency of the sale: listings in native ETH, but accepted offers and collection bids in an ERC-20 (WETH on Seaport-style marketplaces; any marketplace that quotes in IMD would pay IMD). convertRoyalties only reads address(this).balance and swaps native ETH; the hook has no function that moves, unwraps or forwards an ERC-20 it holds (its IMD flows go through PoolManager claims, not its own token balance). Each step is correct in isolation, but the end state contradicts the stated purpose ('royalty ... converts it to IMD and splits it 1% / 3%'): the holders' 3% and the protocol's 1% of every offer-side sale are locked permanently, and the contracts cannot be changed after deployment. Fix: fix the chain's WETH address at deployment and have convertRoyalties unwrap the hook's WETH balance (IWETH.withdraw) before reading address(this).balance; and send any IMD the hook holds directly through the same 1/4 : 3/4 split (transfer to feeRecipient and to the token, then distribute()). Both keep the rule that royalty value can only reach feeRecipient and holders.","line":367,"path":"contracts/src/earn/PepesEarnIMD.sol","reproduction":"State: a buyer's WETH offer of 1 WETH for an $EARN NFT is accepted on a marketplace that honours ERC-2981; the marketplace calls mirror.royaltyInfo(id, 1e18) -> (hook, 0.04e18) and transfers 0.04 WETH to the hook. Equivalent direct input: WETH.transfer(address(hook), 0.04e18) or IMD.transfer(address(hook), 10e18). Then owner calls hook.convertRoyalties(0). Expected: the royalty is converted and 3/4 reaches holders. Actual: convertRoyalties returns 0 when the hook holds no native ETH ('if (ethIn == 0 ...) return 0') and otherwise converts only the native ETH; WETH.balanceOf(hook) (or IMD.balanceOf(hook)) stays unchanged after every external function of PepesEarnIMD (openPool, flush, collectProtocolFees, convertRoyalties, setFeeRecipient, transferOwnership, acceptOwnership, hook callbacks) - none transfers an ERC-20 held by the hook other than $EARN during openPool.","severity":"medium","snippet":"        uint256 ethIn = address(this).balance;","title":"Royalties paid in an ERC-20 (WETH for accepted offers, or IMD) are stuck in the hook forever; only native ETH can be converted"},{"citation":"resolved","description":"The brief accepts that sending a holder dust resets the recipient's timer. The reach is wider than that: _moved also marks `from` active, and DN404 transferFrom(from, to, 0) needs no allowance (0 <= 0), so a third party can reset any holder's lastActive without owning or spending any $EARN. One keeper calling transferFrom(holder_i, x, 0) for every holder once per 30 days keeps expiredRewardsOf at zero for everyone, so the buyback reserve never fills. It never takes rewards from a holder (it only delays expiry), so this is informational, but the 'unclaimed rewards expire' rule is then only as strong as nobody bothering to do this. Fix if expiry is meant to be enforceable: update lastActive only when amount != 0 and only for the party that initiated the movement (the sender on transfers, the caller on claim), not for the recipient or for a `from` moved by a third party with a zero amount.","line":190,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"alice and bob each buy with 100 IMD through the router; warp 31 days; earn.expiredRewardsOf(alice) = 5.999999999999999999 IMD. bob (no allowance from alice) calls earn.transferFrom(alice, bob, 0). Expected: alice's expired rewards are still recyclable. Actual: the call succeeds, lastActive[alice] = block.timestamp, expiredRewardsOf(alice) = 0 and recycle(alice) returns 0 for the next 30 days.","severity":"info","snippet":"            lastActive[from] = block.timestamp;","title":"Anyone can reset any holder's 30-day timer for free with a zero-amount transferFrom, so expiry can be suppressed for the whole collection at gas cost only"},{"citation":"resolved","description":"openPool adds TOTAL_SUPPLY - 1e9 wei (less rounding) as liquidity and sends the remainder to 0x...dEaD. The pool therefore holds strictly less than 2,000e18 $EARN, and the last tokens sit at the far end of a range that runs to the maximum usable tick, so the circulating supply outside the pool can never reach 2,000 whole tokens: at most 1,999 NFTs can be minted, not the 2,000 the collection is described as having. No funds are at risk; it matters only because the supply description and any art/rarity statements that count 2,000 pieces cannot be changed after deployment. Fix if exactly 2,000 NFTs must be reachable: none that keeps single-sided full-supply liquidity (the buffer is needed); otherwise describe the collection as up to 1,999 NFTs.","line":253,"path":"contracts/src/earn/PepesEarnIMD.sol","reproduction":"After hook.openPool(token): earn.balanceOf(poolManager) = 2000e18 - d with d >= 1e9 wei and earn.balanceOf(0x...dEaD) = d (test_openPool_locksWholeSupplyInPool shows the split). Expected by the description: 2,000 NFTs obtainable. Actual: sum of all balances outside the pool and the burn address <= 2000e18 - d < 2000e18, so floor(total / 1e18) <= 1,999 NFTs across all wallets for any sequence of buys; a single router.buy with 2,000 IMD at the planned start price yields 979 tokens (11.9M gas), and each further token costs more than the last.","severity":"info","snippet":"        uint256 amount = TOTAL_SUPPLY - LIQUIDITY_BUFFER;","title":"At most 1,999 NFTs can ever exist: the liquidity buffer and rounding dust are sent to the burn address, so the 2,000th whole token cannot be assembled"}],"hash":"00f999777a6f5c5f2fe5e83d7bee589b48260abe93d37bea0b884f6903597287","nodeId":"bba3aa62-21be-4e12-90d0-2cfc0ec3be4e","outcome":"completed","summary":"The review is finished: `.imd-findings.json` holds five findings (three medium, two info), and no repository file was changed.\n\n**Plain-language answers**\n- **Can anyone take holders' NFTs, tokens or rewards?** No path lets anyone, including the owner, take holders' tokens or NFTs, pool liquidity, or rewards already credited. The owner can, however, capture royalty ETH before it becomes holder rewards, and can extract the buyback reserve (findings 1 and 2).\n- **Can expiry take rewards earned in the last 30 days?** No. `recycle` only moves rewards older than the cutoff, rounding is in the holder's favour, and I found no way to change a balance without updating `lastActive`. The only edge is a distribution in the exact second of the cutoff, which counts as old.\n\n**Findings**\n1. **Medium — owner can sandwich `convertRoyalties`.** The owner picks `minImdOut`, so with 0 they can push the IMD/ETH price, convert, and sell back. On a fork with 5 ETH of royalties, holders got 390.76 IMD instead of 1,444.31 and the owner netted 2.66 ETH.\n2. **Medium — owner can sandwich `buybackAndBurnPepes`.** Same mechanism through the v1 router. On a fork with a 500 IMD reserve, 64% fewer $Pepes were burned and the owner netted 73.6 IMD, plus 91.6 IMD collected by the v1 fee recipient, which is the same address.\n3. **Medium — ERC-20 royalties are stuck forever.** The hook only converts native ETH, and no function moves WETH or IMD it holds. This was established by reading every external function, not by a test, and its weight depends on marketplaces paying offer-side royalties in WETH.\n4. **Info — anyone can reset any holder's timer for free.** `transferFrom(holder, x, 0)` needs no allowance and marks the holder active, so one keeper can suppress expiry for the whole collection. It only delays expiry.\n5. **Info — at most 1,999 NFTs can ever exist.** The liquidity buffer and rounding dust go to the burn address, so the 2,000th whole token cannot be assembled.\n\nFor 1 and 2 the suggested fix is a per-call size cap relative to pool depth plus a minimum interval, which keeps the owner's timing role; for 3, unwrap WETH and forward held IMD through the same 1/4 : 3/4 split. All three need to change before deployment.\n\n**Checked and found sound**\n- **Reward accounting:** a 400-run randomized sequence test held `eligibleSupply` equal to the sum of eligible balances, rewards owed within `accountedBalance`, and the IMD balance at least `accountedBalance + buybackReserve`. It covered buys, sells, token and NFT transfers (including to excluded addresses), claims, recycling, donations, buybacks and `setSkipNFT`.\n- **Flash-borrow guard:** no route lets a borrower hold pool $EARN while a distribution runs, including inside `convertRoyalties` and both routers.\n- **Buyback path on the fork:** $Pepes is IMD-quoted on the v1 router, the reserve is spent exactly, and nothing is left stranded.\n- **NFT gas:** buying 979 tokens in one transaction cost 11.9M gas and selling them 4.7M; a failed oversized buy reverts and traps nothing.\n- **Checkpoints:** overflow is unreachable given IMD's 46,584-token supply, and lookup cost grows only logarithmically.\n\n**Limits**\n- The buyback reserve in the fork run was set by writing storage rather than accumulated through trading.\n- I did not review the renderer or Base64 assembly, and looked at the `_skipNFTDefault` classification only for effects on rewards.\n- Royalty conversion and buybacks only happen when the owner calls them, so a lost owner key strands both. That is a trust assumption, not a finding.\n- The probe tests were deleted along with `test/scratch/`, so the figures above are not re-runnable from the tree.","treeHash":null,"usage":{"cachedInputTokens":1807999,"inputTokens":34,"model":"claude-fable-5-1","outputTokens":51103,"runtime":"claude","turns":20,"wallClockMs":927654}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"720122d0ca9f60ca","findings":[],"hash":"06c92ae78eb20c3b789e87717c53872d49eadcf15de7f0618b15bfea975c68bf","nodeId":"643d9262-011b-4e3b-9aba-241be4dcc538","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":3,"wallClockMs":584353}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"0238a59bba722237","findings":[{"citation":"resolved","description":"convertRoyalties() sells the hook's whole ETH balance on the IMD/ETH pool in one swap with price limit MIN_SQRT_PRICE+1. The only price protection is minImdOut, chosen by the caller, and the caller is the owner. The brief says the owner 'must never be able to take ... royalty IMD meant for holders'; with minImdOut = 0 the owner can, in one transaction (owner is a contract, or batches calls): (1) buy IMD with ETH on the same pool, pushing the IMD price up, (2) call convertRoyalties(0) so the royalty ETH buys IMD at the inflated price, (3) sell the IMD from step 1 back into the pool that now holds the royalty ETH. Holders (3/4) and feeRecipient (1/4) receive a fraction of the fair IMD and the owner keeps the difference minus the 1% pool fee on its two legs. It pays once the accumulated royalty ETH exceeds roughly 1-2% of the pool's ETH depth, and the owner alone decides how long royalties accumulate. A third party cannot do this if the owner sets a tight minimum; this is an owner power that contradicts the stated trust model, and it cannot be removed after deployment. Merged from three specialist reports (economics, flow, permissions). Fix that keeps the design (owner times the conversion, output only to feeRecipient and holders): cap the ETH converted per call to a small fraction of the IMD/ETH pool's virtual ETH reserve read inside the unlock (e.g. 0.5%, so a sandwich pays more in pool fees than it can capture) and enforce a minimum interval between calls; larger balances convert over several calls. With that bound the function can also be permissionless, which removes the dependency on a live owner. Otherwise, state plainly in the docs that the owner is trusted for fair execution of this swap.","line":370,"path":"contracts/src/earn/PepesEarnIMD.sol","reproduction":"Reproduced in the repository's unit-test setup (contracts/test/PepesEarn.t.sol: IMD/ETH pool fee 10000, tick spacing 100, price 1:1, full-range liquidity 10,000e18). alice buys $EARN with 100 IMD and bob with 10 IMD; vm.deal(hook, 500 ether). Baseline: owner calls hook.convertRoyalties(0) -> returns 471.653168175321581705 IMD. Attack, all as owner: PoolSwapTest.swap{value: 7000 ether}(imdEthKey, SwapParams(true, -7000 ether, MIN_SQRT_PRICE+1)); hook.convertRoyalties(0); PoolSwapTest.swap(imdEthKey, SwapParams(false, -int256(imdReceivedInFirstSwap), MAX_SQRT_PRICE-1)). Expected (trust model): about 471.65 IMD is split between feeRecipient and holders and the owner gains nothing. Actual: convertRoyalties returns 167.793624011776061612 IMD (holders get 3/4 of that, about 125.8 instead of about 353.7), the owner's IMD balance is unchanged and its ETH balance goes from 7000 to 7211.823556036828024221, i.e. the owner nets about 211.8 ETH of the 500 ETH of royalties.","severity":"medium","snippet":"if (imdOut < minImdOut) revert Slippage();","title":"Owner can take most of the holders' royalty ETH by sandwiching its own convertRoyalties call (the only price bound is a minimum the owner picks)"},{"citation":"resolved","description":"buybackAndBurnPepes() spends up to the whole reserve in one v1-router buy on the thin $Pepes/IMD curve. The only price protection is minPepesOut, supplied by the owner. The brief says the owner must never be able to take the buyback reserve and that it 'can only be spent buying $Pepes and burning it'. The owner can buy $Pepes through the same v1 router first, run the buyback with minPepesOut = 0 at the inflated price, then sell its $Pepes into the price the reserve just pushed up: most of the reserve's IMD leaves the pool in the owner's sell and far fewer $Pepes are burned. The cost is the v1 hook's 4% on each of the owner's two legs (of which 1% goes to the v1 fee recipient, per the brief the same address as this owner), so it pays once the reserve exceeds a few percent of the $Pepes pool's IMD depth; the owner decides how large the reserve grows before it buys. Who loses: the burn (expired holder rewards). Not exploitable by third parties when the owner sets a tight minimum. Merged from three specialist reports. Fix that keeps the design: cap imdIn per call to a small fraction of the $Pepes pool's IMD depth (well under the 8% round-trip fee, e.g. 2%) plus a minimum interval between buybacks, so a sandwich always costs more than it captures; the owner still times buybacks and sets minPepesOut, and with the cap the call can be permissionless. Trade-off: large reserves take several calls.","line":298,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"Reproduced on a Robinhood Chain fork (chain id 4663, FORK_RPC=https://robinhood.drpc.org) using the setup of contracts/test/PepesEarn.fork.t.sol (real PoolManager, IMD, $Pepes 0xE2C4...5644 and v1 router 0xA736...83dC; the test contract is the owner). alice and bob each router.buy(earn, 8_000e18, 0, now); warp 31 days; recycle(alice); recycle(bob) -> buybackReserve = 479.999999999999999999 IMD. Baseline: earn.buybackAndBurnPepes(reserve, 0, now) burns 16,903,096.41 $Pepes. Attack, as owner in one transaction starting with 3,000 IMD: v1Router.buy(PEPES, 3_000e18, 0, now); earn.buybackAndBurnPepes(reserve, 0, now); v1Router.sell(PEPES, <all bought in step 1>, 0, now). Expected: about 16.9M $Pepes burned and no gain for the owner. Actual: 5,923,531.63 $Pepes burned (65% fewer) and the owner's IMD balance ends at 3,063.043006514956115223, i.e. +63.04 IMD after all fees (before counting the v1 protocol fee that also goes to the same address).","severity":"medium","snippet":"IPepesRouter(pepesRouter).buy(pepes, imdIn, minPepesOut, deadline);","title":"Owner can redirect part of the buyback reserve to itself by sandwiching buybackAndBurnPepes (size, timing and minPepesOut are all owner-chosen)"},{"citation":"resolved","description":"PepesEarnMirror.royaltyInfo names PepesEarnIMD as royalty receiver for every sale, whatever the sale currency, and marketplaces pay ERC-2981 royalties in the currency of the sale: native ETH for listings, but WETH (or another ERC-20, e.g. IMD) for accepted offers and collection bids. convertRoyalties only reads address(this).balance and swaps native ETH. No function of PepesEarnIMD moves an ERC-20 the hook holds: its own fee flows are ERC-6909 claims inside the PoolManager (collectProtocolFees and flush burn claims), and there is no unwrap or sweep. The holders' 3% and the protocol's 1% of every offer-side sale are therefore locked permanently, and the contract cannot be changed after deployment. Merged from three specialist reports (two rated it medium, one low; kept at medium because the loss is permanent and the path is an ordinary marketplace flow). Fix that keeps the rule 'royalty value only reaches feeRecipient and holders': take the chain's WETH address as a constructor immutable and have convertRoyalties call WETH.withdraw(balance) before reading address(this).balance; and split any IMD the hook itself holds 1/4 to feeRecipient and 3/4 to the token followed by distribute() (outside an unlock, or inside the hook's own). Note on the specialist's attached test: it fails for the stated reason, but its second assertion (feeRecipient +1 IMD) would not hold after a correct fix because collectProtocolFees in the same test also pays 1 IMD of trade fees, so it is not attached here.","line":367,"path":"contracts/src/earn/PepesEarnIMD.sol","reproduction":"Unit-test setup, pool opened, alice bought $EARN with 100 IMD. mirror.royaltyInfo(1, 100e18) returns (hook, 4e18). A marketplace pays the royalty in the sale currency: imd.transfer(hook, 4e18) (same for 0.04 WETH on a 1 WETH offer). Then owner calls hook.convertRoyalties(0) and anyone calls hook.collectProtocolFees(imd). Expected: 1 IMD to feeRecipient, 3 IMD credited to holders, hook balance 0. Actual: convertRoyalties returns 0 (ethIn == 0), collectProtocolFees only pays pending trade fees, imd.balanceOf(hook) stays 4000000000000000000. Ran the specialist's test (RoyaltyTokenStuckProof): it fails with 'royalty IMD is stuck in the hook: 4000000000000000000 != 0'.","severity":"medium","snippet":"uint256 ethIn = address(this).balance;","title":"Royalties paid in an ERC-20 (WETH for accepted offers and bids, or IMD) are stuck in the hook forever: only native ETH can be converted"},{"citation":"resolved","description":"Answer to 'can expiry take rewards earned in the last 30 days': only at this boundary. expiredRewardsOf treats a wallet as inactive from block.timestamp == lastActive + 30 days, and magAt(t) returns the per-share value after every distribution with time <= t (a second's checkpoint holds the value after the LAST distribution of that second). So a distribution in second t is treated as expired at second t + 30 days, and at lastActive + 30 days every distribution that happened in the same second as the last activity, after it, is recycled although the holder was never inactive for longer than 30 days with respect to it. Robinhood Chain produces several blocks per second, so a buy or claim followed by another trade's distribution in the same second is the normal case. One second earlier nothing is recyclable; the holder loses the reward if a recycler calls in exactly that window or later without the holder acting. Otherwise I found no path that recycles rewards younger than 30 days: every balance change (ERC-20 _transfer and mirror _transferFromNFT, including transfers to excluded addresses) goes through _moved and updates lastActive, and 'recent' rounds up. Fix: make the boundary exclusive on the holder's side, e.g. cut at magAt(block.timestamp - INACTIVITY_PERIOD - 1) and require block.timestamp > last + INACTIVITY_PERIOD, so a distribution in the cutoff second counts as recent. No repository test covers the exact boundary.","line":261,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"Unit-test setup at timestamp T (use vm.getBlockTimestamp(); with via_ir a cached block.timestamp gives wrong warps). Case 1: alice router.buy 100 IMD at T, bob router.buy 100 IMD in the same second (alice is credited 5.999999999999999999 IMD). vm.warp(T + 30 days - 1): expiredRewardsOf(alice) == 0. vm.warp(T + 30 days): expiredRewardsOf(alice) == 5999999999999999999 == withdrawableDividendOf(alice); carol calls recycle(alice) and alice's withdrawable becomes 0. Case 2: alice buys at T, bob buys at T + 1 day (alice earns 5.999999999999999999 IMD at T + 1 day). At T + 31 days - 1: expired == 0. At T + 31 days (the reward is exactly 30 days old): expired == 5999999999999999999. Expected: rewards no older than 30 days stay claimable. Actual: they move to buybackReserve.","severity":"low","snippet":"uint256 magCut = magAt(block.timestamp - INACTIVITY_PERIOD);","title":"Expiry boundary is inclusive: at exactly 30 days, rewards earned exactly 30 days ago (including those earned after the holder's last activity in the same second) are recycled"},{"citation":"resolved","description":"The constructor comment says only the deploying account can link the mirror. DN404Mirror does not enforce that: its linkMirrorContract(address) fallback branch compares the calldata argument with the stored deployer and then sets baseERC20 = msg.sender. Anyone who passes the deployer's public address becomes the mirror's base token. The earn contracts have no deploy script or factory and the tests deploy mirror and token as two steps; if those are two transactions, an observer can link in between. The real PepesEarnToken constructor then reverts with LinkMirrorContractFailed and that mirror is permanently bound to the attacker's contract. No holder funds are at risk (nothing is live yet) and a correctly linked collection is unaffected; the cost is a failed launch and a retry that can be front-run again. Merged from two specialist reports. Fix: deploy mirror and token atomically in one transaction from a small deployer contract (it is msg.sender for both, so DN404's check still passes), or give the mirror the precomputed token address and require msg.sender == that address before linking.","line":15,"path":"contracts/src/earn/PepesEarnMirror.sol","reproduction":"Deployer D deploys m = new PepesEarnMirror(hook). Attacker contract H calls address(m).call(abi.encodeWithSelector(0x0f4599e5, D)). Expected (per the constructor comment): the call is rejected. Actual: it succeeds and m.baseERC20() == H; D's following new PepesEarnToken(hook, address(m), renderer, pepes, pepesRouter) reverts. Reproduced with a Foundry test on this commit in the unit-test setup (link succeeds, baseERC20 == hijacker, token constructor reverts).","severity":"low","snippet":"constructor(address hook) DN404Mirror(msg.sender) {","title":"Mirror can be linked by anyone before the token is deployed (the deployer check compares a caller-supplied argument), blocking the launch deployment"},{"citation":"resolved","description":"Trust assumption to document, not a post-launch owner power. openPool checks hook, routers, PoolManager, IMD, balance and total supply. The guarantee 'the reserve can only buy $Pepes and burn it' rests on the pepes and pepesRouter immutables of the token the owner passes in, and on that token being PepesEarnToken bytecode; none of this is checked on-chain. A token deployed with pepesRouter set to an owner-controlled contract passes openPool, and buybackAndBurnPepes then approves the reserve to that contract. After openPool nothing can change, so holders must verify these two addresses and the verified source before trading. Fix before deployment: make the $Pepes and v1 router addresses immutables of PepesEarnIMD and add t.pepes() != PEPES || t.pepesRouter() != PEPES_ROUTER to the BadToken check (or have the hook deploy the token itself), and publish 0xE2C46c7068566740A33A4C93f5445B07BCfE5644 / 0xA73604EA3C393B47573986ff9Ce5A9EAb61883dC for holders to compare.","line":210,"path":"contracts/src/earn/PepesEarnIMD.sol","reproduction":"The repository's own setUp shows it: contracts/test/PepesEarn.t.sol deploys PepesEarnToken(hook, mirror, renderer, MockPepes, MockPepesRouter), where MockPepesRouter.buy does imd.transferFrom(msg.sender, address(this), amountIn) and mints mock tokens, and hook.openPool(earn) succeeds. test_buyback_burnsPepesWithReserveOnly then moves the whole reserve's IMD to that arbitrary router contract. Expected for holders: openPool only accepts a token whose buyback goes to $Pepes 0xE2C4...5644 through the v1 router 0xA736...83dC. Actual: any pepes / pepesRouter pair is accepted.","severity":"info","snippet":"t.hook() != address(this) || t.router() != router || t.ethRouter() != ethRouter","title":"openPool does not check the token's buyback targets (pepes, pepesRouter), mirror, renderer or bytecode: the reserve's only exit is whatever the owner-chosen token hard-codes"},{"citation":"resolved","description":"Trust assumption. buybackReserve leaves only through buybackAndBurnPepes (owner of PepesEarnIMD) and royalty ETH only through convertRoyalties (onlyOwner). The contracts are immutable and only the current owner can start an ownership transfer, so a lost or inactive owner freezes every expired reward and every marketplace royalty. Holders' own claimable rewards are not affected. If the two conversions are bounded per call as suggested in the two sandwich findings, they can be made callable by anyone (optionally only after N days without an owner call), which removes this dependency without giving anyone a new destination for the funds.","line":293,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"State: buybackReserve > 0 after a recycle and 1 ETH of royalties in the hook; the owner never calls. Any other address calls earn.buybackAndBurnPepes(reserve, 0, now) -> reverts NotOwner(); hook.convertRoyalties(0) -> reverts NotOwner() (both asserted by the repository tests test_buyback_burnsPepesWithReserveOnly and test_royalties_convertedToImdAndSplit). Expected by the design intent: expired rewards are eventually burned as $Pepes and royalties reach holders. Actual: no caller other than the owner can ever move them.","severity":"info","snippet":"if (msg.sender != IEarnHook(hook).owner()) revert NotOwner();","title":"Buyback reserve and royalty ETH have no permissionless exit: both stay locked if the owner key is lost or the owner stops acting"},{"citation":"resolved","description":"The brief accepts that sending dust resets the recipient's timer. The reach is wider: _moved also marks `from` active, and DN404's transferFrom(from, to, 0) needs no allowance, so a third party can reset any holder's lastActive without holding or spending $EARN. A keeper calling it for every holder once per 30 days keeps expiredRewardsOf at zero for everyone, and the buyback reserve never fills. It never takes anything from a holder (it only delays expiry), hence informational. Fix if expiry should be enforceable: in _moved skip the lastActive updates when amount == 0, and consider marking only the initiating side active (the sender on transfers, the caller on claim).","line":190,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"Unit-test setup: alice and bob each router.buy 100 IMD; warp 31 days; expiredRewardsOf(alice) == 5999999999999999999. dan, who has no allowance from alice and holds no $EARN, calls earn.transferFrom(alice, dan, 0). Expected: alice's expired rewards remain recyclable. Actual: the call succeeds, lastActive[alice] == block.timestamp, expiredRewardsOf(alice) == 0 and recycle(alice) returns 0.","severity":"info","snippet":"lastActive[from] = block.timestamp;","title":"Anyone can reset any holder's 30-day timer for free with a zero-amount transferFrom, so expiry can be suppressed for all holders at gas cost only"},{"citation":"resolved","description":"openPool adds TOTAL_SUPPLY - 1e9 wei (less rounding) as liquidity and line 267 sends the remainder to 0x...dEaD. The pool therefore holds strictly less than 2,000e18 $EARN, and the last tokens sit at the far end of a range that runs to the maximum usable tick, so the supply outside the pool can never reach 2,000 whole tokens. No funds are at risk; it matters because the on-chain description ('2,000 on-chain Pepes' in metadata()) and any rarity statement that counts 2,000 pieces cannot be changed after deployment. Keeping single-sided full-supply liquidity needs the buffer, so the practical fix is wording: 'up to 1,999 NFTs'.","line":253,"path":"contracts/src/earn/PepesEarnIMD.sol","reproduction":"After hook.openPool(token): earn.balanceOf(0x...dEaD) = d >= 1e9 wei and earn.balanceOf(poolManager) = 2000e18 - d (test_openPool_locksWholeSupplyInPool asserts the pool balance is within 1e9 of the supply and the hook keeps 0). For any sequence of buys, the sum of balances outside the pool and the burn address is <= 2000e18 - d < 2000e18, so floor(sum / 1e18) <= 1,999 NFTs. Expected by the description: 2,000 NFTs obtainable. Actual: at most 1,999.","severity":"info","snippet":"uint256 amount = TOTAL_SUPPLY - LIQUIDITY_BUFFER;","title":"At most 1,999 NFTs can ever exist: the liquidity buffer and rounding dust go to the burn address, so the 2,000th whole token cannot be assembled"},{"citation":"resolved","description":"The routers are reused unchanged. PermitHelper.permit calls token.permit(...); PepesEarnToken inherits DN404, which has no permit, so the call reaches DN404's fallback and reverts, and the trade proceeds only if an allowance already exists. No funds are at risk; a front end that offers the gasless-approval sell flow for $EARN will produce reverting transactions. Fix: disable the permit path for this collection in the site (or add EIP-2612 to the token before deployment).","line":128,"path":"contracts/src/PepesFamilyRouter.sol","reproduction":"Unit-test setup: alice buys $EARN with 100 IMD and sets earn.approve(router, 0). alice calls router.sellWithPermit(earn, 1e18, 0, block.timestamp, 27, bytes32(1), bytes32(1)). Expected (router docs): the permit is applied and 1 $EARN is sold. Actual: the call reverts (permit selector not recognised by the token, allowance 0). The same sale succeeds only after a separate earn.approve(router, amount) transaction.","severity":"info","snippet":"PermitHelper.permit(token, tokenAmount, deadline, v, r, s);","title":"sellWithPermit / sellForEthWithPermit cannot be used for $EARN: DN404 has no EIP-2612 permit, so the routers only work after a separate approve"}],"hash":"2f104a270c5ce2a6c58113d932ea9285ae533d519cd891949f31184b82609996","nodeId":"5851f1a5-b192-4b69-b39e-a61a8bfc535f","outcome":"completed","summary":"The review is finished: `.imd-findings.json` holds 10 findings (3 medium, 2 low, 5 info), merged from the specialists' 16. No critical or high findings, so none carries a proof. I ran each reproduction in scratch tests that I removed afterwards, except the two noted below, which rest on the repo's own tests and on reading the code. No repository files were changed.\n\n**Plain-language answers**\n- **Can anyone take holders' NFTs, tokens or claimable rewards?** I found no way, for the owner or anyone else. Every balance change, on both the ERC-20 and NFT sides, goes through `_moved`, and only the holder can claim.\n- **Can the owner take value meant for holders?** Yes, in two places, by sandwiching its own calls with a minimum output of 0. This contradicts the stated trust model.\n- **Can expiry take rewards earned in the last 30 days?** Only at the exact boundary: a reward earned exactly 30 days ago to the second is recyclable. One second earlier nothing is.\n\n**Findings kept**\n\n| Severity | Finding | Reproduction result |\n|---|---|---|\n| medium | Owner sandwich of `convertRoyalties` | Unit setup, 500 ETH of royalties: holders and fee recipient get 167.8 IMD instead of 471.7; owner nets about 211.8 ETH |\n| medium | Owner sandwich of `buybackAndBurnPepes` | Robinhood Chain fork, 480 IMD reserve: 5.92M $Pepes burned instead of 16.9M; owner nets +63 IMD after fees |\n| medium | ERC-20 royalties (WETH offers, IMD) stuck in the hook forever | 4 IMD sent as royalty stays in the hook; the specialist's test fails for the stated reason |\n| low | Inclusive 30-day expiry boundary | At `lastActive + 30 days` exactly, 5.99 IMD earned in the same second as the last activity is recycled |\n| low | Mirror can be linked by anyone between the two deployment transactions | Link succeeds for a third party and the real token constructor reverts |\n| info | `openPool` does not check `pepes` / `pepesRouter` or the token bytecode | Shown by the repo's own `setUp`, which opens the pool with mock targets |\n| info | No permissionless exit for the reserve or royalty ETH if the owner goes away | Non-owner calls revert `NotOwner` in the repo's tests |\n| info | Zero-amount `transferFrom` resets any holder's timer, no allowance needed | `expiredRewardsOf` drops from 5.99 IMD to 0 |\n| info | At most 1,999 NFTs can exist, because the buffer is burned | Follows from the arithmetic at `openPool`; not run as a test |\n| info | `sellWithPermit` cannot work for $EARN (DN404 has no permit) | Call reverts without a prior approve |\n\n**Dropped or changed from the specialists**\n- The \"test gaps\" finding is dropped as a standalone item; I did not rerun its fuzz claims.\n- The stuck-royalty proof is not attached. It fails correctly today, but its fee-recipient assertion would also fail after a correct fix, because the same test collects 1 IMD of trade fees.\n- The owner-liveness finding is downgraded from low to info, as a trust assumption.\n\n**Checked and found sound** (by reading, plus the repo's tests where they cover it): the Base64 assembly, the flash-borrow guards (mid-unlock `distribute`, `flush`, `convertRoyalties`), the `balance >= accountedBalance + buybackReserve` accounting, checkpoint gas, and the EIP-7702 check. An 8,000 IMD buy went through on the fork.\n\n**Before deployment:** bound the two owner swaps per call (or document the owner as trusted for them), handle WETH and IMD royalties, make the expiry boundary exclusive, deploy mirror and token atomically, and validate `pepes` / `pepesRouter` in `openPool`. Suggested fixes are in each finding.","treeHash":null,"usage":{"cachedInputTokens":1100796,"inputTokens":23,"model":"claude-fable-5-1","outputTokens":32212,"runtime":"claude","turns":15,"wallClockMs":534086}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"0256823ae36e7900","findings":[{"citation":"resolved","description":"The mirror constructor passes the deployer to DN404Mirror, but that does not authenticate the caller of the inherited linkMirrorContract(address) entry point: the inherited fallback compares the caller-supplied argument with the stored deployer, then records msg.sender as baseERC20. The documented deployment order creates PepesEarnMirror before PepesEarnToken. If these are separate transactions, any observer can supply the public deployer address and permanently link the mirror to the observer instead. The legitimate token constructor then reverts with LinkMirrorContractFailed, so this mirror cannot be used for the collection. This is a predeployment integration denial of service; an already correctly linked collection is unaffected and no existing holder assets are stolen. Fix by deploying the mirror and token atomically from the same factory transaction, or by adding authentication against an immutable intended base-token address before allowing the inherited link operation. Merely using the same deployment account or submitting the token transaction immediately afterwards does not close the gap.","line":15,"path":"contracts/src/earn/PepesEarnMirror.sol","reproduction":"Let deployer D = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 and attacker A = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8. D deploys PepesEarnMirror(H), with H the intended earn hook, and has not yet deployed PepesEarnToken. From A call address(mirror).call(abi.encodeWithSignature(\"linkMirrorContract(address)\", D)); equivalently send selector 0x0f4599e5 followed by D padded to 32 bytes. Expected: a caller other than the intended base token cannot establish the link. Actual: the call succeeds and mirror.baseERC20() is A. D now deploys PepesEarnToken(H, mirror, renderer, pepes, pepesRouter) with otherwise valid parameters: deployment reverts with LinkMirrorContractFailed (0xd125259c). Reproduced on local Anvil using the compiled, unchanged mirror/token bytecode and a hook stub returning valid nonzero addresses from its constructor getters; the attacker link succeeded, baseERC20 returned A, and the real token constructor reverted with 0xd125259c. Control deployment using a fresh, un-attacked mirror succeeded.","severity":"low","snippet":"    constructor(address hook) DN404Mirror(msg.sender) {","title":"Separate mirror deployment permits an attacker to permanently hijack its DN404 link"}],"hash":"5bf562f9e8a4958db68136b43db8bb3823a8d1ee789765917fc1ca06314dca48","nodeId":"406bb3db-400d-4119-b074-78126fd03943","outcome":"completed","summary":"Wrote [the findings report](/home/imd-worker/.identitymd/work/e6eda4d8-f50d-47cd-9464-9a272283ccd3/406bb3db-400d-4119-b074-78126fd03943/.imd-findings.json).\n\n**One low-severity finding:** an attacker can hijack an unlinked mirror between deployment transactions, blocking token deployment. Deploy the mirror and token atomically.\n\nNo reproducible unauthorized asset withdrawal or expiry of rewards younger than 30 days was found in the assigned review. Owner-selected swap minimums remain a trust assumption.\n\nValidation passed: all **22 local tests** and the **Robinhood fork lifecycle test**. Production files are unchanged.","treeHash":null,"usage":{"cachedInputTokens":1042432,"inputTokens":79000,"model":"gpt-6-astra","outputTokens":6754,"runtime":"codex","turns":6,"wallClockMs":377137}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"72b617d4b615473a","findings":[{"citation":"resolved","description":"The stated trust model says the owner must never be able to take royalty IMD meant for holders or the buyback reserve. Both owner-only swaps (convertRoyalties here, and PepesEarnToken.buybackAndBurnPepes at PepesEarnToken.sol:288) protect against third-party sandwiches only through a minimum output that the owner itself chooses, and both swap at the spot price of a pool anyone can move. The owner (an EOA batching through a contract, or a contract owner) can therefore move the IMD/ETH pool, call convertRoyalties(0), and move the pool back in the same transaction: the royalty ETH is sold at the manipulated price, holders and feeRecipient receive a fraction of the fair IMD, and the owner's closing swap collects the difference minus the 1% pool fee on its own legs. Nothing in the contract bounds minImdOut against a reference price, so the owner-only gate turns the sandwich protection into an owner privilege. The same pattern applies to buybackAndBurnPepes(imdIn, 1, deadline): the owner pre-buys $Pepes, runs the buyback with minPepesOut = 1, and sells into the price the reserve just pushed up (less profitable there because the $Pepes pool charges 4% per leg, but the reserve is still redirected to the owner rather than burned at fair price). Profitability depends on royalty size relative to IMD/ETH depth: it pays whenever the value lost by the conversion exceeds about 2% of the capital the owner moves.","line":366,"path":"contracts/src/earn/PepesEarnIMD.sol","reproduction":"In contracts/test/PepesEarn.t.sol's setup (IMD/ETH pool fee 1%, full-range liquidity 10_000e18 at 1 ETH = 1 IMD): alice buys 100 IMD and bob 10 IMD of $EARN; 300 ETH of royalties sit in the hook (vm.deal(address(hook), 300 ether)). As owner, in one transaction: (1) swap 8000 ETH -> IMD on the IMD/ETH pool through PoolSwapTest (exact in, limit MIN_SQRT_PRICE+1); (2) hook.convertRoyalties(0); (3) swap all IMD received in step 1 back to ETH. Expected (trust model): holders + feeRecipient receive about 291 IMD (300 ETH at pool price minus 1% fee and impact) and the owner gains nothing. Actual: convertRoyalties returns about 90 IMD (holders get 3/4 of 90 instead of 3/4 of ~291) and the owner's ETH balance goes from 8000 to about 8079, i.e. the owner nets about 79 ETH of the royalties after paying pool fees; the rest of the shortfall goes to the pool's LPs. Suggested fix (keeps the owner-times-it design): make the minimum output not purely owner-chosen, e.g. cap the ETH converted per call to a small constant fraction of pool liquidity and/or check the execution price against a manipulation-resistant reference (a TWAP/observed-price checkpoint stored by the hook from earlier blocks, with a fixed max deviation in bps) in both convertRoyalties and buybackAndBurnPepes; alternatively document explicitly that the owner is trusted for fair execution of these two swaps and drop the 'owner can never take royalty IMD / the reserve' claim.","severity":"medium","snippet":"    function convertRoyalties(uint256 minImdOut) external onlyOwner returns (uint256 imdOut) {","title":"Owner can extract royalty IMD meant for holders (and steer the buyback) by sandwiching its own convertRoyalties / buybackAndBurnPepes call"},{"citation":"resolved","description":"expiredRewardsOf treats a wallet as inactive as soon as block.timestamp == lastActive + 30 days, and magAt(t) includes every checkpoint with time <= t (the checkpoint of a second holds the per-share value after the LAST distribution of that second). At the boundary second T+30d the cutoff equals T, so every distribution that happened in second T counts as 'older than 30 days' even though it was earned after the holder's last activity in that same second and is therefore inside the holder's 30-day window ('except what it earned during those 30 days'). Robinhood Chain is an Arbitrum-style chain with several blocks per second, so a holder's buy/claim followed by another trade's distribution in the same second is the normal case, not an edge case. A recycler calling at exactly lastActive + 30 days moves those rewards to the buyback reserve; one second earlier nothing is recyclable.","line":261,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"Using the unit-test setup at timestamp T: router.buy by alice for 100 IMD (lastActive[alice] = T), then router.buy by bob for 100 IMD in the same second (alice is credited ~6 IMD: bob's 3% plus the first-buy fee that was waiting). vm.warp(T + 30 days - 1): expiredRewardsOf(alice) == 0. vm.warp(T + 30 days): expiredRewardsOf(alice) == 5999999999999999999 == withdrawableDividendOf(alice); carol calls recycle(alice) and alice's withdrawable becomes 0. Expected: rewards earned after alice's last activity and no more than 30 days ago stay claimable (recent > 0 for the distribution at T). Actual: all of it is moved to buybackReserve. Fix: make the boundary exclusive on the holder's side, e.g. cut at magAt(block.timestamp - INACTIVITY_PERIOD - 1) (or use a strict `<` in the binary search for this use) and/or require block.timestamp > last + INACTIVITY_PERIOD, so a distribution in the same second as the cutoff/last activity counts as recent.","severity":"low","snippet":"        uint256 magCut = magAt(block.timestamp - INACTIVITY_PERIOD);","title":"Expiry can recycle rewards earned after the holder's last activity, exactly 30 days ago (inclusive boundary in magAt and the lastActive check)"},{"citation":"resolved","description":"The mirror reports PepesEarnIMD as the ERC-2981 receiver for every sale, in whatever currency the sale settles. Marketplaces settle accepted offers/bids (and collection offers) in WETH or another ERC-20 and pay the royalty in that same token by transferring it to the receiver. PepesEarnIMD only handles native ETH: convertRoyalties reads address(this).balance and there is no function that moves any ERC-20 held by the hook (the hook's own IMD fees are ERC-6909 claims inside the PoolManager, so even IMD sent directly to the hook is unaccounted and unreachable). Because the contract is immutable and has no sweep, the holders' 3% and the protocol's 1% of every offer-based sale are lost permanently.","line":360,"path":"contracts/src/earn/PepesEarnIMD.sol","reproduction":"State: a marketplace executes an accepted WETH offer of 10 WETH for an $EARN NFT and honours ERC-2981: royaltyInfo(id, 10e18) returns (hook, 0.4e18), so it calls WETH.transfer(hook, 0.4e18). Then owner calls convertRoyalties(0): address(hook).balance == 0, so it returns 0; no other function references any ERC-20 balance of the hook. Expected: 0.1 WETH worth of IMD to feeRecipient and 0.3 WETH worth to holders. Actual: 0.4 WETH stays in the hook forever. The same holds for IMD or any other token transferred to the hook. Fix: in convertRoyalties also unwrap the chain's WETH balance (constructor-fixed WETH address, withdraw() then swap as ETH), and for IMD held directly by the hook split it 1/4 to feeRecipient and 3/4 to the token followed by distribute(); both keep the destinations restricted to feeRecipient and holders.","severity":"low","snippet":"    receive() external payable {}","title":"Royalties paid in WETH or any ERC-20 are stuck in the hook forever; only native ETH can be converted"},{"citation":"resolved","description":"The comment says 'Only the account deploying this mirror can link it, so it must also deploy the token right after'. That is not what DN404Mirror enforces: linkMirrorContract(address) compares its calldata argument with the stored deployer and then sets baseERC20 = msg.sender. Any contract can call the mirror with the deployer's (public) address as the argument and become the mirror's base token. Since the mirror and the token are deployed in two separate transactions (no deploy script or factory exists for the earn contracts; the tests deploy them as two steps), anyone can link a rogue base in between. The real PepesEarnToken constructor then reverts with LinkMirrorContractFailed, and the mirror permanently points at the attacker's contract, which controls what ownerOf/tokenURI/transfers of that mirror do. The team has to notice, deploy a new mirror and retry (and can be front-run again); if an address of the hijacked mirror was already announced or listed, it shows attacker-controlled NFTs under the PepesEarn mirror bytecode with the 4% royalty pointing at the real hook.","line":15,"path":"contracts/src/earn/PepesEarnMirror.sol","reproduction":"Deployer D deploys `m = new PepesEarnMirror(hook)`. Before D's next transaction, attacker contract H executes `address(m).call(abi.encodeWithSelector(0x0f4599e5, D))`. Result: m.baseERC20() == H. D then runs `new PepesEarnToken(hook, address(m), renderer, pepes, pepesRouter)`. Expected (per the constructor comment): only D can link, token deploys. Actual: the token constructor reverts (LinkMirrorContractFailed) and the mirror is permanently bound to H. Verified with a Foundry test against this commit (hijack succeeds, token deployment reverts). Fix: deploy mirror and token atomically in one transaction from a small deployer contract (the deployer contract is msg.sender for both, so DN404's check still passes), or have the mirror constructor take the precomputed token address and override the link check to require msg.sender == that address.","severity":"low","snippet":"    constructor(address hook) DN404Mirror(msg.sender) {","title":"Mirror can be linked by anyone before the token is deployed (deployer check uses a caller-supplied argument), blocking the launch deployment"},{"citation":"resolved","description":"Trust assumption to document rather than a bypass: openPool checks hook, routers, PoolManager, IMD, balance and total supply, but the guarantee 'the reserve can only buy $Pepes and burn it' rests entirely on immutables of the token the owner passes in (pepes, pepesRouter) and on that token actually being PepesEarnToken bytecode; none of that is checked on-chain. A token deployed with pepesRouter set to an owner-controlled contract (and pepes set to a token that contract can mint) passes openPool; buybackAndBurnPepes then approves imdIn of the reserve to that contract, which keeps the IMD and mints worthless 'pepes' to satisfy minPepesOut. Likewise royalty ETH and the reserve have no permissionless path: if the owner never calls convertRoyalties/buybackAndBurnPepes (or the key is lost) they stay locked forever. After openPool these values can no longer change, so this is a deployment-time verification item for holders, not a post-launch owner power.","line":210,"path":"contracts/src/earn/PepesEarnIMD.sol","reproduction":"State: owner deploys token X = PepesEarnToken(hook, mirror, renderer, pepes_ = FakeToken, pepesRouter_ = OwnerRouter), where OwnerRouter.buy(token, amountIn, minOut, deadline) does IMD.transferFrom(msg.sender, owner, amountIn) and FakeToken.mint(msg.sender, minOut). hook.openPool(X) succeeds (all checked getters match). After rewards expire and are recycled, owner calls X.buybackAndBurnPepes(buybackReserve, 1, deadline): expected by holders - $Pepes 0xE2C4...5644 bought via the v1 router and burned; actual - the whole reserve is transferred to the owner. Fix (pre-deployment): make pepes and pepesRouter constructor immutables of PepesEarnIMD and add `t.pepes() != PEPES || t.pepesRouter() != PEPES_ROUTER` to the BadToken check (or have the hook deploy the token itself with CREATE2 so the bytecode is fixed), and publish the verified addresses 0xE2C46c7068566740A33A4C93f5445B07BCfE5644 / 0xA73604EA3C393B47573986ff9Ce5A9EAb61883dC for holders to check before the pool opens.","severity":"info","snippet":"            t.hook() != address(this) || t.router() != router || t.ethRouter() != ethRouter","title":"openPool does not validate the token's buyback targets (pepes, pepesRouter), renderer, mirror or bytecode; the reserve's only exit is whatever the owner-chosen token hard-codes"}],"hash":"7271bd2428d32750f0721b8bda912884722be77b5ba140b38c13e9fc43b6e405","nodeId":"643d9262-011b-4e3b-9aba-241be4dcc538","outcome":"completed","summary":"The review is written to `.imd-findings.json` in the repository root: five findings (1 medium, 3 low, 1 info), no critical or high, so none carries a `proof`. No repository files were changed; the scratch tests I used were deleted afterwards.\n\n## Plain-language answers\n\n- **Can anyone take holders' NFTs, tokens or rewards?** I found no path for a non-owner to do so. The owner cannot touch tokens, NFTs or already-distributed rewards either, but can capture part of the royalties before they reach holders (finding 1).\n- **Can expiry take rewards earned in the last 30 days?** Only at the exact boundary second (finding 2). Otherwise no: the \"recent\" part rounds up in the holder's favour.\n\n## Findings\n\n1. **Medium — owner can sandwich its own `convertRoyalties` / `buybackAndBurnPepes`** (`PepesEarnIMD.sol:366`). The minimum output is chosen by the owner and the swap uses a spot price anyone can move. In the unit-test setup with 300 ETH of royalties, the owner swapped 8,000 ETH in, called `convertRoyalties(0)`, and swapped back. Holders and fee recipient got about 90 IMD instead of about 291, and the owner netted about 79 ETH. It only pays when royalties are large relative to IMD/ETH pool depth. The buyback variant is the same pattern by reasoning; I did not run it.\n2. **Low — expiry boundary is inclusive** (`PepesEarnToken.sol:261`). At exactly `lastActive + 30 days`, a distribution that landed in the same second as the holder's last activity counts as expired. In the test, alice's whole 5.999… IMD was recyclable at T+30d and zero one second earlier.\n3. **Low — royalties paid in WETH or any ERC-20 are stuck in the hook** (`PepesEarnIMD.sol:360`). Only native ETH is converted and there is no path for tokens. This is from reading the code, not a test.\n4. **Low — the mirror can be linked by anyone before the token is deployed** (`PepesEarnMirror.sol:15`). DN404's deployer check compares a caller-supplied argument, so a rogue contract can link first and the real token constructor then reverts. Confirmed by test.\n5. **Info — `openPool` does not check the token's `pepes`, `pepesRouter`, renderer, mirror or bytecode** (`PepesEarnIMD.sol:210`). The \"reserve can only buy and burn $Pepes\" guarantee rests on what the owner-chosen token hard-codes. Royalty ETH and the reserve also have no permissionless exit if the owner never acts.\n\nEach entry in the file has a reproduction and a suggested fix. All five would need to change before deployment, since the contracts are immutable afterwards.\n\n## Checked and found sound\n\n- **Reward accounting:** every balance-changing path DN404 exposes goes through `_moved`, and initialisation mints only to the excluded hook. I found no double claim and no way to make `eligibleSupply` or the corrections inconsistent.\n- **Accounting invariant:** I found no path in claim, recycle, buyback, royalties or donations that lets `distribute()` hand out the reserve.\n- **Flash-borrowed pool tokens:** neither router hands control to the user before `flush`, and nobody else can act inside the hook's own unlock.\n- **Hook and pool:** no other pool can be initialised with this hook, and no liquidity can be added.\n- **NFT gas:** about 12k gas per minted NFT (810 NFTs in one buy cost about 9.9M), and buys can be split, so nothing is trapped.\n- **Checkpoints:** the binary search stays cheap even with one checkpoint per second for years.\n\n## Not done\n\n- The mainnet-fork test was not run.\n- The renderer and Base64 memory safety were not reviewed; they are outside the assigned permissions area.\n- The existing unit tests passed when run alongside my scratch tests.","treeHash":null,"usage":{"cachedInputTokens":980650,"inputTokens":21,"model":"claude-fable-5-1","outputTokens":33915,"runtime":"claude","turns":15,"wallClockMs":568137}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"30a6c1a419ef4f9c","findings":[{"citation":"resolved","description":"ERC-2981 does not fix the royalty currency: marketplaces pay the royalty in the sale currency. On OpenSea/Seaport every offer or bid settles in WETH, and a listing priced in IMD pays royalties in IMD. PepesEarnMirror.royaltyInfo points all of them at PepesEarnIMD, but the hook only knows how to handle native ETH: receive() accepts ETH and convertRoyalties() swaps address(this).balance. There is no function that moves an ERC-20 held by the hook: collectProtocolFees() only burns ERC-6909 claims it minted itself (pendingProtocolFees), _flush() likewise, and there is no sweep. Any WETH or IMD royalty sent to the hook is therefore locked forever, so neither feeRecipient (1% of the sale) nor $EARN holders (3%) ever receive it. Because the hook cannot be changed after deployment, the loss is permanent for every such sale. Suggested fix (keeps the design): add an owner-callable convertRoyalties variant for ERC-20 royalties, e.g. convertRoyaltiesIMD() that takes imd.balanceOf(this) and splits it 1/4 to feeRecipient, 3/4 to the token followed by distribute() (outside an unlock, so distribute is allowed), and convertRoyaltiesWETH(minImdOut) that unwraps the chain's WETH (fixed address immutable) and reuses _convertRoyalties. Alternatively route royalties through a small splitter contract that can hold and forward arbitrary tokens.","line":367,"path":"contracts/src/earn/PepesEarnIMD.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\n\nimport {PepesEarnIMD} from \"src/earn/PepesEarnIMD.sol\";\nimport {PepesEarnToken} from \"src/earn/PepesEarnToken.sol\";\nimport {PepesEarnMirror} from \"src/earn/PepesEarnMirror.sol\";\nimport {PepesEarnRenderer} from \"src/earn/PepesEarnRenderer.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\nimport {DeployLib} from \"script/DeployLib.sol\";\n\ncontract ProofERC20 {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amt) external {\n        balanceOf[to] += amt;\n    }\n\n    function approve(address s, uint256 amt) external returns (bool) {\n        allowance[msg.sender][s] = amt;\n        return true;\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    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\n/// @notice ERC-2981 royalties paid in IMD (an ERC-20) to PepesEarnIMD can never reach feeRecipient or holders:\n///         `convertRoyalties` only looks at the hook's ETH balance and no other function moves ERC-20s out.\n///         Fails on the current code (the IMD stays in the hook); passes once the hook also converts/splits\n///         IMD (and other ERC-20) royalties it holds.\ncontract RoyaltyTokenStuckProof is Test {\n    address constant FEE_RECIPIENT = 0x3c8A4d94B3219F6633F2cC94094f4765b30c691C;\n    uint256 constant SUPPLY = 2_000e18;\n\n    PoolManager pm;\n    ProofERC20 imd;\n    PepesEarnIMD hook;\n    PepesEarnToken earn;\n    PepesEarnMirror mirror;\n    PepesFamilyRouter router;\n    address owner = makeAddr(\"owner\");\n    address alice = makeAddr(\"alice\");\n    address marketplace = makeAddr(\"marketplace\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        imd = new ProofERC20();\n        PepesEarnRenderer renderer = new PepesEarnRenderer();\n\n        // IMD/ETH pool so ETH royalties can be converted (1 ETH = 1 IMD)\n        PoolKey memory imdEth =\n            PoolKey(Currency.wrap(address(0)), Currency.wrap(address(imd)), 10_000, 100, IHooks(address(0)));\n        pm.initialize(imdEth, TickMath.getSqrtPriceAtTick(0));\n        PoolModifyLiquidityTest lp = new PoolModifyLiquidityTest(pm);\n        vm.deal(address(this), 100_000 ether);\n        imd.mint(address(this), 100_000e18);\n        imd.approve(address(lp), type(uint256).max);\n        lp.modifyLiquidity{value: 20_000 ether}(imdEth, ModifyLiquidityParams(-887200, 887200, 10_000e18, 0), \"\");\n\n        bytes memory initCode = abi.encodePacked(\n            type(PepesEarnIMD).creationCode,\n            abi.encode(\n                pm,\n                address(imd),\n                owner,\n                FEE_RECIPIENT,\n                DeployLib.startTickForMarketCap(2_000e18, SUPPLY),\n                PepesEarnIMD.ImdEthPool(10_000, 100, address(0))\n            )\n        );\n        (bytes32 salt, address expected) = DeployLib.mineSalt(address(this), uint160(0x28CC), initCode, 0);\n        address deployed;\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n        require(deployed == expected, \"hook address\");\n        hook = PepesEarnIMD(payable(deployed));\n        router = PepesFamilyRouter(payable(hook.router()));\n        mirror = new PepesEarnMirror(address(hook));\n        earn = new PepesEarnToken(address(hook), address(mirror), address(renderer), address(1), address(1));\n        vm.prank(owner);\n        hook.openPool(address(earn));\n\n        imd.mint(alice, 1_000e18);\n        vm.startPrank(alice);\n        imd.approve(address(router), type(uint256).max);\n        router.buy(address(earn), 100e18, 0, block.timestamp); // a holder exists\n        vm.stopPrank();\n    }\n\n    function test_imdRoyaltyPaidToHookReachesFeeRecipientAndHolders() public {\n        // A marketplace sells NFT #1 for 100 IMD and pays the 4% ERC-2981 royalty in the sale currency (IMD).\n        (address receiver, uint256 royalty) = mirror.royaltyInfo(1, 100e18);\n        assertEq(receiver, address(hook));\n        assertEq(royalty, 4e18);\n        imd.mint(marketplace, royalty);\n        vm.prank(marketplace);\n        imd.transfer(receiver, royalty);\n        assertEq(imd.balanceOf(address(hook)), 4e18);\n\n        uint256 feeBefore = imd.balanceOf(FEE_RECIPIENT);\n        uint256 holdersBefore = earn.withdrawableDividendOf(alice);\n\n        vm.prank(owner);\n        hook.convertRoyalties(0);\n        hook.collectProtocolFees(address(imd));\n\n        // Expected: the royalty is split like every other royalty: 1/4 to feeRecipient, 3/4 to holders.\n        assertEq(imd.balanceOf(address(hook)), 0, \"royalty IMD is stuck in the hook\");\n        assertEq(imd.balanceOf(FEE_RECIPIENT) - feeBefore, 1e18, \"feeRecipient did not get 1%\");\n        assertApproxEqAbs(earn.withdrawableDividendOf(alice) - holdersBefore, 3e18, 10, \"holders did not get 3%\");\n    }\n}","reproduction":"State: pool opened, alice holds 96 EARN. Input: a marketplace settles NFT #1 for 100 IMD and pays royaltyInfo(1, 100e18) = (hook, 4e18) by IMD.transfer(hook, 4e18) (or 0.04 WETH for a 1 WETH sale). Then owner calls hook.convertRoyalties(0) and anyone calls hook.collectProtocolFees(IMD). Expected: feeRecipient +1 IMD, holders' withdrawable +3 IMD, hook IMD balance 0. Actual: convertRoyalties returns 0 (ethIn == 0), collectProtocolFees is a no-op (pendingProtocolFees[IMD] == 0 or unrelated), imd.balanceOf(hook) stays 4e18 forever. Reproduced in test/scratch/RoyaltyTokenStuck.t.sol (fails with 'royalty IMD is stuck in the hook: 4000000000000000000 != 0') and with a generic ERC-20 in test/scratch/Experiments.t.sol::test_stuckErc20Royalty.","severity":"medium","snippet":"        uint256 ethIn = address(this).balance;","title":"ERC-20 royalties (WETH or IMD) paid to PepesEarnIMD are irrecoverable: convertRoyalties only converts the ETH balance"},{"citation":"resolved","description":"The trust model says the owner must never be able to take the buyback reserve, and relies on 'the reserve can only be spent through this swap'. But the swap's three economic parameters (imdIn up to the whole reserve, timing, minPepesOut) are all chosen by the owner, and the $Pepes pool is a thin x*y=k curve (live market cap ~25,190 IMD, read from the v1 launchpad on chain id 4663). An owner acting through a contract can atomically (1) buy $Pepes with F IMD on the v1 router, (2) call buybackAndBurnPepes(reserve, 0, deadline), which buys at the inflated price, (3) sell the $Pepes back. Profit ~ F*R/(Q+F) - 8%*F, positive whenever the buyback R exceeds ~8% of the pool's IMD depth Q. Reproduced with a PepesFamily v3 instance (identical curve/fee hook to v1) as the $Pepes pool at 20,000 IMD market cap and a 6,014 IMD reserve: F = 5,000 -> owner nets +1,701 IMD; F = 10,000 -> +2,378; F = 20,000 -> +2,629 IMD (44% of the reserve) while only 65.5M $Pepes are burned instead of 224M for an honest buyback. The same pattern applies to convertRoyalties(minImdOut) on the IMD/ETH pool. Not an external attack (a careful owner setting tight minOut is safe against third-party sandwiches), but it directly contradicts the stated guarantee and cannot be fixed after deployment. Suggested fix preserving the feature: bound a single buyback to a small fraction of the pool's depth and rate-limit it (e.g. imdIn <= min(reserve, 1% of IPepesFamily(v1Pad).marketCap(pepes)) and at most one call per hour), which makes any sandwich unprofitable (profit < 0 when R < 8% of depth) and lets the function be permissionless; or require minPepesOut >= quote from a TWAP/on-chain quoter minus a fixed max slippage instead of a free parameter.","line":298,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"State: buybackReserve = 6,014e18 IMD (alice and bob each bought EARN with 100,000 IMD, 31 days passed, recycle(alice)+recycle(bob)); $Pepes pool market cap 20,000 IMD; owner is a contract S holding 20,000 IMD. Input, one transaction from S: v1Router.buy(pepes, 20_000e18, 0, now) -> earn.buybackAndBurnPepes(6_014e18, 0, now) -> pepes.approve(router) -> v1Router.sell(pepes, allPepes, 0, now). Expected (trust model): the owner cannot gain from the reserve; 224M $Pepes burned (honest buyback). Actual: S's IMD balance rises by 2,629 IMD, 65.5M $Pepes burned, reserve fully spent. Script: test/scratch/Experiments.t.sol::SandwichExperiment.test_ownerSelfSandwichBuyback_largeReserve (logs the table above).","severity":"medium","snippet":"        IPepesRouter(pepesRouter).buy(pepes, imdIn, minPepesOut, deadline);","title":"Owner can extract a large share of the buyback reserve by self-sandwiching buybackAndBurnPepes (owner picks size, timing and minOut)"},{"citation":"resolved","description":"The only exits for two pools of value depend on a live, cooperative owner: buybackReserve can leave only through buybackAndBurnPepes (owner of PepesEarnIMD) and royalty ETH only through PepesEarnIMD.convertRoyalties (onlyOwner). The contracts are immutable and ownership is only transferable by the current owner (two-step), so a lost key, a compromised-and-abandoned key, or simply an inactive owner freezes every expired reward and every marketplace royalty permanently. Holders' claimable rewards are not affected, but the buyback-and-burn and 3% royalty promises silently stop. Suggested fix: make both conversions permissionless under bounds that make sandwiching unprofitable (per-call size cap relative to pool depth plus a rate limit, see the buyback finding), optionally keeping the owner path as a faster lane; or at minimum allow anyone to call them after N days without an owner call.","line":293,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"State: owner EOA 0x3c8A...691C loses its key (or convertRoyalties/buybackAndBurnPepes are simply never called). buybackReserve = 500e18 after recycles; hook holds 2 ETH of royalties. Input: any address calls earn.buybackAndBurnPepes(500e18, 0, now) or hook.convertRoyalties(0). Expected (design intent): expired rewards are eventually burned as $Pepes and royalties reach feeRecipient/holders. Actual: both calls revert NotOwner() for every caller forever; the 500 IMD and 2 ETH are unreachable, and nobody can change owner or pendingOwner.","severity":"low","snippet":"        if (msg.sender != IEarnHook(hook).owner()) revert NotOwner();","title":"Buyback reserve and royalty ETH are frozen forever if the owner key is lost or the owner stops acting (no permissionless fallback)"},{"citation":"resolved","description":"PermitHelper.permit calls token.permit(...) in a try block. PepesEarnToken inherits DN404, which has no permit(); the call hits DN404's fallback and reverts FnSelectorNotRecognized, so the catch branch runs and the trade only proceeds if allowance(msg.sender, router) >= tokenAmount, otherwise it reverts PermitFailed. The routers are reused unchanged, and web/index.html marks the v3 router family as permit-capable, so a front end that offers the gasless-approval sell flow for $EARN will produce transactions that revert. No funds are at risk; this is a UX/integration note so the site disables the permit path for this collection (or the token adds EIP-2612 permit, with the care that NFT approvals and ERC-20 allowances are separate in DN404).","line":128,"path":"contracts/src/PepesFamilyRouter.sol","reproduction":"State: alice bought 10 EARN and has not approved the router. Input: alice calls router.sellWithPermit(earn, 1e18, 0, now, v, r, s) with any signature. Expected (per the router's docs): the permit is applied and 1 EARN is sold. Actual: PepesEarnToken has no permit selector -> DN404 fallback reverts -> catch -> allowance 0 < 1e18 -> revert PermitHelper.PermitFailed(). The same call succeeds only after a separate earn.approve(router, ...) transaction, i.e. the permit adds nothing.","severity":"info","snippet":"        PermitHelper.permit(token, tokenAmount, deadline, v, r, s);","title":"sellWithPermit / sellForEthWithPermit cannot use a permit for $EARN: DN404 has no EIP-2612 permit, so the routers only succeed with a prior approve"},{"citation":"resolved","description":"The suite (22 tests) exercises the happy paths well but leaves the edges the brief asks about untested: (a) royalties arriving as ERC-20 (WETH/IMD) - the only royalty test pays ETH, which is why the stuck-ERC-20 case went unnoticed; (b) recycle() called more than once on the same holder over successive windows (expired must equal rewards earned between the two cutoffs; the first-window test only runs one recycle); (c) a holder whose last activity was a claim() rather than a transfer, and a holder with balance 0 but unclaimed rewards; (d) NFTs moved by mirror.transferFrom into contracts/excluded addresses and the effect on eligibleSupply; (e) a stateful/fuzzed check of IMD.balanceOf(token) >= accountedBalance + buybackReserve, sum(withdrawable) <= accountedBalance, eligibleSupply == sum of non-excluded balances, and 'expired never includes rewards earned in the last 30 days'. During this review a 300-run x 40-operation randomized sequence (buys, sells, ERC-20 and NFT transfers, claims, recycles, warps, third-party swaps, royalties, buybacks) held all of these invariants; adding such a test to the repository would guard the properties the design depends on. The brief's gas figures also hold: buying 1,997 EARN mints 1,997 NFTs for 23.9M gas, selling 1,125 costs 5.4M, wallet-to-wallet transfer of 1,997 costs 14.8M, all under the 32M Arbitrum per-transaction limit, so no buy or sell size traps funds.","line":402,"path":"contracts/test/PepesEarn.t.sol","reproduction":"Concrete untested input that currently has no assertion: send 1e18 of any ERC-20 to the hook and call convertRoyalties(0) / collectProtocolFees(token) - balance unchanged (see the medium finding). Concrete untested expiry input: alice buys at t0, bob buys at t0 (alice earns A1), carol buys at t0+20d (A2), dan buys at t0+45d (A3); at t0+35d recycle(alice) must take A1 only, at t0+60d a second recycle(alice) must take A2 only and leave A3 (expected per the design; the current code does satisfy this in the review's fuzz, but no repository test asserts it).","severity":"info","snippet":"    function test_royalties_convertedToImdAndSplit() public {","title":"Test gaps: no test covers ERC-20 royalties, repeated recycles across several 30-day windows, or randomized sequences of the accounting invariants"}],"hash":"86f759b58dbd93417b9694424ed1b3b15005dfc6f01825cd0c1c93b90f784bf3","nodeId":"4607cbd1-b5a9-4bc1-95ca-379721836eb5","outcome":"completed","summary":"Review complete. Findings are in `.imd-findings.json` (5 entries: 2 medium, 1 low, 2 info), with a failing Foundry proof attached to the first medium. Scratch tests live under `contracts/test/scratch/`; no project file was changed and the existing 56 tests still pass.\n\n**Plain-language answers**\n\n- Nobody, including the owner, can take holders' NFTs, tokens or claimable rewards. Every balance-changing path (ERC-20 transfer, mirror NFT transfer) goes through `_moved`, there is no mint or burn path, Permit2 is off, and the only payout goes to `msg.sender`.\n- Expiry never takes rewards earned in the last 30 days. The maths is correct given \"balance unchanged since lastActive\", and that assumption holds. A 300-run randomized fuzz (40 ops each: buys, sells, ERC-20 and NFT transfers, claims, recycles, time warps, third-party swaps, royalties, buybacks) kept every invariant: IMD balance ≥ accounted + reserve, Σ withdrawable ≤ accounted, eligibleSupply = Σ eligible balances, expired ≤ withdrawable minus rewards since the cutoff.\n- The flash-borrow guard holds in all three distribution paths, including the new royalty path inside the hook's own unlock. The Base64 assembly is memory-safe, the 7702 check misclassifies nothing harmful, and NFT gas is not a trap (1,997 mints = 23.9M gas, under the 32M limit).\n\n**Findings**\n\n- **Medium: ERC-20 royalties are irrecoverable.** `convertRoyalties` only swaps the hook's ETH balance, and no function moves an ERC-20 out of the hook. OpenSea offers settle in WETH and IMD-priced listings pay IMD, so those royalties are lost forever. Proof test fails with 4 IMD stuck in the hook.\n- **Medium: the owner can extract much of the buyback reserve.** Size, timing and minOut are owner-chosen, and the $Pepes pool is thin (about 25k IMD market cap on chain). An owner contract that front-runs with 20k IMD, triggers a 6,014 IMD buyback with minOut 0, and sells back nets +2,629 IMD (44% of the reserve). This contradicts the stated trust model. The same shape applies to `convertRoyalties`.\n- **Low: a lost or inactive owner freezes the reserve and royalty ETH forever**, since both exits are owner-only with no fallback.\n- **Info:** `sellWithPermit` does nothing useful for $EARN (DN404 has no permit), and the test suite lacks ERC-20 royalty, multi-window recycle and invariant-fuzz coverage.\n\n**Before deployment**\n\n1. Add an owner path (or splitter) that converts IMD and WETH royalties held by the hook.\n2. Cap each buyback to a small fraction of the $Pepes pool depth with a rate limit. That makes sandwiching unprofitable and lets both conversions become permissionless, which also fixes the lost-owner freeze.\n3. There is no Earn deploy script in the repo. The mirror and token must be deployed by the same account (EOA or the same CREATE2 factory), or `_initializeDN404` reverts.","treeHash":null,"usage":{"cachedInputTokens":6792546,"inputTokens":930,"model":"claude-fable-5-1","outputTokens":93289,"runtime":"claude","turns":54,"wallClockMs":1765993}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"35c52a5b502e847c","findings":[],"hash":"897d79ca886e2123962dbdeb8be34aea51574d1fe14a6aaf2780b25013542bc9","nodeId":"bba3aa62-21be-4e12-90d0-2cfc0ec3be4e","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":5,"wallClockMs":652780}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3f6a9bdd601cb99f","findings":[],"hash":"cc189ba1b966b974bb273a0352ae5358cfe528aa531afa43e6d3b14171304083","nodeId":"4607cbd1-b5a9-4bc1-95ca-379721836eb5","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":4,"wallClockMs":418502}}],"verification":[]}