{"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":"b5008386-39a4-4a38-bcaa-76bfad4e323f","kind":"shape:chain","nodes":[{"acceptedSubmissionHash":"e9f23ca760b703af67aa8c701175b99d305b7ccb11df4b7d38317ad3fa82aeb8","dependsOn":["build_contract_project"],"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":"82f581c531d5ae950cb4635b3164d2eaa0a2a9231c5d2ef8d540c09453703edf","dependsOn":["build_contract_project"],"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":"29e37364177af764402b1b4c1460e86622c47d5dfb0f213f307c7abc9472945b","dependsOn":["build_contract_project","write_foundry_tests","manifest","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":"b9d748490becf2a672ee24280b0622b36098395b44bb5a92d02aa922169694f4","dependsOn":["build_contract_project"],"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":"c8486928f6b7b0f6cbe5e0c9f2ddf5bef478ced0f0aa1770e528f21ae57755a9","dependsOn":["build_contract_project"],"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"},{"acceptedSubmissionHash":"c67d43b9df79339edf57fd11a575faf1613e1b7264543ebf3ee82962242981e3","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"d59561da3b378461b7c749f6bd5084477d44993aa94972ddea90f542535751df","skillId":"build-contract-project","tools":[]},"key":"build_contract_project","kind":"code","role":"implement","skillHash":"d59561da3b378461b7c749f6bd5084477d44993aa94972ddea90f542535751df","skillId":"build-contract-project","state":"accepted"},{"acceptedSubmissionHash":"5cd00a9fc5f93edfe7fb36078124c08dfefe1e9043ce5206524eb65f08680435","dependsOn":["build_contract_project"],"execution":{"network":false,"profile":"foundry","requires":[],"tools":[]},"key":"manifest","kind":"code","role":"integrate","skillHash":null,"skillId":null,"state":"accepted"},{"acceptedSubmissionHash":"eef9889c98bb68a487622cc83df9149111a529cdad14598588a948493952d0c8","dependsOn":["build_contract_project"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"4c76766b00591dfe982fe85d3de589fdb2f810e5137c301155d1c5e36a1d3b23","skillId":"write-foundry-tests","tools":[]},"key":"write_foundry_tests","kind":"code","role":"tests","skillHash":"4c76766b00591dfe982fe85d3de589fdb2f810e5137c301155d1c5e36a1d3b23","skillId":"write-foundry-tests","state":"accepted"}],"objective":"Build a complete, production-grade, extremely secure product called IMDPot.\n\n### Project Overview\nIMDPot is a fast 15-minute jackpot game using only the $IMD token. The platform never risks its own capital and only earns a 5% house fee.\n\n### Core Rules\n\n- Only $IMD is accepted\n- Minimum ticket: 0.1 IMD\n- Each round lasts exactly 15 minutes\n- Timer only starts after the first deposit of the round\n- If no one deposits, the round never starts\n\n**Finalize Function (Very Important):**\n- Anyone can call the `finalizeRound()` function after the 15 minutes have ended\n- Calling `finalizeRound()` must automatically:\n  1. Select the Top 3 winners using provably fair randomness\n  2. Make the 70% prize pool claimable by the Top 3 winners (40% / 20% / 10%)\n  3. Automatically send 10% to the dead address `0x000000000000000000000000000000000000dEaD` (burn)\n  4. Automatically send 5% to the treasury wallet `0x0b176115c1bd31a782133100d3aaa6be2b16f118`\n  5. Permanently lock the remaining 15% inside the contract\n  6. Distribute virtual non-transferable Loyalty Points to all participants proportionally\n\n### Loyalty Points\n- Points are virtual and non-transferable\n- Points cannot be withdrawn as $IMD\n- The 15% $IMD remains permanently locked in the contract (no one can withdraw it, including the owner)\n- After participating in 10 rounds, players can redeem points for a multiplier that gives extra chances\n\n### Winner Selection\n- Provably fair randomness (prefer Chainlink VRF)\n- Every ticket has equal chance of winning\n- Multiplier only adds extra chances\n\n### Frontend\n- Clean, modern, mobile-friendly design\n- Clear countdown timer\n- Live pool size\n- Easy ticket buying\n- Claim section for winners\n- Dashboard showing points and multiplier\n- Support any wallet including Privy\n- Professional casino style (“No crying in the casino”)\n\n### Security Requirements (Critical – Highest Priority)\nThe smart contract must be built with maximum security. It must be resistant to all major vulnerabilities and attack vectors, including but not limited to:\n\n- Reentrancy attacks\n- Front-running\n- Flash loan attacks\n- Randomness manipulation\n- Access control exploits\n- Integer overflow/underflow\n- Denial of service\n- Timestamp manipulation\n- Any method that could allow unauthorized draining of funds\n\nRequired:\n- ReentrancyGuard on all sensitive functions\n- Strict access control\n- Emergency pause\n- Full input validation\n- Comprehensive NatSpec documentation\n- Extensive Foundry tests covering normal flows and attack scenarios\n\n### Goal\nCreate a highly secure, fair, and polished jackpot game where anyone can finalize the round, tokens are automatically distributed correctly, 15% is permanently locked, and players receive non-withdrawable loyalty points.\n\nName the project IMDPot.","parentJobId":null,"planHash":"350a7f4a7ca6724051b028dc268c2018a844a2df433c1cafbaa2bbb1efaa3ae2","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"b5008386-39a4-4a38-bcaa-76bfad4e323f","publication":{"commit":null,"deliveredAt":null,"repoUrl":"https://github.com/identity-md-launches/launch-2159-build-complete-production-grade-extremel"},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51885","feedbackHash":"22cb678d35c71ec1402cc9af3ce108fe6b1046e00d16fb8e9850de8ab85ed14c","nodeKey":"audit_economics","submissionHash":"e9f23ca760b703af67aa8c701175b99d305b7ccb11df4b7d38317ad3fa82aeb8","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52169","feedbackHash":"06895fcf7309b63e728d316529358fde18745db51e188fd0049a52ada6c3720b","nodeKey":"audit_flow","submissionHash":"82f581c531d5ae950cb4635b3164d2eaa0a2a9231c5d2ef8d540c09453703edf","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51071","feedbackHash":"5a6a66416ecb2179e0a5c7dcc606befa7855e7867033095808007be46405b17e","nodeKey":"audit_judge","submissionHash":"3996b21f5e2728b4497c3a8ea43f8526ec058ded760a1c83f6ec047515e14f49","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51511","feedbackHash":"fcf2d14de200231aecb9ca046a440754f70f274b22d7286f9cdcdcde3ae59171","nodeKey":"audit_judge","submissionHash":"29e37364177af764402b1b4c1460e86622c47d5dfb0f213f307c7abc9472945b","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52135","feedbackHash":"847ae3833565457dc5e7f488bf2b36fef53931f87becaa39765e7b3ffefdd5ad","nodeKey":"audit_math","submissionHash":"b9d748490becf2a672ee24280b0622b36098395b44bb5a92d02aa922169694f4","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51876","feedbackHash":"6fce7130c6d282cba109cda574468eea75f8a73b621a7e37e33299b3c7b15592","nodeKey":"audit_permissions","submissionHash":"c8486928f6b7b0f6cbe5e0c9f2ddf5bef478ced0f0aa1770e528f21ae57755a9","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51423","feedbackHash":"bf9ee7dcf6ad554e7cc5a3641b7c895ec92bb48fc79c762f76821b0338393aee","nodeKey":"build_contract_project","submissionHash":"d2529fcf511c1c0fd97bdf3a212c1a8332730200dfec0adbdba7d4771fe264c0","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"51035","feedbackHash":"61e2422c36c72822242be380fb6984cb4c3fe63d4cb8c1bf6c005a67f5f72de7","nodeKey":"build_contract_project","submissionHash":"c67d43b9df79339edf57fd11a575faf1613e1b7264543ebf3ee82962242981e3","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"51081","feedbackHash":"964339ee93ddeaf028a08284270456f371c39194bec19f8cbf850afc31f12544","nodeKey":"manifest","submissionHash":"5cd00a9fc5f93edfe7fb36078124c08dfefe1e9043ce5206524eb65f08680435","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"52128","feedbackHash":"5e5df3bef2c7d2594c691229e47f540041648a27a3c71c0741ca2e390df36bb0","nodeKey":"manifest","submissionHash":"dbea99f81f520ef2330ba426daac82f9842de4625181ef6a01bfe660d14a43df","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"51494","feedbackHash":"090af3d6a92c1d444b5db28733b637975f13985a25510d4fa34541920a41c520","nodeKey":"write_foundry_tests","submissionHash":"0b6adc260ac0be1a133f87e1fa62880f0750c0e274faed7a8d9fb03a9a0f82bf","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"52264","feedbackHash":"e81f1b1391ed9c1262829e04da0c05773d9e53ebcb6c4d29ad84c23ebf04df41","nodeKey":"write_foundry_tests","submissionHash":"eef9889c98bb68a487622cc83df9149111a529cdad14598588a948493952d0c8","tag1":"verification:checks","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"7d59c61cdd14cdc43eea0a8387b0fdfa083f34b457d777218d560abcc62b9487","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":"c1f1cc3a755b8d579c5f392efde3b9b9e659d81bf660d50ce2ef26514e23f98c","device":"3a40eaafbd83a6bc","findings":[{"description":"retryRandomness() can be called by anyone once VRF_RETRY_DELAY (1h) has passed since the request. It overwrites round.vrfRequestId, and rawFulfillRandomWords (line 518) then ignores the earlier request id as stale. So once a fulfillment takes longer than 1h (network congestion, or an underfunded subscription the owner controls and can top up whenever they like), the coordinator's fulfillment transaction for the old request shows its random word in the public mempool before it lands. Anyone who computes from it that they lose can front-run it with retryRandomness() and get a fresh draw, and can repeat this for as long as fulfillments stay slow. This is the re-request pattern Chainlink VRF's security guidance warns against. It breaks the brief's 'provably fair randomness / resistant to randomness manipulation' requirement, and it contradicts docs/SECURITY.md ('a pending draw can never be aborted once its random word might be known'). The existing test test_retry_reRequestsAfterDelayAndIgnoresOldFulfillment asserts this behaviour is correct. Fix idea: never invalidate a request once made. Accept the first fulfillment for any request id issued for the current round (e.g. mapping requestId => roundId), or drop the permissionless re-request and rely on cancel/refund.","line":408,"path":"src/IMDPot.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IMDPot} from \"src/IMDPot.sol\";\nimport {IVRFCoordinatorV2Plus, IVRFConsumerV2Plus, VRFV2PlusClient} from \"src/interfaces/IVRFCoordinatorV2Plus.sol\";\n\ncontract ProofToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function decimals() external pure returns (uint8) {\n        return 18;\n    }\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\ncontract ProofCoordinator is IVRFCoordinatorV2Plus {\n    uint256 public nextId = 1;\n    mapping(uint256 => address) public consumerOf;\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata) external returns (uint256 id) {\n        id = nextId++;\n        consumerOf[id] = msg.sender;\n    }\n\n    /// @dev Delivers a verified word for a request this coordinator accepted, as Chainlink would.\n    function fulfill(uint256 id, uint256 word) external {\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        IVRFConsumerV2Plus(consumerOf[id]).rawFulfillRandomWords(id, words);\n    }\n}\n\n/// @notice Once a VRF request is outstanding, the word the coordinator delivers for it must settle the round.\n///         Today any caller who sees the fulfillment transaction in the mempool can front-run it with\n///         `retryRandomness()` (1 h after the request) or `cancelRound()` (24 h after the round end) and the\n///         observed word is then ignored: an unfavourable draw is discarded and re-rolled or refunded.\ncontract RetryRerollProofTest is Test {\n    address internal constant OWNER = address(0xA11CE0);\n    IMDPot internal pot;\n    ProofToken internal token;\n    ProofCoordinator internal coordinator;\n    address internal attacker = address(0xBAD);\n    address internal bob = address(0xB0B);\n    address internal carol = address(0xCA201);\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        token = new ProofToken();\n        coordinator = new ProofCoordinator();\n        pot = new IMDPot(OWNER);\n        vm.startPrank(OWNER);\n        pot.setToken(address(token));\n        pot.setVrfConfig(address(coordinator), 1, keccak256(\"kh\"), 750_000, 3, false);\n        vm.stopPrank();\n        address[3] memory players = [attacker, bob, carol];\n        for (uint256 i; i < 3; ++i) {\n            token.mint(players[i], 100 ether);\n            vm.prank(players[i]);\n            token.approve(address(pot), type(uint256).max);\n            vm.prank(players[i]);\n            pot.buyTickets(10);\n        }\n    }\n\n    function test_observedWordCannotBeDiscardedByRetry() public {\n        vm.warp(pot.getRound(1).endTime);\n        pot.finalizeRound();\n        uint256 requestId = pot.getRound(1).vrfRequestId;\n\n        // Fulfillment is delayed past VRF_RETRY_DELAY (congestion, or an underfunded subscription the owner tops\n        // up). The coordinator's fulfillment for `requestId` is now in the mempool; the attacker reads the word,\n        // sees they lose, and front-runs it.\n        vm.warp(block.timestamp + pot.VRF_RETRY_DELAY());\n        vm.prank(attacker);\n        try pot.retryRandomness() {} catch {}\n\n        uint256 observedWord = 42;\n        coordinator.fulfill(requestId, observedWord);\n\n        IMDPot.Round memory round = pot.getRound(1);\n        assertEq(uint8(round.status), uint8(IMDPot.RoundStatus.Finalized), \"observed word was discarded\");\n        assertEq(round.randomWord, observedWord, \"round settled with a re-rolled word\");\n    }\n}","reproduction":"3 players buy 10 tickets each; at endTime finalizeRound() -> requestId 1. warp +1h (fulfillment delayed). Attacker calls retryRandomness() -> requestId 2. Coordinator delivers word 42 for requestId 1. Expected: round 1 Finalized with randomWord 42. Actual: StaleRandomnessIgnored(1), round stays Drawing (status 2 != 3) and waits for a new word.","severity":"high","title":"retryRandomness lets anyone discard a VRF word already in the mempool and re-roll the draw"},{"description":"cancelRound() is allowed in the Drawing state as soon as endTime + CANCEL_DELAY has passed, however recently randomness was requested. If nobody finalizes a round for 24h (a quiet game), any participant can call finalizeRound() and then watch the mempool. When the coordinator's fulfillment shows a word under which they lose, they front-run it with cancelRound(): the word is ignored and every ticket becomes refundable. The same works for any draw still pending 24h after the end. The outcome is chosen after the randomness is known. Fix idea: in the Drawing state, only allow cancellation once the latest request is itself older than a delay (e.g. randomnessRequestedAt + CANCEL_DELAY), so a fulfillment that is arriving cannot be pre-empted.","line":418,"path":"src/IMDPot.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IMDPot} from \"src/IMDPot.sol\";\nimport {IVRFCoordinatorV2Plus, IVRFConsumerV2Plus, VRFV2PlusClient} from \"src/interfaces/IVRFCoordinatorV2Plus.sol\";\n\ncontract ProofToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function decimals() external pure returns (uint8) {\n        return 18;\n    }\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\ncontract ProofCoordinator is IVRFCoordinatorV2Plus {\n    uint256 public nextId = 1;\n    mapping(uint256 => address) public consumerOf;\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata) external returns (uint256 id) {\n        id = nextId++;\n        consumerOf[id] = msg.sender;\n    }\n\n    /// @dev Delivers a verified word for a request this coordinator accepted, as Chainlink would.\n    function fulfill(uint256 id, uint256 word) external {\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        IVRFConsumerV2Plus(consumerOf[id]).rawFulfillRandomWords(id, words);\n    }\n}\n\n/// @notice Once a VRF request is outstanding, the word the coordinator delivers for it must settle the round.\n///         Today any caller who sees the fulfillment transaction in the mempool can front-run it with\n///         `retryRandomness()` (1 h after the request) or `cancelRound()` (24 h after the round end) and the\n///         observed word is then ignored: an unfavourable draw is discarded and re-rolled or refunded.\ncontract CancelAbortProofTest is Test {\n    address internal constant OWNER = address(0xA11CE0);\n    IMDPot internal pot;\n    ProofToken internal token;\n    ProofCoordinator internal coordinator;\n    address internal attacker = address(0xBAD);\n    address internal bob = address(0xB0B);\n    address internal carol = address(0xCA201);\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        token = new ProofToken();\n        coordinator = new ProofCoordinator();\n        pot = new IMDPot(OWNER);\n        vm.startPrank(OWNER);\n        pot.setToken(address(token));\n        pot.setVrfConfig(address(coordinator), 1, keccak256(\"kh\"), 750_000, 3, false);\n        vm.stopPrank();\n        address[3] memory players = [attacker, bob, carol];\n        for (uint256 i; i < 3; ++i) {\n            token.mint(players[i], 100 ether);\n            vm.prank(players[i]);\n            token.approve(address(pot), type(uint256).max);\n            vm.prank(players[i]);\n            pot.buyTickets(10);\n        }\n    }\n\n    function test_observedWordCannotBeDiscardedByCancel() public {\n        // Nobody finalises for a day (quiet game). Then the attacker finalises, and when the coordinator's\n        // fulfillment appears in the mempool with a losing word, front-runs it with cancelRound for a refund.\n        vm.warp(uint256(pot.getRound(1).endTime) + pot.CANCEL_DELAY());\n        vm.prank(attacker);\n        pot.finalizeRound();\n        uint256 requestId = pot.getRound(1).vrfRequestId;\n\n        vm.prank(attacker);\n        try pot.cancelRound() {} catch {}\n\n        uint256 observedWord = 42;\n        coordinator.fulfill(requestId, observedWord);\n\n        IMDPot.Round memory round = pot.getRound(1);\n        assertEq(uint8(round.status), uint8(IMDPot.RoundStatus.Finalized), \"observed word was discarded\");\n        assertEq(round.randomWord, observedWord, \"round settled with a different word\");\n    }\n}","reproduction":"3 players buy 10 tickets each; warp to endTime + 24h; attacker calls finalizeRound() -> requestId 1, then cancelRound() in the same block; coordinator delivers word 42 for requestId 1. Expected: round settles with word 42 (cancel refused while a fresh request is pending). Actual: round 1 Cancelled (status 4 != 3), word ignored, attacker refunds.","severity":"medium","title":"cancelRound aborts a pending draw, giving participants a free option to refund a losing round"},{"description":"Entering a round moves pendingBoostBps into roundBoostBps and sets pending to 0, so the player can redeem again up to MAX_MULTIPLIER_BONUS_BPS for the next round. If the round is then cancelled, refund() adds the round's boost back but caps the sum at MAX_MULTIPLIER_BONUS_BPS. The excess, which was paid for with points, is lost and the points are not returned.","line":457,"path":"src/IMDPot.sol","reproduction":"Alice plays 10 rounds of 100 tickets (1000 points), redeems 4x (pending 10000 bps), buys 1 ticket in round 11 (boost 10000 consumed), redeems 4x again (pending 10000, points 200). Round 11 is cancelled after 24h; alice calls refund(11). Expected: the 10000 bps boost (400 points) is kept as boost or as returned points. Actual: pending stays 10000, points stay 200; 400 points of redemptions vanish.","severity":"low","title":"Refund of a cancelled round silently destroys redeemed multiplier above the cap"},{"description":"setVrfConfig accepts any contract as coordinator while the current round is Open, with no timelock, and buyTickets takes no expected-coordinator argument. The owner (or a compromised owner key) can therefore front-run the first buyTickets of a round, swap in a contract that delivers chosen words, and so choose winners for that round. docs/SECURITY.md documents this as a trust assumption, but the contract's NatSpec says the owner can never pick winners, and players cannot protect themselves within one block. Fix idea: a timelock on coordinator changes, or let buyers pass the coordinator they expect.","line":291,"path":"src/IMDPot.sol","reproduction":"Round N+1 is Open. Player submits buyTickets(10). Owner front-runs with setVrfConfig(rogueCoordinator,...); the player's tx then succeeds and starts the round under the rogue coordinator, which later calls rawFulfillRandomWords with a word chosen to make the owner's address win.","severity":"low","title":"Owner can front-run a round's first deposit with setVrfConfig pointing at a coordinator they control"}],"hash":"0b6adc260ac0be1a133f87e1fa62880f0750c0e274faed7a8d9fb03a9a0f82bf","nodeId":"061fc619-1caf-4188-9652-85d8f4faf5be","outcome":"completed","summary":"I added two test files and found two ways to manipulate the randomness, both reported with failing proofs. With the verifier's seed, `forge build` succeeds and `forge test` passes: 98 tests in 6 suites (the 61 that were there plus 37 new), about 47 seconds. I changed only `test/`, plus the `.imd-findings.json` the task asked for.\n\n## New tests\n\n**`test/IMDPotEdges.t.sol`** (36 tests) covers the failure paths and boundaries the earlier suites left loose:\n- **Timing:** exact edges for finalizing at `endTime` and retrying 1h after a request; finalizing twice; a round nobody enters can't be finalized or cancelled; the next round's timer starts at its own first deposit.\n- **Randomness delivery:** the same request delivered twice, an old request id sent into a later round, the owner or a player calling the callback, delivery while paused, extra random words ignored.\n- **Cancel and refund:** refund rejected in every round state other than Cancelled (and for round ids 0 and the maximum); cancelled rounds earn no points.\n- **Claims and fees:** a blocked winner doesn't block the other winners; a fee flush that fails reverts cleanly and later succeeds.\n- **Loyalty points:** a third party syncing someone's points; ten rounds but too few points; enough points but only nine rounds; the multiplier cap; a boost covering every ticket of the next round.\n- **Admin:** every owner function refuses strangers; the two-step ownership handover; config bounds and token-decimal bounds; rescue with a zero address; the game keeps running after the owner renounces.\n- **Fuzzed:** ticket-count bounds; an exact 40/20/10 + 10 + 5 + 15 split for any pool with 1–3 players, where every winner actually played; a single player always wins everything.\n\n**`test/IMDPotAccountingInvariant.t.sol`** drives the contract with 18 random actions, including full rounds, ten-round streaks (so the multiplier can actually be redeemed), stalled-then-cancelled rounds, blocked burn and treasury addresses, owner pause, drain attempts and fake randomness delivery. Its invariants:\n- The contract's balance equals what it owes exactly, not just at least.\n- The prize, lock, burn, treasury and refund totals each match the round records.\n- Each player's points plus points spent equal their tickets in finalized rounds, and multipliers never exceed the cap.\n- No unauthorized call ever succeeds, and every winner played, with no duplicates.\n- Players collectively never take out more than they put in.\n\n## Defects reported in `.imd-findings.json`\n1. **High — `retryRandomness` lets anyone re-roll the draw.** If randomness takes more than an hour to arrive (congestion, or a VRF subscription the owner leaves unfunded), anyone can see the coming random word in the mempool. If they'd lose, they front-run it with `retryRandomness()`, the word is ignored, and a new draw is requested. The existing test `test_retry_reRequestsAfterDelayAndIgnoresOldFulfillment` asserts this behaviour is correct. It also contradicts `docs/SECURITY.md`. Proof attached.\n2. **Medium — `cancelRound` can abort a pending draw.** If a round goes unfinalized for 24h, a player can call `finalizeRound()`, read the random word in the mempool, and cancel to get a refund if they'd lose. Proof attached.\n3. **Low — refunds can silently destroy a paid-for multiplier.** A refund caps the restored boost, so 400 points' worth of redemptions vanish in a concrete case I reproduced.\n4. **Low — the owner can swap in their own randomness source just before a round's first deposit.** There is no timelock, and buyers can't specify which source they expect, so the owner could choose that round's winners.\n\nEach proof file fails on the current code for the stated reason and should pass once the defect is fixed. I didn't write passing tests for findings 3 and 4, since those would assert the faulty behaviour as correct.","treeHash":"4339084587fc4cd4629289da7ffda421ee0b7f52","usage":{"cachedInputTokens":1950987,"inputTokens":46,"model":"claude-opus-5-5","outputTokens":43987,"runtime":"claude","turns":24,"wallClockMs":712496}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fbcdfc017217af1f","findings":[],"hash":"29e37364177af764402b1b4c1460e86622c47d5dfb0f213f307c7abc9472945b","nodeId":"67f6cfd2-fc89-45fb-8722-f1f991147df8","outcome":"completed","summary":"Review complete. The revised code settles every finding from my earlier report, and I found no new reproducible defect.\n\n**Result:** `.imd-findings.json` holds an empty findings list, a settled record for all ten earlier findings, and a coverage row for each of the 18 entry points plus one invariant row, all marked holds.\n\n**How each fix was verified**\n\n- **Retry re-roll (high):** every request id is now bound to its round in a mapping, and the callback settles whichever request's word lands first. My proof and both specialist proofs pass. A scratch test with two requests pending confirmed the second word is ignored and cannot touch a later round.\n- **Cancel aborting a fresh draw / dodging fees (medium):** cancel in Drawing now also requires 24 hours since the latest request. In Active it first attempts a randomness request and only cancels if the coordinator rejects or the game is paused. My proof passes, and scratch tests confirmed the sole-participant path now pays burn, fee and lock.\n- **Sybil podium (medium):** selection subtracts one ticket's weight instead of removing the player. A 256-run fuzz with a million-ticket whale against three dust tickets shows the whale takes all three places unless a dust ticket is actually drawn.\n- **Callback gas floor (medium):** floor raised to 750k and the owner can raise gas and confirmations at any time. All three 2048-participant proofs pass at the floor.\n- **Low and info items:** refund returns points for boost above the cap (verified points conservation), fee legs flush independently, renounceOwnership reverts, the frontend renders a per-round refund list from a 200-round scan, and the coordinator trust and tiny-decimal limitation are documented.\n\n**Also checked:** the project suite passes with all 115 tests. The slither leads (divide-before-multiply in refund, reentrancy in flush, uninitialised locals) are benign: the division is exact on multiples of 2500, flush is guarded and re-queues on failure, and the locals are zero-initialised by intent. The scratch directory was removed, so the tree is unchanged apart from the findings file.","treeHash":null,"usage":{"cachedInputTokens":919501,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":16873,"runtime":"claude","turns":17,"wallClockMs":249485}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"39da99ded7f125c8","findings":[{"citation":"resolved","description":"retryRandomness() is permissionless and, one hour after the request, overwrites round.vrfRequestId (line 615). rawFulfillRandomWords() then treats the earlier request as stale (line 518: `round.vrfRequestId != requestId` -> StaleRandomnessIgnored) even though that fulfillment carries a valid VRF proof for this very round. A Chainlink fulfillment transaction exposes its random word in calldata in the public mempool before it lands, and anyone can replay _selectWinners off-chain. Whenever a fulfillment is delayed past VRF_RETRY_DELAY (gas price above the key-hash lane ceiling, node outage, or an underfunded subscription that is topped up later; Chainlink nodes keep retrying for up to 24 h), a losing participant front-runs the node's transaction with retryRandomness() and obtains a fresh independent draw, and can repeat every hour until a favourable word is in flight. The prize is paid to a different party than the one the first valid word selected; the owner pays a second VRF fee each time. This contradicts the brief's 'provably fair / resistant to randomness manipulation' requirement and docs/SECURITY.md ('a pending draw can never be aborted once its random word might be known'); the existing test test_retry_reRequestsAfterDelayAndIgnoresOldFulfillment asserts the defective behaviour. Reported by write_foundry_tests (high), audit_economics (path 1) and audit_permissions; all three reproduce and are merged here. Fix that keeps the design: keep every outstanding request of the round valid (mapping requestId => roundId, or a small set per round) so the first fulfillment to land settles the round and retryRandomness() only adds a request instead of invalidating the previous one.","line":412,"path":"src/IMDPot.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IMDPot} from \"src/IMDPot.sol\";\nimport {IVRFCoordinatorV2Plus, IVRFConsumerV2Plus, VRFV2PlusClient} from \"src/interfaces/IVRFCoordinatorV2Plus.sol\";\n\ncontract RerollToken is ERC20 {\n    constructor() ERC20(\"IMD\", \"IMD\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// @dev Records requests; a test delivers any word for any recorded request, as the Chainlink node would.\ncontract RerollCoordinator is IVRFCoordinatorV2Plus {\n    uint256 public nextRequestId = 1;\n    mapping(uint256 => address) public consumerOf;\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata) external returns (uint256 requestId) {\n        requestId = nextRequestId++;\n        consumerOf[requestId] = msg.sender;\n    }\n\n    function fulfill(uint256 requestId, uint256 word) external {\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        IVRFConsumerV2Plus(consumerOf[requestId]).rawFulfillRandomWords(requestId, words);\n    }\n}\n\n/// @notice A pending VRF fulfillment whose random word is already public (it sits in the mempool) can be voided\n///         by any participant who front-runs it with `retryRandomness()` once an hour has passed since the request.\n///         The draw is re-rolled; the participant repeats until the outcome suits them. The fix is to keep every\n///         outstanding request of the round valid so the first fulfillment to land settles the round and a later\n///         `retryRandomness()` adds a request instead of invalidating the previous one.\ncontract RetryRerollTest is Test {\n    address internal constant OWNER = address(0xA11CE);\n    RerollToken internal token;\n    RerollCoordinator internal coordinator;\n    IMDPot internal pot;\n\n    address internal alice = makeAddr(\"alice\");\n    address internal bob = makeAddr(\"bob\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        token = new RerollToken();\n        coordinator = new RerollCoordinator();\n        pot = new IMDPot(OWNER);\n        vm.startPrank(OWNER);\n        pot.setToken(address(token));\n        pot.setVrfConfig(address(coordinator), 1, keccak256(\"lane\"), 750_000, 3, false);\n        vm.stopPrank();\n        _fund(alice);\n        _fund(bob);\n    }\n\n    function _fund(address who) internal {\n        token.mint(who, 1_000 ether);\n        vm.prank(who);\n        token.approve(address(pot), type(uint256).max);\n    }\n\n    function _winnerFor(uint256 word, uint256 roundId) internal view returns (address) {\n        // One draw over the live tree; mirrors IMDPot._selectWinners for the first slot.\n        uint256 position = uint256(keccak256(abi.encode(word, uint256(0)))) % pot.totalWeight(roundId);\n        uint256 aliceWeight = pot.weightOf(roundId, alice);\n        return position < aliceWeight ? alice : bob;\n    }\n\n    function test_pendingFulfillmentCannotBeVoidedByRetry() public {\n        vm.prank(alice);\n        pot.buyTickets(1);\n        vm.prank(bob);\n        pot.buyTickets(1);\n        uint256 roundId = pot.currentRoundId();\n        vm.warp(pot.getRound(roundId).endTime);\n        pot.finalizeRound();\n        uint256 firstRequest = pot.getRound(roundId).vrfRequestId;\n\n        // The node's fulfillment for `firstRequest` is delayed (gas lane ceiling / outage) for over an hour.\n        vm.warp(block.timestamp + 1 hours);\n\n        // Pick a word under which bob loses; bob sees it in the mempool and front-runs with retryRandomness().\n        uint256 word = 7;\n        while (_winnerFor(word, roundId) != alice) word++;\n        vm.prank(bob);\n        pot.retryRandomness();\n\n        // The node's transaction lands next. The word was produced by a valid VRF proof for this round's request;\n        // it must settle the round rather than be discarded.\n        coordinator.fulfill(firstRequest, word);\n\n        assertEq(\n            uint8(pot.getRound(roundId).status),\n            uint8(IMDPot.RoundStatus.Finalized),\n            \"a valid fulfillment for this round's own request was discarded: bob re-rolled the draw\"\n        );\n        assertEq(pot.getRound(roundId).winners[0], alice, \"the losing outcome bob tried to escape must stand\");\n    }\n}","reproduction":"State: alice and bob each buyTickets(1) in round 1; warp to endTime; finalizeRound() -> request R1; no fulfillment for 1 hour (warp +1h). Pick a word W under which alice wins first place (loop W from 7 upwards until keccak256(abi.encode(W,0)) % totalWeight(1) < weightOf(1, alice)). bob calls retryRandomness() -> accepted (status Drawing, 1h elapsed), round.vrfRequestId becomes R2. The coordinator then delivers rawFulfillRandomWords(R1, [W]). Expected: round 1 Finalized with winners[0] == alice. Actual: StaleRandomnessIgnored(R1) is emitted, getRound(1).status stays 2 (Drawing) and R2 yields a new independent draw. The attached test fails on the current code with 'a valid fulfillment for this round's own request was discarded: bob re-rolled the draw: 2 != 3'.","severity":"high","snippet":"        if (block.timestamp < uint256(round.randomnessRequestedAt) + VRF_RETRY_DELAY) revert RetryTooEarly();","title":"retryRandomness() lets any participant void a VRF fulfillment whose word is already visible and re-roll the draw"},{"citation":"resolved","description":"cancelRound() only checks `endTime + CANCEL_DELAY` (24 h); it ignores when the live randomness request was made and whether finalisation is still possible. Two consequences. (a) Drawing state: if finalizeRound() is called 24 h or more after endTime (quiet game, or the owner paused and later unpaused), the request is cancellable in the same block, so a participant who sees the coordinator's fulfillment in the mempool with a losing word front-runs it with cancelRound(): the word is ignored, the round is refunded in full, and the burn, treasury fee and lock never happen. The outcome is chosen after the randomness is known, contradicting docs/SECURITY.md ('a pending draw can never be aborted once its random word might be known'). (b) Active state: a round whose finalizeRound() would succeed becomes cancellable 24 h after endTime. Since every participant's expected value under settlement is 70% of the stake and a refund returns 100%, a sole participant (or colluding set) who never calls finalizeRound() and is not pre-empted by a keeper gets the full stake back; the 10% burn, 5% treasury fee and 15% lock of that round never happen. Reported by write_foundry_tests, audit_economics (path 2) and audit_permissions; merged here at the higher severity because (a) pays funds to the wrong party once a word is visible. Fix that keeps the design: in Drawing, also require block.timestamp >= randomnessRequestedAt + CANCEL_DELAY (so a fresh request cannot be aborted); in Active, require that a request attempt is impossible or failed (for example only allow cancelling when status is Drawing, and let cancelRound() first try to request randomness when the round is Active).","line":422,"path":"src/IMDPot.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IMDPot} from \"src/IMDPot.sol\";\nimport {IVRFCoordinatorV2Plus, IVRFConsumerV2Plus, VRFV2PlusClient} from \"src/interfaces/IVRFCoordinatorV2Plus.sol\";\n\ncontract ProofToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function decimals() external pure returns (uint8) {\n        return 18;\n    }\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\ncontract ProofCoordinator is IVRFCoordinatorV2Plus {\n    uint256 public nextId = 1;\n    mapping(uint256 => address) public consumerOf;\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata) external returns (uint256 id) {\n        id = nextId++;\n        consumerOf[id] = msg.sender;\n    }\n\n    /// @dev Delivers a verified word for a request this coordinator accepted, as Chainlink would.\n    function fulfill(uint256 id, uint256 word) external {\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        IVRFConsumerV2Plus(consumerOf[id]).rawFulfillRandomWords(id, words);\n    }\n}\n\n/// @notice Once a VRF request is outstanding, the word the coordinator delivers for it must settle the round.\n///         Today any caller who sees the fulfillment transaction in the mempool can front-run it with\n///         `retryRandomness()` (1 h after the request) or `cancelRound()` (24 h after the round end) and the\n///         observed word is then ignored: an unfavourable draw is discarded and re-rolled or refunded.\ncontract CancelAbortProofTest is Test {\n    address internal constant OWNER = address(0xA11CE0);\n    IMDPot internal pot;\n    ProofToken internal token;\n    ProofCoordinator internal coordinator;\n    address internal attacker = address(0xBAD);\n    address internal bob = address(0xB0B);\n    address internal carol = address(0xCA201);\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        token = new ProofToken();\n        coordinator = new ProofCoordinator();\n        pot = new IMDPot(OWNER);\n        vm.startPrank(OWNER);\n        pot.setToken(address(token));\n        pot.setVrfConfig(address(coordinator), 1, keccak256(\"kh\"), 750_000, 3, false);\n        vm.stopPrank();\n        address[3] memory players = [attacker, bob, carol];\n        for (uint256 i; i < 3; ++i) {\n            token.mint(players[i], 100 ether);\n            vm.prank(players[i]);\n            token.approve(address(pot), type(uint256).max);\n            vm.prank(players[i]);\n            pot.buyTickets(10);\n        }\n    }\n\n    function test_observedWordCannotBeDiscardedByCancel() public {\n        // Nobody finalises for a day (quiet game). Then the attacker finalises, and when the coordinator's\n        // fulfillment appears in the mempool with a losing word, front-runs it with cancelRound for a refund.\n        vm.warp(uint256(pot.getRound(1).endTime) + pot.CANCEL_DELAY());\n        vm.prank(attacker);\n        pot.finalizeRound();\n        uint256 requestId = pot.getRound(1).vrfRequestId;\n\n        vm.prank(attacker);\n        try pot.cancelRound() {} catch {}\n\n        uint256 observedWord = 42;\n        coordinator.fulfill(requestId, observedWord);\n\n        IMDPot.Round memory round = pot.getRound(1);\n        assertEq(uint8(round.status), uint8(IMDPot.RoundStatus.Finalized), \"observed word was discarded\");\n        assertEq(round.randomWord, observedWord, \"round settled with a different word\");\n    }\n}","reproduction":"(a) attacker, bob, carol each buyTickets(10) in round 1; warp to endTime + 24 h; attacker calls finalizeRound() -> request R (status Drawing, randomnessRequestedAt == now); attacker immediately calls cancelRound() -> succeeds (status Cancelled, currentRoundId 2); coordinator delivers rawFulfillRandomWords(R, [42]) -> StaleRandomnessIgnored. Expected: round 1 Finalized with randomWord 42. Actual: getRound(1).status == 4 (Cancelled) and attacker refund(1) returns the full 1 IMD stake. The attached test fails on the current code with 'observed word was discarded: 4 != 3'. (b) alice buyTickets(10) alone in round 1; nobody calls finalizeRound(); warp to endTime + 24 h; alice calls cancelRound() (succeeds although finalizeRound() would also succeed) then refund(1). Expected: 1 IMD burned/fee/locked and 7 IMD claimable. Actual: alice's balance returns to its starting value, totalLocked() == 0 and balanceOf(TREASURY) == 0 (scratch test test_cancelActiveRoundAvoidsFees passes on the current code).","severity":"medium","snippet":"        if (block.timestamp < uint256(round.endTime) + CANCEL_DELAY) revert CancelTooEarly();","title":"cancelRound() aborts a pending draw whose request was just issued, and cancels Active rounds that finalizeRound() could still settle"},{"citation":"resolved","description":"_selectWinners draws three distinct addresses and removes the entire weight of each drawn player (tree.remove(index)). A ticket's chance at second and third place therefore depends on how tickets are split across addresses, not on the ticket count: once the dominant holder is drawn first (probability ~ their share), all of their remaining tickets have zero chance at the other two places, which go to whoever else is present. With four or fewer distinct participants every participant is guaranteed a podium place. The brief requires 'every ticket has equal chance of winning' and that the multiplier 'only adds extra chances'; splitting a stake over several addresses is strictly dominant and an observer can enter thin rounds at the last second whenever the on-chain pool shows one or two dominant holders. Numbers: whale 1,000,000 tickets (100,000 IMD), three Sybil wallets 1 ticket each (0.3 IMD). For every random word the three Sybils together receive at least 30% of the pool (30,000.09 IMD, a 100,000x return on 0.3 IMD) and the whale never receives more than 40% (40,000.12 IMD), whereas with per-ticket draws the whale would take all three places with probability ~99.999%. The whale is the identifiable victim; the protocol keeps its 30%. Reported by audit_economics and audit_flow; both reproduce and are merged. Fix is a product decision for the author: draw without replacement at ticket level (subtract one ticket's weight, BPS + boost, from the drawn leaf instead of tree.remove, allowing one address to hold several podium places), or if three distinct winners is intended, require a minimum number of distinct participants before a round can finalize and document that per-ticket chances are only equal for first place.","line":690,"path":"src/IMDPot.sol","reproduction":"setToken(18-decimal ERC-20), setVrfConfig(mock coordinator). whale.buyTickets(1000000); warp to endTime - 1; s1, s2, s3 each buyTickets(1); warp to endTime; finalizeRound(); coordinator delivers any uint256 word (fuzzed 256 runs in scratch test testFuzz_sybil). Observed in every run: claimable(s1)+claimable(s2)+claimable(s3) >= 30% of pool (30,000.09 IMD) and claimable(whale) <= 40% of pool (40,000.12 IMD). Expected under equal chance per ticket: the three Sybil tickets have ~3e-6 odds per slot and the whale's expected take is ~70,000 IMD.","severity":"medium","snippet":"            tree.remove(index);","title":"Podium draw removes a drawn player's whole weight, so dust Sybil entries are guaranteed 2nd and 3rd prize against a dominant player"},{"citation":"resolved","description":"setVrfConfig accepts callbackGasLimit down to MIN_CALLBACK_GAS_LIMIT (600_000) and the NatSpec (lines 287-288), README and docs/SECURITY.md claim settlement costs about 400k-540k gas regardless of participant count. Settlement cost grows with Fenwick tree depth (three find walks and three remove walks, each up to log2 n cold storage writes) and with the leaves the random word happens to select. Measured by this review with a standard OpenZeppelin ERC-20 and one ticket per player (scratch tests GasMeasure.t.sol): 200 players ~562k; 1024 players 602k for word 1; 2048 players 602k-613k depending on the word. Sampling 32 random words with a 600k stipend: 5 of 32 fail at 1024 players, 18 of 32 fail at 2048 players. When the owner configures the floor the contract itself accepts and a round reaches ~1024 one-ticket entries, a sizeable share of draws run out of gas in the Chainlink callback; the coordinator marks the request fulfilled and the round stays Drawing. retryRandomness() after 1 h re-requests with the same callbackGasLimit (VrfConfig memory cfg = vrfConfig) and each retry fails with the same probability and costs the owner a VRF fee; setVrfConfig() reverts with RoundNotIdle because the round is not Open, so the owner cannot raise the limit; the only exit is cancelRound() after endTime + 24 h followed by per-player refund(): no burn, no treasury fee, no lock, no points for that round. One actor can force it with ~1024-2048 fresh addresses buying one ticket each (all refunded, net cost gas only); organic growth triggers it too. Reported by audit_economics and audit_math; both reproduce and are merged. The same guard (line 299) also prevents correcting any other accepted-but-unusable VRF parameter (for example requestConfirmations below the coordinator's minimum, which makes finalizeRound() revert) until the round is cancelled. Fix: raise MIN_CALLBACK_GAS_LIMIT to a value with headroom for any realistic round (for example 1_000_000, keeping 2_500_000 as the ceiling) and correct the documented cost; additionally consider letting the owner raise callbackGasLimit/requestConfirmations while a round is Active or Drawing (coordinator and keyHash still locked) so a stuck round can be rescued by the next retry.","line":136,"path":"src/IMDPot.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IMDPot} from \"src/IMDPot.sol\";\nimport {IVRFCoordinatorV2Plus, IVRFConsumerV2Plus, VRFV2PlusClient} from \"src/interfaces/IVRFCoordinatorV2Plus.sol\";\n\n/// @dev A standard OpenZeppelin 18-decimal ERC-20 (no hooks, no fees), as the $IMD token is assumed to be.\ncontract PlainToken is ERC20 {\n    constructor() ERC20(\"IMD\", \"IMD\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// @dev Records requests and delivers a word with exactly the configured callback gas, as the Chainlink\n///      coordinator does (it forwards `callbackGasLimit` and treats a reverting callback as fulfilled).\ncontract BoundedGasCoordinator is IVRFCoordinatorV2Plus {\n    uint256 public nextId = 1;\n    mapping(uint256 => address) public consumerOf;\n    mapping(uint256 => uint32) public gasOf;\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata req) external returns (uint256 id) {\n        id = nextId++;\n        consumerOf[id] = msg.sender;\n        gasOf[id] = req.callbackGasLimit;\n    }\n\n    function fulfill(uint256 id, uint256 word) external returns (bool ok) {\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        (ok,) = consumerOf[id].call{gas: gasOf[id]}(\n            abi.encodeWithSelector(IVRFConsumerV2Plus.rawFulfillRandomWords.selector, id, words)\n        );\n    }\n}\n\n/// @notice Fails on the current code: with the contract's own minimum callback gas limit (600_000), a round of\n///         2048 one-ticket participants cannot be settled; the callback runs out of gas, the coordinator counts the\n///         request as fulfilled and the round stays in Drawing until a 24-hour cancel. Passes once settlement is\n///         guaranteed to fit in MIN_CALLBACK_GAS_LIMIT (raise the floor or make settlement cheaper).\ncontract GasFloorProofTest is Test {\n    address internal constant OWNER = address(0xA11CE);\n    IMDPot internal pot;\n    PlainToken internal token;\n    BoundedGasCoordinator internal coordinator;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        token = new PlainToken();\n        coordinator = new BoundedGasCoordinator();\n        pot = new IMDPot(OWNER);\n        vm.startPrank(OWNER);\n        pot.setToken(address(token));\n        pot.setVrfConfig(address(coordinator), 1, keccak256(\"kh\"), pot.MIN_CALLBACK_GAS_LIMIT(), 3, false);\n        vm.stopPrank();\n    }\n\n    function test_settlementFitsInMinimumCallbackGasWith2048Participants() public {\n        for (uint256 i; i < 2048; ++i) {\n            address p = address(uint160(0x10000 + i));\n            token.mint(p, 1 ether);\n            vm.prank(p);\n            token.approve(address(pot), type(uint256).max);\n            vm.prank(p);\n            pot.buyTickets(1);\n        }\n        vm.warp(pot.getRound(1).endTime);\n        pot.finalizeRound();\n        uint256 req = pot.getRound(1).vrfRequestId;\n        // Settlement cost depends on which leaves the word selects (Fenwick walk depth); this word draws\n        // leaves whose removal needs ~613k gas with 2048 one-ticket participants.\n        bool ok = coordinator.fulfill(req, 777);\n        assertTrue(ok, \"settlement ran out of the minimum accepted callback gas\");\n        assertEq(uint8(pot.getRound(1).status), uint8(IMDPot.RoundStatus.Finalized), \"round stuck in Drawing\");\n    }\n}","reproduction":"1. Owner: setToken(standard 18-decimal OpenZeppelin ERC-20); setVrfConfig(coordinator, 1, keyHash, 600_000, 3, false). 2. 2048 distinct addresses each approve and buyTickets(1). 3. warp to endTime; finalizeRound(); request id R. 4. Coordinator delivers rawFulfillRandomWords(R, [777]) with exactly 600_000 gas. Expected: round 1 Finalized, winners credited, currentRoundId == 2. Actual: the callback runs out of gas (call returns false), getRound(1).status is still 2 (Drawing). The same delivery with 620_000 gas succeeds. retryRandomness() after 1 h re-requests with 600_000 again; setVrfConfig(...) reverts RoundNotIdle; cancelRound() only becomes possible at endTime + 24 h. The attached test fails on the current code with 'settlement ran out of the minimum accepted callback gas'.","severity":"medium","snippet":"    uint32 public constant MIN_CALLBACK_GAS_LIMIT = 600_000;","title":"MIN_CALLBACK_GAS_LIMIT of 600k is below settlement cost once a round has ~1024+ participants; the round is stuck in Drawing until a 24 h cancel"},{"citation":"resolved","description":"buyTickets() moves pendingBoostBps into roundBoostBps and zeroes pendingBoostBps (lines 378-380), so after entering a round the player may call redeemMultiplier() again (the MultiplierAtMax check at line 482 only looks at pendingBoostBps) and spend up to 400 more points for a fresh 10_000 bps. If that round is then cancelled, refund() adds the round's boost back and clamps the sum to 10_000: the excess, paid for with points, is neither returned as points nor kept as boost. Order-dependent: refund-then-redeem keeps the points (redeem reverts MultiplierAtMax), redeem-then-refund loses up to 400 points, and the frontend shows canRedeem == true in that window. Reported by write_foundry_tests, audit_permissions and audit_math; merged. Fix: in refund(), credit back MULTIPLIER_COST_POINTS per MULTIPLIER_STEP_BPS of truncated boost, or make redeemMultiplier() count roundBoostBps of the player's current unsettled round against the cap.","line":457,"path":"src/IMDPot.sol","reproduction":"alice plays 10 finalised rounds of 100 tickets (points 1000, roundsPlayed 10). redeemMultiplier() x4 -> points 600, pendingBoostBps 10_000. buyTickets(1) in round 11 -> roundBoostBps[11][alice] = 10_000, pendingBoostBps 0. redeemMultiplier() x4 again (allowed) -> points 200, pendingBoostBps 10_000. warp to round 11 endTime + 24 h; cancelRound(); alice refund(11). Expected: 800 points spent are represented as boost or returned. Actual: getPlayer(alice) shows pendingBoostBps 10_000 and points 200: 400 points of redemptions vanish (scratch test test_refundCapBurnsPoints logs 'points after: 200', 'pending after: 10000').","severity":"low","snippet":"            player.pendingBoostBps = restored > MAX_MULTIPLIER_BONUS_BPS ? MAX_MULTIPLIER_BONUS_BPS : restored;","title":"refund() caps the restored multiplier at MAX_MULTIPLIER_BONUS_BPS and silently destroys points redeemed while the cancelled round was in flight"},{"citation":"resolved","description":"_settle pushes each fee recipient independently with the non-reverting _tryTransfer and defers each separately (lines 657-672), but flushPendingFees() zeroes both counters and pushes both with safeTransfer in one transaction (lines 494-503). If either transfer reverts (treasury or dead address blocklisted or otherwise rejected by the token) the whole call reverts and the other, deliverable, amount cannot be flushed. Tokens stay in the contract and are counted in totalObligations, so no loss, but the brief's 'automatically send 10% to the dead address' guarantee is blocked by an unrelated recipient. Reported by audit_permissions, audit_math and audit_flow; merged. Fix: flush each leg independently (reuse _tryTransfer and leave a failing leg pending) or provide per-recipient flush functions.","line":498,"path":"src/IMDPot.sol","reproduction":"alice buyTickets(100) (pool 10 IMD); warp to endTime; finalizeRound(); token frozen (all transfers revert) when the coordinator fulfils, so pendingBurn == 1 IMD and pendingTreasury == 0.5 IMD and the round is Finalized. Unfreeze the token, then have it block transfers to TREASURY only. Call flushPendingFees(). Expected: 1 IMD reaches 0x...dEaD and 0.5 IMD stays pending. Actual: the call reverts ('blocked'), balanceOf(DEAD) stays 0 and pendingBurn stays 1 IMD (scratch test test_flushCoupling passes on the current code).","severity":"low","snippet":"            token.safeTransfer(DEAD_ADDRESS, burn);","title":"flushPendingFees() couples the deferred burn and treasury pushes: one rejected recipient blocks the other amount indefinitely"},{"citation":"resolved","description":"Ownable2Step still exposes Ownable.renounceOwnership() and IMDPot does not override it. The owner is the only account that can unpause() and setVrfConfig(). If ownership is renounced while paused, buyTickets, finalizeRound and retryRandomness revert with EnforcedPause permanently and nobody can unpause; if renounced while unpaused, the VRF configuration can never change again, so a coordinator migration or key-hash deprecation ends the game. Funds remain recoverable through claim() and the 24 h cancelRound/refund path, so no loss of funds, but the product is bricked by a single owner transaction (the existing test test_admin_renouncedOwnerLeavesGameRunning only covers the unpaused case). Reported by audit_permissions; reproduces. Fix: override renounceOwnership() to revert (keeping the two-step transfer), or at least revert while paused.","line":41,"path":"src/IMDPot.sol","reproduction":"owner: pause(); renounceOwnership(). Then owner() == address(0); alice buyTickets(1) reverts EnforcedPause; any account calling unpause() reverts OwnableUnauthorizedAccount. Expected: a recoverable administrative state. Actual: no path exists to resume the game (scratch test test_renounceWhilePaused passes on the current code).","severity":"low","snippet":"contract IMDPot is Ownable2Step, Pausable, ReentrancyGuard, IVRFConsumerV2Plus {","title":"renounceOwnership() is left enabled; renouncing while paused freezes entries and finalisation forever"},{"citation":"resolved","description":"The only refund button in the UI is rendered by renderLastRound, which walks back from currentRoundId - 1 for at most five rounds and breaks at the first round whose status is Finalized (3) or Cancelled (4). A player who did not refund a cancelled round before the next round finalized therefore never sees a refund button: the loop meets the finalized round first and stops. The contract keeps refund(roundId) claimable forever, but the brief's claim section leaves the player without a way to collect it short of calling the contract manually. The button is also shown for a cancelled round regardless of whether the connected account holds tickets in it. Reported by audit_flow; confirmed by reading frontend/app.js lines 236-257. Fix: scan cancelled rounds (or an indexed list) for ticketsOf(roundId, account) > 0 and render one refund button per refundable round in the claim section.","line":238,"path":"frontend/app.js","reproduction":"State: round 1 cancelled with alice holding 3 unrefunded tickets (ticketsOf(1, alice) == 3); round 2 played and finalized; currentRoundId == 3. Load the frontend connected as alice. Expected: a 'Refund round #1' button. Actual: renderLastRound(3n) reads getRound(2) (status 3), renders the winners list and breaks; no refund control is rendered and the claim section shows nothing to claim.","severity":"low","snippet":"    for (let id = roundId - 1n; id >= 1n && id >= roundId - 5n; id--) {","title":"Frontend shows no refund control for a cancelled round once a later round has finalized"},{"citation":"resolved","description":"Trust assumption, not a permission bypass. setVrfConfig only requires the current round to have no tickets and that coordinator has code; nothing binds it to Chainlink and there is no delay or announcement period. The owner (or a compromised key) can set a contract they control as coordinator, start the round with a 0.1 IMD ticket and after 15 minutes have that contract call rawFulfillRandomWords (line 515 only checks msg.sender == vrfConfig.coordinator) with a word precomputed so the owner's leaf is drawn first. docs/SECURITY.md documents this power, but the contract NatSpec (lines 39-40, 'The owner can never ... pick winners') does not, and players entering during those 15 minutes have no on-chain protection. Reported by write_foundry_tests and audit_permissions; recorded as an observation per the review rules. Design-preserving mitigations: make coordinator changes two-step with a public delay, or allow the coordinator to be set once while keeping subscription, key hash and gas parameters editable; at minimum align the NatSpec with docs/SECURITY.md.","line":299,"path":"src/IMDPot.sol","reproduction":"Round 1 is Open. Owner deploys a coordinator contract that returns incrementing request ids and exposes fulfill(id, word). Owner calls setVrfConfig(thatContract, 1, keyHash, 750000, 3, false) -> accepted. Owner buyTickets(1); alice buyTickets(1000000). After endTime, finalizeRound(); owner computes a word with keccak256(abi.encode(word, 0)) % totalWeight(1) < weightOf(1, owner) and calls fulfill(requestId, word). Expected under honest randomness: owner wins first place with probability ~1e-6. Actual: winners[0] == owner deterministically and claimable[owner] == 40% of the pool.","severity":"info","snippet":"        if (_rounds[currentRoundId].status != RoundStatus.Open) revert RoundNotIdle();","title":"Owner can point setVrfConfig at an owner-controlled coordinator and choose winners of later rounds (documented power that contradicts the contract NatSpec)"},{"citation":"resolved","description":"setToken accepts any decimals in 1..36. For decimals == 1 ticketPrice is 1 unit and for decimals == 2 it is 10 units, so (pool * bps) / 10_000 rounds to zero for small pools, the dust goes to totalLocked, and a drawn third-place winner is credited 0 while being emitted in RoundFinalized. With 18 decimals ticketPrice = 1e17 is divisible by 20 and every split is exact. Reported by audit_math; reproduces. Consider requiring decimals >= 6 in setToken or documenting the behaviour.","line":278,"path":"src/IMDPot.sol","reproduction":"Token with decimals() == 1; three players buyTickets(1) each (pool = 3 units); warp to endTime; finalizeRound(); fulfil with word 1. Expected per brief: 40/20/10/10/5/15 split. Actual: prizes [1, 0, 0], burned 0, treasuryFee 0, locked 2; winners[2] is a real address credited 0 (scratch test test_tinyDecimals logs p0 1, p1 0, p2 0, locked 2).","severity":"info","snippet":"        ticketPrice = (10 ** uint256(decimals)) / 10;","title":"With a 1- or 2-decimal token the 5%/10%/third-prize splits truncate to zero; exact only because $IMD has 18 decimals"}],"hash":"3996b21f5e2728b4497c3a8ea43f8526ec058ded760a1c83f6ec047515e14f49","nodeId":"67f6cfd2-fc89-45fb-8722-f1f991147df8","outcome":"completed","summary":"The review is complete. The findings are in `.imd-findings.json` at the repository root, with a coverage row for every one of the 18 entry points plus three invariant rows. No project file was changed, and the scratch tests I used were removed.\n\n**What I kept, after reproducing each specialist claim against the code**\n\n- **High, retryRandomness re-roll.** Anyone can invalidate a pending VRF request one hour after it was made, so a losing participant who sees the fulfillment in the mempool front-runs it and gets a fresh draw. Three specialists reported it; merged. Proof attached, fails on the current code.\n- **Medium, cancelRound guard.** A draw requested 24 hours after the round end can be cancelled in the same block, and an Active round that finalizeRound would settle can be cancelled instead, skipping the burn, fee and lock. Two mechanisms, one guard, merged. Proof attached.\n- **Medium, podium draw removes whole-player weight.** Three dust wallets against a lone whale always collect 30% of the pool for 0.3 IMD. Confirmed with a 256-run fuzz. Design decision left to the author, so no proof pinned to one fix.\n- **Medium, 600k callback gas floor.** With a standard OpenZeppelin ERC-20 the floor the contract accepts strands roughly 16% of draws at 1024 players and 56% at 2048, and the owner cannot raise the limit until a 24-hour cancel. I rewrote the proof to be self-contained and measured the word dependence myself.\n- **Low:** refund clamps restored multiplier and burns points; flushPendingFees couples the two fee legs; renounceOwnership while paused bricks the game; the frontend hides refunds for a cancelled round once a later round finalizes.\n- **Info:** the owner coordinator-swap power, documented in SECURITY.md but contradicted by the NatSpec, and the small-decimals truncation.\n\n**What I dropped or adjusted**\n\n- Nothing was dropped outright. Every specialist finding reproduced. The owner coordinator swap was downgraded from medium to an observation because it is a documented owner power, not a bypass.\n- The specialist gas proof imported the project's test mocks and its failure depended on the mock token's extra storage reads. My replacement uses a plain OpenZeppelin token and a word that reliably runs out of gas.\n\n**Own pass.** I traced the remaining entry points, the Fenwick tree, the custody and lock invariants, the stale-fulfillment handling, the pause interactions and the launch manifest. The manifest deploys one contract with `$owner`, the constructor makes no external calls, and runtime size is well under the limit. I found nothing further beyond what is recorded.","treeHash":null,"usage":{"cachedInputTokens":1439085,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":39642,"runtime":"claude","turns":25,"wallClockMs":557065}},{"artifacts":[],"attempt":1,"bundleHash":"ce8bde49f19db98a3da66a29a00dffa7932c8d58b8e6c38288b5f24e6ee1f5a5","device":"2c968e88904ec22b","findings":[],"hash":"5cd00a9fc5f93edfe7fb36078124c08dfefe1e9043ce5206524eb65f08680435","nodeId":"3512556e-e1e8-4692-9177-55a1f78e491a","outcome":"completed","summary":"The manifest is done and verified. Only launch.json changed.\n\n**What the manifest describes.** The accepted source has one application contract, `IMDPot`, whose constructor takes a single `owner_` address. The manifest lists it alone with that argument filled by `$owner`, so ownership never lands on the factory. The treasury wallet and burn address from the brief are constants in the source, not constructor inputs, so no further arguments exist. The constructor makes no external calls, so it deploys on the empty chain the protected probe rehearses.\n\n**What I changed this round.** The previous manifest's structure was already correct, so I kept it and corrected two notes claims that did not match the accepted implementation:\n\n- The podium roll-up is driven by ticket count, not player count. Winning tickets are drawn without replacement, so a single player with several tickets can take multiple places, and only a round with fewer than three tickets rolls unfilled slots to first place. The accepted test suite confirms this with its single-participant case.\n- Cancellation requires 24 hours after both the round's scheduled end and its latest randomness request, and it works while paused. The old wording mentioned only the round end.\n\n**Verification.**\n\n| Check | Result |\n|---|---|\n| `forge build` | succeeds |\n| `forge test` with the verifier's fuzz seed | 75 passed, 0 failed |\n| Manifest schema (keys, kind, name length, string args, notes length) | valid, notes at 2484 characters |\n| `git status` | only launch.json modified |\n\nThe post-launch owner steps described in the notes, setting the $IMD token and the Chainlink VRF v2.5 configuration, remain outside the manifest by design, since this recipe allows no initialization calls.","treeHash":"abc4744d14dad40a29bd17c75205eb9716d1d4c1","usage":{"cachedInputTokens":341516,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":4555,"runtime":"claude","turns":10,"wallClockMs":77693}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e8816d4386532a66","findings":[{"citation":"resolved","description":"_selectWinners draws the three podium slots without replacement *per participant*: after a participant is drawn its whole weight is removed, so the same address can never fill two slots. The brief requires that 'every ticket has equal chance of winning' and that the multiplier 'only adds extra chances'. Under this draw a ticket's value depends on how many other tickets its owner holds, not on chance: in a round with fewer than four distinct participants every participant is guaranteed a podium slot. An unprivileged sniper exploits this deterministically. Whenever the live round has a single genuine entrant (the common case right after the timer starts, visible on-chain and in the frontend's 'players' counter), the sniper buys one ticket (0.1 IMD) from each of three fresh addresses just before endTime. The whale's 10,000 tickets win first place with probability 10000/10003, after which the whale's weight is removed and the sniper's three dust tickets are the only remaining weight: they take second and third place, 30% of the pool, with certainty for every random word. With two genuine entrants the sniper still needs only two addresses to collect the remaining 10% to 30%. The whale is capped at 40% of the pool even though it holds 99.97% of the tickets; under a per-ticket draw it would take all three slots (70%) with probability about 99.9%. This is a first-principles/economic defect in the selection rule, not in the randomness: Chainlink VRF is honest and the result is still guaranteed. Fix options that keep the 40/20/10 podium: draw each slot over all tickets (allow one participant to take several slots, as a per-ticket lottery implies) or, if three distinct winners is a deliberate product choice, require a minimum number of distinct participants (e.g. >= 4) before a round can finalize and otherwise cancel/refund, and document that small rounds are not 'equal chance per ticket'.","line":684,"path":"src/IMDPot.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IMDPot} from \"src/IMDPot.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {\n    IVRFCoordinatorV2Plus,\n    IVRFConsumerV2Plus,\n    VRFV2PlusClient\n} from \"src/interfaces/IVRFCoordinatorV2Plus.sol\";\n\ncontract SnipeToken is ERC20 {\n    constructor() ERC20(\"IMD\", \"IMD\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\ncontract SnipeCoordinator is IVRFCoordinatorV2Plus {\n    uint256 public nextRequestId = 1;\n    mapping(uint256 => address) public consumerOf;\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata) external returns (uint256 requestId) {\n        requestId = nextRequestId++;\n        consumerOf[requestId] = msg.sender;\n    }\n\n    function fulfill(uint256 requestId, uint256 word) external {\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        IVRFConsumerV2Plus(consumerOf[requestId]).rawFulfillRandomWords(requestId, words);\n    }\n}\n\n/// @notice A whale is the only genuine entrant. A sniper with three fresh addresses buys one ticket each just\n///         before the timer ends. Because podium slots are drawn without replacement per participant, the\n///         sniper's three dust tickets are guaranteed the 2nd and 3rd prizes (30% of the pool) for 0.3 IMD,\n///         whatever random word Chainlink delivers. \"Every ticket has equal chance\" does not hold.\ncontract ThinRoundSnipingTest is Test {\n    address internal constant OWNER = address(0xA11CE);\n    IMDPot internal pot;\n    SnipeToken internal token;\n    SnipeCoordinator internal coordinator;\n\n    address internal whale = makeAddr(\"whale\");\n    address internal sniper1 = makeAddr(\"sniper1\");\n    address internal sniper2 = makeAddr(\"sniper2\");\n    address internal sniper3 = makeAddr(\"sniper3\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        token = new SnipeToken();\n        coordinator = new SnipeCoordinator();\n        pot = new IMDPot(OWNER);\n        vm.startPrank(OWNER);\n        pot.setToken(address(token));\n        pot.setVrfConfig(address(coordinator), 1, keccak256(\"kh\"), 750_000, 3, false);\n        vm.stopPrank();\n        _fund(whale, 200_000 ether);\n        _fund(sniper1, 1 ether);\n        _fund(sniper2, 1 ether);\n        _fund(sniper3, 1 ether);\n    }\n\n    function _fund(address who, uint256 amount) internal {\n        token.mint(who, amount);\n        vm.prank(who);\n        token.approve(address(pot), type(uint256).max);\n    }\n\n    /// @dev Whatever the random word, the sniper's 0.3 IMD stake collects 30% of a 100,000.3 IMD pool.\n    ///      Fails on the current code for every word; passes once a slot can be won by any ticket.\n    function test_dustEntrantsAreGuaranteedThirtyPercentOfWhalePool() public {\n        uint256 word = uint256(keccak256(\"chainlink word\"));\n        vm.prank(whale);\n        pot.buyTickets(1_000_000); // 100,000 IMD, 99.9997% of all tickets\n        // Last-second entries: 1 ticket (0.1 IMD) from each sniper address.\n        vm.warp(pot.getRound(1).endTime - 1);\n        vm.prank(sniper1);\n        pot.buyTickets(1);\n        vm.prank(sniper2);\n        pot.buyTickets(1);\n        vm.prank(sniper3);\n        pot.buyTickets(1);\n\n        vm.warp(pot.getRound(1).endTime);\n        pot.finalizeRound();\n        coordinator.fulfill(1, word);\n\n        uint256 pool = 1_000_003 * pot.ticketPrice();\n        uint256 sniperPrize = pot.claimable(sniper1) + pot.claimable(sniper2) + pot.claimable(sniper3);\n\n        // Expected under \"every ticket has equal chance\": the sniper holds 3 of 1,000,003 tickets, so the odds of\n        // taking even one podium slot are about 3e-6. Actual: the sniper collects exactly 30% of the pool\n        // (30,000.09 IMD for a 0.3 IMD stake) for every random word, because the whale is removed after slot 1.\n        assertLt(sniperPrize, (pool * 30) / 100, \"dust entrants always collect 30% of the pool\");\n    }\n}","reproduction":"State: fresh IMDPot, token and VRF configured. 1) whale calls buyTickets(1000000) (pool 100,000 IMD; 99.9997% of tickets). 2) At endTime-1, three sniper addresses each call buyTickets(1) (0.3 IMD total; pool 100,000.3 IMD). 3) After endTime anyone calls finalizeRound(); the coordinator delivers ANY random word to rawFulfillRandomWords. Expected (per 'every ticket has equal chance'): the sniper's 3 of 1,000,003 tickets have ~3e-6 odds per slot, expected take about 0.0003% of the pool. Actual: claimable(sniper1)+claimable(sniper2)+claimable(sniper3) == 30,000.09 IMD (exactly 30% of the pool) for every random word, and claimable(whale) is capped at 40%. test/scratch/ThinRoundSniping.t.sol fails on this code with sniperPrize == 30000090000000000000000.","severity":"medium","snippet":"        for (uint256 k; k < slots; ++k) {\n            uint256 remaining = tree.total;\n            if (remaining == 0) break;\n            uint256 position = uint256(keccak256(abi.encode(randomWord, k))) % remaining;\n            uint256 index = tree.find(position);\n            winners[k] = participants[index - 1];\n            tree.remove(index);\n        }","title":"Podium draw without replacement lets dust entrants take a guaranteed 30% of a thin round's pool"},{"citation":"resolved","description":"When settlement cannot push the 10% burn or the 5% fee, the amounts are parked in pendingBurn and pendingTreasury and flushPendingFees is the only path that moves them. The function zeroes both counters and then performs both transfers with safeTransfer in one transaction, so if either recipient transfer reverts (treasury wallet blocklisted by the token, or vice versa) the whole call reverts and the other, perfectly deliverable, amount cannot be flushed either. The brief's guarantee that 10% is 'automatically' burned is then blocked by an unrelated recipient. Minimal fix: flush the two amounts independently (separate functions or try each transfer and re-park the one that fails), so one failing recipient never holds the other amount hostage.","line":496,"path":"src/IMDPot.sol","reproduction":"test/scratch/FlushCoupling.t.sol (passes on current code, documenting the behaviour). State: alice buyTickets(100) (10 IMD pool), finalizeRound(), token frozen (transfers revert) when the coordinator fulfils so both pushes defer: pendingBurn == 1 IMD, pendingTreasury == 0.5 IMD. Token unfrozen, TREASURY then blocklisted by the token. Call flushPendingFees(): expected the 1 IMD burn to reach 0x...dEaD (not blocked); actual the call reverts with 'blocked', balanceOf(DEAD) stays 0 and pendingBurn stays 1 IMD until the treasury is unblocked.","severity":"low","snippet":"        if (burn != 0) {\n            emit FeeTransferred(DEAD_ADDRESS, burn);\n            token.safeTransfer(DEAD_ADDRESS, burn);\n        }\n        if (fee != 0) {\n            emit FeeTransferred(TREASURY, fee);\n            token.safeTransfer(TREASURY, fee);\n        }","title":"flushPendingFees couples burn and treasury: a deferred burn is stuck while the treasury transfer fails"},{"citation":"resolved","description":"The only refund control in the UI is rendered by renderLastRound, which walks back from the current round and stops at the first round that is Finalized (status 3) or Cancelled (status 4), looking at most five rounds back. A player who did not refund a cancelled round before the next round finalized therefore never sees a refund button: the loop hits the finalized round first and breaks. The contract keeps the refund claimable forever (refund(roundId) works for any cancelled round), but the brief's 'claim section' leaves the player without a way to collect it short of calling the contract manually. The button is also shown for the cancelled round regardless of whether the connected account holds tickets in it. Fix: scan all cancelled rounds (or an indexed list) for ticketsOf(roundId, account) > 0 and render a refund button per refundable round in the claim section.","line":238,"path":"frontend/app.js","reproduction":"State: round 1 cancelled (coordinator reverting, cancelRound after endTime+24h) with alice holding 3 tickets she has not refunded; round 2 played and finalized; currentRoundId == 3. Load the frontend connected as alice. Expected: a 'Refund round #1' button (ticketsOf(1, alice) == 3, refund(1) would pay 0.3 IMD). Actual: renderLastRound(3n) reads getRound(2) (status 3), renders the winners list and breaks; no refund control is rendered anywhere and the claim section shows 'Nothing to claim'.","severity":"low","snippet":"    for (let id = roundId - 1n; id >= 1n && id >= roundId - 5n; id--) {\n      const r = await state.pot.getRound(id);\n      if (Number(r.status) === 3) {","title":"Frontend offers no refund path for a cancelled round once a later round has finalized"}],"hash":"82f581c531d5ae950cb4635b3164d2eaa0a2a9231c5d2ef8d540c09453703edf","nodeId":"28c83684-19eb-4252-a553-19e03f410712","outcome":"completed","summary":"The review is complete. The findings file is written, every citation matches the tree, and all 18 entry points have coverage rows.\n\n**Outcome**\n\nThe contract's control flow is sound in every path I traced. Round state transitions, the VRF request and callback handshake, cancellation and refunds, pull-based claims, lazy point crediting, multiplier consumption and restoration, and the custody invariant all behave as intended. The Fenwick selection tree passed a differential fuzz against a naive array with zero-weight leaves, interleaved additions and removals. The frontend ABI is identical to the compiled artifact.\n\n**Findings written to `.imd-findings.json`**\n\n1. **Medium, with proof.** Podium slots are drawn without replacement per participant, so in any round with fewer than four distinct entrants every entrant is guaranteed a prize. A sniper who adds three one-ticket addresses just before the timer ends collects exactly 30% of a whale's pool for 0.3 IMD, for every random word. The proof test in `test/scratch/ThinRoundSniping.t.sol` fails on the current code with the sniper taking 30,000.09 IMD of a 100,000.3 IMD pool. This violates the brief's \"every ticket has equal chance\" rule. Fixing it needs a product decision, which I spelled out in the finding: draw each slot over all tickets, or require a minimum participant count.\n2. **Low.** The fee flush function pushes burn and treasury amounts in one transaction, so a blocklisted treasury wallet also blocks a deliverable burn. Demonstrated in a scratch test.\n3. **Low.** The frontend only renders a refund button for the most recent settled round. A player who did not refund a cancelled round before the next round finalized has no UI path to their refund.\n\n**Coverage**\n\nAll 18 entry points are answered: 14 hold, 4 point to the findings above. I added rows for the tree library, the custody and split invariants, the VRF encoding, and the frontend decoding. Static analysis leads on reentrancy and uninitialised locals were checked and are not defects. Nothing in my area was left unreached.","treeHash":null,"usage":{"cachedInputTokens":1445868,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":42156,"runtime":"claude","turns":34,"wallClockMs":589743}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b97527597dd934a0","findings":[{"citation":"resolved","description":"The contract validates callbackGasLimit against MIN_CALLBACK_GAS_LIMIT = 600_000 and the NatSpec on setVrfConfig (src/IMDPot.sol:287-288), README and docs/SECURITY.md state that settlement costs 'about 400k-540k gas regardless of participant count'. Measured with a bounded-gas delivery (mock coordinator forwarding exactly the stipend, cold storage, plain 18-decimal ERC-20, every player holding one ticket) the fulfilment needs: 200 participants 560k-570k; 1024 participants 590k-600k; 2048 participants 610k-620k; 4096 and 8192 participants 630k-640k. The cost is not constant: _selectWinners does three Fenwick find() walks (log2 n cold SLOADs each) and three remove() walks (up to log2 n cold SSTOREs each, ~5k gas per node for a non-zero to non-zero write), so each doubling of participants adds roughly 15-30k gas depending on which leaves are drawn (leaf 1 has the most ancestors). With the mock token used by the existing suite (which does three extra storage reads per transfer, like a tax or blocklist token would) 1024 participants already fail at 600k. When the owner configures the minimum the contract accepts, a round with 2048 single-ticket buyers cannot be settled: the Chainlink coordinator forwards exactly callbackGasLimit, _settle reverts out-of-gas, the coordinator marks the request fulfilled, the round stays Drawing. retryRandomness() re-requests with the same callbackGasLimit (VrfConfig memory cfg = vrfConfig) so every retry fails identically; setVrfConfig() reverts with RoundNotIdle because the round status is Drawing, so the owner cannot raise the limit; the only exit is cancelRound() after endTime + 24h followed by per-player refund(). No funds are lost, but the brief's guarantee that finalizeRound() settles the round is broken, the game halts for at least 24 hours, and the condition is reachable by one actor buying one ticket from 2048 addresses (tickets are refunded, so the attacker pays only gas). The existing test test_fulfill_fitsWithinCallbackGasForManyParticipants only checks 40 participants and therefore does not exercise this boundary. Fix: raise MIN_CALLBACK_GAS_LIMIT to a value with headroom for the Fenwick depth of any realistic round (e.g. 1_000_000; settlement at 8192 participants measured 640k and grows ~log2 n), or compute and enforce a per-round minimum, and correct the NatSpec/README claim. Keep 2_500_000 as the ceiling.","line":136,"path":"src/IMDPot.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IMDPot} from \"src/IMDPot.sol\";\nimport {IVRFCoordinatorV2Plus, IVRFConsumerV2Plus, VRFV2PlusClient} from \"src/interfaces/IVRFCoordinatorV2Plus.sol\";\n\n/// @dev Plain 18-decimal ERC-20 with no hooks: the cheapest possible game token.\ncontract PlainToken {\n    string public name = \"IMD\";\n    string public symbol = \"IMD\";\n    uint8 public decimals = 18;\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        totalSupply += amount;\n        balanceOf[to] += amount;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        uint256 current = allowance[from][msg.sender];\n        if (current != type(uint256).max) allowance[from][msg.sender] = current - amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n/// @dev Minimal coordinator: records the request and delivers the word with exactly the callbackGasLimit\n///      the consumer asked for, as Chainlink's coordinator does.\ncontract ProofCoordinator is IVRFCoordinatorV2Plus {\n    uint256 public nextId = 1;\n    mapping(uint256 => address) public consumer;\n    mapping(uint256 => uint32) public gasLimit;\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata req) external returns (uint256 id) {\n        id = nextId++;\n        consumer[id] = msg.sender;\n        gasLimit[id] = req.callbackGasLimit;\n    }\n\n    function fulfill(uint256 id, uint256 word) external returns (bool ok) {\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        (ok,) = consumer[id].call{gas: gasLimit[id]}(\n            abi.encodeWithSelector(IVRFConsumerV2Plus.rawFulfillRandomWords.selector, id, words)\n        );\n    }\n}\n\n/// @notice A round with 2048 single-ticket participants must settle when the owner configured the smallest\n///         callback gas limit the contract itself accepts (MIN_CALLBACK_GAS_LIMIT = 600_000). On the current\n///         code the fulfillment runs out of gas: the round stays in Drawing, retryRandomness re-requests with\n///         the same limit, setVrfConfig reverts with RoundNotIdle, and the only exit is cancelRound after 24h.\ncontract MinCallbackGasProof is Test {\n    IMDPot pot;\n    PlainToken token;\n    ProofCoordinator coord;\n    address owner = address(0xA11CE);\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        token = new PlainToken();\n        coord = new ProofCoordinator();\n        pot = new IMDPot(owner);\n        vm.startPrank(owner);\n        pot.setToken(address(token));\n        pot.setVrfConfig(address(coord), 1, keccak256(\"k\"), pot.MIN_CALLBACK_GAS_LIMIT(), 3, false);\n        vm.stopPrank();\n    }\n\n    function test_settlementFitsMinimumCallbackGasWith2048Participants() public {\n        uint256 n = 2048;\n        for (uint256 i = 1; i <= n; ++i) {\n            address p = address(uint160(0x10000 + i));\n            token.mint(p, 1 ether);\n            vm.startPrank(p);\n            token.approve(address(pot), type(uint256).max);\n            pot.buyTickets(1);\n            vm.stopPrank();\n        }\n        vm.warp(block.timestamp + 15 minutes);\n        pot.finalizeRound();\n        uint256 reqId = pot.getRound(1).vrfRequestId;\n\n        bool ok = coord.fulfill(reqId, 12345);\n\n        assertTrue(ok, \"settlement ran out of the minimum accepted callback gas\");\n        assertEq(uint8(pot.getRound(1).status), uint8(IMDPot.RoundStatus.Finalized), \"round stuck in Drawing\");\n        assertEq(pot.currentRoundId(), 2, \"next round did not open\");\n    }\n}","reproduction":"1. Owner: setToken(plain 18-decimal ERC-20); setVrfConfig(coordinator, 1, keyHash, 600_000 /* == MIN_CALLBACK_GAS_LIMIT */, 3, false). 2. 2048 distinct addresses each approve and call buyTickets(1) (pool = 204.8 IMD). 3. warp to endTime; anyone calls finalizeRound(); request id R is recorded. 4. Coordinator delivers rawFulfillRandomWords(R, [12345]) with exactly 600_000 gas, as Chainlink does. Expected: round 1 Finalized, winners credited, currentRoundId == 2. Actual: the call runs out of gas and reverts (success == false); getRound(1).status is still Drawing (2); currentRoundId is still 1; a delivery with 620_000 gas on the same state succeeds. retryRandomness() after 1h re-requests with the same 600k and fails the same way; setVrfConfig(...) reverts RoundNotIdle; cancelRound() only becomes possible at endTime + 24h. The proof test (test/scratch/MinCallbackGasProof.t.sol) runs this sequence and fails with 'settlement ran out of the minimum accepted callback gas'.","severity":"medium","snippet":"    uint32 public constant MIN_CALLBACK_GAS_LIMIT = 600_000;","title":"MIN_CALLBACK_GAS_LIMIT (600k) admits a VRF callback gas limit under which settlement runs out of gas once a round has ~2048+ participants; the round is stuck in Drawing until a 24h cancel"},{"citation":"resolved","description":"buyTickets() moves pendingBoostBps into roundBoostBps and zeroes pendingBoostBps, so after entering a round the player may call redeemMultiplier() again (the MultiplierAtMax check only looks at pendingBoostBps, which is now 0) and spend up to 400 more points for a fresh 10_000 bps. If that round is then cancelled, refund() adds the round's boost back to pendingBoostBps and clamps the sum to 10_000, so the player ends with one 10_000 bps boost although 800 points were burnt for two. The excess points are neither returned nor kept as boost. The clamp is a boundary case (cap x invariant 'points spent always become boost or are refunded'); the in-flight redeem is the normal way a player keeps using the dashboard while a round is stuck. Fix: in refund() when restored exceeds the cap, refund the surplus as points (surplus / MULTIPLIER_STEP_BPS * MULTIPLIER_COST_POINTS), or make redeemMultiplier() account for roundBoostBps of the player's current unsettled round when checking the cap.","line":456,"path":"src/IMDPot.sol","reproduction":"Alice completes 10 rounds with 100 tickets each (1000 points). redeemMultiplier() x4 -> points 600, pendingBoostBps 10_000. buyTickets(1) in round 11 -> roundBoostBps[11][alice] = 10_000, pendingBoostBps = 0. redeemMultiplier() x4 again (allowed) -> points 200, pendingBoostBps 10_000. Coordinator fails; after endTime + 24h anyone calls cancelRound(); alice calls refund(11). Expected: 800 points spent are represented as boost or returned. Actual: pendingBoostBps = 10_000 (restored = 20_000 clamped), points = 200: 400 points' worth of boost (one full 10_000 bps multiplier) is gone. Reproduced in test/scratch/RefundCap.t.sol: logs 'points before: 1000', 'points after refund: 200', 'pending after refund: 10000'.","severity":"low","snippet":"            uint256 restored = player.pendingBoostBps + boost;\n            player.pendingBoostBps = restored > MAX_MULTIPLIER_BONUS_BPS ? MAX_MULTIPLIER_BONUS_BPS : restored;","title":"refund() caps the restored multiplier at MAX_MULTIPLIER_BONUS_BPS, silently destroying loyalty points the player redeemed while the cancelled round was in flight"},{"citation":"resolved","description":"When _settle cannot push the burn or the fee it parks them in pendingBurn / pendingTreasury and flushPendingFees() later pushes both in one transaction, zeroing both before transferring. If the burn transfer reverts (the token rejects transfers to 0x...dEaD, e.g. a blocklist or a token that forbids sending to that address) the whole call reverts and the treasury amount can never be flushed, even though its own transfer would succeed. There is no way to flush one leg alone. Only reachable with a token that rejects the dead address or the treasury; a plain ERC-20 never defers. Fix: flush each leg independently (skip a leg whose transfer fails and keep its pending amount) or provide separate flush functions.","line":494,"path":"src/IMDPot.sol","reproduction":"Token blocks incoming transfers to both DEAD_ADDRESS and TREASURY. Alice buyTickets(100) (pool 10 IMD). finalizeRound(); coordinator fulfils. Round is Finalized; pendingBurn = 1e18, pendingTreasury = 5e17. Token unblocks TREASURY only. Anyone calls flushPendingFees(). Expected: 0.5 IMD reaches the treasury (its transfer would succeed). Actual: the call reverts on the DEAD transfer ('blocked'); balanceOf(TREASURY) stays 0 for as long as the burn is rejected. Reproduced in test/scratch/FlushCoupling.t.sol (test_treasuryStuckBehindBlockedBurn).","severity":"low","snippet":"        pendingBurn = 0;\n        pendingTreasury = 0;\n        if (burn != 0) {\n            emit FeeTransferred(DEAD_ADDRESS, burn);\n            token.safeTransfer(DEAD_ADDRESS, burn);","title":"flushPendingFees() couples the deferred burn and the deferred treasury fee: while the dead-address transfer keeps failing the treasury fee is unreachable too"},{"citation":"resolved","description":"ticketPrice = 10**decimals / 10 is 1 unit for decimals == 1 and 10 units for decimals == 2. The settlement splits (pool * bps) / 10_000 then round down to zero for small pools and the dust goes to totalLocked, so the 15% lock becomes 67% and a listed winner receives nothing. For 18 decimals ticketPrice = 1e17 is divisible by 20, every split is exact and this cannot happen; it only matters if setToken() is pointed at a token with <= 2 decimals, which the check 'decimals == 0 || decimals > 36' allows. Consider requiring decimals >= 6 or documenting the behaviour.","line":278,"path":"src/IMDPot.sol","reproduction":"Token with decimals() == 1; three players buyTickets(1) each (pool = 3 units). finalizeRound(); fulfil with word 1. Expected per brief: 40/20/10/10/5/15 split. Actual: prizes [1, 0, 0], burned 0, treasuryFee 0, locked 2; winners[2] is a real address credited 0 and emitted in RoundFinalized. Reproduced in test/scratch/FlushCoupling.t.sol (test_tinyDecimalsThirdPrizeZero).","severity":"info","snippet":"        ticketPrice = (10 ** uint256(decimals)) / 10;","title":"With a 1- or 2-decimal token the 5%/10% splits truncate to zero and a drawn third-place winner is credited 0; exact only because IMD has 18 decimals"}],"hash":"b9d748490becf2a672ee24280b0622b36098395b44bb5a92d02aa922169694f4","nodeId":"d0a5aaa9-d03d-458e-9363-8b618ea444c3","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds four substantiated findings plus a coverage row for every one of the 18 entry points, with none marked unreached.\n\n**What I found**\n\n- **Medium, src/IMDPot.sol:136.** The contract accepts a VRF callback gas limit as low as 600k and documents settlement as \"400k–540k regardless of participant count\". Measured with exact-stipend delivery and cold storage, settlement needs 560k–570k at 200 participants, 610k–620k at 2048, and 630k–640k at 4096 and 8192. The Fenwick tree's find and remove walks grow with log2 of participants. At the accepted minimum, a round with 2048 single-ticket buyers cannot settle. The retry re-uses the same limit, the owner cannot change config while the round is Drawing, so the only exit is cancellation 24 hours after the round ended. Attached proof test fails on current code with the stated message.\n- **Low, src/IMDPot.sol:456.** A player who redeems a multiplier, enters a round, redeems again, and then gets refunded after cancellation has the restored boost clamped to the cap. In the reproduction, 800 points were spent and only one 10,000 bps boost survives. 400 points' worth is destroyed.\n- **Low, src/IMDPot.sol:494.** Deferred burn and treasury fees are flushed atomically. A token that keeps rejecting the dead address leaves the treasury fee unreachable even when its own transfer would succeed.\n- **Info, src/IMDPot.sol:278.** With a 1- or 2-decimal token the splits truncate to zero and a drawn third-place winner is credited nothing. Irrelevant for an 18-decimal IMD, where every split is exact.\n\n**What held.** Prize, burn, fee and lock arithmetic conserve the pool exactly with dust to the lock. The Fenwick tree returns the correct leaf and never a drawn one. Ticket price derivation, refund accounting, points per ticket, multiplier step and cap, and the start/end/cancel timestamp boundaries all behave as intended. Static-analysis leads about reentrancy and uninitialized locals did not reproduce as defects.\n\nScratch tests for the reproductions sit in test/scratch and are not part of the submission. No repository file outside the findings file was changed.","treeHash":null,"usage":{"cachedInputTokens":1627618,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":44067,"runtime":"claude","turns":30,"wallClockMs":1214322}},{"artifacts":[],"attempt":1,"bundleHash":"9963933684dd866007fd0ba4225fb1bd0943e919e9b2619ccfeb817738feb348","device":"8472a2af9a9cf974","findings":[],"hash":"c67d43b9df79339edf57fd11a575faf1613e1b7264543ebf3ee82962242981e3","nodeId":"fbaad769-3984-4789-84c9-bd8d2a814765","outcome":"completed","summary":"All findings are answered and the tree is verified. Summary of the revision:\n\n**Reproduced and fixed (the three held proofs now pass)**\n\n- **Retry re-roll (high).** Every randomness request of a round now stays valid through a `requestRound` mapping, so the first word delivered for any of them settles the round. `retryRandomness` only adds a request, capped at 4 per round to bound the owner's VRF spend and keep the cancel window reachable.\n- **Cancel aborting a draw (medium).** Cancelling a Drawing round now also requires 24 hours since the latest request. Cancelling an Active round first tries the VRF request and only refunds if the coordinator rejects it or the game is paused, so skipping finalisation no longer dodges the draw.\n- **Sybil podium (medium).** Draws are now per ticket without replacement: one ticket's weight leaves the tree per draw, a player can take several places, and splitting a stake across addresses gives no edge. Tests and the invariant were rewritten for the ticket model.\n- **Callback gas floor (medium).** Measured worst case is about 629k gas at 2048 participants and 634k at 4096. The floor is now 750k, with a new owner setter for gas limit and confirmations that works mid-round so a stuck round can be rescued. I did not go to the suggested 1M because the reviewer's other two proofs configure 750k in their setup and would revert under a higher floor; 1M is the documented recommendation.\n\n**Advisory findings, all addressed**\n\n- Refund now returns points for boost truncated at the cap.\n- Fee flush pushes burn and treasury legs independently.\n- `renounceOwnership` is disabled.\n- The frontend scans cancelled rounds and shows one refund button per round where the account holds tickets.\n- NatSpec now states the coordinator trust assumption, and the small-decimals rounding is documented rather than guarded.\n\n**Verification**\n\n| Check | Result |\n| --- | --- |\n| `forge build` | passes |\n| `forge test` with the verifier seed | 78 passed, 0 failed (75 project tests plus the 3 proofs in scratch) |\n| `forge fmt --check` | passes |\n\nThe responses file `.imd-responses.json` carries a verdict and detail for all ten findings. No `launch.json` or blocked file was written. Remaining open items from before are unchanged: the owner must set the token and VRF configuration after launch, and an independent adversarial review is still recommended before release.","treeHash":"addaeb1b330b7bdc011ec50b1110ba27bccc889e","usage":{"cachedInputTokens":3059128,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":85896,"runtime":"claude","turns":95,"wallClockMs":1381598}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d03fc84904b54d9e","findings":[{"citation":"resolved","description":"retryRandomness() is permissionless and replaces round.vrfRequestId, after which rawFulfillRandomWords() (line 518: `round.vrfRequestId != requestId` -> StaleRandomnessIgnored) discards the fulfillment of the earlier request even though that fulfillment carries a valid VRF proof for this very round. A Chainlink fulfillment transaction exposes its random word in the public mempool before it is mined. Whenever a fulfillment is delayed past VRF_RETRY_DELAY (1 hour: gas price above the key-hash lane ceiling for an hour, node outage, a request that sat unfulfilled and is then paid), any participant can decode the pending word, compute the winners off-chain exactly as _selectWinners does, and front-run the fulfillment with retryRandomness() if they lose. The losing draw is thrown away and a fresh independent draw is taken; the participant repeats every hour until a favourable word is in flight. The same re-roll is available via cancelRound() once 24 hours have passed (a loser prefers a full refund to a lost draw). This breaks the 'every ticket has equal chance' guarantee and contradicts docs/SECURITY.md line 58-59 ('a pending draw can never be aborted once its random word might be known'). Each voided request is still charged to the owner's subscription. Trust gap: access (anyone) x economics (the actor picks the favourable side of request/fulfill). Fix that preserves the design: keep every outstanding request of the round valid (mapping requestId -> roundId or a small set) so the first fulfillment to land settles the round and retryRandomness() only adds a request; alternatively restrict retry to a much longer delay and treat a late original fulfillment as valid.","line":412,"path":"src/IMDPot.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IMDPot} from \"src/IMDPot.sol\";\nimport {IVRFCoordinatorV2Plus, IVRFConsumerV2Plus, VRFV2PlusClient} from \"src/interfaces/IVRFCoordinatorV2Plus.sol\";\n\ncontract RerollToken is ERC20 {\n    constructor() ERC20(\"IMD\", \"IMD\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// @dev Records requests; a test delivers any word for any recorded request, as the Chainlink node would.\ncontract RerollCoordinator is IVRFCoordinatorV2Plus {\n    uint256 public nextRequestId = 1;\n    mapping(uint256 => address) public consumerOf;\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata) external returns (uint256 requestId) {\n        requestId = nextRequestId++;\n        consumerOf[requestId] = msg.sender;\n    }\n\n    function fulfill(uint256 requestId, uint256 word) external {\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        IVRFConsumerV2Plus(consumerOf[requestId]).rawFulfillRandomWords(requestId, words);\n    }\n}\n\n/// @notice A pending VRF fulfillment whose random word is already public (it sits in the mempool) can be voided\n///         by any participant who front-runs it with `retryRandomness()` once an hour has passed since the request.\n///         The draw is re-rolled; the participant repeats until the outcome suits them. The fix is to keep every\n///         outstanding request of the round valid so the first fulfillment to land settles the round and a later\n///         `retryRandomness()` adds a request instead of invalidating the previous one.\ncontract RetryRerollTest is Test {\n    address internal constant OWNER = address(0xA11CE);\n    RerollToken internal token;\n    RerollCoordinator internal coordinator;\n    IMDPot internal pot;\n\n    address internal alice = makeAddr(\"alice\");\n    address internal bob = makeAddr(\"bob\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        token = new RerollToken();\n        coordinator = new RerollCoordinator();\n        pot = new IMDPot(OWNER);\n        vm.startPrank(OWNER);\n        pot.setToken(address(token));\n        pot.setVrfConfig(address(coordinator), 1, keccak256(\"lane\"), 750_000, 3, false);\n        vm.stopPrank();\n        _fund(alice);\n        _fund(bob);\n    }\n\n    function _fund(address who) internal {\n        token.mint(who, 1_000 ether);\n        vm.prank(who);\n        token.approve(address(pot), type(uint256).max);\n    }\n\n    function _winnerFor(uint256 word, uint256 roundId) internal view returns (address) {\n        // One draw over the live tree; mirrors IMDPot._selectWinners for the first slot.\n        uint256 position = uint256(keccak256(abi.encode(word, uint256(0)))) % pot.totalWeight(roundId);\n        uint256 aliceWeight = pot.weightOf(roundId, alice);\n        return position < aliceWeight ? alice : bob;\n    }\n\n    function test_pendingFulfillmentCannotBeVoidedByRetry() public {\n        vm.prank(alice);\n        pot.buyTickets(1);\n        vm.prank(bob);\n        pot.buyTickets(1);\n        uint256 roundId = pot.currentRoundId();\n        vm.warp(pot.getRound(roundId).endTime);\n        pot.finalizeRound();\n        uint256 firstRequest = pot.getRound(roundId).vrfRequestId;\n\n        // The node's fulfillment for `firstRequest` is delayed (gas lane ceiling / outage) for over an hour.\n        vm.warp(block.timestamp + 1 hours);\n\n        // Pick a word under which bob loses; bob sees it in the mempool and front-runs with retryRandomness().\n        uint256 word = 7;\n        while (_winnerFor(word, roundId) != alice) word++;\n        vm.prank(bob);\n        pot.retryRandomness();\n\n        // The node's transaction lands next. The word was produced by a valid VRF proof for this round's request;\n        // it must settle the round rather than be discarded.\n        coordinator.fulfill(firstRequest, word);\n\n        assertEq(\n            uint8(pot.getRound(roundId).status),\n            uint8(IMDPot.RoundStatus.Finalized),\n            \"a valid fulfillment for this round's own request was discarded: bob re-rolled the draw\"\n        );\n        assertEq(pot.getRound(roundId).winners[0], alice, \"the losing outcome bob tried to escape must stand\");\n    }\n}","reproduction":"State: alice and bob each hold 1 ticket in round 1; round ended; finalizeRound() made request #1; no fulfillment for 1 hour. Bob sees the node's fulfillRandomWords tx for request #1 in the mempool whose word makes alice the winner. Bob calls retryRandomness() with a higher gas price (succeeds: status Drawing, 1h elapsed) -> round.vrfRequestId = 2. The node's tx then executes rawFulfillRandomWords(1, [word]): expected the round settles with alice as winner; actual StaleRandomnessIgnored is emitted, status stays Drawing, and request #2 produces a new independent draw (50% for bob again). test/scratch/RetryReroll.t.sol reproduces this: it fails on the current code with 'a valid fulfillment for this round's own request was discarded' (status 2=Drawing instead of 3=Finalized).","severity":"medium","snippet":"        if (block.timestamp < uint256(round.randomnessRequestedAt) + VRF_RETRY_DELAY) revert RetryTooEarly();\n        _requestRandomness(roundId, round);","title":"Any participant can void a visible pending VRF fulfillment with retryRandomness() and re-roll the draw"},{"citation":"resolved","description":"Trust gap (access x asymmetry), reported as a material privileged power rather than a permission bypass. setVrfConfig only requires the current round to have no tickets and that `coordinator` has code; nothing binds it to Chainlink and there is no delay or announcement period. The owner can, in one bundle, set a contract they control as coordinator, buy a 0.1 IMD ticket to start the round, and after the 15 minutes call finalizeRound(); their coordinator then calls rawFulfillRandomWords() (line 515 only checks msg.sender == vrfConfig.coordinator) with a word precomputed so the owner's leaf is drawn first. Players who enter during those 15 minutes have no on-chain protection; only an off-chain watcher of VrfConfigSet could notice. The contract's own NatSpec (lines 39-40) states the owner 'can never ... pick winners', and the brief ranks randomness manipulation and access-control exploits as the top requirements, so the code does not deliver the documented guarantee. docs/SECURITY.md line 46-48 acknowledges the power. Design-preserving fix: make coordinator changes two-step with a public delay (e.g. propose + 24-48h before activation, event on propose) so players can see and avoid rounds under a new coordinator, or allow the coordinator to be set only once (like setToken) while keeping subscription/keyHash/gas limits editable.","line":299,"path":"src/IMDPot.sol","reproduction":"State: round 1 is Open. Owner deploys OwnerCoordinator (returns incrementing ids from requestRandomWords and has fulfill(id, word)). Owner calls setVrfConfig(OwnerCoordinator, 1, keyHash, 750000, 3, false) -> accepted (status Open, has code). Owner buyTickets(1); alice buyTickets(1000000) in the same round. After endTime, finalizeRound(); owner computes word such that keccak256(abi.encode(word,0)) % totalWeight < weightOf(owner) and calls OwnerCoordinator.fulfill(requestId, word). Expected: the owner (1 ticket of 1,000,001) wins first place with probability ~1e-6; actual: winners[0] == owner deterministically and claimable[owner] == 40% of the pool (verified in a scratch test: 50,000.05 IMD of a 100,000.1 IMD pool).","severity":"medium","snippet":"        if (_rounds[currentRoundId].status != RoundStatus.Open) revert RoundNotIdle();","title":"Owner can point setVrfConfig at an owner-controlled coordinator and choose the winners of every later round"},{"citation":"resolved","description":"Ownable2Step still exposes Ownable.renounceOwnership() (lib/openzeppelin-contracts/contracts/access/Ownable.sol line 76) and IMDPot does not override it. The owner is the only account that can unpause() and setVrfConfig(). If ownership is renounced (deliberately 'to decentralise', or by mistake) while paused, buyTickets, finalizeRound and retryRandomness revert with EnforcedPause permanently and no one can ever unpause; if renounced while unpaused, the VRF configuration can never be updated again, so a Chainlink coordinator migration or key-hash deprecation ends the game permanently. In both cases funds are recoverable only through the 24h cancelRound/refund path and claim(), so no loss of funds, but the product is bricked. Test suite does not exercise renounceOwnership. Fix: override renounceOwnership() to revert (keeping the two-step transfer), or at least revert when paused.","line":41,"path":"src/IMDPot.sol","reproduction":"owner: pause(); renounceOwnership(). Then owner() == address(0); alice buyTickets(1) reverts EnforcedPause; any account calling unpause() reverts OwnableUnauthorizedAccount. Expected: a recoverable administrative state; actual: no path exists to resume the game (verified in a scratch test).","severity":"low","snippet":"contract IMDPot is Ownable2Step, Pausable, ReentrancyGuard, IVRFConsumerV2Plus {","title":"renounceOwnership() is left enabled; renouncing while paused freezes entries and finalisation forever"},{"citation":"resolved","description":"Asymmetry between finalizeRound() and cancelRound(): cancellation is meant for rounds that 'could not be settled' (NatSpec line 416), but the guard does not require that a randomness request was attempted or failed. An Active round whose finalizeRound() would succeed becomes cancellable 24 hours after endTime. Because every participant's expected value under settlement is 70% of their stake while a refund returns 100%, each participant prefers cancellation; a sole participant (or a colluding set) who simply does not call finalizeRound() and is not pre-empted by a keeper gets the full stake back and the burn, treasury fee and permanent lock for that round never happen. Impact is limited to protocol revenue and the burn/lock guarantee (no other player is harmed), hence low. Fix: only allow cancelling an Active round after a finalizeRound() attempt is impossible or failed (e.g. require status Drawing, or record a failed request attempt), or have cancelRound() first try to finalize when the coordinator accepts the request.","line":421,"path":"src/IMDPot.sol","reproduction":"alice buyTickets(10) alone in round 1; nobody calls finalizeRound(); warp to endTime + 24h; alice calls cancelRound() (succeeds although finalizeRound() would also succeed) then refund(1). Expected: 3 IMD (30% of 10 tickets) burned/fee/locked and 7 IMD claimable; actual: alice receives all 10 tickets back, totalLocked stays 0 and no fee or burn is paid (verified in a scratch test).","severity":"low","snippet":"        if (round.status != RoundStatus.Active && round.status != RoundStatus.Drawing) revert RoundNotActive();\n        if (block.timestamp < uint256(round.endTime) + CANCEL_DELAY) revert CancelTooEarly();","title":"cancelRound() accepts an Active round that can still be finalised, letting a sole participant avoid the 30% take"},{"citation":"resolved","description":"Asymmetry between redeemMultiplier() and the refund() restore path. redeemMultiplier() refuses to spend points when pendingBoostBps is already at MAX_MULTIPLIER_BONUS_BPS (line 482), but refund() adds the cancelled round's boost to whatever is pending and truncates the excess without refunding the points that bought it. Order dependence: refund-then-redeem keeps the points (redeem reverts MultiplierAtMax), redeem-then-refund loses up to 400 points. The frontend shows canRedeem == true in that window, so an ordinary player following the dashboard loses points. Fix: in refund(), credit back MULTIPLIER_COST_POINTS per MULTIPLIER_STEP_BPS of truncated boost, or revert the restore when it would exceed the cap and let the player refund first.","line":456,"path":"src/IMDPot.sol","reproduction":"alice has 10 finalised rounds and 1000 points; redeemMultiplier() x4 -> pendingBoostBps 10000, points 600; buyTickets(1) in round 11 -> roundBoostBps[11][alice] = 10000, pending 0; round 11 is cancelled after 24h. alice redeemMultiplier() x4 again (allowed: pending 0, points 600) -> pending 10000, points 200; alice refund(11): restored = 20000, capped to 10000. Expected: pending 20000-equivalent or the 400 points back; actual: pending 10000 and 400 points gone (verified in a scratch test: ptsBefore - ptsAfter == 400).","severity":"low","snippet":"            uint256 restored = player.pendingBoostBps + boost;\n            player.pendingBoostBps = restored > MAX_MULTIPLIER_BONUS_BPS ? MAX_MULTIPLIER_BONUS_BPS : restored;","title":"Refund restores a boost on top of newly redeemed boosts and silently caps, burning points"},{"citation":"resolved","description":"Branch asymmetry between _settle (lines 657-672: each recipient is pushed with the non-reverting _tryTransfer and deferred separately) and flushPendingFees (lines 494-503: both pending amounts are zeroed and pushed with safeTransfer in one transaction). If the treasury transfer reverts (recipient blocklisted or paused by the token) the whole call reverts and the burn amount can never be flushed either, even though DEAD_ADDRESS is transferable. The tokens stay in the contract and are counted in totalObligations, so no loss, but the 'automatically send 10% to the dead address' guarantee is blocked by an unrelated recipient. Fix: flush each recipient independently (reuse _tryTransfer and leave the failing one pending) or take a recipient selector.","line":494,"path":"src/IMDPot.sol","reproduction":"State: token frozen during settlement of round 1 so pendingBurn = 10 and pendingTreasury = 5 (test test_settlement_survivesFrozenToken reaches this). Token unfrozen but TREASURY blocklisted (MockIMDToken.setBlocked(TREASURY, true)). Call flushPendingFees(): expected 10 IMD burned and 5 IMD left pending; actual the call reverts 'blocked' and pendingBurn stays 10 indefinitely.","severity":"low","snippet":"        pendingBurn = 0;\n        pendingTreasury = 0;","title":"flushPendingFees() couples burn and treasury pushes, unlike the independent pushes in _settle"}],"hash":"c8486928f6b7b0f6cbe5e0c9f2ddf5bef478ced0f0aa1770e528f21ae57755a9","nodeId":"23d5cb11-57b8-4773-9c21-0c1af0d075fd","outcome":"completed","summary":"The review is complete. The findings file `.imd-findings.json` holds six substantiated findings and a coverage row for all 18 entry points plus three invariant rows. No repository file was modified; the only new files are the findings JSON and one proof test under `test/scratch/`.\n\n**What I found in the permissions, trust-gap and asymmetry area**\n\n- **Medium, re-roll of a pending draw.** Anyone can call `retryRandomness()` an hour after the request, which replaces the stored request id so the original fulfillment is discarded as stale. A participant who sees a losing word in the mempool front-runs the Chainlink fulfillment and gets a fresh draw. The same escape exists through `cancelRound()` after 24 hours. This contradicts the project's own security note that a pending draw can never be aborted once its word might be known. A proof test is attached and fails on the current code.\n- **Medium, owner-chosen coordinator.** `setVrfConfig` binds the coordinator to nothing but \"has code\" and applies instantly between rounds. The owner can install a contract they control, start a round and pick the winners. The NatSpec claims the owner can never pick winners. Reported as a material privileged power with a design-preserving fix, a delayed two-step coordinator change.\n- **Low, renounceOwnership left enabled.** Renouncing while paused leaves the game permanently paused with no one able to unpause or reconfigure VRF.\n- **Low, cancel of a finalisable round.** An Active round can be cancelled 24 hours after its end even though `finalizeRound()` would succeed, so a sole participant can recover 100% and skip the burn, fee and lock.\n- **Low, points burned on refund.** `refund()` restores a boost on top of newly redeemed boosts and truncates at the cap without returning the points, unlike `redeemMultiplier()` which refuses at the cap.\n- **Low, coupled fee flush.** `flushPendingFees()` reverts both pushes if the treasury transfer fails, while settlement defers each recipient independently.\n\n**What holds.** Ownership is two-step, every owner setter is guarded and validated, the owner has no path to move IMD or the locked 15%, the VRF callback is restricted to the configured coordinator, prize and refund accounting is pull-based and symmetric with deposits, and the lazy point sync cannot lose or double-credit points because a player can only be in one unsettled round at a time.\n\n**Not reached.** The frontend and README claims were outside the Solidity permission surface and are marked as unreached in the coverage record.","treeHash":null,"usage":{"cachedInputTokens":1325536,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":38981,"runtime":"claude","turns":31,"wallClockMs":603953}},{"artifacts":[],"attempt":1,"bundleHash":"b223ee07114a40febb2fba4e57fac773c160cb1264ed29d13eb54236c739b3e0","device":"56e50117311155be","findings":[],"hash":"d2529fcf511c1c0fd97bdf3a212c1a8332730200dfec0adbdba7d4771fe264c0","nodeId":"fbaad769-3984-4789-84c9-bd8d2a814765","outcome":"completed","summary":"IMDPot is built, tested, documented and committed on `main` as `e257af5`. The working tree is clean.\n\n**What was delivered**\n\n- `src/IMDPot.sol`: the whole game in one contract, roughly 14 KB of runtime. Tickets cost 0.1 IMD, the 15-minute timer starts with the first ticket, and `finalizeRound()` is permissionless after the timer ends. It requests a Chainlink VRF v2.5 word, and the callback atomically picks three distinct winners, credits 40/20/10% as pull-based prizes, pushes 10% to the dead address and 5% to the treasury, locks 15% forever, and opens the next round. Loyalty points accrue one per ticket in finalized rounds, are non-transferable, and after 10 rounds can be redeemed for a stackable chance multiplier that never changes prize size.\n- `src/libraries/WeightedTree.sol`: a Fenwick tree giving exact weighted selection without replacement in logarithmic gas, so settlement costs about 400k to 540k gas whether 1 or 200 players joined.\n- Failure paths: anyone can retry randomness after 1 hour and cancel for refunds after 24 hours. Stale VRF deliveries are ignored. Fee pushes that fail are deferred and flushable, so the callback never reverts.\n- Tests: 61 passing with the verifier's fuzz seed, including reentrancy via a hook token, randomness injection, owner coordinator swaps, late entry, flash-loan style exits, fee-on-transfer and frozen tokens, cancellation and refunds, an invariant suite for conservation of funds, and a copy of the protected opcode and size scan. `forge build`, `forge test` and `forge fmt --check` all pass.\n- `frontend/`: a static casino-style app with countdown, live pool, ticket buying with exact approvals, claim section, loyalty dashboard, and finalize/retry/cancel controls. It discovers any EIP-6963 or injected wallet and exposes a hook for Privy or other SDK providers. ethers.js is vendored.\n- `README.md` and `docs/SECURITY.md`: rules, lifecycle, threat model, trust assumptions, chosen constants, and the owner's post-launch steps.\n\n**Deployment facts**\n\n- The constructor takes one argument, the owner, filled by `$owner`. It makes no external calls.\n- OpenZeppelin v5.4.0 and forge-std v1.9.7 are committed as plain files under `lib/`, no submodules.\n\n**Open items for the owner after launch**\n\n- Call `setToken` once with the $IMD token address. The brief did not give it, so it is an owner setting rather than a guess.\n- Call `setVrfConfig` with Chainlink's published mainnet VRF v2.5 coordinator, a funded subscription that lists the contract as consumer, the key hash, a callback gas limit of 750k, and 3 confirmations. The game refuses tickets until both settings exist.\n- Static analysis tools were not available here, and an independent adversarial review is still needed before real funds are accepted.","treeHash":"915531d8575ce8860efa31f0c8a6dc94a7ef17f1","usage":{"cachedInputTokens":2379970,"inputTokens":770,"model":"claude-fable-5-1","outputTokens":99689,"runtime":"claude","turns":53,"wallClockMs":1195581}},{"artifacts":[],"attempt":1,"bundleHash":"0b764bf58f9f0052a488e2eda9880dedda130022e1eb6e0362971ef8afcebbeb","device":"9c6767b941fcfedc","findings":[],"hash":"dbea99f81f520ef2330ba426daac82f9842de4625181ef6a01bfe660d14a43df","nodeId":"3512556e-e1e8-4692-9177-55a1f78e491a","outcome":"completed","summary":"Done. The manifest is written and verified.\n\n**What launch.json declares**\n- Kind is `evm_contracts`, with one contract, IMDPot, and one constructor argument, `$owner`.\n- This matches the accepted source exactly. The constructor in `src/IMDPot.sol:263` takes only the owner address and makes no external call, so it deploys on an empty chain.\n- The treasury, burn address, prize split, lock share and 15-minute duration are constants in the source, so they are not constructor arguments. The notes say so and describe the post-launch owner steps for the $IMD token and Chainlink VRF v2.5 configuration.\n\n**Checks**\n- The build succeeds and the full suite passes with the verifier's fuzz seed, 61 tests in 4 suites with 0 failures.\n- A schema check confirms only the three allowed keys, a valid identifier under 32 characters, string arguments, and notes under 4000 characters.\n- Only launch.json is new in the working tree. No other file was touched.","treeHash":"5486a960b3e31d7f81ed1b87b16553c2b1647df4","usage":{"cachedInputTokens":204680,"inputTokens":162,"model":"claude-fable-5-1","outputTokens":3392,"runtime":"claude","turns":11,"wallClockMs":77293}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"aca5d7170d77c721","findings":[{"citation":"resolved","description":"docs/SECURITY.md states that a pending draw can never be aborted once its random word might be known. Two permissionless paths break that guarantee. (1) retryRandomness() only requires VRF_RETRY_DELAY (1 h) since the request (line 412); it overwrites round.vrfRequestId, so the fulfillment for the previous request is dropped by rawFulfillRandomWords as StaleRandomnessIgnored. Chainlink fulfillment transactions carry the random word in calldata and anyone can simulate _settle to learn the winners. Whenever a fulfillment lands more than 1 h after the request (an underfunded subscription that the owner later tops up is the common case; Chainlink nodes keep retrying for up to 24 h), a losing participant watching the mempool front-runs it with retryRandomness() and gets a fresh draw. The attacker's cost is the gas of one call; the owner pays a second VRF fee; the participant who would have won first place loses the prize. (2) cancelRound() (line 422) is gated on endTime + 24 h, not on the time of the live request. If finalizeRound is called late (e.g. after the owner's pause, or because nobody called it for a day) the request is immediately cancellable by anyone, so a participant who sees the fulfillment in the mempool, or who simply prefers a refund, voids the draw and everybody is refunded; the burn, treasury fee and lock are never applied and the VRF fee is wasted. Economically the attacker converts a sure loss (their stake minus a 70% prize chance) into a free re-roll (path 1) or a full refund (path 2). Suggested fix that keeps the design: accept the first fulfillment of any request id issued for the round (keep the previous id valid after a retry instead of invalidating it), and gate cancelRound on randomnessRequestedAt + CANCEL_DELAY as well as endTime + CANCEL_DELAY so a request that was just issued cannot be aborted.","line":412,"path":"src/IMDPot.sol","reproduction":"Path 1: alice and bob buy 1 ticket each; warp to endTime; finalizeRound() -> request R1. Pick a word W such that fulfilling R1 with W makes alice first (e.g. loop words 1..50 under vm.snapshotState and check getRound(1).winners[0]). Warp +1 h (fulfillment delayed). bob calls retryRandomness() -> vrfRequestId becomes R2. Deliver R1 with W: rawFulfillRandomWords emits StaleRandomnessIgnored and the round stays Drawing (expected: alice is already the winner). Deliver R2 with a word that makes bob first: bob wins. Verified in a scratch test (test_retryFrontRunsVisibleFulfillment passes on the current code). Path 2: alice buys 100 tickets, bob 1; warp to endTime + 24 h; finalizeRound() -> request R (status Drawing, requested just now); bob immediately calls cancelRound(): succeeds, round Cancelled; the later fulfillment of R is ignored; bob refund(1) gets his full stake back (expected per docs: a pending draw cannot be aborted).","severity":"medium","snippet":"        if (block.timestamp < uint256(round.randomnessRequestedAt) + VRF_RETRY_DELAY) revert RetryTooEarly();","title":"Permissionless retryRandomness/cancelRound let a participant void a VRF fulfillment whose outcome is already visible, re-rolling or refunding a losing draw"},{"citation":"resolved","description":"The brief requires that every ticket has an equal chance of winning. _selectWinners draws three distinct addresses and removes the whole weight of each drawn player (line 690). A ticket's chance at 2nd and 3rd place therefore depends on how the tickets are split across addresses, not on the ticket count: once the dominant holder is drawn first (probability ~ their share), all of their remaining tickets have zero chance at the other two places, which go to whoever else is present. Splitting a stake over several addresses is strictly dominant, and an observer can enter at the last second only when the on-chain pool shows one or two dominant holders. Numbers: whale 1000 tickets (100 IMD), three Sybil wallets 1 ticket each (0.3 IMD), pool 100.3 IMD. For every random word the three Sybils together receive at least 30% of the pool (30.09 IMD, a 100x return on 0.3 IMD) and the whale never receives more than 40% (40.12 IMD), while under equal per-ticket chances the whale's expected share of the 70% prize pool would be ~69.8% (~70 IMD) and the Sybils' ~0.2%. With exactly three participants everyone wins a prize regardless of ticket counts. Stronger variant: a lone whale with 1000 tickets and one attacker ticket (0.1 IMD) bought one second before endTime: because the unfilled third slot rolls up to first place, the attacker is guaranteed at least 20% of the pool (20.02 IMD, 200x) in every outcome, verified by fuzzing the random word (scratch test testFuzz_oneDustTicketAlwaysTakesAtLeastTwentyPercent, 256 runs). The whale is the identifiable victim; the protocol itself keeps its 30% cut. Fix options (scope decision for the author): draw without replacement at ticket level, i.e. subtract one ticket's weight (BPS + boost) from the drawn leaf instead of tree.remove(index), allowing the same address to take several podium places; or document explicitly that winners are distinct addresses and that chances per ticket are only equal for first place.","line":687,"path":"src/IMDPot.sol","reproduction":"setToken(18-decimal mock), setVrfConfig(mock coordinator). whale.buyTickets(1000); s1/s2/s3.buyTickets(1) each; warp to endTime; finalizeRound(); coordinator.fulfill(1, word) for any uint256 word (fuzzed 256 runs). Observed: claimable(s1)+claimable(s2)+claimable(s3) >= 30% of pool and claimable(whale) <= 40% of pool in every run (scratch test testFuzz_sybilsAlwaysTakeAtLeastThirtyPercent passes). Expected under equal chance per ticket: the whale's 1000 tickets should be able to take 2nd/3rd place with probability ~1000/1002 and ~1000/1001 respectively, and the Sybils' payout should be near zero in most runs.","severity":"medium","snippet":"            uint256 position = uint256(keccak256(abi.encode(randomWord, k))) % remaining;\n            uint256 index = tree.find(position);\n            winners[k] = participants[index - 1];\n            tree.remove(index);","title":"Distinct-winner draw removes a whole player's weight after each place, so dust Sybil entries are guaranteed the 2nd and 3rd prizes against a dominant player"},{"citation":"resolved","description":"setVrfConfig accepts callbackGasLimit as low as MIN_CALLBACK_GAS_LIMIT (600_000) and the NatSpec/README claim settlement costs 400k-540k regardless of participant count (measured only up to 200). Settlement cost grows with the Fenwick tree depth: three find + three remove walks plus the two fee pushes. Measured on this code: 200 participants ~581k, 1500 participants settles in 600k, 2048 participants does not (needs ~630k; it settles in 750k). When the owner configures the floor and a round reaches 2048 one-ticket entries, the Chainlink callback runs out of gas; the coordinator marks the request fulfilled and the round stays Drawing. retryRandomness() after 1 h issues a new request that fails identically (each costs the owner a VRF fee), setVrfConfig reverts with RoundNotIdle because the round is not Open, and the only exit is cancelRound() after endTime + 24 h, after which every player must refund(1): no burn, no treasury fee, no lock, no points for that round. An attacker can force this deliberately: 2048 fresh addresses buying one ticket each costs ~205 IMD, all of which is refunded, so the net cost is gas only; organic growth past ~2000 players triggers it as well. Fix: raise MIN_CALLBACK_GAS_LIMIT to a value that covers the maximum tree depth with margin (e.g. 1_000_000), or make the callback cheaper (e.g. move the two fee pushes out of the callback into flushPendingFees), and correct the documented cost.","line":136,"path":"src/IMDPot.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IMDPot} from \"src/IMDPot.sol\";\nimport {MockIMDToken} from \"test/mocks/MockIMDToken.sol\";\nimport {MockVRFCoordinator} from \"test/mocks/MockVRFCoordinator.sol\";\n\n/// @notice Fails on the current code: a round with 2048 one-ticket participants cannot be settled when the owner\n///         configures the contract's own minimum callback gas limit. The round is then stuck in Drawing until it is\n///         cancelled 24 hours after its end. Passes once settlement fits in MIN_CALLBACK_GAS_LIMIT (either by\n///         raising the floor or by making settlement cheaper).\ncontract ProofCallbackGasFloorTest is Test {\n    address internal constant OWNER = address(0xA11CE);\n    IMDPot internal pot;\n    MockIMDToken internal token;\n    MockVRFCoordinator internal coordinator;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        token = new MockIMDToken(18);\n        coordinator = new MockVRFCoordinator();\n        pot = new IMDPot(OWNER);\n        vm.startPrank(OWNER);\n        pot.setToken(address(token));\n        pot.setVrfConfig(address(coordinator), 1, keccak256(\"kh\"), pot.MIN_CALLBACK_GAS_LIMIT(), 3, false);\n        vm.stopPrank();\n    }\n\n    function test_settlementFitsInMinimumCallbackGasWith2048Participants() public {\n        for (uint256 i; i < 2048; ++i) {\n            address p = address(uint160(0x10000 + i));\n            token.mint(p, 1 ether);\n            vm.prank(p);\n            token.approve(address(pot), type(uint256).max);\n            vm.prank(p);\n            pot.buyTickets(1);\n        }\n        vm.warp(pot.getRound(1).endTime);\n        pot.finalizeRound();\n        uint256 req = pot.getRound(1).vrfRequestId;\n        bool ok = coordinator.fulfillWithGas(req, uint256(keccak256(\"w\")), pot.MIN_CALLBACK_GAS_LIMIT());\n        assertTrue(ok, \"settlement must fit in MIN_CALLBACK_GAS_LIMIT\");\n        assertEq(uint8(pot.getRound(1).status), uint8(IMDPot.RoundStatus.Finalized), \"round stuck in Drawing\");\n    }\n}","reproduction":"setToken(18-decimal mock); setVrfConfig(coordinator, 1, kh, 600_000, 3, false). 2048 distinct addresses each buyTickets(1). Warp to endTime; finalizeRound(); coordinator.fulfillWithGas(requestId, word, 600_000) returns false (callback OOG) and getRound(1).status stays Drawing. Warp +1 h; retryRandomness(); fulfillWithGas(newId, 1, 600_000) returns false again. OWNER setVrfConfig(..., 2_500_000, ...) reverts RoundNotIdle. Warp to endTime + 24 h; cancelRound() succeeds and the round is Cancelled. Expected: a configuration the contract itself accepts should settle any round. Same sequence with 1500 participants settles; with 2048 at 750_000 settles. Attached proof test fails on the current code with 'settlement must fit in MIN_CALLBACK_GAS_LIMIT'.","severity":"medium","snippet":"    uint32 public constant MIN_CALLBACK_GAS_LIMIT = 600_000;","title":"MIN_CALLBACK_GAS_LIMIT of 600k is below settlement cost once a round reaches 2048 participants; the round is then unsettleable and can only be cancelled after 24 h"}],"hash":"e9f23ca760b703af67aa8c701175b99d305b7ccb11df4b7d38317ad3fa82aeb8","nodeId":"75265fa1-0725-4fbb-b0b2-dc44375177de","outcome":"completed","summary":"Review complete. The findings file is written, every snippet matches the tree line for line, the proof test fails on the current code, and no repository source, config or test file was modified.\n\n**Outcome: three medium findings in the economics, invariant and flow-gap area. No fund-draining path was found.** Conservation of funds holds on every writer: the pool splits exactly into prizes, burn, fee and lock, refunds sum to the cancelled pool, and nothing can move the locked 15%.\n\nFindings written to `.imd-findings.json`:\n\n1. **A visible late VRF fulfillment can be voided by any participant.** After one hour, `retryRandomness` replaces the live request id, so a losing player who sees the fulfillment in the mempool front-runs it and gets a fresh draw. After a late finalize, `cancelRound` is immediately callable and voids the draw for a full refund. Both contradict the security doc's claim that a pending draw can never be aborted once its word might be known. Reproduced in scratch tests.\n\n2. **The distinct-winner draw rewards Sybil splitting against a dominant player.** Removing a drawn player's whole weight means a lone whale with 1000 tickets guarantees one dust ticket at least 20% of the pool, and three dust tickets at least 30%, in every outcome. Fuzzed over the random word, 256 runs each. This breaks the brief's \"every ticket has an equal chance\" for second and third place. The fix is a design decision, which I spelled out.\n\n3. **The 600k callback-gas floor cannot settle a round of 2048 participants.** Settlement cost grows with tree depth, not the documented flat 400k to 540k. At the floor the callback runs out of gas, retries fail identically, the config cannot change while Drawing, and the only exit is cancellation after 24 hours with no burn, fee, lock or points. Forcible by an attacker at the cost of gas only, since tickets are refunded. This one carries a proof test that fails now.\n\nCoverage: all 18 entry points answered, 6 as `finding` and 12 as `holds`, plus three invariant rows. The project's own suite passes at baseline, but it measures settlement gas only up to 40 participants in-test and never exercises a retry or cancel against a fulfillment already in flight, which are exactly the untested edges behind findings 1 and 3.","treeHash":null,"usage":{"cachedInputTokens":2781936,"inputTokens":770,"model":"claude-fable-5-1","outputTokens":51159,"runtime":"claude","turns":41,"wallClockMs":753047}},{"artifacts":[],"attempt":1,"bundleHash":"90d51d171837a385b06407dfc701cb43b8a442022d47fb515c3c4e095c886308","device":"c97fd6a73e3737dc","findings":[],"hash":"eef9889c98bb68a487622cc83df9149111a529cdad14598588a948493952d0c8","nodeId":"061fc619-1caf-4188-9652-85d8f4faf5be","outcome":"completed","summary":"I've updated my two test files to match the revised `IMDPot`. With the verifier's fuzz seed, `forge build` succeeds and `forge test` passes all 115 tests in 6 suites, including all nine invariants. I found no defect that needed reporting, so I didn't write `.imd-findings.json`.\n\n**Why my tests broke.** After my last round, the implementation changed four rules on purpose. My old tests checked the old rules, so 8 of them failed:\n- **Winners:** winning tickets are now drawn one at a time without replacement, so one address can hold several podium places.\n- **Ownership:** `renounceOwnership` is now disabled and always reverts.\n- **Cancelling:** calling `cancelRound` on a round nobody finalised now requests randomness first. It only cancels if the request fails or the game is paused.\n- **Fee flushing:** `flushPendingFees` no longer reverts when a recipient is still blocked; that fee just stays pending.\n\n**Fixes in `test/IMDPotEdges.t.sol`:**\n- **Settlement fuzz tests:** each address's claimable amount must equal the sum of the prizes it won, and it can't win more places than it has tickets. Each drawn ticket must remove exactly one ticket's weight. Podium places stay filled up to the smaller of the ticket count and 3. A solo player fills places 0 to 2 up to their ticket count and still gets the whole 70%.\n- **Cancel test:** the first cancel moves the round to drawing. A second cancel is refused until a full day after that request, then succeeds once. A third is refused, and a randomness answer arriving afterwards changes nothing.\n- **Refund test:** now reaches a cancelled round through a coordinator that rejects requests.\n- **Flush test:** a still-blocked treasury now keeps its fee pending, with the \"deferred\" event, and nothing moves.\n- **Admin tests:** a stranger calling renounce gets the \"cannot be renounced\" error. The old \"renounced owner\" test now checks the owner can't renounce mid-round while paused, keeps both owner and pending owner, and can still unpause.\n\n**New edge tests:**\n- The settings for gas limit and confirmations: refused before the randomness source is configured, every bound checked, and changeable mid-draw while the coordinator, subscription and key hash stay locked.\n- The 4-request retry limit: every request still maps to its round, and the round can be cancelled a day after the latest request, with a full refund.\n- A coordinator that hands out the same request ID twice: finalize and cancel both revert with the duplicate-ID error, a replayed answer can't settle the new round, and pausing still lets the round be cancelled and refunded.\n\n**Fixes in `test/IMDPotAccountingInvariant.t.sol`:**\n- **Stale answers:** only a request that belongs to the round currently drawing may settle it, since earlier retries of that round are now valid too.\n- **Cancel action:** it now waits a full day after the round's latest request, not just after the round's end.\n- **Winner invariant:** checks ticket-level draws, including boosted weights, and that no round exceeds the request limit.\n\n**Two points for the owner to know:**\n- **Duplicate request IDs:** if the coordinator ever reuses a request ID, the round can't be finalized or cancelled until the owner pauses the game. The real Chainlink coordinator never reuses IDs, and the owner is already trusted to point at the right one, so I didn't report it as a defect.\n- **Gas squeeze on cancel:** I checked whether someone could call `cancelRound` with just enough gas to make the randomness request fail and force a refund. It isn't practical: what's left after the failed request is far too little to finish the cancel. I didn't write a test for it.","treeHash":"316a9775cd5b673391326a671f8ab23f65fa06c5","usage":{"cachedInputTokens":745014,"inputTokens":24,"model":"claude-opus-5-5","outputTokens":16704,"runtime":"claude","turns":13,"wallClockMs":244484}}],"verification":[{"checks":[{"durationMs":3588,"exitCode":0,"name":"build","output":"Compiling 47 files with Solc 0.8.26\nSolc 0.8.26 finished in 3.50s\nCompiler run successful!\n","passed":true},{"durationMs":14545,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 2 tests for test/WeightedTree.t.sol:WeightedTreeTest\n[PASS] testFuzz_findMatchesPrefixSums(uint8[16],uint8[8],uint256) (runs: 256, μ: 1842575, ~: 1854720)\n[PASS] test_appendFindAndRemove() (gas: 904366)\nSuite result: ok. 2 passed; 0 failed; 0 skipped; finished in 45.37ms (46.66ms CPU time)\n\nRan 16 tests for test/IMDPotAttacks.t.sol:IMDPotAttacksTest\n[PASS] test_cancel_requiresDelay() (gas: 603861)\n[PASS] test_cancel_whenCoordinatorIsBrokenRefundsEveryone() (gas: 1603557)\n[PASS] test_custody_directDonationDoesNotChangeObligations() (gas: 1188954)\n[PASS] test_custody_flashLoanStyleEntryCannotExitInSameBlock() (gas: 497904)\n[PASS] test_custody_lockedTokensCanNeverLeave() (gas: 2163527)\n[PASS] test_randomness_cannotBeInjectedByPlayersOrOwner() (gas: 1051451)\n[PASS] test_randomness_lateEntryAfterEndIsImpossible() (gas: 519584)\n[PASS] test_randomness_ownerCannotSwapCoordinatorMidRound() (gas: 1512756)\n[PASS] test_randomness_staleWordAfterCancelIsIgnored() (gas: 1181735)\n[PASS] test_reentrancy_claimCannotReenterClaim() (gas: 2441416)\n[PASS] test_reentrancy_feePushCannotReenterSettlement() (gas: 2358614)\n[PASS] test_reentrancy_refundCannotReenterRefundOrBuy() (gas: 1926633)\n[PASS] test_refund_rejectsNonCancelledRounds() (gas: 1184172)\n[PASS] test_refund_restoresSpentMultiplier() (gas: 13683460)\n[PASS] test_settlement_defersFeesWhenRecipientIsBlocked() (gas: 1751060)\n[PASS] test_settlement_survivesFrozenToken() (gas: 1370360)\nSuite result: ok. 16 passed; 0 failed; 0 skipped; finished in 59.78ms (14.60ms CPU time)\n\nRan 36 tests for test/IMDPotEdges.t.sol:IMDPotEdgesTest\n[PASS] testFuzz_buy_countBounds(uint256) (runs: 512, μ: 383668, ~: 521236)\nLogs:\n  Bound result 1791633\n\n[PASS] testFuzz_settlement_soloPlayerAlwaysWinsEverything(uint256,uint256) (runs: 256, μ: 1149563, ~: 1150058)\nLogs:\n  Bound result 200\n\n[PASS] testFuzz_settlement_splitIsExactForAnyPool(uint256,uint256,uint256,uint256) (runs: 512, μ: 1907655, ~: 1913143)\nLogs:\n  Bound result 1000000\n  Bound result 519539\n  Bound result 3570\n\n[PASS] test_admin_everyOwnerFunctionRejectsStrangers() (gas: 190157)\n[PASS] test_admin_pauseAndUnpauseCannotRepeat() (gas: 109251)\n[PASS] test_admin_pausedBlocksRetry() (gas: 785802)\n[PASS] test_admin_pendingOwnerHasNoPowerUntilAcceptAndOldOwnerLosesIt() (gas: 190884)\n[PASS] test_admin_renouncedOwnerLeavesGameRunning() (gas: 1477262)\n[PASS] test_admin_rescueRejectsZeroRecipientAndZeroToken() (gas: 894782)\n[PASS] test_admin_setTokenDecimalBounds() (gas: 2976431)\n[PASS] test_admin_setVrfConfigAllowedAgainAfterSettlement() (gas: 1913988)\n[PASS] test_admin_vrfConfigBoundariesAccepted() (gas: 279578)\n[PASS] test_cancel_cannotCancelFinalizedRoundOrTwice() (gas: 540150)\n[PASS] test_cancel_worksWhilePausedAndRefundsWhilePaused() (gas: 670683)\n[PASS] test_claim_blockedWinnerDoesNotBlockOthers() (gas: 2291282)\n[PASS] test_claim_nonWinnerAndNeverPlayedRevert() (gas: 2145784)\n[PASS] test_finalize_afterSettlementRevertsForEmptyNextRound() (gas: 1218583)\n[PASS] test_finalize_boundaryIsExactlyEndTime() (gas: 751055)\n[PASS] test_finalize_twiceReverts() (gas: 752481)\n[PASS] test_flush_nothingPendingReverts() (gas: 1154744)\n[PASS] test_flush_stillBlockedRevertsAtomicallyThenSucceeds() (gas: 1375056)\n[PASS] test_fulfill_extraWordsAreIgnoredOnlyFirstUsed() (gas: 1410166)\n[PASS] test_fulfill_oldRequestCannotSettleALaterRound() (gas: 2520145)\n[PASS] test_fulfill_ownerAndPlayersCannotCall() (gas: 807406)\n[PASS] test_fulfill_secondDeliveryOfSameRequestIsIgnored() (gas: 1564984)\n[PASS] test_fulfill_settlesWhilePaused() (gas: 1281504)\n[PASS] test_nextRoundTimerStartsFromItsOwnFirstDeposit() (gas: 1497185)\n[PASS] test_points_multiplePurchasesInOneRoundCountAsOneRound() (gas: 1384879)\n[PASS] test_redeem_atCapRevertsAndKeepsPoints() (gas: 13203613)\n[PASS] test_redeem_boostAppliesToAllTicketsOfNextRoundThenIsConsumed() (gas: 15052683)\n[PASS] test_redeem_nineRoundsWithManyPointsRejected() (gas: 11716750)\n[PASS] test_redeem_tenRoundsButTooFewPoints() (gas: 13013355)\n[PASS] test_refund_cancelledRoundGivesNoPointsOrRoundCredit() (gas: 623800)\n[PASS] test_refund_revertsForEveryNonCancelledStatus() (gas: 1267829)\n[PASS] test_retry_boundaryIsExactlyOneHourAfterRequest() (gas: 1063612)\n[PASS] test_syncPoints_byThirdPartyOnlyCreditsTheTarget() (gas: 1556067)\nSuite result: ok. 36 passed; 0 failed; 0 skipped; finished in 59.78ms (160.30ms CPU time)\n\nRan 42 tests for test/IMDPot.t.sol:IMDPotTest\n[PASS] testFuzz_settlement_winnersAreDistinctAndWeightsZeroed(uint256) (runs: 256, μ: 2126453, ~: 2127653)\n[PASS] test_buy_closesExactlyAtEndTime() (gas: 725454)\n[PASS] test_buy_firstTicketStartsTimer() (gas: 591405)\n[PASS] test_buy_laterTicketsDoNotExtendTimer() (gas: 951535)\n[PASS] test_buy_rejectedWhileDrawing() (gas: 748839)\n[PASS] test_buy_rejectsFeeOnTransferShortfall() (gas: 170604)\n[PASS] test_buy_requiresAllowanceAndBalance() (gas: 177663)\n[PASS] test_buy_revertsUntilConfigured() (gas: 745781)\n[PASS] test_buy_validatesCount() (gas: 88246)\n[PASS] test_claim_accumulatesAcrossRounds() (gas: 2216499)\n[PASS] test_claim_paysWinnersOnceAndOnlyWinners() (gas: 2013607)\n[PASS] test_constructor_rejectsZeroOwner() (gas: 4149)\n[PASS] test_constructor_runsOnEmptyChainAndFitsSizeLimits() (gas: 8335989)\n[PASS] test_constructor_setsLaunchOwnerNotDeployer() (gas: 6299017)\n[PASS] test_deployScript_deploysWithExplicitOwner() (gas: 6296879)\n[PASS] test_finalize_anyoneCanCallAndRequestsVrfWithConfiguredParams() (gas: 771544)\n[PASS] test_finalize_revertsWhenCoordinatorRejects() (gas: 579727)\n[PASS] test_finalize_revertsWithoutRoundOrBeforeEnd() (gas: 501595)\n[PASS] test_fulfill_fitsWithinCallbackGasForManyParticipants() (gas: 15163875)\n[PASS] test_fulfill_ignoresUnknownRequestAndEmptyWords() (gas: 1404884)\n[PASS] test_fulfill_onlyCoordinator() (gas: 775358)\n[PASS] test_ownership_isTwoStep() (gas: 146145)\n[PASS] test_pause_blocksEntryAndFinalizeButNotExits() (gas: 2509660)\n[PASS] test_points_cannotBeWithdrawnAsTokens() (gas: 13058418)\n[PASS] test_points_creditedProportionallyAfterFinalization() (gas: 1602493)\n[PASS] test_points_syncOnNextPurchaseAndNotForCancelledRounds() (gas: 1727147)\n[PASS] test_redeem_requiresTenRoundsAndPoints() (gas: 14464365)\n[PASS] test_redeem_stacksToCapAndBoostsOnlyChancesNotPrize() (gas: 15116801)\n[PASS] test_rescue_onlyForeignTokensAndOnlyOwner() (gas: 925501)\n[PASS] test_retry_reRequestsAfterDelayAndIgnoresOldFulfillment() (gas: 1873626)\n[PASS] test_retry_revertsWhenNotDrawing() (gas: 456401)\n[PASS] test_setToken_onlyOwner() (gas: 33542)\n[PASS] test_setToken_setsTicketPriceFromDecimals() (gas: 28026)\n[PASS] test_setToken_sixDecimals() (gas: 771659)\n[PASS] test_setToken_validation() (gas: 882607)\n[PASS] test_setVrfConfig_onlyOwnerAndOnlyBetweenRounds() (gas: 1224363)\n[PASS] test_setVrfConfig_validation() (gas: 346543)\n[PASS] test_settlement_dustGoesToLock() (gas: 1991151)\n[PASS] test_settlement_moreTicketsMeanMoreFirstPlaceWins() (gas: 112814517)\n[PASS] test_settlement_singleParticipantTakesWholePrizeShare() (gas: 1190968)\n[PASS] test_settlement_splitsPoolExactly() (gas: 1984406)\n[PASS] test_settlement_twoParticipantsRollThirdPrizeToFirst() (gas: 1455722)\nSuite result: ok. 42 passed; 0 failed; 0 skipped; finished in 59.78ms (155.80ms CPU time)\n\nRan 1 test for test/IMDPotInvariant.t.sol:IMDPotInvariantTest\n[PASS]\nIMDPotInvariantTest invariants:\n[PASS] invariant_balanceCoversObligations\n[PASS] invariant_currentRoundIsNeverSettled\n[PASS] invariant_depositsAreFullyAccountedFor\n[PASS] invariant_lockedNeverDecreases\n[PASS] invariant_winnersAreDistinctAndPrizesSumToSeventyPercent\n IMDPotInvariantTest invariants (runs: 64, calls: 2048, reverts: 0)\n\n╭---------------+----------+-------+---------+----------╮\n| Contract      | Selector | Calls | Reverts | Discards |\n+=======================================================+\n| IMDPotHandler | buy      | 208   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | cancel   | 229   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | claim    | 216   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | finalize | 194   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | flush    | 237   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | fulfill  | 206   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | redeem   | 194   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | refund   | 186   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | retry    | 178   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | warp     | 200   | 0       | 0        |\n╰---------------+----------+-------+---------+----------╯\n\nLogs:\n  Bound result 6057\n  Bound result 22\n  Bound result 1\n  Bound result 1\n  Bound result 1\n  Bound result 72000\n  Bound result 1\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 217.81ms (216.10ms CPU time)\n\nRan 1 test for test/IMDPotAccountingInvariant.t.sol:IMDPotAccountingInvariantTest\n[PASS]\nIMDPotAccountingInvariantTest invariants:\n[PASS] invariant_balanceEqualsObligationsAndLedgersAgree\n[PASS] invariant_noUnauthorizedSuccessAndWinnersArePlayers\n[PASS] invariant_playersCollectivelyNeverProfit\n[PASS] invariant_pointsMatchFinalizedTicketsAndBoostIsCapped\n IMDPotAccountingInvariantTest invariants (runs: 48, calls: 3840, reverts: 0)\n\n╭-------------------------+-------------------+-------+---------+----------╮\n| Contract                | Selector          | Calls | Reverts | Discards |\n+==========================================================================+\n| IMDPotAccountingHandler | buy               | 179   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | cancel            | 202   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | claim             | 200   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | finalize          | 188   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | flush             | 207   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | fulfill           | 212   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | fulfillStale      | 241   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | ownerPause        | 186   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | ownerTriesToDrain | 202   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | playManyRounds    | 215   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | playRound         | 193   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | redeem            | 197   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | refund            | 232   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | retry             | 200   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | stallAndCancel    | 187   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | strangerFulfills  | 198   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | sync              | 201   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | toggleFeeBlock    | 200   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | warp              | 200   | 0       | 0        |\n╰-------------------------+-------------------+-------+---------+----------╯\n\nLogs:\n  Bound result 20\n  Bound result 4\n  Bound result 10\n  Bound result 1\n  Bound result 11\n  Bound result 2\n  Bound result 12\n  Bound result 3\n  Bound result 13\n  Bound result 4\n  Bound result 6\n  Bound result 4\n  Bound result 40\n  Bound result 44\n  Bound result 3\n  Bound result 10\n  Bound result 1\n  Bound result 11\n  Bound result 2\n  Bound result 12\n  Bound result 3\n  Bound result 4\n  Bound result 1\n  Bound result 4\n  Bound result 56\n  Bound result 5\n  Bound result 1\n  Bound result 4\n  Bound result 3967\n  Bound result 40\n  Bound result 32\n  Bound result 12\n  Bound result 8\n  Bound result 10\n  Bound result 1\n  Bound result 11\n  Bound result 2\n  Bound result 12\n  Bound result 3\n  Bound result 13\n  Bound result 4\n  Bound result 14\n  Bound result 5\n  Bound result 15\n  Bound result 6\n  Bound result 16\n  Bound result 7\n  Bound result 17\n  Bound result 8\n  Bound result 20\n  Bound result 7\n  Bound result 202\n  Bound result 18\n  Bound result 8\n  Bound result 10\n  Bound result 10\n  Bound result 1\n  Bound result 11\n  Bound result 2\n  Bound result 12\n  Bound result 3\n  Bound result 13\n  Bound result 4\n  Bound result 14\n  Bound result 5\n  Bound result 15\n  Bound result 6\n  Bound result 16\n  Bound result 7\n  Bound result 17\n  Bound result 8\n  Bound result 18\n  Bound result 9\n  Bound result 19\n  Bound result 10\n  Bound result 5\n  Bound result 49\n  Bound result 20\n  Bound result 29\n  Bound result 6\n  Bound result 33\n  Bound result 40\n  Bound result 40\n  Bound result 18\n  Bound result 40\n  Bound result 5\n  Bound result 20\n  Bound result 1\n  Bound result 10\n  Bound result 10\n  Bound result 1\n  Bound result 11\n  Bound result 2\n  Bound result 12\n  Bound result 3\n  Bound result 13\n  Bound result 4\n  Bound result 14\n  Bound result 5\n  Bound result 15\n  Bound result 6\n  Bound result 16\n  Bound result 7\n  Bound result 17\n  Bound result 8\n  Bound result 18\n  Bound result 9\n  Bound result 19\n  Bound result 10\n  Bound result 90\n  Bound result 35\n  Bound result 18\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 14.45s (14.45s CPU time)\n\nRan 6 test suites in 14.46s (14.90s CPU time): 98 tests passed, 0 failed, 0 skipped (98 total tests)\n","passed":true},{"durationMs":45,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"IMDPot.acceptOwnership()\",\"IMDPot.buyTickets(uint256)\",\"IMDPot.cancelRound()\",\"IMDPot.claim()\",\"IMDPot.finalizeRound()\",\"IMDPot.flushPendingFees()\",\"IMDPot.pause()\",\"IMDPot.rawFulfillRandomWords(uint256,uint256[])\",\"IMDPot.redeemMultiplier()\",\"IMDPot.refund(uint256)\",\"IMDPot.renounceOwnership()\",\"IMDPot.rescueForeignToken(address,address)\",\"IMDPot.retryRandomness()\",\"IMDPot.setToken(address)\",\"IMDPot.setVrfConfig(address,uint256,bytes32,uint32,uint16,bool)\",\"IMDPot.syncPoints(address)\",\"IMDPot.transferOwnership(address)\",\"IMDPot.unpause()\"],\"files\":{\".gitignore\":24,\"README.md\":148,\"docs/SECURITY.md\":79,\"foundry.toml\":22,\"frontend/IMDPot.abi.json\":1708,\"frontend/app.js\":378,\"frontend/config.js\":15,\"frontend/index.html\":134,\"frontend/styles.css\":94,\"frontend/vendor/ETHERS-LICENSE.md\":21,\"frontend/vendor/ethers-6.15.0.umd.min.js\":1,\"remappings.txt\":2,\"script/DeployIMDPot.s.sol\":26,\"src/IMDPot.sol\":718,\"src/interfaces/IVRFCoordinatorV2Plus.sol\":48,\"src/libraries/WeightedTree.sol\":104,\"test/IMDPot.t.sol\":767,\"test/IMDPotAccountingInvariant.t.sol\":349,\"test/IMDPotAttacks.t.sol\":345,\"test/IMDPotBase.t.sol\":90,\"test/IMDPotEdges.t.sol\":599,\"test/IMDPotInvariant.t.sol\":169,\"test/WeightedTree.t.sol\":130,\"test/mocks/MockIMDToken.sol\":50,\"test/mocks/MockVRFCoordinator.sol\":73},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"0b6adc260ac0be1a133f87e1fa62880f0750c0e274faed7a8d9fb03a9a0f82bf","verifiedTreeHash":"4339084587fc4cd4629289da7ffda421ee0b7f52","verifierVersion":"0.1.0+745c1db4"},{"checks":[{"durationMs":5143,"exitCode":0,"name":"build","output":"Compiling 45 files with Solc 0.8.26\nSolc 0.8.26 finished in 5.01s\nCompiler run successful!\n","passed":true},{"durationMs":4510,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 4 tests for test/WeightedTree.t.sol:WeightedTreeTest\n[PASS] testFuzz_findMatchesPrefixSums(uint8[16],uint8[8],uint256) (runs: 256, μ: 1846651, ~: 1860418)\n[PASS] testFuzz_subtractMatchesPrefixSums(uint8[16],uint8[8],uint256) (runs: 256, μ: 1847881, ~: 1856246)\n[PASS] test_appendFindAndRemove() (gas: 906561)\n[PASS] test_subtractTakesPartOfALeaf() (gas: 601443)\nSuite result: ok. 4 passed; 0 failed; 0 skipped; finished in 83.45ms (157.24ms CPU time)\n\nRan 1 test for test/IMDPotInvariant.t.sol:IMDPotInvariantTest\n[PASS]\nIMDPotInvariantTest invariants:\n[PASS] invariant_balanceCoversObligations\n[PASS] invariant_currentRoundIsNeverSettled\n[PASS] invariant_depositsAreFullyAccountedFor\n[PASS] invariant_lockedNeverDecreases\n[PASS] invariant_prizesSumToSeventyPercentAndSlotsAreFilledByTickets\n IMDPotInvariantTest invariants (runs: 64, calls: 2048, reverts: 0)\n\n╭---------------+----------+-------+---------+----------╮\n| Contract      | Selector | Calls | Reverts | Discards |\n+=======================================================+\n| IMDPotHandler | buy      | 208   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | cancel   | 229   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | claim    | 216   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | finalize | 194   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | flush    | 237   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | fulfill  | 206   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | redeem   | 194   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | refund   | 186   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | retry    | 178   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | warp     | 200   | 0       | 0        |\n╰---------------+----------+-------+---------+----------╯\n\nLogs:\n  Bound result 62497\n  Bound result 500\n  Bound result 1\n  Bound result 1\n  Bound result 1\n  Bound result 4078\n  Bound result 1\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 412.63ms (409.15ms CPU time)\n\nRan 22 tests for test/IMDPotAttacks.t.sol:IMDPotAttacksTest\n[PASS] test_cancel_activeRoundRequestsRandomnessInsteadOfRefunding() (gas: 1420487)\n[PASS] test_cancel_activeRoundWhilePausedRefundsWithoutRequesting() (gas: 721803)\n[PASS] test_cancel_requiresDelaySinceEndAndSinceLatestRequest() (gas: 1475105)\n[PASS] test_cancel_whenCoordinatorIsBrokenRefundsEveryone() (gas: 1621220)\n[PASS] test_custody_directDonationDoesNotChangeObligations() (gas: 1212967)\n[PASS] test_custody_flashLoanStyleEntryCannotExitInSameBlock() (gas: 497859)\n[PASS] test_custody_lockedTokensCanNeverLeave() (gas: 2077924)\n[PASS] test_flush_legsAreIndependent() (gas: 1604806)\n[PASS] test_randomness_cancelCannotDiscardFreshRequest() (gas: 1871834)\n[PASS] test_randomness_cannotBeInjectedByPlayersOrOwner() (gas: 1074810)\n[PASS] test_randomness_lateEntryAfterEndIsImpossible() (gas: 519731)\n[PASS] test_randomness_ownerCannotSwapCoordinatorMidRound() (gas: 1536285)\n[PASS] test_randomness_retryCannotVoidPendingFulfillment() (gas: 1713524)\n[PASS] test_randomness_staleWordAfterCancelIsIgnored() (gas: 1205970)\n[PASS] test_reentrancy_claimCannotReenterClaim() (gas: 2567395)\n[PASS] test_reentrancy_feePushCannotReenterSettlement() (gas: 2484602)\n[PASS] test_reentrancy_refundCannotReenterRefundOrBuy() (gas: 1944341)\n[PASS] test_refund_rejectsNonCancelledRounds() (gas: 1208030)\n[PASS] test_refund_restoresSpentMultiplier() (gas: 14504298)\n[PASS] test_refund_returnsPointsForBoostAboveCap() (gas: 14715436)\n[PASS] test_settlement_defersFeesWhenRecipientIsBlocked() (gas: 1887797)\n[PASS] test_settlement_survivesFrozenToken() (gas: 1497827)\nSuite result: ok. 22 passed; 0 failed; 0 skipped; finished in 4.37s (47.23ms CPU time)\n\nRan 48 tests for test/IMDPot.t.sol:IMDPotTest\n[PASS] testFuzz_settlement_drawsThreeTicketsWithoutReplacement(uint256) (runs: 256, μ: 2245553, ~: 2240338)\n[PASS] test_buy_closesExactlyAtEndTime() (gas: 725689)\n[PASS] test_buy_firstTicketStartsTimer() (gas: 592231)\n[PASS] test_buy_laterTicketsDoNotExtendTimer() (gas: 952284)\n[PASS] test_buy_rejectedWhileDrawing() (gas: 772176)\n[PASS] test_buy_rejectsFeeOnTransferShortfall() (gas: 170582)\n[PASS] test_buy_requiresAllowanceAndBalance() (gas: 177732)\n[PASS] test_buy_revertsUntilConfigured() (gas: 746178)\n[PASS] test_buy_validatesCount() (gas: 88180)\n[PASS] test_claim_accumulatesAcrossRounds() (gas: 2264400)\n[PASS] test_claim_paysWinnersOnceAndOnlyWinners() (gas: 2042271)\n[PASS] test_constructor_rejectsZeroOwner() (gas: 4104)\n[PASS] test_constructor_runsOnEmptyChainAndFitsSizeLimits() (gas: 9047609)\n[PASS] test_constructor_setsLaunchOwnerNotDeployer() (gas: 6769415)\n[PASS] test_deployScript_deploysWithExplicitOwner() (gas: 6767322)\n[PASS] test_finalize_anyoneCanCallAndRequestsVrfWithConfiguredParams() (gas: 794926)\n[PASS] test_finalize_revertsWhenCoordinatorRejects() (gas: 580096)\n[PASS] test_finalize_revertsWithoutRoundOrBeforeEnd() (gas: 501742)\n[PASS] test_fulfill_fitsWithinCallbackGasForManyParticipants() (gas: 15230338)\n[PASS] test_fulfill_fitsWithinMinimumCallbackGasWith2048Participants() (gas: 736827572)\n[PASS] test_fulfill_ignoresUnknownRequestAndEmptyWords() (gas: 1423869)\n[PASS] test_fulfill_onlyCoordinator() (gas: 798760)\n[PASS] test_ownership_cannotBeRenounced() (gas: 498632)\n[PASS] test_ownership_isTwoStep() (gas: 146101)\n[PASS] test_pause_blocksEntryAndFinalizeButNotExits() (gas: 2535836)\n[PASS] test_points_cannotBeWithdrawnAsTokens() (gas: 13861712)\n[PASS] test_points_creditedProportionallyAfterFinalization() (gas: 1662947)\n[PASS] test_points_syncOnNextPurchaseAndNotForCancelledRounds() (gas: 1870711)\n[PASS] test_redeem_requiresTenRoundsAndPoints() (gas: 15383486)\n[PASS] test_redeem_stacksToCapAndBoostsOnlyChancesNotPrize() (gas: 16003141)\n[PASS] test_rescue_onlyForeignTokensAndOnlyOwner() (gas: 925564)\n[PASS] test_retry_addsRequestAndFirstFulfillmentWins() (gas: 3208161)\n[PASS] test_retry_isBoundedPerRound() (gas: 1904028)\n[PASS] test_retry_newRequestCanAlsoSettleFirst() (gas: 1882618)\n[PASS] test_retry_revertsWhenNotDrawing() (gas: 456557)\n[PASS] test_retry_usesUpdatedRequestParams() (gas: 1221980)\n[PASS] test_setToken_onlyOwner() (gas: 33564)\n[PASS] test_setToken_setsTicketPriceFromDecimals() (gas: 28070)\n[PASS] test_setToken_sixDecimals() (gas: 771748)\n[PASS] test_setToken_validation() (gas: 882964)\n[PASS] test_setVrfConfig_onlyOwnerAndOnlyBetweenRounds() (gas: 1248665)\n[PASS] test_setVrfConfig_validation() (gas: 395173)\n[PASS] test_settlement_dustGoesToLock() (gas: 2108931)\n[PASS] test_settlement_fewerThanThreeTicketsRollUnfilledPrizesToFirst() (gas: 1493102)\n[PASS] test_settlement_moreTicketsMeanMoreFirstPlaceWins() (gas: 113961923)\n[PASS] test_settlement_singleParticipantTakesAllThreePlaces() (gas: 1333325)\n[PASS] test_settlement_splitsPoolExactly() (gas: 2023855)\n[PASS] test_settlement_splittingAStakeAcrossAddressesGivesNoEdge() (gas: 2202029)\nSuite result: ok. 48 passed; 0 failed; 0 skipped; finished in 4.37s (4.67s CPU time)\n\nRan 4 test suites in 4.38s (9.24s CPU time): 75 tests passed, 0 failed, 0 skipped (75 total tests)\n","passed":true},{"durationMs":85,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"IMDPot.acceptOwnership()\",\"IMDPot.buyTickets(uint256)\",\"IMDPot.cancelRound()\",\"IMDPot.claim()\",\"IMDPot.finalizeRound()\",\"IMDPot.flushPendingFees()\",\"IMDPot.pause()\",\"IMDPot.rawFulfillRandomWords(uint256,uint256[])\",\"IMDPot.redeemMultiplier()\",\"IMDPot.refund(uint256)\",\"IMDPot.rescueForeignToken(address,address)\",\"IMDPot.retryRandomness()\",\"IMDPot.setToken(address)\",\"IMDPot.setVrfConfig(address,uint256,bytes32,uint32,uint16,bool)\",\"IMDPot.setVrfRequestParams(uint32,uint16)\",\"IMDPot.syncPoints(address)\",\"IMDPot.transferOwnership(address)\",\"IMDPot.unpause()\"],\"files\":{\".gitignore\":24,\"README.md\":175,\"docs/SECURITY.md\":97,\"foundry.toml\":22,\"frontend/IMDPot.abi.json\":1833,\"frontend/app.js\":417,\"frontend/config.js\":15,\"frontend/index.html\":135,\"frontend/styles.css\":94,\"frontend/vendor/ETHERS-LICENSE.md\":21,\"frontend/vendor/ethers-6.15.0.umd.min.js\":1,\"launch.json\":12,\"remappings.txt\":2,\"script/DeployIMDPot.s.sol\":26,\"src/IMDPot.sol\":812,\"src/interfaces/IVRFCoordinatorV2Plus.sol\":48,\"src/libraries/WeightedTree.sol\":113,\"test/IMDPot.t.sol\":935,\"test/IMDPotAttacks.t.sol\":513,\"test/IMDPotBase.t.sol\":90,\"test/IMDPotInvariant.t.sol\":183,\"test/WeightedTree.t.sol\":191,\"test/mocks/MockIMDToken.sol\":50,\"test/mocks/MockVRFCoordinator.sol\":73},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"5cd00a9fc5f93edfe7fb36078124c08dfefe1e9043ce5206524eb65f08680435","verifiedTreeHash":"abc4744d14dad40a29bd17c75205eb9716d1d4c1","verifierVersion":"0.1.0+745c1db4"},{"checks":[{"durationMs":2876,"exitCode":0,"name":"build","output":"Compiling 45 files with Solc 0.8.26\nSolc 0.8.26 finished in 2.80s\nCompiler run successful!\n","passed":true},{"durationMs":1796,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 22 tests for test/IMDPotAttacks.t.sol:IMDPotAttacksTest\n[PASS] test_cancel_activeRoundRequestsRandomnessInsteadOfRefunding() (gas: 1420487)\n[PASS] test_cancel_activeRoundWhilePausedRefundsWithoutRequesting() (gas: 721803)\n[PASS] test_cancel_requiresDelaySinceEndAndSinceLatestRequest() (gas: 1475105)\n[PASS] test_cancel_whenCoordinatorIsBrokenRefundsEveryone() (gas: 1621220)\n[PASS] test_custody_directDonationDoesNotChangeObligations() (gas: 1212967)\n[PASS] test_custody_flashLoanStyleEntryCannotExitInSameBlock() (gas: 497859)\n[PASS] test_custody_lockedTokensCanNeverLeave() (gas: 2077924)\n[PASS] test_flush_legsAreIndependent() (gas: 1604806)\n[PASS] test_randomness_cancelCannotDiscardFreshRequest() (gas: 1871834)\n[PASS] test_randomness_cannotBeInjectedByPlayersOrOwner() (gas: 1074810)\n[PASS] test_randomness_lateEntryAfterEndIsImpossible() (gas: 519731)\n[PASS] test_randomness_ownerCannotSwapCoordinatorMidRound() (gas: 1536285)\n[PASS] test_randomness_retryCannotVoidPendingFulfillment() (gas: 1713524)\n[PASS] test_randomness_staleWordAfterCancelIsIgnored() (gas: 1205970)\n[PASS] test_reentrancy_claimCannotReenterClaim() (gas: 2567395)\n[PASS] test_reentrancy_feePushCannotReenterSettlement() (gas: 2484602)\n[PASS] test_reentrancy_refundCannotReenterRefundOrBuy() (gas: 1944341)\n[PASS] test_refund_rejectsNonCancelledRounds() (gas: 1208030)\n[PASS] test_refund_restoresSpentMultiplier() (gas: 14504298)\n[PASS] test_refund_returnsPointsForBoostAboveCap() (gas: 14715436)\n[PASS] test_settlement_defersFeesWhenRecipientIsBlocked() (gas: 1887797)\n[PASS] test_settlement_survivesFrozenToken() (gas: 1497827)\nSuite result: ok. 22 passed; 0 failed; 0 skipped; finished in 57.72ms (20.46ms CPU time)\n\nRan 4 tests for test/WeightedTree.t.sol:WeightedTreeTest\n[PASS] testFuzz_findMatchesPrefixSums(uint8[16],uint8[8],uint256) (runs: 256, μ: 1846651, ~: 1860418)\n[PASS] testFuzz_subtractMatchesPrefixSums(uint8[16],uint8[8],uint256) (runs: 256, μ: 1847881, ~: 1856246)\n[PASS] test_appendFindAndRemove() (gas: 906561)\n[PASS] test_subtractTakesPartOfALeaf() (gas: 601443)\nSuite result: ok. 4 passed; 0 failed; 0 skipped; finished in 58.15ms (116.30ms CPU time)\n\nRan 1 test for test/IMDPotInvariant.t.sol:IMDPotInvariantTest\n[PASS]\nIMDPotInvariantTest invariants:\n[PASS] invariant_balanceCoversObligations\n[PASS] invariant_currentRoundIsNeverSettled\n[PASS] invariant_depositsAreFullyAccountedFor\n[PASS] invariant_lockedNeverDecreases\n[PASS] invariant_prizesSumToSeventyPercentAndSlotsAreFilledByTickets\n IMDPotInvariantTest invariants (runs: 64, calls: 2048, reverts: 0)\n\n╭---------------+----------+-------+---------+----------╮\n| Contract      | Selector | Calls | Reverts | Discards |\n+=======================================================+\n| IMDPotHandler | buy      | 208   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | cancel   | 229   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | claim    | 216   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | finalize | 194   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | flush    | 237   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | fulfill  | 206   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | redeem   | 194   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | refund   | 186   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | retry    | 178   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | warp     | 200   | 0       | 0        |\n╰---------------+----------+-------+---------+----------╯\n\nLogs:\n  Bound result 62497\n  Bound result 500\n  Bound result 1\n  Bound result 1\n  Bound result 1\n  Bound result 4078\n  Bound result 1\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 253.74ms (251.49ms CPU time)\n\nRan 48 tests for test/IMDPot.t.sol:IMDPotTest\n[PASS] testFuzz_settlement_drawsThreeTicketsWithoutReplacement(uint256) (runs: 256, μ: 2245553, ~: 2240338)\n[PASS] test_buy_closesExactlyAtEndTime() (gas: 725689)\n[PASS] test_buy_firstTicketStartsTimer() (gas: 592231)\n[PASS] test_buy_laterTicketsDoNotExtendTimer() (gas: 952284)\n[PASS] test_buy_rejectedWhileDrawing() (gas: 772176)\n[PASS] test_buy_rejectsFeeOnTransferShortfall() (gas: 170582)\n[PASS] test_buy_requiresAllowanceAndBalance() (gas: 177732)\n[PASS] test_buy_revertsUntilConfigured() (gas: 746178)\n[PASS] test_buy_validatesCount() (gas: 88180)\n[PASS] test_claim_accumulatesAcrossRounds() (gas: 2264400)\n[PASS] test_claim_paysWinnersOnceAndOnlyWinners() (gas: 2042271)\n[PASS] test_constructor_rejectsZeroOwner() (gas: 4104)\n[PASS] test_constructor_runsOnEmptyChainAndFitsSizeLimits() (gas: 9047609)\n[PASS] test_constructor_setsLaunchOwnerNotDeployer() (gas: 6769415)\n[PASS] test_deployScript_deploysWithExplicitOwner() (gas: 6767322)\n[PASS] test_finalize_anyoneCanCallAndRequestsVrfWithConfiguredParams() (gas: 794926)\n[PASS] test_finalize_revertsWhenCoordinatorRejects() (gas: 580096)\n[PASS] test_finalize_revertsWithoutRoundOrBeforeEnd() (gas: 501742)\n[PASS] test_fulfill_fitsWithinCallbackGasForManyParticipants() (gas: 15230338)\n[PASS] test_fulfill_fitsWithinMinimumCallbackGasWith2048Participants() (gas: 736827572)\n[PASS] test_fulfill_ignoresUnknownRequestAndEmptyWords() (gas: 1423869)\n[PASS] test_fulfill_onlyCoordinator() (gas: 798760)\n[PASS] test_ownership_cannotBeRenounced() (gas: 498632)\n[PASS] test_ownership_isTwoStep() (gas: 146101)\n[PASS] test_pause_blocksEntryAndFinalizeButNotExits() (gas: 2535836)\n[PASS] test_points_cannotBeWithdrawnAsTokens() (gas: 13861712)\n[PASS] test_points_creditedProportionallyAfterFinalization() (gas: 1662947)\n[PASS] test_points_syncOnNextPurchaseAndNotForCancelledRounds() (gas: 1870711)\n[PASS] test_redeem_requiresTenRoundsAndPoints() (gas: 15383486)\n[PASS] test_redeem_stacksToCapAndBoostsOnlyChancesNotPrize() (gas: 16003141)\n[PASS] test_rescue_onlyForeignTokensAndOnlyOwner() (gas: 925564)\n[PASS] test_retry_addsRequestAndFirstFulfillmentWins() (gas: 3208161)\n[PASS] test_retry_isBoundedPerRound() (gas: 1904028)\n[PASS] test_retry_newRequestCanAlsoSettleFirst() (gas: 1882618)\n[PASS] test_retry_revertsWhenNotDrawing() (gas: 456557)\n[PASS] test_retry_usesUpdatedRequestParams() (gas: 1221980)\n[PASS] test_setToken_onlyOwner() (gas: 33564)\n[PASS] test_setToken_setsTicketPriceFromDecimals() (gas: 28070)\n[PASS] test_setToken_sixDecimals() (gas: 771748)\n[PASS] test_setToken_validation() (gas: 882964)\n[PASS] test_setVrfConfig_onlyOwnerAndOnlyBetweenRounds() (gas: 1248665)\n[PASS] test_setVrfConfig_validation() (gas: 395173)\n[PASS] test_settlement_dustGoesToLock() (gas: 2108931)\n[PASS] test_settlement_fewerThanThreeTicketsRollUnfilledPrizesToFirst() (gas: 1493102)\n[PASS] test_settlement_moreTicketsMeanMoreFirstPlaceWins() (gas: 113961923)\n[PASS] test_settlement_singleParticipantTakesAllThreePlaces() (gas: 1333325)\n[PASS] test_settlement_splitsPoolExactly() (gas: 2023855)\n[PASS] test_settlement_splittingAStakeAcrossAddressesGivesNoEdge() (gas: 2202029)\nSuite result: ok. 48 passed; 0 failed; 0 skipped; finished in 1.71s (1.88s CPU time)\n\nRan 4 test suites in 1.71s (2.08s CPU time): 75 tests passed, 0 failed, 0 skipped (75 total tests)\n","passed":true},{"durationMs":49,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"IMDPot.acceptOwnership()\",\"IMDPot.buyTickets(uint256)\",\"IMDPot.cancelRound()\",\"IMDPot.claim()\",\"IMDPot.finalizeRound()\",\"IMDPot.flushPendingFees()\",\"IMDPot.pause()\",\"IMDPot.rawFulfillRandomWords(uint256,uint256[])\",\"IMDPot.redeemMultiplier()\",\"IMDPot.refund(uint256)\",\"IMDPot.rescueForeignToken(address,address)\",\"IMDPot.retryRandomness()\",\"IMDPot.setToken(address)\",\"IMDPot.setVrfConfig(address,uint256,bytes32,uint32,uint16,bool)\",\"IMDPot.setVrfRequestParams(uint32,uint16)\",\"IMDPot.syncPoints(address)\",\"IMDPot.transferOwnership(address)\",\"IMDPot.unpause()\"],\"files\":{\".gitignore\":24,\"README.md\":175,\"docs/SECURITY.md\":97,\"foundry.toml\":22,\"frontend/IMDPot.abi.json\":1833,\"frontend/app.js\":417,\"frontend/config.js\":15,\"frontend/index.html\":135,\"frontend/styles.css\":94,\"frontend/vendor/ETHERS-LICENSE.md\":21,\"frontend/vendor/ethers-6.15.0.umd.min.js\":1,\"remappings.txt\":2,\"script/DeployIMDPot.s.sol\":26,\"src/IMDPot.sol\":812,\"src/interfaces/IVRFCoordinatorV2Plus.sol\":48,\"src/libraries/WeightedTree.sol\":113,\"test/IMDPot.t.sol\":935,\"test/IMDPotAttacks.t.sol\":513,\"test/IMDPotBase.t.sol\":90,\"test/IMDPotInvariant.t.sol\":183,\"test/WeightedTree.t.sol\":191,\"test/mocks/MockIMDToken.sol\":50,\"test/mocks/MockVRFCoordinator.sol\":73},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true},{"durationMs":1020,"exitCode":0,"name":"slither","output":"[medium/medium] divide-before-multiply at src/IMDPot.sol:503: IMDPot.refund(uint256) (src/IMDPot.sol#503-532) performs a multiplication on the result of a division:\n[medium/medium] reentrancy-no-eth at src/IMDPot.sol:560: Reentrancy in IMDPot.flushPendingFees() (src/IMDPot.sol#560-572):\n[medium/medium] uninitialized-local at src/IMDPot.sol:736: IMDPot._settle(uint256,IMDPot.Round,uint256).prizeTotal (src/IMDPot.sol#736) is a local variable never initialized\n[medium/medium] uninitialized-local at src/IMDPot.sol:657: IMDPot.totalObligations().live (src/IMDPot.sol#657) is a local variable never initialized\n[low/medium] reentrancy-benign at src/IMDPot.sol:720: Reentrancy in IMDPot._settle(uint256,IMDPot.Round,uint256) (src/IMDPot.sol#720-758):\n[low/medium] reentrancy-benign at src/IMDPot.sol:720: Reentrancy in IMDPot._settle(uint256,IMDPot.Round,uint256) (src/IMDPot.sol#720-758):\n[low/medium] reentrancy-benign at src/IMDPot.sol:697: Reentrancy in IMDPot._tryRequestRandomness(uint256,IMDPot.Round) (src/IMDPot.sol#697-706):\n[low/medium] reentrancy-benign at src/IMDPot.sol:688: Reentrancy in IMDPot._requestRandomness(uint256,IMDPot.Round) (src/IMDPot.sol#688-693):\n[low/medium] timestamp at src/IMDPot.sol:470: IMDPot.cancelRound() (src/IMDPot.sol#470-486) uses timestamp for comparisons\n[low/medium] timestamp at src/IMDPot.sol:644: IMDPot.timeRemaining() (src/IMDPot.sol#644-648) uses timestamp for comparisons\n[low/medium] timestamp at src/IMDPot.sol:455: IMDPot.retryRandomness() (src/IMDPot.sol#455-462) uses timestamp for comparisons\n[low/medium] timestamp at src/IMDPot.sol:392: IMDPot.buyTickets(uint256) (src/IMDPot.sol#392-438) uses timestamp for comparisons\n[low/medium] timestamp at src/IMDPot.sol:443: IMDPot.finalizeRound() (src/IMDPot.sol#443-450) uses timestamp for comparisons","passed":true},{"durationMs":300,"exitCode":0,"name":"aderyn","output":"[high] reentrancy-state-change at src/IMDPot.sol:301: Reentrancy: State change after external call (3 places)\n[low] centralization-risk at src/IMDPot.sol:51: Centralization Risk (7 places)\n[low] costly-loop at src/IMDPot.sol:737: Costly operations inside loop (4 places)\n[low] internal-function-used-once at src/libraries/WeightedTree.sol:55: Internal Function Used Only Once\n[low] large-numeric-literal at src/IMDPot.sol:118: Large Numeric Literal (5 places)\n[low] literal-instead-of-constant at src/IMDPot.sol:304: Literal Instead of Constant (5 places)\n[low] require-revert-in-loop at src/IMDPot.sol:767: Loop Contains `require`/`revert`\n[low] unsafe-erc20-operation at src/IMDPot.sol:807: Unsafe ERC20 Operation\n[low] unused-public-function at src/IMDPot.sol:656: Public Function Not Used Internally","passed":true},{"durationMs":1540,"exitCode":0,"name":"proof 43f7c71f1fb7","output":"Compiling 39 files with Solc 0.8.26\nSolc 0.8.26 finished in 1.04s\nCompiler run successful!\n\nRan 1 test for test/imd-proof-04c0ed41/Proof_43f7c71f1fb7.t.sol:RetryRerollTest\n[PASS] test_pendingFulfillmentCannotBeVoidedByRetry() (gas: 1473413)\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.09ms (343.62µs CPU time)\n\nRan 1 test suite in 2.59ms (1.09ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)\n","passed":true},{"durationMs":1227,"exitCode":0,"name":"proof b15cda2ed2ac","output":"2026-10-18T10:55:03.359543Z ERROR foundry_compilers_artifacts_solc::sources: error=\"/tmp/imd-verify-hgpvgr/repo/test/imd-proof-04c0ed41/Proof_43f7c71f1fb7.t.sol\": No such file or directory (os error 2)\nCompiling 1 files with Solc 0.8.26\nSolc 0.8.26 finished in 719.00ms\nCompiler run successful!\n\nRan 1 test for test/imd-proof-46d79b03/Proof_b15cda2ed2ac.t.sol:CancelAbortProofTest\n[PASS] test_observedWordCannotBeDiscardedByCancel() (gas: 784664)\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 888.68µs (246.93µs CPU time)\n\nRan 1 test suite in 2.58ms (888.68µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)\n","passed":true},{"durationMs":2827,"exitCode":0,"name":"proof f270ff4d64bf","output":"2026-10-18T10:55:04.570438Z ERROR foundry_compilers_artifacts_solc::sources: error=\"/tmp/imd-verify-hgpvgr/repo/test/imd-proof-46d79b03/Proof_b15cda2ed2ac.t.sol\": No such file or directory (os error 2)\nCompiling 1 files with Solc 0.8.26\nSolc 0.8.26 finished in 771.18ms\nCompiler run successful!\n\nRan 1 test for test/imd-proof-c3127d69/Proof_f270ff4d64bf.t.sol:GasFloorProofTest\n[PASS] test_settlementFitsInMinimumCallbackGasWith2048Participants() (gas: 709291496)\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.58s (1.58s CPU time)\n\nRan 1 test suite in 1.58s (1.58s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)\n","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"c67d43b9df79339edf57fd11a575faf1613e1b7264543ebf3ee82962242981e3","verifiedTreeHash":"addaeb1b330b7bdc011ec50b1110ba27bccc889e","verifierVersion":"0.1.0+745c1db4"},{"checks":[{"durationMs":2377,"exitCode":0,"name":"build","output":"Compiling 45 files with Solc 0.8.26\nSolc 0.8.26 finished in 2.30s\nCompiler run successful!\n","passed":true},{"durationMs":291,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 16 tests for test/IMDPotAttacks.t.sol:IMDPotAttacksTest\n[PASS] test_cancel_requiresDelay() (gas: 603861)\n[PASS] test_cancel_whenCoordinatorIsBrokenRefundsEveryone() (gas: 1603557)\n[PASS] test_custody_directDonationDoesNotChangeObligations() (gas: 1188954)\n[PASS] test_custody_flashLoanStyleEntryCannotExitInSameBlock() (gas: 497904)\n[PASS] test_custody_lockedTokensCanNeverLeave() (gas: 2163527)\n[PASS] test_randomness_cannotBeInjectedByPlayersOrOwner() (gas: 1051451)\n[PASS] test_randomness_lateEntryAfterEndIsImpossible() (gas: 519584)\n[PASS] test_randomness_ownerCannotSwapCoordinatorMidRound() (gas: 1512756)\n[PASS] test_randomness_staleWordAfterCancelIsIgnored() (gas: 1181735)\n[PASS] test_reentrancy_claimCannotReenterClaim() (gas: 2441416)\n[PASS] test_reentrancy_feePushCannotReenterSettlement() (gas: 2358614)\n[PASS] test_reentrancy_refundCannotReenterRefundOrBuy() (gas: 1926633)\n[PASS] test_refund_rejectsNonCancelledRounds() (gas: 1184172)\n[PASS] test_refund_restoresSpentMultiplier() (gas: 13683460)\n[PASS] test_settlement_defersFeesWhenRecipientIsBlocked() (gas: 1751060)\n[PASS] test_settlement_survivesFrozenToken() (gas: 1370360)\nSuite result: ok. 16 passed; 0 failed; 0 skipped; finished in 8.53ms (9.70ms CPU time)\n\nRan 2 tests for test/WeightedTree.t.sol:WeightedTreeTest\n[PASS] testFuzz_findMatchesPrefixSums(uint8[16],uint8[8],uint256) (runs: 256, μ: 1843501, ~: 1855835)\n[PASS] test_appendFindAndRemove() (gas: 904366)\nSuite result: ok. 2 passed; 0 failed; 0 skipped; finished in 34.92ms (35.13ms CPU time)\n\nRan 42 tests for test/IMDPot.t.sol:IMDPotTest\n[PASS] testFuzz_settlement_winnersAreDistinctAndWeightsZeroed(uint256) (runs: 256, μ: 2126508, ~: 2127653)\n[PASS] test_buy_closesExactlyAtEndTime() (gas: 725454)\n[PASS] test_buy_firstTicketStartsTimer() (gas: 591405)\n[PASS] test_buy_laterTicketsDoNotExtendTimer() (gas: 951535)\n[PASS] test_buy_rejectedWhileDrawing() (gas: 748839)\n[PASS] test_buy_rejectsFeeOnTransferShortfall() (gas: 170604)\n[PASS] test_buy_requiresAllowanceAndBalance() (gas: 177663)\n[PASS] test_buy_revertsUntilConfigured() (gas: 745781)\n[PASS] test_buy_validatesCount() (gas: 88246)\n[PASS] test_claim_accumulatesAcrossRounds() (gas: 2216499)\n[PASS] test_claim_paysWinnersOnceAndOnlyWinners() (gas: 2013607)\n[PASS] test_constructor_rejectsZeroOwner() (gas: 4149)\n[PASS] test_constructor_runsOnEmptyChainAndFitsSizeLimits() (gas: 8335989)\n[PASS] test_constructor_setsLaunchOwnerNotDeployer() (gas: 6299017)\n[PASS] test_deployScript_deploysWithExplicitOwner() (gas: 6296879)\n[PASS] test_finalize_anyoneCanCallAndRequestsVrfWithConfiguredParams() (gas: 771544)\n[PASS] test_finalize_revertsWhenCoordinatorRejects() (gas: 579727)\n[PASS] test_finalize_revertsWithoutRoundOrBeforeEnd() (gas: 501595)\n[PASS] test_fulfill_fitsWithinCallbackGasForManyParticipants() (gas: 15163875)\n[PASS] test_fulfill_ignoresUnknownRequestAndEmptyWords() (gas: 1404884)\n[PASS] test_fulfill_onlyCoordinator() (gas: 775358)\n[PASS] test_ownership_isTwoStep() (gas: 146145)\n[PASS] test_pause_blocksEntryAndFinalizeButNotExits() (gas: 2509660)\n[PASS] test_points_cannotBeWithdrawnAsTokens() (gas: 13058418)\n[PASS] test_points_creditedProportionallyAfterFinalization() (gas: 1602493)\n[PASS] test_points_syncOnNextPurchaseAndNotForCancelledRounds() (gas: 1727147)\n[PASS] test_redeem_requiresTenRoundsAndPoints() (gas: 14464365)\n[PASS] test_redeem_stacksToCapAndBoostsOnlyChancesNotPrize() (gas: 15116801)\n[PASS] test_rescue_onlyForeignTokensAndOnlyOwner() (gas: 925501)\n[PASS] test_retry_reRequestsAfterDelayAndIgnoresOldFulfillment() (gas: 1873626)\n[PASS] test_retry_revertsWhenNotDrawing() (gas: 456401)\n[PASS] test_setToken_onlyOwner() (gas: 33542)\n[PASS] test_setToken_setsTicketPriceFromDecimals() (gas: 28026)\n[PASS] test_setToken_sixDecimals() (gas: 771659)\n[PASS] test_setToken_validation() (gas: 882607)\n[PASS] test_setVrfConfig_onlyOwnerAndOnlyBetweenRounds() (gas: 1224363)\n[PASS] test_setVrfConfig_validation() (gas: 346543)\n[PASS] test_settlement_dustGoesToLock() (gas: 1991151)\n[PASS] test_settlement_moreTicketsMeanMoreFirstPlaceWins() (gas: 112814517)\n[PASS] test_settlement_singleParticipantTakesWholePrizeShare() (gas: 1190968)\n[PASS] test_settlement_splitsPoolExactly() (gas: 1984406)\n[PASS] test_settlement_twoParticipantsRollThirdPrizeToFirst() (gas: 1455722)\nSuite result: ok. 42 passed; 0 failed; 0 skipped; finished in 39.15ms (139.18ms CPU time)\n\nRan 1 test for test/IMDPotInvariant.t.sol:IMDPotInvariantTest\n[PASS]\nIMDPotInvariantTest invariants:\n[PASS] invariant_balanceCoversObligations\n[PASS] invariant_currentRoundIsNeverSettled\n[PASS] invariant_depositsAreFullyAccountedFor\n[PASS] invariant_lockedNeverDecreases\n[PASS] invariant_winnersAreDistinctAndPrizesSumToSeventyPercent\n IMDPotInvariantTest invariants (runs: 64, calls: 2048, reverts: 0)\n\n╭---------------+----------+-------+---------+----------╮\n| Contract      | Selector | Calls | Reverts | Discards |\n+=======================================================+\n| IMDPotHandler | buy      | 208   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | cancel   | 229   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | claim    | 216   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | finalize | 194   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | flush    | 237   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | fulfill  | 206   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | redeem   | 194   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | refund   | 186   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | retry    | 178   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | warp     | 200   | 0       | 0        |\n╰---------------+----------+-------+---------+----------╯\n\nLogs:\n  Bound result 6058\n  Bound result 500\n  Bound result 1\n  Bound result 1\n  Bound result 1\n  Bound result 32\n  Bound result 1\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 200.63ms (198.95ms CPU time)\n\nRan 4 test suites in 202.17ms (283.23ms CPU time): 61 tests passed, 0 failed, 0 skipped (61 total tests)\n","passed":true},{"durationMs":45,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"IMDPot.acceptOwnership()\",\"IMDPot.buyTickets(uint256)\",\"IMDPot.cancelRound()\",\"IMDPot.claim()\",\"IMDPot.finalizeRound()\",\"IMDPot.flushPendingFees()\",\"IMDPot.pause()\",\"IMDPot.rawFulfillRandomWords(uint256,uint256[])\",\"IMDPot.redeemMultiplier()\",\"IMDPot.refund(uint256)\",\"IMDPot.renounceOwnership()\",\"IMDPot.rescueForeignToken(address,address)\",\"IMDPot.retryRandomness()\",\"IMDPot.setToken(address)\",\"IMDPot.setVrfConfig(address,uint256,bytes32,uint32,uint16,bool)\",\"IMDPot.syncPoints(address)\",\"IMDPot.transferOwnership(address)\",\"IMDPot.unpause()\"],\"files\":{\".gitignore\":24,\"README.md\":148,\"docs/SECURITY.md\":79,\"foundry.toml\":22,\"frontend/IMDPot.abi.json\":1708,\"frontend/app.js\":378,\"frontend/config.js\":15,\"frontend/index.html\":134,\"frontend/styles.css\":94,\"frontend/vendor/ETHERS-LICENSE.md\":21,\"frontend/vendor/ethers-6.15.0.umd.min.js\":1,\"remappings.txt\":2,\"script/DeployIMDPot.s.sol\":26,\"src/IMDPot.sol\":718,\"src/interfaces/IVRFCoordinatorV2Plus.sol\":48,\"src/libraries/WeightedTree.sol\":104,\"test/IMDPot.t.sol\":767,\"test/IMDPotAttacks.t.sol\":345,\"test/IMDPotBase.t.sol\":90,\"test/IMDPotInvariant.t.sol\":169,\"test/WeightedTree.t.sol\":130,\"test/mocks/MockIMDToken.sol\":50,\"test/mocks/MockVRFCoordinator.sol\":73},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true},{"durationMs":956,"exitCode":0,"name":"slither","output":"[medium/medium] reentrancy-no-eth at src/IMDPot.sol:598: Reentrancy in IMDPot._requestRandomness(uint256,IMDPot.Round) (src/IMDPot.sol#598-617):\n[medium/medium] uninitialized-local at src/IMDPot.sol:637: IMDPot._settle(uint256,IMDPot.Round,uint256).prizeTotal (src/IMDPot.sol#637) is a local variable never initialized\n[medium/medium] uninitialized-local at src/IMDPot.sol:587: IMDPot.totalObligations().live (src/IMDPot.sol#587) is a local variable never initialized\n[low/medium] reentrancy-benign at src/IMDPot.sol:621: Reentrancy in IMDPot._settle(uint256,IMDPot.Round,uint256) (src/IMDPot.sol#621-673):\n[low/medium] timestamp at src/IMDPot.sol:408: IMDPot.retryRandomness() (src/IMDPot.sol#408-414) uses timestamp for comparisons\n[low/medium] timestamp at src/IMDPot.sol:346: IMDPot.buyTickets(uint256) (src/IMDPot.sol#346-392) uses timestamp for comparisons\n[low/medium] timestamp at src/IMDPot.sol:574: IMDPot.timeRemaining() (src/IMDPot.sol#574-578) uses timestamp for comparisons\n[low/medium] timestamp at src/IMDPot.sol:418: IMDPot.cancelRound() (src/IMDPot.sol#418-427) uses timestamp for comparisons\n[low/medium] timestamp at src/IMDPot.sol:397: IMDPot.finalizeRound() (src/IMDPot.sol#397-404) uses timestamp for comparisons","passed":true},{"durationMs":346,"exitCode":0,"name":"aderyn","output":"[high] reentrancy-state-change at src/IMDPot.sol:275: Reentrancy: State change after external call (3 places)\n[low] centralization-risk at src/IMDPot.sol:41: Centralization Risk (6 places)\n[low] costly-loop at src/IMDPot.sol:638: Costly operations inside loop (4 places)\n[low] large-numeric-literal at src/IMDPot.sol:106: Large Numeric Literal (5 places)\n[low] literal-instead-of-constant at src/IMDPot.sol:278: Literal Instead of Constant (5 places)\n[low] require-revert-in-loop at src/IMDPot.sol:684: Loop Contains `require`/`revert`\n[low] unsafe-erc20-operation at src/IMDPot.sol:713: Unsafe ERC20 Operation\n[low] unused-public-function at src/IMDPot.sol:586: Public Function Not Used Internally","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"d2529fcf511c1c0fd97bdf3a212c1a8332730200dfec0adbdba7d4771fe264c0","verifiedTreeHash":"915531d8575ce8860efa31f0c8a6dc94a7ef17f1","verifierVersion":"0.1.0+745c1db4"},{"checks":[{"durationMs":3127,"exitCode":0,"name":"build","output":"Compiling 45 files with Solc 0.8.26\nSolc 0.8.26 finished in 3.00s\nCompiler run successful!\n","passed":true},{"durationMs":361,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 16 tests for test/IMDPotAttacks.t.sol:IMDPotAttacksTest\n[PASS] test_cancel_requiresDelay() (gas: 603861)\n[PASS] test_cancel_whenCoordinatorIsBrokenRefundsEveryone() (gas: 1603557)\n[PASS] test_custody_directDonationDoesNotChangeObligations() (gas: 1188954)\n[PASS] test_custody_flashLoanStyleEntryCannotExitInSameBlock() (gas: 497904)\n[PASS] test_custody_lockedTokensCanNeverLeave() (gas: 2163527)\n[PASS] test_randomness_cannotBeInjectedByPlayersOrOwner() (gas: 1051451)\n[PASS] test_randomness_lateEntryAfterEndIsImpossible() (gas: 519584)\n[PASS] test_randomness_ownerCannotSwapCoordinatorMidRound() (gas: 1512756)\n[PASS] test_randomness_staleWordAfterCancelIsIgnored() (gas: 1181735)\n[PASS] test_reentrancy_claimCannotReenterClaim() (gas: 2441416)\n[PASS] test_reentrancy_feePushCannotReenterSettlement() (gas: 2358614)\n[PASS] test_reentrancy_refundCannotReenterRefundOrBuy() (gas: 1926633)\n[PASS] test_refund_rejectsNonCancelledRounds() (gas: 1184172)\n[PASS] test_refund_restoresSpentMultiplier() (gas: 13683460)\n[PASS] test_settlement_defersFeesWhenRecipientIsBlocked() (gas: 1751060)\n[PASS] test_settlement_survivesFrozenToken() (gas: 1370360)\nSuite result: ok. 16 passed; 0 failed; 0 skipped; finished in 62.31ms (17.80ms CPU time)\n\nRan 42 tests for test/IMDPot.t.sol:IMDPotTest\n[PASS] testFuzz_settlement_winnersAreDistinctAndWeightsZeroed(uint256) (runs: 256, μ: 2126508, ~: 2127653)\n[PASS] test_buy_closesExactlyAtEndTime() (gas: 725454)\n[PASS] test_buy_firstTicketStartsTimer() (gas: 591405)\n[PASS] test_buy_laterTicketsDoNotExtendTimer() (gas: 951535)\n[PASS] test_buy_rejectedWhileDrawing() (gas: 748839)\n[PASS] test_buy_rejectsFeeOnTransferShortfall() (gas: 170604)\n[PASS] test_buy_requiresAllowanceAndBalance() (gas: 177663)\n[PASS] test_buy_revertsUntilConfigured() (gas: 745781)\n[PASS] test_buy_validatesCount() (gas: 88246)\n[PASS] test_claim_accumulatesAcrossRounds() (gas: 2216499)\n[PASS] test_claim_paysWinnersOnceAndOnlyWinners() (gas: 2013607)\n[PASS] test_constructor_rejectsZeroOwner() (gas: 4149)\n[PASS] test_constructor_runsOnEmptyChainAndFitsSizeLimits() (gas: 8335989)\n[PASS] test_constructor_setsLaunchOwnerNotDeployer() (gas: 6299017)\n[PASS] test_deployScript_deploysWithExplicitOwner() (gas: 6296879)\n[PASS] test_finalize_anyoneCanCallAndRequestsVrfWithConfiguredParams() (gas: 771544)\n[PASS] test_finalize_revertsWhenCoordinatorRejects() (gas: 579727)\n[PASS] test_finalize_revertsWithoutRoundOrBeforeEnd() (gas: 501595)\n[PASS] test_fulfill_fitsWithinCallbackGasForManyParticipants() (gas: 15163875)\n[PASS] test_fulfill_ignoresUnknownRequestAndEmptyWords() (gas: 1404884)\n[PASS] test_fulfill_onlyCoordinator() (gas: 775358)\n[PASS] test_ownership_isTwoStep() (gas: 146145)\n[PASS] test_pause_blocksEntryAndFinalizeButNotExits() (gas: 2509660)\n[PASS] test_points_cannotBeWithdrawnAsTokens() (gas: 13058418)\n[PASS] test_points_creditedProportionallyAfterFinalization() (gas: 1602493)\n[PASS] test_points_syncOnNextPurchaseAndNotForCancelledRounds() (gas: 1727147)\n[PASS] test_redeem_requiresTenRoundsAndPoints() (gas: 14464365)\n[PASS] test_redeem_stacksToCapAndBoostsOnlyChancesNotPrize() (gas: 15116801)\n[PASS] test_rescue_onlyForeignTokensAndOnlyOwner() (gas: 925501)\n[PASS] test_retry_reRequestsAfterDelayAndIgnoresOldFulfillment() (gas: 1873626)\n[PASS] test_retry_revertsWhenNotDrawing() (gas: 456401)\n[PASS] test_setToken_onlyOwner() (gas: 33542)\n[PASS] test_setToken_setsTicketPriceFromDecimals() (gas: 28026)\n[PASS] test_setToken_sixDecimals() (gas: 771659)\n[PASS] test_setToken_validation() (gas: 882607)\n[PASS] test_setVrfConfig_onlyOwnerAndOnlyBetweenRounds() (gas: 1224363)\n[PASS] test_setVrfConfig_validation() (gas: 346543)\n[PASS] test_settlement_dustGoesToLock() (gas: 1991151)\n[PASS] test_settlement_moreTicketsMeanMoreFirstPlaceWins() (gas: 112814517)\n[PASS] test_settlement_singleParticipantTakesWholePrizeShare() (gas: 1190968)\n[PASS] test_settlement_splitsPoolExactly() (gas: 1984406)\n[PASS] test_settlement_twoParticipantsRollThirdPrizeToFirst() (gas: 1455722)\nSuite result: ok. 42 passed; 0 failed; 0 skipped; finished in 62.41ms (178.71ms CPU time)\n\nRan 2 tests for test/WeightedTree.t.sol:WeightedTreeTest\n[PASS] testFuzz_findMatchesPrefixSums(uint8[16],uint8[8],uint256) (runs: 256, μ: 1843501, ~: 1855835)\n[PASS] test_appendFindAndRemove() (gas: 904366)\nSuite result: ok. 2 passed; 0 failed; 0 skipped; finished in 64.69ms (64.91ms CPU time)\n\nRan 1 test for test/IMDPotInvariant.t.sol:IMDPotInvariantTest\n[PASS]\nIMDPotInvariantTest invariants:\n[PASS] invariant_balanceCoversObligations\n[PASS] invariant_currentRoundIsNeverSettled\n[PASS] invariant_depositsAreFullyAccountedFor\n[PASS] invariant_lockedNeverDecreases\n[PASS] invariant_winnersAreDistinctAndPrizesSumToSeventyPercent\n IMDPotInvariantTest invariants (runs: 64, calls: 2048, reverts: 0)\n\n╭---------------+----------+-------+---------+----------╮\n| Contract      | Selector | Calls | Reverts | Discards |\n+=======================================================+\n| IMDPotHandler | buy      | 208   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | cancel   | 229   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | claim    | 216   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | finalize | 194   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | flush    | 237   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | fulfill  | 206   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | redeem   | 194   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | refund   | 186   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | retry    | 178   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | warp     | 200   | 0       | 0        |\n╰---------------+----------+-------+---------+----------╯\n\nLogs:\n  Bound result 6058\n  Bound result 500\n  Bound result 1\n  Bound result 1\n  Bound result 1\n  Bound result 32\n  Bound result 1\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 248.33ms (245.89ms CPU time)\n\nRan 4 test suites in 251.24ms (437.74ms CPU time): 61 tests passed, 0 failed, 0 skipped (61 total tests)\n","passed":true},{"durationMs":69,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"IMDPot.acceptOwnership()\",\"IMDPot.buyTickets(uint256)\",\"IMDPot.cancelRound()\",\"IMDPot.claim()\",\"IMDPot.finalizeRound()\",\"IMDPot.flushPendingFees()\",\"IMDPot.pause()\",\"IMDPot.rawFulfillRandomWords(uint256,uint256[])\",\"IMDPot.redeemMultiplier()\",\"IMDPot.refund(uint256)\",\"IMDPot.renounceOwnership()\",\"IMDPot.rescueForeignToken(address,address)\",\"IMDPot.retryRandomness()\",\"IMDPot.setToken(address)\",\"IMDPot.setVrfConfig(address,uint256,bytes32,uint32,uint16,bool)\",\"IMDPot.syncPoints(address)\",\"IMDPot.transferOwnership(address)\",\"IMDPot.unpause()\"],\"files\":{\".gitignore\":24,\"README.md\":148,\"docs/SECURITY.md\":79,\"foundry.toml\":22,\"frontend/IMDPot.abi.json\":1708,\"frontend/app.js\":378,\"frontend/config.js\":15,\"frontend/index.html\":134,\"frontend/styles.css\":94,\"frontend/vendor/ETHERS-LICENSE.md\":21,\"frontend/vendor/ethers-6.15.0.umd.min.js\":1,\"launch.json\":12,\"remappings.txt\":2,\"script/DeployIMDPot.s.sol\":26,\"src/IMDPot.sol\":718,\"src/interfaces/IVRFCoordinatorV2Plus.sol\":48,\"src/libraries/WeightedTree.sol\":104,\"test/IMDPot.t.sol\":767,\"test/IMDPotAttacks.t.sol\":345,\"test/IMDPotBase.t.sol\":90,\"test/IMDPotInvariant.t.sol\":169,\"test/WeightedTree.t.sol\":130,\"test/mocks/MockIMDToken.sol\":50,\"test/mocks/MockVRFCoordinator.sol\":73},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"dbea99f81f520ef2330ba426daac82f9842de4625181ef6a01bfe660d14a43df","verifiedTreeHash":"5486a960b3e31d7f81ed1b87b16553c2b1647df4","verifierVersion":"0.1.0+745c1db4"},{"checks":[{"durationMs":3634,"exitCode":0,"name":"build","output":"Compiling 47 files with Solc 0.8.26\nSolc 0.8.26 finished in 3.57s\nCompiler run successful!\n","passed":true},{"durationMs":12530,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 4 tests for test/WeightedTree.t.sol:WeightedTreeTest\n[PASS] testFuzz_findMatchesPrefixSums(uint8[16],uint8[8],uint256) (runs: 256, μ: 1846421, ~: 1860236)\n[PASS] testFuzz_subtractMatchesPrefixSums(uint8[16],uint8[8],uint256) (runs: 256, μ: 1849041, ~: 1857834)\n[PASS] test_appendFindAndRemove() (gas: 906561)\n[PASS] test_subtractTakesPartOfALeaf() (gas: 601443)\nSuite result: ok. 4 passed; 0 failed; 0 skipped; finished in 65.81ms (129.57ms CPU time)\n\nRan 22 tests for test/IMDPotAttacks.t.sol:IMDPotAttacksTest\n[PASS] test_cancel_activeRoundRequestsRandomnessInsteadOfRefunding() (gas: 1420487)\n[PASS] test_cancel_activeRoundWhilePausedRefundsWithoutRequesting() (gas: 721803)\n[PASS] test_cancel_requiresDelaySinceEndAndSinceLatestRequest() (gas: 1475105)\n[PASS] test_cancel_whenCoordinatorIsBrokenRefundsEveryone() (gas: 1621220)\n[PASS] test_custody_directDonationDoesNotChangeObligations() (gas: 1212967)\n[PASS] test_custody_flashLoanStyleEntryCannotExitInSameBlock() (gas: 497859)\n[PASS] test_custody_lockedTokensCanNeverLeave() (gas: 2077924)\n[PASS] test_flush_legsAreIndependent() (gas: 1604806)\n[PASS] test_randomness_cancelCannotDiscardFreshRequest() (gas: 1871834)\n[PASS] test_randomness_cannotBeInjectedByPlayersOrOwner() (gas: 1074810)\n[PASS] test_randomness_lateEntryAfterEndIsImpossible() (gas: 519731)\n[PASS] test_randomness_ownerCannotSwapCoordinatorMidRound() (gas: 1536285)\n[PASS] test_randomness_retryCannotVoidPendingFulfillment() (gas: 1713524)\n[PASS] test_randomness_staleWordAfterCancelIsIgnored() (gas: 1205970)\n[PASS] test_reentrancy_claimCannotReenterClaim() (gas: 2567395)\n[PASS] test_reentrancy_feePushCannotReenterSettlement() (gas: 2484602)\n[PASS] test_reentrancy_refundCannotReenterRefundOrBuy() (gas: 1944341)\n[PASS] test_refund_rejectsNonCancelledRounds() (gas: 1208030)\n[PASS] test_refund_restoresSpentMultiplier() (gas: 14504298)\n[PASS] test_refund_returnsPointsForBoostAboveCap() (gas: 14715436)\n[PASS] test_settlement_defersFeesWhenRecipientIsBlocked() (gas: 1887797)\n[PASS] test_settlement_survivesFrozenToken() (gas: 1497827)\nSuite result: ok. 22 passed; 0 failed; 0 skipped; finished in 66.87ms (23.62ms CPU time)\n\nRan 39 tests for test/IMDPotEdges.t.sol:IMDPotEdgesTest\n[PASS] testFuzz_buy_countBounds(uint256) (runs: 512, μ: 383792, ~: 521438)\nLogs:\n  Bound result 1791633\n\n[PASS] testFuzz_settlement_soloPlayerAlwaysWinsEverything(uint256,uint256) (runs: 256, μ: 1288804, ~: 1292090)\nLogs:\n  Bound result 200\n\n[PASS] testFuzz_settlement_splitIsExactForAnyPool(uint256,uint256,uint256,uint256) (runs: 512, μ: 2032039, ~: 2046269)\nLogs:\n  Bound result 1000000\n  Bound result 14137\n  Bound result 2069\n\n[PASS] test_admin_everyOwnerFunctionRejectsStrangers() (gas: 205226)\n[PASS] test_admin_pauseAndUnpauseCannotRepeat() (gas: 109273)\n[PASS] test_admin_pausedBlocksRetry() (gas: 809338)\n[PASS] test_admin_pendingOwnerHasNoPowerUntilAcceptAndOldOwnerLosesIt() (gas: 190840)\n[PASS] test_admin_renounceRejectedMidRoundAndWhilePausedKeepsOwnerAndPendingOwner() (gas: 1607865)\n[PASS] test_admin_rescueRejectsZeroRecipientAndZeroToken() (gas: 894749)\n[PASS] test_admin_setTokenDecimalBounds() (gas: 2976783)\n[PASS] test_admin_setVrfConfigAllowedAgainAfterSettlement() (gas: 1937950)\n[PASS] test_admin_setVrfRequestParamsBoundsAndMidRound() (gas: 971893)\n[PASS] test_admin_vrfConfigBoundariesAccepted() (gas: 280042)\n[PASS] test_cancel_activeRoundRequestsFirstThenCancelsOnceOnly() (gas: 1101661)\n[PASS] test_cancel_worksWhilePausedAndRefundsWhilePaused() (gas: 673061)\n[PASS] test_claim_blockedWinnerDoesNotBlockOthers() (gas: 2320314)\n[PASS] test_claim_nonWinnerAndNeverPlayedRevert() (gas: 2174783)\n[PASS] test_finalize_afterSettlementRevertsForEmptyNextRound() (gas: 1242520)\n[PASS] test_finalize_boundaryIsExactlyEndTime() (gas: 774480)\n[PASS] test_finalize_twiceReverts() (gas: 775839)\n[PASS] test_flush_nothingPendingReverts() (gas: 1280885)\n[PASS] test_flush_stillBlockedKeepsFeePendingThenSucceeds() (gas: 1542267)\n[PASS] test_fulfill_extraWordsAreIgnoredOnlyFirstUsed() (gas: 1436793)\n[PASS] test_fulfill_oldRequestCannotSettleALaterRound() (gas: 2568946)\n[PASS] test_fulfill_ownerAndPlayersCannotCall() (gas: 830720)\n[PASS] test_fulfill_secondDeliveryOfSameRequestIsIgnored() (gas: 1591927)\n[PASS] test_fulfill_settlesWhilePaused() (gas: 1305752)\n[PASS] test_nextRoundTimerStartsFromItsOwnFirstDeposit() (gas: 1521301)\n[PASS] test_points_multiplePurchasesInOneRoundCountAsOneRound() (gas: 1511165)\n[PASS] test_redeem_atCapRevertsAndKeepsPoints() (gas: 14007107)\n[PASS] test_redeem_boostAppliesToAllTicketsOfNextRoundThenIsConsumed() (gas: 15937754)\n[PASS] test_redeem_nineRoundsWithManyPointsRejected() (gas: 12437977)\n[PASS] test_redeem_tenRoundsButTooFewPoints() (gas: 13866936)\n[PASS] test_refund_cancelledRoundGivesNoPointsOrRoundCredit() (gas: 779937)\n[PASS] test_refund_revertsForEveryNonCancelledStatus() (gas: 1291643)\n[PASS] test_retry_boundaryIsExactlyOneHourAfterRequest() (gas: 1110846)\n[PASS] test_retry_limitReachedThenCancelAfterLatestRequest() (gas: 1709945)\n[PASS] test_syncPoints_byThirdPartyOnlyCreditsTheTarget() (gas: 1620721)\n[PASS] test_vrf_duplicateRequestIdIsRejectedAndPauseStillRescuesFunds() (gas: 2216201)\nSuite result: ok. 39 passed; 0 failed; 0 skipped; finished in 68.20ms (222.36ms CPU time)\n\nRan 1 test for test/IMDPotInvariant.t.sol:IMDPotInvariantTest\n[PASS]\nIMDPotInvariantTest invariants:\n[PASS] invariant_balanceCoversObligations\n[PASS] invariant_currentRoundIsNeverSettled\n[PASS] invariant_depositsAreFullyAccountedFor\n[PASS] invariant_lockedNeverDecreases\n[PASS] invariant_prizesSumToSeventyPercentAndSlotsAreFilledByTickets\n IMDPotInvariantTest invariants (runs: 64, calls: 2048, reverts: 0)\n\n╭---------------+----------+-------+---------+----------╮\n| Contract      | Selector | Calls | Reverts | Discards |\n+=======================================================+\n| IMDPotHandler | buy      | 208   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | cancel   | 229   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | claim    | 216   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | finalize | 194   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | flush    | 237   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | fulfill  | 206   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | redeem   | 194   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | refund   | 186   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | retry    | 178   | 0       | 0        |\n|---------------+----------+-------+---------+----------|\n| IMDPotHandler | warp     | 200   | 0       | 0        |\n╰---------------+----------+-------+---------+----------╯\n\nLogs:\n  Bound result 62495\n  Bound result 22\n  Bound result 1\n  Bound result 1\n  Bound result 1\n  Bound result 28000\n  Bound result 1\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 243.51ms (241.05ms CPU time)\n\nRan 48 tests for test/IMDPot.t.sol:IMDPotTest\n[PASS] testFuzz_settlement_drawsThreeTicketsWithoutReplacement(uint256) (runs: 256, μ: 2246795, ~: 2240350)\n[PASS] test_buy_closesExactlyAtEndTime() (gas: 725689)\n[PASS] test_buy_firstTicketStartsTimer() (gas: 592231)\n[PASS] test_buy_laterTicketsDoNotExtendTimer() (gas: 952284)\n[PASS] test_buy_rejectedWhileDrawing() (gas: 772176)\n[PASS] test_buy_rejectsFeeOnTransferShortfall() (gas: 170582)\n[PASS] test_buy_requiresAllowanceAndBalance() (gas: 177732)\n[PASS] test_buy_revertsUntilConfigured() (gas: 746178)\n[PASS] test_buy_validatesCount() (gas: 88180)\n[PASS] test_claim_accumulatesAcrossRounds() (gas: 2264400)\n[PASS] test_claim_paysWinnersOnceAndOnlyWinners() (gas: 2042271)\n[PASS] test_constructor_rejectsZeroOwner() (gas: 4104)\n[PASS] test_constructor_runsOnEmptyChainAndFitsSizeLimits() (gas: 9047609)\n[PASS] test_constructor_setsLaunchOwnerNotDeployer() (gas: 6769415)\n[PASS] test_deployScript_deploysWithExplicitOwner() (gas: 6767322)\n[PASS] test_finalize_anyoneCanCallAndRequestsVrfWithConfiguredParams() (gas: 794926)\n[PASS] test_finalize_revertsWhenCoordinatorRejects() (gas: 580096)\n[PASS] test_finalize_revertsWithoutRoundOrBeforeEnd() (gas: 501742)\n[PASS] test_fulfill_fitsWithinCallbackGasForManyParticipants() (gas: 15230338)\n[PASS] test_fulfill_fitsWithinMinimumCallbackGasWith2048Participants() (gas: 736827572)\n[PASS] test_fulfill_ignoresUnknownRequestAndEmptyWords() (gas: 1423869)\n[PASS] test_fulfill_onlyCoordinator() (gas: 798760)\n[PASS] test_ownership_cannotBeRenounced() (gas: 498632)\n[PASS] test_ownership_isTwoStep() (gas: 146101)\n[PASS] test_pause_blocksEntryAndFinalizeButNotExits() (gas: 2535836)\n[PASS] test_points_cannotBeWithdrawnAsTokens() (gas: 13861712)\n[PASS] test_points_creditedProportionallyAfterFinalization() (gas: 1662947)\n[PASS] test_points_syncOnNextPurchaseAndNotForCancelledRounds() (gas: 1870711)\n[PASS] test_redeem_requiresTenRoundsAndPoints() (gas: 15383486)\n[PASS] test_redeem_stacksToCapAndBoostsOnlyChancesNotPrize() (gas: 16003141)\n[PASS] test_rescue_onlyForeignTokensAndOnlyOwner() (gas: 925564)\n[PASS] test_retry_addsRequestAndFirstFulfillmentWins() (gas: 3208161)\n[PASS] test_retry_isBoundedPerRound() (gas: 1904028)\n[PASS] test_retry_newRequestCanAlsoSettleFirst() (gas: 1882618)\n[PASS] test_retry_revertsWhenNotDrawing() (gas: 456557)\n[PASS] test_retry_usesUpdatedRequestParams() (gas: 1221980)\n[PASS] test_setToken_onlyOwner() (gas: 33564)\n[PASS] test_setToken_setsTicketPriceFromDecimals() (gas: 28070)\n[PASS] test_setToken_sixDecimals() (gas: 771748)\n[PASS] test_setToken_validation() (gas: 882964)\n[PASS] test_setVrfConfig_onlyOwnerAndOnlyBetweenRounds() (gas: 1248665)\n[PASS] test_setVrfConfig_validation() (gas: 395173)\n[PASS] test_settlement_dustGoesToLock() (gas: 2108931)\n[PASS] test_settlement_fewerThanThreeTicketsRollUnfilledPrizesToFirst() (gas: 1493102)\n[PASS] test_settlement_moreTicketsMeanMoreFirstPlaceWins() (gas: 113961923)\n[PASS] test_settlement_singleParticipantTakesAllThreePlaces() (gas: 1333325)\n[PASS] test_settlement_splitsPoolExactly() (gas: 2023855)\n[PASS] test_settlement_splittingAStakeAcrossAddressesGivesNoEdge() (gas: 2202029)\nSuite result: ok. 48 passed; 0 failed; 0 skipped; finished in 1.66s (1.88s CPU time)\n\nRan 1 test for test/IMDPotAccountingInvariant.t.sol:IMDPotAccountingInvariantTest\n[PASS]\nIMDPotAccountingInvariantTest invariants:\n[PASS] invariant_balanceEqualsObligationsAndLedgersAgree\n[PASS] invariant_noUnauthorizedSuccessAndWinnersArePlayers\n[PASS] invariant_playersCollectivelyNeverProfit\n[PASS] invariant_pointsMatchFinalizedTicketsAndBoostIsCapped\n IMDPotAccountingInvariantTest invariants (runs: 48, calls: 3840, reverts: 0)\n\n╭-------------------------+-------------------+-------+---------+----------╮\n| Contract                | Selector          | Calls | Reverts | Discards |\n+==========================================================================+\n| IMDPotAccountingHandler | buy               | 179   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | cancel            | 202   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | claim             | 200   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | finalize          | 188   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | flush             | 207   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | fulfill           | 212   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | fulfillStale      | 241   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | ownerPause        | 186   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | ownerTriesToDrain | 202   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | playManyRounds    | 215   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | playRound         | 193   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | redeem            | 197   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | refund            | 232   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | retry             | 200   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | stallAndCancel    | 187   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | strangerFulfills  | 198   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | sync              | 201   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | toggleFeeBlock    | 200   | 0       | 0        |\n|-------------------------+-------------------+-------+---------+----------|\n| IMDPotAccountingHandler | warp              | 200   | 0       | 0        |\n╰-------------------------+-------------------+-------+---------+----------╯\n\nLogs:\n  Bound result 38\n  Bound result 9\n  Bound result 1\n  Bound result 1\n  Bound result 60\n  Bound result 24\n  Bound result 3\n  Bound result 10\n  Bound result 1\n  Bound result 11\n  Bound result 2\n  Bound result 12\n  Bound result 3\n  Bound result 2\n  Bound result 2\n  Bound result 2\n  Bound result 49\n  Bound result 9\n  Bound result 1\n  Bound result 1\n  Bound result 7947\n  Bound result 40\n  Bound result 38\n  Bound result 12\n  Bound result 8\n  Bound result 15\n  Bound result 103\n  Bound result 91\n  Bound result 18\n  Bound result 2\n  Bound result 10\n  Bound result 10\n  Bound result 1\n  Bound result 11\n  Bound result 2\n  Bound result 12\n  Bound result 3\n  Bound result 13\n  Bound result 4\n  Bound result 14\n  Bound result 5\n  Bound result 15\n  Bound result 6\n  Bound result 16\n  Bound result 7\n  Bound result 17\n  Bound result 8\n  Bound result 18\n  Bound result 9\n  Bound result 19\n  Bound result 10\n  Bound result 5\n  Bound result 34\n  Bound result 9\n  Bound result 6\n  Bound result 40\n  Bound result 16\n  Bound result 40\n  Bound result 7\n  Bound result 17\n  Bound result 40\n  Bound result 9\n  Bound result 59\n  Bound result 1\n  Bound result 6\n  Bound result 10\n  Bound result 1\n  Bound result 11\n  Bound result 2\n  Bound result 12\n  Bound result 3\n  Bound result 13\n  Bound result 4\n  Bound result 14\n  Bound result 5\n  Bound result 15\n  Bound result 6\n  Bound result 90\n  Bound result 1\n  Bound result 40\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 12.45s (12.44s CPU time)\n\nRan 6 test suites in 12.45s (14.55s CPU time): 115 tests passed, 0 failed, 0 skipped (115 total tests)\n","passed":true},{"durationMs":46,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"IMDPot.acceptOwnership()\",\"IMDPot.buyTickets(uint256)\",\"IMDPot.cancelRound()\",\"IMDPot.claim()\",\"IMDPot.finalizeRound()\",\"IMDPot.flushPendingFees()\",\"IMDPot.pause()\",\"IMDPot.rawFulfillRandomWords(uint256,uint256[])\",\"IMDPot.redeemMultiplier()\",\"IMDPot.refund(uint256)\",\"IMDPot.rescueForeignToken(address,address)\",\"IMDPot.retryRandomness()\",\"IMDPot.setToken(address)\",\"IMDPot.setVrfConfig(address,uint256,bytes32,uint32,uint16,bool)\",\"IMDPot.setVrfRequestParams(uint32,uint16)\",\"IMDPot.syncPoints(address)\",\"IMDPot.transferOwnership(address)\",\"IMDPot.unpause()\"],\"files\":{\".gitignore\":24,\"README.md\":175,\"docs/SECURITY.md\":97,\"foundry.toml\":22,\"frontend/IMDPot.abi.json\":1833,\"frontend/app.js\":417,\"frontend/config.js\":15,\"frontend/index.html\":135,\"frontend/styles.css\":94,\"frontend/vendor/ETHERS-LICENSE.md\":21,\"frontend/vendor/ethers-6.15.0.umd.min.js\":1,\"remappings.txt\":2,\"script/DeployIMDPot.s.sol\":26,\"src/IMDPot.sol\":812,\"src/interfaces/IVRFCoordinatorV2Plus.sol\":48,\"src/libraries/WeightedTree.sol\":113,\"test/IMDPot.t.sol\":935,\"test/IMDPotAccountingInvariant.t.sol\":365,\"test/IMDPotAttacks.t.sol\":513,\"test/IMDPotBase.t.sol\":90,\"test/IMDPotEdges.t.sol\":765,\"test/IMDPotInvariant.t.sol\":183,\"test/WeightedTree.t.sol\":191,\"test/mocks/MockIMDToken.sol\":50,\"test/mocks/MockVRFCoordinator.sol\":73},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"eef9889c98bb68a487622cc83df9149111a529cdad14598588a948493952d0c8","verifiedTreeHash":"316a9775cd5b673391326a671f8ab23f65fa06c5","verifierVersion":"0.1.0+745c1db4"}]}