{"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":"65ce90fa-871f-4e2c-a110-9e3d99a786f0","kind":"audit","nodes":[{"acceptedSubmissionHash":"351cd15167bbe79556a1824dffc7ca8c09fbaae5d5e79100719d0ac4b9a97fba","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":"7b8e82647ffd0c9128587f45261462a69e2aee4ceb6c54fbe1f572a58a46ec46","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":"044779c644ea6595995b65aa1deadcd43c28e85c312469e48da45e3f74cd2c32","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"ee40e1f6f09dfed24528bce13307cd9a44ddc1b17e714dce3f0b1c98894daade","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":"86d0a2ec611b5cccc3ed4cfa444170ac602e5f7b887002cf89624ea9a58bea96","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 the three Ponzinomics contracts in src/pimd at this commit. PimdToken is a fixed-supply ERC-20: exactly 1,000,000,000 at 18 decimals, minted once in the constructor, no owner, no mint and no burn function (burning is a plain transfer to DEAD, so totalSupply never moves). PimdHook is a Uniswap V4 hook that taxes every trade in IMD, 2.4% on buys and 5.6% on sells with the pool's own 1.25% on top, split 75% to holders and 25% to the team, taking its fees as ERC-6909 claims on the quote currency. The IMD launch factory constructs the hook and opens the pool itself, so the team wallet, the engine address, the quote token and the opening tick (129,000, tolerance 300) are all source constants. PimdEngine pushes the holders' IMD into wallets weighted by balance times hold-streak, the tiers being zero under an hour and then 0.5x, 1x, 1.5x, 2x and 3x from fourteen days; tally weighs the whole set in one call and pay is paged. Round 3, and the surface that matters is everything since the round-2 commit f182e9f. Your round-2 high is closed by taking the choice of read instant away: `tally` is now restricted to keeper addresses fixed in the constructor, while `fire`, `pay`, `register`, `prune` and `abortEpoch` stay open to anyone. Then four independent review passes found nine more things, all fixed here, and three of those were defects in the fixes themselves -- so treat the fixes as the least trustworthy code in the tree, not the most. In order of how much I would like a second opinion: (a) the keeper tip budget is now shared out by phase rather than first come, via `_tipTo(to, amt, floorBps)`, and inside `pay` each page may draw only its pro-rata slice of what is left, because reserving a share for the phase left the first page taking all of it and the tail unpaid -- attack the arithmetic, the paging, `abortEpoch` interactions, and whether any sequence leaves the budget or the pot inconsistent with what was paid; (b) `fireTip` is no longer paid by `fire` but recorded in `epochFirer` and paid by `tally`, only where the epoch weighs something; (c) `_shapeChanged` carves out EIP-7702 delegation stubs via new inline assembly in `_isDelegationStub`, which deliberately makes a guard return false where it previously returned true; (d) a full holder set now reclaims a dead slot through a bounded cursor sweep rather than refusing registration for ever; (e) `minInterval` is 15 minutes in production and the holder bound is 700 under a constructor ceiling of 800. Four things are accepted and documented rather than fixed, so please do not re-report them as defects: weight is balance times tier and a rented balance held across the tier-0 hour earns like any other; a contract that cannot forward IMD can be registered by a stranger unless named at bind; `fireTip` is a ceiling rather than a guarantee because the fire floor caps it at a tenth of the budget; and the tip floors order the draw without rationing between actors, so one party holding several roles collects every share, bounded only by the 5% `TIP_BUDGET_BPS`. Earlier rounds, for context: Since f182e9f this commit answers your high by taking the choice of read instant away -- `tally` is now restricted to keeper addresses fixed in the constructor, everything else stays permissionless -- and your finding 2 and finding 7 (the reclaim cursor could not survive the revert, and the 900 ceiling was measured with balances unchanged). Two independent reviews then found six more, all fixed here: an EIP-7702 delegation is carved out of `_shapeChanged`, a zero-weight epoch pays no per-holder tip, a deferred registration emits an event, `totalToTeam` is net of the tip, `Fired` moved after the early return, and the holder bound is 700 under a ceiling of 800. Attack the keeper split hardest: say whether a non-keeper can still reach engine state at a moment of its choosing through `register`, which writes `lastBal`, or through `_reclaimSlot`, which reads balances and removes entries. Note two things are accepted and documented rather than fixed, so do not re-report them as defects: weight is balance times tier and a rented balance held across the tier-0 hour earns (the NatSpec on `tally` says so), and a contract that cannot forward IMD can be registered by a stranger unless named at bind. Earlier context: flush no longer tips the engine, a full holder set now reclaims a dead slot instead of locking everyone out for ever, bind excludes the launch factory and register refuses precompiles, and the holder bound is 800 under a constructor ceiling of 900. Check each of those actually closes what you found, and say whether any of them opened something new -- the reclaim path in particular, which is permissionless and removes an entry. The rest of the holder-set logic is still only one audit old. Four things in particular. First, register probes an address with _isPool but can only see the code that is there at the time, so it now records a vettedCodeless bit and tally calls _shapeChanged, which takes all weight off an address once code arrives where there was none. Say whether that really closes the play of picking a CREATE2 address, funding it, registering it while it is still empty, letting the streak mature and only then deploying pair code into it, and whether it can be evaded from the other side by an address that carries code from the start. Second, _shapeChanged is blunt on purpose: any code arriving voids the verdict, a legitimate EIP-7702 delegation included, and prune drops a holder on that same test so the address can register again on what it now is. Confirm there is no reachable state in which a holder earns nothing and cannot be pruned, because the engine has no owner and that would be permanent. Third, prune now drops a holder on its shape as well as its size and is permissionless: confirm it cannot be aimed at a holder who should keep earning, and that the swap-and-pop is still right when the pruned holder is the last element. Fourth, the exclusion list at bind is the token, imd, the hook, the PoolManager, the engine, the team, address(0) and DEAD, plus whatever the binder names. Say whether anything else can hold PIMD, be registered, and then be unable to forward an IMD payout. Then the standing ones. Tally weighs the whole set in one call against min(bal, lastBal) so a single bag cannot be counted once per wallet it is moved through, which was the high you found last time: confirm it holds. The engine must never read holder weights while the PoolManager is unlocked, which is where a flash borrower would stand. One holder who cannot receive IMD must not be able to stall a batch. beforeRemoveLiquidity is the whole safety case for letting the launch factory hold the liquidity position: it must refuse every negative liquidityDelta for ever, from any caller including the position's owner and the hook itself, while allowing a zero delta so the pool's own fee collection still works. beforeAddLiquidity must allow exactly one add, the factory's seed, and refuse every later one, reentrancy and the hook calling itself included. beforeInitialize is the only gate on the pool's shape: confirm it cannot be bypassed and that every assumption the tax maths makes is enforced there, in particular that IMD is currency0. And the fee accounting: claims minted in beforeSwap and afterSwap must always equal holdersOwed plus teamOwed, with nothing double counted or stranded, and flush must not be able to pay out more than was taken. The engine pulls from the hook inside a try/catch, which has hidden one breakage from us already: say whether that pattern is safe here. Report findings rather than fixing them, and do not propose changes to the economics, the tax rates, the split or the tier ladder.","parentJobId":null,"planHash":"58126ad168af9c7db10fa6883bed17261f63e7851e3f29d35f0121aad79e0b7c","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"65ce90fa-871f-4e2c-a110-9e3d99a786f0","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":"52232","feedbackHash":"60d98cda64c07a18ba5bff854a9c3a0ddaf3c638d44008f5d4b5bf30c9210c8e","nodeKey":"audit_economics","submissionHash":"351cd15167bbe79556a1824dffc7ca8c09fbaae5d5e79100719d0ac4b9a97fba","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52173","feedbackHash":"c4d183d34fe6d1e478debb824a08a221cc50a7ff2b1ff07661fa2fc5ddddc470","nodeKey":"audit_flow","submissionHash":"7b8e82647ffd0c9128587f45261462a69e2aee4ceb6c54fbe1f572a58a46ec46","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51291","feedbackHash":"2dec461870cbd55bcf0bcd854ae4245db6571672589dcce50c55ef171f32cfef","nodeKey":"audit_judge","submissionHash":"044779c644ea6595995b65aa1deadcd43c28e85c312469e48da45e3f74cd2c32","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52165","feedbackHash":"01fb7bedb98573615f7e4ee5630da6d9dc4922ae53eb030ccae0fa0cac0a403f","nodeKey":"audit_math","submissionHash":"ee40e1f6f09dfed24528bce13307cd9a44ddc1b17e714dce3f0b1c98894daade","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52163","feedbackHash":"f15c6741161d127c14e36e9520269f49acf86cbdca6de0a36e79bb299369cc7d","nodeKey":"audit_permissions","submissionHash":"86d0a2ec611b5cccc3ed4cfa444170ac602e5f7b887002cf89624ea9a58bea96","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"0fe243962fe5dd05dfcce234e1bd75f3e05e2ab3376c125ef4ecdcb039e770fe","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"bd7adba3a8045853","findings":[{"citation":"resolved","description":"`pay` adds the caller's page size to `cursor` and only afterwards clamps `end` to `epochCount`. On the first page `cursor == 0` so any argument works; on every later page `cursor > 0` and an argument above `2^256 - 1 - cursor` (type(uint256).max is the natural 'all of it' value) hits Panic(0x11) before the clamp. `tally` is safe at the same point because it compares (`maxHolders < epochCount`) rather than adds. Nothing is lost and a smaller argument succeeds, but the finishing pages are exactly the ones the round-3 pro-rata tip exists to reward, and a finisher whose transaction reverts is one more way a tail goes unpaid until `abortEpoch`. Minimal fix: clamp before adding, e.g. `uint256 left = epochCount - start; if (maxHolders > left) maxHolders = left; uint256 end = start + maxHolders;`. Merged from audit_math.","line":566,"path":"src/pimd/PimdEngine.sol","reproduction":"Local harness (PimdBaseTest, 4% drip, 2 min minInterval): buy 300 IMD of PIMD for alice and bob, register both, warp 2 days. keeper: fire(); tally(500) -> phase == Pay, epochCount == 2. Anyone: pay(1) -> cursor == 1. Anyone: pay(type(uint256).max). Expected: pays bob and closes the epoch. Actual: reverts with arithmetic overflow (Panic 0x11) at `start + maxHolders`; a following pay(500) succeeds and closes the epoch. Reproduced in test/scratch/Judge.t.sol::test_pay_max_uint_on_second_page_reverts (asserts the revert with stdError.arithmeticError, then the recovery).","severity":"low","snippet":"        uint256 end = start + maxHolders;","title":"pay() computes start + maxHolders in checked arithmetic before clamping, so a 'pay the rest' call with a large argument reverts on every page after the first"},{"citation":"resolved","description":"The comment block at lines 544-549 inside the `tw == 0` branch of `tally` describes the pre-round-3 design: `fire` paying `fireTip` before anything is weighed, so a null epoch is 'bounded but not closed' and the tip 'is gone from the pot'. In the code as it stands `fire` pays nothing and only records `epochFirer` (line 457), and `tally` pays the firer at line 556 only on the `phase = Phase.Pay` path, which the null branch returns before (line 551). So the paragraph asserts a pot leak that no longer exists, in exactly the fix the requester asked to have re-read (item b), and the requester treats wrong comments as defects (f1d843f). The same stale sentence is repeated in test/pimd/PimdFixes.t.sol lines 407-410 above test_a_null_epoch_pays_no_per_holder_tip. The `lastFire has advanced` half of the paragraph is still accurate. Code is right; the comment is wrong. Merged from audit_flow, audit_permissions, audit_math and audit_economics (four identical reports).","line":544,"path":"src/pimd/PimdEngine.sol","reproduction":"State: a registered holder set that weighs nothing (every holder registered under an hour ago), pot > 0, phase Idle, elapsed >= minInterval. Non-keeper X calls fire(); a keeper calls tally(epochCount). Expected per the comment: X's IMD balance rose by fireTip (or a tenth of the budget) and pot fell by it. Actual: X's IMD balance is unchanged, pot is back to its pre-fire value, tipBudget == 0. The existing test test/pimd/PimdFixes.t.sol::test_a_null_epoch_pays_the_firer_nothing asserts the actual behaviour and passes, contradicting the comment next to the code it tests. fire() at lines 412-458 contains no _tipTo call.","severity":"info","snippet":"                // This does NOT make a null epoch free. `fire` paid `fireTip` at the top, before","title":"tally's null-epoch comment still says fire paid fireTip up front, which commit 78fc912 removed"},{"citation":"resolved","description":"The null-epoch branch of tally closes the budget explicitly (`tipBudget = 0`, line 550). Neither of the other two ways an epoch ends does: pay's closing block (lines 596-605) returns `leftover` to the pot and zeroes epochQuote/epochPaidQuote but leaves tipBudget and epochTipBudget at their last values, and abortEpoch (lines 633-646) resets epochQuote, epochPaidQuote, epochPaidHolders, totalWeight and cursor but not these two. Between epochs the public getters `tipBudget()` and `epochTipBudget()` therefore report an open budget for an epoch that no longer exists, contradicting the field comments ('what is left of this epoch's keeper tips'). No fund impact: `_tipTo` is reached only from tally (Phase.Tally) and pay (Phase.Pay), and the next fire overwrites both before either phase can run; verified that pot == imd.balanceOf(engine) after a close, after an abort and after the following epoch, and that fire resets tipBudget to 5% of the new drip. It is the one state inconsistency the requester's item (a) asked about, and the fix is two assignments: `tipBudget = 0; epochTipBudget = 0;` in pay's `end == epochCount` block and in abortEpoch. Merged from audit_permissions.","line":633,"path":"src/pimd/PimdEngine.sol","reproduction":"Local harness, two holders registered 2 days ago, keeper: fire(); tally(500); pay(500) -> phase == Idle yet tipBudget() == 315276846767364698 and epochTipBudget() == 337276846767364698. Then fire() again, warp 1 day, abortEpoch(): tipBudget() == epochTipBudget() == the entire budget of the aborted epoch. Expected: 0 after an epoch has ended by any path, as the null-tally path does. Reproduced in test/scratch/Judge.t.sol::test_tip_budget_left_open_after_close_and_abort; test_stale_budget_cannot_be_drawn_between_epochs shows pay and tally both revert WrongPhase in Idle and the next fire overwrites the budget, so nothing can draw on it.","severity":"info","snippet":"    function abortEpoch() external nonReentrant {","title":"tipBudget and epochTipBudget are left at stale values after a normal close and after abortEpoch; only the null-tally path zeroes them"},{"citation":"resolved","description":"`tally(uint256 maxHolders)` (line 501) and `pay(uint256 maxHolders)` (line 562) take a parameter named identically to the immutable `maxHolders` declared at line 111, which is the bound register enforces at line 366. solc 0.8.26 emits warning 2519 for both. Inside these two bodies the immutable is unreachable and every `maxHolders` is the caller's page size, so `maxHolders < epochCount` on line 506 reads at a glance as a comparison of the configured bound against the epoch size when it is the page argument. Behaviour is as intended today, but a future check written in either function against 'the bound' would silently compare against the caller's argument and compile. Fix: rename the parameters (e.g. `pageSize`); parameter names are not part of the selector so the ABI is unchanged. Merged from audit_flow and audit_permissions.","line":501,"path":"src/pimd/PimdEngine.sol","reproduction":"`forge build --force` prints 'Warning (2519): This declaration shadows an existing declaration' at src/pimd/PimdEngine.sol:501:20 and :562:18, each noting the shadowed declaration at :111:5. Input: a keeper calls tally(1) with epochCount == 3. Expected by a reader who takes `maxHolders` on line 506 for the immutable (800): no revert. Actual: TallyMustBeWhole(3), because the name resolves to the argument 1.","severity":"info","snippet":"    function tally(uint256 maxHolders) external nonReentrant {","title":"tally and pay name their page-size parameter maxHolders, shadowing the immutable holder bound of the same name"},{"citation":"resolved","description":"When the budget binds (tipPerHolder * n > tipBudget * n / m), a page covering n of the m uncovered holders draws tipBudget * n / m, leaving tipBudget * (m - n) / m for m - n holders: the remaining budget per uncovered holder is invariant, so every page is paid the same rate B/m per holder and the last page is paid the same rate as the first, not more. When the budget does not bind every page gets tipPerHolder per holder, also flat. The comment at lines 614-617 and the NatSpec at line 75 ('finishing an epoch is always the best-paid page rather than the worst') therefore overstate the fix: the tail is no longer starved, which was the defect, but finishing is paid the same per holder as sniping, and only earns more by covering more holders. No tip exceeds the budget and pot + epochQuote == balance held in every sequence tried (paged pay, abort mid-pay, refire), so this is a documentation inaccuracy, not a security defect. Merged from audit_economics.","line":617,"path":"src/pimd/PimdEngine.sol","reproduction":"Local harness with a pot small enough that the budget binds: three holders of 1 IMD each registered 2 days ago; keeper fire(); tally(500) -> tipBudget 758872905226570 < 3 * tipPerHolder (3 * 5e14). Three different addresses each call pay(1). Expected per the comment: the third (finishing) page is paid more than the first. Actual tips: 252957635075523, 252957635075523, 252957635075524 (the finisher gets one wei of rounding). Reproduced in test/scratch/Judge.t.sol::test_pro_rata_pages_pay_the_same_per_holder_when_budget_binds; the non-binding case (budget 0.48 IMD) pays 5e14 to each of three pagers (test_pro_rata_pages_pay_the_same_per_holder).","severity":"info","snippet":"        // keeps finishing an epoch the best-paid thing to do rather than the worst.","title":"pay's pro-rata comment says finishing an epoch is the best-paid page; the arithmetic pays every page the same per holder"},{"citation":"resolved","description":"This is the direct answer to the standing question whether a non-keeper reaches engine state at a moment of its choosing through `register`, and it is a documentation finding, not a re-report of the accepted rented-balance item. `register` writes `lastBal` from the balance at the registrant's instant, and `tally` compares only that and the balance at the keeper's instant. Two consequences: (1) a freshly registered wallet is weighed in full at the very next keeper count once its hour is up, so the sentence at line 486 ('tokens must survive one epoch before they earn') does not hold for a new registration; the bag need only be present at registration and at one count, not across the hour between them. (2) A bag that leaves and returns between two keeper counts (bal == lastBal at both) neither resets nor blends the streak, so line 37 ('Selling or sending PIMD out restarts the clock') is only true if the bag is still out when the keeper counts; the clock matures to 3x while the bag is absent most of the time. What keeps this from being a defect: a token can be in only one registered wallet at each count, the loop is whole-set and keeper-timed, and a net reduction at any count resets the streak, so total weight per token per epoch is conserved and nobody is paid twice. No code change proposed; correct the two sentences so the next review does not rediscover it. Merged from audit_math.","line":486,"path":"src/pimd/PimdEngine.sol","reproduction":"test/scratch/Judge.t.sol. (1) test_register_then_present_only_at_the_keeper_instant: alice and bob buy 300 IMD of PIMD each and are registered; bob at once sends his whole bag (84204498602874296400475686 wei) to a parking address; warp 2 hours; the bag returns to bob in the keeper's block; keeper fire(); tally(500). Expected per line 486: bob carries no weight. Actual: totalWeight == alice_bal * 5000/10000 + bob_bag * 5000/10000, bob weighed in full. (2) test_round_trip_between_tallies_keeps_streak: bob registers; for 20 daily epochs the whole bag is out for 23 hours and back one hour before the keeper's count. Expected per line 37: streakStart restarts. Actual: streakStart unchanged after 20 epochs and holderInfo reports tierBps 30000.","severity":"info","snippet":"    /// is. The deliberate cost: tokens must survive one epoch before they earn, so a fresh buy waits a","title":"Two NatSpec claims about the streak do not match min(bal, lastBal): a bag only has to be present at two keeper instants, and a round trip between counts never restarts the clock"},{"citation":"resolved","description":"The README contradicts the code on every number and role that matters: it states 3%/7% tax (PimdHook.sol:63-64 are 240 and 560 bps, 2.4%/5.6%), a 60/20/20 split with a hook-side buy-and-burn (HOLDERS_BPS is 7_500 and PimdHook.sol:36-39 say the burn is the pool's own 1.25% fee handled outside the contract), that the hook 'Launches the pool single-sided' through a `PimdHook.launcher` calling `launch()` (lines 19, 28, 43, 90; grep finds no `launch(` in src/pimd, the pool is opened and seeded by the launch factory via beforeInitialize/beforeAddLiquidity), that 'the token's whole supply mints directly to the hook' (line 36; PimdToken.sol:34 mints to msg.sender, the factory), that `launch()` reverts with BadCurrencyOrder (line 43; the error at PimdHook.sol:175 is never thrown), '32 local tests' (85 run), and 'the contract only enforces a two minute floor between epochs' (line 107; minInterval is a constructor parameter, 15 minutes in production per script/DeployPimd.s.sol). It never mentions that `tally` is keeper-only. The README is the first thing an integrator reads, so these are the numbers they will build against. Fix: rewrite the numbers, the launch description and the role list (binder, keepers, launch factory) to match this commit. Merged from audit_permissions.","line":6,"path":"README.md","reproduction":"Compare README.md lines 6-7, 19, 28, 36, 43, 56, 86-91, 107 against src/pimd/PimdHook.sol:63-65 (BUY_TAX_BPS = 240, SELL_TAX_BPS = 560, HOLDERS_BPS = 7_500), `grep -rn 'launch(' src/pimd/` (no result), src/pimd/PimdToken.sol:34 (`_mint(msg.sender, INITIAL_SUPPLY)`), src/pimd/PimdEngine.sol:501-502 (tally reverts NotKeeper for non-keepers), script/DeployPimd.s.sol (minInterval 15 minutes when production), and `forge test` (85 tests). Expected: the README states the economy and roles the code implements. Actual: it states the pre-launch-factory design.","severity":"info","snippet":"- **3% on buys, 7% on sells**, taken in IMD by the hook","title":"README describes a different economy, launch path and role set from the contracts at this commit"}],"hash":"044779c644ea6595995b65aa1deadcd43c28e85c312469e48da45e3f74cd2c32","nodeId":"80571218-a065-4a1e-93a7-397012a12401","outcome":"completed","summary":"The tip is capped at a share of `toTeam`, so the net booking cannot underflow and the hook change is sound. The review is complete and the findings file is written.\n\n**Outcome.** `.imd-findings.json` holds seven findings, one low and six info. No critical or high survived, so no proof files were attached. The round-3 fixes the requester asked about hold up under test: the tip arithmetic, paging and `abortEpoch` never left the pot or budget inconsistent with what was paid, the delegation-stub carve-out matches EIP-7702 and EIP-3541 semantics, the reclaim sweep behaves, and a 700-holder worst-case `tally` measured about 14M gas against the 32M cap.\n\n**Kept, each reproduced in test/scratch/Judge.t.sol or by direct comparison:**\n- **Low:** `pay` adds `start + maxHolders` before clamping, so `pay(type(uint256).max)` reverts with an overflow panic on every page after the first. A smaller argument recovers.\n- **Info:** the null-epoch comment in `tally` still says `fire` paid the tip up front. Four specialists reported this identically, merged into one.\n- **Info:** `tipBudget` and `epochTipBudget` stay at stale nonzero values after a normal close and after `abortEpoch`. No fund impact, since nothing can draw on them in Idle and the next `fire` overwrites them.\n- **Info:** the `maxHolders` parameters of `tally` and `pay` shadow the immutable bound. Two reports merged.\n- **Info:** the pro-rata comment claims finishing an epoch is best paid, but three single-holder pages were tipped identically when the budget binds.\n- **Info:** two NatSpec sentences about the streak are inaccurate. A bag present only at registration and at one keeper count is weighed in full, and a bag out for 23 hours of every day kept its streak to 3x over 20 epochs. Framed as documentation, not as the accepted rented-balance item.\n- **Info:** the README still describes the 3%/7%, 60/20/20, hook-launched design.\n\n**Dropped.** The specialist's observation that `abortEpoch` mid-pay returns the tail's share to the common pot reproduced numerically, but the `abortEpoch` NatSpec already states that unpaid IMD returns to the pot, so it is documented design rather than a defect.\n\nNo source files were changed. The scratch tests under test/scratch are disposable.","treeHash":null,"usage":{"cachedInputTokens":1704631,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":32006,"runtime":"claude","turns":22,"wallClockMs":949674}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"82692ab5f7f7e54e","findings":[{"citation":"resolved","description":"The comment inside the tw == 0 branch of tally (lines 544-549) describes the pre-fix tip flow: it says fire paid fireTip before anything was weighed and that this cost is gone from the pot, so a null epoch is bounded but not closed. Since commit 78fc912 fire only records epochFirer (line 457) and the fire tip is paid by tally at line 556, after the tw == 0 early return at line 551, so a null epoch pays nothing at all; the contract's own header comment on epochFirer (lines 188-193) and the test test_a_null_epoch_pays_the_firer_nothing say so. The stale text sits in exactly the fix the requester asked to have re-checked (item b) and tells a reader the opposite of what the code does. Code behaviour is correct; this is a documentation defect only.","line":544,"path":"src/pimd/PimdEngine.sol","reproduction":"State: a registered holder set that weighs nothing (every holder under an hour old), pot > 0, phase Idle. Call fire() from address F, then tally(n) from a keeper. Expected per the comment at line 544: fireTip has already left the pot to F. Actual: F's IMD balance is unchanged, pot is unchanged apart from the epochQuote round-trip, tipBudget is 0 (lines 537-551). The existing test test/pimd/PimdFixes.t.sol::test_a_null_epoch_pays_the_firer_nothing asserts exactly this and passes.","severity":"info","snippet":"                // This does NOT make a null epoch free. `fire` paid `fireTip` at the top, before\n                // anything had been weighed and before anything could know the epoch was worthless,\n                // and that is gone from the pot. `fire` is permissionless while this is keeper-only,","title":"tally's null-epoch NatSpec still says fire paid fireTip up front, which this commit removed"},{"citation":"resolved","description":"When the budget binds (tipPerHolder * n > tipBudget * n / m), a page covering n of the m uncovered holders draws tipBudget * n / m, which leaves tipBudget * (m - n) / m for m - n holders: the remaining budget per uncovered holder is invariant, so every page is paid the same per-holder rate B/m and the final page is paid the same rate as the first, not more. The comments at lines 614-617 ('keeps finishing an epoch the best-paid thing to do rather than the worst') and the test's assertion that the finisher earns more than the sniper only hold because the finisher covers more holders. The tail is no longer starved, which was the defect being fixed, so this is a doc/design-claim inaccuracy rather than a security defect; no tip can exceed the budget and the ledger pot + epochQuote == balance held in every sequence tried (paged pay, abort mid-pay, refire).","line":620,"path":"src/pimd/PimdEngine.sol","reproduction":"State after tally: tipBudget = 0.9 IMD, epochCount = 3, tipPerHolder = 1 IMD (budget binds). pay(1) from A: share = 0.9 * 1 / 3 = 0.3, A is tipped 0.3, tipBudget = 0.6. pay(1) from B: share = 0.6 * 1 / 2 = 0.3, B is tipped 0.3, tipBudget = 0.3. pay(1) from C (the finishing page): share = 0.3 * 1 / 1 = 0.3, C is tipped 0.3. Expected per the comment: the finishing page is the best paid. Actual: all three pages are paid identically per holder.","severity":"info","snippet":"            uint256 share = (tipBudget * (end - start)) / (epochCount - start);\n            _tipTo(msg.sender, want < share ? want : share, 0);","title":"pay's pro-rata tip pays every page the same per-holder rate; the comment's claim that finishing is best paid is not what the arithmetic gives"}],"hash":"351cd15167bbe79556a1824dffc7ca8c09fbaae5d5e79100719d0ac4b9a97fba","nodeId":"b23739f2-7e95-416d-b7c5-0d3bd4b1fc77","outcome":"completed","summary":"## Scope\n\nFiles read in full: `src/pimd/PimdEngine.sol` (869 lines), `src/pimd/PimdHook.sol` (537), `src/pimd/PimdToken.sol` (49), the full diff since `f182e9f`, the base harness, the fix and attack test names, the deploy script config, and the Lock slot in v4-core. The 85 offline tests pass. Two fork tests against the live Robinhood Chain IMD also pass, including a real-IMD engine payout under the 60k send cap.\n\n**Severity counts:** 0 Critical · 0 High · 0 Medium · 0 Low · 2 Info\n\nBoth findings are in `.imd-findings.json`. They are documentation defects inside the two fixes the requester asked to have re-checked, not exploitable behaviour.\n\n- **[I-1] `PimdEngine.sol:544`.** The null-epoch comment in `tally` still says `fire` paid `fireTip` up front and that cost is gone from the pot. Since this commit the firer is recorded and paid only by `tally` after the zero-weight return, so a null epoch pays nothing. The existing null-epoch test proves the code, and the comment says the opposite.\n- **[I-2] `PimdEngine.sol:620`.** The pro-rata page tip pays a constant per-holder rate when the budget binds. With budget 0.9 and three holders paged one at a time, each page draws exactly 0.3. The claim that finishing is the best-paid page is not what the arithmetic gives. The tail is no longer starved, which is what mattered.\n\n## Coverage, by the requester's priority list\n\n**(a) Tip budget and paging.** Traced `_tipTo` floors by hand: the firer gets at most 10% of the budget plus one wei of rounding, `tally` cannot draw below the 45% floor, and the sum of all page tips never exceeds what `tally` left. A scratch test ran paged `pay(1)`, abort after a day, refire, and full pay. At every Idle point `pot + epochQuote` equalled the engine's IMD balance, and mid-pay it equalled that minus the holders already paid. Abort leaves `tipBudget`, `epochTipBudget` and `epochFirer` stale, but nothing can draw on them until the next `fire` overwrites all three. Stale `h.weight` after an abort mid-pay is always rewritten by the next whole-set tally, and removal deletes the record, so `pay` never reads a weight from a previous epoch.\n\n**(b) Deferred fire tip.** `epochFirer` is only written in `fire` after the early return and only read in `tally` after the zero-weight return. No path pays a firer for an epoch they did not open.\n\n**(c) `_isDelegationStub`.** The `mstore` zero, three-byte `extcodecopy` and `shr(232)` together isolate exactly the first three bytes. EIP-3541 blocks any CREATE or CREATE2 code beginning `0xEF`, so a 23-byte deploy cannot forge the prefix. Stylus programs start `0xEFF000` and are never 23 bytes, so they still trip the guard. An address that carries code from the start is probed by `_isPool` and never shape-checked, so a later turn into a pair only ends at `prune`, which the requester documented.\n\n**(d) Reclaim sweep.** The cursor is written before `_removeAt`, so the element swapped in is re-examined next time. Removal of the last element writes the same record twice in the right order. During an epoch the sweep declines and nothing moves under `tally` or `pay`.\n\n**(e) Bound.** A fresh 800-holder tally with every balance changed measured 29.64M gas, about 37,050 per holder, matching the author's figure and under the 32M limit. The second epoch cost about 19,900 per holder.\n\n**Keeper split.** `register` writes `lastBal` at the caller's instant, but the first tally after the tier-0 hour overwrites it, and weight still needs the bag present at the keeper's instant. `_reclaimSlot` reads balances only to remove entries that `prune` would remove anyway. Neither gives a non-keeper a usable read instant.\n\n**Standing checks.** The holder count, liquidity lock, single seed, `beforeInitialize` ordering, fee-claim equality and flush accounting all hold as in prior rounds. The hook changed only the engine constant and the net-of-tip team total this round, and both are correct.\n\n## Observation, not a defect\n\nThe live IMD at the quote address is a","treeHash":null,"usage":{"cachedInputTokens":1255774,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":54266,"runtime":"claude","turns":25,"wallClockMs":1066150}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c2e167266d13dcf4","findings":[{"citation":"resolved","description":"The comment inside the `tw == 0` branch of `tally` (lines 544-549) describes the pre-78fc912 design: it says `fire` paid `fireTip` at the top, that the tip is gone from the pot, and that a null epoch is therefore 'bounded but not closed'. In the code as it stands `fire` pays nothing (it only records `epochFirer`, line 457) and `tally` pays the firer only on the `phase = Phase.Pay` path (line 556), which the null-epoch branch returns before. So the paragraph asserts a pot leak that no longer exists. The requester explicitly treated wrong comments as defects in f1d843f ('Five comments of mine that said the wrong thing'), and this one sits in exactly the code they asked to have re-read (item b). The same stale sentence is repeated in test/pimd/PimdFixes.t.sol:407-410 above `test_a_null_epoch_pays_no_per_holder_tip`. No behavioural impact; the code is right and the comment is wrong.","line":544,"path":"src/pimd/PimdEngine.sol","reproduction":"State: a registered holder whose streak is under an hour (tier 0), pot > 0, elapsed >= minInterval. Input: address X (non-keeper) calls `fire()`; then a keeper calls `tally(epochCount)`. Expected per the comment: X's IMD balance rose by `fireTip` and `pot` fell by it. Actual: X's IMD balance is unchanged, `pot` is back to its pre-fire value plus nothing was deducted, `tipBudget == 0`. The existing test `test_a_null_epoch_pays_the_firer_nothing` already asserts the actual behaviour, contradicting the comment next to the code it tests.","severity":"info","snippet":"                // This does NOT make a null epoch free. `fire` paid `fireTip` at the top, before\n                // anything had been weighed and before anything could know the epoch was worthless,\n                // and that is gone from the pot. `fire` is permissionless while this is keeper-only,","title":"tally's null-epoch NatSpec still says fire paid fireTip up front, which 78fc912 removed"},{"citation":"resolved","description":"`tally(uint256 maxHolders)` (line 501) and `pay(uint256 maxHolders)` (line 562) take a parameter named identically to the immutable holder bound `maxHolders` (line 111). solc 0.8.26 emits warning 2519 for both. Inside these two functions the immutable is unreachable and every `maxHolders` is the caller's page size, so the check `maxHolders < epochCount` on line 506 reads, at a glance, as a comparison of the configured bound against the epoch size when it is actually the page argument. Behaviour is as intended today (the bound is enforced in `register`, not here), but a future edit that intends to reference the bound inside either function will silently get the argument.","line":501,"path":"src/pimd/PimdEngine.sol","reproduction":"Build: `forge build` prints 'Warning (2519): This declaration shadows an existing declaration' for src/pimd/PimdEngine.sol:501:20 and :562:18. Input: a keeper calls `tally(1)` with `epochCount == 3`. Expected by a reader who takes `maxHolders` on line 506 for the immutable (800): no revert. Actual: `TallyMustBeWhole(3)` because the name resolves to the argument 1.","severity":"info","snippet":"    function tally(uint256 maxHolders) external nonReentrant {","title":"tally and pay parameter `maxHolders` shadows the immutable of the same name"},{"citation":"resolved","description":"This is a second opinion on item (a), not a re-report of the round-4 fix. The pro-rata page tip changes the incentive to finish an epoch but not the outcome when nobody does: `abortEpoch` returns the unpaid tail's share to the common pot, from which the next drip is released at `dripBpsPerPeriod` and shared again by weight across every holder, including the prefix that was already paid in full. The arithmetic is consistent throughout (I checked `imd.balanceOf(engine) == pot + epochQuote - epochPaidQuote` after every step of fire/tally/partial pay/abort/fire/tally/pay, and that pages of one sum exactly to the reserve), so the budget and the pot are never inconsistent with what was paid. But the tail's entitlement is not preserved as an entitlement; it becomes pot. The NatSpec on `abortEpoch` ('Holders already paid keep what they received') states the first half of this and not the second. If that is the intended trade-off it is worth saying so in the NatSpec so the next reviewer does not raise it as the round-4 reviewer did.","line":636,"path":"src/pimd/PimdEngine.sol","reproduction":"State (test config: 4%/period drip, 2 min minInterval): five holders of roughly equal bags and equal 2-day streaks, pot large. Sequence: `fire()`; keeper `tally(5)`; anyone `pay(2)` (holders 0 and 1 paid); warp 1 day; `abortEpoch()`; warp 15 min; `fire()`; `tally(5)`; `pay(5)`. Expected if the tail's share were preserved: holders 2-4 end near holders 0-1. Actual measured: holders 0 and 1 hold 2.149 and 1.926 IMD, holders 2, 3 and 4 hold 0.075, 0.068 and 0.062 IMD, because the second drip is 4% of the returned pot and is split five ways. The invariant on the pot held at every step; only the distribution is skewed.","severity":"info","snippet":"        uint256 unpaid = epochQuote - epochPaidQuote;\n        pot += unpaid;","title":"abortEpoch mid-pay leaves the paid prefix with a full epoch share and the tail with a deferred, diluted one"}],"hash":"7b8e82647ffd0c9128587f45261462a69e2aee4ceb6c54fbe1f572a58a46ec46","nodeId":"741f9790-341d-4db5-b57e-0e5dbee683b2","outcome":"completed","summary":"Findings are written to `.imd-findings.json` and the scratch test is removed. The review is complete.\n\n**Outcome: no material defect found in the round-3 surface.** The three contracts build cleanly, the 85-test suite passes, and the five areas the requester ranked for a second opinion all hold up under concrete sequences. The file holds three informational notes, none of which carries a proof because none is high or critical.\n\n**What I verified, in the requester's order**\n\n- **Tip budget (a).** I traced fire, tally, paged pay and abort with a scratch test. The engine balance equalled pot plus epochQuote minus epochPaidQuote after every step. Pages of one holder summed exactly to the reserve, the last page took the remainder, and a failed send restored both pot and budget. The only residual is distributional: an abort mid-pay returns the tail's share to the common pot, where the already-paid prefix shares it again. That is noted as info, with measured numbers, since the NatSpec only states half of it.\n- **Firer tip in tally (b).** Paid only on the Pay path, never on the null-epoch path. The comment inside that null-epoch branch still describes the old up-front payment and is wrong. Reported as info.\n- **7702 carve-out (c).** The assembly is correct, the 0xEF prefix cannot come from CREATE or CREATE2, and a delegate that answers as a pair is still caught by prune. The one widening the author names is real and already documented.\n- **Reclaim sweep (d).** Cursor survives a miss, a removal at the last index is handled by the ordering in the swap-and-pop, and the sweep is idle-only, so no epoch walks a shortened array.\n- **Bounds (e).** Measured tally at 34.9k gas per fresh holder with unchanged balances. The worst case of 38k times 800 stays under the 32M ceiling.\n\n**Keeper split.** A non-keeper reaches lastBal only through register, which skips registered addresses, so it cannot re-base an existing holder. Reclaim removes only entries that prune would also remove. Neither gives a non-keeper a weighing instant.\n\n**Also checked without finding fault:** the hook's four swap cases against the PoolManager's delta accounting, the claim invariant, the partial-fill guard, the liquidity lock, the single seed, and the try/catch around flush, which cannot be starved of gas without also starving the rest of fire.","treeHash":null,"usage":{"cachedInputTokens":1150743,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":47037,"runtime":"claude","turns":21,"wallClockMs":753353}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"52c98c0dc01791cd","findings":[{"citation":"resolved","description":"The comment block at lines 544-549 describes the pre-round-3 behaviour: `fire` paying `fireTip` before anything is weighed, so that a null epoch still costs the pot a tip. Since commit 78fc912 `fire` only records `epochFirer` (line 457) and the tip is paid at line 556, after `tw != 0` is established; the null branch returns at line 551 before any `_tipTo`. A null epoch therefore costs the pot nothing, which the test `test_a_null_epoch_pays_the_firer_nothing` asserts. The comment says the opposite ('This does NOT make a null epoch free ... that is gone from the pot ... but not closed'), so a reader of the contract is told the pot leaks on every null epoch when it does not. Documentation only; no behavioural impact.","line":544,"path":"src/pimd/PimdEngine.sol","reproduction":"State: any epoch whose whole set weighs zero (e.g. the only registered holder registered under an hour ago). Call fire() as X, then tally(n) as a keeper. Expected per the comment at line 544: pot is lower by fireTip (or by a tenth of the budget) and X received it. Actual: X's IMD balance is unchanged, pot >= pot before fire (test/pimd/PimdFixes.t.sol test_a_null_epoch_pays_the_firer_nothing passes). Fix: replace lines 544-549 with the current rule: nothing is paid for a null epoch; lastFire still advances so the window's release is deferred, not lost.","severity":"info","snippet":"                // This does NOT make a null epoch free. `fire` paid `fireTip` at the top, before\n                // anything had been weighed and before anything could know the epoch was worthless,\n                // and that is gone from the pot. `fire` is permissionless while this is keeper-only,","title":"tally's null-epoch NatSpec still says fire pays fireTip, which (b) removed"},{"citation":"resolved","description":"The null-epoch branch of tally closes the budget explicitly (`tipBudget = 0`, line 550, 'Closing the budget as well stops the rest of it being spent'). Neither of the other two ways an epoch ends does: pay's closing block (lines 596-605) returns `leftover` to the pot and zeroes epochQuote/epochPaidQuote but leaves tipBudget and epochTipBudget at their last values, and abortEpoch (lines 633-646) resets every other epoch variable (epochQuote, epochPaidQuote, epochPaidHolders, totalWeight, cursor) but not these two. So between epochs the public getters `tipBudget()` and `epochTipBudget()` report an open tip budget for an epoch that no longer exists. Nothing can draw on it: `_tipTo` is reached only from tally (Phase.Tally) and pay (Phase.Pay), and the next fire overwrites both before either phase can run, so there is no fund impact and the pot ledger stays exact (verified: pot == imd.balanceOf(engine) after abort and after the following epoch). It is an inconsistency in the epoch's state that the requester asked about directly, and it contradicts the field comments ('what is left of this epoch's keeper tips').","line":641,"path":"src/pimd/PimdEngine.sol","reproduction":"Local test setup, two holders registered 2 days ago. fire(); tally(500); pay(500) as the keeper -> phase == Idle, yet tipBudget() == 315276846767364698 and epochTipBudget() == 337276846767364698 (budget minus the fire and tally tips; pay's want was below its share). Then fire() again, warp 1 day, abortEpoch(): tipBudget() == epochTipBudget() == 43934031218143628, the entire budget of the aborted epoch, still reported as 'left'. Expected: 0 after an epoch has ended by any path, as the null-tally path already does. Fix: set `tipBudget = 0` (and optionally `epochTipBudget = 0`) in pay's `end == epochCount` block and in abortEpoch.","severity":"info","snippet":"        totalWeight = 0;\n        cursor = 0;\n        phase = Phase.Idle;","title":"tipBudget and epochTipBudget are left stale after a normal close and after abortEpoch; only the null-tally path zeroes them"},{"citation":"resolved","description":"Both `tally(uint256 maxHolders)` (line 501) and `pay(uint256 maxHolders)` (line 562) name their page-size parameter identically to the immutable `maxHolders` declared at line 111, which is the holder-set bound register enforces at line 366. solc 0.8.26 emits warning 2519 for each. Inside these two bodies `maxHolders` is the caller-supplied page size, not the bound. Today neither body needs the immutable, so there is no behavioural defect, but any future check written in either function against 'the bound' (for example a gas guard `if (epochCount > maxHolders)`) would silently compare against the caller's argument instead and compile without error. The code base elsewhere uses `maxHolders` to mean the bound (constructor line 249, register line 366, NatSpec line 106).","line":501,"path":"src/pimd/PimdEngine.sol","reproduction":"forge build prints `Warning (2519): This declaration shadows an existing declaration` at src/pimd/PimdEngine.sol:501:20 and :562:18. Expected: no shadowing of a state variable by a parameter. Fix: rename the parameters (e.g. `pageSize`); ABI parameter names are not part of the selector, so the interface is unchanged.","severity":"info","snippet":"    function tally(uint256 maxHolders) external nonReentrant {","title":"tally and pay parameters shadow the immutable maxHolders"},{"citation":"resolved","description":"The README is the first thing an integrator or auditor reads and it contradicts the code on every number that matters: it states 3%/7% tax (PimdHook.sol:63-64 are 240 and 560 bps, 2.4%/5.6%), a 60/20/20 split with a buy-and-burn (HOLDERS_BPS is 7_500, there is no burn path in the hook; PimdHook.sol:36-39 explain the burn is the pool's own 1.25% fee handled outside this contract), that the hook 'Launches the pool single-sided' and has a `PimdHook.launcher` that calls `launch()` (lines 19, 28, 90; the hook has no launch function, the pool is opened and seeded by the launch factory via beforeInitialize/beforeAddLiquidity), that 'The token's whole supply mints directly to the hook' (line 36; PimdToken mints to msg.sender, the factory), that `tally` is permissionless ('32 local tests ... three tests drive this'; it is keeper-only since e534066), and 'the contract only enforces a two minute floor between epochs' (line 107; minInterval is a constructor parameter, 15 minutes in production per script/DeployPimd.s.sol:69). The 'Two things an auditor should look at first' section (line 43) still refers to `launch()` reverting with BadCurrencyOrder, which is unreachable (the error exists at PimdHook.sol:175 but is never thrown).","line":6,"path":"README.md","reproduction":"Compare README.md lines 6-7, 19, 28, 36, 43, 86-91, 107 against src/pimd/PimdHook.sol:63-65 (BUY_TAX_BPS = 240, SELL_TAX_BPS = 560, HOLDERS_BPS = 7_500), the absence of any `launch`/`launcher` symbol in src/pimd (grep returns none), src/pimd/PimdEngine.sol:501-502 (tally is keeper-only), and script/DeployPimd.s.sol:69 (minInterval 15 minutes in production). Expected: the README states the economy and roles the code implements. Fix: rewrite the README's numbers, the launch description and the role list (binder, keepers, launch factory) to match this commit.","severity":"info","snippet":"- **3% on buys, 7% on sells**, taken in IMD by the hook\n- split **60% holders / 20% buy-and-burn / 20% team**","title":"README describes a different economy and launch path from the contracts at this commit"}],"hash":"86d0a2ec611b5cccc3ed4cfa444170ac602e5f7b887002cf89624ea9a58bea96","nodeId":"77aba375-bfa9-4908-b151-8dba3bac1002","outcome":"completed","summary":"Review complete. `.imd-findings.json` holds four findings, all informational. I found no defect in the round-3 fixes that moves funds or breaks liveness.\n\n**What I verified, with concrete evidence**\n\n- **Tip budget (a).** Traced every draw through `_tipTo` and tested paged pay with abort: fire took exactly 10% of the budget, tally exactly up to the 45% floor, each pay page exactly `tipBudget · n / m`, and six pages of one holder summed to the same total as one page. Pot equals the engine's IMD balance after close, after abort, and after the following epoch. Stale weights left by an aborted pay are always overwritten by the next whole-set tally before any pay reads them.\n- **Fire tip via tally (b).** Null epochs pay nothing, the firer is paid only once weight exists, and `epochFirer` is always rewritten by `fire`. The only problem is the NatSpec at line 544, which still describes the old behaviour.\n- **Delegation stub (c).** The assembly is correct: three bytes copied, `shr(232)` keeps exactly them. EIP-3541 does make the prefix unforgeable by CREATE2. The widening the author documents is real but has no economic weight, since a 7702 account acting as a pair attracts no third-party liquidity.\n- **Reclaim sweep (d).** Tested a dead slot at the last index, swap-and-pop consistency of every `index1`, re-prune of a removed address, and that a stub holder is kept while a coded holder is reclaimed. Growth during an epoch is safe because shrinkage is Idle-only.\n- **Bounds (e).** Measured on a local anvil with real per-transaction cold storage: tally of 800 holders costs 26.9M gas in the first epoch (the true worst case, weight slots written from zero) and 13 to 16M afterwards. The 800 ceiling fits the 32M ArbOS limit with margin. The engine the hook names on Robinhood Chain is live, unbound, carries the production parameters and both keepers, and its bytecode equals this commit's build except for the immutable slots.\n- **Keeper split.** A non-keeper reaches `lastBal` only through a fresh registration, which starts at tier zero, and the blend dilutes any later top-up, so the only residual is the accepted rented-balance case. `_reclaimSlot` removes only entries that would weigh zero anyway.\n\n**The four findings**\n\n1. Stale comment in `tally` says `fire` paid `fireTip` and that null epochs cost the pot.\n2. `tipBudget` and `epochTipBudget` stay stale after a normal close and after `abortEpoch`; only the null path zeroes them. No fund impact.\n3. `tally` and `pay` parameters shadow the immutable `maxHolders`.\n4. The README describes the old economy and launch path.\n\n**Residual worth knowing, not reported as a defect:** if nobody finishes a paged pay within a day, the paid prefix keeps its share and the tail's share returns to the pot for everyone. The pro-rata tip changes the incentive, not the outcome. Scratch tests are in `test/scratch/` and nothing else in the tree was changed.","treeHash":null,"usage":{"cachedInputTokens":3012692,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":77717,"runtime":"claude","turns":38,"wallClockMs":1580201}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e12f98dda6acc55f","findings":[{"citation":"resolved","description":"`pay` computes `start + maxHolders` in checked arithmetic and only then clamps `end` to `epochCount`. On the first page `start == 0`, so any argument works, which is why the obvious test passes. On every later page `start > 0`, and an argument above `2^256 - 1 - cursor` (type(uint256).max being the natural 'all of it' value) hits Panic(0x11) before the clamp is reached. `tally` is safe at the same boundary because it compares rather than adds. Nothing is lost and a smaller argument succeeds, but it is the finishing pages, the ones the pro-rata tip fix in this round exists to reward, that revert, and a finisher whose transaction reverts is one more way a tail goes unpaid until `abortEpoch`. The minimal fix is to clamp before adding: `uint256 left = epochCount - start; if (maxHolders > left) maxHolders = left; uint256 end = start + maxHolders;`.","line":566,"path":"src/pimd/PimdEngine.sol","reproduction":"Test harness (PimdBaseTest): buy for alice and bob, register both, warp 2 days, keeper calls fire() then tally(500) so phase == Pay with epochCount == 2. Call engine.pay(1): cursor becomes 1. Call engine.pay(type(uint256).max). Expected: pays bob and closes the epoch. Actual: reverts with arithmetic overflow (Panic 0x11) at `start + maxHolders`; the following engine.pay(500) succeeds and closes the epoch. Reproduced in test/scratch/Gas800.t.sol::test_pay_max_uint_on_second_page_reverts (passes, i.e. the revert is observed).","severity":"low","snippet":"        uint256 end = start + maxHolders;","title":"pay() overflows on `start + maxHolders` once the cursor is nonzero, so a 'page the rest' call with a large argument reverts mid-epoch"},{"citation":"resolved","description":"Since commit 78fc912 `fire` only records `epochFirer`, and the tip is paid by `tally` at line 556 inside the `tw != 0` branch, so a null epoch pays the firer nothing (test_a_null_epoch_pays_the_firer_nothing asserts exactly that). The comment directly above `tipBudget = 0` still describes the removed behaviour and concludes the null epoch is 'bounded but not closed'. The code is right and the comment is wrong, and because the requester flagged the fixes as the least trustworthy code, a comment that documents the pre-fix behaviour as accepted is the kind of thing a later edit re-introduces. The `lastFire has advanced` half of the paragraph is still accurate.","line":544,"path":"src/pimd/PimdEngine.sol","reproduction":"Compare PimdEngine.sol:544-549 with fire() at :442-457: fire contains no _tipTo call. Run test_a_null_epoch_pays_the_firer_nothing in test/pimd/PimdFixes.t.sol: the firer's IMD balance is 0 after fire and after tally, and the pot does not shrink, contradicting 'that is gone from the pot'.","severity":"info","snippet":"                // This does NOT make a null epoch free. `fire` paid `fireTip` at the top, before","title":"Stale comment in tally's null-epoch branch says fire already paid fireTip; fire no longer pays anything"},{"citation":"resolved","description":"Not re-reporting the accepted rented-balance item; this is the direct answer to 'can a non-keeper reach engine state at a moment of its choosing through register'. Yes, in one narrow way: `register` writes `lastBal` from the balance at the registrant's instant. Because `tally` weighs `min(bal, lastBal)` and compares only those two instants, (1) a freshly registered wallet is weighed in full at the very next keeper count, so the NatSpec at :486 ('tokens must survive one epoch before they earn') does not hold for a new registration, the bag only has to be present at registration and at one keeper count, not across the hour between them; and (2) a bag that leaves and returns between two keeper counts (bal == lastBal at both) neither resets nor blends the streak, so the clock matures to 3x while the bag is absent most of the time. What stops this being a defect: a token can be in only one registered wallet at each count, the loop is whole-set and keeper-timed, and a net reduction at any count resets the streak, so total weight per token per epoch is unchanged and nobody is double paid. The practical consequence is only that the 'held across the hour' cost the tally NatSpec relies on is really 'present at two instants', which matters if a short-term PIMD rental market ever appears. No code change proposed; suggest correcting the two NatSpec sentences so the next review does not rediscover it.","line":376,"path":"src/pimd/PimdEngine.sol","reproduction":"test/scratch/Leads.t.sol: (1) test_register_then_present_only_at_the_keeper_instant: alice buys 300 IMD of PIMD (got = 103,680,960,444,816,776,386,714,779 wei), transfers it to bob, bob is registered (lastBal = got), bob sends the bag to a parking address, warp 2 hours, the bag returns to bob in the keeper's block, keeper fires and tallies: totalWeight == got * 5000 / 10000, bob is then paid in pay. (2) test_round_trip_between_tallies_keeps_streak: bob registers, and for 20 days moves the whole bag out for an hour and back before each epoch; streakStart is unchanged after 20 epochs and tierBps reads 30000. Expected per the NatSpec: a bag not held across the epoch carries no weight / a bag sent out restarts the clock. Actual: weighed in full and the clock never restarts, because only the two count instants are compared.","severity":"info","snippet":"            h.lastBal = uint128(bal);","title":"Answer to the keeper-split question: register() is a lastBal instant of the registrant's choosing, and the streak never sees the bag leave between keeper counts; weight is still conserved per token so"}],"hash":"ee40e1f6f09dfed24528bce13307cd9a44ddc1b17e714dce3f0b1c98894daade","nodeId":"a51cc87b-303a-424f-a8fb-cc54a650376c","outcome":"completed","summary":"The review is complete. The findings file is in place with three entries, no tracked file was changed, and the project's own 91 non-fork tests pass at this commit.\n\n**Verdict on the five areas you asked about**\n\n- **(a) Tip split and paging.** Sound. I fuzzed 400 random sequences of fire, keeper tally, paged pay with page sizes 1 to 4, abort after a day, prune, re-register, trades and whole-bag moves between holders. Two invariants never broke: engine IMD balance plus IMD paid this epoch is at least pot plus epochQuote, and tips collected per epoch never exceed 5% of that epoch's drip. The pro-rata share, the floors, failed-send restores and abort's `epochQuote - epochPaidQuote` all reconcile. The one defect is a boundary: on any page after the first, `pay(type(uint256).max)` reverts with an overflow at `start + maxHolders` before the clamp, so a finisher using max-uint to mean \"the rest\" fails on exactly the tail pages the fix rewards. Low, concrete repro in the file.\n- **(b) Deferred fire tip.** Correct. The only defect is the comment above `tipBudget = 0` in the null-epoch branch, which still says fire already paid the tip and that the null epoch is \"not closed\". The code pays nothing and the project's own test asserts that. Info.\n- **(c) Delegation carve-out.** Correct as written. The three-byte copy, the 232-bit shift and the EIP-3541 argument all hold, and a 23-byte CREATE2 contract without the prefix still voids the verdict. Nothing new beyond the widening you already documented.\n- **(d) Reclaim sweep.** Correct, including the last-element swap-and-pop and the cursor pointing at the moved entry. It can only remove entries that are already prunable, so a non-keeper cannot aim it at an earning holder.\n- **(e) Bounds.** No defect found. I could not reproduce your 37 to 38k per-holder cost because forge measures warm state inside one test, so I did not use my numbers to contradict yours.\n\n**The keeper-split question** is answered as an info finding rather than a defect, because it sits beside the accepted rented-balance item: `register` does hand a non-keeper one `lastBal` instant, so a fresh registration is weighed in full at the very next keeper count, and a bag that leaves and returns between counts never resets the streak. Weight per token per count is still conserved, so nothing is double paid. Two NatSpec sentences overstate what the code enforces.\n\n**Not found:** no rounding, cast, overflow or decimal defect in the fee formulas, the release curve or the hook's claim accounting. Those were already proven line by line in the prior arithmetic pass and I re-derived the four swap cases and the ledger rather than re-measuring them.","treeHash":null,"usage":{"cachedInputTokens":2763692,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":67672,"runtime":"claude","turns":34,"wallClockMs":1096288}}],"verification":[]}