{"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":"ea823664-6ab6-4f1a-9702-1bd102eca1e6","kind":"audit","nodes":[{"acceptedSubmissionHash":"c27572d02d67a99d430cdb682b761282e9b1bd2a874aed0997198d57d5f16b01","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":"d800bd0cca9012a73bcfc0ee5b40e40ef4b12c4952d1e0babbb5cee6cb5e427a","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":"931fa74612afcd5327e472559900ddacd20c4ffa192f6929312bf31d4e44432b","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":"2946179f96b8ab4d95d70f59dc0c8b0bab3a04cc1ddac45ffeca0ebe9a3a0c8a","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":"1f5a9ed81b254b3260bc3f2499ed204eafd730f8348c4f8aa4a593f33df613f4","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":"Adversarial security review of PvP Pad v3, already DEPLOYED and IMMUTABLE on Robinhood Chain (4663). This job changes no contract and deploys nothing; produce a findings report. No RPC, fork or broadcast are available here: static review plus the existing forge suite and new adversarial tests to prove findings.\n\nREPO: github.com/identity-md-launches/launch-1764-pvp-pad-v3-robinhood @c3a2fac8312fac1dabbd8deca78a987e180523c8. Confirm the deployed bytecode matches the verified source at these addresses on robin.etherscan.io: PvPPad 0xf425c5130208847f2e8422e35bcd37dcb7540bc5, PadGame template 0x82c032a933bf351c3b14d356b694e63f3cbb51eb, PadToken template 0x6870b7777e30a3f152a5bb6476d1152211a017f0, PadLiquidity 0x3ebbe5148eb9a2c45c5d2206dd9b8d9bf7ef7700, KingOfThePad 0xafb61b177e7522c748f9b3e138843a39ac189b37. The pad is still closed (openPad not yet called), so the central question is: is it safe to open?\n\nCONTEXT: v3 fixes the v2 external audit (job 5c4ec689, report api.imd.fun/jobs/5c4ec689-085b-4d72-8807-668b284d3222/report.md). docs/AUDIT.md maps each finding to its fix. Robinhood Chain runs Arbitrum's stack at about 10 L2 blocks/s; RNG uses ArbSys (0x64). Uniswap v4 PoolManager 0x8366a39cc670b4001a1121b8f6a443a643e40951.\n\nPRIORITIES (fund safety first):\n- Can anyone drain, strand or permanently lock ETH or tokens? Above all graduation: any input or state that makes graduate() revert permanently on the REAL PoolManager would lock a game forever.\n- King settlement, rewritten twice: the exttload unlock check, the EIP-150 gas precondition, settleCrown's gas-bounded walk with the 16-pool floor, broken-pool flagging across blocks, and the reign stamps (reignKing, settledReign, AUDIT.md #29). Can a bidder still take fees from the outgoing king, starve or steer the walk, brick takeCrown, or make a broken pool pay the wrong reign? Check fees paid in kind cannot fail or be redirected.\n- Resolver tips (90/7/2/1): tip accounting, double-claim, tips routed through upkeepFor to the right caller, expiry still penalised, ETH conservation.\n- Draw: binding commit, deterministic fallback seed, the bounty paid exactly once; re-examine disputed finding 779d74d2 and say if you agree.\n- Upkeep gas caps: can any step still make enter() or roll() revert, or be skipped forever so a game never finalizes or graduates?\n- Carry pulled at finalize under fundedReleaseCap(): can it be drained, double-pulled or made profitable for a sybil?\n- Everything new since v2: reentrancy, CEI, access control, invariants.\nDo NOT simply accept docs/AUDIT.md: independently re-test its accepted risks and say where you disagree.\n\nDELIVER: findings ranked by severity (critical/high/medium/low/info). Each finding: impact, exact file:line, a failing forge test or concrete step-by-step scenario, whether it is exploitable before or only after openPad, and a recommended fix (the contracts cannot be patched without a v4 redeploy). Add proof tests under test/ where possible. End with a clear verdict: is it safe to call openPad, or should a redeploy fix listed issues first. Note this remains a swarm review; an independent human audit and legal review are still recommended before a public launch.","parentJobId":null,"planHash":"02d26ed6ff76bccdfe58fcfc81c9743118f4b8b1c5335b5bd1933ccd15469057","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"ea823664-6ab6-4f1a-9702-1bd102eca1e6","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":"51021","feedbackHash":"8eb4e87eb3762bf47a5a93e5630376ee7e1cbbac6a67705ddb2ee2c58fe021e4","nodeKey":"audit_economics","submissionHash":"c27572d02d67a99d430cdb682b761282e9b1bd2a874aed0997198d57d5f16b01","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51488","feedbackHash":"fb9712080521ecc15e37affdb6187c12698ae98ee57772f14e291bc315dd3c40","nodeKey":"audit_flow","submissionHash":"d800bd0cca9012a73bcfc0ee5b40e40ef4b12c4952d1e0babbb5cee6cb5e427a","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52252","feedbackHash":"308330fd38700ae8b57a3d475467cd5eb2858df1890ca2168e76057ca0711d54","nodeKey":"audit_judge","submissionHash":"931fa74612afcd5327e472559900ddacd20c4ffa192f6929312bf31d4e44432b","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51203","feedbackHash":"a0b2dc9eba65caedf51f12ad27bf586582ed93d7ce5b062d5ae2a802f34857e7","nodeKey":"audit_math","submissionHash":"2946179f96b8ab4d95d70f59dc0c8b0bab3a04cc1ddac45ffeca0ebe9a3a0c8a","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51035","feedbackHash":"7d7c3697e15d20fcedae8f1aaaed71b17057ebe2717c1c9cf8ec9369ae8972a5","nodeKey":"audit_permissions","submissionHash":"1f5a9ed81b254b3260bc3f2499ed204eafd730f8348c4f8aa4a593f33df613f4","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"8e63236c9f897b29803d66ff07995fc5cb7053e93e72d801a81cbe3daf4e88d3","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"8472a2af9a9cf974","findings":[{"citation":"resolved","description":"Trust-gap seam. The first-bid branch of takeCrown (KingOfThePad.sol:66-68) runs no settleCrown, and PadLiquidity._distribute returns early while no king exists (PadLiquidity.sol:224), so every LP fee the PVP pool (and any pool graduated before the first crown) accrues between graduation and the first bid belongs to the first king. The bidder's own 0.01 ETH does not stay with the crown: it is split 50/25/25 to games, burn and dev. Fee accrual before the first crown is therefore a public bounty that grows until it exceeds MIN_BID and is then sniped in one transaction (takeCrown + distribute). Each later crown change closes a reign and pays the outgoing king, so only the first king gets this free capture: an asymmetry between the first-bid and later-bid branches. docs/AUDIT.md #29 states this as intended ('the first crown closes no reign, so fees accrued before any king go to the first king as before'); the reviewer disagrees with treating it as a non-issue because the amount is unbounded and the price is fixed at 0.01 ETH, and because the pad can only be opened once, so no later change is possible. Exploitable only after openPad and after PVP graduates. Recommended fix (v4 redeploy): treat the pre-crown period as reign 0 owed to the protocol (route reign-0 collections to the burn bucket or carry pot, e.g. reignKing[0] = address(this) semantics), or require the first bid to be at least the ETH fees already accrued. Honest-alternative mitigation without redeploy: the owner takes the first crown immediately after PVP graduation so the pre-crown window is short.","line":66,"path":"src/KingOfThePad.sol","reproduction":"State: pad opened, PVP graduated, 1 ETH of LP fees accrued on the PVP pool (test/scratch/FirstCrown.t.sol donates 1 ETH via the real PoolManager). Input: sniper calls king.takeCrown{value: 0.01 ether}() then liq.distribute(1). Expected (if pre-crown fees were protocol-owned): sniper pays 0.01 ETH, receives nothing of the past accrual. Actual: sniper's balance rises by 990,001,096,389,949,748 wei (0.99 ETH) in the same block; test log 'net gain of the first bidder (wei): 990001096389949748'.","severity":"low","snippet":"        if (previous == address(0)) {","title":"First crown skips settlement: all fees accrued before any king are captured in full by whoever pays the 0.01 ETH minimum bid (access x economics x asymmetry)"},{"citation":"resolved","description":"Static review of PadLiquidity.graduate against vendored v4-core v4.0.0 found no input or state that makes it revert on the vendored manager: currency ordering (0 < token) always holds, sqrtP is bounded inside (SQRT_MIN_FULL, SQRT_MAX_FULL), ticks are multiples of 200, liquidity is far below maxLiquidityPerTick, the 1 ppm haircut covers v4's round-up, the hook address flag (bit 13) matches BEFORE_INITIALIZE_FLAG, and the hook selector/return shape matches Hooks.callHook. However every one of these is a property of the vendored manager, and the only manager that matters is 0x8366a39cc670b4001a1121b8f6a443a643e40951 on chain 4663. This review had no RPC and the explorer API (api.robin.etherscan.io) returned no data, so the requested bytecode match for the five pad contracts and the manager's version were NOT confirmed. If the live manager is a pre-release v4 build (different hook-flag bit layout, different Lock transient slot, different pools slot), then (a) PoolManager.initialize reverts HookAddressNotValid for every graduation, and because _graduate is the only path that moves ethRaised, every finalized game's curve ETH is locked forever (genesis included, which also blocks launch() and takeCrown forever); (b) requireManagerLocked's exttload would read a wrong slot and always pass, re-opening the v2 High finding (crown inside an unlock); (c) _priceLimit would read spot = 0 and the burn buy would run without its 1% impact cap. None of this is a code defect on the vendored manager; it is the single most important precondition for openPad because openPad is irreversible and creates genesis, whose graduation is then on the critical path for the whole pad.","line":678,"path":"src/PadGame.sol","reproduction":"State: any game finalized with ethRaised > 0; manager whose initialize reverts for this hook address (e.g. vm.etch a reverting stub at the manager address after finalize). Input: anyone calls graduate(), poke() or pad upkeep. Expected: some path eventually releases ethRaised. Actual: _graduate reverts every time (require 'not graduable' is never reached because the inner call reverts), graduated stays false, ethRaised stays in the game, no other function can move it; upkeep swallows the revert so the failure is silent. Required before openPad: confirm with cast code / explorer that the five addresses match this tree's bytecode and that the manager is v4-core v4.0.0 (Lock.IS_UNLOCKED_SLOT = keccak256('Unlocked')-1, POOLS_SLOT = 6, BEFORE_INITIALIZE_FLAG = 1<<13), and rehearse one initialize + modifyLiquidity with a hook address of the same flag shape on that manager.","severity":"info","snippet":"        IPadLiquidity(liquidity).graduate{value: ethRaised}(launchId, token, bal, VETH + ethRaised, VTOK - tokensSold);","title":"Graduation has no fallback: a single deterministic PoolManager revert locks every game's curve ETH forever; live-manager compatibility (hook flag mask, Lock slot, POOLS_SLOT) could not be verified fro"},{"citation":"resolved","description":"Re-examined. The first post-signup draw() caller fixes drawCommitBlock, and the fallback seed at commit+258 is fully determined by it, so the committer holds a free option: pick a commit block whose fallback seating favours its wallets, then hope nobody captures the hash seed for 258 L2 blocks (~26 s at 10 blocks/s). If anyone captures, the committer is no worse than random. Agreement with docs/AUDIT.md #30: the seed only orders seats unless entries exceed 200; capture is permissionless and paid (the first entry fee); the hash path is no less steerable by a sequencer. One nuance the dispute text overstates: 'any upkeep' does not necessarily tend the target game. PvPPad._upkeep tends only the latest entered game and one round-robin older game per call, so on a pad with many entered games a given game's draw can go untended by upkeep for several calls; capture then relies on direct draw()/poke() on that game. This does not change the rating: the victims themselves can call draw() at any time. Recommendation stands: run an independent seed-capture keeper; no redeploy needed for this item. A v4 could additionally mix in arbBlockHash(signupEnd block) to remove the committer's choice, at the cost that the same block is observable by the first caller anyway.","line":193,"path":"src/PadGame.sol","reproduction":"State: game with 201+ entries, signup over, quiet pad. Input: attacker calls draw(1) at a chosen L2 block B (pre-computed so keccak(game, B, entries) seats its wallets), then nobody calls draw/poke on the game for 258 L2 blocks; attacker calls draw(32) repeatedly. Expected: seating independent of the committer. Actual: seating equals the pre-computed fallback. Covered by RaffleTest.test_lapsedCommitFallsBackOnceAndNeverRecommits for the mechanism; the attack additionally needs the 26-second silence.","severity":"info","snippet":"                drawSeed = keccak256(abi.encode(address(this), drawCommitBlock, entrants.length));","title":"Disputed finding 779d74d2 (fallback seed chosen by the first committer): agree it is low and does not need a redeploy; note the free-option shape and the narrow upkeep coverage"},{"citation":"resolved","description":"Independent re-check of docs/AUDIT.md #13 and of the trust-gap seam where a bidder steers 50% of its crown extra (fundGames) into a live game it controls. Released funded ETH is min(3 x roll-earned pot, 15% x ethRaised) with ethRaised capped at 4 ETH (sell-out), so at most 0.6 ETH per game. Reaching that needs about 10,000 rolls in the 50-minute play window (200 wallets); five wallets at the 60 s cooldown reach about 250 rolls, 0.45 ETH raised, release cap 0.0675 ETH. Dumping every token into the graduated pool returns 0.99(1+e)/(1.99+0.99e) of the curve ETH e (59% at e=0.45, 83% at e=4), so the sybil's net result is a loss of roughly 0.19 ETH to extract 0.0675 ETH (five wallets) or 0.16 ETH to extract 0.6 ETH (200 wallets, sell-out), before gas. The same cap bounds what a bidder can route back to its own game through fundGames, so a bid cannot be made cheaper than its face value by more than the cap, and only at a loss. Conclusion: agree with the accepted risk. Residual: draining X ETH of carry costs a griefer at least 0.27 X (plus thousands of transactions), so a very large carry pot is a griefing target rather than a profit target; the ETH ends up permanently in the pool's LP, not with the griefer. No redeploy needed.","line":43,"path":"src/PadGame.sol","reproduction":"State: ordinary launch, 5 sybil wallets entered and seated, carry pot 5 ETH. Input: 250 rolls over 50 minutes (0.5 ETH), finalize with 5 living, graduate, dump all tokens. Expected by the attacker: a profit. Actual: ethRaised 0.45 ETH, release cap 0.0675 ETH, pot and tips returned ~0.04 ETH, dump returns ~0.265 ETH, total back ~0.37 ETH against 0.5 ETH spent. Existing test test_soleBuyerSybilLosesMoneyEvenAfterDumpingEveryTokenAndTakingTheCappedRelease plays the same shape on the real PoolManager.","severity":"info","snippet":"    uint256 public constant FUNDED_RELEASE_CURVE_BPS = 1_500;","title":"Carry release cap and king-share routing re-tested: a sybil or a bidder-player cannot profit, and the 50-minute play cap makes carry griefing cost more than it drains"},{"citation":"resolved","description":"Access-control and asymmetry review of the king path found no bypass: settleCrown is king-contract-only, collectPool is self-only while locked, requireManagerLocked guards takeCrown, distribute and retryPool, the reign is incremented before the walk so every reached pool pays reignKing[old] and is stamped with the new reign, unreached and broken pools keep the old stamp and pay the old king later, and a bidder moving the public cursor first gains nothing (existing CrownSettlementTest). An outsider cannot make collectPool revert (token transfer has no hooks, ETH push falls back to claimable, the inner call always receives the full 300k), so the 'any unflagged failure reverts the bid' rule cannot be weaponised to brick takeCrown. One liveness question the docs do not measure was tested here: with many pools the walk consumes everything above the fixed 600k reserve, so the post-settlement work (refund push, fundGames over up to 10 live games, burn, dev push, upkeep) must fit the reserve plus the EIP-150 retention. test/scratch/KingReserve.t.sol: 60 real pools, 10 live games (5 entries each), an outgoing king whose receive burns the full push stipend, bid at gas 5,800,000: passes, total 5,769,894 gas; a 12M-gas bid also passes. Not a defect; recorded so the verdict's claim is traceable.","line":200,"path":"src/PadLiquidity.sol","reproduction":"State: 60 graduated pools, old king is a contract whose receive() executes INVALID, 10 launches with 5 paid entries each inside the last-10 window. Input: bidder calls takeCrown{value: 0.011 ether, gas: 5_800_000}(). Expected: the bid succeeds and the old king's refund is parked as claimable. Actual: succeeds, king() == bidder, claimable(oldKing) == 0.01 ether, 5,769,894 gas used.","severity":"info","snippet":"            IPoolStateReader(address(poolManager)).exttload(Lock.IS_UNLOCKED_SLOT) == bytes32(0), \"manager unlocked\"","title":"Crown settlement liveness verified at the worst combination reachable by outsiders: minimum-gas bid, 60 pools, 10 live games, gas-eating outgoing king"}],"hash":"1f5a9ed81b254b3260bc3f2499ed204eafd730f8348c4f8aa4a593f33df613f4","nodeId":"64b771ed-7685-48d5-ba32-0a879e164601","outcome":"completed","summary":"The findings file is valid and nothing in `src/`, `lib/` or configuration was touched (the findings file and `test/scratch/` are ignored by git, as expected). The review is complete.\n\n## Outcome\n\n**Verdict: no fund-safety defect was found in the permission model, and nothing in the code requires a redeploy before `openPad`. One precondition is unverified from this machine and must be checked by the deployer first.** Findings are in `.imd-findings.json` (one low, four info).\n\n**What was reviewed in depth.** Every state-changing entry point across the five contracts, who may call it, and the guard that enforces it. Each guard holds: one-shot binders are only safe under atomic deployment (documented trust assumption), `upkeepStep`/`fundPot`/`takeCarry`/`noteFirstEntry` are registry-checked, `settleCrown` is king-contract-only, `collectPool` is self-only while locked, `unlockCallback` is manager-only, and the hook refuses any initializer but PadLiquidity. The owner's only power is opening the pad early. Reign stamping, the 16-pool floor, broken-pool flagging, in-kind fee payment, resolver tips through `upkeepFor`, bounty single-payment, carry pull at finalize, and ETH conservation all checked out. The full suite passes (193 tests).\n\n**Findings.**\n- **Low, trust gap.** The first crown runs no settlement, so all fees accrued before any king go to whoever pays the 0.01 ETH minimum bid first. Reproduced on the real PoolManager: a 0.01 ETH bid collected 0.99 ETH in the same block. The docs call this intended; I disagree with rating it a non-issue because the capture is unbounded at a fixed price. Mitigation without redeploy: the owner takes the first crown right after PVP graduates.\n- **Info, precondition.** Graduation has no fallback path. If the live PoolManager differs from vendored v4.0.0 (hook flag bit, Lock slot, pools slot), every game's curve ETH locks forever and the v2 nested-unlock finding reopens. The explorer API returned nothing and no RPC was available, so the bytecode match for the five addresses and the manager version remain unverified. Do this check and a graduation rehearsal on the live manager before opening.\n- **Info.** Disputed finding 779d74d2: I agree it is low and needs no redeploy; the committer holds a free option that only matters above 200 entries and needs 26 seconds of silence.\n- **Info.** Carry release and king-share steering re-tested with the 50-minute cap: five wallets can release at most 0.0675 ETH per game at a loss of about 0.19 ETH. Not profitable, only an expensive grief.\n- **Info.** Crown liveness verified at the worst outsider-reachable combination (60 pools, 10 live games, gas-eating old king, minimum-gas bid) in a scratch test.\n\n**Limits.** This is one reviewer's pass with the access-control, trust-gap and asymmetry lenses plus the fund-safety priorities. No Slither, no fork, no chain reads. An independent human audit and legal review remain recommended before public launch.","treeHash":null,"usage":{"cachedInputTokens":1565494,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":65427,"runtime":"claude","turns":30,"wallClockMs":1386666}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"896d1238054266ca","findings":[{"citation":"resolved","description":"AUDIT.md #29 stamps each pool with the reign it was last settled for and pays reignKing[stamp] on its next collection. The stamp is per pool, not per fee, so a pool the pre-crown walk did not reach pays ALL of its accrued fees to the outgoing king, including trading fees that accrue entirely during the new king's reign, until anyone collects it. Two aggravations: (1) the walk starts at the public distributeCursor, so the outgoing king can front-run the bid with distribute(1) to choose which pools fall outside a minimum-gas walk; (2) the bid's trailing upkeep only runs distribute(2), so with 60 fee-bearing pools 17 pools stay stamped to the old reign until further public distribute/upkeep calls, which on a quiet pad may be a long time. Measured on the real PoolManager with 60 pools: a 5.8M-gas bid reaches 41 pools, leaves 19 unreached, and 0.05 ETH donated to an unreached pool after the crown event is paid in full to the OLD king when retryPool runs, 0 to the new king. The AUDIT.md text already accepts this imprecision; I agree it is bounded (fee flow on unreached pools during the collection gap), but it is a real transfer from the new king to the old one that the old king can influence, so it belongs in the report as low rather than as a pure note. Exploitable only after openPad and after at least two crowns with more than ~40 fee-bearing pools. Fix (v4 only): at settleCrown, for pools the walk does not reach, snapshot the position's feeGrowthInside (readable via extsload) so the next collection can split pre- and post-crown fees; or make the strict walk cover every pool (bound pool count or require the bid's gas to fit poolCount collections). Operational mitigation now: a new king should immediately call distribute(16) until every pool's settledReign equals reign.","line":264,"path":"src/PadLiquidity.sol","reproduction":"Expected: fees accrued on any pool after the Crowned event belong to the new king. Actual: on an unreached pool they are paid to reignKing[oldReign]. Steps (test/scratch/ReignLeak.t.sol, passes = demonstrates): graduatePvp(); add 59 more real pools; oldKing takeCrown{0.01 ETH}; warp to challengeOpensAt; donate 0.001 ETH fees to all 60 pools; bidder takeCrown{value: 0.011 ETH, gas: 5_800_000} -> reign==1, 19 pools keep settledReign==0; donate 0.05 ETH fees to one unreached pool AFTER the crown; anyone calls retryPool(idx): oldKing gains 0.051 ETH (0.001 pre-crown + 0.05 post-crown), bidder gains 0. Log: 'pools unreached by the minimum-gas bid: 19', 'post-crown fees paid to OLD king (wei): 50999999999999999', 'post-crown fees paid to NEW king (wei): 0'.","severity":"low","snippet":"        address payee = settled < reign ? reignKing[settled] : k;","title":"Reign stamp pays every fee of an unreached pool to the old king, including fees earned after the crown changed; the old king can pick which pools go unreached"},{"citation":"resolved","description":"The pad exposes a public constant that no code path reads (grep over src/ and test/ finds only the declaration). The actual rule is the literal `>= 5` at src/PadGame.sol:384 and PadGame.FUNDED_MIN_LIVING. Behaviourally harmless today because both values are 5, but a front end or a future redeploy that reads KING_MIN_IMPS as the source of truth could diverge from the enforced threshold. Not exploitable before or after openPad. Fix for a v4: drop the constant or have PadGame read it from the pad; no change possible on the immutable deployment.","line":15,"path":"src/PvPPad.sol","reproduction":"State: any. Input: read pad.KING_MIN_IMPS() (returns 5) and compare with the enforcement site src/PadGame.sol:384 `(drawComplete ? imps.length : entrants.length) >= 5`. Expected: one definition used by the enforcing code. Actual: the pad constant is dead code; changing it alone would not change behaviour.","severity":"info","snippet":"    uint256 public constant KING_MIN_IMPS = 5;","title":"PvPPad.KING_MIN_IMPS is declared but never enforced; the live-for-king threshold lives only as the literal 5 in PadGame.isLiveForKing"},{"citation":"resolved","description":"The fallback seed depends only on the commit block, which the first post-signup draw() caller selects by timing, so that caller can precompute the fallback seating for candidate blocks and then hope nobody captures the hash-based seed for 258 L2 blocks. Re-examined: (a) the attacker must be first to call draw(), control 201+ paid wallets, and see no draw/poke/enter/roll/launch/takeCrown upkeep from anyone for 258 L2 blocks; on an Arbitrum-stack chain L2 blocks are only produced by transactions, so 258 blocks of silence requires other traffic without any pad interaction, which lengthens rather than shortens the window honest keepers have; (b) with 200 or fewer entrants everyone is seated and the seed only sets seat index, the LAST tie-break key, which is only consulted when two living Imps share |height|, heightReachedAt and heightRoll; heightRoll is unique per roll, so that is reachable only for two living Imps whose every roll expired (both still at height 0 with zero stamps), practically never; (c) the hash-based seed is itself sequencer-influenceable, so no replacement input available at commit time is better. Verdict: agree with AUDIT.md #30, not a blocker for openPad; run an independent seed-capture keeper as the README says.","line":193,"path":"src/PadGame.sol","reproduction":"State: entrants.length > 200, attacker owns the first draw() call after signupEnd and no one else calls draw/poke/upkeep for 258 L2 blocks. Input: draw(1) in a block b chosen so keccak256(abi.encode(game, b, entrants.length)) seats the attacker's wallets; then draw(32) at b+258 or later. Expected: a seed no single party can precompute. Actual: the fallback seed is fully determined by (game, b, entryCount) and seats are reproducible offline. Below 201 entrants the seed cannot change who is seated or, in practice, who is paid.","severity":"info","snippet":"                drawSeed = keccak256(abi.encode(address(this), drawCommitBlock, entrants.length));","title":"Disputed finding 779d74d2 (predictable fallback seed chosen by the first draw caller): I agree with the author's dispute; it is low/info and the math shows it is weaker than stated"},{"citation":"resolved","description":"The job asks to confirm that the deployed bytecode matches the verified source at PvPPad 0xf425c5130208847f2e8422e35bcd37dcb7540bc5, PadGame 0x82c032a933bf351c3b14d356b694e63f3cbb51eb, PadToken 0x6870b7777e30a3f152a5bb6476d1152211a017f0, PadLiquidity 0x3ebbe5148eb9a2c45c5d2206dd9b8d9bf7ef7700 and KingOfThePad 0xafb61b177e7522c748f9b3e138843a39ac189b37. robin.etherscan.io answered HTTP 403 to a plain fetch and no RPC for chain 4663 was reachable, so this review could not check runtime code, constructor arguments (owner, template addresses, PoolManager 0x8366a39cc670b4001a1121b8f6a443a643e40951) or whether that PoolManager is v4-core v4.0.0 as vendored. This is an open pre-openPad item, not a code defect: a mismatch in any constructor argument or in the manager's settle/sync semantics would not be caught by the offline suite. The deployer should run `forge build` with the pinned foundry.toml and compare `cast code` of each address against out/*.json deployedBytecode (bytecode_hash none, cbor_metadata false, so the comparison is exact) before calling openPad.","line":1,"path":"docs/VERIFY.md","reproduction":"Input: GET https://robin.etherscan.io/address/0xf425c5130208847f2e8422e35bcd37dcb7540bc5 -> 403; POST eth_chainId to a public RPC -> no response. Expected: runtime bytecode equal to the artifact built from commit c3a2fac; actual: unverified here.","severity":"info","snippet":"# Verifying sources on robin.etherscan.io","title":"Deployed bytecode at the five Robinhood Chain addresses could not be compared with the pinned source from this environment"}],"hash":"2946179f96b8ab4d95d70f59dc0c8b0bab3a04cc1ddac45ffeca0ebe9a3a0c8a","nodeId":"125fd707-a4bf-4720-af2e-b58c45c5c9d1","outcome":"completed","summary":"Findings file is written and validated. Final recap:\n\n**Scope covered:** the Math Precision, Boundary and Numerical Gap passes over all six contracts, with emphasis on the bonding curve, finalize split and release caps, graduation liquidity math on the real vendored PoolManager, king settlement gas and reign stamps, draw sampling, and resolver tip accounting. The existing 193-test suite passes. I added two scratch suites under `test/scratch/` (not kept): a graduation boundary suite and a reign-leak probe.\n\n**What held up (no defect found):**\n- Curve quote: ceiling division, exact 4 ETH sell-out, no overflow, no zero-rounding at the 0.0018 ETH roll share; sold-out rolls route 97% to pot as intended.\n- Graduation: fuzzed every reachable raise from one roll to the exact sell-out, plus one wei below it and unreachable 1-wei raises, against the real v4 PoolManager. It never reverts, dust stays within the 1 ppm haircut, no tokens strand in PadLiquidity. This is the main fund-safety question for my area and it is clean.\n- Finalize: fee, funded return, bell share and triangular ranks conserve ETH exactly; dust goes to carry; sole-occupant and 5-living boundaries use the right comparators; sort-key bit layout has no overlap.\n- Release-cap economics in AUDIT.md #13: I rederived the dump-recovery formula and confirm it. A sybil loses money at every raise, and pot inflation past sell-out cannot lift the cap.\n- Draw rejection sampling and sparse Fisher-Yates are correct; ArbSys hash window boundaries match Nitro's exact rule.\n- Death, cooldown, signup and bell boundaries are consistent across roll, isAlive and finalize.\n\n**Findings written to `.imd-findings.json`:**\n1. Low. Reign stamps pay all fees of an unreached pool to the old king, including fees earned after the crown change, and the old king can steer which pools go unreached. Measured on 60 real pools: 19 unreached by a minimum-gas bid, 0.05 ETH of post-crown fees paid to the old king. Documented as accepted in AUDIT.md #29; I agree it is bounded but it is a real, influenceable transfer.\n2. Info. `KING_MIN_IMPS` in the pad is dead; the threshold is a literal in PadGame.\n3. Info. Disputed finding 779d74d2: I agree with the author. The fallback seed is predictable but requires 201+ wallets, being first to draw, and 258 silent L2 blocks, and below 201 entrants it practically cannot change payouts.\n4. Info. Deployed bytecode could not be compared from here (explorer 403, no RPC). That remains an open pre-open item for the deployer.\n\n**Verdict for my area:** nothing I found blocks calling openPad. The one low item is a v4 improvement, mitigated now by a new king calling distribute promptly. Bytecode and constructor-argument verification against the live chain should be completed before opening, and this remains a swarm review; an independent human audit and legal review are still recommended before a public launch.","treeHash":null,"usage":{"cachedInputTokens":2563503,"inputTokens":674,"model":"claude-fable-5-1","outputTokens":56148,"runtime":"claude","turns":32,"wallClockMs":1510621}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c0fc4ea4f50e3380","findings":[{"citation":"resolved","description":"fundGames() only looks at the last KING_MAX_GAMES (10) ids of `entered`, and a launch joins that list on its first 0.001 ETH entry. launch() is free and unlimited once genesis has graduated. An attacker therefore fills the whole window with ten dead one-entry launches for 0.01 ETH plus gas before (or right after) a bid; no honest live game is examined, count == 0 and the entire games share of the bid is parked in carryPot instead of funding the live game it was meant for. Carry then only drips out at finalization under the 15%-of-raise cap, so the honest players of the live game lose that share (not stolen, mis-routed). Each honest first entry re-enters the window, so the attacker must keep ten dead launches ahead of it; cost 0.01 ETH per refresh. Merged from audit_economics; reproduced. Exploitable only after openPad and after genesis graduates (king open). Recommended fix (v4 redeploy): select the king window by liveness (walk `entered` backwards collecting up to 10 games whose isLiveForKing() is true, bounded), or only push a launch to `entered` once it has KING_MIN_IMPS paid entries. Operational mitigation now: monitor dead launches; a bidder can also wait until the honest game is in the window.","line":190,"path":"src/PvPPad.sol","reproduction":"test/scratch/Judge.t.sol::test_kingShareDivertedToCarryByTenDeadLaunches (passes = demonstrates) on the project's real-v4 BaseTest: graduatePvp(); launch 'Honest' H and enter it from five wallets (H.isLiveForKing() == true); then ten times pad.launch() + one 0.001 ETH enter() from a fresh wallet; players[9] calls king.takeCrown{value: 0.1 ether}(). Expected (docs/AUDIT.md #13): H.fundedPot() == 0.05 ether, carryPot unchanged. Actual: H.fundedPot() == 0 and pad.carryPot() grew by exactly 0.05 ether. Control test test_control_honestLiveGameReceivesKingShare without the ten dead launches: H.fundedPot() == 0.05 ether, carry delta 0.","severity":"medium","snippet":"        uint256 from = n > KING_MAX_GAMES ? n - KING_MAX_GAMES : 0;","title":"King's 50% games share is diverted to carry by ten one-entry launches: the last-10 window is by entry order, not liveness"},{"citation":"resolved","description":"The stamp is per pool, not per fee. A pool the pre-crown walk did not reach (or a broken pool) keeps settledReign below `reign`, and its NEXT collection pays reignKing[stamp] the whole accrued amount, including all trading fees earned under the new king until someone collects it. Two aggravations: the walk starts at the public distributeCursor, so the outgoing king can call distribute(1) before an expected bid to choose which pools fall outside a minimum-gas walk; and the bid's trailing upkeep only runs distribute(2), so on a quiet pad the remaining pools stay stamped to the old reign for a long time. docs/AUDIT.md #29 accepts this as an imprecision; re-tested, the transfer is real and old-king influenceable, so it is reported as low, not as a note. Not exploitable for the bidder, and the walk cannot be starved below 16 pools or bricked by an outsider (re-verified: collectPool cannot be made to revert by outsiders, ETH push falls back to claimable, token transfer has no hooks). Merged from audit_math and audit_economics (same mechanism). Exploitable only after openPad, after at least two crowns, with more pools than the bid's gas settles (about 40 at the minimum stipend). Fix (v4): snapshot the position's feeGrowthInside for unreached pools at settleCrown so the next collection splits pre- and post-crown fees, or require a bid's gas to cover every pool. Operational now: a new king should call distribute(16) in a loop right after being crowned until every pool's settledReign == reign.","line":264,"path":"src/PadLiquidity.sol","reproduction":"test/scratch/Judge.t.sol::test_unreachedPoolPaysPostCrownFeesToOldKing (passes = demonstrates) on the real PoolManager: graduatePvp(); add 59 more real pools; oldKing takeCrown{0.01 ETH}; warp to challengeOpensAt; donate 0.001 ETH fees to all 60 pools; bidder takeCrown{value: 0.011 ether, gas: 5_800_000} -> reign == 1, 19 pools still have settledReign == 0 after the bid's trailing upkeep; donate 0.05 ETH fees to one of those pools AFTER the crown; anyone calls retryPool(idx). Expected: post-crown fees belong to the new king. Actual: oldKing receives 50,999,999,999,999,999 wei (0.001 pre-crown + the whole 0.05 post-crown), bidder receives 0. Logs: 'pools unreached by the minimum-gas bid: 19', 'post-crown fees paid to OLD king: 50999999999999999', 'post-crown fees paid to NEW king: 0'.","severity":"low","snippet":"        address payee = settled < reign ? reignKing[settled] : k;","title":"Reign stamp pays every fee of an unreached pool to the outgoing king, including fees earned after the crown changed; the outgoing king can steer which pools go unreached"},{"citation":"resolved","description":"The first-bid branch runs no settleCrown and _distribute returns while no king exists (PadLiquidity.sol:224), so all fees accrued on the genesis pool (and on any pool graduated before the first crown) between graduation and the first bid belong to the first king, who collects them in the same transaction through the bid's trailing upkeep or a distribute(16) call. The price is fixed at MIN_BID = 0.01 ETH, and that bid is not even spent: KingOfThePad refunds `previousBid` in full on the next crown (the 0.01 ETH is split 50/25/25 at bid time and repaid out of the next bidder's value), so the first king's only cost is the opportunity cost of 0.01 ETH for one hour. docs/AUDIT.md #29 states this as intended; disagreement: the captured amount is unbounded in time and the pad can only be opened once, so the window should be closed operationally. Merged from audit_permissions; reproduced by the suite's own test. Exploitable only after openPad and after PVP graduates. Fix (v4): treat the pre-crown period as a reign owed to the protocol (route reign-0-before-any-king collections to the burn bucket or carry) or require the first bid to be at least the ETH fees already collectable. Mitigation now: the owner takes the first crown immediately after genesis graduation so the pre-crown window is seconds, not days.","line":66,"path":"src/KingOfThePad.sol","reproduction":"Existing test test/CrownSettlement.t.sol::test_firstCrownOpensNoReignAndPaysPreCrownFeesToTheFirstKing: donate 0.05 ETH fees to the PVP pool with no king, distribute(16) leaves ethFees == 0 (fees stay in the position); players[8] takeCrown{0.01 ether}; distribute(16): players[8] balance rises by >= 0.05 ether - 0.01 ether in the same block and reign stays 0. Replace the 0.05 ETH donation by any amount of real swap fees accrued over the pre-crown period to see the unbounded capture. The refund of the 0.01 ETH on the next crown is KingOfThePad.sol:87-91 (previousBid pushed to previous).","severity":"low","snippet":"        if (previous == address(0)) {","title":"First crown closes no reign: every LP fee accrued before any king is captured by whoever pays the 0.01 ETH minimum bid, and that bid is refunded on dethronement"},{"citation":"resolved","description":"docs/AUDIT.md #13 argues a sybil that fills a launch to extract funded ETH loses at least 4% of the curve ETH plus the 2% burn share and gas. On the genesis game (and every restarted genesis) the 2% burn share is added to the pot instead of burned, so the roll-earned pot is 9% of rolls (measured: 180,000,000,000,000 wei per 0.002 ETH roll) and is paid back to the sybil's own top-half Imps; the 1% tip returns when the sybil resolves its own rolls with a second wallet, and the finalizer fee goes to the sybil finalizer. The irrecoverable cost is only the share of curve ETH a full dump cannot pull out of the pool (about 17% of e at the 4 ETH sell-out) minus the 15% release, i.e. about 2% of e plus entry fees and gas, while up to min(3 x roll pot, 0.6 ETH) leaves the carry pot. Still a loss, so griefing with leverage (about 5x the attacker's loss leaves carry) rather than profit; honest players lose carry that would have been released to real games. Only after openPad and only while the carry pot holds ETH (donations, returned funded pots, king shares diverted by the window finding). Merged from audit_flow; arithmetic re-derived and the 9% genesis roll pot plus the carry pull reproduced. Fix (v4): exclude the genesis burn share from the roll-earned pot used by _releaseCap, or lower FUNDED_RELEASE_CURVE_BPS to about 1_000. Operational: keep the carry pot small and watch genesis games that end with exactly five living Imps.","line":261,"path":"src/PadGame.sol","reproduction":"test/scratch/Judge.t.sol::test_genesisBurnShareLiftsReleaseCap (passes = demonstrates): openPad(); fundCarry 1 ETH; five wallets enter genesis and roll every 61 s for 49 rounds; before finalization pvp.rollPot() == rolls x 0.002 ether x 9 / 100 (not 7/100) and fundedReleaseCap() == min(3 x rollPot, 15% x ethRaised); after finalize with livingAtEnd == 5 the carry pot dropped by 66,149,999,999,999,999 wei (15% of the 0.441 ETH raised). Full-scale arithmetic at the 4 ETH sell-out: 45 wallets, about 2,223 rolls = 4.446 ETH; ethRaised 4.0 ETH; roll pot 0.400 ETH; tips 0.044 ETH; release 0.6 ETH; dump of 800M tokens into the 1% pool returns 4 x 0.99 x 800/(160 + 0.99 x 800) = 3.327 ETH; spent 4.491 ETH, recovered about 4.371 ETH, net loss about 0.12 ETH while 0.6 ETH left the carry pot. Expected per AUDIT.md: net loss >= 0.16 ETH plus burn.","severity":"low","snippet":"        if (genesis) potEth += burnEth;","title":"Carry can be pulled by a sybil-filled genesis game at about 2% loss, not the 4%+ floor docs/AUDIT.md #13 claims: the genesis burn share is credited to the roll pot and comes back to the sybil"},{"citation":"resolved","description":"_upkeep always tends the newest entered launch and one older entered launch chosen round-robin over the unbounded `entered` list, which anyone grows for 0.001 ETH per launch. After k dead launches, the automatic draw / resolve / finalize / graduate step that every enter(), roll(), launch() and takeCrown() on the pad was meant to give an honest game arrives once every n-1 pad actions, and the newest-entered slot is spent on the attacker's latest dead launch. Nothing is lost: draw(), resolvePending(), poke(), finalize() and graduate() stay permissionless on the game itself with full caller gas, and resolver tips still reward direct callers; but docs/AUDIT.md #3 and the README rely on pad-driven upkeep for abandoned games (expired-roll resolution, finalization, the carry pull, graduation). Merged from audit_economics; reproduced. Only after openPad. Fix (v4): have upkeepFor(caller) tend the calling game first, or keep a small queue of games with pending lifecycle work instead of the entry-ordered list. Operational: run a keeper that calls poke() on every game with pending work.","line":238,"path":"src/PvPPad.sol","reproduction":"test/scratch/Judge.t.sol::test_padUpkeepReachesHonestGameOncePerCycle (passes = demonstrates): graduatePvp(); launch honest game H with five entries; 30 dead launches entered once each (0.03 ETH); warp to H.signupEnd(); repeatedly call pad.upkeep() with 4 L2 blocks between calls. Measured: 32 entered launches; H's draw committed on the 3rd call; exactly 31 (= n-1) further pad.upkeep() calls elapse before H is tended again. Expected by docs/AUDIT.md #3: pad actions tend pending lifecycle work promptly. Each extra dead entry lengthens the cycle by one.","severity":"low","snippet":"            _tend(launches[entered[n - 1] - 1].game, caller);","title":"Pad upkeep rotation can be starved by cheap dead launches: the newest-entered slot is attacker-refreshable and the round-robin slot reaches an honest game once per n-1 pad actions"},{"citation":"resolved","description":"Re-examined. The first post-signup draw() caller fixes drawCommitBlock, and the fallback seed is fully determined by (game, commitBlock, entryCount), so that caller can precompute the fallback seating for candidate blocks and then hope nobody captures the hash seed for 258 L2 blocks (about 26 s at 10 blocks/s). Agreement with docs/AUDIT.md #30 and all three specialists: the attack needs 201+ paid entries (below that the seed only orders seats, the last tie-break key), the attacker must be the first committer, and any draw(1)/poke() by anyone (including the victims) in blocks commit+4..commit+257 takes the hash path and earns the first-entry bounty; the hash path is itself sequencer-influenceable, so no commit-time input is better. One nuance the dispute text overstates: pad upkeep tends only the newest entered game and one round-robin game, so a game's seed capture cannot be assumed to ride on unrelated pad traffic (see the upkeep starvation finding); it relies on direct draw()/poke() calls. Verdict: low, disclosed, not a blocker; run an independent seed-capture keeper from open. A v4 could remove the committer's choice (two-step capture of blockhash(c+1), or VRF).","line":193,"path":"src/PadGame.sol","reproduction":"State: launch with N >= 201 paid entries, signup over, no draw yet. Steps: attacker precomputes seating(keccak256(abi.encode(game, c, N))) for candidate blocks c and sends the first draw(1) at the best c; nobody touches the game for 258 L2 blocks; attacker calls draw(32) repeatedly. Actual: seating equals the precomputed fallback (mechanism covered by test/Raffle.t.sol test_lapsedCommitFallsBackOnceAndNeverRecommits). Any single draw/poke by anyone in c+4..c+257 instead seeds from arbBlockHash(c+1..c+3) and pays that caller the 0.001 ETH bounty.","severity":"info","snippet":"                drawSeed = keccak256(abi.encode(address(this), drawCommitBlock, entrants.length));","title":"Disputed finding 779d74d2 (fallback seed chosen by the first draw caller): agree it is low and needs no redeploy; an independent seed-capture keeper is a pre-open requirement"},{"citation":"resolved","description":"Verified from this review over public Robinhood Chain RPC (chain id 4663, block 89,090,985) against the local forge build of commit c3a2fac (solc 0.8.26, via-IR, 200 runs, no metadata): runtime code at PvPPad 0xf425..0bc5, PadGame 0x82c0..51eb, PadToken 0x6870..17f0, PadLiquidity 0x3ebb..7700 and KingOfThePad 0xafb6..9b37 equals the artifact deployedBytecode with zero differing bytes outside the immutable references (PvPPad 512 immutable bytes, PadLiquidity 512, KingOfThePad 224; PadGame and PadToken have none and are identical). Bindings read on chain: pad.liquidity == 0x3ebb..7700, pad.king == 0xafb6..9b37, liq.pad == pad, liq.king == king, liq.poolManager == 0x8366..0951, king.pad/king.liquidity/king.dev as expected, owner 0x5b95..0d06, tokenTemplate/gameTemplate as expected; pad.opened == false, launchCount 0, hook 0, poolCount 0, all three ETH balances 0, deployedAt 1792015720 (open deadline 2026-11-13, after which anyone can open). PoolManager 0x8366..0951 is byte-identical (24,009 bytes) to the canonical Ethereum mainnet v4 PoolManager 0x0000..8A90 except the 20-byte NoDelegateCall immutable, so the vendored v4-core v4.0.0 Lock.IS_UNLOCKED_SLOT, pools slot 6, initialize(key, sqrtPrice) and hook flag mask apply; exttload(IS_UNLOCKED_SLOT) == 0 at rest; ArbSys at 0x64 answers arbBlockNumber. This resolves the audit_permissions and audit_math 'could not verify' items and the graduation-compatibility concern: graduate() has no input or state found that reverts permanently on this manager (currency ordering, bounded sqrtP, tick multiples, liquidity far below maxLiquidityPerTick, 1 ppm haircut, hook flag bit 13). Note: a protocol fee controller is set (0x6d00..314c) and 0.268 ETH of protocol fees are already accrued on this manager, so the pad's pools may pay up to v4's protocol fee share out of swap fees, lowering king income; it cannot block graduation, collection or burns. Remaining pre-open items: rehearse one initialize + modifyLiquidity with a hook of the same flag shape is optional given the byte-identical manager; mine the open salt so hookAddressFor(salt) & 0x3FFF == 0x2000; have keepers (resolver, seed capture, distribute) running before openPad.","line":72,"path":"src/PadLiquidity.sol","reproduction":"cast code of each address compared with out/<Name>.sol/<Name>.json deployedBytecode with immutableReferences masked: zero differing bytes. cast call pad liquidity()/king()/opened()/launchCount()/hook(); liq king()/hook()/poolManager()/poolCount(); king pad()/liquidity()/dev()/king(); cast call 0x8366..0951 exttload(bytes32) 0xc090fc46...ab23 -> 0x0; cast code 0x8366..0951 versus mainnet 0x000000000004444c5dc75cB358380D2e3dE08A90: 20 differing bytes at offset 13618 only. Expected and actual match; nothing to fix.","severity":"info","snippet":"    IPoolManager public immutable poolManager;","title":"Deployment verified on chain against the pinned source: runtime bytecode, bindings, closed state and the real PoolManager match what the test suite assumes; pre-open checklist"},{"citation":"resolved","description":"Two timing facts, both disclosed in docs/AUDIT.md #1 and agreed as design limits, not fund-safety defects, but they decide whether the game is fair after openPad. (1) The resolution window is L2 blocks commit+4..commit+257 (about 0.4 s to 25.7 s at 10 blocks/s) while COOLDOWN is 60 s, so the own-previous-roll path in roll() always finds the previous roll expired: height moves up to 4 toward zero and the 0.00002 ETH tip goes to the pot. Without independent resolvers every honest roll is penalised. (2) A roll committed in block c resolves only when arbBlockNumber() > c + 3; Arbitrum-stack chains produce blocks only with transactions, so finalize()/poke() revert 'not finalizable' until three further L2 blocks exist, and a keeper that tries once and gives up leaves the pot and carry pull pending. Re-tested the other upkeep questions from the brief: enter() and roll() cannot be reverted by upkeep (every pad step is behind _canSpend and try/catch), _resolve has a 200k gas floor, and no step can be skipped forever because draw/resolvePending/finalize/graduate are permissionless with full caller gas. Merged from audit_flow and audit_economics. Recommendation: at least two independent resolver keepers and a finalize/graduate keeper before openPad; a v4 could anchor expiry to block.timestamp or shorten the cooldown.","line":518,"path":"src/PadGame.sol","reproduction":"(1) MockArbSys at 10 blocks/s: genesis draw completes, Imp 0 rolls at L2 block B, nobody resolves, the same wallet rolls again 60 s later at block B+600: getRoll(0).expired == true, claimable(roller) == 0, pot grew by the expired tip (existing test test_tenBlocksPerSecondSixtySecondRollerNeverExpiresWithResolver shows the resolver side). (2) Game in play, one roll at L2 block c, warp past endTime(), call finalize() while arbBlockNumber() == c+1: reverts 'not finalizable' (pendingCount == 1); advance to c+4 and call again: succeeds.","severity":"info","snippet":"        if (commit + 1 + HASH_WINDOW < cur) {","title":"Keeper requirements the open decision depends on: at 10 L2 blocks/s a roller can never resolve its own roll on its next roll, and finalize() needs three more L2 blocks after the last roll"},{"citation":"resolved","description":"Re-tested the carry pull at finalize: pulled once (finalized is set right after, fundPot requires !finalized), only by the registered game, only with >= 5 living Imps, only up to fundedReleaseCap() - fundedPot with the cap min(3 x roll pot, 15% of ethRaised), so at most 0.6 ETH per sold-out game. The flip side is that carry is sticky: king shares diverted by the window finding, funding returned from games that ended with fewer than five living Imps, and ranking dust accumulate with no other release path. On a quiet pad that ETH is effectively locked until honest well-raised games finalize. Design trade-off to document for users; a v4 could add a time-based decay of carry into the burn bucket. Merged from audit_economics.","line":175,"path":"src/PvPPad.sol","reproduction":"State: carryPot = 5 ETH. A launch raising 0.1 ETH that ends with 5 living Imps pulls at most min(3 x rollPot, 0.015 ETH) - fundedPot; a sold-out 4 ETH launch at most 0.6 ETH. Existing test test_fiveWalletCarryChainOnlyPullsUnusedCapAtFinalize shows the cap; start it with a large carryPot to see the remainder stay.","severity":"info","snippet":"        uint256 carry = carryPot < maxAmount ? carryPot : maxAmount;","title":"Carry pot has a single capped exit: ETH parked there drips out at most 15% of each qualifying raise and can sit indefinitely"},{"citation":"resolved","description":"Dead public constant. Harmless today because both values are 5, but an explorer reader or a front end may take the pad constant as the source of truth while the enforced threshold is `>= 5` at src/PadGame.sol:384 and PadGame.FUNDED_MIN_LIVING. Not exploitable before or after openPad. Merged from audit_flow and audit_math. Fix (v4): drop the constant or have PadGame read it from the pad.","line":15,"path":"src/PvPPad.sol","reproduction":"grep KING_MIN_IMPS src/ test/: only the declaration at src/PvPPad.sol:15. pad.KING_MIN_IMPS() returns 5; the enforcing code is src/PadGame.sol:384 `(drawComplete ? imps.length : entrants.length) >= 5`. Expected: one definition used by the enforcing code. Actual: unused.","severity":"info","snippet":"    uint256 public constant KING_MIN_IMPS = 5;","title":"PvPPad.KING_MIN_IMPS is declared but never read; the five-Imp rule lives only as the literal 5 in PadGame.isLiveForKing and FUNDED_MIN_LIVING"},{"citation":"resolved","description":"Scope reviewed: all six contracts in src/, the 193-test suite (all pass on this tree), the specialists' 21 findings (reproduced 5 defects and merged 7 duplicates; dropped nothing that reproduced), and the deployed state on chain. Fund safety: no path was found that drains, strands or permanently locks ETH or tokens; graduate() has no permanent-revert input on the real PoolManager (byte-identical to canonical v4.0.0); the king settlement cannot be bricked or starved below 16 pools by outsiders, in-kind fees cannot fail; resolver tips are paid once to the real resolver through upkeepFor; the draw bounty is paid exactly once; carry is pulled once under the cap. Agreed accepted risks with corrections: the reign stamp leaks post-crown fees of unreached pools to the old king (low), the first crown captures all pre-crown fees for a refundable 0.01 ETH (low), the genesis sybil loss is about 2% not 4% (low), and the king window and pad upkeep can be starved by 0.001 ETH dead launches (medium/low). None of these lock funds; all are griefing or mis-routing that a v4 should fix. Recommendation: calling openPad is safe from a fund-safety standpoint provided (1) the owner takes the first crown immediately after genesis graduates, (2) at least two independent resolver keepers, a seed-capture keeper and a distribute/finalize keeper run from the moment of opening, (3) the new king's UI loops distribute(16) after each crown, (4) dead-launch spam of the king window is monitored and the carry pot kept small, (5) the dev wallet is an address that accepts 30k-gas pushes or claims regularly. If the requester wants the king-window and reign-stamp semantics fixed, that needs a v4 redeploy before opening, since nothing can be patched afterwards. This remains a swarm review; an independent human audit and legal review are still recommended before a public launch.","line":108,"path":"src/PvPPad.sol","reproduction":"forge test: 193 passed, 0 failed on commit c3a2fac. test/scratch/Judge.t.sol: 5 reproductions pass (king window diversion with control, unreached-pool reign leak on 60 real pools, upkeep starvation cycle of n-1, genesis 9% roll pot and carry pull). On-chain reads listed in the deployment finding.","severity":"info","snippet":"    function openPad(bytes32 salt) external {","title":"Verdict: no critical or high defect found; openPad is acceptable with operational prerequisites, and the medium/low items above are v4 candidates, not blockers"}],"hash":"931fa74612afcd5327e472559900ddacd20c4ffa192f6929312bf31d4e44432b","nodeId":"cdf4a91e-9433-45b8-b301-e2e85ced9021","outcome":"completed","summary":"The review is complete. All eleven entries in `.imd-findings.json` have snippets that match the tree, and the only working-tree additions are the findings file and the scratch tests, which are not kept.\n\n**Verdict: no critical or high defect. Opening the pad is acceptable on fund-safety grounds, with operational prerequisites.** The medium and low items are griefing or mis-routing that a v4 should fix, not fund locks.\n\n**What I verified myself**\n\n- **Deployed bytecode matches the pinned source.** Over public Robinhood RPC, all five contracts equal the local build byte for byte outside immutables. Bindings are correct, the pad is closed with zero launches and zero balances, and the manager's unlock slot reads zero.\n- **The Robinhood PoolManager is byte-identical to the canonical mainnet v4 PoolManager** except its 20-byte immutable. That settles the graduation-compatibility and Lock-slot concerns two specialists could not check. One note: a protocol fee controller is active on that manager, which can lower king fee income but cannot block graduation.\n- **The existing suite passes** (193 tests), and five scratch tests in `test/scratch/Judge.t.sol` reproduce every defect I kept.\n\n**Findings kept, duplicates merged**\n\n- **Medium.** Ten one-entry dead launches (0.01 ETH) fill the king's last-10 window so a live honest game gets none of the 50% share. Reproduced: honest game funded 0, carry up by the full 0.05 ETH; the control run funds the game.\n- **Low.** Reign stamp pays post-crown fees of unreached pools to the old king, who can steer the cursor. Reproduced on 60 real pools: 19 pools unreached at the minimum-gas bid, 0.05 ETH donated after the crown paid entirely to the old king.\n- **Low.** First crown captures all pre-crown fees for a refundable 0.01 ETH bid. I disagree with treating this as a non-issue; mitigation is the owner taking the first crown immediately.\n- **Low.** Genesis sybil carry pull loses about 2%, not the 4% floor in AUDIT.md, because the genesis burn share is credited to the roll pot. Reproduced the 9% roll pot and a 0.066 ETH carry pull.\n- **Low.** Pad upkeep is starved by dead launches: with 32 entered launches an honest game is tended once per 31 pad actions.\n- **Info.** Agreement with the disputed seed finding 779d74d2 as low; deployment verification and pre-open checklist; keeper timing limits; sticky carry pot; dead constant; and the verdict with its conditions.\n\n**Conditions for openPad:** owner takes the first crown right after genesis graduates, two independent resolver keepers plus seed-capture and distribute/finalize keepers run from opening, new kings loop distribute(16) after each crown, and dead-launch spam of the king window is monitored. If the requester wants the king-window and reign-stamp semantics fixed, that requires a v4 redeploy before opening. An independent human audit and legal review are still recommended before a public launch.","treeHash":null,"usage":{"cachedInputTokens":1613697,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":40990,"runtime":"claude","turns":32,"wallClockMs":738699}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"72ae9b5bbd1a54b6","findings":[{"citation":"resolved","description":"fundGames() only considers the last KING_MAX_GAMES (10) launches of the `entered` list, and a launch joins that list on its first 0.001 ETH entry (noteFirstEntry). launch() is free and unlimited after genesis graduates. An attacker can therefore fill the whole window with dead one-entry launches for 0.01 ETH plus gas, so no honest live game is seen, count == 0 and the entire games share of every subsequent crown bid is parked in carryPot instead of funding the live games it was meant for. Carry is only released later under the 15%-of-raise cap at some future finalization (see the carry note), so the honest players lose the king share for that bid. Funds are not stolen (the sybil cannot profitably pull carry, re-tested: dump recovery is at most 83% of curve ETH so a 15% release is a net loss), so impact is griefing / mis-routing of ETH flow. Exploitable only after openPad and after genesis has graduated (king open). Fix for a v4 redeploy: pick the king window from live games (walk `entered` backwards and collect up to 10 games whose isLiveForKing() is true, bounded by a gas-aware limit), or only push a launch to `entered` once it has KING_MIN_IMPS (5) paid entries.","line":190,"path":"src/PvPPad.sol","reproduction":"State: pad open, genesis graduated, king open. Steps (test/scratch/KingWindowGrief.t.sol on the project's real-v4 BaseTest): (1) pad.launch('Honest') -> game H, five wallets enter H (0.005 ETH) so H.isLiveForKing() == true during signup. (2) Attacker calls pad.launch() ten times and enters each new game once from a fresh wallet (0.001 ETH each, 0.01 ETH total). (3) Anyone calls king.takeCrown{value: 0.1 ether}(). Expected (design, docs/AUDIT.md #13): H.fundedPot() == 0.05 ether, carryPot unchanged. Actual: H.fundedPot() == 0 and pad.carryPot() increases by exactly 0.05 ether. Control run without step (2): H.fundedPot() == 0.05 ether and carry delta 0. Both assertions pass on this tree.","severity":"low","snippet":"        uint256 from = n > KING_MAX_GAMES ? n - KING_MAX_GAMES : 0;","title":"King's 50% games share is diverted to carry by ten free launches plus ten 0.001 ETH entries (last-10 window is by entry order, not liveness)"},{"citation":"resolved","description":"_upkeep always tends launches[entered[n-1]] (the newest entered launch) and one older entered launch chosen round-robin. Both slots are controlled by the unbounded `entered` list that anyone can grow for 0.001 ETH per launch. After the attacker appends k dead launches, the automatic draw / resolve / finalize / graduate step that every enter(), roll(), launch() and takeCrown() on the pad was supposed to give an honest game arrives once every n-1 pad actions, and the newest-entered slot is spent on the attacker's latest dead launch. Nothing is lost: draw(), resolvePending(), poke(), finalize() and graduate() remain permissionless on the honest game itself, and resolver tips still reward direct callers; but docs/AUDIT.md #3 and the README rely on pad-driven upkeep for games whose players have left (the carry pull, graduation and expired-roll resolution of an abandoned game). Only after openPad. Fix for a redeploy: tend the calling game first (upkeepFor(caller) from a game should step that game), or keep a small queue of games with pending lifecycle work rather than the entry-ordered list.","line":240,"path":"src/PvPPad.sol","reproduction":"State: pad open, genesis graduated. Honest game H entered and in play. Attacker: for i in 1..1000: pad.launch(); enter{value: 0.001 ether}() from a fresh wallet (1 ETH total). Now entered.length n = 1002. Expected: an honest game with pending rolls / an ended game waiting for finalize is tended by the next pad actions. Actual: every _upkeep call tends the attacker's newest launch (entered[n-1], nothing to do) and launches[entered[upkeepCursor % 1001]]; H is reached once per 1001 upkeep calls, and each extra 0.001 ETH entry moves it further. Observable with the existing harness by counting PadGame.Resolved / Finalized events emitted for H from pad.upkeep() calls after the spam versus before.","severity":"low","snippet":"                uint256 c = upkeepCursor % (n - 1);","title":"Pad upkeep rotation can be starved by cheap dead launches: the latest-entered slot is attacker-refreshable and the round-robin slot visits an honest game once per (n-1) pad actions"},{"citation":"resolved","description":"The fallback seed depends only on values known at commit time (game address, the commit block number the first post-signup caller chose, and the fixed entry count). An attacker holding B of N>200 paid entries can evaluate the fallback seating for each candidate commit block, send the first draw() in the block that seats most of its wallets, and then hope the hash path is never taken. Re-tested conclusions: (a) the draw_() hash path (blocks c+1..c+3) is taken by the first call between c+4 and c+257 by anyone, including the pad's own upkeep from any enter/roll on any game and the bounty-seeking keeper, so the attacker needs a pad that is completely idle for ~26 seconds at 10 blocks/s; (b) the hash path is itself sequencer-influenceable, so an alternative commit-time input (signup-end block, an earlier blockhash) is equally observable; (c) below 200 entries the seed only orders seats (last tie-break key). We therefore agree with the builder's disposition (docs/AUDIT.md #30): low, disclosed, operate an independent seed-capture keeper. If a v4 redeploy is made, prefer removing the attacker's choice entirely: let anyone commit but derive the fallback from blockhash(c+1) captured by a later block (two-step) or from a VRF; or make the bounty capture an automatic part of the first roll() after c+3.","line":193,"path":"src/PadGame.sol","reproduction":"State: a launch with N = 201 or more paid entries (B of them the attacker's), signup over, no draw yet. Steps: (1) attacker offline computes, for candidate blocks c, seating(keccak256(abi.encode(game, c, N))) and picks c maximizing its seats; (2) at block c the attacker is the first draw(1) caller -> drawCommitBlock = c; (3) nobody calls draw/poke/enter/roll on the pad until arbBlockNumber() > c + 257; (4) attacker calls draw(32) repeatedly -> seed equals the precomputed fallback, attacker gets the chosen seats and also the 0.001 ETH bounty. Expected: seat selection unbiased; actual: biased toward the attacker, conditional on (3). If any party calls between c+4 and c+257 the hash path is taken and the precomputation is worthless (the suite's test_lapsedSeedIsIndependentOfCallerAndCaptureDelayAndBountyPaidOnce covers the mechanics).","severity":"low","snippet":"                drawSeed = keccak256(abi.encode(address(this), drawCommitBlock, entrants.length));","title":"Disputed finding 779d74d2 re-examined: the first committer picks the commit block and can precompute the fallback seating, but only wins if nobody captures the hash seed for 258 L2 blocks; we agree it"},{"citation":"resolved","description":"Re-tested the rewritten settlement (exttload unlock check, EIP-150 precondition, gas-bounded walk with the 16-pool floor, broken-pool flagging, reignKing / settledReign). No path lets the bidder take the outgoing king's fees: the trailing distribute(2) inside takeCrown pays reignKing[stamp] for every unreached pool; a bidder cannot lower the forwarded gas below 5M + 5M/63 and the 16 floor is met with the minimum stipend even if every collection burns its full 300k; collection cannot be made to fail by outsiders (fixed PadToken, fixed hook, 30k-capped push with claimable fallback), so takeCrown cannot be bricked; in-kind token fees cannot revert (token is unlocked, PadLiquidity is also its `liquidity`). The accepted imprecision in docs/AUDIT.md #29 is, however, larger than the one-line note suggests: every fee earned on an unreached pool after the crown change, for as long as nobody collects it, goes to the OLD king. With a minimum-gas bid settling ~40 pools (builder's measurement), a pad with P pools leaves P-40 pools owed to the previous king, and the trailing upkeep only advances two per pad action. The new king must spend its own gas on distribute(16)/retryPool over the remainder immediately after being crowned. Not exploitable for profit by the bidder; it is an incentive/keeper note. Recommendation (operational, no redeploy): the king UI should run distribute(16) in a loop right after takeCrown until every pool's settledReign == reign; a v4 could record per-pool feeGrowth snapshots at the crown change instead of stamping whole pools.","line":264,"path":"src/PadLiquidity.sol","reproduction":"State: 60 graduated pools, king A, trades on all pools. Steps: B calls takeCrown with the minimum gas (5M + 5M/63 + 600k); the strict walk settles pools from the cursor until gasleft < 400k (about 40), stamps them reign+1; pools 41..60 keep stamp = old reign. Then traders swap on pool 59 for a day. Anyone calls distribute(1) when the cursor reaches 59. Expected (intuitively): fees earned under B go to B. Actual: reignKing[old] = A is paid the whole accumulated amount, then the pool is stamped. The suite's test_poolsBeyondTheBidGasStayOwedToTheOutgoingKing shows the first half; add a post-crown donation to an unreached pool before collecting to observe the second half.","severity":"info","snippet":"        address payee = settled < reign ? reignKing[settled] : k;","title":"Reign stamps: pools the bid's walk does not reach pay ALL their later fees to the outgoing king until someone collects; cost of correcting this grows linearly with pool count"},{"citation":"resolved","description":"What this review could confirm from robin.etherscan.io (page text, no RPC): PvPPad 0xf425..., PadLiquidity 0x3ebb..., KingOfThePad 0xafb6... are 'Source Code Verified [Exact Match]' with solc v0.8.26+commit.8a97fa7a, 200 runs, cancun, and the decoded constructor arguments match the manifest: PvPPad(owner 0x5b95...0d06, tokenTemplate 0x6870...17f0, gameTemplate 0x82c0...51eb); PadLiquidity(poolManager 0x8366...0951, pad 0xf425...); KingOfThePad(pad 0xf425..., liquidity 0x3ebb..., dev 0x5b95...). PoolManager 0x8366... is verified as 'Uniswap: Pool Manager', solc 0.8.26, 44444444 runs (the official v4 build settings). What could NOT be confirmed and must be read on chain before openPad, because the one-shot binders accept the first caller (docs/AUDIT.md #19) and openPad() cannot be undone: pad.liquidity() == 0x3ebb..., pad.king() == 0xafb6..., liq.king() == 0xafb6..., liq.hook() == 0, pad.opened() == false, pad.launchCount() == 0, PoolManager.exttload(0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23) == 0 at rest (Lock.IS_UNLOCKED_SLOT), and that the deployed PoolManager is v4-core v4.0.0 (`_pools` at storage slot 6, initialize(key, sqrtPriceX96) without hookData, hook flag mask 0x3FFF). If the Lock slot differed, requireManagerLocked would pass unconditionally and the v2 High #1 would be live again; if slot 6 differed, _priceLimit would read 0 and burn buys would run with no impact cap.","line":79,"path":"src/PvPPad.sol","reproduction":"Checks (any RPC or the explorer Read tab): cast call 0xf425c5130208847f2e8422e35bcd37dcb7540bc5 'liquidity()(address)'; 'king()(address)'; 'opened()(bool)'; cast call 0x3ebbe5148eb9a2c45c5d2206dd9b8d9bf7ef7700 'king()(address)'; 'hook()(address)'; cast call 0x8366a39cc670b4001a1121b8f6a443a643e40951 'exttload(bytes32)(bytes32)' 0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23. Expected: 0x3ebb..., 0xafb6..., false, 0xafb6..., 0x0, 0x0. If any differs, do not call openPad; redeploy. Also rehearse: read PoolManager.extsload for an existing pool's slot0 (keccak256(poolId . 6)) and compare with its known price.","severity":"info","snippet":"        liquidity = msg.sender;","title":"Pre-open checklist: on-chain bindings and PoolManager layout cannot be verified offline; openPad is irreversible if `liquidity`/`king` were bound by another caller"},{"citation":"resolved","description":"A roll committed in block c resolves only when arbBlockNumber() > c + 3. Arbitrum-stack chains produce L2 blocks only when there are transactions, so after the game ends the last roll may sit unresolvable until three further blocks are produced, and finalize()/poke() cannot pay the pot before then. Each transaction normally makes its own block, so four transactions (even failing finalize() calls) unblock it; at 10 blocks/s on a busy chain this is 0.4 s. Not a fund-safety issue, but a keeper that calls finalize once and gives up will leave the pot and the carry pull pending. Re-tested the other upkeep gas questions from the brief: enter() and roll() cannot be reverted by upkeep (every pad step is behind _canSpend and try/catch), _resolve has a 200k floor, and no step can be skipped forever since draw/resolvePending/finalize/graduate are permissionless with full caller gas.","line":559,"path":"src/PadGame.sol","reproduction":"State: game in play, one Imp rolls at L2 block c (MockArbSys set to c), then vm.warp past endTime(). Call finalize() while arbBlockNumber() == c + 1: reverts 'not finalizable' (pendingCount == 1 and _resolveRoll returns false). Advance arbBlockNumber to c + 4 and call again: succeeds. Expected by a naive keeper: finalizable as soon as ended() is true; actual: also needs the three post-commit blocks.","severity":"info","snippet":"        if (finalized || !ended() || pendingCount != 0) return false;","title":"Finalization waits for four L2 blocks after the last roll; on an idle sequencer finalize() reverts 'not finalizable' until four more blocks exist"},{"citation":"resolved","description":"Re-tested the carry pull at finalize: it is pulled once (finalized is set right after, fundPot requires !finalized), only by the registered game, only with >= 5 living Imps, and only up to fundedReleaseCap() - fundedPot where the cap is min(3x roll pot, 15% of ethRaised). The 15% constant is below the share of curve ETH a sole buyer can ever pull back out of the full-range pool (0.99(1+e)/(1.99+0.99e) of e, max 83% at the 4 ETH sell-out), so a sybil that fills a launch to claim carry loses at least 4% of its curve ETH plus the 2% burn and gas; we agree with docs/AUDIT.md #13 and could not construct a profitable sybil. The flip side is that carry is sticky: a sybil-diverted king share (see the window finding), funding returned from games that ended with fewer than five living Imps, and ranking dust accumulate with no other release path, and each qualifying game can take at most 0.6 ETH (15% of a 4 ETH sell-out). On a quiet pad this ETH is effectively locked until honest, well-raised games finalize. Design trade-off to document for users; a v4 could add a time-based decay of carry into the burn bucket.","line":175,"path":"src/PvPPad.sol","reproduction":"State: carryPot = 5 ETH (e.g. after several king bids landed while no honest game was in the window). A launch raises e = 0.1 ETH and ends with 5 living Imps: takeCarry maxAmount = min(3 x rollPot, 0.015 ETH) - fundedPot -> at most 0.015 ETH leaves carry. A sold-out launch (4 ETH) takes at most 0.6 ETH. Expected by users: the king share funds the game it was bid during; actual: it is spread over future finalizations, possibly never. Observable with the suite's test_fiveWalletCarryChainOnlyPullsUnusedCapAtFinalize by starting with a large carryPot.","severity":"info","snippet":"        uint256 carry = carryPot < maxAmount ? carryPot : maxAmount;","title":"Carry pot has a single, capped exit: ETH parked there (returned funding, diverted king shares, donations, dust) can stay indefinitely and drips out at most 15% of each qualifying raise"}],"hash":"c27572d02d67a99d430cdb682b761282e9b1bd2a874aed0997198d57d5f16b01","nodeId":"75efa74e-1cfc-4d15-ba1e-fb87d597813a","outcome":"completed","summary":"Findings are written to `.imd-findings.json` (7 entries, all parse and point at exact lines). Scratch test and logs live only under `test/scratch/`.\n\n## Verdict\n\nOpening the pad is safe from a fund-safety standpoint, provided the on-chain bindings are read first. I found no critical or high defect, no way to drain, strand or permanently lock ETH or tokens, and no input that makes graduation revert permanently on a genuine v4 PoolManager. A redeploy is not required before `openPad`; the three low findings are griefing and fairness issues that a v4 could fix later.\n\n## What I re-tested and agree with\n\n- **Graduation.** Only PadLiquidity can initialize a pool with PadHook, price and liquidity bounds hold for every reachable raise (0.0018 to 4 ETH), the 1 ppm haircut covers v4's round-up, and the game always holds at least 200M tokens so the \"empty\" guard cannot trip. Native settlement is explicitly synced.\n- **King settlement.** A bidder cannot forward less than 5M + 5M/63 gas, the 16-pool floor is met even if every collection burns its full 300k, unreached and broken pools pay the stamped reign's king even in the bid's own trailing upkeep, collections cannot be made to fail by outsiders, and in-kind token fees cannot revert. `takeCrown` cannot be bricked.\n- **Tips, draw, carry.** Tips are credited once, routed to the forwarded caller, and expired tips are penalised into the pot. The bounty is zeroed before payment. Carry is pulled once at finalize under the 15% cap, which sits below the 83% maximum a sole buyer can dump back out of the pool, so I could not build a profitable sybil. On the disputed draw finding 779d74d2, I agree with the builder: low, conditional on a pad idle for 258 blocks.\n- **Explorer.** PvPPad, PadLiquidity, KingOfThePad and the PoolManager show \"Exact Match\" verification with the expected constructor arguments. The template addresses match the pad's constructor.\n\n## Findings\n\n1. **Low, PvPPad.sol:190.** Ten free launches plus ten 0.001 ETH entries fill the king's last-10 window with dead games, so a crown bid's 50% games share goes to carry instead of the live honest game. Confirmed on the real-v4 harness: the honest game's funded pot drops from 0.05 ETH to 0 and carry grows by exactly 0.05 ETH.\n2. **Low, PvPPad.sol:240.** The same cheap entries starve pad-driven upkeep: the newest-entered slot is attacker-refreshable and an honest game is tended once per n-1 pad actions. Everything stays callable directly.\n3. **Low, PadGame.sol:193.** Fallback seed predictability, as in 779d74d2. Agree with the accepted-risk disposition.\n4. **Info.** Reign stamps misattribute all later fees of unreached pools to the outgoing king until collected. The new king should loop `distribute(16)` right after crowning.\n5. **Info.** Pre-open checks that cannot be done offline: `pad.liquidity()`, `pad.king()`, `liq.king()`, `opened()`, and the PoolManager's Lock slot via `exttload`. If `liquidity` was bound by a stranger, opening is irreversible.\n6. **Info.** Finalize needs four L2 blocks after the last roll on an idle sequencer.\n7. **Info.** Carry has a single capped exit and can sit indefinitely on a quiet pad.\n\n## Limits\n\nStatic review plus the existing suite and one new scratch test. No RPC, so bindings and the deployed manager's storage layout were not read. This remains a swarm review. An independent human audit and legal review are still recommended before a public launch.","treeHash":null,"usage":{"cachedInputTokens":1104355,"inputTokens":258,"model":"claude-fable-5-1","outputTokens":61625,"runtime":"claude","turns":19,"wallClockMs":1492927}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"5835e48821d8827d","findings":[{"citation":"resolved","description":"AUDIT.md #13 argues a sybil that fills an idle launch to extract funded ETH loses 'at least 4% of the curve ETH plus burn and gas'. That bound treats the 2% burn share as lost. On the genesis game (and on every restarted genesis) the burn share is credited to the pot instead (this line), and the pot is paid to the top half of the sybil's own Imps, so it comes back. The 1% resolver tip also comes back when the sybil resolves its own rolls, and the finalizer fee is paid to whoever finalizes (the sybil). With the curve capped at 4 ETH the release cap is min(3x roll pot, 15% of ethRaised) = 0.6 ETH. The sybil's irrecoverable cost is only the ETH a full dump cannot pull back out of the graduated pool (about 17% of e) minus the 15% release, so about 1.8% of the curve ETH plus entry fees and gas. Exploitable only after openPad and only when the carry pot holds ETH (king extras, returned funded pots, donations). It is griefing with leverage, not profit: honest players lose carry that would have been released to a real game. I therefore do not accept the AUDIT.md framing that the 15% cap makes the pull 'a loss however the roll pot was inflated' as sufficient; the loss is real but about 5 to 8 times smaller than the carry removed. Recommended fix (needs a v4 redeploy): exclude the genesis burn share from the roll-earned pot used by _releaseCap, or lower FUNDED_RELEASE_CURVE_BPS to about 1_000 so release stays below the retained-ETH floor minus the recoverable 2%/1% shares; operationally, keep the carry pot small (king extras drip at 15% per game) and monitor games with exactly five living Imps.","line":261,"path":"src/PadGame.sol","reproduction":"Genesis after openPad, carry pot >= 0.6 ETH (e.g. one king extra of 1.2 ETH after an earlier genesis is impossible, so assume donations via fundCarry or returned funded pots). Sybil enters 45 wallets (0.045 ETH), gets 45 seats (<200 entrants), each Imp rolls every 60 s for 50 min: ~2,223 rolls = 4.446 ETH. ethRaised stops at 4.0 ETH (quote() sells out at K/(VTOK-CURVE_SUPPLY)-vEth = 4 ETH); roll pot earned = 7% + 2% genesis burn = 0.400 ETH; tips 0.044 ETH claimable to the sybil resolver. At finalize living = 45 >= 5, _releaseCap(0.400) = min(1.2, 0.6) = 0.6 ETH pulled from carry and paid with the pot to the sybil's own top-23 Imps (1.0 ETH back, finalizer fee included). Graduation seeds the pool with 4 ETH against 160M tokens at 40M tokens/ETH; dumping the sybil's 800M tokens through the 1% pool returns 4*0.99*800/(160+0.99*800) = 3.327 ETH. Expected per AUDIT.md: net loss >= 4% of 4 ETH = 0.16 ETH plus burn. Actual: spent 4.491 ETH, recovered 4.371 ETH, net loss 0.12 ETH (0.073 ETH before entry fees), while 0.6 ETH left the carry pot. test/scratch/Probe.t.sol::test_releaseCapAtSellOut checks the 0.6 ETH cap arithmetic; the Revision suite's sole-buyer test measures the non-genesis case only.","severity":"low","snippet":"        if (genesis) potEth += burnEth;","title":"Carry pot can be pulled by a sybil-filled genesis game at about 2% loss, not the 4%+ floor AUDIT.md #13 claims"},{"citation":"resolved","description":"The resolution window is L2 blocks commit+4 .. commit+257 (about 0.4 s to 25.7 s at the stated 10 blocks/s), while COOLDOWN is 60 s. The 'own-previous-roll resolution' path in roll() (line 253) therefore always finds the previous roll expired on a chain producing 10 blocks/s: the height moves up to 4 toward zero and the 0.00002 ETH tip goes to the pot instead of the roller. AUDIT.md #1 discloses this and I agree it is an accepted design limit, not a fund-safety defect: no ETH is lost or locked, and finalization is never blocked (an expired roll still resolves). It matters for the open decision because the game is only fair if independent resolvers (poke/resolvePending/upkeep from other games) run continuously from the first roll; without them every honest roll is penalised. Recommended: run at least two independent resolver keepers before openPad; in a v4 redeploy consider a window independent of the sequencer's block cadence (e.g. anchor expiry to block.timestamp) or a shorter cooldown.","line":518,"path":"src/PadGame.sol","reproduction":"test/scratch/Probe.t.sol::test_rollerCannotSelfResolveAtTenBlocksPerSecond (passes on this code): genesis draw completes, Imp 0 rolls at L2 block B, nobody resolves, the same wallet rolls again 60 s later at block B+600. Expected by a player: its first roll resolves with real dice and the tip is credited to it. Actual: getRoll(0).expired == true, claimable(roller) == 0, pot grew by 7% of the second roll plus the 0.00002 ETH expired tip.","severity":"info","snippet":"        if (commit + 1 + HASH_WINDOW < cur) {","title":"At 10 L2 blocks/s a roller can never resolve its own roll on its next roll; every roll needs a second transaction or a third-party resolver within ~25 s"},{"citation":"resolved","description":"The first post-signup caller fixes drawCommitBlock and can precompute the fallback seating for each candidate commit block (entrants.length is frozen once signup ends). The fallback only applies if no one calls draw/poke/resolve-with-upkeep for the game between commit+4 and commit+257 (about 26 s), and whoever captures the seed in that window receives the first-entry bounty, so any honest participant (including the victim) is paid to defeat the attack. Below 200 entries the seed only orders seats, which matters only as the last ranking tie-break. I therefore concur with AUDIT.md #30. Caveat: pad-level upkeep (_upkeep) tends only the latest entered game and one round-robin game, so a game in its draw phase that is neither relies on its own entrants or a keeper to capture the seed; a seed-capture keeper is part of the pre-open checklist, not optional. No change needed in the contracts for this.","line":193,"path":"src/PadGame.sol","reproduction":"Attacker with 201 of N entries is the first draw() caller after signupEnd at L2 block C (chosen by waiting for a C whose keccak256(game, C, N) seating favours it). If no transaction touches the game for 258 L2 blocks, the next _draw sets drawSeed = keccak256(abi.encode(game, C, N)) and seats follow the precomputed order. Any single draw(1) or poke() by anyone at blocks C+4..C+257 instead seeds from arbBlockHash(C+1..C+3), which the attacker cannot predict at commit time, and pays that caller the 0.001 ETH bounty.","severity":"info","snippet":"                drawSeed = keccak256(abi.encode(address(this), drawCommitBlock, entrants.length));","title":"Disputed finding 779d74d2 (predictable fallback seed chosen by the first committer): I agree it is low and acceptable, with one operational caveat"},{"citation":"resolved","description":"Not a defect; recorded because the task asked for it and it gates the open decision. Read over public Robinhood Chain RPC (chain id 4663, block ~89,080,646): the runtime code at PvPPad 0xf425..0bc5, PadGame 0x82c0..51eb, PadToken 0x6870..17f0, PadLiquidity 0x3ebb..7700 and KingOfThePad 0xafb6..9b37 equals the local forge build of commit c3a2fac (solc 0.8.26, via-IR, 200 runs, no metadata) byte for byte except inside the immutable slots (PvPPad 272 bytes, PadLiquidity 308, KingOfThePad 137; PadGame and PadToken identical). Bindings: pad.liquidity, pad.king, liq.pad, liq.king, king.pad, king.liquidity and king.dev all point at each other and at owner 0x5b95..0d06; pad.opened == false, launchCount == 0, hook == 0, all three ETH balances 0, deployedAt 1792015720 (open deadline 1794607720, 2026-11-13, after which anyone may open). The PoolManager at 0x8366..0951 is byte-identical (24,009 bytes) to the canonical Ethereum mainnet v4 PoolManager 0x0000..8A90 except for the 20-byte NoDelegateCall `original` immutable, so Lock.IS_UNLOCKED_SLOT, the pools slot 6 used by sqrtPriceOf, sync/settle semantics and BalanceDelta packing in the vendored v4-core v4.0.0 are the deployed ones; exttload(IS_UNLOCKED_SLOT) returns 0 and extsload works. A protocol fee controller is set (0x6d00..314c): it can take up to the v4 maximum share of swap fees from the pad's pools, lowering king fee income, but cannot block graduation, collection or burns. ArbSys at 0x64 answers (arbBlockNumber 89,080,943, ArbOS 116), so the arbBlockHash 256-block availability rule the code relies on holds.","line":72,"path":"src/PadLiquidity.sol","reproduction":"cast code for each address versus out/<Name>.sol/<Name>.json deployedBytecode with immutableReferences masked: zero differing bytes outside immutables. cast code of the Robinhood PoolManager versus the mainnet canonical PoolManager: 20 differing bytes at offset 13618, equal to the respective contract addresses. Expected and actual match; nothing to fix.","severity":"info","snippet":"    IPoolManager public immutable poolManager;","title":"Deployment verified against the pinned source: runtime bytecode, bindings and the real PoolManager match the assumptions the tests make"},{"citation":"resolved","description":"Dead public constant. Harmless, but an explorer reader may assume PvPPad enforces the minimum; the actual check is `>= 5` in PadGame.isLiveForKing and FUNDED_MIN_LIVING in _finalize. Documentation-only; no behavioural impact before or after openPad.","line":15,"path":"src/PvPPad.sol","reproduction":"grep KING_MIN_IMPS src/: only the declaration at PvPPad.sol:15. Expected: used in fundGames or removed. Actual: unused.","severity":"info","snippet":"    uint256 public constant KING_MIN_IMPS = 5;","title":"PvPPad.KING_MIN_IMPS is declared but never read; the five-Imp rule lives only in PadGame"}],"hash":"d800bd0cca9012a73bcfc0ee5b40e40ef4b12c4952d1e0babbb5cee6cb5e427a","nodeId":"5c70ca50-3353-40c5-8d90-dd262352afa2","outcome":"completed","summary":"Review complete. The findings file holds five entries, all validated against the tree. Here is the report.\n\n**Verdict: it is safe to call openPad.** I found no critical, high or medium defect in the v3 contracts. Nothing I could construct drains, strands or locks ETH or tokens, makes graduate() revert permanently, bricks takeCrown, or lets a bidder take the outgoing king's fees. Two operational preconditions matter more than any code change: run independent resolver and seed-capture keepers from the first entry onward, and keep the carry pot small. A redeploy is not required before opening.\n\n**Deployment verification (done over public RPC, which turned out to be reachable)**\n\n- Runtime bytecode at all five addresses equals the local build of commit c3a2fac byte for byte outside the immutable slots.\n- All cross-bindings are consistent, the pad is unopened with zero balances, and the public open deadline is 2026-11-13.\n- The Robinhood PoolManager is byte-identical to the canonical mainnet v4 PoolManager except for its own address in the NoDelegateCall immutable. The lock slot, pools slot, sync/settle semantics and delta packing the code depends on are therefore the real ones. A protocol fee controller is set, which can only reduce king fee income.\n- ArbSys at 0x64 responds with ArbOS 116, so the 256-block hash window the draw and dice rely on holds.\n\n**Findings, ranked**\n\n| Severity | Finding | When |\n|---|---|---|\n| low | Carry pull by a sybil-filled genesis loses only about 2% of curve ETH, not the 4%+ AUDIT.md #13 states, because the genesis burn share returns to the sybil's own pot. Leveraged griefing of the carry pot, not profit. Fix in v4: exclude the genesis burn share from the release-cap base or lower the curve cap. | after open |\n| info | At ten blocks per second the 257-block window ends before the 60 s cooldown, so a roller can never self-resolve on its next roll. Confirmed by a scratch test. Accepted in AUDIT.md #1; I concur, but keepers are mandatory, not optional. | after open |\n| info | Disputed finding 779d74d2 on the fallback seed: I agree it is low and acceptable. Any single call in the 26 s window defeats it and is paid the bounty. Caveat: pad upkeep only tends two games per call, so a drawing game may depend on its own entrants or a keeper. | after open |\n| info | Deployment verification result as above. No defect. | pre-open |\n| info | KING_MIN_IMPS in PvPPad is dead code. | none |\n\n**Areas re-tested and found sound.** King settlement: the exttload check, the EIP-150 precondition, the gas-bounded walk, the sixteen-pool floor, broken-pool flagging across blocks and the reign stamps all behave as AUDIT.md #21 and #29 describe. The minimum stipend covers sixteen collections even if every one consumes its full 300k cap. A bidder can shorten the walk but every unreached pool stays owed to the outgoing king. In-kind fee payment cannot fail except by gas, and ETH falls back to claimable. Resolver tips: credited once, routed to the real caller through upkeepFor, expiry still penalised, and ETH conservation holds. Draw: one binding commit, one bounty payment settled before the push. Upkeep: every step is capped and caught, so enter() and roll() cannot be made to revert, and finalize and graduate stay callable directly. Carry: pulled once at finalize, under the cap, only with five living Imps. Graduation: the only remaining failure mode would have been a PoolManager build mismatch, which the bytecode comparison rules out.\n\n**Limits.** This is a static review plus the existing 193 passing tests and two scratch probes, with no fork. I did not quantify sequencer influence on hashes or MEV against burn buys. An independent human audit and legal review are still recommended before public launch.","treeHash":null,"usage":{"cachedInputTokens":1759002,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":63965,"runtime":"claude","turns":25,"wallClockMs":1141644}}],"verification":[]}