{"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":"a58eb2c6-bfc3-441e-86d8-432c2ab15114","kind":"audit","nodes":[{"acceptedSubmissionHash":"ac1c05c36202858e9d871ebcf7840ee88cb5450339fa48493bff9f76b9817cad","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":"5b55e4db0411372b7d13369f4e891e3d018dbda3a363d58cb273ed7b2457c75a","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":"e42474cbcb5c03e482f211ff3c8766af2586ea2047053abef5b6a8571f36ab49","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":"9416d28ab9df6dba14bd8bc6c8765381e757895edc6e1e18e90d14ddcbdcca86","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":"342187b549d98f13d312e41ecd8c5fc157f3be9ec7e3d25f5231f072e8ef8e6e","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":"Final check: Pepes Earn IMD. Repository https://github.com/0xtenang/PepesFamily, commit b686e0e2e0735d794da97378da595ea232b99773, scope contracts/src/earn/. Your re-check (https://explorer.imd.fun/jobs/f6d3cd0e-8371-417b-80d6-7b99fc9efa0c) at 7bb7a90 found 1 high, 2 low and 2 info. Changes since:\n\nRoyalty conversion removed entirely. The ERC-2981 royalty is 1% to PepesEarnIMD.feeRecipient() (read live by the mirror). The hook holds and swaps nothing and has no receive(). This addresses the high, low 3 and info 4–5.\nLow 2: in _moved, the recipient's lastActive is only set for buys (from an excluded address), transfers it initiated (actor == to), or a first-time holder.\nPlease confirm these are fixed and check for any new issues, especially in _moved (expiry correctness when an incoming transfer isn't recorded) and in buybackAndBurnPepes / maxBuyback. Tests: contracts/test/PepesEarn.t.sol, and the fork test FORK_RPC=https://robinhood.drpc.org forge test --mc PepesEarnForkTest.","parentJobId":null,"planHash":"6a82d6236da9429935c519c9633a8c8d35440990c546577fd0db59d9d20580f6","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"a58eb2c6-bfc3-441e-86d8-432c2ab15114","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":"781c70ebbdc435df0825482b8dfcbd5539bd420f8b52113c6517d5635fe31132","nodeKey":"audit_economics","submissionHash":"ac1c05c36202858e9d871ebcf7840ee88cb5450339fa48493bff9f76b9817cad","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50939","feedbackHash":"591560dd4a6d325a017198e8b3143597158c4991e432a2d2edc7b287665066c9","nodeKey":"audit_flow","submissionHash":"5b55e4db0411372b7d13369f4e891e3d018dbda3a363d58cb273ed7b2457c75a","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50981","feedbackHash":"6e4e5140d8d06452e3302883f1b21bc2543eaf28473421eb276ae5732485373a","nodeKey":"audit_judge","submissionHash":"e42474cbcb5c03e482f211ff3c8766af2586ea2047053abef5b6a8571f36ab49","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50959","feedbackHash":"1d038f18098363dce93076a0bf9b3bc3cada7151c633f2616c14bdcdee887b44","nodeKey":"audit_math","submissionHash":"9416d28ab9df6dba14bd8bc6c8765381e757895edc6e1e18e90d14ddcbdcca86","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51504","feedbackHash":"1fc5676aa262647038d5c0bc0ba37f9fe084bb80384dc601a41e280d16f7fe66","nodeKey":"audit_permissions","submissionHash":"342187b549d98f13d312e41ecd8c5fc157f3be9ec7e3d25f5231f072e8ef8e6e","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"4f5234585a4ff62160b2a913b3f070600ccffab2fc72a643b330228804a22176","state":"completed","submissions":[{"artifacts":[],"attempt":2,"bundleHash":null,"device":"0238a59bba722237","findings":[{"citation":"resolved","description":"_moved treats every transfer whose sender is an excluded address as the recipient's own buy (`fromExcluded`). But the PoolManager is an excluded address that anyone can make send $EARN to any address: inside their own `poolManager.unlock`, a third party calls `poolManager.take(EARN, victim, 1)` and repays the pool with 1 wei of their own $EARN (`sync` / `transfer` / `settle`). The token sees from = poolManager, msg.sender = poolManager, so `fromExcluded` is true and `lastActive[victim] = block.timestamp`, although the victim did nothing. This is the same griefing as re-check Low 2 (a dust gift keeps someone's rewards from ever expiring, so the $Pepes buyback reserve never receives them), with the gift routed through the PoolManager instead of a plain transfer. Cost to the caller: 1 wei of $EARN and gas, no swap fee (no swap happens). The plain-transfer path (`bob.transfer(alice, 1)`) is fixed and test_expiry_dustGiftDoesNotResetTimer covers only that path. The token cannot tell a router buy from an arbitrary `take`, because both routers also deliver with `poolManager.take(token, user, out)`. Fixing it without changing the design needs one of: (a) count an incoming transfer from an excluded address as activity only above a meaningful size (e.g. at least one whole token), so resetting someone's timer costs a real purchase each 30 days; (b) stop counting receipts as activity at all except for a first-time holder (timer resets on claim or on sending), which also removes the asymmetry in the next finding; or (c) accept and document that anyone can keep any wallet's rewards from expiring.","line":217,"path":"contracts/src/earn/PepesEarnToken.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 {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {PepesEarnIMD} from \"src/earn/PepesEarnIMD.sol\";\nimport {PepesEarnToken} from \"src/earn/PepesEarnToken.sol\";\nimport {PepesEarnRenderer} from \"src/earn/PepesEarnRenderer.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\n\ncontract MockERC20 {\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 from, address to, uint256 amt) external returns (bool) {\n        allowance[from][msg.sender] -= amt;\n        balanceOf[from] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\n/// @dev Anyone: moves 1 wei of $EARN out of the PoolManager to `victim` and repays the pool with 1 wei of its own.\ncontract DustTaker is IUnlockCallback {\n    IPoolManager immutable pm;\n    PepesEarnToken immutable t;\n    address victim;\n\n    constructor(IPoolManager pm_, PepesEarnToken t_) {\n        pm = pm_;\n        t = t_;\n    }\n\n    function poke(address v) external {\n        victim = v;\n        pm.unlock(\"\");\n    }\n\n    function unlockCallback(bytes calldata) external returns (bytes memory) {\n        pm.take(Currency.wrap(address(t)), victim, 1);\n        pm.sync(Currency.wrap(address(t)));\n        t.transfer(address(pm), 1);\n        pm.settle();\n        return \"\";\n    }\n}\n\ncontract DustTakeTest is Test {\n    PoolManager pm;\n    MockERC20 imd;\n    PepesEarnIMD hook;\n    PepesEarnToken earn;\n    PepesFamilyRouter router;\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address owner = makeAddr(\"owner\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        imd = new MockERC20();\n        bytes memory initCode = abi.encodePacked(\n            type(PepesEarnIMD).creationCode,\n            abi.encode(\n                pm, address(imd), owner, owner, int24(0), PepesEarnIMD.ImdEthPool(10_000, 100, address(0)), address(0xBEEF), address(0xCAFE)\n            )\n        );\n        // mine a CREATE2 salt so the hook address carries the hook flags in its low 14 bits\n        bytes32 h = keccak256(initCode);\n        uint256 salt;\n        for (;; salt++) {\n            address a = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(salt), h)))));\n            if (uint160(a) & 0x3FFF == 0x28CC) break;\n        }\n        address deployed;\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n        hook = PepesEarnIMD(deployed);\n        router = PepesFamilyRouter(hook.router());\n        earn = new PepesEarnToken(address(hook), address(new PepesEarnRenderer()));\n        vm.prank(owner);\n        hook.openPool(address(earn));\n        address[2] memory users = [alice, bob];\n        for (uint256 i; i < 2; i++) {\n            imd.mint(users[i], 1_000e18);\n            vm.prank(users[i]);\n            imd.approve(address(router), type(uint256).max);\n        }\n    }\n\n    function test_dustTakenFromPoolManagerMustNotResetTimer() public {\n        vm.prank(alice);\n        router.buy(address(earn), 100e18, 0, block.timestamp);\n        vm.prank(bob);\n        router.buy(address(earn), 100e18, 0, block.timestamp); // alice earns\n        vm.warp(vm.getBlockTimestamp() + 31 days);\n        uint256 expired = earn.expiredRewardsOf(alice);\n        assertGt(expired, 0, \"alice's rewards have expired\");\n\n        // bob (any third party) gifts alice 1 wei, routed through the PoolManager instead of a plain transfer\n        DustTaker d = new DustTaker(pm, earn);\n        vm.prank(bob);\n        earn.transfer(address(d), 1);\n        d.poke(alice);\n\n        assertGe(earn.expiredRewardsOf(alice), expired - 1, \"a third party's dust gift reset alice's 30-day timer\");\n    }\n}","reproduction":"State: alice buys 100 IMD of $EARN through the router at t0, bob buys 100 IMD (alice earns 5999999999999999999 wei IMD), then 31 days pass with alice idle: expiredRewardsOf(alice) = 5999999999999999999. Input: bob sends 1 wei $EARN to a helper contract, which calls poolManager.unlock and in the callback does poolManager.take(EARN, alice, 1); poolManager.sync(EARN); earn.transfer(poolManager, 1); poolManager.settle(). Expected (per re-check Low 2 and the comment at lines 212-214): expiredRewardsOf(alice) stays >= 5999999999999999998 and lastActive(alice) stays t0. Actual: lastActive(alice) = now and expiredRewardsOf(alice) = 0; recycle(alice) returns 0, and repeating this every 30 days keeps alice's rewards out of the buyback reserve forever. The proof test fails with: 'a third party's dust gift reset alice's 30-day timer: 0 < 5999999999999999998'.","severity":"low","snippet":"            if (fromExcluded || actor == to || lastActive[to] == 0) lastActive[to] = block.timestamp;","title":"Re-check Low 2 is only partly fixed: 1 wei of $EARN taken from the PoolManager to a victim still resets the victim's 30-day timer"},{"citation":"resolved","description":"After the Low 2 change, an existing holder's timer is reset by receiving only if the tokens come from an excluded address or the holder is msg.sender. A purchase the holder made and paid for on an NFT marketplace (the marketplace contract is the `msgSender` of mirror.transferFrom), through an OTC/escrow contract, or through any router that forwards tokens from its own balance, moves tokens from a non-excluded address with actor != to, so it is not activity. A pool buy of the same size is. I checked the expiry maths requested in the brief and it stays safe for the holder: an unrecorded incoming transfer only raises balanceOf, so `recent` (line 287) is over-estimated, never under-estimated, and rewards earned after the unrecorded purchase expire on the same schedule as after a pool buy. The only real difference is for rewards the holder had already earned more than 30 days before: a pool buy gives them 30 more days, a marketplace buy leaves them recyclable by anyone immediately. Two comments no longer match the code: line 107 ('Last claim or $EARN balance change of each holder') and lines 284-285 ('The balance cannot have changed since `last` (any change marks the holder active)'); the contract notice at lines 52-53 ('neither claimed nor moved any $EARN') also reads as if a purchase counted. Fix: either document that only claims, sends and pool buys reset the timer (and correct those comments), or adopt option (b) of the previous finding so every kind of receipt is treated alike.","line":217,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"State: alice buys 100 IMD of $EARN at t0, bob buys 100 IMD (alice earns 5999999999999999999 wei IMD), 31 days pass. Input: bob approves a marketplace address with mirror.setApprovalForAll(market, true); market calls mirror.transferFrom(bob, alice, id) (alice's purchase of one NFT; her $EARN balance rises by 1e18). Expected if a purchase is activity (as for router.buy at the same moment, which sets lastActive(alice) = now and expiredRewardsOf(alice) = 0): expiredRewardsOf(alice) == 0. Actual: lastActive(alice) is still t0 and expiredRewardsOf(alice) == 5999999999999999999, so anyone can call recycle(alice) right after her purchase and move her 6 IMD into the buyback reserve.","severity":"info","snippet":"            if (fromExcluded || actor == to || lastActive[to] == 0) lastActive[to] = block.timestamp;","title":"A holder who buys more $EARN through a marketplace or any third-party-initiated transfer is not marked active, unlike a pool buy; lastActive docs are now stale"}],"hash":"342187b549d98f13d312e41ecd8c5fc157f3be9ec7e3d25f5231f072e8ef8e6e","nodeId":"4e2425c5-96a3-4308-81c1-ac623125fdac","outcome":"completed","summary":"The re-check fixes hold except Low 2, which is only partly fixed. I found one low and one info, both written to `.imd-findings.json`; no repository files were changed.\n\n**Confirmation of the earlier findings**\n- **High, Low 3, Info 4–5 (royalty conversion):** fixed. The hook has no royalty code and no `receive()`, the mirror's `royaltyInfo` returns 1% to the live `feeRecipient()`, and the inherited DN404 `receive()` on token and mirror reverts on any ETH.\n- **Low 2 (dust gift resets the timer):** fixed for a plain transfer, but still reachable through the PoolManager (first finding below).\n\n**Findings**\n1. **Low — a third party can still reset anyone's 30-day timer** (`contracts/src/earn/PepesEarnToken.sol:217`). Anyone can call `poolManager.take(EARN, victim, 1)` inside their own unlock and repay the pool with 1 wei of their own. The token sees the PoolManager as sender, treats it as the victim's buy, and sets `lastActive[victim]`.\n   - In the test, Alice's expired rewards went from 5999999999999999999 to 0 after this, at a cost of 1 wei and gas.\n   - The finding carries a self-contained Foundry proof that fails on this commit.\n   - The token cannot tell this from a real router buy, since both routers also deliver via `take`. The options are a minimum size for a receipt to count, not counting receipts as activity at all, or accepting and documenting it.\n2. **Info — a marketplace or other third-party-initiated purchase is not activity, unlike a pool buy** (same line). Alice, idle for 31 days, buys an NFT from Bob through a marketplace operator; her timer does not reset and her 5999999999999999999 wei of old rewards stay recyclable. The comments at lines 107 and 284–285 no longer match the code.\n\n**The two areas you asked about**\n- **`_moved` expiry correctness:** safe for the holder. An unrecorded incoming transfer only raises the balance, so the \"recent\" amount is over-estimated, never under-estimated. Rewards earned after such a transfer expire on the same schedule as after a pool buy.\n- **`buybackAndBurnPepes` / `maxBuyback`:** no defect found. My own arithmetic for a constant-product pool puts a sandwich's gain at no more than about 4% of the attacker's size, against roughly 8% in round-trip fees if the $Pepes pool charges 4% each way. That holds even when the attacker inflates the cap by moving the price first. The reserve is decremented before the external call, and the allowance is reset afterwards.\n\n**What ran:** the 27 unit tests in `PepesEarnTest` pass, and both fork tests pass against `robinhood.drpc.org`. No static analysis or extended fuzzing was run.\n\nNot reported because the repository's `AUDIT.md` already describes the behaviour: a buyer who swaps directly through the PoolManager and then calls `flush` shares in pending holder fees, including from their own trade.","treeHash":null,"usage":{"cachedInputTokens":1009419,"inputTokens":27,"model":"claude-fable-5-1","outputTokens":23720,"runtime":"claude","turns":15,"wallClockMs":348473}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"72b617d4b615473a","findings":[{"citation":"resolved","description":"_moved treats every transfer whose sender is an excluded address as the recipient's own activity (`fromExcluded`), on the assumption that tokens leaving the pool are a buy by the recipient. The PoolManager is an excluded address, and inside an unlock anyone can call poolManager.take(EARN, victim, amount) for an arbitrary recipient and settle the debt with their own $EARN. No swap happens, so the hook charges no fee: the cost is 1 wei of $EARN plus gas. The transfer is PoolManager -> victim, so lastActive[victim] is set to now and expiredRewardsOf(victim) drops to 0 for another 30 days. This is the same dust-gift reset the fix was meant to remove, only routed through the pool; repeated once every 30 days per wallet (or just before any recycle call) it keeps any wallet's old rewards from ever reaching the buyback reserve. test_expiry_dustGiftDoesNotResetTimer only covers a direct holder-to-holder transfer, so it passes. Fixing it without changing the design means not inferring activity from `fromExcluded` alone: e.g. count a pool-sourced receipt only when the recipient is identifiable as the trader (the project's routers passing the buyer to the token, or tx.origin == to), or require the receipt to be at least one whole unit so a reset costs a real purchase.","line":217,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"In the PepesEarn.t.sol setup: alice buys with 100 IMD, bob buys with 100 IMD (alice earns ~6 IMD). bob sends 10 wei $EARN to a helper contract D. Warp 31 days: earn.expiredRewardsOf(alice) = 5999999999999999999. D (called by any third party) runs pm.unlock(\"\") and in unlockCallback: pm.take(Currency.wrap(earn), alice, 1); pm.sync(Currency.wrap(earn)); earn.transfer(address(pm), 1); pm.settle(). Expected: alice did nothing, so her expired rewards stay ~6 IMD and recycle(alice) moves them to the reserve. Actual (run on this commit): lastActive(alice) == block.timestamp, expiredRewardsOf(alice) == 0 and recycle(alice) returns 0.","severity":"low","snippet":"if (fromExcluded || actor == to || lastActive[to] == 0) lastActive[to] = block.timestamp;","title":"Re-check low 2 is not closed: anyone can still reset a holder's 30-day timer with 1 wei routed through the PoolManager"},{"citation":"resolved","description":"For NFT transfers `actor` is the mirror's msg.sender. In a marketplace sale that is the marketplace's operator/conduit, never the buyer, and the seller is a normal (non-excluded) wallet, so for a buyer who has held before none of `fromExcluded || actor == to || lastActive[to] == 0` is true and lastActive[buyer] is not updated. The same holds for any ERC-20 purchase delivered by an intermediary contract (an aggregator that receives the tokens and forwards them). The contract documents expiry as applying to a wallet that 'has neither claimed nor moved any $EARN for more than 30 days', but a wallet that paid for and received an NFT 11 days ago is treated as idle and anyone can recycle its older rewards. The seller in the same sale is marked active by the operator's call, so the two sides of one trade are treated differently. The new unit test only checks pool buys and self-initiated transfers, not this path. The gift and the marketplace purchase are indistinguishable on-chain, so a fix needs a scope decision: either state in the docs/UI that only pool buys, sends and claims reset the timer, or count the receipt of a whole unit (an NFT) as activity, which makes a griefing gift cost a full $EARN rather than dust.","line":194,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"In the PepesEarn.t.sol setup at time t0: alice buys with 100 IMD, bob buys with 100 IMD (alice has ~6 IMD of rewards). Warp to t0+20 days. bob calls mirror.setApprovalForAll(carol, true) (carol stands in for the marketplace conduit) and carol calls mirror.transferFrom(bob, alice, id) for one of bob's NFTs: alice now holds one more $EARN. Expected: alice's timer restarts at t0+20d, so at t0+31d expiredRewardsOf(alice) == 0. Actual (run on this commit): lastActive(alice) is still t0, and at t0+31d expiredRewardsOf(alice) == 5999999999999999999, equal to her whole withdrawable balance, which any caller can move to the buyback reserve with recycle(alice).","severity":"low","snippet":"_moved(from, to, _unit(), msgSender);","title":"A holder who buys an NFT through a marketplace is not marked active, so rewards expire while the wallet is acquiring $EARN"}],"hash":"5b55e4db0411372b7d13369f4e891e3d018dbda3a363d58cb273ed7b2457c75a","nodeId":"dd66564e-e23c-4315-941a-7df638d3aac6","outcome":"completed","summary":"The review of `contracts/src/earn/` at b686e0e found two low-severity defects, both in `_moved`, and no critical, high or medium ones; they are written to `.imd-findings.json` in the repository root.\n\n## Were the previous findings fixed?\n\n- **High, low 3, info 4–5 (royalty conversion): fixed.** The hook no longer holds or swaps anything. The hook, token and mirror all reject plain ETH, and `royaltyInfo` returns 1% to the live `feeRecipient()`.\n- **Low 2 (dust gift resets the expiry timer): not fully fixed.** The direct holder-to-holder gift is closed, but the same reset still works through the PoolManager (finding 1 below).\n\n## Findings\n\n1. **Low — anyone can still reset a holder's 30-day timer for 1 wei** (`PepesEarnToken.sol:217`).\n   - **Cause:** any transfer from an excluded address counts as the recipient's activity, and the PoolManager is excluded.\n   - **Reproduction:** inside an unlock, a third party calls `poolManager.take(EARN, victim, 1)` and settles with 1 wei of their own $EARN. There is no swap, so no fee.\n   - **Result:** alice's `expiredRewardsOf` went from 5999999999999999999 to 0, `lastActive(alice)` became now, and `recycle(alice)` returned 0.\n   - **Test gap:** `test_expiry_dustGiftDoesNotResetTimer` only covers the direct transfer.\n\n2. **Low — buying an NFT through a marketplace does not mark the buyer active** (`PepesEarnToken.sol:194`).\n   - **Cause:** on the NFT path `actor` is the marketplace operator, never the buyer, so a returning holder's timer is not updated. The seller in the same sale is marked active.\n   - **Reproduction:** alice is last active at t0. She receives an NFT from bob via an operator at t0+20d.\n   - **Result:** at t0+31d her whole ~6 IMD balance is recyclable.\n   - **Fix:** this needs a scope decision, because a gift and a marketplace purchase look the same on-chain. Either document that only pool buys, sends and claims reset the timer, or count receipt of a whole unit as activity.\n\n## Checked and found sound\n\n- **Expiry when an incoming transfer is not recorded:** the \"recent\" exemption uses the current balance, which can only be higher than the balance at the cutoff. Expired rewards can therefore only be under-counted, never over-counted, so no holder loses recent rewards.\n- **`buybackAndBurnPepes` / `maxBuyback`:**\n  - A sandwich on the real $Pepes pool lost money at every attacker size from 100 to 300,000 IMD, even after the attacker claimed its $Pepes dividends.\n  - The cap cannot be inflated by flash liquidity, because the v1 router's own unlock reverts inside another unlock.\n  - No $Pepes dividends were stranded on the $EARN token after two buybacks.\n  - The allowance is zeroed after each buyback (asserted by the existing unit test).\n- **Hook entry points and mirror linking:** nothing new since the last diff.\n\n## Verification\n\n- The 27 `PepesEarnTest` unit tests pass.\n- Both `PepesEarnForkTest` fork tests pass against `https://robinhood.drpc.org`.\n- Both findings were reproduced with scratch tests on this commit. The scratch tests extended the project's own test contracts, so they are not self-contained; I removed them and attached no `proof` (not required for low severity).\n\n## Not done\n\n- No repository files were changed.\n- `PepesEarnRenderer.sol` and `LibEarnString.sol` were not reviewed.\n- I did not run all twelve specialty passes from the reference guide; the review concentrated on the changed code, `_moved`/expiry, the buyback and the hook's entry points.\n- No static analysis and no long fuzz runs.","treeHash":null,"usage":{"cachedInputTokens":909436,"inputTokens":23,"model":"claude-fable-5-1","outputTokens":19670,"runtime":"claude","turns":15,"wallClockMs":406699}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"433c37ef2c9c708d","findings":[],"hash":"6fae1e938b8900bb9575d221fe4008195c3f49c85b5b22ee43194d179e93b9a5","nodeId":"4e2425c5-96a3-4308-81c1-ac623125fdac","outcome":"failed","summary":"submodules did not fetch: Submodule 'contracts/lib/dn404' (https://github.com/Vectorized/dn404) registered for path 'contracts/lib/dn404'\nSubmodule 'contracts/lib/forge-std' (https://github.com/foundry-rs/forge-std) registered for path 'contracts/lib/forge-std'\nSubmodule 'contracts/lib/v4-core' (https://github.com/Uniswap/v4-core) registered for path 'contracts/lib/v4-core'\nCloning into '/home/imd4/.identitymd/work/a58eb2c","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":null,"outputTokens":0,"runtime":"claude","turns":0,"wallClockMs":9160}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"61b4050710026370","findings":[],"hash":"8f87868eb7aead13e8e0acfcb5512409e6af5b81f023edaeeea97728c86c5de3","nodeId":"e786d9f4-ac47-49f9-93c2-1b0ff49be346","outcome":"failed","summary":"runtime reported <synthetic>, not the required premium model claude-fable-5-1","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"<synthetic>","outputTokens":0,"runtime":"claude","turns":1,"wallClockMs":4339}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"468e82a89b9bfe18","findings":[{"citation":"resolved","description":"After the re-check fix, an incoming transfer resets the recipient's 30-day timer only when the tokens come from an excluded address (a pool buy through the routers), when the recipient is the msg.sender of the move, or when the recipient has never held. An NFT bought on a marketplace arrives through PepesEarnMirror.transferFrom called by the marketplace contract (Seaport-style conduit or escrow), so `actor` is the marketplace, `from` is the seller (not excluded) and the buyer already holds, so none of the three conditions is true. The same holds for a pool buy through a third-party v4 router that `take`s to itself and forwards the tokens. The expiry arithmetic itself remains correct (verified: an unrecorded inflow can only under-estimate what expires, and is exact when the inflow predates the cutoff), but the README's wording 'no $EARN it bought or pulled itself' reads as if any purchase counts; a collector who keeps buying NFTs on a marketplace but never claims will have everything earned more than 30 days ago recycled 30 days after their last pool trade or claim. This is a documentation/UX gap, not a loss path: expired rewards go to the burn reserve, nobody else can take them, and a claim at any time resets the timer. Suggested fix: state explicitly on the site and in the README that marketplace purchases and gifts do not reset the timer and only a claim, a pool trade through the PepesFamily routers, a send, or a pull the wallet itself calls does (the chain cannot tell a purchase from a gift, so counting operator transfers would reopen re-check finding 2).","line":217,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"Scratch test test_operatorTransferToExistingHolder_notActivity (test/scratch/EarnSeams.t.sol, run against PepesEarnTest's setUp): t0: alice buys 10 IMD of $EARN through the router (lastActive[alice] = t0), bob buys 10 IMD. Both approve an operator `market` via mirror.setApprovalForAll. t0+10d: `market` calls mirror.transferFrom(bob, alice, id) (a marketplace sale to alice). Expected by the README wording: alice's timer resets to t0+10d. Actual: earn.lastActive(alice) == t0 (bob's is t0+10d), and at t0+30d+1 expiredRewardsOf(alice) equals everything alice earned before t0+1 although she bought an NFT 20 days earlier. Control: alice calling earn.transferFrom(bob, alice, 1e18) herself does set lastActive[alice] = t0+10d.","severity":"info","snippet":"            if (fromExcluded || actor == to || lastActive[to] == 0) lastActive[to] = block.timestamp;","title":"Marketplace (operator-executed) NFT purchases do not count as the buyer's activity, so an active collector's older rewards can still expire"}],"hash":"9416d28ab9df6dba14bd8bc6c8765381e757895edc6e1e18e90d14ddcbdcca86","nodeId":"de0d3826-4db7-49f4-a5cc-3236dec1fd02","outcome":"completed","summary":"Review complete. The findings file holds one info-level item and no defects above that.\n\n**Prior findings are fixed.** The royalty conversion path (the earlier high, low 3 and info 4 and 5) is gone from the hook: no `receive`, no swap, no WETH, no held balance. The mirror now returns a 1% royalty to the hook's live fee recipient. Low 2 is fixed as described in `_moved`.\n\n**Expiry math after the `_moved` change is correct.** An unrecorded incoming transfer can only raise the balance, which inflates the \"recent\" term and so under-estimates what expires. When the inflow predates the 30-day cutoff the result is exact, because the correction already excludes rewards the gifted tokens did not earn. I checked this with a 40-step randomized sequence over 256 seeds (buys, sells, sends, pulls, operator NFT transfers, claims, recycles, warps, buybacks) against a shadow ledger of every distribution. Expired never exceeded ground truth, matched it to rounding when the balance was constant, and the solvency, eligible-supply and IMD balance identities held at every step.\n\n**Buyback and cap.** `maxBuyback` reads 2% of the $Pepes pool's virtual IMD reserve from active liquidity, which is correct for the v1 single-position pool while price is in range. Manipulating price before the call rescales the cap with the depth, so a sandwich still gains about 4% against 8% of v1 hook fees. The fork test on live Robinhood state confirms: cap 80.87 IMD per call, a 3,000 IMD sandwich loses 150 IMD. Reserve decrements match the router pull exactly, and the balance identity holds across repeated capped steps.\n\n**One info finding** at `contracts/src/earn/PepesEarnToken.sol:217`: an NFT bought on a marketplace arrives via the operator, so it does not reset the buyer's timer. The README wording \"no $EARN it bought\" suggests otherwise. No loss path, since expired rewards only reach the burn reserve and a claim resets the timer. Fix is documentation.\n\n**Also checked, no issue:** fee rounding in both swap modes, `toInt128` casts, launch liquidity rounding against the buffer in both orientations, `marketCap`, checkpoint and `magAt` boundaries, `_toInt` overflow bounds (unreachable below ~8.5e16 IMD distributed), renderer index ranges, and the base64 encoder.\n\n**Not covered:** no stateful invariant run of the hook's six-nine-oh-nine claim balance, and no live marketplace integration. Unit suite (30 tests) and fork suite (2 tests) pass at this commit.","treeHash":null,"usage":{"cachedInputTokens":2680094,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":62889,"runtime":"claude","turns":40,"wallClockMs":1390620}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ca080fd306399669","findings":[{"citation":"resolved","description":"_moved treats every transfer whose sender is an excluded address as a buy by the recipient (`fromExcluded`) and sets lastActive[to]. The PoolManager is excluded, and anyone can make it the sender to an arbitrary recipient: inside their own unlock they call poolManager.take($EARN, victim, 1) and repay the 1 wei with sync / transfer / settle. No swap happens, so no 4% fee is paid; the cost is 1 wei of $EARN plus gas. The victim did nothing, yet its timer restarts, so the dust gift that the fix was meant to neutralise still keeps a wallet's rewards from expiring, now routed through the pool instead of a direct transfer. Repeated once per 30 days per wallet, it keeps the expired rewards of lost or abandoned wallets out of the $Pepes buyback reserve indefinitely. The existing test test_expiry_dustGiftDoesNotResetTimer only covers a direct earn.transfer gift. Fix without changing the design: when the sender is the PoolManager, count the receipt as activity only if the recipient can be tied to the action (for example `to == tx.origin`, or `actor == to` for the other excluded senders), instead of `fromExcluded` alone; buys through the project's routers are taken to the buyer who sent the transaction, so they stay recorded.","line":217,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"State (unit test setup of contracts/test/PepesEarn.t.sol): alice buys with 100 IMD, bob buys with 100 IMD, warp 31 days. expiredRewardsOf(alice) = 5999999999999999999. Attacker contract D holds 1 wei $EARN (bob transfers it 1 wei) and runs: pm.unlock -> in unlockCallback: pm.take(Currency.wrap(earn), alice, 1); pm.sync(Currency.wrap(earn)); earn.transfer(address(pm), 1); pm.settle(). Expected: alice did not act, so lastActive(alice) is unchanged, expiredRewardsOf(alice) stays about 5999999999999999999 and recycle(alice) moves it to the reserve. Actual (run locally with forge): lastActive(alice) == block.timestamp, expiredRewardsOf(alice) == 0 and recycle(alice) returns 0.","severity":"low","snippet":"            if (fromExcluded || actor == to || lastActive[to] == 0) lastActive[to] = block.timestamp;","title":"Re-check low 2 is still reachable: a 1-wei $EARN sent through PoolManager.take resets any wallet's 30-day timer"},{"citation":"resolved","description":"After the fix a receipt only counts as activity when the tokens come from an excluded address, when `actor == to`, or for a first-time holder. A marketplace purchase satisfies none of these for an existing holder: the marketplace contract (the seller's approved operator) calls mirror.transferFrom(seller, buyer, id), so `actor` is the marketplace, `from` is the seller, and lastActive[buyer] is already non-zero. The same holds for any purchase where another contract delivers the tokens (an accepted offer, an aggregator or third-party pool that forwards $EARN, a withdrawal from a custodian). The source comment argues expiry 'stays correct' because the larger balance only over-estimates recent rewards, but that covers the amount computation only, not the timer: the buyer has just bought $EARN, the README lists '$EARN it bought' as activity and the site says rewards expire only if a wallet 'doesn't claim or move any $EARN for 30 days', yet anyone can recycle everything the buyer earned more than 30 days ago immediately after the purchase. Before this change the purchase reset the timer. Marketplace trading of the NFTs is an advertised path (ERC-2981 royalty), so this is a regular user flow, and the loss is permanent for the holder (the IMD goes to the buyback reserve). Fix options that keep the design: also record the receipt when `to == tx.origin` (the buyer submitted the purchase), which a third-party dust gift cannot satisfy; or state in the README and on the site that only claims, sends and pool buys reset the timer and marketplace purchases do not.","line":217,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"State (unit test setup of contracts/test/PepesEarn.t.sol): alice buys with 100 IMD at day 0; bob buys with 100 IMD (alice's withdrawableDividendOf = 5999999999999999999); bob calls mirror.setApprovalForAll(market, true). Warp 31 days. The marketplace `market` calls mirror.transferFrom(bob, alice, id) for one of bob's NFT ids with alice as tx.origin (alice buys the NFT): alice's $EARN balance rises by 1e18. Warp 1 day and call earn.recycle(alice) from any account. Expected: alice acquired $EARN one day ago, so she is active and recycle returns 0. Actual (run locally with forge): lastActive(alice) is still the day-0 timestamp (2678400 seconds old at the purchase), recycle(alice) returns 5999999999999999999 and that IMD moves from alice's claimable rewards into buybackReserve.","severity":"low","snippet":"            if (fromExcluded || actor == to || lastActive[to] == 0) lastActive[to] = block.timestamp;","title":"Low-2 fix makes a wallet that buys an NFT on a marketplace stay 'inactive': its older rewards can be recycled right after the purchase"}],"hash":"ac1c05c36202858e9d871ebcf7840ee88cb5450339fa48493bff9f76b9817cad","nodeId":"8857c242-7e07-4cb2-9615-22396c5545c6","outcome":"completed","summary":"The review is written to `.imd-findings.json`: two low findings, both in `_moved` at `contracts/src/earn/PepesEarnToken.sol:217`, and nothing critical, high or medium. No repository files were changed.\n\n**Fixes confirmed**\n- **Royalty conversion (high, low 3, info 4–5):** it is gone. The hook has no `receive()`, holds and swaps nothing, and the mirror reads `feeRecipient()` live for the 1% ERC-2981 royalty. I did not have the text of the earlier findings, so this is confirmed against your description of the change, not against the original report.\n- **Low 2:** the direct dust gift (`earn.transfer(alice, 1)`) no longer resets the timer, but the fix is incomplete — see the first finding.\n\n**New findings**\n1. **Low — the dust-gift reset is still reachable through the PoolManager.** Anyone can, inside their own unlock, call `poolManager.take($EARN, victim, 1)` and repay the 1 wei. The sender is the excluded PoolManager, so `_moved` treats it as a buy by the victim and restarts the timer. No swap, no fee; the cost is 1 wei plus gas. In my local test, alice's expired rewards went from 5999999999999999999 to 0 and `recycle(alice)` returned 0.\n2. **Low — a marketplace purchase no longer counts as activity for an existing holder.** The marketplace contract is the `actor`, the seller is `from`, and the buyer's `lastActive` is already set, so none of the three conditions match. In my local test, alice bought an NFT 31 days after her last activity, and one day later `recycle(alice)` moved her full 5999999999999999999 of older rewards into the buyback reserve. The README lists \"$EARN it bought\" as activity, so this contradicts the documented rule.\n\nBoth findings carry a suggested fix that keeps the design (tie the receipt to the recipient, e.g. `to == tx.origin`), or for the second, simply document that marketplace purchases don't reset the timer. Neither carries a proof file, since those are only required for critical or high.\n\n**Expiry amounts and buyback**\n- **Expiry amount when an incoming transfer isn't recorded:** the comment in the code is right about the amount. The larger balance only over-estimates \"recent\" rewards, so expired is never over-stated. The problem is the timer, not the amount (finding 2).\n- **`buybackAndBurnPepes` / `maxBuyback`:** no defect found. By my own arithmetic, a sandwich at the 2% cap loses at any pump size, given 4% per leg on the $Pepes pool. One edge I did not report: a caller who already holds roughly 70% or more of circulating $Pepes would recover enough of their own fees to profit; that depends on the external pool's holder concentration.\n\n**Tests:** the 27 unit tests in `PepesEarn.t.sol` pass, and both fork tests pass against `https://robinhood.drpc.org` (the sandwich attacker loses about 150.6 IMD). My two reproduction tests were run locally and then deleted.","treeHash":null,"usage":{"cachedInputTokens":1181435,"inputTokens":27,"model":"claude-fable-5-1","outputTokens":19882,"runtime":"claude","turns":18,"wallClockMs":1142485}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"5739ce0d803a43cd","findings":[{"citation":"resolved","description":"_moved treats every receipt whose sender is an excluded address as the recipient's own buy (`fromExcluded`), on the assumption that tokens leaving the pool were bought by the recipient. The PoolManager is excluded, and anyone can make it the sender to an arbitrary address: inside their own `poolManager.unlock` they call `poolManager.take(EARN, victim, 1)` and repay the pool with 1 wei of their own $EARN (`sync` / `transfer` / `settle`). No swap happens, so the hook charges no fee; the whole cost is 1 wei of $EARN plus gas. The token sees from = poolManager and msg.sender = poolManager, so `fromExcluded` is true and lastActive[victim] = block.timestamp although the victim did nothing. A dust swap through any third-party v4 router with the victim as recipient does the same (the 4% fee on dust rounds to 0). This is the same griefing as re-check low 2, routed through the pool instead of a plain transfer: repeated once per 30 days per wallet it keeps the rewards of lost or abandoned wallets out of the $Pepes buyback reserve indefinitely. Only the direct holder-to-holder gift is fixed, and test_expiry_dustGiftDoesNotResetTimer covers only that path. Nobody steals anything, so the severity stays low as in the re-check. The token cannot distinguish a router buy from an arbitrary `take`: both project routers deliver with `poolManager.take(token, user, out)`. Fix options that keep the design: (a) stop inferring activity from `fromExcluded`; let receipts reset the timer only for a first-time holder or when `actor == to`, so a buyer's timer restarts on its next claim or send (and document that); (b) have the project's routers report the buyer to the token through a function restricted to `router` / `ethRouter` (needs a router change, since the routers are reused unchanged); (c) require a receipt from an excluded address to be at least one whole token before it counts, so a reset costs the attacker a real $EARN gifted to the victim each 30 days; or (d) accept and document that anyone can keep any wallet's rewards from expiring. Reproduced with the attached test and again with a Forwarder helper in test/scratch (4 specialist reports of this issue merged here).","line":217,"path":"contracts/src/earn/PepesEarnToken.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 {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {PepesEarnIMD} from \"src/earn/PepesEarnIMD.sol\";\nimport {PepesEarnToken} from \"src/earn/PepesEarnToken.sol\";\nimport {PepesEarnRenderer} from \"src/earn/PepesEarnRenderer.sol\";\nimport {PepesFamilyRouter} from \"src/PepesFamilyRouter.sol\";\n\ncontract MockERC20 {\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 from, address to, uint256 amt) external returns (bool) {\n        allowance[from][msg.sender] -= amt;\n        balanceOf[from] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\n/// @dev Anyone: moves 1 wei of $EARN out of the PoolManager to `victim` and repays the pool with 1 wei of its own.\ncontract DustTaker is IUnlockCallback {\n    IPoolManager immutable pm;\n    PepesEarnToken immutable t;\n    address victim;\n\n    constructor(IPoolManager pm_, PepesEarnToken t_) {\n        pm = pm_;\n        t = t_;\n    }\n\n    function poke(address v) external {\n        victim = v;\n        pm.unlock(\"\");\n    }\n\n    function unlockCallback(bytes calldata) external returns (bytes memory) {\n        pm.take(Currency.wrap(address(t)), victim, 1);\n        pm.sync(Currency.wrap(address(t)));\n        t.transfer(address(pm), 1);\n        pm.settle();\n        return \"\";\n    }\n}\n\ncontract DustTakeTest is Test {\n    PoolManager pm;\n    MockERC20 imd;\n    PepesEarnIMD hook;\n    PepesEarnToken earn;\n    PepesFamilyRouter router;\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address owner = makeAddr(\"owner\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        imd = new MockERC20();\n        bytes memory initCode = abi.encodePacked(\n            type(PepesEarnIMD).creationCode,\n            abi.encode(\n                pm, address(imd), owner, owner, int24(0), PepesEarnIMD.ImdEthPool(10_000, 100, address(0)), address(0xBEEF), address(0xCAFE)\n            )\n        );\n        // mine a CREATE2 salt so the hook address carries the hook flags in its low 14 bits\n        bytes32 h = keccak256(initCode);\n        uint256 salt;\n        for (;; salt++) {\n            address a = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(salt), h)))));\n            if (uint160(a) & 0x3FFF == 0x28CC) break;\n        }\n        address deployed;\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n        hook = PepesEarnIMD(deployed);\n        router = PepesFamilyRouter(hook.router());\n        earn = new PepesEarnToken(address(hook), address(new PepesEarnRenderer()));\n        vm.prank(owner);\n        hook.openPool(address(earn));\n        address[2] memory users = [alice, bob];\n        for (uint256 i; i < 2; i++) {\n            imd.mint(users[i], 1_000e18);\n            vm.prank(users[i]);\n            imd.approve(address(router), type(uint256).max);\n        }\n    }\n\n    function test_dustTakenFromPoolManagerMustNotResetTimer() public {\n        vm.prank(alice);\n        router.buy(address(earn), 100e18, 0, block.timestamp);\n        vm.prank(bob);\n        router.buy(address(earn), 100e18, 0, block.timestamp); // alice earns\n        vm.warp(vm.getBlockTimestamp() + 31 days);\n        uint256 expired = earn.expiredRewardsOf(alice);\n        assertGt(expired, 0, \"alice's rewards have expired\");\n\n        // bob (any third party) gifts alice 1 wei, routed through the PoolManager instead of a plain transfer\n        DustTaker d = new DustTaker(pm, earn);\n        vm.prank(bob);\n        earn.transfer(address(d), 1);\n        d.poke(alice);\n\n        assertGe(earn.expiredRewardsOf(alice), expired - 1, \"a third party's dust gift reset alice's 30-day timer\");\n    }\n}","reproduction":"Unit setup of contracts/test/PepesEarn.t.sol (or the attached self-contained test). alice buys with 100 IMD, bob buys with 100 IMD (alice earns 5999999999999999999 wei IMD), warp 31 days: expiredRewardsOf(alice) = 5999999999999999999. bob sends 1 wei $EARN to a helper contract D; any account calls D.poke(alice), which runs pm.unlock and in unlockCallback: pm.take(Currency.wrap(earn), alice, 1); pm.sync(Currency.wrap(earn)); earn.transfer(address(pm), 1); pm.settle(). Expected (per the comment at lines 212-214 and re-check low 2): alice did not act, lastActive(alice) unchanged, expiredRewardsOf(alice) >= 5999999999999999998 and recycle(alice) moves it to the reserve. Actual on b686e0e: lastActive(alice) == block.timestamp, expiredRewardsOf(alice) == 0, recycle(alice) returns 0. The attached test fails with 'a third party's dust gift reset alice's 30-day timer: 0 < 5999999999999999998'.","severity":"low","snippet":"            if (fromExcluded || actor == to || lastActive[to] == 0) lastActive[to] = block.timestamp;","title":"Re-check low 2 is only half closed: 1 wei of $EARN moved out of the PoolManager by anyone still resets any wallet's 30-day timer"},{"citation":"resolved","description":"After the low 2 change a receipt resets the recipient's timer only when the tokens come from an excluded address, when `actor == to`, or for a first-time holder. A marketplace sale satisfies none of these for an existing holder: the marketplace (the seller's approved operator or conduit) calls mirror.transferFrom(seller, buyer, id), so `actor` (the mirror's msgSender passed at line 194) is the marketplace, `from` is the seller (not excluded) and lastActive[buyer] is already set. The same holds for a pool buy through a third-party v4 router that takes to itself and forwards, an accepted offer, an escrow or a custodian withdrawal. The seller in the same sale is marked active by the operator's call, so the two sides of one trade are treated differently. Before this change the purchase reset the timer. The README (contracts table, 'no $EARN it bought or pulled itself') and the site ('doesn't claim or move any $EARN for 30 days', 'or move any $EARN') tell holders a purchase keeps their rewards, but a collector who keeps buying NFTs on a marketplace and never claims has everything earned more than 30 days ago recycled 30 days after its last pool trade, send or claim; the loss to the holder is permanent (the IMD goes to the buyback reserve), though nobody else can take it. Marketplace trading is an advertised path (ERC-2981 royalty), so this is a regular user flow. I checked the expiry arithmetic the brief asks about and it stays safe: an unrecorded inflow only raises balanceOf, so `recent` at line 287 is over-estimated, never under-estimated (scratch tests: a gift before the cutoff gives expired == A1 exactly; a gift after the cutoff gives expired 5143749999999962499 < A1 = 5999999999999999999). Only the activity timer is affected. The chain cannot tell a purchase from a gift, so this needs a scope decision rather than a one-line fix: either state in the README and on the site that only claims, sends, pulls the wallet makes itself and pool buys through the PepesFamily routers reset the timer (marketplace purchases and gifts do not), or count the receipt of at least one whole unit (an NFT) as activity, which makes a griefing gift cost a full $EARN rather than dust, or treat all receipts alike per option (a) of the previous finding and document that. Four specialist reports merged.","line":217,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"Unit setup of contracts/test/PepesEarn.t.sol at t0 = 1800000000: alice buys with 100 IMD, bob buys with 100 IMD (withdrawableDividendOf(alice) = 5999999999999999999). Warp to t0+20 days. bob calls mirror.setApprovalForAll(market, true); market calls mirror.transferFrom(bob, alice, id) for one of bob's ids with alice as tx.origin (vm.prank(market, alice)): alice's $EARN balance rises by 1e18. Expected per README/site: alice's timer restarts at t0+20d, so at t0+31d expiredRewardsOf(alice) == 0 (control: _buy(alice, 1e18) at t0+20d gives lastActive(alice) == t0+20d and expiredRewardsOf(alice) == 0 at t0+31d). Actual on b686e0e: lastActive(bob) == 1801728000 (t0+20d) but lastActive(alice) == 1800000000 (t0); at t0+31d expiredRewardsOf(alice) == 5999999999999999999 and recycle(alice), callable by anyone, moves that IMD into buybackReserve. Scratch test test_judge_marketplacePurchaseIsNotActivity in test/scratch/EarnJudge.t.sol.","severity":"low","snippet":"            if (fromExcluded || actor == to || lastActive[to] == 0) lastActive[to] = block.timestamp;","title":"A marketplace NFT purchase (or any intermediary-delivered buy) no longer counts as the buyer's activity, so a buying wallet's older rewards can be recycled right after it buys"},{"citation":"resolved","description":"Since the low 2 change an incoming transfer from a non-excluded address that the recipient did not initiate no longer touches lastActive, so three places now misdescribe the code: line 107 ('Last claim or $EARN balance change of each holder'), lines 284-285 ('The balance cannot have changed since `last` (any change marks the holder active)') and the contract notice at lines 52-53 ('neither claimed nor moved any $EARN'). The expiredRewardsOf maths does not rely on the line 284 claim (an unrecorded inflow only over-estimates `recent`, see the previous finding), so this is documentation only, but a future reader of line 284 would conclude the balance is constant since `last` and could build on that. Fix: reword line 107 to 'last claim, send, own pull or pool buy', and line 284 to say the balance can only have grown since `last` (sends always record activity), which is why using the current balance over-estimates recent rewards in the holder's favour; align the notice at lines 52-53 and the README/site wording with whichever rule the previous finding settles on.","line":284,"path":"contracts/src/earn/PepesEarnToken.sol","reproduction":"State: alice buys at t0 and bob buys (alice earns). At t0+20d bob transfers 50e18 $EARN to alice (bob initiates). Read earn.lastActive(alice): comment at line 107 says it is the time of her last balance change (t0+20d); actual value is t0. Scratch test test_judge_expiryAmountWithUnrecordedInflow_afterCutoff shows the balance did change since `last` while expiredRewardsOf still returned a value below the true expired amount (5143749999999962499 vs 5999999999999999999), i.e. the line 284 premise is false but the result is safe.","severity":"info","snippet":"        // The balance cannot have changed since `last` (any change marks the holder active), so what it earned","title":"Comments and NatSpec still describe lastActive as updated on every balance change, which the low 2 fix made false"}],"hash":"e42474cbcb5c03e482f211ff3c8766af2586ea2047053abef5b6a8571f36ab49","nodeId":"e786d9f4-ac47-49f9-93c2-1b0ff49be346","outcome":"completed","summary":"The review is complete and the findings file is written. No tracked files were changed. Only the scratch tests and the findings JSON were added.\n\n**Outcome.** The three fixes the requester described are confirmed on commit b686e0e. The royalty conversion is gone, the hook has no receive function and holds nothing, and the mirror's royalty is 1% to the live fee recipient. The fork test against Robinhood Chain passed both cases. The buyback and its cap check out: the live v1 pad charges 4% per leg and rejects outside liquidity, and the sandwich test on the fork shows the attacker losing money. The expiry arithmetic stays safe when an incoming transfer is not recorded, since the current balance can only over-estimate recent rewards.\n\n**Kept findings** (nine specialist reports merged into three):\n\n- **Low, line 217 of PepesEarnToken.** Re-check low 2 is only half closed. Anyone can call `poolManager.take` for 1 wei to a victim inside their own unlock and repay it, or route a dust swap to the victim. The token sees the PoolManager as sender, treats it as a buy, and resets the victim's timer. Reproduced with the specialist's proof test, which fails as stated, and with my own helper. The proof is attached.\n- **Low, line 217.** A marketplace NFT purchase, or any intermediary-delivered buy, no longer counts as the buyer's activity, because the operator is the actor and the seller is not excluded. Reproduced: the seller's timer resets, the buyer's does not, and anyone can recycle the buyer's older rewards right after the purchase. The README and site say buying resets the timer. This needs a scope decision, so no proof is attached.\n- **Info, line 284.** Three comments still say every balance change updates the activity timer, which the fix made false. The expiry maths does not depend on that claim.\n\n**Dropped or merged.** Nothing was dropped. The four PoolManager reports merged into finding one, the four marketplace reports into finding two, and the stale-comment remarks into finding three.\n\n**Not found.** No new issue in `buybackAndBurnPepes` or `maxBuyback`.","treeHash":null,"usage":{"cachedInputTokens":912471,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":25109,"runtime":"claude","turns":23,"wallClockMs":558591}}],"verification":[]}