{"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":"a7490fc5-0042-40c0-96c5-01df79178086","kind":"shape:chain","nodes":[{"acceptedSubmissionHash":"4ed4a862397e1293c8689e6bfdf27a3d5ab5f593d65875f37c988c02ad314701","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":"efe34288e142b267293d5d4aeea91fe54abbf3153ae559d3a5bfbc4748a595b4","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":"5cf5feb4ff3006b50ddcc5b906852b533d35044072124b8fc0aa3bd6d9fb1d9a","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":"42eea7b9552f28abb532399a558b856ffdc4abfa4ca330648eb3711c386ea2fb","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":"2f03a6cc8e8cadcf4eee51ba37e6e85c0f41450722727957dfd758258b1397cb","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":"6587e1c1a4fe80e44e584b8a3b54b159c3f4bd9353b4f9171f794e0bcbbdbb6f","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":"2933c0adbe4558b2e23ff090cf9029a963b2e27c33288d10dd82b16485fef92b","dependsOn":["build_contract_project"],"execution":{"network":false,"profile":"foundry","requires":[],"tools":[]},"key":"manifest","kind":"code","role":"integrate","skillHash":null,"skillId":null,"state":"accepted"},{"acceptedSubmissionHash":"0dd8789213793a8b18f5a88eae05a973fea84e37319fa497acf42b0edf7b753c","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":"Launch NIGHTSHIFT (SHIFT), the platform token of NIGHTSHIFT, with a Uniswap v4 hook on its SHIFT/ETH pool and two more contracts: FeeSplitter and ShiftRewards.\n\n1. NightshiftHook, launch gate: until the address 0x3322e6dd06ade8377f1f1bcb766f6133d7b64d69 has made one buy, only a swap in a transaction signed by that address (tx.origin == 0x3322e6dd06ade8377f1f1bcb766f6133d7b64d69) can go through; every other swap reverts. The check uses tx.origin, not the hook's sender argument, so the first buy works through any router, including the Uniswap v4 Universal Router 0x66a9893cc07d91d95644aedd05d03f95e1dba8af. That buy has no minimum and no maximum. Right after it, trading opens to everyone forever: `bool public tradingOpen` becomes true and `event TradingOpened(address firstBuyer, uint256 shiftOut)` is emitted. Nobody can open it in any other way, and nobody can ever close it.\n\n2. NightshiftHook, sell fee: on every sell (SHIFT in, ETH out) the hook takes 5% in total: 2.5% of the SHIFT input and 2.5% of the ETH output, both sent to FeeSplitter, with `event FeeTaken(address indexed seller, uint256 shiftFee, uint256 ethFee)`. Buys pay no hook fee. The owner can lower either part, never raise either above 2.5%, and change nothing else in the hook.\n\n3. FeeSplitter: one fixed rule for every token it receives (ETH, SHIFT, IMD or any ERC-20): 40% to ShiftRewards, 20% to the dev wallet 0x6c4c78f2c3bdf4c4318fe305fb1c8bc1694d19b5, 20% to the treasury wallet 0x3322e6dd06ade8377f1f1bcb766f6133d7b64d69, 20% burned. Burned means: SHIFT is sent to 0x000000000000000000000000000000000000dEaD; IMD (0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7) is sent to IMD's BurnExecutor 0xe29386719c155b6847ad5a4e97c6674f10ffc750; ETH and any other token are kept in a burn reserve. `distribute(address token)` (address(0) for ETH) can be called by anyone and emits `event Distributed(address indexed token, uint256 rewards, uint256 dev, uint256 treasury, uint256 burned)`. `burnReserve(address token, uint256 amountIn, uint256 minImdOut)` can be called only by a keeper address the owner sets: it swaps that much of the reserve to IMD on Uniswap, requires at least minImdOut, and sends all the bought IMD straight to the BurnExecutor in the same call, emitting `event ImdBurned(address indexed token, uint256 amountIn, uint256 imdOut)`. `reserve(address token)` is a view of the burn reserve held for that token. The split, the dev, treasury and burn addresses are fixed forever; the owner can only change the keeper, with `setKeeper(address keeper)`.\n\n4. ShiftRewards: `stake(uint256 amount, uint256 lockDays)` with lockDays from 0 to 730; a locked stake cannot be withdrawn before its unlock time; `withdraw(uint256 amount)` takes unlocked stake back at any time; `getReward()` pays what was earned. Rewards are pro rata to the amount staked and the time staked, Synthetix StakingRewards style with several reward tokens (ETH and SHIFT at least): the more you stake, the more you earn. Views: `balanceOf(address account)` (staked amount) and `earned(address account, address token)` (claimable now). Events: `Staked(address indexed account, uint256 amount, uint256 unlockAt)`, `Withdrawn(address indexed account, uint256 amount)`, `RewardPaid(address indexed account, address indexed token, uint256 amount)`. Of every reward it receives, half streams to SHIFT stakers over 7 days and half goes to a NIGHT FROGS holders pot (collection 0x55b8c762f1b86fe1d5406edaaae0f8ecf3688a0c), paid by epoch: the owner calls `postRoot(uint256 epoch, bytes32 root, address token, uint256 total)`, which reserves that total from the pot and never more than the pot holds; a wallet calls `claimFrogs(uint256 epoch, address token, uint256 amount, bytes32[] proof)` with an OpenZeppelin StandardMerkleTree leaf keccak256(bytes.concat(keccak256(abi.encode(epoch, account, token, amount)))), once per epoch and token; what is not claimed after 30 days goes back to the pot. The owner can also add SHIFT for stakers from outside with `notifyRewardAmount(address token, uint256 amount, uint256 duration)`. The owner can post roots and add rewards, and cannot take staked SHIFT or rewards already earned.\n\nBuys and sells must work through the standard Uniswap v4 Universal Router (V4_SWAP, SWAP_EXACT_IN_SINGLE) and be quotable by the V4 Quoter 0x52f0e24d1c21c8a0cb1e5a5dd6198556bd9e1203, with empty hookData. The token itself is plain: no mint, no blocklist, no transfer fee, no pause. Owner roles go to the dev wallet. No backend signer.","parentJobId":null,"planHash":"8d9ee4e93f166210b13f3cbf3479381a9263f4ebc0d1cc1325f9cc4ac995aecd","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"a7490fc5-0042-40c0-96c5-01df79178086","publication":{"commit":null,"deliveredAt":null,"repoUrl":"https://github.com/identity-md-launches/launch-2124-launch-nightshift-shift-platform"},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51508","feedbackHash":"3fe4b529e84386cfa593ec350b9e719988d1ea648990917dfce30a0c0ae1b52a","nodeKey":"audit_economics","submissionHash":"4ed4a862397e1293c8689e6bfdf27a3d5ab5f593d65875f37c988c02ad314701","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51082","feedbackHash":"82cc6f5be693e2f8cdaccbe3bbc568e8e18d85afa5e64a7c87b73735e1544098","nodeKey":"audit_flow","submissionHash":"efe34288e142b267293d5d4aeea91fe54abbf3153ae559d3a5bfbc4748a595b4","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51425","feedbackHash":"2d3439ddc37ba714534a847e07e986e8850a5913e5191d8c25414e2d3ece510d","nodeKey":"audit_judge","submissionHash":"5cf5feb4ff3006b50ddcc5b906852b533d35044072124b8fc0aa3bd6d9fb1d9a","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52252","feedbackHash":"279966fe65cb97ed6830ab74732753932adcab21c6653fab5772a9c446d6606f","nodeKey":"audit_judge","submissionHash":"3537488441c4146d1c9ed68d2b0445c1e43c7d1afe09d8bdbb34cb9647c7aee1","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52604","feedbackHash":"744c273e52e26543c5d31077a101bc2d8225b44edcd30c47ef25018b61e8a69a","nodeKey":"audit_math","submissionHash":"42eea7b9552f28abb532399a558b856ffdc4abfa4ca330648eb3711c386ea2fb","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52366","feedbackHash":"e30325c43a4c085a2e53f502f251b1d1f2135e46e6355ce56f3638741eab3a34","nodeKey":"audit_permissions","submissionHash":"2f03a6cc8e8cadcf4eee51ba37e6e85c0f41450722727957dfd758258b1397cb","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52673","feedbackHash":"27bb294bf7ef59cedf82f7b7c148554d77616a0a5302646f942cc203699615cc","nodeKey":"build_contract_project","submissionHash":"6587e1c1a4fe80e44e584b8a3b54b159c3f4bd9353b4f9171f794e0bcbbdbb6f","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"51506","feedbackHash":"45bed2e66fb0d82854490ab9789387ac9e3768f0c38d939989792439b88c59a7","nodeKey":"build_contract_project","submissionHash":"ee236c9e4b2524bc44f98836a5385a9c8d59ff4e64e37a9e77b1e86778b3fd2a","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"51026","feedbackHash":"6431b70d691179548f9dc36b02ac656260adead4afeced546cf2454e3f3aa6fa","nodeKey":"manifest","submissionHash":"2933c0adbe4558b2e23ff090cf9029a963b2e27c33288d10dd82b16485fef92b","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"51390","feedbackHash":"8c5ee6b888ef1a3c07a515d24b224155f88bfb5ca77677bc72c70da041657cdd","nodeKey":"manifest","submissionHash":"3aaff3dbe9af821b8f57c613f5847b48cb45379654af488145894ed016ec5c4a","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"50976","feedbackHash":"4f1ec650b5958d97d9d659faf0fe8ce9f2122e39242291564dc1da18bd080f2d","nodeKey":"write_foundry_tests","submissionHash":"1877f5372ee456b9eb0995ffaad37b1db5d2547e3733199b57601082d5fb507f","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"52605","feedbackHash":"8475c361c21cddb42ea5de638111903ca7c3344d2dc2fe49440ce4db180c520b","nodeKey":"write_foundry_tests","submissionHash":"0dd8789213793a8b18f5a88eae05a973fea84e37319fa497acf42b0edf7b753c","tag1":"verification:checks","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"f6e2dca7d9d5f1f0b91cfafcd590831ef06c72703424a986c5045603655041c5","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":"56cc541d747f16eece20a695effe5614e32a57e772929ca900eeed7b403f40de","device":"c79042525fc4a50d","findings":[{"description":"receive() accepts ETH from any sender and _split() calls _notifyStakers(feeStream[ETH], ..., STREAM_DURATION), which restarts the 7-day window and spreads the leftover over a fresh 7 days (rate = (amount + leftover) / 7 days). A 1 wei deposit adds nothing but re-schedules everything still unstreamed, so repeated dust deposits turn the linear 7-day stream into an exponential decay with a 7-day time constant. Nothing is lost (it keeps streaming), but stakers are paid much later than the brief's 'streams to SHIFT stakers over 7 days'. The same happens through FeeSplitter.distribute(address(0)) after sending it a few wei, and for ERC-20s through distribute(token). Suggested fix: only accept splitter/owner ETH in receive(), or keep the existing periodFinish when a top-up is small (e.g. extend rate without moving periodFinish unless the stream has ended), or set a minimum top-up.","line":384,"path":"src/ShiftRewards.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ShiftRewards} from \"src/ShiftRewards.sol\";\nimport {ShiftToken} from \"src/ShiftToken.sol\";\n\ncontract GriefTest is Test {\n    address constant OWNER = 0x6C4c78f2C3bDf4C4318Fe305FB1C8BC1694d19b5;\n    ShiftToken shift;\n    ShiftRewards rewards;\n    address alice = makeAddr(\"alice\");\n    address griefer = makeAddr(\"griefer\");\n\n    function setUp() public {\n        vm.warp(1_700_000_000);\n        shift = new ShiftToken();\n        rewards = new ShiftRewards(address(shift), OWNER);\n        rewards.setDistributor(makeAddr(\"distributor\"));\n        shift.transfer(alice, 100e18);\n        vm.prank(alice);\n        shift.approve(address(rewards), type(uint256).max);\n        vm.prank(alice);\n        rewards.stake(100e18, 0);\n        vm.deal(address(this), 14 ether);\n        (bool ok,) = address(rewards).call{value: 14 ether}(\"\");\n        assertTrue(ok);\n        vm.deal(griefer, 1 ether);\n    }\n\n    /// Stakers are owed the 7 ETH staker half within the 7-day window.\n    function test_oneWeiDepositsDoNotDelayTheStream() public {\n        for (uint256 i = 0; i < 7 * 24; i++) {\n            vm.warp(block.timestamp + 1 hours);\n            vm.prank(griefer);\n            (bool ok,) = address(rewards).call{value: 1}(\"\");\n            assertTrue(ok);\n        }\n        // Without the 1 wei deposits alice has 7 ETH here; with them she has about 63% of it.\n        assertApproxEqRel(rewards.earned(alice, address(0)), 7 ether, 0.01e18, \"stream delayed by dust deposits\");\n    }\n}","reproduction":"Alice stakes 100 SHIFT; 14 ETH is sent to ShiftRewards (7 ETH to stakers over 7 days). A griefer sends 1 wei to ShiftRewards every hour for 7 days (168 wei total). Expected at day 7: earned(alice, ETH) ~= 7 ETH. Actual: 4.4325 ETH (36.7% still unpaid).","severity":"low","title":"Anyone can delay the stakers' 7-day fee stream indefinitely with 1 wei ETH deposits"}],"hash":"0dd8789213793a8b18f5a88eae05a973fea84e37319fa497acf42b0edf7b753c","nodeId":"a328cf9f-9929-418f-8211-be2be6babc12","outcome":"completed","summary":"I updated my tests to match the revised implementation and reported one low-severity defect. `forge test` passes with the verifier's seed: 85 tests across 8 suites, including both invariant suites.\n\n**Three tests were out of date, and I rewrote them to the revised behaviour** (all in `test/EdgeCases.t.sol`):\n- **`burnReserve`:** now checks that a `minImdOut` of 0 is refused with `ZeroAmount`, so a burn can't run with no slippage limit. Every other failure path is still checked with a non-zero minimum, and the reserve is still unchanged after each failure.\n- **A staker that refuses ETH:** `getReward()` now skips the ETH payment it can't make, emits `RewardPaymentFailed` and still pays SHIFT. The ETH stays owed and on the books. Asking for ETH alone with `getReward(ETH)` reverts and changes nothing, and the stake can still be withdrawn.\n- **Streaming while nobody is staked:** this test used to assert that the elapsed reward was never paid. Now that the stream's clock pauses, it asserts the stream picks up where it stopped when someone stakes, and pays the whole staker half without overpaying.\n\n**New tests:**\n- A fuzz test that unstakes everyone for 1 second to 60 days in the middle of a stream. Nothing earned before the gap changes, nothing is stranded, and the total paid never exceeds what was streamed.\n- `PartialFill`: sells (exact input and exact output) that stop at a price limit before they fill revert with `PartialFill`, and no fee is kept. A small sell under the same limit goes through and pays both fee parts. A buy that fills only partly is allowed and pays no fee. These use a small router contract in the test file that lets the caller set the price limit.\n- `setImdPool` and `setEthPool` refuse routes through a pool with a hook and accept the same routes without one.\n\n**Reported in `.imd-findings.json` (low):** anyone can delay the stakers' 7-day fee stream with 1 wei deposits.\n- **Why:** `ShiftRewards.receive()` (`src/ShiftRewards.sol:384`) accepts ETH from any sender, and every deposit restarts the 7-day window for everything not yet paid out. The same works through `FeeSplitter.distribute` after sending the splitter a few wei.\n- **Reproduction:** with 7 ETH streaming to one staker, one 1 wei deposit every hour for 7 days (168 wei in total) leaves them with 4.43 ETH at day 7 instead of 7 ETH. Nothing is lost; it is paid out later.\n- **Proof:** the finding includes this as a test that fails on the current code.\n\nI also noticed something I didn't report. On an exact-input sell, the SHIFT part of the fee is paid out of the PoolManager's own SHIFT before the seller pays in. So a sell whose 2.5% is more than all the SHIFT the PoolManager holds reverts. That takes a sell of roughly 40 times the pool's SHIFT, so it doesn't matter in practice.\n\nOnly `test/EdgeCases.t.sol` changed, and I haven't committed it. `.imd-findings.json` is in the repository root but doesn't show in `git status`, so it is probably ignored by git.","treeHash":"327e4692bd0ecc092ff5e2041e7dc545acf3528b","usage":{"cachedInputTokens":1421724,"inputTokens":40,"model":"claude-opus-5-5","outputTokens":19543,"runtime":"claude","turns":20,"wallClockMs":230444}},{"artifacts":[],"attempt":1,"bundleHash":"c601ca723889a4a0fe9979cce3e8a5dc6a0eb1e9bae4ca11ce6526aa6e93bc71","device":"9b06782c7559b54c","findings":[{"description":"FeeSplitter.distribute(token) is permissionless and, for any ERC-20, forwards 40% to ShiftRewards and calls notifyReceived(token), which calls _addRewardToken. ETH and SHIFT take 2 of the MAX_REWARD_TOKENS = 16 slots; an attacker deploys 14 worthless tokens, sends 1 wei of each to the FeeSplitter and calls distribute on each. From then on _addRewardToken reverts TooManyRewardTokens for any token not already listed, so distribute(IMD) (named in the brief: 'one fixed rule for every token it receives (ETH, SHIFT, IMD or any ERC-20)') and distribute(anyOtherErc20) revert permanently. FeeSplitter has no other exit for those tokens (reserve[IMD] is always 0 so burnReserve cannot move it either), so the tokens are stuck forever. Cost to the attacker: 14 token deployments and 14 distribute calls. Suggested fix: let the splitter (or ShiftRewards) only register reward tokens from an owner-curated allow-list, or keep non-listed tokens out of the staker stream instead of reverting (e.g. route the rewards share to the frogs pot / reserve), so distribute never reverts on a full list.","line":297,"path":"src/ShiftRewards.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 {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {FeeSplitter} from \"src/FeeSplitter.sol\";\nimport {ShiftRewards} from \"src/ShiftRewards.sol\";\nimport {ShiftToken} from \"src/ShiftToken.sol\";\n\ncontract JunkToken is ERC20 {\n    constructor() ERC20(\"Junk\", \"JUNK\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// Anyone can fill ShiftRewards' 16 reward-token slots with junk through FeeSplitter.distribute, after which\n/// distribute(IMD) (and any other new token) reverts forever and that token is stuck in the FeeSplitter.\ncontract RewardTokenGriefProof is Test {\n    address constant OWNER = 0x6C4c78f2C3bDf4C4318Fe305FB1C8BC1694d19b5;\n    address constant IMD = 0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7;\n    address constant BURN_EXECUTOR = 0xe29386719C155B6847aD5a4E97C6674f10ffc750;\n\n    function test_junkTokensCannotBlockImdDistribution() public {\n        PoolManager manager = new PoolManager(address(this));\n        ShiftToken shift = new ShiftToken();\n        ShiftRewards rewards = new ShiftRewards(address(shift), OWNER);\n        FeeSplitter splitter = new FeeSplitter(IPoolManager(address(manager)), address(shift), address(rewards), OWNER);\n        rewards.setDistributor(address(splitter));\n        vm.etch(IMD, address(new JunkToken()).code);\n\n        // Attacker: 14 junk tokens, 1 wei each, distributed by anyone.\n        address attacker = makeAddr(\"attacker\");\n        for (uint256 i = 0; i < 14; i++) {\n            JunkToken junk = new JunkToken();\n            junk.mint(address(splitter), 1);\n            vm.prank(attacker);\n            splitter.distribute(address(junk));\n        }\n\n        // Real IMD fees arrive. The fixed rule says 40/20/20/20 with the burn share to the BurnExecutor.\n        JunkToken(IMD).mint(address(splitter), 1000e18);\n        splitter.distribute(IMD); // reverts TooManyRewardTokens on the current code\n        assertEq(JunkToken(IMD).balanceOf(BURN_EXECUTOR), 200e18);\n        assertEq(JunkToken(IMD).balanceOf(address(rewards)), 400e18);\n    }\n}","reproduction":"Deploy ShiftRewards + FeeSplitter (as the hook does). For i in 1..14: deploy ERC-20 junk_i, mint 1 wei to the splitter, call splitter.distribute(junk_i) from any address. Then mint 1000e18 IMD to the splitter and call splitter.distribute(IMD). Expected: success, 400e18 to ShiftRewards, 200e18 each to dev and treasury, 200e18 to BurnExecutor 0xe29386719C155B6847aD5a4E97C6674f10ffc750. Actual: reverts TooManyRewardTokens(); the 1000e18 IMD stay in the FeeSplitter with no way out.","severity":"medium","title":"Anyone can fill ShiftRewards' 16 reward-token slots with junk tokens, after which FeeSplitter.distribute reverts forever for IMD and every other new token"},{"description":"The brief says 'the split, the dev, treasury and burn addresses are fixed forever; the owner can only change the keeper' and burnReserve 'swaps that much of the reserve to IMD on Uniswap'. setImdPool / setEthPool let the keeper point burnReserve at any PoolKey whose currencies are (ETH, IMD) or (ETH, token): any fee, any tick spacing and any hooks address, including a pool the keeper just initialized and is the only LP of, or one whose hook uses beforeSwapReturnDelta to take the whole input. The keeper also chooses minImdOut, so nothing bounds the price. Example: keeper initializes an ETH/IMD pool at an extreme price with a few wei of IMD single-sided liquidity, calls setImdPool(thatKey), then burnReserve(ETH, reserve(ETH), 0): the ETH reserve goes into the keeper's position, almost no IMD reaches the BurnExecutor, and the keeper removes liquidity and keeps the ETH. The burn share is therefore not fixed: it is at the discretion of a role the owner can change at any time. Suggested fix: fix the route (pool key) at construction or let only a time-locked owner change it, and bound minImdOut by an on-chain reference (or require the pool to have no hook / a known hook).","line":196,"path":"src/FeeSplitter.sol","reproduction":"Set keeper K. K initializes PoolKey(ETH, IMD, fee 3000, tickSpacing 60, hooks 0) at sqrtPriceX96 near TickMath.MAX_SQRT_PRICE (IMD almost worthless in ETH), adds tiny single-sided liquidity, calls splitter.setImdPool(key) (accepted, only currency0/currency1 are checked) and splitter.burnReserve(address(0), reserve, 0). Expected (per brief): the 20% ETH burn share buys IMD at the market and is burned. Actual: the reserve's ETH lands in K's pool position; the BurnExecutor receives a negligible amount of IMD.","severity":"medium","title":"The keeper picks the swap route (any v4 pool and hook) as well as minImdOut, so it can divert the whole burn reserve instead of burning it"},{"description":"On an exact-input sell the SHIFT part is amountIn * shiftFeeBps / 10000, i.e. 2.5% of what the seller pays. On an exact-output sell afterSwap computes shiftFee = shiftIn * shiftFeeBps / 10000 where shiftIn is only what the pool consumed, then adds it on top, so the seller's total SHIFT input is shiftIn + fee and the fee is 250/10250 = 2.4390% of it. The ETH part is grossed up correctly on exact output (requested * bps / (10000 - bps)), so the two directions disagree and exact-output sellers pay about 0.061% less of the SHIFT input than the brief's '2.5% of the SHIFT input'. The existing test test_sellExactOutputTakesBothFeeParts asserts the 2.4390% behaviour as correct. Fix: shiftFee = shiftIn * shiftFeeBps / (10000 - shiftFeeBps).","line":189,"path":"src/NightshiftHook.sol","reproduction":"Pool from test/NightshiftHook.t.sol setUp (1e22 liquidity, 1:1), trading opened, alice sells exact-output 1 ether ETH. Seller pays 1064488361262843904 SHIFT; FeeSplitter receives 25963130762508387 SHIFT. Expected 2.5% of the input: 26612209031571097. Actual: 25963130762508387 (2.4390%).","severity":"low","title":"Exact-output sells charge 2.4390% of the seller's SHIFT input, not 2.5%"},{"description":"getReward() and exit() pay every listed reward token in one loop. Because anyone can add tokens to the list through FeeSplitter.distribute (see the first finding), an attacker can list a token that reverts on transfer (or on transfer to some recipients); every staker then accrues a balance of it and getReward()/exit() revert for all of them. Stakes and other tokens remain reachable through withdraw and getReward(token) one at a time, so nothing is lost, but the documented entry points stop working. The same holds for any staker contract that refuses ETH (covered by test_ethRefusingStakerKeepsStakeAndOtherRewards). Fix: curate reward tokens, or skip/escrow failed payouts instead of reverting the batch.","line":169,"path":"src/ShiftRewards.sol","reproduction":"Deploy token T whose transfer reverts unless from == the FeeSplitter. Mint 1e18 T to the FeeSplitter, call distribute(T). Stake 100e18 SHIFT as alice, warp 7 days, alice calls getReward(): expected ETH and SHIFT rewards paid; actual revert from T.transfer. getReward(address(shift)) still works.","severity":"low","title":"A reward token whose transfer reverts blocks getReward() and exit() for every staker who accrued it"},{"description":"Synthetix accounting: while totalSupply == 0 rewardPerToken does not grow but lastUpdateTime advances, so stream time with no stakers is never paid to anyone, and there is no recovery path (accounted keeps it booked, so it is not re-streamed either). Early in the launch, before anyone stakes, every distribute sends 20% of all fees (half of the 40% rewards share) into this black hole. Documented in the README; test_streamWithNoStakersIsNotPaidToLateStaker pins the behaviour without blessing it as intended economics. Consider starting the stream only once totalSupply > 0, or re-streaming unallocated rewards.","line":270,"path":"src/ShiftRewards.sol","reproduction":"Fresh ShiftRewards, send 2 ether via the distributor, warp 7 days, alice stakes 1e18, warp 7 days: earned(alice, ETH) == 0 and 1 ether of staker stream sits in the contract with no claimant.","severity":"info","title":"Staker stream elapsed while nobody is staked is stranded in ShiftRewards forever"}],"hash":"1877f5372ee456b9eb0995ffaad37b1db5d2547e3733199b57601082d5fb507f","nodeId":"a328cf9f-9929-418f-8211-be2be6babc12","outcome":"completed","summary":"I've added invariant tests over random call sequences and new failure-path tests, and found five defects that I reported instead of writing tests that pass on them. `forge test` with the verifier's seed passes: 76 tests in 8 suites, 0 failing. Before this the suite had 52.\n\n**What I added**\n- **`test/invariant/ShiftRewards.invariant.t.sol`**: three stakers, the distributor, the owner and NIGHT FROGS claimers act in random order (stake with locks, withdraw, claim rewards, fund ETH and SHIFT, owner rewards, post roots, claim, settle, time jumps). After every call it checks four things:\n  - the contract holds at least what it has booked for each token;\n  - what it has booked covers all stakes, everyone's claimable rewards, the frogs pot and open epochs;\n  - each staked balance equals what was put in minus what was taken out, and no staker loses SHIFT;\n  - stakers are never paid more than was streamed to them, and the frogs half is conserved.\n  \n  Wrong callers, bad proofs and early withdrawals are checked to revert along the way. 128 runs × 60 calls, no unexpected reverts.\n- **`test/invariant/System.invariant.t.sol`**: the real hook on a live PoolManager, plus the FeeSplitter, ShiftRewards and an ETH/IMD pool. Random buys and sells (exact in and exact out) from the first buyer and others, fee changes, distributions and reserve burns. It checks that:\n  - nobody but the first buyer trades before opening, and trading never closes again;\n  - buys pay no fee, and each fee part matches its rate exactly and never goes above 2.5%;\n  - the hook keeps no SHIFT or ETH;\n  - every fee wei is either still in the splitter or left it through a split or a burn;\n  - dev, treasury and burn get identical shares and ShiftRewards gets at least twice that;\n  - the IMD at the BurnExecutor matches what the burns bought.\n  \n  64 runs × 50 calls. Every action ran (about 440–490 calls each) with no unexpected reverts. My attempt to count how many runs actually reached sells and burns was inconclusive, so a few runs may never get past opening.\n- **`test/EdgeCases.t.sol`** (22 tests): exact `TradingNotOpen` reverts, including a call from the Universal Router's address with a stranger as the signer; an exact-output first buy opening trading; only one `TradingOpened` event; zero fees; caps when lowering then raising fees; lock and claim-window boundaries to the second; wrong-caller and wrong-value paths; a staker contract that refuses ETH; fuzzed checks that rewards are pro rata to amount staked and to time; and burn failures leaving the reserve untouched.\n\n**Defects reported in `.imd-findings.json`**\n1. **Medium: anyone can permanently block distribution of IMD and any other new token.** Calling `distribute` on 14 junk tokens fills ShiftRewards' 16 reward-token slots. After that, `distribute(IMD)` reverts with `TooManyRewardTokens` and the IMD has no other way out of the FeeSplitter. This finding includes a proof test that fails on the current code.\n2. **Medium: the keeper can divert the burn reserve.** The keeper chooses both the swap pool (any fee, any hook) and `minImdOut`. It can route the reserve into a pool it alone provides liquidity to, so almost no IMD gets burned and the keeper keeps the ETH. The brief says the owner can only change the keeper.\n3. **Low: exact-output sells charge 2.439% of the SHIFT input, not 2.5%.** In one trial, a seller paid 1064488361262843904 SHIFT and the fee was 25963130762508387; 2.5% would be 26612209031571097. Exact-input sells and the ETH part are correct. An earlier test, `test_sellExactOutputTakesBothFeeParts`, asserts the 2.439% figure as correct; I left it in place, and my own tests only check the fee never exceeds 2.5%.\n4. **Low: a reward token that reverts on transfer breaks `getReward()` and `exit()` for every staker who earned some of it.** Stakes stay reachable through `withdraw` and per-token `getReward(token)`.\n5. **Info: rewards streamed while nobody is staked are stuck in ShiftRewards for good.** This affects","treeHash":"d262699e0bf03f13c008e4c3e355514c813aa5d0","usage":{"cachedInputTokens":3018536,"inputTokens":56,"model":"claude-opus-5-5","outputTokens":57559,"runtime":"claude","turns":30,"wallClockMs":677896}},{"artifacts":[],"attempt":1,"bundleHash":"3e316861017c5c6373c2582ccdb0ff212e3c587212f8a1fa0bd7eeeeaa07a9f4","device":"56b13350bba65fd7","findings":[],"hash":"2933c0adbe4558b2e23ff090cf9029a963b2e27c33288d10dd82b16485fef92b","nodeId":"0f605814-3978-4af3-9036-a1b577aea6f2","outcome":"completed","summary":"What I needed next, privately: confirmation that the manifest's declared fields still match the accepted implementation — constructor order, permission set, token metadata, pool terms, notes length — plus a clean build and passing tests. All of those are local, and all are done.\n\nWhat I need from you: nothing. There is no outstanding item, and no value the brief leaves for the requester to supply.\n\nState of the deliverable:\n\n- `launch.json` is schema-shaped: `kind` is `univ4_hook`; `fee`, `tickSpacing` and `decimals` are numbers; every address is lowercase; `initialPrice` is the decimal `79228162514264337593543950336`.\n- Constructor arguments are `[\"$poolManager\", \"$token\", \"$owner\", \"$factory\"]`, matching `NightshiftHook`'s `(IPoolManager, address token_, address owner_, address factory_)` in declaration order.\n- Declared permissions are exactly what `getHookPermissions` returns: `beforeInitialize`, `beforeSwap`, `afterSwap`, `beforeSwapReturnDelta`, `afterSwapReturnDelta`, which encodes to address flags `0x20CC` (8396), the value the repo's own miner and README use.\n- Token is `ShiftToken`, NIGHTSHIFT/SHIFT, 18 decimals, no constructor arguments. Pool pairs native ETH at fee 12500 and tick spacing 60.\n- Notes are 3343 characters, under the 4000 cap.\n- `forge build` exits 0. `forge test` with `FOUNDRY_FUZZ_SEED=0xe8f69d5a97069c4b71fa2bf1ed5665e21603e46101b3c8af05b66bb01fc141a5` passes 57 of 57, including both fuzz tests and the address-flag assertion.\n- `git status` is clean: `launch.json` is the only path this manifest work touches, and no library, config or source file was changed.\n\nSince the accepted tree already carried a manifest that satisfies every point, this round changed no files. The current `launch.json` is the submission.","treeHash":"c4c87481931bed6688dca1e4f891d68498105d3f","usage":{"cachedInputTokens":606223,"inputTokens":32,"model":"claude-fable-5-1","outputTokens":11828,"runtime":"claude","turns":19,"wallClockMs":212380}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1188f01303e45ecd","findings":[{"citation":"resolved","description":"Access-control confused deputy. FeeSplitter.distribute(address token) has no caller or token restriction (src/FeeSplitter.sol:88) and, for every ERC-20, forwards 40% to ShiftRewards and then calls rewards.notifyReceived(token) (src/FeeSplitter.sol:105). ShiftRewards.notifyReceived trusts the FeeSplitter unconditionally (src/ShiftRewards.sol:334: `msg.sender != distributor && msg.sender != owner()`), and _split -> _notifyStakers -> _addRewardToken (src/ShiftRewards.sol:295-300) registers the token in the 16-slot rewardTokens list. So the owner-grade power 'add a reward token' is reachable by any EOA that deploys an ERC-20, mints it to the splitter and calls distribute. Two consequences: (1) 14 junk tokens fill the list (ETH and SHIFT hold the other two). From then on distribute(IMD) and distribute(anyOtherToken) revert with TooManyRewardTokens inside notifyReceived, and so does the owner's notifyRewardAmount for any new token. The brief's fixed rule 'for every token it receives (ETH, SHIFT, IMD or any ERC-20)' is permanently broken; IMD or other tokens sent to the FeeSplitter are stuck forever because the contract has no other exit. (2) A single attacker token whose transfer() reverts when ShiftRewards is the sender streams to all stakers; every staker then holds an unpayable rewards[account][evil] > 0, and getReward() (loops every token, src/ShiftRewards.sol:169-175) and exit() (src/ShiftRewards.sol:184-190) revert for them permanently. Only the per-token getReward(address) and withdraw() keep working. Guard gap: ShiftRewards.notifyRewardAmount is onlyOwner, but the equivalent write to rewardTokens via distribute -> notifyReceived is open to everyone. Minimal fix that keeps the design: have FeeSplitter only call notifyReceived (or only run the ERC-20 branch) for tokens on an owner-allowlist or for SHIFT/IMD, or have ShiftRewards.notifyReceived accept new tokens only from the owner while the distributor may only top up tokens already registered.","line":104,"path":"src/FeeSplitter.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 {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {FeeSplitter} from \"src/FeeSplitter.sol\";\nimport {ShiftRewards} from \"src/ShiftRewards.sol\";\nimport {ShiftToken} from \"src/ShiftToken.sol\";\n\n/// @dev Plain mintable ERC-20 standing in for an attacker-deployed token, or for IMD.\ncontract Mintable is ERC20 {\n    constructor() ERC20(\"M\", \"M\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// @dev Attacker token: transfers work for everyone except when ShiftRewards is the sender, so the\n/// FeeSplitter can forward it but ShiftRewards can never pay it out.\ncontract RevertingForRewards is ERC20 {\n    address public immutable blocked;\n\n    constructor(address blocked_) ERC20(\"EVIL\", \"EVIL\") {\n        blocked = blocked_;\n    }\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n\n    function transfer(address to, uint256 amount) public override returns (bool) {\n        require(msg.sender != blocked, \"evil: no payout\");\n        return super.transfer(to, amount);\n    }\n}\n\n/// @notice FeeSplitter.distribute(token) is permissionless and is the trusted `distributor` of\n/// ShiftRewards.notifyReceived, so anyone can make ShiftRewards register any ERC-20 as a reward token.\ncontract RewardTokenPoisonProof is Test {\n    address constant OWNER = 0x6C4c78f2C3bDf4C4318Fe305FB1C8BC1694d19b5;\n    address constant IMD = 0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7;\n    address constant BURN_EXECUTOR = 0xe29386719C155B6847aD5a4E97C6674f10ffc750;\n\n    PoolManager manager;\n    ShiftToken shift;\n    ShiftRewards rewards;\n    FeeSplitter splitter;\n    address attacker = makeAddr(\"attacker\");\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_700_000_000);\n        manager = new PoolManager(address(this));\n        shift = new ShiftToken();\n        rewards = new ShiftRewards(address(shift), OWNER);\n        splitter = new FeeSplitter(IPoolManager(address(manager)), address(shift), address(rewards), OWNER);\n        rewards.setDistributor(address(splitter));\n        vm.etch(IMD, address(new Mintable()).code);\n        shift.transfer(alice, 1_000e18);\n        vm.prank(alice);\n        shift.approve(address(rewards), type(uint256).max);\n    }\n\n    /// @dev The attacker's calls go through a low-level call so a fix that makes them revert still\n    /// lets the test reach its assertions.\n    function _attackerDistribute(address token) internal {\n        vm.prank(attacker);\n        (bool ok,) = address(splitter).call(abi.encodeCall(FeeSplitter.distribute, (token)));\n        ok; // ignored on purpose\n    }\n\n    /// Fourteen junk tokens fill the 16-slot reward list (ETH and SHIFT hold the other two). After that\n    /// `distribute(IMD)` reverts with TooManyRewardTokens forever: IMD (and every other ERC-20) sent to\n    /// the FeeSplitter can never be split or burned, and there is no other way out of the contract.\n    function test_anyoneCanBrickDistributeForImdAndOtherTokens() public {\n        for (uint256 i = 0; i < 14; i++) {\n            Mintable junk = new Mintable();\n            junk.mint(address(splitter), 1e18);\n            _attackerDistribute(address(junk));\n        }\n\n        Mintable(IMD).mint(address(splitter), 500e18);\n        // Expected: the fixed rule runs and 20% of the IMD reaches the BurnExecutor.\n        splitter.distribute(IMD);\n        assertEq(Mintable(IMD).balanceOf(BURN_EXECUTOR), 100e18, \"IMD burn share must reach the BurnExecutor\");\n        assertEq(Mintable(IMD).balanceOf(address(splitter)), 0, \"nothing may stay stuck in the splitter\");\n    }\n\n    /// One attacker token whose transfers revert when ShiftRewards sends it becomes a reward token and\n    /// streams to every staker. Each staker then holds an unpayable `rewards[account][evil]`, so the\n    /// brief's `getReward()` and `exit()` revert for them permanently.\n    function test_anyoneCanBrickGetRewardAndExitForStakers() public {\n        vm.prank(alice);\n        rewards.stake(1_000e18, 0);\n\n        RevertingForRewards evil = new RevertingForRewards(address(rewards));\n        evil.mint(address(splitter), 1_000e18);\n        _attackerDistribute(address(evil));\n\n        vm.warp(block.timestamp + 1 days);\n\n        // Expected: a staker can always collect what they earned and leave.\n        vm.prank(alice);\n        rewards.getReward();\n        vm.prank(alice);\n        rewards.exit();\n        assertEq(rewards.balanceOf(alice), 0, \"alice could not exit\");\n        assertEq(shift.balanceOf(alice), 1_000e18, \"alice did not get her stake back\");\n    }\n}","reproduction":"State: FeeSplitter + ShiftRewards wired as the hook constructor does. (1) Attacker (any EOA) deploys 14 plain ERC-20s, mints 1e18 of each to the FeeSplitter and calls FeeSplitter.distribute(junk_i) for each. Each call succeeds and ShiftRewards.rewardTokenCount() becomes 16. Then mint 500e18 IMD (0xD34a...63B7) to the FeeSplitter and call distribute(IMD). Expected: 100e18 IMD reaches BURN_EXECUTOR, 200e18 to ShiftRewards, 100e18 each to dev and treasury. Actual: revert TooManyRewardTokens(); the IMD stays in the FeeSplitter forever. (2) alice stakes 1_000e18 SHIFT with lockDays 0. Attacker deploys a token whose transfer() reverts when msg.sender == ShiftRewards, mints 1_000e18 to the FeeSplitter, calls distribute(evil). Warp 1 day. alice calls getReward(). Expected: pays her ETH/SHIFT rewards. Actual: revert 'evil: no payout' from the evil token; exit() reverts the same way. Both are shown by test/scratch/RewardTokenPoisonProof.t.sol.","severity":"high","snippet":"            erc.safeTransfer(address(rewards), toRewards);\n            rewards.notifyReceived(token);","title":"Permissionless FeeSplitter.distribute(token) acts as ShiftRewards' trusted distributor: anyone registers junk reward tokens, permanently bricking distribute(IMD)/other ERC-20s and getReward()/exit()"},{"citation":"resolved","description":"Asymmetry / trust gap between two writers of rewardData[token]. The owner-only notifyRewardAmount(token, amount, duration) (src/ShiftRewards.sol:351-370) sets rewardRate and periodFinish for a chosen duration of 1 to 365 days. The splitter path _split -> _notifyStakers(token, toStakers, STREAM_DURATION) (src/ShiftRewards.sol:347) writes the same struct with a fixed 7 days, and _notifyStakers folds the whole leftover of the running stream into the new rate: `r.rewardRate = (scaled + leftover) / duration; r.periodFinish = block.timestamp + duration` (src/ShiftRewards.sol:316-319). The splitter path is reachable by anyone: transfer dust SHIFT to the FeeSplitter and call the permissionless FeeSplitter.distribute(SHIFT) (src/FeeSplitter.sol:88), which forwards 4 wei and calls notifyReceived(SHIFT). For ETH, anyone can simply send 1 wei to ShiftRewards.receive() (src/ShiftRewards.sol:326-329). Result: an unprivileged caller rewrites the owner's one-year SHIFT schedule into a 7-day stream (compression: whoever is staked that week takes the whole year's rewards, later stakers get nothing), and conversely repeated dust notifies push periodFinish out again and again, stretching any stream indefinitely (rate decays geometrically). The brief's guarantee that notifyRewardAmount adds rewards 'streamed over duration' therefore does not hold against anyone with a few wei. Minimal fix: keep separate RewardData for owner streams and splitter streams (or key by (token, source)), or have _notifyStakers keep max(remaining, duration) as the new period and require a minimum notify amount.","line":341,"path":"src/ShiftRewards.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {FeeSplitter} from \"src/FeeSplitter.sol\";\nimport {ShiftRewards} from \"src/ShiftRewards.sol\";\nimport {ShiftToken} from \"src/ShiftToken.sol\";\n\n/// @notice The owner's `notifyRewardAmount(SHIFT, amount, 365 days)` stream and the splitter's 7-day\n/// stream share one `rewardData[SHIFT]`. Anyone who drops 10 wei of SHIFT on the FeeSplitter and calls\n/// the permissionless `distribute(SHIFT)` re-notifies the token with STREAM_DURATION, which rewrites\n/// the owner's leftover into a 7-day stream.\ncontract StreamDurationOverrideProof is Test {\n    address constant OWNER = 0x6C4c78f2C3bDf4C4318Fe305FB1C8BC1694d19b5;\n\n    PoolManager manager;\n    ShiftToken shift;\n    ShiftRewards rewards;\n    FeeSplitter splitter;\n    address attacker = makeAddr(\"attacker\");\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_700_000_000);\n        manager = new PoolManager(address(this));\n        shift = new ShiftToken();\n        rewards = new ShiftRewards(address(shift), OWNER);\n        splitter = new FeeSplitter(IPoolManager(address(manager)), address(shift), address(rewards), OWNER);\n        rewards.setDistributor(address(splitter));\n        shift.transfer(alice, 1_000e18);\n        shift.transfer(OWNER, 365_000e18);\n        shift.transfer(attacker, 1e18);\n        vm.prank(alice);\n        shift.approve(address(rewards), type(uint256).max);\n        vm.prank(OWNER);\n        shift.approve(address(rewards), type(uint256).max);\n    }\n\n    function test_unprivilegedDistributeCompressesOwnerStreamIntoSevenDays() public {\n        vm.prank(alice);\n        rewards.stake(1_000e18, 0);\n\n        // Owner: 365,000 SHIFT for stakers over one year, i.e. 1,000 SHIFT per day.\n        vm.prank(OWNER);\n        rewards.notifyRewardAmount(address(shift), 365_000e18, 365 days);\n        (uint256 finishBefore,,,) = rewards.rewardData(address(shift));\n        assertEq(finishBefore, block.timestamp + 365 days);\n\n        // Attacker: 10 wei of SHIFT to the splitter, then the permissionless distribute.\n        vm.startPrank(attacker);\n        shift.transfer(address(splitter), 10);\n        (bool ok,) = address(splitter).call(abi.encodeCall(FeeSplitter.distribute, (address(shift))));\n        ok; // a fix that refuses the dust call must still leave the owner's stream intact\n        vm.stopPrank();\n\n        // Expected: the owner's one-year schedule still stands, so after 7 days alice has earned about\n        // 7,000 SHIFT (plus the attacker's 4 wei). Actual on this code: periodFinish moved to +7 days and\n        // the whole 365,000 SHIFT has streamed out.\n        (uint256 finishAfter,,,) = rewards.rewardData(address(shift));\n        assertEq(finishAfter, finishBefore, \"owner's stream end was rewritten by an unprivileged caller\");\n\n        vm.warp(block.timestamp + 7 days);\n        uint256 earned = rewards.earned(alice, address(shift));\n        assertLe(earned, 7_100e18, \"a year-long stream paid out in a week\");\n    }\n}","reproduction":"alice stakes 1_000e18 SHIFT. Owner calls notifyRewardAmount(SHIFT, 365_000e18, 365 days): rewardData(SHIFT).periodFinish == now + 365 days. Attacker transfers 10 wei SHIFT to the FeeSplitter and calls FeeSplitter.distribute(SHIFT). Expected: owner's schedule unchanged, after 7 days alice has earned about 7_000e18. Actual: periodFinish becomes now + 7 days (1700604800 instead of 1731536000 in the proof) and after 7 days earned(alice, SHIFT) is about 365_000e18. Shown by test/scratch/StreamDurationOverrideProof.t.sol.","severity":"medium","snippet":"    function _split(address token, uint256 amount) internal {\n        uint256 toFrogs = amount / 2;\n        uint256 toStakers = amount - toFrogs;\n        accounted[token] += amount;\n        frogsPot[token] += toFrogs;\n        emit FrogsPotFunded(token, toFrogs);\n        _notifyStakers(token, toStakers, STREAM_DURATION);\n    }","title":"Owner's notifyRewardAmount duration is overwritten by any unprivileged distribute(): one shared rewardData per token, re-notified with 7 days"},{"citation":"resolved","description":"Trust-gap (access x economics) on a privileged role, documented as a material privileged power rather than a bypass. The brief says the burn share is fixed forever and the owner can only change the keeper. In code the keeper's setImdPool accepts any PoolKey whose currencies are ETH/IMD (src/FeeSplitter.sol:198); fee, tickSpacing and hooks are unconstrained, and Uniswap v4 lets anyone initialize a new ETH/IMD pool with any fee tier or hook. burnReserve (src/FeeSplitter.sol:131-144) then swaps with sqrtPriceLimit at the extreme (src/FeeSplitter.sol:181) and only the keeper-supplied minImdOut as a bound. A keeper can therefore create an ETH/IMD pool it alone provides liquidity in (or one with its own hook taking a 100% beforeSwap delta), point imdPool at it, and call burnReserve(ETH, reserve, 0): the ETH meant to be burned ends up in the keeper's position or hook, and the BurnExecutor receives dust. The owner (dev wallet) appoints the keeper, so this is an owner+keeper trust assumption that contradicts the 'fixed burn' statement in the brief. Fix options that keep the design: restrict setImdPool to a pool with hooks == address(0) and a fee from the standard tiers, or make the owner (not the keeper) set the route and require minImdOut > 0.","line":196,"path":"src/FeeSplitter.sol","reproduction":"State: FeeSplitter.reserve(ETH) == 10 ether after distributions. Owner setKeeper(K). K initializes a fresh v4 pool PoolKey{currency0: ETH, currency1: IMD, fee: 1_000_000 (100%), tickSpacing: 1, hooks: 0} or a pool with K's own hook, and adds a tiny position. K calls setImdPool(thatKey) (passes BadRoute check) and burnReserve(address(0), 10 ether, 0). Expected per brief: 10 ETH worth of IMD at market reaches the BurnExecutor. Actual: the swap pays K's LP fee / hook; BurnExecutor receives near-zero IMD and the call succeeds because minImdOut is 0.","severity":"low","snippet":"    function setImdPool(PoolKey calldata key) external {\n        if (msg.sender != keeper) revert NotKeeper();\n        if (Currency.unwrap(key.currency0) != ETH || Currency.unwrap(key.currency1) != IMD) revert BadRoute();\n        imdPool = key;","title":"Keeper can route the 20% burn reserve to a pool it controls: setImdPool only pins currencies, not fee, tickSpacing or hooks, and minImdOut is keeper-chosen"},{"citation":"resolved","description":"Branch asymmetry inside exit(): it first calls _withdraw(withdrawable(msg.sender)) (src/ShiftRewards.sol:185) and _withdraw reverts ZeroAmount when amount == 0 (src/ShiftRewards.sol:193). A staker whose whole balance is still time-locked, or who already withdrew, has withdrawable == 0, so exit() always reverts and never reaches the reward loop. getReward() still works, so no funds are lost, but the convenience entry point is unusable for exactly the lock-in users the contract is designed for. Fix: in exit(), skip _withdraw when withdrawable is zero.","line":184,"path":"src/ShiftRewards.sol","reproduction":"alice stakes 100e18 with lockDays = 30. One day later (ETH rewards streaming) alice calls exit(). Expected: her earned rewards are paid and nothing is withdrawn (all locked). Actual: revert ZeroAmount().","severity":"low","snippet":"    function exit() external nonReentrant {\n        _withdraw(withdrawable(msg.sender));\n        uint256 n = rewardTokens.length;\n        for (uint256 i = 0; i < n; i++) {\n            _payReward(msg.sender, rewardTokens[i]);\n        }\n    }","title":"exit() reverts whenever a staker has nothing unlocked, so a fully locked staker cannot use exit() to collect rewards"}],"hash":"2f03a6cc8e8cadcf4eee51ba37e6e85c0f41450722727957dfd758258b1397cb","nodeId":"b2eec2ae-bc5c-45f6-b1eb-eb22eae0d253","outcome":"completed","summary":"The review record is corrected in place. All four findings and the coverage record are kept; nothing else in the tree changed.\n\n**What was wrong with the flagged finding.** The medium finding about the owner's reward stream being overwritten quoted the whole `_split` function, which starts at line 341 of `src/ShiftRewards.sol`, but anchored it at line 347. The checker could not match the cited line to the first line of the snippet.\n\n**What I changed.** Each finding's `line` now points at the first line of its exactly-copied snippet, so path, line and snippet agree:\n\n| Finding | File | Line before | Line now |\n|---|---|---|---|\n| 1 Permissionless distribute registers junk reward tokens | src/FeeSplitter.sol | 105 | 104 |\n| 2 Owner stream duration overwritten by 7-day re-notify | src/ShiftRewards.sol | 347 | 341 |\n| 3 Keeper can route burn reserve to a pool it controls | src/FeeSplitter.sol | 198 | 196 |\n| 4 exit() reverts for a fully locked staker | src/ShiftRewards.sol | 185 | 184 |\n\nThe other three had not been flagged, but they anchored a line inside their snippet rather than its start, so I aligned them too rather than leave the same ambiguity.\n\n**Verification.** I re-ran finding 2's proof test from the record on the current code. It fails as stated: after a 10 wei SHIFT dust transfer and a permissionless `distribute(SHIFT)`, the owner's one-year `periodFinish` is rewritten to seven days out. The scratch test was removed afterwards. Coverage rows that cite findings by position are unchanged and still point at the right entries, since finding order did not move.","treeHash":null,"usage":{"cachedInputTokens":1379516,"inputTokens":484,"model":"claude-fable-5-1","outputTokens":40807,"runtime":"claude","turns":36,"wallClockMs":566651}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c0fc4ea4f50e3380","findings":[{"citation":"resolved","description":"FeeSplitter.distribute(address token) (src/FeeSplitter.sol:88) has no caller or token restriction. For every ERC-20 it forwards 40% to ShiftRewards and calls rewards.notifyReceived(token) (line 105). ShiftRewards.notifyReceived trusts the distributor unconditionally (src/ShiftRewards.sol:334) and _split -> _notifyStakers -> _addRewardToken (src/ShiftRewards.sol:295-300) registers the token in the 16-slot rewardTokens list, which nothing can ever shrink. So the owner-grade power 'add a reward token' is open to any EOA that deploys an ERC-20, sends dust to the splitter and calls distribute. Consequences: (a) ETH and SHIFT occupy 2 slots; 14 dust distributions of junk fill the rest, after which distribute(IMD) and distribute(anyOtherErc20) revert with TooManyRewardTokens inside notifyReceived, permanently. The brief's fixed rule 'for every token it receives (ETH, SHIFT, IMD or any ERC-20)' is broken and any IMD or other token that reaches the FeeSplitter is stuck forever (the owner can only setKeeper; reserve[IMD] is always 0 so burnReserve cannot move it). (b) One attacker token whose transfer() reverts when ShiftRewards is the sender streams to all stakers; every staker then holds an unpayable rewards[account][evil] > 0 and getReward() (src/ShiftRewards.sol:169-175) and exit() (184-190), which loop over every token, revert for them permanently. Only the per-token getReward(address) and withdraw() still work. Merged from audit_permissions #1, write_foundry_tests #1 and #4, audit_flow #1, audit_math #2, audit_economics #2 (same root cause). Minimal fix preserving the design: let ShiftRewards.notifyReceived register a new token only when msg.sender is the owner (the distributor may only top up tokens already registered), or have FeeSplitter forward to ShiftRewards only tokens on an owner allow-list (ETH, SHIFT, IMD by default) and route other tokens' 40% share to the reserve; and make _payReward tolerate a failing transfer so one token cannot block the batch.","line":105,"path":"src/FeeSplitter.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 {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {FeeSplitter} from \"src/FeeSplitter.sol\";\nimport {ShiftRewards} from \"src/ShiftRewards.sol\";\nimport {ShiftToken} from \"src/ShiftToken.sol\";\n\n/// @dev Plain mintable ERC-20 standing in for an attacker-deployed token, or for IMD.\ncontract Mintable is ERC20 {\n    constructor() ERC20(\"M\", \"M\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// @dev Attacker token: transfers work for everyone except when ShiftRewards is the sender, so the\n/// FeeSplitter can forward it but ShiftRewards can never pay it out.\ncontract RevertingForRewards is ERC20 {\n    address public immutable blocked;\n\n    constructor(address blocked_) ERC20(\"EVIL\", \"EVIL\") {\n        blocked = blocked_;\n    }\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n\n    function transfer(address to, uint256 amount) public override returns (bool) {\n        require(msg.sender != blocked, \"evil: no payout\");\n        return super.transfer(to, amount);\n    }\n}\n\n/// @notice FeeSplitter.distribute(token) is permissionless and is the trusted `distributor` of\n/// ShiftRewards.notifyReceived, so anyone can make ShiftRewards register any ERC-20 as a reward token.\ncontract RewardTokenPoisonProof is Test {\n    address constant OWNER = 0x6C4c78f2C3bDf4C4318Fe305FB1C8BC1694d19b5;\n    address constant IMD = 0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7;\n    address constant BURN_EXECUTOR = 0xe29386719C155B6847aD5a4E97C6674f10ffc750;\n\n    PoolManager manager;\n    ShiftToken shift;\n    ShiftRewards rewards;\n    FeeSplitter splitter;\n    address attacker = makeAddr(\"attacker\");\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_700_000_000);\n        manager = new PoolManager(address(this));\n        shift = new ShiftToken();\n        rewards = new ShiftRewards(address(shift), OWNER);\n        splitter = new FeeSplitter(IPoolManager(address(manager)), address(shift), address(rewards), OWNER);\n        rewards.setDistributor(address(splitter));\n        vm.etch(IMD, address(new Mintable()).code);\n        shift.transfer(alice, 1_000e18);\n        vm.prank(alice);\n        shift.approve(address(rewards), type(uint256).max);\n    }\n\n    /// @dev The attacker's calls go through a low-level call so a fix that makes them revert still\n    /// lets the test reach its assertions.\n    function _attackerDistribute(address token) internal {\n        vm.prank(attacker);\n        (bool ok,) = address(splitter).call(abi.encodeCall(FeeSplitter.distribute, (token)));\n        ok; // ignored on purpose\n    }\n\n    /// Fourteen junk tokens fill the 16-slot reward list (ETH and SHIFT hold the other two). After that\n    /// `distribute(IMD)` reverts with TooManyRewardTokens forever: IMD (and every other ERC-20) sent to\n    /// the FeeSplitter can never be split or burned, and there is no other way out of the contract.\n    function test_anyoneCanBrickDistributeForImdAndOtherTokens() public {\n        for (uint256 i = 0; i < 14; i++) {\n            Mintable junk = new Mintable();\n            junk.mint(address(splitter), 1e18);\n            _attackerDistribute(address(junk));\n        }\n\n        Mintable(IMD).mint(address(splitter), 500e18);\n        // Expected: the fixed rule runs and 20% of the IMD reaches the BurnExecutor.\n        splitter.distribute(IMD);\n        assertEq(Mintable(IMD).balanceOf(BURN_EXECUTOR), 100e18, \"IMD burn share must reach the BurnExecutor\");\n        assertEq(Mintable(IMD).balanceOf(address(splitter)), 0, \"nothing may stay stuck in the splitter\");\n    }\n\n    /// One attacker token whose transfers revert when ShiftRewards sends it becomes a reward token and\n    /// streams to every staker. Each staker then holds an unpayable `rewards[account][evil]`, so the\n    /// brief's `getReward()` and `exit()` revert for them permanently.\n    function test_anyoneCanBrickGetRewardAndExitForStakers() public {\n        vm.prank(alice);\n        rewards.stake(1_000e18, 0);\n\n        RevertingForRewards evil = new RevertingForRewards(address(rewards));\n        evil.mint(address(splitter), 1_000e18);\n        _attackerDistribute(address(evil));\n\n        vm.warp(block.timestamp + 1 days);\n\n        // Expected: a staker can always collect what they earned and leave.\n        vm.prank(alice);\n        rewards.getReward();\n        vm.prank(alice);\n        rewards.exit();\n        assertEq(rewards.balanceOf(alice), 0, \"alice could not exit\");\n        assertEq(shift.balanceOf(alice), 1_000e18, \"alice did not get her stake back\");\n    }\n}","reproduction":"State: ShiftRewards + FeeSplitter wired as the hook constructor does (distributor = splitter). (1) Attacker, any EOA: for i in 1..14 { deploy a plain ERC-20 J_i; J_i.mint(splitter, 1e18); splitter.distribute(J_i) }. Each call succeeds; rewards.rewardTokenCount() == 16. Then mint 500e18 IMD (0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7, MockERC20 code etched there) to the splitter and call splitter.distribute(IMD). Expected: 100e18 IMD to BurnExecutor 0xe29386719C155B6847aD5a4E97C6674f10ffc750, 200e18 to ShiftRewards, 100e18 each to dev and treasury. Actual: revert TooManyRewardTokens(); the IMD stays in the FeeSplitter with no exit. (2) alice stakes 1_000e18 SHIFT, lock 0. Attacker deploys a token whose transfer() reverts when msg.sender == ShiftRewards, mints 1_000e18 to the splitter, calls distribute(evil) (succeeds). Warp 1 day. alice calls getReward(). Expected: her ETH/SHIFT rewards are paid. Actual: revert 'evil: no payout'; exit() reverts the same way. Run: forge test --match-path test/scratch/Proof_b359b1180564.t.sol -> both tests FAIL on the current code (TooManyRewardTokens() and 'evil: no payout').","severity":"high","snippet":"            rewards.notifyReceived(token);","title":"Permissionless FeeSplitter.distribute is ShiftRewards' trusted distributor: anyone registers junk reward tokens, bricking distribute() for IMD/new tokens forever and getReward()/exit() for stakers"},{"citation":"resolved","description":"beforeSwap computes the exact-input SHIFT fee from params.amountSpecified (line 150) and, for exact-output sells, the ETH fee from params.amountSpecified too (line 158: `uint256 ethFee = uint256(params.amountSpecified) * ethFeeBps / (BPS - ethFeeBps);`), takes it from the PoolManager at once and returns it as the BeforeSwapDelta. Uniswap v4 does not revert on a partial fill: when the pool runs out of liquidity in the sell direction the swap stops at the price limit and the swapper settles only what was consumed, but the hook's fee is not reduced, so the seller pays 2.5% of the whole specified amount while the pool took a fraction. This is the launch's own scenario: the pool is seeded with SHIFT only, so the ETH side holds exactly what buyers paid in, and any sell larger than that is a partial fill. The Universal Router's V4_SWAP SWAP_EXACT_IN_SINGLE passes the extreme sqrtPriceLimit and only checks amountOutMinimum, so a seller who submits with a loose minimum (or any direct caller) loses the fee on the unconsumed input to the FeeSplitter. Calibration: the V4 Quoter (BaseV4Quoter._swap) reverts NotEnoughLiquidity when the specified amount is not fully traded, so a UI that quotes first is warned; the loss needs a partial fill, hence medium rather than high. Second consequence of the same sizing: the exact-input SHIFT fee is taken from the manager before the seller settles, so when 2.5% of amountSpecified exceeds the SHIFT the manager holds the swap reverts (ERC20InsufficientBalance) although the trade would have been a valid partial fill. Merged from audit_math #1 and audit_flow #6. Fix: size the fee from the realised delta. For exact-input sells take the SHIFT part in afterSwap from -delta.amount1() (as the exact-output path does), for exact-output sells take the ETH part from delta.amount0(); or in afterSwap compare the consumed specified amount with amountSpecified and revert on a partial fill.","line":150,"path":"src/NightshiftHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {BalanceDelta} from \"v4-core/src/types/BalanceDelta.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {NightshiftHook} from \"src/NightshiftHook.sol\";\nimport {FeeSplitter} from \"src/FeeSplitter.sol\";\nimport {ShiftToken} from \"src/ShiftToken.sol\";\n\n/// @dev Minimal router: swaps and adds liquidity on a PoolManager, paying from its own balances.\ncontract ScratchRouter is IUnlockCallback {\n    IPoolManager public immutable manager;\n\n    constructor(IPoolManager m) {\n        manager = m;\n    }\n\n    receive() external payable {}\n\n    function swapExact(PoolKey memory key, bool zeroForOne, int256 amountSpecified) public returns (BalanceDelta) {\n        SwapParams memory params = SwapParams({\n            zeroForOne: zeroForOne,\n            amountSpecified: amountSpecified,\n            sqrtPriceLimitX96: zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1\n        });\n        bytes memory result = manager.unlock(abi.encode(true, abi.encode(key, params)));\n        return abi.decode(result, (BalanceDelta));\n    }\n\n    function addLiquidity(PoolKey memory key, int24 lo, int24 hi, int256 liquidity) external returns (BalanceDelta) {\n        ModifyLiquidityParams memory params =\n            ModifyLiquidityParams({tickLower: lo, tickUpper: hi, liquidityDelta: liquidity, salt: 0});\n        bytes memory result = manager.unlock(abi.encode(false, abi.encode(key, params)));\n        return abi.decode(result, (BalanceDelta));\n    }\n\n    function unlockCallback(bytes calldata data) external returns (bytes memory) {\n        require(msg.sender == address(manager), \"not manager\");\n        (bool isSwap, bytes memory inner) = abi.decode(data, (bool, bytes));\n        BalanceDelta delta;\n        PoolKey memory key;\n        if (isSwap) {\n            SwapParams memory params;\n            (key, params) = abi.decode(inner, (PoolKey, SwapParams));\n            delta = manager.swap(key, params, \"\");\n        } else {\n            ModifyLiquidityParams memory params;\n            (key, params) = abi.decode(inner, (PoolKey, ModifyLiquidityParams));\n            (delta,) = manager.modifyLiquidity(key, params, \"\");\n        }\n        _settle(key.currency0, delta.amount0());\n        _settle(key.currency1, delta.amount1());\n        return abi.encode(delta);\n    }\n\n    function _settle(Currency currency, int128 amount) internal {\n        if (amount < 0) {\n            uint256 owed = uint256(uint128(-amount));\n            manager.sync(currency);\n            if (currency.isAddressZero()) {\n                manager.settle{value: owed}();\n            } else {\n                IERC20(Currency.unwrap(currency)).transfer(address(manager), owed);\n                manager.settle();\n            }\n        } else if (amount > 0) {\n            manager.take(currency, address(this), uint256(uint128(amount)));\n        }\n    }\n}\n\ncontract PartialFillFeeTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    uint24 constant POOL_FEE = 12_500;\n    int24 constant SPACING = 60;\n    uint160 constant ALL_FLAGS = uint160((1 << 14) - 1);\n    // beforeInitialize | beforeSwap | afterSwap | beforeSwapReturnDelta | afterSwapReturnDelta\n    uint160 constant FLAGS = (1 << 13) | (1 << 7) | (1 << 6) | (1 << 3) | (1 << 2);\n\n    address constant OWNER = 0x6C4c78f2C3bDf4C4318Fe305FB1C8BC1694d19b5;\n    address constant FIRST_BUYER = 0x3322E6DD06Ade8377F1f1BCb766f6133d7b64D69;\n\n    PoolManager manager;\n    ShiftToken shift;\n    NightshiftHook hook;\n    FeeSplitter splitter;\n    ScratchRouter router;\n    PoolKey key;\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        manager = new PoolManager(address(this));\n        shift = new ShiftToken();\n        bytes memory creationCode = abi.encodePacked(\n            type(NightshiftHook).creationCode, abi.encode(manager, address(shift), OWNER, address(this))\n        );\n        bytes32 initCodeHash = keccak256(creationCode);\n        bytes32 salt;\n        for (uint256 i = 0; i < 500_000; i++) {\n            address predicted = address(\n                uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(i), initCodeHash))))\n            );\n            if ((uint160(predicted) & ALL_FLAGS) == FLAGS) {\n                salt = bytes32(i);\n                break;\n            }\n        }\n        hook = new NightshiftHook{salt: salt}(IPoolManager(address(manager)), address(shift), OWNER, address(this));\n        splitter = hook.feeSplitter();\n        key = PoolKey({\n            currency0: Currency.wrap(address(0)),\n            currency1: Currency.wrap(address(shift)),\n            fee: POOL_FEE,\n            tickSpacing: SPACING,\n            hooks: IHooks(address(hook))\n        });\n        manager.initialize(key, SQRT_PRICE_1_1); // this test contract plays the launch factory\n        router = new ScratchRouter(manager);\n        // Launch seed: SHIFT only, in a range below the opening price (no ETH in the pool yet).\n        shift.transfer(address(router), 1_000_000e18);\n        router.addLiquidity(key, TickMath.minUsableTick(SPACING), -SPACING, 1e23);\n        vm.deal(address(router), 100 ether);\n        // The first buyer opens trading with a 1 ETH buy.\n        vm.prank(FIRST_BUYER, FIRST_BUYER);\n        router.swapExact(key, true, -1 ether);\n        assertTrue(hook.tradingOpen());\n    }\n\n    /// @notice An exact-input sell that the pool can only partly fill is charged the SHIFT fee on the whole\n    /// specified amount, not on the SHIFT the pool actually took.\n    function test_partialFillChargesShiftFeeOnUnconsumedInput() public {\n        uint256 specified = 1_000e18; // far more than the 1 ETH of liquidity on the ETH side can absorb\n        uint256 feeBefore = shift.balanceOf(address(splitter));\n        vm.prank(alice, alice);\n        BalanceDelta d;\n        // A fix that refuses partial fills instead of repricing the fee is acceptable: then nobody is overcharged.\n        try router.swapExact(key, false, -int256(specified)) returns (BalanceDelta res) {\n            d = res;\n        } catch {\n            return;\n        }\n\n        uint256 shiftFee = shift.balanceOf(address(splitter)) - feeBefore;\n        uint256 sellerPaid = uint256(uint128(-d.amount1()));\n        uint256 poolTook = sellerPaid - shiftFee;\n        emit log_named_uint(\"SHIFT specified      \", specified);\n        emit log_named_uint(\"SHIFT the pool took  \", poolTook);\n        emit log_named_uint(\"SHIFT fee to splitter\", shiftFee);\n        emit log_named_uint(\"SHIFT seller paid    \", sellerPaid);\n\n        // The swap was a partial fill: the pool took far less than the seller specified.\n        assertLt(poolTook, specified / 10, \"expected a partial fill\");\n        // Spec: the hook takes 2.5% of the SHIFT input. The fee must not exceed 2.5% of what the seller paid.\n        assertLe(shiftFee, sellerPaid * 250 / 10_000 + 1, \"SHIFT fee exceeds 2.5% of the SHIFT actually sold\");\n    }\n}","reproduction":"Setup: PoolManager, ShiftToken, NightshiftHook (owner = dev wallet, factory = test), pool ETH/SHIFT fee 12500 spacing 60 at 1:1; liquidity 1e23 in ticks [minUsableTick, -60] (SHIFT only, no ETH); FIRST_BUYER (tx.origin 0x3322...) buys exact-in 1 ETH, trading opens. alice (tx.origin alice) sells exact-in amountSpecified = -1000e18 with sqrtPriceLimit = MAX_SQRT_PRICE - 1 (what the Universal Router passes). Expected: SHIFT fee = 2.5% of the SHIFT actually sold (about 0.016e18 of 0.65e18) and the rest stays with alice. Actual: FeeSplitter receives 25e18 SHIFT while the pool consumed about 0.65e18; alice's delta charges her about 25.65e18 SHIFT for about 0.97 ETH, 96% of her outlay going to the fee. Run: forge test --match-path test/scratch/Proof_cf6b329f7169.t.sol -> FAIL 'SHIFT fee exceeds 2.5% of the SHIFT actually sold: 25000000000000000000 > 649850211895192447'. Revert case: give the router 5_000_000e18 SHIFT and sell exact-in 5_000_000e18 on a pool whose manager holds ~99,699e18 SHIFT: expected a partial fill, actual revert inside beforeSwap's poolManager.take (fee 125_000e18 > manager balance).","severity":"medium","snippet":"            uint256 shiftFee = uint256(-params.amountSpecified) * shiftFeeBps / BPS;","title":"Sell fee is sized from amountSpecified, not from what the pool traded: a partially filled sell pays 2.5% on SHIFT/ETH that never traded, and a very large sell reverts in beforeSwap"},{"citation":"resolved","description":"The owner-only notifyRewardAmount(token, amount, duration) (src/ShiftRewards.sol:351-370) sets rewardRate and periodFinish for a chosen duration of 1 to 365 days, but the splitter path _split -> _notifyStakers(token, toStakers, STREAM_DURATION) (line 347) writes the same struct with a fixed 7 days, and _notifyStakers folds the whole leftover of the running stream into the new rate: `r.rewardRate = (scaled + leftover) / duration; ... r.periodFinish = block.timestamp + duration;` (lines 316-319). Both triggers are permissionless: ShiftRewards.receive() (line 326) accepts 1 wei of ETH from anyone, and FeeSplitter.distribute(SHIFT) (src/FeeSplitter.sol:88) can be called by anyone after transferring dust SHIFT to the splitter. So (a) whoever is staked can compress a year of owner-funded rewards into one week and capture them before later stakers arrive, which breaks the brief's notifyRewardAmount 'streamed over duration' guarantee; and (b) repeated dust notifies push periodFinish out again and again, so a 7-day stream decays geometrically and never finishes (1 wei per day for 7 days delivers 66% of the stream in the week it should have completed in). Normal fee inflow has the same effect on any owner stream, so the issue is reachable without an attacker. Merged from audit_permissions #2, audit_flow #2, audit_economics #1 and #4, audit_math #4 (same root cause). Minimal fix: keep separate RewardData for owner streams and splitter streams (key by (token, source)), or when topping up keep max(existing periodFinish, now + duration) as the new end and spread (scaled + leftover) over that, and require a minimum notify amount.","line":347,"path":"src/ShiftRewards.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {FeeSplitter} from \"src/FeeSplitter.sol\";\nimport {ShiftRewards} from \"src/ShiftRewards.sol\";\nimport {ShiftToken} from \"src/ShiftToken.sol\";\n\n/// @notice The owner's `notifyRewardAmount(SHIFT, amount, 365 days)` stream and the splitter's 7-day\n/// stream share one `rewardData[SHIFT]`. Anyone who drops 10 wei of SHIFT on the FeeSplitter and calls\n/// the permissionless `distribute(SHIFT)` re-notifies the token with STREAM_DURATION, which rewrites\n/// the owner's leftover into a 7-day stream.\ncontract StreamDurationOverrideProof is Test {\n    address constant OWNER = 0x6C4c78f2C3bDf4C4318Fe305FB1C8BC1694d19b5;\n\n    PoolManager manager;\n    ShiftToken shift;\n    ShiftRewards rewards;\n    FeeSplitter splitter;\n    address attacker = makeAddr(\"attacker\");\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_700_000_000);\n        manager = new PoolManager(address(this));\n        shift = new ShiftToken();\n        rewards = new ShiftRewards(address(shift), OWNER);\n        splitter = new FeeSplitter(IPoolManager(address(manager)), address(shift), address(rewards), OWNER);\n        rewards.setDistributor(address(splitter));\n        shift.transfer(alice, 1_000e18);\n        shift.transfer(OWNER, 365_000e18);\n        shift.transfer(attacker, 1e18);\n        vm.prank(alice);\n        shift.approve(address(rewards), type(uint256).max);\n        vm.prank(OWNER);\n        shift.approve(address(rewards), type(uint256).max);\n    }\n\n    function test_unprivilegedDistributeCompressesOwnerStreamIntoSevenDays() public {\n        vm.prank(alice);\n        rewards.stake(1_000e18, 0);\n\n        // Owner: 365,000 SHIFT for stakers over one year, i.e. 1,000 SHIFT per day.\n        vm.prank(OWNER);\n        rewards.notifyRewardAmount(address(shift), 365_000e18, 365 days);\n        (uint256 finishBefore,,,) = rewards.rewardData(address(shift));\n        assertEq(finishBefore, block.timestamp + 365 days);\n\n        // Attacker: 10 wei of SHIFT to the splitter, then the permissionless distribute.\n        vm.startPrank(attacker);\n        shift.transfer(address(splitter), 10);\n        (bool ok,) = address(splitter).call(abi.encodeCall(FeeSplitter.distribute, (address(shift))));\n        ok; // a fix that refuses the dust call must still leave the owner's stream intact\n        vm.stopPrank();\n\n        // Expected: the owner's one-year schedule still stands, so after 7 days alice has earned about\n        // 7,000 SHIFT (plus the attacker's 4 wei). Actual on this code: periodFinish moved to +7 days and\n        // the whole 365,000 SHIFT has streamed out.\n        (uint256 finishAfter,,,) = rewards.rewardData(address(shift));\n        assertEq(finishAfter, finishBefore, \"owner's stream end was rewritten by an unprivileged caller\");\n\n        vm.warp(block.timestamp + 7 days);\n        uint256 earned = rewards.earned(alice, address(shift));\n        assertLe(earned, 7_100e18, \"a year-long stream paid out in a week\");\n    }\n}","reproduction":"alice stakes 1_000e18 SHIFT, lock 0. Owner calls notifyRewardAmount(SHIFT, 365_000e18, 365 days): rewardData(SHIFT).periodFinish == now + 365 days. Attacker transfers 10 wei SHIFT to the FeeSplitter and calls FeeSplitter.distribute(SHIFT). Expected: owner's schedule unchanged, after 7 days earned(alice, SHIFT) is about 7_000e18. Actual: periodFinish becomes now + 7 days (1700604800 instead of 1731536000) and after 7 days earned(alice, SHIFT) is about 365_000e18. Run: forge test --match-path test/scratch/Proof_910e44f30502.t.sol -> FAIL 'owner's stream end was rewritten by an unprivileged caller: 1700604800 != 1731536000'. ETH variant (test/scratch/JudgeChecks.t.sol test_oneWeiReschedulesOwnerStream): owner notifyRewardAmount{value: 365 ether}(address(0), 365 ether, 365 days); any EOA sends 1 wei to ShiftRewards; periodFinish becomes now + 7 days and earned(alice, ETH) after 7 days is 365 ether.","severity":"medium","snippet":"        _notifyStakers(token, toStakers, STREAM_DURATION);","title":"One shared RewardData per token lets any unprivileged dust deposit (1 wei ETH to receive(), or dust SHIFT + distribute()) rewrite the owner's notifyRewardAmount schedule into 7 days and keep every str"},{"citation":"resolved","description":"rewardPerToken returns the stored value while totalSupply == 0 (line 270), but _updateAll (line 287) and _notifyStakers (line 308) still advance lastUpdateTime to lastTimeRewardApplicable, so the part of the stream that elapsed with no stakers is never added to rewardPerTokenStored, never earned by anyone and never re-queued (the top-up branch at lines 314-316 only carries the not-yet-elapsed `remaining * rewardRate`). The amount stays inside accounted[token], so notifyReceived cannot recognise it as new income, it is not in frogsPot, and no function moves it: it is lost for good. This is the launch-day case: trading opens with the first buy, sells immediately generate fees, distribute() is permissionless and will be called, and the 7-day staker streams start before anyone has bought and staked SHIFT; every reward that reaches ShiftRewards before the first stake (half of the 40% rewards share) and every stream segment after the last staker withdraws is stranded. The README records the behaviour but it is still permanently unrecoverable funds. Merged from write_foundry_tests #5, audit_flow #3, audit_math #3, audit_economics #3. Fix: do not advance lastUpdateTime while totalSupply == 0 (pause the clock so the elapsed stream goes to the first staker, as several Synthetix forks do), or track the undistributed amount and roll it into the next _notifyStakers for that token or into frogsPot.","line":270,"path":"src/ShiftRewards.sol","reproduction":"Fresh deployment, nobody staked (test/scratch/JudgeChecks.t.sol test_strandedStreamWhileNobodyStaked). vm.deal(FeeSplitter, 10 ether); distribute(address(0)) -> ShiftRewards receives 4 ETH: frogsPot 2 ETH, 2 ETH stream over 7 days. Warp 8 days. alice stakes 1000e18 SHIFT, warp 7 more days. Expected: the 2 ETH staker half is payable to stakers once they exist (earned(alice, ETH) == 2 ether) or returned to the pot. Actual: earned(alice, ETH) == 0 and accounted(ETH) - frogsPot(ETH) == 2 ether with no function able to pay or re-notify it; the test asserts exactly 2000000000000000000 stuck.","severity":"medium","snippet":"        if (totalSupply == 0) return r.rewardPerTokenStored;","title":"Rewards streamed while totalSupply == 0 are credited to nobody and stay locked in accounted[token] forever"},{"citation":"resolved","description":"For an exact-input sell beforeSwap takes shiftFeeBps/BPS of the seller's whole SHIFT input (line 150). For an exact-output sell afterSwap takes shiftFeeBps/BPS of shiftIn, the SHIFT the pool consumed, and charges it on top (lines 188-192), so relative to what the seller actually pays the rate is 250/10250 = 2.439%. The ETH part is consistent on both paths (the exact-output formula at line 158 grosses up correctly); only the SHIFT part differs, so the same economic sell costs a different hook fee depending on how it is specified and FeeSplitter, stakers and frogs receive about 6 bps less on SWAP_EXACT_OUT_SINGLE sells. The repo's test_sellExactOutputTakesBothFeeParts pins the 250/10250 relation. Merged from write_foundry_tests #3, audit_flow #5, audit_math #5, audit_economics #6. Fix: in the exact-output branch compute shiftFee = shiftIn * shiftFeeBps / (BPS - shiftFeeBps).","line":189,"path":"src/NightshiftHook.sol","reproduction":"Pool from test/NightshiftHook.t.sol setUp (1e22 liquidity, 1:1, fee 12500), trading opened; alice sells exact-output amountSpecified = +1 ether (test/scratch/JudgeChecks.t.sol test_exactOutputShiftFeeRatio). Seller's gross SHIFT paid (-delta.amount1()) = 1064488361262843904; SHIFT received by FeeSplitter = 25963130762508387 = 243.90 bps of gross. Expected 2.5% of the input: 26612209031571097.","severity":"low","snippet":"        uint256 shiftFee = shiftIn * shiftFeeBps / BPS;","title":"Exact-output sells pay a 2.439% SHIFT fee instead of the 2.5% that exact-input sells pay"},{"citation":"resolved","description":"exit() is documented as 'Withdraws all unlocked stake and pays every reward' but calls _withdraw(withdrawable(msg.sender)) unconditionally (line 185) and _withdraw reverts ZeroAmount when amount == 0 (line 193). A staker whose whole balance is still time-locked, or who has already withdrawn but has unpaid rewards, cannot call exit() at all and never reaches the reward loop. getReward() still works so nothing is lost; the brief-listed entry point is unusable in a common state. Merged from audit_permissions #4, audit_flow #7, audit_math #6, audit_economics #7. Fix: only call _withdraw when withdrawable(msg.sender) > 0 (and still run _updateAll).","line":185,"path":"src/ShiftRewards.sol","reproduction":"alice stakes 100e18 SHIFT with lockDays = 30; 10 ETH on the FeeSplitter, distribute(address(0)); warp 1 day; earned(alice, ETH) > 0. alice calls exit() (test/scratch/JudgeChecks.t.sol test_exitRevertsWhenAllLocked). Expected: rewards paid, nothing withdrawn. Actual: revert ZeroAmount().","severity":"low","snippet":"        _withdraw(withdrawable(msg.sender));","title":"exit() reverts with ZeroAmount whenever a staker has nothing unlocked, so a fully locked staker cannot use it to collect rewards"},{"citation":"resolved","description":"The brief gives the keeper one job: swap reserve to IMD on Uniswap with a minImdOut floor. In code setImdPool (line 196-202) accepts any PoolKey whose currencies are (ETH, IMD): fee, tickSpacing and hooks are unconstrained, and Uniswap v4 lets anyone initialize a new ETH/IMD pool with any fee tier or hook. setEthPool (lines 205-214) does the same for non-ETH reserves. burnReserve (lines 131-144) swaps with the extreme sqrtPriceLimit (line 181) and only the keeper-supplied minImdOut as a bound. A keeper can therefore initialize an ETH/IMD pool it alone provides liquidity in (or one with its own hook taking the input via beforeSwapReturnDelta), point imdPool at it and call burnReserve(ETH, reserve, 0): the ETH leaves to the keeper's position or hook, imdOut is near zero, and the call succeeds emitting ImdBurned. This is documented as a privileged-role trust assumption, not a bypass: the owner (dev wallet) appoints the keeper and the brief already lets the keeper pick minImdOut, so the route choice widens a role that could already be abused by sandwiching with minImdOut = 0. Merged from audit_permissions #3, write_foundry_tests #2, audit_flow #4. Options that keep the design: require hooks == address(0) and a standard fee tier on the configured keys, let the owner rather than the keeper set the routes, and require minImdOut > 0.","line":198,"path":"src/FeeSplitter.sol","reproduction":"Owner: setKeeper(K). K calls setImdPool(PoolKey{currency0: ETH, currency1: IMD 0xD34a..., fee: 1_000_000, tickSpacing: 1, hooks: 0xdeadbeef}) (test/scratch/JudgeChecks.t.sol test_keeperMayPointBurnAtHookedPool). Expected per brief: only a legitimate ETH/IMD market is accepted. Actual: the call succeeds and imdPoolSet() == true, only currency0/currency1 are checked. With reserve(ETH) == 10 ether, a pool K initialized with its own hook or single-sided liquidity, and burnReserve(address(0), 10 ether, 0): the 10 ETH is paid into K's pool and the BurnExecutor receives near-zero IMD.","severity":"low","snippet":"        if (Currency.unwrap(key.currency0) != ETH || Currency.unwrap(key.currency1) != IMD) revert BadRoute();","title":"Trust assumption: the keeper chooses the burn route (any ETH/IMD PoolKey including its own hook or pool) as well as minImdOut, so a compromised or malicious keeper can divert the whole burn reserve"},{"citation":"resolved","description":"The brief asks for a plain token with no transfer fee and a hook fee on the SHIFT/ETH pool, so this is inherent to the requested design rather than a coding error. beforeInitialize only governs pools that name this hook (line 125-133): anyone may initialize PoolKey(ETH, SHIFT, anyFee, anySpacing, hooks = address(0)) and add liquidity, and sellers there keep the 5%, so FeeSplitter, stakers and the NIGHT FROGS pot receive nothing from those sells. Recorded so the requester knows that 'on every sell' holds only for the launch pool. From audit_economics #5. No code change required by the brief.","line":128,"path":"src/NightshiftHook.sol","reproduction":"After the first buy: anyone calls PoolManager.initialize(PoolKey(ETH, SHIFT, 3000, 60, hooks = address(0)), price) and adds liquidity; alice sells 10_000 SHIFT exact-in on that pool. Expected per brief's wording: 2.5% SHIFT + 2.5% ETH to FeeSplitter. Actual: FeeSplitter receives 0; the hook is never called.","severity":"info","snippet":"        if (!key.currency0.isAddressZero() || Currency.unwrap(key.currency1) != shift) revert WrongPool();","title":"Design limit: the 5% sell fee exists only on the hooked pool; an unhooked SHIFT/ETH v4 pool (or v2/v3) sells with no hook fee"},{"citation":"resolved","description":"postRoot accepts any root and any total up to the pot (lines 375-387) and claimFrogs only checks the proof against that root (lines 391-405); nothing on chain ties a leaf to a NIGHT FROGS holder. The owner (dev wallet) can therefore move the frogs half of all rewards to itself at any time. This is within the brief ('the owner calls postRoot'), and staked SHIFT and earned staker rewards remain untouchable (accounted/frogsPot bookkeeping holds), but it is the owner's power over 50% of all rewards and should be recorded. From audit_flow #8. A snapshot commitment or timelock on postRoot would narrow it.","line":375,"path":"src/ShiftRewards.sol","reproduction":"frogsPot[ETH] = 5 ether. Owner builds a one-leaf tree root = keccak256(bytes.concat(keccak256(abi.encode(1, owner, address(0), 5e18)))), calls postRoot(1, root, address(0), 5e18) then claimFrogs(1, address(0), 5e18, []) (empty proof, leaf == root). Result: owner receives 5 ETH; the pot is 0.","severity":"info","snippet":"    function postRoot(uint256 epoch, bytes32 root, address token, uint256 total) external onlyOwner {","title":"Trust assumption: the owner can post a Merkle root that assigns the whole NIGHT FROGS pot to addresses it chooses"}],"hash":"3537488441c4146d1c9ed68d2b0445c1e43c7d1afe09d8bdbb34cb9647c7aee1","nodeId":"d5c485e2-c1a6-4995-a177-ca1bb0a6e97c","outcome":"completed","summary":"The review is complete and `.imd-findings.json` is written with 9 findings and a 36-row coverage record (all 34 entry points plus two invariants).\n\n**What I kept, after reproducing each against the code**\n\n- **High, reward-token poisoning through `FeeSplitter.distribute`.** Anyone can register junk ERC-20s as staker reward tokens because the permissionless splitter is ShiftRewards' trusted distributor. Fourteen dust distributions fill the 16 slots, after which IMD and any new token are stuck in the splitter forever. One reverting token also bricks `getReward()` and `exit()` for every staker. Six specialist reports merged into this one. Proof attached and failing on current code.\n- **Medium, sell fee sized from `amountSpecified`.** On a partial fill the seller pays 2.5% on SHIFT or ETH that never traded. In the specialist's launch scenario the fee was 96% of the seller's outlay. I lowered this from high because the V4 Quoter reverts with NotEnoughLiquidity on partial fills, so a quoted trade is protected. Proof attached.\n- **Medium, stream rescheduling by dust.** One `RewardData` per token means 1 wei of ETH to `receive()` or dust SHIFT plus `distribute()` collapses an owner's 365-day stream into 7 days, and repeated dust keeps streams from finishing. Five reports merged. Proof attached. My own test confirmed the ETH variant pays the full 365 ETH in a week.\n- **Medium, stranded stream while nobody is staked.** Rewards that elapse with totalSupply at zero stay in `accounted` with no claimant and no recovery path. Confirmed 2 ETH stuck in my test.\n- **Low** findings for the 2.439% exact-output SHIFT fee, `exit()` reverting when all stake is locked, and the keeper's free choice of burn route including hooked pools. Two **info** entries record the unhooked-pool fee bypass and the owner's power over the frogs pot.\n\n**What I checked myself and found sound**\n\nHook delta accounting nets to zero on all four sell paths, the first-buy gate and trading-open logic, beforeInitialize restrictions, both unlockCallbacks, claimFrogs and settleEpoch windows, lock accounting, and the manifest. Creation code is 25,927 bytes plus args and runtime 6,721 bytes, both within the deployment limits. Slither's uninitialized-state and storage-array leads are false positives.\n\n**Dropped or merged**\n\nNothing was dropped outright. Every specialist finding reproduced, but 30 reports collapsed into 9 by root cause. The audit_math claim that the Quoter returns partially filled output is incorrect and I noted the correction in finding 2.","treeHash":null,"usage":{"cachedInputTokens":1067109,"inputTokens":258,"model":"claude-fable-5-1","outputTokens":43132,"runtime":"claude","turns":22,"wallClockMs":689554}},{"artifacts":[],"attempt":1,"bundleHash":"5991492e62def489f49c696ce5ae174b413c301fdfbf1d5e5c3922107d29d6ce","device":"dd0f9bb0bf93f9dd","findings":[],"hash":"3aaff3dbe9af821b8f57c613f5847b48cb45379654af488145894ed016ec5c4a","nodeId":"0f605814-3978-4af3-9036-a1b577aea6f2","outcome":"completed","summary":"Created [launch.json](/home/imd-worker/.identitymd/work/a7490fc5-0042-40c0-96c5-01df79178086/0f605814-3978-4af3-9036-a1b577aea6f2/launch.json) with matching constructor arguments, permissions, token metadata and required pool settings. Notes fit the cap.\n\n`forge build` succeeded; all 52 tests passed with the supplied fuzz seed. Only `launch.json` is a deliverable change.","treeHash":"3916399357a96d10295eb58740bb854101832dac","usage":{"cachedInputTokens":505600,"inputTokens":59735,"model":"gpt-6-astra","outputTokens":4882,"runtime":"codex","turns":4,"wallClockMs":120105}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9e17b97b8ac7c97a","findings":[{"citation":"resolved","description":"beforeSwap sizes the SHIFT part of the sell fee from params.amountSpecified (line 150) and, for exact-output sells, the ETH part from params.amountSpecified too (line 158: `uint256 ethFee = uint256(params.amountSpecified) * ethFeeBps / (BPS - ethFeeBps);`), takes it from the PoolManager at once, and returns it as a BeforeSwapDelta. Uniswap v4 does not revert on a partial fill: when the pool runs out of liquidity in the sell direction it stops at the price limit and the swapper only settles the amount actually consumed. The hook's fee is not reduced, so the seller pays 2.5% of the whole specified amount while the pool took only a fraction of it. The launch pool is the scenario where this happens by design: the launch seeds SHIFT only (see test_firstBuyWorksOnTokensOnlyPool), so the ETH side holds exactly what buyers have paid in; any sell larger than that is a partial fill. The Universal Router's SWAP_EXACT_IN_SINGLE uses the extreme price limit and only checks amountOutMinimum, and the V4 Quoter returns the partially filled output, so a seller quoting and then selling receives the quoted ETH and silently loses the fee on the unconsumed SHIFT. Measured on a one-sided seed (liquidity 1e23 in [minTick, -60]) after the first buyer's 1 ETH buy: exact-in sell of 1000 SHIFT -> pool took 0.994 SHIFT, hook kept 25 SHIFT, seller paid 25.994 SHIFT for 0.97 ETH (fee is 96% of what was paid instead of 2.5%). Exact-out request of 5 ETH -> pool delivered 0.9875 ETH, hook kept 0.128205 ETH (= 5e18*250/9750), seller received 0.859 ETH (fee 13% of the delivered ETH). Second consequence of the same sizing: for an exact-in sell the SHIFT fee is taken from the manager before the seller settles, so when 2.5% of amountSpecified exceeds the SHIFT the manager holds the swap reverts with ERC20InsufficientBalance (manager holding 99,699 SHIFT, sell of 5,000,000 SHIFT -> fee 125,000 -> revert), although the trade itself would have been a valid partial fill. Fix: size the fee from what the pool actually moved, not from the request. For sells, defer the fee to afterSwap and derive it from the swap delta (SHIFT part from -delta.amount1(), ETH part from delta.amount0()), or in afterSwap compare the consumed input against amountSpecified and revert on a partial fill so nobody is overcharged.","line":150,"path":"src/NightshiftHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {BalanceDelta} from \"v4-core/src/types/BalanceDelta.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {NightshiftHook} from \"src/NightshiftHook.sol\";\nimport {FeeSplitter} from \"src/FeeSplitter.sol\";\nimport {ShiftToken} from \"src/ShiftToken.sol\";\n\n/// @dev Minimal router: swaps and adds liquidity on a PoolManager, paying from its own balances.\ncontract ScratchRouter is IUnlockCallback {\n    IPoolManager public immutable manager;\n\n    constructor(IPoolManager m) {\n        manager = m;\n    }\n\n    receive() external payable {}\n\n    function swapExact(PoolKey memory key, bool zeroForOne, int256 amountSpecified) public returns (BalanceDelta) {\n        SwapParams memory params = SwapParams({\n            zeroForOne: zeroForOne,\n            amountSpecified: amountSpecified,\n            sqrtPriceLimitX96: zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1\n        });\n        bytes memory result = manager.unlock(abi.encode(true, abi.encode(key, params)));\n        return abi.decode(result, (BalanceDelta));\n    }\n\n    function addLiquidity(PoolKey memory key, int24 lo, int24 hi, int256 liquidity) external returns (BalanceDelta) {\n        ModifyLiquidityParams memory params =\n            ModifyLiquidityParams({tickLower: lo, tickUpper: hi, liquidityDelta: liquidity, salt: 0});\n        bytes memory result = manager.unlock(abi.encode(false, abi.encode(key, params)));\n        return abi.decode(result, (BalanceDelta));\n    }\n\n    function unlockCallback(bytes calldata data) external returns (bytes memory) {\n        require(msg.sender == address(manager), \"not manager\");\n        (bool isSwap, bytes memory inner) = abi.decode(data, (bool, bytes));\n        BalanceDelta delta;\n        PoolKey memory key;\n        if (isSwap) {\n            SwapParams memory params;\n            (key, params) = abi.decode(inner, (PoolKey, SwapParams));\n            delta = manager.swap(key, params, \"\");\n        } else {\n            ModifyLiquidityParams memory params;\n            (key, params) = abi.decode(inner, (PoolKey, ModifyLiquidityParams));\n            (delta,) = manager.modifyLiquidity(key, params, \"\");\n        }\n        _settle(key.currency0, delta.amount0());\n        _settle(key.currency1, delta.amount1());\n        return abi.encode(delta);\n    }\n\n    function _settle(Currency currency, int128 amount) internal {\n        if (amount < 0) {\n            uint256 owed = uint256(uint128(-amount));\n            manager.sync(currency);\n            if (currency.isAddressZero()) {\n                manager.settle{value: owed}();\n            } else {\n                IERC20(Currency.unwrap(currency)).transfer(address(manager), owed);\n                manager.settle();\n            }\n        } else if (amount > 0) {\n            manager.take(currency, address(this), uint256(uint128(amount)));\n        }\n    }\n}\n\ncontract PartialFillFeeTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    uint24 constant POOL_FEE = 12_500;\n    int24 constant SPACING = 60;\n    uint160 constant ALL_FLAGS = uint160((1 << 14) - 1);\n    // beforeInitialize | beforeSwap | afterSwap | beforeSwapReturnDelta | afterSwapReturnDelta\n    uint160 constant FLAGS = (1 << 13) | (1 << 7) | (1 << 6) | (1 << 3) | (1 << 2);\n\n    address constant OWNER = 0x6C4c78f2C3bDf4C4318Fe305FB1C8BC1694d19b5;\n    address constant FIRST_BUYER = 0x3322E6DD06Ade8377F1f1BCb766f6133d7b64D69;\n\n    PoolManager manager;\n    ShiftToken shift;\n    NightshiftHook hook;\n    FeeSplitter splitter;\n    ScratchRouter router;\n    PoolKey key;\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        manager = new PoolManager(address(this));\n        shift = new ShiftToken();\n        bytes memory creationCode = abi.encodePacked(\n            type(NightshiftHook).creationCode, abi.encode(manager, address(shift), OWNER, address(this))\n        );\n        bytes32 initCodeHash = keccak256(creationCode);\n        bytes32 salt;\n        for (uint256 i = 0; i < 500_000; i++) {\n            address predicted = address(\n                uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(i), initCodeHash))))\n            );\n            if ((uint160(predicted) & ALL_FLAGS) == FLAGS) {\n                salt = bytes32(i);\n                break;\n            }\n        }\n        hook = new NightshiftHook{salt: salt}(IPoolManager(address(manager)), address(shift), OWNER, address(this));\n        splitter = hook.feeSplitter();\n        key = PoolKey({\n            currency0: Currency.wrap(address(0)),\n            currency1: Currency.wrap(address(shift)),\n            fee: POOL_FEE,\n            tickSpacing: SPACING,\n            hooks: IHooks(address(hook))\n        });\n        manager.initialize(key, SQRT_PRICE_1_1); // this test contract plays the launch factory\n        router = new ScratchRouter(manager);\n        // Launch seed: SHIFT only, in a range below the opening price (no ETH in the pool yet).\n        shift.transfer(address(router), 1_000_000e18);\n        router.addLiquidity(key, TickMath.minUsableTick(SPACING), -SPACING, 1e23);\n        vm.deal(address(router), 100 ether);\n        // The first buyer opens trading with a 1 ETH buy.\n        vm.prank(FIRST_BUYER, FIRST_BUYER);\n        router.swapExact(key, true, -1 ether);\n        assertTrue(hook.tradingOpen());\n    }\n\n    /// @notice An exact-input sell that the pool can only partly fill is charged the SHIFT fee on the whole\n    /// specified amount, not on the SHIFT the pool actually took.\n    function test_partialFillChargesShiftFeeOnUnconsumedInput() public {\n        uint256 specified = 1_000e18; // far more than the 1 ETH of liquidity on the ETH side can absorb\n        uint256 feeBefore = shift.balanceOf(address(splitter));\n        vm.prank(alice, alice);\n        BalanceDelta d;\n        // A fix that refuses partial fills instead of repricing the fee is acceptable: then nobody is overcharged.\n        try router.swapExact(key, false, -int256(specified)) returns (BalanceDelta res) {\n            d = res;\n        } catch {\n            return;\n        }\n\n        uint256 shiftFee = shift.balanceOf(address(splitter)) - feeBefore;\n        uint256 sellerPaid = uint256(uint128(-d.amount1()));\n        uint256 poolTook = sellerPaid - shiftFee;\n        emit log_named_uint(\"SHIFT specified      \", specified);\n        emit log_named_uint(\"SHIFT the pool took  \", poolTook);\n        emit log_named_uint(\"SHIFT fee to splitter\", shiftFee);\n        emit log_named_uint(\"SHIFT seller paid    \", sellerPaid);\n\n        // The swap was a partial fill: the pool took far less than the seller specified.\n        assertLt(poolTook, specified / 10, \"expected a partial fill\");\n        // Spec: the hook takes 2.5% of the SHIFT input. The fee must not exceed 2.5% of what the seller paid.\n        assertLe(shiftFee, sellerPaid * 250 / 10_000 + 1, \"SHIFT fee exceeds 2.5% of the SHIFT actually sold\");\n    }\n}","reproduction":"Setup: PoolManager, ShiftToken, NightshiftHook (owner = dev wallet, factory = test), pool ETH/SHIFT fee 12500 spacing 60 at price 1:1; add liquidity 1e23 in ticks [minUsableTick, -60] (SHIFT only, no ETH); first buyer (tx.origin 0x3322...) buys with 1 ETH exact-in, trading opens. Then alice (tx.origin alice) sells exact-in amountSpecified = -1000e18 with sqrtPriceLimit = MAX_SQRT_PRICE-1 (what the Universal Router passes). Expected: SHIFT fee = 2.5% of the SHIFT actually input (about 0.025 SHIFT) and the unconsumed 999 SHIFT stays with alice. Actual: FeeSplitter receives 25e18 SHIFT, the swap delta charges alice 25.994e18 SHIFT, the pool consumed 0.994e18. Exact-out: alice requests amountSpecified = +5e18 ETH: expected fee 2.5% of the ETH delivered (0.0247 ETH of 0.9875); actual FeeSplitter receives 0.128205e18 ETH and alice gets 0.859e18. Revert case: after transferring 5,000,000e18 SHIFT to the router, alice sells exact-in 5,000,000e18: expected a (partial) fill; actual the swap reverts inside beforeSwap's poolManager.take because 125,000e18 > 99,699e18 SHIFT held by the manager. Run: forge test --match-path test/scratch/PartialFillFee.t.sol (fails on the current code).","severity":"high","snippet":"            uint256 shiftFee = uint256(-params.amountSpecified) * shiftFeeBps / BPS;","title":"Sell fee is computed on amountSpecified, so a partially filled sell is charged on SHIFT/ETH that never traded, and a large sell reverts outright"},{"citation":"resolved","description":"FeeSplitter.distribute(token) is permissionless and accepts any address with ERC-20 shaped code: it forwards 40% to ShiftRewards and calls rewards.notifyReceived(token) (FeeSplitter.sol line 105), which calls _split -> _notifyStakers -> _addRewardToken(token) (line 304) with no allowlist. Every staker then earns that token, and getReward() / exit() loop over rewardTokens and call _payReward -> _send -> safeTransfer for each token with a non-zero balance. Boundary: the external call `IERC20(token).safeTransfer(to, amount)` on an attacker-controlled token. The code assumes the transfer succeeds; the attacker's token can revert (or be paused/blocklisted later), and then getReward() and exit() revert for every staker who has earned any of it, permanently, because there is no way to remove a reward token or skip a failing transfer. getReward(address) and withdraw(uint256) still work, so ETH and SHIFT rewards remain claimable token by token, but the documented getReward() and exit() entry points are dead. Second effect: MAX_REWARD_TOKENS is 16, ETH and SHIFT take two slots, so 14 dust distributions of junk tokens fill the list; afterwards distribute(IMD) or any new token reverts with TooManyRewardTokens in notifyReceived (the 40% leg is already transferred when it reverts, so the whole distribute reverts and that token is stuck in the splitter), and every stake/withdraw/getReward walks 16 tokens for the rest of the contract's life. Fix: only accept reward tokens from an owner/distributor-approved list (or only ETH, SHIFT, IMD), and make _payReward tolerate a failing transfer (try/catch, leaving the reward credited) so one token cannot block the others.","line":304,"path":"src/ShiftRewards.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 {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {ShiftRewards} from \"src/ShiftRewards.sol\";\nimport {FeeSplitter} from \"src/FeeSplitter.sol\";\nimport {ShiftToken} from \"src/ShiftToken.sol\";\n\n/// @dev An ERC-20 whose transfers the deployer can switch off after it has been registered as a reward token.\ncontract EvilToken is ERC20 {\n    bool public blocked;\n\n    constructor() ERC20(\"Evil\", \"EVIL\") {\n        _mint(msg.sender, 1_000_000e18);\n    }\n\n    function setBlocked(bool b) external {\n        blocked = b;\n    }\n\n    function _update(address from, address to, uint256 value) internal override {\n        require(!blocked, \"EVIL: blocked\");\n        super._update(from, to, value);\n    }\n}\n\ncontract EvilRewardTokenTest is Test {\n    address constant OWNER = 0x6C4c78f2C3bDf4C4318Fe305FB1C8BC1694d19b5;\n    address constant ETH = address(0);\n\n    ShiftToken shift;\n    ShiftRewards rewards;\n    FeeSplitter splitter;\n    address alice = makeAddr(\"alice\");\n    address attacker = makeAddr(\"attacker\");\n\n    function setUp() public {\n        vm.warp(1_700_000_000);\n        shift = new ShiftToken();\n        rewards = new ShiftRewards(address(shift), OWNER);\n        // The pool manager is not touched by distribute(); any address will do here.\n        splitter = new FeeSplitter(IPoolManager(address(0xBEEF)), address(shift), address(rewards), OWNER);\n        rewards.setDistributor(address(splitter));\n\n        shift.transfer(alice, 1_000e18);\n        vm.startPrank(alice);\n        shift.approve(address(rewards), type(uint256).max);\n        rewards.stake(1_000e18, 0);\n        vm.stopPrank();\n\n        // Real fee income: 10 ETH reaches the splitter and is distributed.\n        vm.deal(address(splitter), 10 ether);\n        splitter.distribute(ETH);\n    }\n\n    function test_anyoneCanRegisterARevertingRewardTokenAndBrickGetReward() public {\n        // Attacker deploys a token, drops 1 EVIL on the splitter and has it distributed: distribute() is\n        // permissionless and forwards any ERC-20 to ShiftRewards.notifyReceived, which registers it.\n        vm.startPrank(attacker);\n        EvilToken evil = new EvilToken();\n        evil.transfer(address(splitter), 1e18);\n        // A fix that refuses unknown tokens here (or does not register them) is acceptable.\n        try splitter.distribute(address(evil)) {}\n        catch {\n            vm.stopPrank();\n            return;\n        }\n        vm.stopPrank();\n        if (!rewards.isRewardToken(address(evil))) return;\n\n        // Alice earns some EVIL alongside her ETH, then the attacker switches EVIL transfers off.\n        vm.warp(block.timestamp + 1 days);\n        assertGt(rewards.earned(alice, address(evil)), 0);\n        uint256 ethEarned = rewards.earned(alice, ETH);\n        assertGt(ethEarned, 0);\n        vm.prank(attacker);\n        evil.setBlocked(true);\n\n        // getReward() must still pay Alice her ETH; on the current code it reverts on the EVIL transfer.\n        vm.prank(alice);\n        rewards.getReward();\n        assertEq(alice.balance, ethEarned, \"ETH reward not paid through getReward()\");\n\n        // exit() is bricked the same way.\n        vm.warp(block.timestamp + 1 days);\n        vm.prank(alice);\n        rewards.exit();\n        assertEq(rewards.balanceOf(alice), 0);\n    }\n}","reproduction":"Setup: ShiftToken, ShiftRewards(shift, owner), FeeSplitter(anyPoolManager, shift, rewards, owner), rewards.setDistributor(splitter); alice stakes 1000 SHIFT with lock 0; 10 ETH on the splitter, distribute(address(0)). Attacker deploys EvilToken (ERC-20 with a switch that makes _update revert), transfers 1e18 EVIL to the splitter and calls splitter.distribute(EVIL): succeeds, rewards.isRewardToken(EVIL) == true. Warp 1 day: earned(alice, EVIL) > 0 and earned(alice, ETH) > 0. Attacker flips the switch. alice calls getReward(): expected her ETH is paid; actual the call reverts with 'EVIL: blocked'. alice calls exit(): same revert. Run: forge test --match-path test/scratch/EvilRewardToken.t.sol (fails on the current code).","severity":"medium","snippet":"        _addRewardToken(token);","title":"Anyone can register an arbitrary ERC-20 as a staker reward token through FeeSplitter.distribute; a reverting token bricks getReward() and exit() for every staker and the 16-token list can be filled"},{"citation":"resolved","description":"Seam boundary x invariant. The invariant the contract keeps is accounted[token] == stakes + unpaid stream + frogsPot + posted epochs, i.e. everything it holds is owed to someone. rewardPerToken() returns the stored value while totalSupply == 0 (line 270), but _updateAll and _notifyStakers still advance r.lastUpdateTime to lastTimeRewardApplicable, so the part of the stream that elapsed with no stakers is skipped: it is never added to rewardPerTokenStored, never earned by anyone, and never re-queued (the top-up branch at line 314-316 only carries `remaining * rewardRate`, the not-yet-elapsed part). The skipped amount remains inside accounted[token], so notifyReceived cannot see it as new income either, and there is no owner or anyone path that moves it. This is reachable in normal operation: sell fees flow from the first trade, distribute() is permissionless and will be called by bots, and the first stake typically comes later. Every reward that reaches ShiftRewards before the first stake (half of 40% of all fees in that window) and every stream segment during which the last staker has withdrawn is lost. Numbers: 10 ETH received with totalSupply == 0 -> frogsPot 5 ETH, stream 5 ETH over 7 days at rewardRate 5e18*1e18/604800. After 7 days alice stakes; after 7 more days earned(alice, ETH) == 0, contract balance 10 ETH, frogsPot 5 ETH, 5 ETH unreachable. Fix: when totalSupply == 0 do not advance lastUpdateTime (so the elapsed time is credited to the first staker, as several Synthetix forks do), or track the undistributed amount and roll it into the next _notifyStakers for that token.","line":270,"path":"src/ShiftRewards.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ShiftRewards} from \"src/ShiftRewards.sol\";\nimport {ShiftToken} from \"src/ShiftToken.sol\";\n\ncontract LostStreamTest is Test {\n    address constant OWNER = 0x6C4c78f2C3bDf4C4318Fe305FB1C8BC1694d19b5;\n    address constant ETH = address(0);\n\n    ShiftToken shift;\n    ShiftRewards rewards;\n    address distributor = makeAddr(\"distributor\");\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_700_000_000);\n        shift = new ShiftToken();\n        rewards = new ShiftRewards(address(shift), OWNER);\n        rewards.setDistributor(distributor);\n        shift.transfer(alice, 1_000e18);\n        vm.prank(alice);\n        shift.approve(address(rewards), type(uint256).max);\n    }\n\n    /// @notice 10 ETH of fee income arrives while nobody is staked. Half (5 ETH) streams to stakers over 7 days\n    /// with totalSupply == 0, so it is credited to nobody; it stays in `accounted[ETH]` and no path can ever\n    /// pay it out or re-stream it.\n    function test_streamWithNoStakersIsLockedForever() public {\n        vm.deal(distributor, 10 ether);\n        vm.prank(distributor);\n        (bool ok,) = address(rewards).call{value: 10 ether}(\"\");\n        assertTrue(ok);\n        assertEq(rewards.frogsPot(ETH), 5 ether);\n\n        // The whole 7-day stream elapses with totalSupply == 0.\n        vm.warp(block.timestamp + 7 days);\n\n        // Now the first staker arrives and stays through another full week. A further 1 ETH of income\n        // arrives after she staked, so a fix that re-queues the undistributed part on the next notify\n        // also gets its chance to pay it out.\n        vm.prank(alice);\n        rewards.stake(1_000e18, 0);\n        vm.deal(distributor, 1 ether);\n        vm.prank(distributor);\n        (ok,) = address(rewards).call{value: 1 ether}(\"\");\n        assertTrue(ok);\n        vm.warp(block.timestamp + 7 days);\n\n        uint256 claimable = rewards.earned(alice, ETH);\n        uint256 stuck = address(rewards).balance - rewards.frogsPot(ETH) - claimable;\n        emit log_named_uint(\"claimable by the only staker\", claimable);\n        emit log_named_uint(\"ETH streamed to nobody      \", stuck);\n\n        // The 5 ETH staker half must still be payable to stakers (up to rounding dust); on the current\n        // code it is locked.\n        assertLe(stuck, 1e9, \"staker half of the reward is locked in the contract\");\n    }\n}","reproduction":"Setup: ShiftToken, ShiftRewards(shift, owner), setDistributor(distributor). Distributor sends 10 ether to ShiftRewards (receive). vm.warp(+7 days); alice stakes 1000e18 lock 0; distributor sends 1 more ether (a fresh notify, so a re-queue fix gets its chance); vm.warp(+7 days). Expected: the 5 ETH staker half is payable to stakers (earned(alice, ETH) == 5.5e18, or re-streamed by the fresh notify). Actual: earned(alice, ETH) == 0.5e18 only; address(rewards).balance - frogsPot(ETH) - earned == 5e18 and no function can pay or re-notify it. Run: forge test --match-path test/scratch/LostStream.t.sol (fails on the current code).","severity":"medium","snippet":"        if (totalSupply == 0) return r.rewardPerTokenStored;","title":"Rewards streamed while totalSupply is zero are credited to nobody and stay locked in accounted[] forever"},{"citation":"resolved","description":"receive() (line 326) and FeeSplitter.distribute() are permissionless and each call ends in _notifyStakers(token, amount, STREAM_DURATION), which spreads `scaled + leftover` over a fresh 7 days and sets periodFinish = now + 7 days (line 319). There is no minimum amount, so a 1-wei ETH transfer (or a 1-wei SHIFT transfer to the splitter followed by distribute(shift)) resets the stream. Each reset multiplies the remaining rate by remaining/7d, so a griefer poking once a day delivers only 1 - (6/7)^7 = 66% of a stream in the week it should have completed in, and the tail never finishes. Measured: 14 ETH received with 1000 SHIFT staked (7 ETH stream, 1 ETH/day); 1 wei sent each day for 7 days -> earned after 7 days = 4.6206 ETH instead of 7 ETH, and periodFinish is still 7 days away. Nothing is lost permanently, so this is a timing/dilution grief rather than a loss. Fix: require a minimum reward amount for a notify (e.g. amount * PRECISION / duration > 0 and amount >= some dust floor), or only extend the period when the new amount is material relative to the leftover.","line":316,"path":"src/ShiftRewards.sol","reproduction":"Setup: hook-deployed ShiftRewards; alice stakes 1000e18; vm.warp(1_700_000_000); send 14 ether to ShiftRewards. Loop 7 times: vm.warp(+1 days); griefer sends 1 wei to ShiftRewards. Expected after 7 days: earned(alice, ETH) ~= 7e18 and the period finished. Actual: earned(alice, ETH) = 4620583260376201000 and rewardData(ETH).periodFinish - block.timestamp == 604800.","severity":"low","snippet":"            r.rewardRate = (scaled + leftover) / duration;","title":"Any 1-wei deposit restarts the 7-day staker stream, so repeated dust deposits keep rewards perpetually diluted"},{"citation":"resolved","description":"Precision x invariant: the two sell paths are meant to charge the same rate, '2.5% of the SHIFT input'. For an exact-input sell beforeSwap takes shiftFeeBps/BPS of the seller's total SHIFT (line 150). For an exact-output sell afterSwap takes shiftFeeBps/BPS of shiftIn, the pool's SHIFT charge, and adds it on top (line 189-192), so relative to what the seller actually pays the rate is 250/10250 = 2.439%. The ETH part is consistent on both paths (2.5% of the gross output; the exact-output formula at line 158 grosses up correctly), only the SHIFT part differs. A seller choosing exact-output pays 0.061 percentage points less than one choosing exact-input for the same trade. Measured: exact-output request of 0.5 ETH -> seller paid 529102948860235339 SHIFT, fee 12904949972200861 SHIFT = 2.439% of paid (the repo's own test_sellExactOutputTakesBothFeeParts asserts this 250/10250 relation). Fix: in the exact-output branch compute shiftFee = shiftIn * shiftFeeBps / (BPS - shiftFeeBps) so the fee is 2.5% of the seller's total SHIFT on both paths.","line":189,"path":"src/NightshiftHook.sol","reproduction":"Same pool setup as finding 1 (or the repo's NightshiftHookTest setUp), trading open. alice sells exact-output amountSpecified = +0.5e18 ETH. Expected: FeeSplitter SHIFT fee == 2.5% of -delta.amount1() (the SHIFT alice paid) = 13227573721505883. Actual: 12904949972200861 (2.439%).","severity":"low","snippet":"        uint256 shiftFee = shiftIn * shiftFeeBps / BPS;","title":"Exact-output sells pay a 2.439% SHIFT fee instead of the 2.5% that exact-input sells pay"},{"citation":"resolved","description":"Boundary: zero input into the withdraw fast path. exit() always calls _withdraw(withdrawable(msg.sender)) and _withdraw reverts on amount == 0 (line 193), so a staker whose whole stake is still locked cannot call exit() at all, even though exit() is also the 'pay every reward' function. getReward() covers the reward part, so no funds are at risk; this is a usability gap that integrators calling exit() will hit.","line":185,"path":"src/ShiftRewards.sol","reproduction":"alice stakes 100e18 with lockDays = 30; vm.warp(+1 days); alice calls exit(). Expected: rewards paid and nothing withdrawn (or only the rewards). Actual: revert ZeroAmount().","severity":"info","snippet":"        _withdraw(withdrawable(msg.sender));","title":"exit() reverts with ZeroAmount while all of a staker's balance is locked, so it cannot be used to collect rewards during a lock"}],"hash":"42eea7b9552f28abb532399a558b856ffdc4abfa4ca330648eb3711c386ea2fb","nodeId":"1daf73e5-6e85-448c-9b62-faeab49ceb43","outcome":"completed","summary":"Everything is in place. Review complete; nothing in `src/` was changed, and the findings file plus three proof tests under `test/scratch/` are written and verified.\n\n**Outcome:** `.imd-findings.json` holds 6 findings (1 high, 2 medium, 2 low, 1 info) and 36 coverage rows covering all 34 listed entry points plus two invariants. Every snippet was checked against the tree, and all three proof tests fail on the current code.\n\n**Findings**\n\n1. **High, NightshiftHook.beforeSwap line 150.** The sell fee is sized from `amountSpecified`, not from what the pool moved. On a partial fill the seller pays 2.5% of the whole request: a 1000 SHIFT sell on the token-only launch seed consumed 0.994 SHIFT but the hook kept 25 SHIFT. Exact-output has the same flaw with ETH (0.128 ETH kept of a 0.987 ETH delivery). The same sizing makes a sell whose fee exceeds the manager's SHIFT revert outright. Proof: `test/scratch/PartialFillFee.t.sol`.\n2. **Medium, ShiftRewards line 304.** Anyone can register an arbitrary ERC-20 as a reward token via the permissionless `FeeSplitter.distribute`. A token that later reverts on transfer bricks `getReward()` and `exit()` for every staker, and 14 dust tokens fill the 16-slot list so new tokens such as IMD can never be distributed. Proof: `test/scratch/EvilRewardToken.t.sol`.\n3. **Medium, ShiftRewards line 270.** Stream time that elapses with zero stakers is skipped but stays inside `accounted`, so it is owed to nobody and can never be paid or re-streamed. 10 ETH received before the first stake leaves 5 ETH locked. Proof: `test/scratch/LostStream.t.sol`.\n4. **Low, ShiftRewards line 316.** Any 1-wei deposit restarts the 7-day stream. Daily pokes delivered 4.62 ETH of a 7 ETH stream in its week and pushed the finish another 7 days out.\n5. **Low, NightshiftHook line 189.** Exact-output sells pay a 2.439% SHIFT fee against the 2.5% exact-input sells pay.\n6. **Info, ShiftRewards line 185.** `exit()` reverts with `ZeroAmount` while all stake is locked.\n\n**Coverage.** All FeeSplitter, hook, ShiftRewards and token entry points were traced for the Math Precision, Boundary and Numerical Gap checklists. The rate, split, gross-up, lock, epoch and two-hop burn math otherwise held. Nothing was left unreached. Static-analysis leads outside these (tx.origin, reentrancy in distribute, the uint128 lock cast) were checked and did not reproduce as defects: the cast is unreachable with the fixed 1e27 supply, and distribute's external calls go to the trusted rewards contract and the two fixed wallets under `nonReentrant`.","treeHash":null,"usage":{"cachedInputTokens":2953167,"inputTokens":738,"model":"claude-fable-5-1","outputTokens":60339,"runtime":"claude","turns":34,"wallClockMs":821014}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3516474d8a268bd8","findings":[{"citation":"resolved","description":"_notifyStakers keeps one RewardData (rewardRate, periodFinish) per token and every notifier rewrites it: the whole remaining leftover is re-spread over the caller's duration. The owner's notifyRewardAmount(token, amount, duration) promises a stream over `duration` (up to 365 days), but two unprivileged paths call _notifyStakers with STREAM_DURATION = 7 days on the same token: ShiftRewards.receive() (open to anyone, line 326-329) for ETH, and FeeSplitter.distribute(token) (anyone, after sending 1 wei of that token to the splitter) -> notifyReceived -> _split for ERC-20s including SHIFT. So whoever is staked at the moment can compress a year of owner-funded rewards into one week and capture them before later stakers arrive (stake large, donate 1 wei, wait 7 days, exit). The inverse also holds: a fee inflow that lands while a long owner stream is active is itself compressed, so the stated 7-day / N-day schedules are never guaranteed. Fix: track the splitter stream and owner streams separately (e.g. per (token, source) RewardData, or disallow a notify that shortens periodFinish when the remaining leftover dominates the new amount), and gate receive() to the distributor/owner.","line":316,"path":"src/ShiftRewards.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ShiftRewards} from \"src/ShiftRewards.sol\";\nimport {ShiftToken} from \"src/ShiftToken.sol\";\n\n/// @notice An unprivileged actor collapses the owner's year-long staker stream into 7 days with 1 wei of ETH.\n///\n/// `ShiftRewards.receive()` is open to anyone and calls `_notifyStakers(ETH, msg.value, STREAM_DURATION)`,\n/// which rewrites the single per-token `RewardData` (rate, periodFinish) shared with the owner's\n/// `notifyRewardAmount` stream: the whole remaining leftover is re-spread over 7 days.\ncontract StreamCompressionTest is Test {\n    address constant OWNER = 0x6C4c78f2C3bDf4C4318Fe305FB1C8BC1694d19b5;\n    address constant ETH = address(0);\n\n    ShiftToken shift;\n    ShiftRewards rewards;\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address attacker = makeAddr(\"attacker\");\n\n    function setUp() public {\n        vm.warp(1_700_000_000);\n        shift = new ShiftToken();\n        rewards = new ShiftRewards(address(shift), OWNER);\n        rewards.setDistributor(makeAddr(\"distributor\"));\n        shift.transfer(alice, 1_000e18);\n        shift.transfer(bob, 1_000e18);\n        vm.prank(alice);\n        shift.approve(address(rewards), type(uint256).max);\n        vm.prank(bob);\n        shift.approve(address(rewards), type(uint256).max);\n    }\n\n    function test_unprivilegedActorCannotCollapseOwnerStreamInto7Days() public {\n        // Alice is the only staker now; Bob intends to stake in a month.\n        vm.prank(alice);\n        rewards.stake(1_000e18, 0);\n\n        // Owner streams 365 ETH to stakers over 365 days: ~1 ETH per day.\n        vm.deal(OWNER, 365 ether);\n        vm.prank(OWNER);\n        rewards.notifyRewardAmount{value: 365 ether}(ETH, 365 ether, 365 days);\n        (uint256 finishBefore,,,) = rewards.rewardData(ETH);\n        assertEq(finishBefore, block.timestamp + 365 days);\n\n        // Attacker (colluding with Alice, or Alice herself) sends 1 wei to the open receive().\n        vm.deal(attacker, 1);\n        vm.prank(attacker);\n        // Whether the contract accepts or rejects the wei is not the point; the schedule must not move.\n        address(rewards).call{value: 1}(\"\");\n\n        // The owner's schedule must survive an unprivileged 1-wei donation.\n        (uint256 finishAfter,,,) = rewards.rewardData(ETH);\n        assertEq(finishAfter, finishBefore, \"a 1 wei donation rescheduled the owner's 365-day stream\");\n\n        // Economic consequence: after 7 days Alice should have about 7 ETH, not the whole year.\n        vm.warp(block.timestamp + 7 days);\n        uint256 earned = rewards.earned(alice, ETH);\n        assertLe(earned, 8 ether, \"Alice collected the entire year's rewards in one week\");\n    }\n}","reproduction":"State: Alice staked 1000 SHIFT (lock 0). Owner: notifyRewardAmount{value: 365 ether}(address(0), 365 ether, 365 days) -> rewardData(ETH).periodFinish = now + 365 days, rate ~1 ETH/day. Attacker (any EOA, 1 wei): address(rewards).call{value: 1}(\"\"). Actual: periodFinish becomes now + 7 days and rewardRate = (1e18 + 365e36)/604800, i.e. ~52 ETH/day; after 7 days earned(alice, ETH) ~= 365 ETH. Expected: periodFinish unchanged (now + 365 days) and earned after 7 days ~= 7 ETH. Same for SHIFT: owner notifyRewardAmount(SHIFT, 365e18, 365 days); attacker transfers 1 wei SHIFT to FeeSplitter and calls distribute(SHIFT) -> notifyReceived -> _notifyStakers(SHIFT, 1, 7 days). Proof test: test/scratch/StreamCompression.t.sol fails on the current code with 'a 1 wei donation rescheduled the owner's 365-day stream'.","severity":"medium","snippet":"            r.rewardRate = (scaled + leftover) / duration;\n        }\n        r.lastUpdateTime = block.timestamp;\n        r.periodFinish = block.timestamp + duration;","title":"Anyone can reschedule the staker reward stream: 1 wei to ShiftRewards.receive() collapses the owner's 365-day stream into 7 days (and a 1 wei distribute() does the same for any token)"},{"citation":"resolved","description":"distribute(token) is callable by anyone for any token with a balance in the splitter, and for every non-ETH token it calls rewards.notifyReceived(token), which calls _addRewardToken(token) and registers the token in rewardTokens (cap MAX_REWARD_TOKENS = 16, ShiftRewards.sol:297). An attacker deploys 14 worthless ERC-20s, mints 1 wei of each to the FeeSplitter and calls distribute() 14 times (gas only). From then on every token that is not already registered reverts in _addRewardToken with TooManyRewardTokens, so distribute(IMD) and distribute(anyOtherToken) revert permanently. The brief requires the splitter to apply the fixed rule to 'ETH, SHIFT, IMD or any ERC-20'; after the attack any IMD (or other ERC-20) that reaches the FeeSplitter can never be split or burned and there is no rescue path (owner can only setKeeper). Secondary cost: every stake/withdraw/getReward/exit now loops over 16 tokens (_updateAll), and getReward()/exit() pay all 16 including junk, so a junk token whose transfer later reverts breaks getReward() and exit() for everyone holding a nonzero junk reward (getReward(token) still works). Fix: only register reward tokens from an allow-list (ETH, SHIFT, IMD by default, owner-extendable), and have distribute() route unregistered tokens' 40% share elsewhere or refuse unknown tokens, so an unprivileged caller cannot consume shared capacity.","line":105,"path":"src/FeeSplitter.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 {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {FeeSplitter} from \"src/FeeSplitter.sol\";\nimport {ShiftRewards} from \"src/ShiftRewards.sol\";\nimport {ShiftToken} from \"src/ShiftToken.sol\";\n\ncontract JunkToken is ERC20 {\n    constructor() ERC20(\"Junk\", \"JNK\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// @notice Anyone fills ShiftRewards' 16 reward-token slots through the permissionless\n/// `FeeSplitter.distribute(token)`; afterwards IMD (or any new token) sent to the FeeSplitter can never be\n/// distributed and is stuck there forever, because `distribute` reverts in `notifyReceived -> _addRewardToken`.\ncontract RewardTokenExhaustionTest is Test {\n    address constant OWNER = 0x6C4c78f2C3bDf4C4318Fe305FB1C8BC1694d19b5;\n    address constant IMD = 0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7;\n    address constant BURN_EXECUTOR = 0xe29386719C155B6847aD5a4E97C6674f10ffc750;\n\n    PoolManager manager;\n    ShiftToken shift;\n    ShiftRewards rewards;\n    FeeSplitter splitter;\n    address attacker = makeAddr(\"attacker\");\n\n    function setUp() public {\n        manager = new PoolManager(address(this));\n        shift = new ShiftToken();\n        rewards = new ShiftRewards(address(shift), OWNER);\n        splitter = new FeeSplitter(IPoolManager(address(manager)), address(shift), address(rewards), OWNER);\n        rewards.setDistributor(address(splitter));\n        // IMD lives at a fixed mainnet address: put a mintable ERC-20 there.\n        vm.etch(IMD, address(new JunkToken()).code);\n    }\n\n    function test_imdFeesStayDistributableAfterJunkTokens() public {\n        // ETH and SHIFT occupy 2 of the 16 slots. The attacker fills the other 14 with 1 wei each.\n        vm.startPrank(attacker);\n        for (uint256 i = 0; i < 14; i++) {\n            JunkToken junk = new JunkToken();\n            junk.mint(address(splitter), 1);\n            splitter.distribute(address(junk));\n        }\n        vm.stopPrank();\n        assertEq(rewards.rewardTokenCount(), 16);\n\n        // Later, IMD fees reach the FeeSplitter (the brief lists IMD as a token it must split).\n        JunkToken(IMD).mint(address(splitter), 1_000e18);\n\n        // The fixed rule must still apply: 40% rewards, 20% dev, 20% treasury, 20% to the BurnExecutor.\n        splitter.distribute(IMD);\n        assertEq(JunkToken(IMD).balanceOf(BURN_EXECUTOR), 200e18, \"IMD burn share never reached the BurnExecutor\");\n        assertEq(JunkToken(IMD).balanceOf(address(splitter)), 0, \"IMD stuck in the FeeSplitter\");\n    }\n}","reproduction":"Deploy per README wiring (hook creates ShiftRewards and FeeSplitter; distributor = splitter). Attacker: for i in 0..13 { J = new ERC20; J.mint(splitter, 1); splitter.distribute(J) } -> rewards.rewardTokenCount() == 16. Then 1000e18 IMD arrives at the FeeSplitter; anyone calls splitter.distribute(IMD). Actual: revert TooManyRewardTokens(); IMD balance stays in the FeeSplitter indefinitely, BurnExecutor receives nothing. Expected: 400e18 to ShiftRewards, 200e18 dev, 200e18 treasury, 200e18 to BurnExecutor 0xe29386719C155B6847aD5a4E97C6674f10ffc750. Proof test: test/scratch/RewardTokenExhaustion.t.sol fails on the current code with TooManyRewardTokens().","severity":"medium","snippet":"            rewards.notifyReceived(token);","title":"Permissionless FeeSplitter.distribute(token) lets anyone fill ShiftRewards' 16 reward-token slots with junk; afterwards IMD or any new token sent to the FeeSplitter is stuck there forever"},{"citation":"resolved","description":"rewardPerToken returns the stored value when totalSupply == 0 (line 270), yet _updateAll and _notifyStakers still advance lastUpdateTime to lastTimeRewardApplicable. The time during which nobody was staked is therefore consumed without crediting anyone. The amount stays counted in accounted[token], so notifyReceived cannot re-recognise it as surplus, it is not in frogsPot, and there is no sweep. It is lost for good. This is the launch-day case: trading opens with the first buy, sells immediately generate ETH and SHIFT fees, anyone calls FeeSplitter.distribute, and the 7-day streams start before anyone has bought and staked SHIFT. Every hour of that window strands the staker half of all fees received. Fix: in _notifyStakers/_updateAll, do not advance lastUpdateTime while totalSupply == 0 (pause the clock), or move the undistributed remainder back into frogsPot / a pending bucket that the next notify re-streams.","line":286,"path":"src/ShiftRewards.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ShiftRewards} from \"src/ShiftRewards.sol\";\nimport {ShiftToken} from \"src/ShiftToken.sol\";\n\n/// @notice The staker half of every reward that streams while `totalSupply == 0` is lost for good: the clock\n/// still advances in `_updateAll` / `_notifyStakers`, nobody is credited, and the amount stays inside\n/// `accounted[token]` where no function can ever pay it out.\ncontract StrandedRewardsTest is Test {\n    address constant OWNER = 0x6C4c78f2C3bDf4C4318Fe305FB1C8BC1694d19b5;\n    address constant ETH = address(0);\n\n    ShiftToken shift;\n    ShiftRewards rewards;\n    address distributor = makeAddr(\"distributor\");\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_700_000_000);\n        shift = new ShiftToken();\n        rewards = new ShiftRewards(address(shift), OWNER);\n        rewards.setDistributor(distributor);\n        shift.transfer(alice, 1_000e18);\n        vm.prank(alice);\n        shift.approve(address(rewards), type(uint256).max);\n    }\n\n    function test_rewardsReceivedWithNoStakersAreNotStranded() public {\n        // Launch day: sell fees arrive through the FeeSplitter before anyone has staked.\n        vm.deal(distributor, 10 ether);\n        vm.prank(distributor);\n        (bool ok,) = address(rewards).call{value: 10 ether}(\"\");\n        assertTrue(ok);\n        assertEq(rewards.accounted(ETH), 10 ether);\n        assertEq(rewards.frogsPot(ETH), 5 ether);\n\n        // The 7-day stream runs out with nobody staked. Then Alice stakes and waits another full stream.\n        vm.warp(block.timestamp + 7 days);\n        vm.prank(alice);\n        rewards.stake(1_000e18, 0);\n        vm.warp(block.timestamp + 7 days);\n\n        vm.prank(alice);\n        rewards.getReward();\n\n        // Every wei the contract still accounts for must be either in the frogs pot or claimable by a staker.\n        uint256 stranded = rewards.accounted(ETH) - rewards.frogsPot(ETH) - rewards.earned(alice, ETH);\n        assertLe(stranded, 1e6, \"the staker half streamed while totalSupply == 0 is permanently stuck\");\n    }\n}","reproduction":"State: no stakers. FeeSplitter (or anyone) sends 10 ETH to ShiftRewards -> accounted[ETH] = 10 ETH, frogsPot[ETH] = 5 ETH, 5 ETH stream over 7 days. Warp 7 days. Alice stakes 1000 SHIFT, warp 7 more days, Alice getReward(). Actual: Alice receives 0, earned(alice, ETH) == 0, accounted[ETH] - frogsPot[ETH] == 5 ETH with no path that can pay it to anyone. Expected: the 5 ETH is either claimable by stakers once they exist or returned to the pot. Proof test: test/scratch/StrandedRewards.t.sol fails on the current code with 'the staker half streamed while totalSupply == 0 is permanently stuck: 5000000000000000000 > 1000000'.","severity":"medium","snippet":"            r.rewardPerTokenStored = rewardPerToken(token);\n            r.lastUpdateTime = lastTimeRewardApplicable(token);","title":"Rewards streamed while totalSupply == 0 are permanently stranded inside accounted[token]: nobody can ever claim them and no function can recover them"},{"citation":"resolved","description":"Each call to _notifyStakers re-spreads the remaining leftover over a fresh 7 days (periodFinish = now + 7 days). Because receive() and FeeSplitter.distribute() are permissionless, a griefer can top up with 1 wei whenever they like, so the stream decays exponentially instead of finishing: after 7 days only ~63% of the original amount has been emitted, after 14 days ~86%, and the tail never closes. Stakers who leave lose the deferred part to later stakers; rewards are delayed, not lost. Natural fee inflow has the same effect, so the '7 days' in the brief is a minimum, never a schedule. Fix: either fix periodFinish for the stream once set and accumulate new amounts as a separate rate that starts at the next boundary, or (common Synthetix practice) only allow extension by a trusted notifier.","line":326,"path":"src/ShiftRewards.sol","reproduction":"Alice stakes 1000 SHIFT. 10 ETH in FeeSplitter, distribute(address(0)) -> 2 ETH stream, periodFinish = T0 + 7 days. Every day for 14 days an EOA sends 1 wei to ShiftRewards. Actual after 14 days (test/scratch/Explore.t.sol test_streamNeverFinishesWith1WeiDonations): periodFinish = T0 + 21 days (still 7 days ahead), Alice earned 1.769 ETH of the 2 ETH that should have been fully emitted by T0 + 7 days. Expected: stream complete at T0 + 7 days.","severity":"low","snippet":"    receive() external payable {\n        if (msg.value == 0) revert ZeroAmount();\n        _split(ETH, msg.value);\n    }","title":"Perpetual stream extension: 1 wei donations to ShiftRewards.receive() (or 1 wei distribute() calls) keep the 7-day staker stream from ever finishing"},{"citation":"resolved","description":"The brief asks for a plain token (no transfer fee) and a hook fee on the SHIFT/ETH pool, so this is inherent to the requested design rather than a coding error, but it is the central economic assumption of the fee model and the brief says 'on every sell'. beforeInitialize only restricts pools that name this hook; Uniswap v4 lets anyone initialize a SHIFT/ETH pool with hooks = address(0) (or any other hook) and LPs are economically incentivised to do so, since sellers there keep the 5% and buyers see the same price. Once liquidity migrates, FeeSplitter, ShiftRewards stakers and the NIGHT FROGS pot receive nothing. Report to the requester as a design limitation: if 'every sell' is a hard requirement it needs a token-level mechanism (which the brief forbids) or at least should be documented for stakers.","line":196,"path":"src/NightshiftHook.sol","reproduction":"After the first buy: anyone calls PoolManager.initialize(PoolKey(ETH, SHIFT, 3000, 60, hooks=address(0)), price) and adds liquidity. Alice sells 10,000 SHIFT exact-in on that pool. Actual (test/scratch/Explore.t.sol test_feeBypassViaUnhookedPool): FeeSplitter receives 0 SHIFT and 0 ETH; Alice gets ~4992 ETH-equivalent vs ~4783 on the hooked pool at the same price (the 5% plus the LP-fee difference). Expected per brief: 2.5% SHIFT + 2.5% ETH to FeeSplitter on every sell.","severity":"low","snippet":"    function _isSell(PoolKey calldata key, SwapParams calldata params) internal view returns (bool) {\n        Currency input = params.zeroForOne ? key.currency0 : key.currency1;\n        return Currency.unwrap(input) == shift;\n    }","title":"The 5% sell fee exists only on the hooked pool: anyone can open an unhooked SHIFT/ETH v4 pool (or a v2/v3 pool) and sell SHIFT with zero hook fee, bypassing FeeSplitter, stakers and frogs"},{"citation":"resolved","description":"For exact-input sells the SHIFT fee is 2.5% of the seller's whole input (beforeSwap, line 150). For exact-output sells afterSwap takes 2.5% of what the pool consumed and adds it on top, so the seller's gross input is shiftIn * 1.025 and the fee is 250/10250 = 2.439% of it. The ETH part is consistent in both modes (2.5% of gross output). A seller using SWAP_EXACT_OUT_SINGLE therefore pays about 6 bps less SHIFT fee than the brief's '2.5% of the SHIFT input'; FeeSplitter, stakers and frogs receive correspondingly less. Fix: shiftFee = shiftIn * shiftFeeBps / (BPS - shiftFeeBps) in the exact-output branch, mirroring the ETH exact-output formula on line 158.","line":188,"path":"src/NightshiftHook.sol","reproduction":"Trading open, pool at 1:1 with 1e22 liquidity. Alice: swapExact(key, zeroForOne=false, amountSpecified=+1 ether). Actual (test/scratch/Explore.t.sol test_exactOutputShiftFeeIsBelow250BpsOfGross): gross SHIFT paid = 1,064,488,361,262,843,886,xxx; SHIFT to FeeSplitter = 25,963,130,762,508,387 = 243 bps of gross. Expected: 26,612,209,031,571,097 (250 bps of gross).","severity":"low","snippet":"        uint256 shiftIn = uint256(uint128(-delta.amount1()));\n        uint256 shiftFee = shiftIn * shiftFeeBps / BPS;","title":"Exact-output sells pay 2.44% SHIFT fee instead of 2.5% of the SHIFT input: the SHIFT part is computed on the pool's consumption, not on the seller's gross input"},{"citation":"resolved","description":"_withdraw reverts with ZeroAmount when amount == 0, and exit() always calls it with withdrawable(msg.sender). A user who only has locked stake (or no stake at all but unpaid rewards) cannot use exit() to collect rewards; they must call getReward() instead. Not a loss of funds, but the ABI entry point is unusable in a common state and a front-end calling exit() will fail. Fix: in exit(), skip _withdraw when withdrawable is zero.","line":184,"path":"src/ShiftRewards.sol","reproduction":"Alice stakes 1000 SHIFT with lockDays = 30. 10 ETH distributed, warp 7 days, earned(alice, ETH) > 0. Alice calls exit(). Actual: revert ZeroAmount() (test/scratch/Explore.t.sol test_exitRevertsWhenAllStakeLocked). Expected: rewards paid, nothing withdrawn.","severity":"low","snippet":"    function exit() external nonReentrant {\n        _withdraw(withdrawable(msg.sender));","title":"exit() reverts for a staker whose entire stake is still time-locked, even when rewards are owed"}],"hash":"4ed4a862397e1293c8689e6bfdf27a3d5ab5f593d65875f37c988c02ad314701","nodeId":"8b8cb494-1b53-4862-a48c-1c6430f454ad","outcome":"completed","summary":"All seven snippets match the source lines exactly. The working tree is unchanged apart from the findings file and my scratch tests. The review is complete.\n\n## Review complete: 7 findings written to `.imd-findings.json`\n\nI audited the economics of NIGHTSHIFT against the Pashov Economic Security, Invariant and Flow Gap guides. No source file was changed. The existing suite of 52 tests passes. The findings file holds 7 findings, 3 with failing Foundry proofs, and 37 coverage rows covering all 34 listed entry points plus 3 invariants.\n\n**Medium findings (each with a proof under `test/scratch/` that fails on the current code):**\n\n- **Anyone reschedules the staker stream.** `ShiftRewards.receive()` is open and `FeeSplitter.distribute()` is permissionless. Both rewrite the single per-token reward schedule. One wei sent to ShiftRewards collapses an owner-funded 365-day stream into 7 days, so current stakers capture a year of rewards in a week.\n- **Reward-token slot exhaustion strands tokens in the FeeSplitter.** Anyone can register 14 junk ERC-20s through `distribute()`, filling the 16-slot cap. After that, `distribute(IMD)` or any new token reverts forever, and the brief's \"any ERC-20\" rule is broken with no rescue path.\n- **Rewards streamed with zero stakers are lost permanently.** The stream clock advances while `totalSupply == 0`, nobody is credited, and the amount stays inside `accounted` where nothing can pay it out. This is the launch-day state, when sell fees arrive before anyone has staked.\n\n**Low findings:** perpetual stream extension via 1-wei top-ups, the sell fee being avoidable on any unhooked SHIFT/ETH pool (inherent to the plain-token design, flagged for the requester), exact-output sells paying 2.44% rather than 2.5% SHIFT fee, and `exit()` reverting when all stake is locked.\n\n**What holds:** the hook's delta accounting conserves value in all four swap modes, the launch gate and fee caps behave as specified, the FeeSplitter's 40/20/20/20 split conserves every wei, the two-hop burn settles correctly, the Merkle epoch lifecycle is sound, and no owner path reaches staked SHIFT or earned rewards.\n\n**Not reached:** behavior of the real IMD token and its ETH pool on mainnet, and the Universal Router's exact settle ordering against this hook, which the in-repo router stands in for.","treeHash":null,"usage":{"cachedInputTokens":1294394,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":44701,"runtime":"claude","turns":33,"wallClockMs":589552}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"bcb8a3f211778fd6","findings":[{"citation":"resolved","description":"The revision separates the owner's rewardData stream from the splitter's feeStream, which settles the owner-schedule part of the earlier medium (confirmed: 1 wei ETH and dust SHIFT via FeeSplitter.distribute leave rewardData(token).periodFinish unchanged; the attached proof and the repo's test_ownerStreamNotRescheduledBySplitterIncome pass). The second half of that finding remains: feeStream[token] is still re-rated by every notify, and the notifiers are permissionless (ShiftRewards.receive() at line 384 accepts any ETH; FeeSplitter.distribute(SHIFT) accepts dust from anyone). _notifyStakers folds the whole leftover into a fresh 7-day window (lines 372-377), so repeated dust turns the linear 7-day stream into a geometric decay whose tail never finishes. Rewards are delayed, not lost, and a staker who exits before the tail streams forfeits it to later stakers. Natural fee inflow has the same effect, so '7 days' is a minimum, never a schedule. Low severity: no loss, cost to the griefer is gas only. Minimal fix that keeps the design: when topping up an active fee stream keep max(existing periodFinish, now + 7 days) as the end and spread (scaled + leftover) over that, or ignore notifies below a dust floor.","line":377,"path":"src/ShiftRewards.sol","reproduction":"ShiftRewards(shift, owner), distributor set. alice stakes 1_000e18 SHIFT lock 0. Anyone sends 14 ether to ShiftRewards (7 ETH to feeStream over 7 days): feeStream(ETH).periodFinish == now + 7 days. Then for 7 days, once a day, griefer (any EOA) sends 1 wei to ShiftRewards. Expected after 7 days: earned(alice, ETH) ~= 7e18 and the stream finished. Actual (test/scratch/JudgeR2.t.sol test_dustReschedulesFeeStream on this tree): earned(alice, ETH) == 4620583260376201000 and feeStream(ETH).periodFinish - block.timestamp == 604800, still a full week away; rewardData(ETH).periodFinish == 0 (owner stream untouched).","severity":"low","snippet":"        r.periodFinish = block.timestamp + duration;","title":"Residual of the stream-rescheduling finding: a 1 wei deposit still restarts the 7-day fee stream, so the staker half of fee income is paid later than the 7 days the brief states (owner streams are now"},{"citation":"resolved","description":"The fix holds for what was asked: setImdPool and setEthPool now reject any key with hooks != address(0) (lines 200 and 212), and burnReserve reverts ZeroAmount when minImdOut == 0 (line 133), so the hooked-pool route and the zero floor in the earlier low are closed. What remains is inherent to the brief's keeper role: the keeper picks the pool's fee tier and tickSpacing (any ETH/IMD pool anyone initializes, including a 100% fee tier or one the keeper alone provides liquidity in) and the minImdOut floor (1 wei satisfies the check), with the swap still run at the extreme sqrtPriceLimit (line 181). The owner (dev wallet) appoints the keeper, so this is a privileged-role trust assumption, recorded for the requester, not a bypass. No code change required by the brief; an owner-set route and a quote-derived floor would narrow it further.","line":196,"path":"src/FeeSplitter.sol","reproduction":"Owner: setKeeper(K). K: initialize PoolKey{ETH, IMD, fee: 1_000_000, tickSpacing: 1, hooks: 0} with only K's liquidity; setImdPool(thatKey) succeeds (currencies and hooks checks pass). With reserve(ETH) == 10 ether, K calls burnReserve(address(0), 10 ether, 1). Expected per brief: 10 ETH of IMD at market reaches the BurnExecutor. Actual: the swap pays K's 100% LP fee, BurnExecutor receives near-zero IMD (>= 1 wei), the call succeeds and emits ImdBurned.","severity":"info","snippet":"    function setImdPool(PoolKey calldata key) external {","title":"Trust assumption (narrowed, residual): the keeper still chooses the ETH/IMD fee tier and minImdOut, so a compromised keeper can burn the reserve at a bad price on an unhooked pool of its own"},{"citation":"resolved","description":"The author disputed this as inherent to the brief (plain token with no transfer fee, fee in the hook). Agreed: beforeInitialize governs only pools that name this hook, and the brief forbids a token-level fee, so no code change is required. Kept as an information record so the requester knows 'on every sell' holds only for the launch pool; liquidity that migrates to an unhooked pool pays nothing to FeeSplitter, stakers or the frogs pot.","line":129,"path":"src/NightshiftHook.sol","reproduction":"After the first buy: anyone calls PoolManager.initialize(PoolKey(ETH, SHIFT, 3000, 60, hooks = address(0)), price) and adds liquidity; alice sells 10_000e18 SHIFT exact-in there. Expected per the brief's wording: 2.5% SHIFT + 2.5% ETH to FeeSplitter. Actual: FeeSplitter receives 0; the hook is never called.","severity":"info","snippet":"        if (!key.currency0.isAddressZero() || Currency.unwrap(key.currency1) != shift) revert WrongPool();","title":"Design limit (unchanged, accepted): the 5% sell fee exists only on the hooked pool; an unhooked SHIFT/ETH pool sells with no hook fee"},{"citation":"resolved","description":"The author disputed this as within the brief ('the owner calls postRoot'). Agreed: postRoot accepts any root up to the pot (lines 443-455) and claimFrogs checks only the proof (lines 459-473); nothing on chain ties a leaf to a NIGHT FROGS holder, and staked SHIFT plus earned staker rewards stay untouchable (accounted/frogsPot bookkeeping verified). Recorded as the owner's power over 50% of all rewards; no code change required by the brief.","line":443,"path":"src/ShiftRewards.sol","reproduction":"frogsPot[ETH] = 5 ether. Owner builds a one-leaf tree root = keccak256(bytes.concat(keccak256(abi.encode(1, owner, address(0), 5e18)))), calls postRoot(1, root, address(0), 5e18) then claimFrogs(1, address(0), 5e18, []) (leaf == root). Result: owner receives 5 ETH; the pot is 0.","severity":"info","snippet":"    function postRoot(uint256 epoch, bytes32 root, address token, uint256 total) external onlyOwner {","title":"Trust assumption (unchanged, accepted): the owner can post a Merkle root that assigns the whole NIGHT FROGS pot to addresses it chooses"}],"hash":"5cf5feb4ff3006b50ddcc5b906852b533d35044072124b8fc0aa3bd6d9fb1d9a","nodeId":"d5c485e2-c1a6-4995-a177-ca1bb0a6e97c","outcome":"completed","summary":"The review is complete and the findings file is written.\n\n**Outcome:** every earlier blocking finding is fixed. One low residual and three information records remain.\n\n**Settled findings, each reproduced against the revised code:**\n- **Reward-token poisoning (high):** fixed. Only the owner can register tokens now, through addRewardToken, notifyRewardAmount or an owner-called notifyReceived. The splitter's unregistered tokens park their staker half in a held balance until the owner registers them, and the batch payout skips a failing token instead of reverting. The attached proofs pass, and my scratch test confirmed a held token is released correctly by the owner.\n- **Partial-fill fee sizing (medium):** fixed. afterSwap now reverts PartialFill when the pool did not consume the full specified amount, on both exact-input and exact-output sells. I also confirmed ordinary sells on a tokens-only pool still succeed with correct fees, so the guard does not misfire.\n- **Owner stream rescheduled by dust (medium):** fixed for the owner's stream, which now lives in its own RewardData. The proofs pass.\n- **Stranded rewards while nobody staked (medium):** fixed. The stream clock pauses while totalSupply is zero. My conservation tests, including a 30-day empty gap between stakers and two notifies before any stake, paid out the full stream within rounding dust.\n- **Exact-output SHIFT fee rate, exit() with everything locked, keeper hooked-pool route:** all fixed as the author described.\n\n**What remains in the file:**\n- **Low:** a 1 wei deposit still restarts the 7-day fee stream. After seven daily dust deposits, a staker has earned 4.62 of 7 ETH and the stream end is still a week away. This is delay rather than loss, and natural fee inflow does the same.\n- **Info:** the keeper still picks the fee tier and minImdOut floor, the sell fee only exists on the hooked pool, and the owner can post any frogs root. All three are accepted trust assumptions or design limits, recorded for the requester.\n\nThe coverage record answers all 36 entry points plus two invariants, with one row pointing at the low finding. Deployability checks pass: hook initcode is 28,603 bytes and the manifest arguments match the constructor. The full repository suite passes with 85 tests. The scratch tests I wrote were removed, so the tree holds only the findings file.","treeHash":null,"usage":{"cachedInputTokens":1648002,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":26686,"runtime":"claude","turns":22,"wallClockMs":463821}},{"artifacts":[],"attempt":1,"bundleHash":"b21daaaef4f8286196a055be1b5a7401150156d078724649a428c2406d481a5c","device":"476778ae63dff113","findings":[],"hash":"6587e1c1a4fe80e44e584b8a3b54b159c3f4bd9353b4f9171f794e0bcbbdbb6f","nodeId":"daeadcf0-ff32-4b89-af6d-191985d13b71","outcome":"completed","summary":"I fixed all four required findings and the three low ones. I disputed the two informational ones, and `.imd-responses.json` answers all nine. With the verifier's fuzz seed, `forge build`, `forge test` (61 tests, the three reviewer proofs included) and `forge fmt --check` all pass.\n\n**Required fixes**\n- **Anyone could add reward tokens (high):** only the owner can register a reward token now, through the new `addRewardToken`, `notifyRewardAmount` or `notifyReceived`. IMD is registered from the start, next to ETH and SHIFT. When the FeeSplitter forwards a token the owner hasn't registered, the frogs half still goes to the pot and the staker half is held in `unstreamed(token)` until the owner registers it. `getReward()` and `exit()` now skip a token whose transfer fails and leave it claimable, so one bad token can't block the rest.\n- **Sell fee on untraded amounts (medium):** a sell the pool can't fill completely now reverts with `PartialFill`, in both the exact-input and exact-output paths. The reviewer's proof accepts this refusal. The V4 Quoter already refuses to quote such sells.\n- **Dust deposits rewriting the owner's reward schedule (medium):** the owner's `notifyRewardAmount` stream (`rewardData`) and the fee stream from the splitter or plain ETH (`feeStream`) are now kept separately and added together. A 1 wei deposit can no longer squeeze a year of owner rewards into a week.\n- **Rewards stranded while nobody is staked (medium):** while nothing is staked, the stream's clock pauses, and the unpaid part goes to the next stakers instead of being lost.\n\n**Low fixes**\n- An exact-output sell now pays 2.5% of all the SHIFT the seller pays, the same rate as an exact-input sell.\n- `exit()` works when the whole stake is still locked: it pays the rewards and withdraws nothing.\n- The keeper can only route burns through pools without a hook, and `burnReserve` requires `minImdOut > 0`. The keeper still picks the pool's fee tier and the minimum, so it remains a trusted role the owner appoints.\n\n**Disputed, documented in the README only**\n- The sell fee applies only on the launch pool. A SHIFT pool without this hook pays no fee, which follows from the brief's plain token.\n- The owner's Merkle roots decide who gets the NIGHT FROGS pot, as the brief allows. This is listed as a trust assumption.\n\nOne design point remains: income from the splitter still restarts its own 7-day window each time it arrives (standard Synthetix behaviour), so frequent small deposits slow that stream down. It no longer touches the owner's stream.\n\nI updated existing tests that checked the old behaviour: the exact-output fee ratio, the reward-token limit, which stream the ETH test reads, and `minImdOut = 1` in the burn tests. I added tests for each fix and updated the README.","treeHash":"06db75c7bfbcff1a728e65986dc7c6d65ca1265a","usage":{"cachedInputTokens":2317060,"inputTokens":52,"model":"claude-opus-5-5","outputTokens":21241,"runtime":"claude","turns":27,"wallClockMs":314983}},{"artifacts":[],"attempt":1,"bundleHash":"d890b54281c0edb0bcf7930f948252c9be0065e855d397136a6a24578666515c","device":"7236faf97e42979a","findings":[],"hash":"ee236c9e4b2524bc44f98836a5385a9c8d59ff4e64e37a9e77b1e86778b3fd2a","nodeId":"daeadcf0-ff32-4b89-af6d-191985d13b71","outcome":"completed","summary":"The NIGHTSHIFT launch project is complete and committed on `main` (commit `7d5526f`). Build, tests and format check all pass from a clean build with the verifier's fuzz seed.\n\n**What was delivered**\n\n- `src/ShiftToken.sol`: plain fixed-supply ERC-20, 1,000,000,000 SHIFT minted to the deployer, no admin functions.\n- `src/NightshiftHook.sol`: constructor `($poolManager, $token, $owner, $factory)`, flags `0x20CC` (beforeInitialize, beforeSwap, afterSwap, both swap return deltas). The launch gate checks `tx.origin` against the treasury wallet; the first buy flips `tradingOpen` and emits `TradingOpened`, with no other path to open or close. Sells pay 2.5% of SHIFT input plus 2.5% of ETH output to the FeeSplitter with `FeeTaken`; buys pay nothing. ETH falls back to an ERC-6909 claim when the manager lacks native balance, redeemable by anyone. The owner can only set the two fee parts, capped at 250 bps.\n- `src/FeeSplitter.sol`: fixed 40/20/20/20 rule with compile-time wallets, SHIFT to dEaD, IMD to the BurnExecutor, ETH and other tokens to a reserve. Keeper-only `burnReserve` swaps to IMD on v4 in one unlock and sends it straight to the BurnExecutor. The owner's only setting is `setKeeper`.\n- `src/ShiftRewards.sol`: locks 0 to 730 days, Synthetix-style multi-token streaming, half of each receipt to stakers over 7 days and half to the NIGHT FROGS pot, Merkle epochs with a 30-day window and return to pot, owner `notifyRewardAmount`, no path for the owner to take stakes or earned rewards.\n- Vendored forge-std, v4-core, OpenZeppelin 5.4.0 and solmate as plain files. `test/mocks/MockERC20.sol`, `test/mocks/Swapper.sol`, `test/floor/OwnerChecks.sol` and `src/HookFlags.sol` are the files the pinned protected suites import.\n\n**Verification**\n\n| Check | Result |\n| --- | --- |\n| `forge build` (solc 0.8.26, bytecode_hash none) | pass |\n| `forge test` with the verifier's seed | 52 passed, 0 failed |\n| Pinned protected suites, run from scratch with `$token` and `$factory` probes | 13 passed |\n| `forge fmt --check` | pass |\n\n**Open point for the requester**\n\nThe brief says the burn reserve is swapped to IMD \"on Uniswap\" but names no pool. I assumed a Uniswap v4 ETH/IMD pool, and the keeper sets its PoolKey after launch with `setImdPool`. If IMD only trades on v2 or v3, the splitter cannot express that route. The README's \"After launch\" section lists this with the other operational steps: first buy by the treasury, keeper setup, route configuration, distribute and burn calls, and epoch root posting.","treeHash":"3bd896526342ce6f3abda0e6ce04dc500395e99a","usage":{"cachedInputTokens":4162495,"inputTokens":994,"model":"claude-fable-5-1","outputTokens":95533,"runtime":"claude","turns":51,"wallClockMs":1533027}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"f47f953f8e35921e","findings":[{"citation":"resolved","description":"FeeSplitter.distribute(token) is permissionless and, for any ERC-20, calls rewards.notifyReceived(token) (src/FeeSplitter.sol:105), which registers the token as a staker reward via _addRewardToken. Registration is capped at MAX_REWARD_TOKENS = 16 and nothing can ever remove a token. ETH and SHIFT take 2 slots, so an unprivileged attacker spends 14 dust transfers of worthless tokens to fill the rest. Two consequences: (a) every later distribute() of a token not yet registered reverts with TooManyRewardTokens forever, so IMD (which the brief lists as a token the splitter receives) or any other new token sent to FeeSplitter is stuck there with no recovery path (no sweep, split is fixed); (b) a registered token the attacker controls can be switched to revert on transfer, and since getReward() (src/ShiftRewards.sol:172-174) and exit() loop over every reward token and _payReward reverts on the failed safeTransfer, the brief's getReward() and exit() revert for every staker that earned any dust of it. Only the per-token getReward(address) still works. Minimal fix: restrict notifyReceived/registration to tokens on an owner allow-list or let FeeSplitter only forward ETH, SHIFT and IMD to ShiftRewards (sending other tokens' 40% share to the reserve), and/or make _payReward skip a token whose transfer fails instead of reverting the whole loop.","line":297,"path":"src/ShiftRewards.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 {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {FeeSplitter} from \"src/FeeSplitter.sol\";\nimport {ShiftRewards} from \"src/ShiftRewards.sol\";\nimport {ShiftToken} from \"src/ShiftToken.sol\";\n\n/// Plain mintable token an attacker deploys.\ncontract JunkToken is ERC20 {\n    constructor() ERC20(\"Junk\", \"JNK\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// Token whose transfers the attacker can switch off after it has been registered as a reward token.\ncontract PoisonToken is ERC20 {\n    bool public blocked;\n\n    constructor() ERC20(\"Poison\", \"PSN\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n\n    function setBlocked(bool b) external {\n        blocked = b;\n    }\n\n    function _update(address from, address to, uint256 value) internal override {\n        require(!blocked, \"poison\");\n        super._update(from, to, value);\n    }\n}\n\ncontract ProofRewardTokensTest is Test {\n    address constant OWNER = 0x6C4c78f2C3bDf4C4318Fe305FB1C8BC1694d19b5;\n    address constant IMD = 0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7;\n\n    ShiftToken shift;\n    ShiftRewards rewards;\n    FeeSplitter splitter;\n    address attacker = makeAddr(\"attacker\");\n    address staker = makeAddr(\"staker\");\n\n    function setUp() public {\n        shift = new ShiftToken();\n        rewards = new ShiftRewards(address(shift), OWNER);\n        // distribute() never touches the PoolManager, so any non-zero address will do here.\n        splitter = new FeeSplitter(IPoolManager(address(1)), address(shift), address(rewards), OWNER);\n        rewards.setDistributor(address(splitter));\n        // IMD lives at a fixed mainnet address: put a mintable ERC-20 there.\n        vm.etch(IMD, address(new JunkToken()).code);\n    }\n\n    /// An unprivileged attacker fills every free reward-token slot with dust of worthless tokens.\n    /// Afterwards FeeSplitter.distribute(IMD) reverts forever, so IMD sent to the splitter is stuck.\n    function test_anyoneExhaustsRewardTokenSlotsAndBricksDistributeOfNewTokens() public {\n        // ETH and SHIFT take 2 of the 16 slots; 14 are free.\n        for (uint256 i = 0; i < 14; i++) {\n            vm.startPrank(attacker);\n            JunkToken junk = new JunkToken();\n            junk.mint(address(splitter), 5);\n            // Low-level so the test also passes once a fix makes junk distributions revert or skip registration.\n            (bool ok,) = address(splitter).call(abi.encodeCall(splitter.distribute, (address(junk))));\n            ok;\n            vm.stopPrank();\n        }\n\n        // IMD now arrives at the splitter (the brief lists IMD as a token it receives).\n        JunkToken(IMD).mint(address(splitter), 1_000e18);\n\n        // Expected: 40% to rewards, 20% dev, 20% treasury, 20% to the BurnExecutor.\n        // Actual: ShiftRewards.notifyReceived -> _addRewardToken reverts TooManyRewardTokens, forever.\n        splitter.distribute(IMD);\n        assertEq(splitter.pending(IMD), 0, \"IMD distributed\");\n    }\n\n    /// A reward token registered through distribute() whose transfer later reverts makes getReward()\n    /// and exit() revert for every staker that earned any of it. The spec'd getReward() is unusable.\n    function test_poisonRewardTokenBricksGetRewardForEveryStaker() public {\n        shift.transfer(staker, 1_000e18);\n        vm.startPrank(staker);\n        shift.approve(address(rewards), type(uint256).max);\n        rewards.stake(1_000e18, 0);\n        vm.stopPrank();\n\n        vm.startPrank(attacker);\n        PoisonToken poison = new PoisonToken();\n        poison.mint(address(splitter), 1_000e18);\n        (bool ok,) = address(splitter).call(abi.encodeCall(splitter.distribute, (address(poison))));\n        ok;\n        vm.stopPrank();\n\n        // Legit ETH rewards also arrive and stream.\n        vm.deal(address(splitter), 10 ether);\n        splitter.distribute(address(0));\n        vm.warp(block.timestamp + 1 days);\n\n        // Attacker switches the junk token off.\n        vm.prank(attacker);\n        poison.setBlocked(true);\n\n        uint256 ethEarned = rewards.earned(staker, address(0));\n        assertGt(ethEarned, 0, \"staker has ETH to claim\");\n\n        // Expected: getReward() pays what was earned (at least the ETH).\n        // Actual: reverts with \"poison\" while paying the junk token, so the staker gets nothing from getReward().\n        vm.prank(staker);\n        rewards.getReward();\n        assertEq(staker.balance, ethEarned, \"ETH reward paid by getReward()\");\n    }\n}","reproduction":"State: fresh deployment, staker has staked 1000 SHIFT. Attacker: for i in 1..14 { deploy JunkToken; mint 5 wei to FeeSplitter; FeeSplitter.distribute(junk) } -> rewardTokenCount()==16. Then mint 1000e18 IMD (MockERC20 etched at 0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7) to FeeSplitter and call distribute(IMD). Expected: 40/20/20/20 split with 200 IMD to BurnExecutor. Actual: revert TooManyRewardTokens(); the IMD stays in FeeSplitter forever. Poison variant: attacker deploys PoisonToken, mints 1000e18 to FeeSplitter, distribute(poison); later flips blocked=true. Staker with earned ETH > 0 calls getReward(). Expected: ETH paid. Actual: revert 'poison'. See test/scratch/ProofRewardTokens.t.sol (both tests fail on current code).","severity":"high","snippet":"        if (rewardTokens.length >= MAX_REWARD_TOKENS) revert TooManyRewardTokens();\n        isRewardToken[token] = true;\n        rewardTokens.push(token);","title":"Anyone can fill ShiftRewards' 16 reward-token slots through FeeSplitter.distribute, permanently bricking distribute() for new tokens (IMD stuck) and getReward()/exit() with a poison token"},{"citation":"resolved","description":"notifyRewardAmount(token, amount, duration) lets the owner stream an outside reward over 1 to 365 days, but the stream state per token is a single RewardData. _split (called from notifyReceived for ERC-20s and from receive() for ETH) always calls _notifyStakers(token, toStakers, STREAM_DURATION): in _notifyStakers the remaining leftover (remaining * rewardRate) is folded into a new rate over 7 days and periodFinish is set to now + 7 days (src/ShiftRewards.sol:313-319). FeeSplitter.distribute(shift) and plain ETH transfers to ShiftRewards are permissionless, so anyone with 5 wei of SHIFT (or 1 wei ETH) collapses a 365-day owner programme into one week, roughly 52x the intended rate; in normal operation every sell's fee distribution does the same. The brief's 'notifyRewardAmount(token, amount, duration)' guarantee is broken, and a whale who stakes just after the collapse captures a year's rewards in 7 days. Minimal fix: keep separate RewardData per source (fee stream vs owner stream) or, when topping up, keep the existing periodFinish if it is later than now + duration (rate = (scaled + leftover) / (periodFinish - now)).","line":347,"path":"src/ShiftRewards.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {FeeSplitter} from \"src/FeeSplitter.sol\";\nimport {ShiftRewards} from \"src/ShiftRewards.sol\";\nimport {ShiftToken} from \"src/ShiftToken.sol\";\n\ncontract ProofDurationTest is Test {\n    address constant OWNER = 0x6C4c78f2C3bDf4C4318Fe305FB1C8BC1694d19b5;\n\n    ShiftToken shift;\n    ShiftRewards rewards;\n    FeeSplitter splitter;\n    address attacker = makeAddr(\"attacker\");\n    address staker = makeAddr(\"staker\");\n\n    function setUp() public {\n        shift = new ShiftToken();\n        rewards = new ShiftRewards(address(shift), OWNER);\n        splitter = new FeeSplitter(IPoolManager(address(1)), address(shift), address(rewards), OWNER);\n        rewards.setDistributor(address(splitter));\n        shift.transfer(OWNER, 10_000_000e18);\n        shift.transfer(attacker, 1e18);\n        shift.transfer(staker, 1_000e18);\n        vm.startPrank(staker);\n        shift.approve(address(rewards), type(uint256).max);\n        rewards.stake(1_000e18, 0);\n        vm.stopPrank();\n    }\n\n    /// The owner streams an outside SHIFT reward over 365 days. Anyone then sends 5 wei SHIFT to the\n    /// FeeSplitter and calls distribute(shift): _split -> _notifyStakers(..., 7 days) folds the whole\n    /// 365-day leftover into a fresh 7-day period, so the year-long program pays out in one week.\n    function test_feeDistributionCollapsesOwnerRewardDurationToSevenDays() public {\n        uint256 start = block.timestamp;\n        vm.startPrank(OWNER);\n        shift.approve(address(rewards), type(uint256).max);\n        rewards.notifyRewardAmount(address(shift), 3_650_000e18, 365 days);\n        vm.stopPrank();\n        (uint256 finish0, uint256 rate0,,) = rewardData(address(shift));\n        assertEq(finish0, start + 365 days, \"owner asked for a year\");\n\n        vm.warp(start + 1 days);\n        vm.startPrank(attacker);\n        shift.transfer(address(splitter), 5);\n        splitter.distribute(address(shift));\n        vm.stopPrank();\n\n        (uint256 finish1, uint256 rate1,,) = rewardData(address(shift));\n        // Expected: the owner's 365-day schedule is honoured (finish stays at start + 365 days).\n        // Actual: finish1 == start + 1 day + 7 days and the rate is ~52x higher.\n        assertEq(finish1, start + 365 days, \"owner's duration was overridden by a dust fee distribution\");\n        assertLe(rate1, rate0 * 2, \"rate exploded\");\n    }\n\n    function rewardData(address token) internal view returns (uint256, uint256, uint256, uint256) {\n        return rewards.rewardData(token);\n    }\n}","reproduction":"Staker stakes 1000 SHIFT. Owner: approve, notifyRewardAmount(SHIFT, 3_650_000e18, 365 days) -> rewardData(SHIFT).periodFinish == start + 365 days. Warp 1 day. Attacker: shift.transfer(FeeSplitter, 5); FeeSplitter.distribute(SHIFT). Expected: periodFinish still start + 365 days. Actual: periodFinish == start + 1 day + 7 days (691201 vs 31536001 in the test) and rewardRate about 52x higher. See test/scratch/ProofDuration.t.sol.","severity":"medium","snippet":"        _notifyStakers(token, toStakers, STREAM_DURATION);","title":"Any fee distribution re-rates the owner's notifyRewardAmount stream over 7 days, so the requested duration is never honoured"},{"citation":"resolved","description":"rewardPerToken does not advance while totalSupply == 0, but _updateAll and _notifyStakers still move lastUpdateTime forward, so the stream for the no-staker window is skipped. The amount stays in accounted[token] and is never claimable by anyone, and there is no sweep or re-notify path. At launch the first sells (and their fee distributions) will happen before anybody has staked, so the stakers' half of early fees is lost. The README acknowledges the behaviour; reported because the funds are permanently unrecoverable. Minimal fix: when totalSupply == 0, do not advance lastUpdateTime (so the stream resumes when the first staker arrives), or sweep the un-streamed remainder into the frogs pot.","line":270,"path":"src/ShiftRewards.sol","reproduction":"Fresh deployment, nobody staked. vm.deal(FeeSplitter, 10 ether); distribute(address(0)) -> ShiftRewards gets 4 ETH, 2 ETH to frogsPot, 2 ETH streaming over 7 days. Warp 8 days. Alice stakes 1000 SHIFT, warps 7 more days, calls getReward(). Expected: Alice (sole staker) receives ~2 ETH stakers' share. Actual: earned(alice, ETH) == 0; ShiftRewards keeps 2 ETH in accounted[ETH] forever.","severity":"low","snippet":"        if (totalSupply == 0) return r.rewardPerTokenStored;\n        uint256 elapsed = lastTimeRewardApplicable(token) - r.lastUpdateTime;\n        return r.rewardPerTokenStored + (elapsed * r.rewardRate) / totalSupply;","title":"Rewards streamed while totalSupply == 0 are dropped and stay stuck in ShiftRewards with no recovery path"},{"citation":"resolved","description":"The brief defines the burn reserve as 'burned' and gives the keeper one job: swap the reserve to IMD and send it to the BurnExecutor with a minImdOut. The implementation lets the keeper also choose the venue: setImdPool only checks currency0 == ETH and currency1 == IMD; fee, tickSpacing and hooks are free. A keeper (an owner-set bot key) can initialize a fresh ETH/IMD pool whose hook has beforeSwapReturnDelta and takes the whole input, or a pool with only its own liquidity at an absurd price, set it as imdPool, and call burnReserve(ETH, reserve[ETH], 0): the ETH leaves to the keeper's pool/hook, imdOut is ~0 and the call succeeds, emitting ImdBurned. The same applies to setEthPool for non-ETH reserves. This is wider than the brief's keeper role and turns a bot-key compromise into loss of the whole reserve. Minimal fix: have the owner (not the keeper) set the routes, require hooks == address(0) on the configured keys, and/or enforce a non-zero minImdOut floor derived from a quote.","line":196,"path":"src/FeeSplitter.sol","reproduction":"Owner: setKeeper(K). K: deploy hook H with beforeSwap + beforeSwapReturnDelta that returns specified delta = the full input (NoOp) and keeps it as a claim; initialize pool {ETH, IMD, 3000, 60, H}; setImdPool(that key) -> passes BadRoute check. FeeSplitter holds reserve[ETH] = 5 ether. K: burnReserve(ETH, 5 ether, 0). Expected: 5 ETH worth of IMD reaches BurnExecutor. Actual: the 5 ETH is credited to H, imdOut == 0, ImdBurned(ETH, 5e18, 0) emitted and reserve[ETH] == 0.","severity":"low","snippet":"    function setImdPool(PoolKey calldata key) external {\n        if (msg.sender != keeper) revert NotKeeper();\n        if (Currency.unwrap(key.currency0) != ETH || Currency.unwrap(key.currency1) != IMD) revert BadRoute();\n        imdPool = key;\n        imdPoolSet = true;\n        emit ImdPoolSet(key);","title":"Keeper can point burnReserve at any ETH/IMD PoolKey, including a pool with its own hook or liquidity, and drain the burn reserve with minImdOut = 0"},{"citation":"resolved","description":"For an exact-input sell the SHIFT fee is 2.5% of the amount the seller pays (beforeSwap, line 150). For an exact-output sell afterSwap computes the fee as 2.5% of the SHIFT the pool consumed and charges it on top, so the seller pays poolIn * 1.025 and the fee is 2.5/102.5 = 2.439% of what they paid. The ETH side is consistent in both paths (2.5% of gross output), only the SHIFT side differs, so the same economic sell costs a different hook fee depending on how it is specified (the existing test pins the pool-input interpretation). Minimal fix: in the exact-output path use shiftFee = shiftIn * shiftFeeBps / (BPS - shiftFeeBps) so the fee is 2.5% of the gross SHIFT input, matching the exact-input path.","line":188,"path":"src/NightshiftHook.sol","reproduction":"Trading open, pool at price 1:1 with 1e22 liquidity. Alice sells exact-output 1 ether: afterSwap sees shiftIn = pool input P, charges fee P*250/10000, total paid = 1.025P, fee share 2.439%. Alice instead sells exact-input 1.025P SHIFT: fee = 1.025P*0.025 = 2.5% of what she paid. Expected: same 2.5% rule; actual: 2.439% vs 2.5%.","severity":"low","snippet":"        uint256 shiftIn = uint256(uint128(-delta.amount1()));\n        uint256 shiftFee = shiftIn * shiftFeeBps / BPS;","title":"Exact-output sells pay a smaller SHIFT fee share than exact-input sells (2.5% of pool input vs 2.5% of the seller's input)"},{"citation":"resolved","description":"beforeSwap takes shiftFee = 2.5% of |amountSpecified| from the PoolManager before the pool runs and credits the hook via the BeforeSwapDelta. If the swap hits sqrtPriceLimitX96 before consuming the whole input, the pool consumes only part of (amountSpecified - shiftFee) and the seller's final delta is -(consumed + shiftFee): the fee is charged on SHIFT that never entered the pool. The Universal Router's default limit (MIN/MAX) consumes everything so the common path is right, but any router or user that sets a limit (or a partial fill because the pool's liquidity ends) over-pays. The same holds for the exact-output ETH fee in beforeSwap (line 158). Minimal fix: take the SHIFT fee in afterSwap from the realised -delta.amount1() (as the exact-output path already does for ETH), or cap it to the consumed amount.","line":150,"path":"src/NightshiftHook.sol","reproduction":"Trading open, pool price sqrtP. Alice sends an exact-input sell of 1000e18 SHIFT with sqrtPriceLimitX96 = sqrtP + a few ticks so the pool consumes about 1e18 SHIFT. Expected fee: ~0.025e18 SHIFT. Actual: beforeSwap takes 25e18 SHIFT to FeeSplitter and Alice's delta is -(~1e18 + 25e18) SHIFT, about 96% of her outlay going to the fee.","severity":"low","snippet":"            uint256 shiftFee = uint256(-params.amountSpecified) * shiftFeeBps / BPS;\n            _tstore(shiftFee);\n            if (shiftFee > 0) poolManager.take(key.currency1, address(feeSplitter), shiftFee);\n            return (IHooks.beforeSwap.selector, toBeforeSwapDelta(shiftFee.toInt128(), 0), 0);","title":"Exact-input sell fee is computed from amountSpecified, not from the SHIFT actually consumed, so a swap stopped by its price limit still pays the full fee"},{"citation":"resolved","description":"exit() is documented as 'Withdraws all unlocked stake and pays every reward' but calls _withdraw(withdrawable()) unconditionally, and _withdraw reverts ZeroAmount when the amount is 0 (line 193). Any account whose whole stake is time-locked gets a revert instead of its rewards; the same for a staker whose balance is already fully withdrawn. getReward() still works, so impact is a broken entry point rather than loss. Minimal fix: only call _withdraw when withdrawable(msg.sender) > 0.","line":185,"path":"src/ShiftRewards.sol","reproduction":"Alice: stake(100e18, 30) (locked 30 days). Fees arrive and stream. Alice calls exit() on day 3. Expected: rewards paid, nothing withdrawn. Actual: revert ZeroAmount().","severity":"low","snippet":"        _withdraw(withdrawable(msg.sender));\n        uint256 n = rewardTokens.length;\n        for (uint256 i = 0; i < n; i++) {\n            _payReward(msg.sender, rewardTokens[i]);\n        }","title":"exit() reverts with ZeroAmount while all stake is locked, so a locked staker cannot use it to collect rewards"},{"citation":"resolved","description":"postRoot accepts any root and any total up to the pot, and claimFrogs only checks the proof against that root; nothing ties a leaf to a NIGHT FROGS holder. So the owner (dev wallet) can at any time move the frogs half of all rewards to addresses it chooses. This is within the brief's statement that the owner posts roots, and staked SHIFT and earned staker rewards are indeed untouchable (accounted/frogsPot bookkeeping holds), but it should be recorded as the owner's power over 50% of all rewards. No code change required by the brief; a snapshot commitment or timelock on postRoot would narrow it.","line":375,"path":"src/ShiftRewards.sol","reproduction":"frogsPot[ETH] = 5 ether. Owner builds a one-leaf tree keccak256(bytes.concat(keccak256(abi.encode(1, owner, address(0), 5e18)))), calls postRoot(1, root, address(0), 5e18) then claimFrogs(1, address(0), 5e18, []). Result: owner receives 5 ETH; pot is 0.","severity":"info","snippet":"    function postRoot(uint256 epoch, bytes32 root, address token, uint256 total) external onlyOwner {\n        if (root == bytes32(0)) revert EmptyRoot();\n        if (total == 0) revert ZeroAmount();\n        Epoch storage e = _epochs[epoch][token];\n        if (e.root != bytes32(0)) revert EpochExists();\n        uint256 pot = frogsPot[token];\n        if (total > pot) revert ExceedsPot(total, pot);\n        frogsPot[token] = pot - total;","title":"Trust assumption: the owner can post a Merkle root that assigns the whole NIGHT FROGS pot to itself"}],"hash":"efe34288e142b267293d5d4aeea91fe54abbf3153ae559d3a5bfbc4748a595b4","nodeId":"55b6e4a1-9f8b-4e16-b71c-18d81d0decaa","outcome":"completed","summary":"Review complete. The findings file `.imd-findings.json` holds 8 findings and 37 coverage rows, all snippets verified against the tree, and no source file was changed (the scratch tests and findings file are git-ignored).\n\n**What I found, by severity**\n\n- **High: reward-token slot exhaustion and poisoning.** `FeeSplitter.distribute` is permissionless and registers any ERC-20 as a staker reward token in ShiftRewards, capped at 16 with no removal. An attacker fills the 14 free slots with dust tokens, after which `distribute(IMD)` or any new token reverts forever and the tokens are stuck in the splitter. A registered token that later reverts on transfer also makes `getReward()` and `exit()` revert for every staker. Proof in `test/scratch/ProofRewardTokens.t.sol` (two failing tests).\n- **Medium: owner reward duration never honoured.** Any fee distribution of SHIFT, or 1 wei of ETH sent to ShiftRewards, re-rates the whole leftover of an owner's `notifyRewardAmount` stream over 7 days. A 365-day programme collapses to one week after a 5 wei dust distribution. Proof in `test/scratch/ProofDuration.t.sol`.\n- **Low (5):** rewards streamed while nobody is staked are permanently stuck (documented in README but unrecoverable); the keeper can point `burnReserve` at a pool with its own hook and drain the reserve with `minImdOut = 0`; exact-output sells pay 2.44% SHIFT fee versus 2.5% on exact-input; exact-input fee is charged on `amountSpecified` even when a price limit leaves most of it unconsumed; `exit()` reverts while all stake is locked.\n- **Info:** the owner can post a root paying the whole NIGHT FROGS pot to itself (trust assumption, within the brief).\n\n**What held.** The launch gate, hook delta accounting on every swap path, `beforeInitialize` restrictions, the fixed 40/20/20/20 split and burn addresses, the Merkle claim and settle flow, lock enforcement, and the plain token all traced clean. Every listed entry point has a coverage row with none marked unreached.\n\n**Not reached.** I did not reproduce the keeper-drain path with a live malicious hook or the price-limit fee case in a test; both are reported with concrete call sequences but without proof files.","treeHash":null,"usage":{"cachedInputTokens":1419777,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":46733,"runtime":"claude","turns":23,"wallClockMs":764035}}],"verification":[{"checks":[{"durationMs":4652,"exitCode":0,"name":"build","output":"Compiling 95 files with Solc 0.8.26\nSolc 0.8.26 finished in 4.56s\nCompiler run successful!\n","passed":true},{"durationMs":3711,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 4 tests for test/ShiftToken.t.sol:ShiftTokenTest\n[PASS] test_hasNoMintOrOwner() (gas: 51960)\n[PASS] test_metadata() (gas: 29133)\n[PASS] test_mintsWholeSupplyToDeployer() (gas: 15181)\n[PASS] test_transferMovesExactAmount() (gas: 78818)\nSuite result: ok. 4 passed; 0 failed; 0 skipped; finished in 538.27µs (476.02µs CPU time)\n\nRan 13 tests for test/FeeSplitter.t.sol:FeeSplitterTest\n[PASS] testFuzz_splitConservesEveryWei(uint256) (runs: 256, μ: 330326, ~: 335936)\n[PASS] test_burnReserveEmitsImdBurned() (gas: 891848)\n[PASS] test_burnReserveEthSwapsToImdAndBurns() (gas: 1152149)\n[PASS] test_burnReserveRequiresKeeperAndRoute() (gas: 435289)\n[PASS] test_burnReserveTokenGoesThroughEth() (gas: 1554485)\n[PASS] test_distributeEth() (gas: 333213)\n[PASS] test_distributeImdBurnsToExecutor() (gas: 362039)\n[PASS] test_distributeOtherTokenReservesBurnShare() (gas: 500181)\n[PASS] test_distributeShiftBurnsToDead() (gas: 385981)\n[PASS] test_fixedAddressesAndSplit() (gas: 48658)\n[PASS] test_onlyOwnerSetsKeeper() (gas: 90000)\n[PASS] test_routeSettersValidate() (gas: 141576)\n[PASS] test_unlockCallbackOnlyManager() (gas: 32562)\nSuite result: ok. 13 passed; 0 failed; 0 skipped; finished in 15.33ms (12.17ms CPU time)\n\nRan 17 tests for test/ShiftRewards.t.sol:ShiftRewardsTest\n[PASS] testFuzz_earnedNeverExceedsStream(uint128,uint128,uint32) (runs: 256, μ: 660207, ~: 660299)\n[PASS] test_ethRewardSplitsHalfToStakersHalfToPot() (gas: 641242)\n[PASS] test_exitWithEverythingLockedStillPaysRewards() (gas: 628301)\n[PASS] test_failingRewardTokenDoesNotBlockBatch() (gas: 1603597)\n[PASS] test_frogsClaimTokenAndEpochCap() (gas: 502172)\n[PASS] test_frogsEpochLifecycle() (gas: 793995)\n[PASS] test_lockedStakeCannotBeWithdrawnEarly() (gas: 951944)\n[PASS] test_ownerCannotTakeStakesOrRewards() (gas: 516078)\n[PASS] test_ownerNotifyRewardAmount() (gas: 644125)\n[PASS] test_ownerStreamNotRescheduledBySplitterIncome() (gas: 523254)\n[PASS] test_rewardTokenListIsBounded() (gas: 24460754)\n[PASS] test_rewardsProRataToStakeAndTime() (gas: 936630)\n[PASS] test_shiftRewardsDoNotMixWithStakes() (gas: 644837)\n[PASS] test_stakeAndWithdrawUnlocked() (gas: 412209)\n[PASS] test_stakeRejectsBadInputs() (gas: 7031873)\n[PASS] test_streamWhileNobodyStakedGoesToFirstStaker() (gas: 431658)\n[PASS] test_tokenRewardsThroughNotifyReceived() (gas: 986382)\nSuite result: ok. 17 passed; 0 failed; 0 skipped; finished in 30.60ms (39.31ms CPU time)\n\nRan 14 tests for test/EdgeCases.t.sol:ShiftRewardsEdgeTest\n[PASS] testFuzz_earnedProRataToStake(uint256,uint256,uint256) (runs: 500, μ: 592817, ~: 593058)\n[PASS] testFuzz_earnedProRataToTime(uint256) (runs: 500, μ: 636173, ~: 636291)\n[PASS] testFuzz_gapWithNoStakersPausesStream(uint256,uint256) (runs: 300, μ: 871167, ~: 871452)\n[PASS] test_distributorCanBeSetOnlyOnce() (gas: 39974)\n[PASS] test_ethRefusingStakerKeepsStakeAndOtherRewards() (gas: 1509195)\n[PASS] test_frogsClaimByWrongAccountOrTokenRefused() (gas: 364002)\n[PASS] test_frogsWindowBoundaries() (gas: 774998)\n[PASS] test_lockBoundaryExact() (gas: 548334)\n[PASS] test_lockedPartAndFreePartAreSeparate() (gas: 694200)\n[PASS] test_notifyReceivedRefusals() (gas: 340471)\n[PASS] test_notifyRewardAmountRefusals() (gas: 520510)\n[PASS] test_streamWithNoStakersIsPaidToNextStakers() (gas: 509965)\n[PASS] test_withdrawZeroAndUnstaked() (gas: 251386)\n[PASS] test_zeroValueEthRefused() (gas: 26279)\nSuite result: ok. 14 passed; 0 failed; 0 skipped; finished in 43.51ms (115.72ms CPU time)\n\nRan 23 tests for test/NightshiftHook.t.sol:NightshiftHookTest\n[PASS] testFuzz_sellExactInputFee(uint256) (runs: 256, μ: 365886, ~: 366796)\n[PASS] testFuzz_setFeesNeverAboveCap(uint16,uint16) (runs: 256, μ: 35929, ~: 32807)\n[PASS] test_buysPayNoFeeExactInAndExactOut() (gas: 405311)\n[PASS] test_callbacksRefuseNonManager() (gas: 120916)\n[PASS] test_constructorWiresHelpersAndOwner() (gas: 100467)\n[PASS] test_ethFeeFallsBackToClaimAndIsRedeemable() (gas: 537106)\n[PASS] test_firstBuyHasNoMinimumOrMaximum() (gas: 129101)\n[PASS] test_firstBuyOpensTradingThroughAnyRouter() (gas: 475400)\n[PASS] test_firstBuyWorksOnTokensOnlyPool() (gas: 10719684)\n[PASS] test_firstBuyerSellDoesNotOpenTrading() (gas: 252130)\n[PASS] test_gateBlocksEveryoneBeforeFirstBuy() (gas: 161133)\n[PASS] test_initializeRefusesDynamicFee() (gas: 9084203)\n[PASS] test_initializeRefusesNonFactory() (gas: 9236532)\n[PASS] test_initializeRefusesSecondPool() (gas: 43660)\n[PASS] test_initializeRefusesWrongPair() (gas: 9731385)\n[PASS] test_loweredFeesApply() (gas: 559547)\n[PASS] test_nobodyCanOpenOrCloseTradingOtherwise() (gas: 265059)\n[PASS] test_partialFillSellIsRefused() (gas: 11790796)\n[PASS] test_permissionsMatchAddressFlags() (gas: 8732)\n[PASS] test_sellExactInputTakesBothFeeParts() (gas: 372692)\n[PASS] test_sellExactOutputTakesBothFeeParts() (gas: 356928)\n[PASS] test_setFeesBounds() (gas: 169714)\n[PASS] test_zeroLiquidityCollectPassesThrough() (gas: 598826)\nSuite result: ok. 23 passed; 0 failed; 0 skipped; finished in 60.88ms (88.23ms CPU time)\n\nRan 12 tests for test/EdgeCases.t.sol:LaunchEdgeTest\n[PASS] testFuzz_sellExactOutputSellerGetsExactlyRequested(uint256) (runs: 300, μ: 334715, ~: 334849)\n[PASS] test_burnReserveFailurePathsKeepReserve() (gas: 1659061)\n[PASS] test_buyStoppedByPriceLimitIsAllowedWithoutFee() (gas: 990817)\n[PASS] test_distributeReservedEthIsNotRedistributed() (gas: 382675)\n[PASS] test_firstBuyExactOutputOpensWithRequestedShift() (gas: 342415)\n[PASS] test_firstBuyerSellsThenStrangerStillBlocked() (gas: 252382)\n[PASS] test_hookedRoutesRefused() (gas: 812621)\n[PASS] test_onlyOneTradingOpenedEvent() (gas: 372903)\n[PASS] test_ownerCannotRaiseAfterLowering() (gas: 122630)\n[PASS] test_sellStoppedByPriceLimitRevertsPartialFill() (gas: 1328600)\n[PASS] test_strangerRevertsWithTradingNotOpen() (gas: 110913)\n[PASS] test_zeroFeesTakeNothing() (gas: 448762)\nSuite result: ok. 12 passed; 0 failed; 0 skipped; finished in 85.41ms (43.89ms CPU time)\n\nRan 1 test for test/invariant/System.invariant.t.sol:SystemInvariantTest\n[PASS]\nSystemInvariantTest invariants:\n[PASS] invariant_fixedSplit\n[PASS] invariant_hookHoldsNothingAndFeesCapped\n[PASS] invariant_launchGate\n[PASS] invariant_splitterConservesFees\n SystemInvariantTest invariants (runs: 64, calls: 3200, reverts: 145)\n\n╭---------------+-----------------+-------+---------+----------╮\n| Contract      | Selector        | Calls | Reverts | Discards |\n+==============================================================+\n| SystemHandler | burnReserve     | 490   | 145     | 0        |\n|---------------+-----------------+-------+---------+----------|\n| SystemHandler | buy             | 449   | 0       | 0        |\n|---------------+-----------------+-------+---------+----------|\n| SystemHandler | distribute      | 447   | 0       | 0        |\n|---------------+-----------------+-------+---------+----------|\n| SystemHandler | redeem          | 440   | 0       | 0        |\n|---------------+-----------------+-------+---------+----------|\n| SystemHandler | sell            | 442   | 0       | 0        |\n|---------------+-----------------+-------+---------+----------|\n| SystemHandler | setFees         | 480   | 0       | 0        |\n|---------------+-----------------+-------+---------+----------|\n| SystemHandler | strangerSetFees | 452   | 0       | 0        |\n╰---------------+-----------------+-------+---------+----------╯\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 620.04ms (557.66ms CPU time)\n\nRan 1 test for test/invariant/ShiftRewards.invariant.t.sol:ShiftRewardsInvariantTest\n[PASS]\nShiftRewardsInvariantTest invariants:\n[PASS] invariant_holdsWhatItAccounts\n[PASS] invariant_paidNeverExceedsFunded\n[PASS] invariant_solvent\n[PASS] invariant_stakeBookkeeping\n ShiftRewardsInvariantTest invariants (runs: 128, calls: 7680, reverts: 0)\n\n╭---------------------+-------------+-------+---------+----------╮\n| Contract            | Selector    | Calls | Reverts | Discards |\n+================================================================+\n| ShiftRewardsHandler | claimFrogs  | 751   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | fundEth     | 787   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | fundShift   | 779   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | getReward   | 781   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | ownerNotify | 778   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | postRoot    | 780   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | settleEpoch | 772   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | stake       | 751   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | warp        | 757   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | withdraw    | 744   | 0       | 0        |\n╰---------------------+-------------+-------+---------+----------╯\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.61s (3.61s CPU time)\n\nRan 8 test suites in 3.61s (4.46s CPU time): 85 tests passed, 0 failed, 0 skipped (85 total tests)\n","passed":true},{"durationMs":66,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"FeeSplitter.burnReserve(address,uint256,uint256)\",\"FeeSplitter.distribute(address)\",\"FeeSplitter.receive()\",\"FeeSplitter.renounceOwnership()\",\"FeeSplitter.setEthPool(address,(address,address,uint24,int24,address))\",\"FeeSplitter.setImdPool((address,address,uint24,int24,address))\",\"FeeSplitter.setKeeper(address)\",\"FeeSplitter.transferOwnership(address)\",\"FeeSplitter.unlockCallback(bytes)\",\"NightshiftHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"NightshiftHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"NightshiftHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"NightshiftHook.redeemFeeClaims()\",\"NightshiftHook.renounceOwnership()\",\"NightshiftHook.setFees(uint16,uint16)\",\"NightshiftHook.transferOwnership(address)\",\"NightshiftHook.unlockCallback(bytes)\",\"ShiftRewards.addRewardToken(address)\",\"ShiftRewards.claimFrogs(uint256,address,uint256,bytes32[])\",\"ShiftRewards.exit()\",\"ShiftRewards.getReward()\",\"ShiftRewards.getReward(address)\",\"ShiftRewards.notifyReceived(address)\",\"ShiftRewards.notifyRewardAmount(address,uint256,uint256)\",\"ShiftRewards.postRoot(uint256,bytes32,address,uint256)\",\"ShiftRewards.receive()\",\"ShiftRewards.renounceOwnership()\",\"ShiftRewards.setDistributor(address)\",\"ShiftRewards.settleEpoch(uint256,address)\",\"ShiftRewards.stake(uint256,uint256)\",\"ShiftRewards.transferOwnership(address)\",\"ShiftRewards.withdraw(uint256)\",\"ShiftToken.approve(address,uint256)\",\"ShiftToken.transfer(address,uint256)\",\"ShiftToken.transferFrom(address,address,uint256)\"],\"files\":{\".gitignore\":19,\"README.md\":234,\"foundry.toml\":17,\"remappings.txt\":4,\"src/FeeSplitter.sol\":227,\"src/HookFlags.sol\":33,\"src/NightshiftHook.sol\":268,\"src/ShiftRewards.sol\":512,\"src/ShiftToken.sol\":15,\"test/EdgeCases.t.sol\":636,\"test/FeeSplitter.t.sol\":271,\"test/NightshiftHook.t.sol\":443,\"test/ShiftRewards.t.sol\":450,\"test/ShiftToken.t.sol\":40,\"test/floor/OwnerChecks.sol\":24,\"test/invariant/ShiftRewards.invariant.t.sol\":344,\"test/invariant/System.invariant.t.sol\":342,\"test/mocks/MockERC20.sol\":15,\"test/mocks/Swapper.sol\":100,\"test/utils/HookMiner.sol\":22},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"0dd8789213793a8b18f5a88eae05a973fea84e37319fa497acf42b0edf7b753c","verifiedTreeHash":"327e4692bd0ecc092ff5e2041e7dc545acf3528b","verifierVersion":"0.1.0+c3fba9eb"},{"checks":[{"durationMs":4404,"exitCode":0,"name":"build","output":"Compiling 94 files with Solc 0.8.26\nSolc 0.8.26 finished in 4.30s\nCompiler run successful!\n","passed":true},{"durationMs":3644,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 4 tests for test/ShiftToken.t.sol:ShiftTokenTest\n[PASS] test_hasNoMintOrOwner() (gas: 51960)\n[PASS] test_metadata() (gas: 29133)\n[PASS] test_mintsWholeSupplyToDeployer() (gas: 15181)\n[PASS] test_transferMovesExactAmount() (gas: 78818)\nSuite result: ok. 4 passed; 0 failed; 0 skipped; finished in 15.35ms (637.19µs CPU time)\n\nRan 13 tests for test/EdgeCases.t.sol:ShiftRewardsEdgeTest\n[PASS] testFuzz_earnedProRataToStake(uint256,uint256,uint256) (runs: 500, μ: 498424, ~: 498671)\n[PASS] testFuzz_earnedProRataToTime(uint256) (runs: 500, μ: 539520, ~: 539637)\n[PASS] test_distributorCanBeSetOnlyOnce() (gas: 39952)\n[PASS] test_ethRefusingStakerKeepsStakeAndOtherRewards() (gas: 1356746)\n[PASS] test_frogsClaimByWrongAccountOrTokenRefused() (gas: 366570)\n[PASS] test_frogsWindowBoundaries() (gas: 777961)\n[PASS] test_lockBoundaryExact() (gas: 435574)\n[PASS] test_lockedPartAndFreePartAreSeparate() (gas: 533185)\n[PASS] test_notifyReceivedRefusals() (gas: 340650)\n[PASS] test_notifyRewardAmountRefusals() (gas: 525656)\n[PASS] test_streamWithNoStakersIsNotPaidToLateStaker() (gas: 352475)\n[PASS] test_withdrawZeroAndUnstaked() (gas: 170007)\n[PASS] test_zeroValueEthRefused() (gas: 26301)\nSuite result: ok. 13 passed; 0 failed; 0 skipped; finished in 48.21ms (67.52ms CPU time)\n\nRan 13 tests for test/ShiftRewards.t.sol:ShiftRewardsTest\n[PASS] testFuzz_earnedNeverExceedsStream(uint128,uint128,uint32) (runs: 256, μ: 551958, ~: 552171)\n[PASS] test_ethRewardSplitsHalfToStakersHalfToPot() (gas: 546379)\n[PASS] test_frogsClaimTokenAndEpochCap() (gas: 597057)\n[PASS] test_frogsEpochLifecycle() (gas: 796531)\n[PASS] test_lockedStakeCannotBeWithdrawnEarly() (gas: 718648)\n[PASS] test_ownerCannotTakeStakesOrRewards() (gas: 461790)\n[PASS] test_ownerNotifyRewardAmount() (gas: 590211)\n[PASS] test_rewardTokenListIsBounded() (gas: 12089955)\n[PASS] test_rewardsProRataToStakeAndTime() (gas: 785465)\n[PASS] test_shiftRewardsDoNotMixWithStakes() (gas: 534072)\n[PASS] test_stakeAndWithdrawUnlocked() (gas: 331514)\n[PASS] test_stakeRejectsBadInputs() (gas: 5705944)\n[PASS] test_tokenRewardsThroughNotifyReceived() (gas: 726081)\nSuite result: ok. 13 passed; 0 failed; 0 skipped; finished in 52.83ms (66.54ms CPU time)\n\nRan 9 tests for test/EdgeCases.t.sol:LaunchEdgeTest\n[PASS] testFuzz_sellExactOutputSellerGetsExactlyRequested(uint256) (runs: 300, μ: 334190, ~: 334321)\n[PASS] test_burnReserveFailurePathsKeepReserve() (gas: 1630439)\n[PASS] test_distributeReservedEthIsNotRedistributed() (gas: 387148)\n[PASS] test_firstBuyExactOutputOpensWithRequestedShift() (gas: 341865)\n[PASS] test_firstBuyerSellsThenStrangerStillBlocked() (gas: 251889)\n[PASS] test_onlyOneTradingOpenedEvent() (gas: 372903)\n[PASS] test_ownerCannotRaiseAfterLowering() (gas: 122586)\n[PASS] test_strangerRevertsWithTradingNotOpen() (gas: 110913)\n[PASS] test_zeroFeesTakeNothing() (gas: 447762)\nSuite result: ok. 9 passed; 0 failed; 0 skipped; finished in 56.45ms (42.95ms CPU time)\n\nRan 13 tests for test/FeeSplitter.t.sol:FeeSplitterTest\n[PASS] testFuzz_splitConservesEveryWei(uint256) (runs: 256, μ: 425610, ~: 430881)\n[PASS] test_burnReserveEmitsImdBurned() (gas: 894177)\n[PASS] test_burnReserveEthSwapsToImdAndBurns() (gas: 1154452)\n[PASS] test_burnReserveRequiresKeeperAndRoute() (gas: 437757)\n[PASS] test_burnReserveTokenGoesThroughEth() (gas: 1648949)\n[PASS] test_distributeEth() (gas: 335781)\n[PASS] test_distributeImdBurnsToExecutor() (gas: 409963)\n[PASS] test_distributeOtherTokenReservesBurnShare() (gas: 603113)\n[PASS] test_distributeShiftBurnsToDead() (gas: 386398)\n[PASS] test_fixedAddressesAndSplit() (gas: 48658)\n[PASS] test_onlyOwnerSetsKeeper() (gas: 90000)\n[PASS] test_routeSettersValidate() (gas: 141559)\n[PASS] test_unlockCallbackOnlyManager() (gas: 32562)\nSuite result: ok. 13 passed; 0 failed; 0 skipped; finished in 66.53ms (19.76ms CPU time)\n\nRan 22 tests for test/NightshiftHook.t.sol:NightshiftHookTest\n[PASS] testFuzz_sellExactInputFee(uint256) (runs: 256, μ: 365177, ~: 366303)\n[PASS] testFuzz_setFeesNeverAboveCap(uint16,uint16) (runs: 256, μ: 35865, ~: 32807)\n[PASS] test_buysPayNoFeeExactInAndExactOut() (gas: 405311)\n[PASS] test_callbacksRefuseNonManager() (gas: 120894)\n[PASS] test_constructorWiresHelpersAndOwner() (gas: 100576)\n[PASS] test_ethFeeFallsBackToClaimAndIsRedeemable() (gas: 536613)\n[PASS] test_firstBuyHasNoMinimumOrMaximum() (gas: 129101)\n[PASS] test_firstBuyOpensTradingThroughAnyRouter() (gas: 474874)\n[PASS] test_firstBuyWorksOnTokensOnlyPool() (gas: 9995507)\n[PASS] test_firstBuyerSellDoesNotOpenTrading() (gas: 251615)\n[PASS] test_gateBlocksEveryoneBeforeFirstBuy() (gas: 161133)\n[PASS] test_initializeRefusesDynamicFee() (gas: 8362019)\n[PASS] test_initializeRefusesNonFactory() (gas: 8510886)\n[PASS] test_initializeRefusesSecondPool() (gas: 43660)\n[PASS] test_initializeRefusesWrongPair() (gas: 9005685)\n[PASS] test_loweredFeesApply() (gas: 558561)\n[PASS] test_nobodyCanOpenOrCloseTradingOtherwise() (gas: 265059)\n[PASS] test_permissionsMatchAddressFlags() (gas: 8732)\n[PASS] test_sellExactInputTakesBothFeeParts() (gas: 372199)\n[PASS] test_sellExactOutputTakesBothFeeParts() (gas: 353339)\n[PASS] test_setFeesBounds() (gas: 169714)\n[PASS] test_zeroLiquidityCollectPassesThrough() (gas: 598333)\nSuite result: ok. 22 passed; 0 failed; 0 skipped; finished in 66.51ms (104.79ms CPU time)\n\nRan 1 test for test/invariant/System.invariant.t.sol:SystemInvariantTest\n[PASS]\nSystemInvariantTest invariants:\n[PASS] invariant_fixedSplit\n[PASS] invariant_hookHoldsNothingAndFeesCapped\n[PASS] invariant_launchGate\n[PASS] invariant_splitterConservesFees\n SystemInvariantTest invariants (runs: 64, calls: 3200, reverts: 0)\n\n╭---------------+-----------------+-------+---------+----------╮\n| Contract      | Selector        | Calls | Reverts | Discards |\n+==============================================================+\n| SystemHandler | burnReserve     | 490   | 0       | 0        |\n|---------------+-----------------+-------+---------+----------|\n| SystemHandler | buy             | 449   | 0       | 0        |\n|---------------+-----------------+-------+---------+----------|\n| SystemHandler | distribute      | 447   | 0       | 0        |\n|---------------+-----------------+-------+---------+----------|\n| SystemHandler | redeem          | 440   | 0       | 0        |\n|---------------+-----------------+-------+---------+----------|\n| SystemHandler | sell            | 442   | 0       | 0        |\n|---------------+-----------------+-------+---------+----------|\n| SystemHandler | setFees         | 480   | 0       | 0        |\n|---------------+-----------------+-------+---------+----------|\n| SystemHandler | strangerSetFees | 452   | 0       | 0        |\n╰---------------+-----------------+-------+---------+----------╯\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 575.39ms (556.44ms CPU time)\n\nRan 1 test for test/invariant/ShiftRewards.invariant.t.sol:ShiftRewardsInvariantTest\n[PASS]\nShiftRewardsInvariantTest invariants:\n[PASS] invariant_holdsWhatItAccounts\n[PASS] invariant_paidNeverExceedsFunded\n[PASS] invariant_solvent\n[PASS] invariant_stakeBookkeeping\n ShiftRewardsInvariantTest invariants (runs: 128, calls: 7680, reverts: 0)\n\n╭---------------------+-------------+-------+---------+----------╮\n| Contract            | Selector    | Calls | Reverts | Discards |\n+================================================================+\n| ShiftRewardsHandler | claimFrogs  | 751   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | fundEth     | 787   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | fundShift   | 779   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | getReward   | 781   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | ownerNotify | 778   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | postRoot    | 780   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | settleEpoch | 772   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | stake       | 751   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | warp        | 757   | 0       | 0        |\n|---------------------+-------------+-------+---------+----------|\n| ShiftRewardsHandler | withdraw    | 744   | 0       | 0        |\n╰---------------------+-------------+-------+---------+----------╯\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.52s (3.52s CPU time)\n\nRan 8 test suites in 3.53s (4.40s CPU time): 76 tests passed, 0 failed, 0 skipped (76 total tests)\n","passed":true},{"durationMs":62,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"FeeSplitter.burnReserve(address,uint256,uint256)\",\"FeeSplitter.distribute(address)\",\"FeeSplitter.receive()\",\"FeeSplitter.renounceOwnership()\",\"FeeSplitter.setEthPool(address,(address,address,uint24,int24,address))\",\"FeeSplitter.setImdPool((address,address,uint24,int24,address))\",\"FeeSplitter.setKeeper(address)\",\"FeeSplitter.transferOwnership(address)\",\"FeeSplitter.unlockCallback(bytes)\",\"NightshiftHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"NightshiftHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"NightshiftHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"NightshiftHook.redeemFeeClaims()\",\"NightshiftHook.renounceOwnership()\",\"NightshiftHook.setFees(uint16,uint16)\",\"NightshiftHook.transferOwnership(address)\",\"NightshiftHook.unlockCallback(bytes)\",\"ShiftRewards.claimFrogs(uint256,address,uint256,bytes32[])\",\"ShiftRewards.exit()\",\"ShiftRewards.getReward()\",\"ShiftRewards.getReward(address)\",\"ShiftRewards.notifyReceived(address)\",\"ShiftRewards.notifyRewardAmount(address,uint256,uint256)\",\"ShiftRewards.postRoot(uint256,bytes32,address,uint256)\",\"ShiftRewards.receive()\",\"ShiftRewards.renounceOwnership()\",\"ShiftRewards.setDistributor(address)\",\"ShiftRewards.settleEpoch(uint256,address)\",\"ShiftRewards.stake(uint256,uint256)\",\"ShiftRewards.transferOwnership(address)\",\"ShiftRewards.withdraw(uint256)\",\"ShiftToken.approve(address,uint256)\",\"ShiftToken.transfer(address,uint256)\",\"ShiftToken.transferFrom(address,address,uint256)\"],\"files\":{\".gitignore\":19,\"README.md\":210,\"foundry.toml\":17,\"remappings.txt\":4,\"src/FeeSplitter.sol\":225,\"src/HookFlags.sol\":33,\"src/NightshiftHook.sol\":261,\"src/ShiftRewards.sol\":433,\"src/ShiftToken.sol\":15,\"test/EdgeCases.t.sol\":478,\"test/FeeSplitter.t.sol\":271,\"test/NightshiftHook.t.sol\":421,\"test/ShiftRewards.t.sol\":368,\"test/ShiftToken.t.sol\":40,\"test/floor/OwnerChecks.sol\":24,\"test/invariant/ShiftRewards.invariant.t.sol\":344,\"test/invariant/System.invariant.t.sol\":342,\"test/mocks/MockERC20.sol\":15,\"test/mocks/Swapper.sol\":100,\"test/utils/HookMiner.sol\":22},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"1877f5372ee456b9eb0995ffaad37b1db5d2547e3733199b57601082d5fb507f","verifiedTreeHash":"d262699e0bf03f13c008e4c3e355514c813aa5d0","verifierVersion":"0.1.0+c3fba9eb"},{"checks":[{"durationMs":3082,"exitCode":0,"name":"build","output":"Compiling 91 files with Solc 0.8.26\nSolc 0.8.26 finished in 2.97s\nCompiler run successful!\n","passed":true},{"durationMs":186,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 4 tests for test/ShiftToken.t.sol:ShiftTokenTest\n[PASS] test_hasNoMintOrOwner() (gas: 51960)\n[PASS] test_metadata() (gas: 29133)\n[PASS] test_mintsWholeSupplyToDeployer() (gas: 15181)\n[PASS] test_transferMovesExactAmount() (gas: 78818)\nSuite result: ok. 4 passed; 0 failed; 0 skipped; finished in 579.99µs (397.76µs CPU time)\n\nRan 13 tests for test/FeeSplitter.t.sol:FeeSplitterTest\n[PASS] testFuzz_splitConservesEveryWei(uint256) (runs: 256, μ: 330734, ~: 335942)\n[PASS] test_burnReserveEmitsImdBurned() (gas: 891848)\n[PASS] test_burnReserveEthSwapsToImdAndBurns() (gas: 1152149)\n[PASS] test_burnReserveRequiresKeeperAndRoute() (gas: 435289)\n[PASS] test_burnReserveTokenGoesThroughEth() (gas: 1554485)\n[PASS] test_distributeEth() (gas: 333213)\n[PASS] test_distributeImdBurnsToExecutor() (gas: 362039)\n[PASS] test_distributeOtherTokenReservesBurnShare() (gas: 500181)\n[PASS] test_distributeShiftBurnsToDead() (gas: 385981)\n[PASS] test_fixedAddressesAndSplit() (gas: 48658)\n[PASS] test_onlyOwnerSetsKeeper() (gas: 90000)\n[PASS] test_routeSettersValidate() (gas: 141576)\n[PASS] test_unlockCallbackOnlyManager() (gas: 32562)\nSuite result: ok. 13 passed; 0 failed; 0 skipped; finished in 10.71ms (14.95ms CPU time)\n\nRan 17 tests for test/ShiftRewards.t.sol:ShiftRewardsTest\n[PASS] testFuzz_earnedNeverExceedsStream(uint128,uint128,uint32) (runs: 256, μ: 660216, ~: 660299)\n[PASS] test_ethRewardSplitsHalfToStakersHalfToPot() (gas: 641242)\n[PASS] test_exitWithEverythingLockedStillPaysRewards() (gas: 628301)\n[PASS] test_failingRewardTokenDoesNotBlockBatch() (gas: 1603597)\n[PASS] test_frogsClaimTokenAndEpochCap() (gas: 502172)\n[PASS] test_frogsEpochLifecycle() (gas: 793995)\n[PASS] test_lockedStakeCannotBeWithdrawnEarly() (gas: 951944)\n[PASS] test_ownerCannotTakeStakesOrRewards() (gas: 516078)\n[PASS] test_ownerNotifyRewardAmount() (gas: 644125)\n[PASS] test_ownerStreamNotRescheduledBySplitterIncome() (gas: 523254)\n[PASS] test_rewardTokenListIsBounded() (gas: 24460754)\n[PASS] test_rewardsProRataToStakeAndTime() (gas: 936630)\n[PASS] test_shiftRewardsDoNotMixWithStakes() (gas: 644837)\n[PASS] test_stakeAndWithdrawUnlocked() (gas: 412209)\n[PASS] test_stakeRejectsBadInputs() (gas: 7031873)\n[PASS] test_streamWhileNobodyStakedGoesToFirstStaker() (gas: 431658)\n[PASS] test_tokenRewardsThroughNotifyReceived() (gas: 986382)\nSuite result: ok. 17 passed; 0 failed; 0 skipped; finished in 17.86ms (31.83ms CPU time)\n\nRan 23 tests for test/NightshiftHook.t.sol:NightshiftHookTest\n[PASS] testFuzz_sellExactInputFee(uint256) (runs: 256, μ: 365659, ~: 366796)\n[PASS] testFuzz_setFeesNeverAboveCap(uint16,uint16) (runs: 256, μ: 35865, ~: 32807)\n[PASS] test_buysPayNoFeeExactInAndExactOut() (gas: 405311)\n[PASS] test_callbacksRefuseNonManager() (gas: 120916)\n[PASS] test_constructorWiresHelpersAndOwner() (gas: 100467)\n[PASS] test_ethFeeFallsBackToClaimAndIsRedeemable() (gas: 537106)\n[PASS] test_firstBuyHasNoMinimumOrMaximum() (gas: 129101)\n[PASS] test_firstBuyOpensTradingThroughAnyRouter() (gas: 475400)\n[PASS] test_firstBuyWorksOnTokensOnlyPool() (gas: 10719684)\n[PASS] test_firstBuyerSellDoesNotOpenTrading() (gas: 252130)\n[PASS] test_gateBlocksEveryoneBeforeFirstBuy() (gas: 161133)\n[PASS] test_initializeRefusesDynamicFee() (gas: 9084203)\n[PASS] test_initializeRefusesNonFactory() (gas: 9236532)\n[PASS] test_initializeRefusesSecondPool() (gas: 43660)\n[PASS] test_initializeRefusesWrongPair() (gas: 9731385)\n[PASS] test_loweredFeesApply() (gas: 559547)\n[PASS] test_nobodyCanOpenOrCloseTradingOtherwise() (gas: 265059)\n[PASS] test_partialFillSellIsRefused() (gas: 11790796)\n[PASS] test_permissionsMatchAddressFlags() (gas: 8732)\n[PASS] test_sellExactInputTakesBothFeeParts() (gas: 372692)\n[PASS] test_sellExactOutputTakesBothFeeParts() (gas: 356928)\n[PASS] test_setFeesBounds() (gas: 169714)\n[PASS] test_zeroLiquidityCollectPassesThrough() (gas: 598826)\nSuite result: ok. 23 passed; 0 failed; 0 skipped; finished in 75.31ms (109.72ms CPU time)\n\nRan 4 test suites in 76.23ms (104.46ms CPU time): 57 tests passed, 0 failed, 0 skipped (57 total tests)\n","passed":true},{"durationMs":57,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"FeeSplitter.burnReserve(address,uint256,uint256)\",\"FeeSplitter.distribute(address)\",\"FeeSplitter.receive()\",\"FeeSplitter.renounceOwnership()\",\"FeeSplitter.setEthPool(address,(address,address,uint24,int24,address))\",\"FeeSplitter.setImdPool((address,address,uint24,int24,address))\",\"FeeSplitter.setKeeper(address)\",\"FeeSplitter.transferOwnership(address)\",\"FeeSplitter.unlockCallback(bytes)\",\"NightshiftHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"NightshiftHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"NightshiftHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"NightshiftHook.redeemFeeClaims()\",\"NightshiftHook.renounceOwnership()\",\"NightshiftHook.setFees(uint16,uint16)\",\"NightshiftHook.transferOwnership(address)\",\"NightshiftHook.unlockCallback(bytes)\",\"ShiftRewards.addRewardToken(address)\",\"ShiftRewards.claimFrogs(uint256,address,uint256,bytes32[])\",\"ShiftRewards.exit()\",\"ShiftRewards.getReward()\",\"ShiftRewards.getReward(address)\",\"ShiftRewards.notifyReceived(address)\",\"ShiftRewards.notifyRewardAmount(address,uint256,uint256)\",\"ShiftRewards.postRoot(uint256,bytes32,address,uint256)\",\"ShiftRewards.receive()\",\"ShiftRewards.renounceOwnership()\",\"ShiftRewards.setDistributor(address)\",\"ShiftRewards.settleEpoch(uint256,address)\",\"ShiftRewards.stake(uint256,uint256)\",\"ShiftRewards.transferOwnership(address)\",\"ShiftRewards.withdraw(uint256)\",\"ShiftToken.approve(address,uint256)\",\"ShiftToken.transfer(address,uint256)\",\"ShiftToken.transferFrom(address,address,uint256)\"],\"files\":{\".gitignore\":19,\"README.md\":234,\"foundry.toml\":17,\"launch.json\":32,\"remappings.txt\":4,\"src/FeeSplitter.sol\":227,\"src/HookFlags.sol\":33,\"src/NightshiftHook.sol\":268,\"src/ShiftRewards.sol\":512,\"src/ShiftToken.sol\":15,\"test/FeeSplitter.t.sol\":271,\"test/NightshiftHook.t.sol\":443,\"test/ShiftRewards.t.sol\":450,\"test/ShiftToken.t.sol\":40,\"test/floor/OwnerChecks.sol\":24,\"test/mocks/MockERC20.sol\":15,\"test/mocks/Swapper.sol\":100,\"test/utils/HookMiner.sol\":22},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"2933c0adbe4558b2e23ff090cf9029a963b2e27c33288d10dd82b16485fef92b","verifiedTreeHash":"c4c87481931bed6688dca1e4f891d68498105d3f","verifierVersion":"0.1.0+c3fba9eb"},{"checks":[{"durationMs":3009,"exitCode":0,"name":"build","output":"Compiling 91 files with Solc 0.8.26\nSolc 0.8.26 finished in 2.90s\nCompiler run successful!\n","passed":true},{"durationMs":177,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 4 tests for test/ShiftToken.t.sol:ShiftTokenTest\n[PASS] test_hasNoMintOrOwner() (gas: 51960)\n[PASS] test_metadata() (gas: 29133)\n[PASS] test_mintsWholeSupplyToDeployer() (gas: 15181)\n[PASS] test_transferMovesExactAmount() (gas: 78818)\nSuite result: ok. 4 passed; 0 failed; 0 skipped; finished in 704.46µs (477.96µs CPU time)\n\nRan 13 tests for test/FeeSplitter.t.sol:FeeSplitterTest\n[PASS] testFuzz_splitConservesEveryWei(uint256) (runs: 256, μ: 425187, ~: 430881)\n[PASS] test_burnReserveEmitsImdBurned() (gas: 894177)\n[PASS] test_burnReserveEthSwapsToImdAndBurns() (gas: 1154452)\n[PASS] test_burnReserveRequiresKeeperAndRoute() (gas: 437757)\n[PASS] test_burnReserveTokenGoesThroughEth() (gas: 1648949)\n[PASS] test_distributeEth() (gas: 335781)\n[PASS] test_distributeImdBurnsToExecutor() (gas: 409963)\n[PASS] test_distributeOtherTokenReservesBurnShare() (gas: 603113)\n[PASS] test_distributeShiftBurnsToDead() (gas: 386398)\n[PASS] test_fixedAddressesAndSplit() (gas: 48658)\n[PASS] test_onlyOwnerSetsKeeper() (gas: 90000)\n[PASS] test_routeSettersValidate() (gas: 141559)\n[PASS] test_unlockCallbackOnlyManager() (gas: 32562)\nSuite result: ok. 13 passed; 0 failed; 0 skipped; finished in 16.04ms (15.88ms CPU time)\n\nRan 13 tests for test/ShiftRewards.t.sol:ShiftRewardsTest\n[PASS] testFuzz_earnedNeverExceedsStream(uint128,uint128,uint32) (runs: 256, μ: 552018, ~: 552171)\n[PASS] test_ethRewardSplitsHalfToStakersHalfToPot() (gas: 546379)\n[PASS] test_frogsClaimTokenAndEpochCap() (gas: 597057)\n[PASS] test_frogsEpochLifecycle() (gas: 796531)\n[PASS] test_lockedStakeCannotBeWithdrawnEarly() (gas: 718648)\n[PASS] test_ownerCannotTakeStakesOrRewards() (gas: 461790)\n[PASS] test_ownerNotifyRewardAmount() (gas: 590211)\n[PASS] test_rewardTokenListIsBounded() (gas: 12089955)\n[PASS] test_rewardsProRataToStakeAndTime() (gas: 785465)\n[PASS] test_shiftRewardsDoNotMixWithStakes() (gas: 534072)\n[PASS] test_stakeAndWithdrawUnlocked() (gas: 331514)\n[PASS] test_stakeRejectsBadInputs() (gas: 5705944)\n[PASS] test_tokenRewardsThroughNotifyReceived() (gas: 726081)\nSuite result: ok. 13 passed; 0 failed; 0 skipped; finished in 29.35ms (35.56ms CPU time)\n\nRan 22 tests for test/NightshiftHook.t.sol:NightshiftHookTest\n[PASS] testFuzz_sellExactInputFee(uint256) (runs: 256, μ: 365237, ~: 366303)\n[PASS] testFuzz_setFeesNeverAboveCap(uint16,uint16) (runs: 256, μ: 35865, ~: 32807)\n[PASS] test_buysPayNoFeeExactInAndExactOut() (gas: 405311)\n[PASS] test_callbacksRefuseNonManager() (gas: 120894)\n[PASS] test_constructorWiresHelpersAndOwner() (gas: 100576)\n[PASS] test_ethFeeFallsBackToClaimAndIsRedeemable() (gas: 536613)\n[PASS] test_firstBuyHasNoMinimumOrMaximum() (gas: 129101)\n[PASS] test_firstBuyOpensTradingThroughAnyRouter() (gas: 474874)\n[PASS] test_firstBuyWorksOnTokensOnlyPool() (gas: 9995507)\n[PASS] test_firstBuyerSellDoesNotOpenTrading() (gas: 251615)\n[PASS] test_gateBlocksEveryoneBeforeFirstBuy() (gas: 161133)\n[PASS] test_initializeRefusesDynamicFee() (gas: 8362019)\n[PASS] test_initializeRefusesNonFactory() (gas: 8510886)\n[PASS] test_initializeRefusesSecondPool() (gas: 43660)\n[PASS] test_initializeRefusesWrongPair() (gas: 9005685)\n[PASS] test_loweredFeesApply() (gas: 558561)\n[PASS] test_nobodyCanOpenOrCloseTradingOtherwise() (gas: 265059)\n[PASS] test_permissionsMatchAddressFlags() (gas: 8732)\n[PASS] test_sellExactInputTakesBothFeeParts() (gas: 372199)\n[PASS] test_sellExactOutputTakesBothFeeParts() (gas: 353339)\n[PASS] test_setFeesBounds() (gas: 169714)\n[PASS] test_zeroLiquidityCollectPassesThrough() (gas: 598333)\nSuite result: ok. 22 passed; 0 failed; 0 skipped; finished in 54.80ms (87.17ms CPU time)\n\nRan 4 test suites in 56.21ms (100.89ms CPU time): 52 tests passed, 0 failed, 0 skipped (52 total tests)\n","passed":true},{"durationMs":59,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"FeeSplitter.burnReserve(address,uint256,uint256)\",\"FeeSplitter.distribute(address)\",\"FeeSplitter.receive()\",\"FeeSplitter.renounceOwnership()\",\"FeeSplitter.setEthPool(address,(address,address,uint24,int24,address))\",\"FeeSplitter.setImdPool((address,address,uint24,int24,address))\",\"FeeSplitter.setKeeper(address)\",\"FeeSplitter.transferOwnership(address)\",\"FeeSplitter.unlockCallback(bytes)\",\"NightshiftHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"NightshiftHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"NightshiftHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"NightshiftHook.redeemFeeClaims()\",\"NightshiftHook.renounceOwnership()\",\"NightshiftHook.setFees(uint16,uint16)\",\"NightshiftHook.transferOwnership(address)\",\"NightshiftHook.unlockCallback(bytes)\",\"ShiftRewards.claimFrogs(uint256,address,uint256,bytes32[])\",\"ShiftRewards.exit()\",\"ShiftRewards.getReward()\",\"ShiftRewards.getReward(address)\",\"ShiftRewards.notifyReceived(address)\",\"ShiftRewards.notifyRewardAmount(address,uint256,uint256)\",\"ShiftRewards.postRoot(uint256,bytes32,address,uint256)\",\"ShiftRewards.receive()\",\"ShiftRewards.renounceOwnership()\",\"ShiftRewards.setDistributor(address)\",\"ShiftRewards.settleEpoch(uint256,address)\",\"ShiftRewards.stake(uint256,uint256)\",\"ShiftRewards.transferOwnership(address)\",\"ShiftRewards.withdraw(uint256)\",\"ShiftToken.approve(address,uint256)\",\"ShiftToken.transfer(address,uint256)\",\"ShiftToken.transferFrom(address,address,uint256)\"],\"files\":{\".gitignore\":19,\"README.md\":210,\"foundry.toml\":17,\"launch.json\":32,\"remappings.txt\":4,\"src/FeeSplitter.sol\":225,\"src/HookFlags.sol\":33,\"src/NightshiftHook.sol\":261,\"src/ShiftRewards.sol\":433,\"src/ShiftToken.sol\":15,\"test/FeeSplitter.t.sol\":271,\"test/NightshiftHook.t.sol\":421,\"test/ShiftRewards.t.sol\":368,\"test/ShiftToken.t.sol\":40,\"test/floor/OwnerChecks.sol\":24,\"test/mocks/MockERC20.sol\":15,\"test/mocks/Swapper.sol\":100,\"test/utils/HookMiner.sol\":22},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"3aaff3dbe9af821b8f57c613f5847b48cb45379654af488145894ed016ec5c4a","verifiedTreeHash":"3916399357a96d10295eb58740bb854101832dac","verifierVersion":"0.1.0+c3fba9eb"},{"checks":[{"durationMs":2862,"exitCode":0,"name":"build","output":"Compiling 91 files with Solc 0.8.26\nSolc 0.8.26 finished in 2.75s\nCompiler run successful!\n","passed":true},{"durationMs":201,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 4 tests for test/ShiftToken.t.sol:ShiftTokenTest\n[PASS] test_hasNoMintOrOwner() (gas: 51960)\n[PASS] test_metadata() (gas: 29133)\n[PASS] test_mintsWholeSupplyToDeployer() (gas: 15181)\n[PASS] test_transferMovesExactAmount() (gas: 78818)\nSuite result: ok. 4 passed; 0 failed; 0 skipped; finished in 1.83ms (512.72µs CPU time)\n\nRan 13 tests for test/FeeSplitter.t.sol:FeeSplitterTest\n[PASS] testFuzz_splitConservesEveryWei(uint256) (runs: 256, μ: 330734, ~: 335942)\n[PASS] test_burnReserveEmitsImdBurned() (gas: 891848)\n[PASS] test_burnReserveEthSwapsToImdAndBurns() (gas: 1152149)\n[PASS] test_burnReserveRequiresKeeperAndRoute() (gas: 435289)\n[PASS] test_burnReserveTokenGoesThroughEth() (gas: 1554485)\n[PASS] test_distributeEth() (gas: 333213)\n[PASS] test_distributeImdBurnsToExecutor() (gas: 362039)\n[PASS] test_distributeOtherTokenReservesBurnShare() (gas: 500181)\n[PASS] test_distributeShiftBurnsToDead() (gas: 385981)\n[PASS] test_fixedAddressesAndSplit() (gas: 48658)\n[PASS] test_onlyOwnerSetsKeeper() (gas: 90000)\n[PASS] test_routeSettersValidate() (gas: 141576)\n[PASS] test_unlockCallbackOnlyManager() (gas: 32562)\nSuite result: ok. 13 passed; 0 failed; 0 skipped; finished in 15.84ms (19.12ms CPU time)\n\nRan 17 tests for test/ShiftRewards.t.sol:ShiftRewardsTest\n[PASS] testFuzz_earnedNeverExceedsStream(uint128,uint128,uint32) (runs: 256, μ: 660216, ~: 660299)\n[PASS] test_ethRewardSplitsHalfToStakersHalfToPot() (gas: 641242)\n[PASS] test_exitWithEverythingLockedStillPaysRewards() (gas: 628301)\n[PASS] test_failingRewardTokenDoesNotBlockBatch() (gas: 1603597)\n[PASS] test_frogsClaimTokenAndEpochCap() (gas: 502172)\n[PASS] test_frogsEpochLifecycle() (gas: 793995)\n[PASS] test_lockedStakeCannotBeWithdrawnEarly() (gas: 951944)\n[PASS] test_ownerCannotTakeStakesOrRewards() (gas: 516078)\n[PASS] test_ownerNotifyRewardAmount() (gas: 644125)\n[PASS] test_ownerStreamNotRescheduledBySplitterIncome() (gas: 523254)\n[PASS] test_rewardTokenListIsBounded() (gas: 24460754)\n[PASS] test_rewardsProRataToStakeAndTime() (gas: 936630)\n[PASS] test_shiftRewardsDoNotMixWithStakes() (gas: 644837)\n[PASS] test_stakeAndWithdrawUnlocked() (gas: 412209)\n[PASS] test_stakeRejectsBadInputs() (gas: 7031873)\n[PASS] test_streamWhileNobodyStakedGoesToFirstStaker() (gas: 431658)\n[PASS] test_tokenRewardsThroughNotifyReceived() (gas: 986382)\nSuite result: ok. 17 passed; 0 failed; 0 skipped; finished in 21.93ms (33.60ms CPU time)\n\nRan 23 tests for test/NightshiftHook.t.sol:NightshiftHookTest\n[PASS] testFuzz_sellExactInputFee(uint256) (runs: 256, μ: 365659, ~: 366796)\n[PASS] testFuzz_setFeesNeverAboveCap(uint16,uint16) (runs: 256, μ: 35865, ~: 32807)\n[PASS] test_buysPayNoFeeExactInAndExactOut() (gas: 405311)\n[PASS] test_callbacksRefuseNonManager() (gas: 120916)\n[PASS] test_constructorWiresHelpersAndOwner() (gas: 100467)\n[PASS] test_ethFeeFallsBackToClaimAndIsRedeemable() (gas: 537106)\n[PASS] test_firstBuyHasNoMinimumOrMaximum() (gas: 129101)\n[PASS] test_firstBuyOpensTradingThroughAnyRouter() (gas: 475400)\n[PASS] test_firstBuyWorksOnTokensOnlyPool() (gas: 10719684)\n[PASS] test_firstBuyerSellDoesNotOpenTrading() (gas: 252130)\n[PASS] test_gateBlocksEveryoneBeforeFirstBuy() (gas: 161133)\n[PASS] test_initializeRefusesDynamicFee() (gas: 9084203)\n[PASS] test_initializeRefusesNonFactory() (gas: 9236532)\n[PASS] test_initializeRefusesSecondPool() (gas: 43660)\n[PASS] test_initializeRefusesWrongPair() (gas: 9731385)\n[PASS] test_loweredFeesApply() (gas: 559547)\n[PASS] test_nobodyCanOpenOrCloseTradingOtherwise() (gas: 265059)\n[PASS] test_partialFillSellIsRefused() (gas: 11790796)\n[PASS] test_permissionsMatchAddressFlags() (gas: 8732)\n[PASS] test_sellExactInputTakesBothFeeParts() (gas: 372692)\n[PASS] test_sellExactOutputTakesBothFeeParts() (gas: 356928)\n[PASS] test_setFeesBounds() (gas: 169714)\n[PASS] test_zeroLiquidityCollectPassesThrough() (gas: 598826)\nSuite result: ok. 23 passed; 0 failed; 0 skipped; finished in 62.93ms (95.64ms CPU time)\n\nRan 4 test suites in 63.89ms (102.53ms CPU time): 57 tests passed, 0 failed, 0 skipped (57 total tests)\n","passed":true},{"durationMs":56,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"FeeSplitter.burnReserve(address,uint256,uint256)\",\"FeeSplitter.distribute(address)\",\"FeeSplitter.receive()\",\"FeeSplitter.renounceOwnership()\",\"FeeSplitter.setEthPool(address,(address,address,uint24,int24,address))\",\"FeeSplitter.setImdPool((address,address,uint24,int24,address))\",\"FeeSplitter.setKeeper(address)\",\"FeeSplitter.transferOwnership(address)\",\"FeeSplitter.unlockCallback(bytes)\",\"NightshiftHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"NightshiftHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"NightshiftHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"NightshiftHook.redeemFeeClaims()\",\"NightshiftHook.renounceOwnership()\",\"NightshiftHook.setFees(uint16,uint16)\",\"NightshiftHook.transferOwnership(address)\",\"NightshiftHook.unlockCallback(bytes)\",\"ShiftRewards.addRewardToken(address)\",\"ShiftRewards.claimFrogs(uint256,address,uint256,bytes32[])\",\"ShiftRewards.exit()\",\"ShiftRewards.getReward()\",\"ShiftRewards.getReward(address)\",\"ShiftRewards.notifyReceived(address)\",\"ShiftRewards.notifyRewardAmount(address,uint256,uint256)\",\"ShiftRewards.postRoot(uint256,bytes32,address,uint256)\",\"ShiftRewards.receive()\",\"ShiftRewards.renounceOwnership()\",\"ShiftRewards.setDistributor(address)\",\"ShiftRewards.settleEpoch(uint256,address)\",\"ShiftRewards.stake(uint256,uint256)\",\"ShiftRewards.transferOwnership(address)\",\"ShiftRewards.withdraw(uint256)\",\"ShiftToken.approve(address,uint256)\",\"ShiftToken.transfer(address,uint256)\",\"ShiftToken.transferFrom(address,address,uint256)\"],\"files\":{\".gitignore\":19,\"README.md\":234,\"foundry.toml\":17,\"remappings.txt\":4,\"src/FeeSplitter.sol\":227,\"src/HookFlags.sol\":33,\"src/NightshiftHook.sol\":268,\"src/ShiftRewards.sol\":512,\"src/ShiftToken.sol\":15,\"test/FeeSplitter.t.sol\":271,\"test/NightshiftHook.t.sol\":443,\"test/ShiftRewards.t.sol\":450,\"test/ShiftToken.t.sol\":40,\"test/floor/OwnerChecks.sol\":24,\"test/mocks/MockERC20.sol\":15,\"test/mocks/Swapper.sol\":100,\"test/utils/HookMiner.sol\":22},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true},{"durationMs":2477,"exitCode":0,"name":"slither","output":"[high/medium] arbitrary-send-eth at src/ShiftRewards.sol:504: ShiftRewards._send(address,address,uint256) (src/ShiftRewards.sol#504-511) sends eth to arbitrary user\n[high/medium] arbitrary-send-eth at src/ShiftRewards.sol:494: ShiftRewards._trySend(address,address,uint256) (src/ShiftRewards.sol#494-502) sends eth to arbitrary user\n[high/medium] reentrancy-eth at src/ShiftRewards.sol:193: Reentrancy in ShiftRewards._payAll(address) (src/ShiftRewards.sol#193-209):\n[high/medium] reentrancy-eth at src/FeeSplitter.sol:88: Reentrancy in FeeSplitter.distribute(address) (src/FeeSplitter.sol#88-117):\n[high/high] uninitialized-state at src/ShiftRewards.sol:154: ShiftRewards._locks (src/ShiftRewards.sol#75) is never initialized. It is used in:\n[medium/high] incorrect-equality at src/ShiftRewards.sol:418: ShiftRewards.notifyRewardAmount(address,uint256,uint256) (src/ShiftRewards.sol#418-438) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/FeeSplitter.sol:88: FeeSplitter.distribute(address) (src/FeeSplitter.sol#88-117) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/ShiftRewards.sol:393: ShiftRewards.notifyReceived(address) (src/ShiftRewards.sol#393-401) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/FeeSplitter.sol:222: FeeSplitter._sendEth(address,uint256) (src/FeeSplitter.sol#222-226) uses a dangerous strict equality:\n[medium/medium] reentrancy-no-eth at src/FeeSplitter.sol:88: Reentrancy in FeeSplitter.distribute(address) (src/FeeSplitter.sol#88-117):\n[medium/medium] tx-origin at src/NightshiftHook.sol:138: NightshiftHook.beforeSwap(address,PoolKey,SwapParams,bytes) (src/NightshiftHook.sol#138-163) uses tx.origin for authorization: ! tradingOpen && tx.origin != FIRST_BUYER (src/NightshiftHook.sol#143)\n[medium/medium] unused-return at src/FeeSplitter.sol:147: FeeSplitter.unlockCallback(bytes) (src/FeeSplitter.sol#147-173) ignores return value by poolManager.settle() (src/FeeSplitter.sol#161)\n[medium/medium] unused-return at src/NightshiftHook.sol:228: NightshiftHook.redeemFeeClaims() (src/NightshiftHook.sol#228-233) ignores return value by poolManager.unlock(abi.encode(amount)) (src/NightshiftHook.sol#231)\n[medium/medium] unused-return at src/FeeSplitter.sol:147: FeeSplitter.unlockCallback(bytes) (src/FeeSplitter.sol#147-173) ignores return value by poolManager.settle{value: ethIn}() (src/FeeSplitter.sol#169)\n[low/medium] missing-zero-check at src/NightshiftHook.sol:90: NightshiftHook.constructor(IPoolManager,address,address,address).token_ (src/NightshiftHook.sol#90) lacks a zero-check on :\n[low/medium] missing-zero-check at src/FeeSplitter.sol:190: FeeSplitter.setKeeper(address).keeper_ (src/FeeSplitter.sol#190) lacks a zero-check on :\n[low/medium] missing-zero-check at src/FeeSplitter.sol:77: FeeSplitter.constructor(IPoolManager,address,address,address).shift_ (src/FeeSplitter.sol#77) lacks a zero-check on :\n[low/medium] missing-zero-check at src/ShiftRewards.sol:145: ShiftRewards.setDistributor(address).distributor_ (src/ShiftRewards.sol#145) lacks a zero-check on :\n[low/medium] missing-zero-check at src/NightshiftHook.sol:90: NightshiftHook.constructor(IPoolManager,address,address,address).factory_ (src/NightshiftHook.sol#90) lacks a zero-check on :\n[low/medium] calls-loop at src/ShiftRewards.sol:494: ShiftRewards._trySend(address,address,uint256) (src/ShiftRewards.sol#494-502) has external calls inside a loop: (ok,None) = to.call{value: amount}() (src/ShiftRewards.sol#496)\n[low/medium] calls-loop at src/ShiftRewards.sol:494: ShiftRewards._trySend(address,address,uint256) (src/ShiftRewards.sol#494-502) has external calls inside a loop: (success,data) = token.call(abi.encodeCall(IERC20.transfer,(to,amount))) (src/ShiftRewards.sol#500)\n[low/medium] calls-loop at src/ShiftRewards.sol:494: ShiftRewards._trySend(address,address,uint256) (src/ShiftRewards.sol#494-502) has external calls inside a loop: (success,data) = token.call(abi.encodeCall(IERC20.transfer,(to,amount))) (src/ShiftRewards.sol#500)\n[low/medium] calls-loop at src/ShiftRewards.sol:494: ShiftRewards._trySend(address,address,uint256) (src/ShiftRewards.sol#494-502) has external calls inside a loop: (ok,None) = to.call{value: amount}() (src/ShiftRewards.sol#496)\n[low/medium] reentrancy-events at src/NightshiftHook.sol:228: Reentrancy in NightshiftHook.redeemFeeClaims() (src/NightshiftHook.sol#228-233):\n[low/medium] reentrancy-events at src/NightshiftHook.sol:165: Reentrancy in NightshiftHook.afterSwap(address,PoolKey,SwapParams,BalanceDelta,bytes) (src/NightshiftHook.sol#165-200):\n[low/medium] reentrancy-events at src/NightshiftHook.sol:165: Reentrancy in NightshiftHook.afterSwap(address,PoolKey,SwapParams,BalanceDelta,bytes) (src/NightshiftHook.sol#165-200):\n[low/medium] reentrancy-events at src/NightshiftHook.sol:210: Reentrancy in NightshiftHook._collectEth(uint256) (src/NightshiftHook.sol#210-218):\n[low/medium] timestamp at src/ShiftRewards.sol:256: ShiftRewards._lockedOf(address) (src/ShiftRewards.sol#256-262) uses timestamp for comparisons\n[low/medium] timestamp at src/ShiftRewards.sol:300: ShiftRewards._rpt(ShiftRewards.RewardData) (src/ShiftRewards.sol#300-304) uses timestamp for comparisons\n[low/medium] timestamp at src/ShiftRewards.sol:459: ShiftRewards.claimFrogs(uint256,address,uint256,bytes32[]) (src/ShiftRewards.sol#459-473) uses timestamp for comparisons\n[low/medium] timestamp at src/ShiftRewards.sol:476: ShiftRewards.settleEpoch(uint256,address) (src/ShiftRewards.sol#476-485) uses timestamp for comparisons\n[low/medium] timestamp at src/ShiftRewards.sol:243: ShiftRewards._pruneLocks(address) (src/ShiftRewards.sol#243-254) uses timestamp for comparisons\n[low/medium] timestamp at src/ShiftRewards.sol:296: ShiftRewards._applicable(ShiftRewards.RewardData) (src/ShiftRewards.sol#296-298) uses timestamp for comparisons\n[low/medium] timestamp at src/ShiftRewards.sol:364: ShiftRewards._notifyStakers(ShiftRewards.RewardData,address,uint256,uint256) (src/ShiftRewards.sol#364-379) uses timestamp for comparisons","passed":true},{"durationMs":600,"exitCode":0,"name":"aderyn","output":"[high] reentrancy-state-change at src/FeeSplitter.sol:105: Reentrancy: State change after external call (4 places)\n[high] storage-array-memory-edit at src/FeeSplitter.sol:165: Storage Array Edited with Memory\n[high] tx-origin-used-for-auth at src/NightshiftHook.sol:143: Use of `tx.origin` for authentication\n[high] unsafe-casting at src/ShiftRewards.sol:165: Unsafe Casting of integers\n[low] centralization-risk at src/FeeSplitter.sol:21: Centralization Risk (8 places)\n[low] costly-loop at src/ShiftRewards.sol:195: Costly operations inside loop (3 places)\n[low] internal-function-used-once at src/HookFlags.sol:25: Internal Function Used Only Once\n[low] large-numeric-literal at src/FeeSplitter.sol:31: Large Numeric Literal (2 places)\n[low] non-reentrant-not-first at src/ShiftRewards.sol:354: `nonReentrant` is Not the First Modifier (2 places)\n[low] state-change-without-event at src/NightshiftHook.sol:126: State Change Without Event\n[low] state-no-address-check at src/FeeSplitter.sol:191: Address State Variable Set Without Checks (2 places)\n[low] unchecked-return at src/FeeSplitter.sol:161: Unchecked Return (2 places)\n[low] unsafe-erc20-operation at src/ShiftRewards.sol:500: Unsafe ERC20 Operation\n[low] unused-import at src/NightshiftHook.sol:11: Unused Import\n[low] unused-public-function at src/ShiftRewards.sol:292: Public Function Not Used Internally","passed":true},{"durationMs":2350,"exitCode":0,"name":"proof 6c223b68e963","output":"Compiling 88 files with Solc 0.8.26\nSolc 0.8.26 finished in 1.71s\nCompiler run successful!\n\nRan 2 tests for test/imd-proof-4bbb1457/Proof_6c223b68e963.t.sol:RewardTokenPoisonProof\n[PASS] test_anyoneCanBrickDistributeForImdAndOtherTokens() (gas: 11795246)\n[PASS] test_anyoneCanBrickGetRewardAndExitForStakers() (gas: 1365139)\nSuite result: ok. 2 passed; 0 failed; 0 skipped; finished in 3.23ms (2.87ms CPU time)\n\nRan 1 test suite in 3.94ms (3.23ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)\n","passed":true},{"durationMs":2201,"exitCode":0,"name":"proof 59794a734967","output":"2026-10-18T00:12:50.859024Z ERROR foundry_compilers_artifacts_solc::sources: error=\"/tmp/imd-verify-GGV7LI/repo/test/imd-proof-4bbb1457/Proof_6c223b68e963.t.sol\": No such file or directory (os error 2)\nCompiling 1 files with Solc 0.8.26\nSolc 0.8.26 finished in 1.47s\nCompiler run successful!\n\nRan 1 test for test/imd-proof-9118c119/Proof_59794a734967.t.sol:PartialFillFeeTest\n[PASS] test_partialFillChargesShiftFeeOnUnconsumedInput() (gas: 552361)\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 25.29ms (569.59µs CPU time)\n\nRan 1 test suite in 25.99ms (25.29ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)\n","passed":true},{"durationMs":1799,"exitCode":0,"name":"proof c160dad93b13","output":"2026-10-18T00:12:52.991364Z ERROR foundry_compilers_artifacts_solc::sources: error=\"/tmp/imd-verify-GGV7LI/repo/test/imd-proof-9118c119/Proof_59794a734967.t.sol\": No such file or directory (os error 2)\nCompiling 1 files with Solc 0.8.26\nSolc 0.8.26 finished in 1.18s\nCompiler run successful!\n\nRan 1 test for test/imd-proof-744337cb/Proof_c160dad93b13.t.sol:StreamDurationOverrideProof\n[PASS] test_unprivilegedDistributeCompressesOwnerStreamIntoSevenDays() (gas: 700216)\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.09ms (197.67µs CPU time)\n\nRan 1 test suite in 3.38ms (1.09ms 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":"6587e1c1a4fe80e44e584b8a3b54b159c3f4bd9353b4f9171f794e0bcbbdbb6f","verifiedTreeHash":"06db75c7bfbcff1a728e65986dc7c6d65ca1265a","verifierVersion":"0.1.0+c3fba9eb"},{"checks":[{"durationMs":6074,"exitCode":0,"name":"build","output":"Compiling 91 files with Solc 0.8.26\nSolc 0.8.26 finished in 5.88s\nCompiler run successful!\n","passed":true},{"durationMs":324,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 4 tests for test/ShiftToken.t.sol:ShiftTokenTest\n[PASS] test_hasNoMintOrOwner() (gas: 51960)\n[PASS] test_metadata() (gas: 29133)\n[PASS] test_mintsWholeSupplyToDeployer() (gas: 15181)\n[PASS] test_transferMovesExactAmount() (gas: 78818)\nSuite result: ok. 4 passed; 0 failed; 0 skipped; finished in 1.09ms (687.39µs CPU time)\n\nRan 13 tests for test/FeeSplitter.t.sol:FeeSplitterTest\n[PASS] testFuzz_splitConservesEveryWei(uint256) (runs: 256, μ: 425187, ~: 430881)\n[PASS] test_burnReserveEmitsImdBurned() (gas: 894177)\n[PASS] test_burnReserveEthSwapsToImdAndBurns() (gas: 1154452)\n[PASS] test_burnReserveRequiresKeeperAndRoute() (gas: 437757)\n[PASS] test_burnReserveTokenGoesThroughEth() (gas: 1648949)\n[PASS] test_distributeEth() (gas: 335781)\n[PASS] test_distributeImdBurnsToExecutor() (gas: 409963)\n[PASS] test_distributeOtherTokenReservesBurnShare() (gas: 603113)\n[PASS] test_distributeShiftBurnsToDead() (gas: 386398)\n[PASS] test_fixedAddressesAndSplit() (gas: 48658)\n[PASS] test_onlyOwnerSetsKeeper() (gas: 90000)\n[PASS] test_routeSettersValidate() (gas: 141559)\n[PASS] test_unlockCallbackOnlyManager() (gas: 32562)\nSuite result: ok. 13 passed; 0 failed; 0 skipped; finished in 23.46ms (28.84ms CPU time)\n\nRan 13 tests for test/ShiftRewards.t.sol:ShiftRewardsTest\n[PASS] testFuzz_earnedNeverExceedsStream(uint128,uint128,uint32) (runs: 256, μ: 552018, ~: 552171)\n[PASS] test_ethRewardSplitsHalfToStakersHalfToPot() (gas: 546379)\n[PASS] test_frogsClaimTokenAndEpochCap() (gas: 597057)\n[PASS] test_frogsEpochLifecycle() (gas: 796531)\n[PASS] test_lockedStakeCannotBeWithdrawnEarly() (gas: 718648)\n[PASS] test_ownerCannotTakeStakesOrRewards() (gas: 461790)\n[PASS] test_ownerNotifyRewardAmount() (gas: 590211)\n[PASS] test_rewardTokenListIsBounded() (gas: 12089955)\n[PASS] test_rewardsProRataToStakeAndTime() (gas: 785465)\n[PASS] test_shiftRewardsDoNotMixWithStakes() (gas: 534072)\n[PASS] test_stakeAndWithdrawUnlocked() (gas: 331514)\n[PASS] test_stakeRejectsBadInputs() (gas: 5705944)\n[PASS] test_tokenRewardsThroughNotifyReceived() (gas: 726081)\nSuite result: ok. 13 passed; 0 failed; 0 skipped; finished in 30.47ms (38.09ms CPU time)\n\nRan 22 tests for test/NightshiftHook.t.sol:NightshiftHookTest\n[PASS] testFuzz_sellExactInputFee(uint256) (runs: 256, μ: 365237, ~: 366303)\n[PASS] testFuzz_setFeesNeverAboveCap(uint16,uint16) (runs: 256, μ: 35865, ~: 32807)\n[PASS] test_buysPayNoFeeExactInAndExactOut() (gas: 405311)\n[PASS] test_callbacksRefuseNonManager() (gas: 120894)\n[PASS] test_constructorWiresHelpersAndOwner() (gas: 100576)\n[PASS] test_ethFeeFallsBackToClaimAndIsRedeemable() (gas: 536613)\n[PASS] test_firstBuyHasNoMinimumOrMaximum() (gas: 129101)\n[PASS] test_firstBuyOpensTradingThroughAnyRouter() (gas: 474874)\n[PASS] test_firstBuyWorksOnTokensOnlyPool() (gas: 9995507)\n[PASS] test_firstBuyerSellDoesNotOpenTrading() (gas: 251615)\n[PASS] test_gateBlocksEveryoneBeforeFirstBuy() (gas: 161133)\n[PASS] test_initializeRefusesDynamicFee() (gas: 8362019)\n[PASS] test_initializeRefusesNonFactory() (gas: 8510886)\n[PASS] test_initializeRefusesSecondPool() (gas: 43660)\n[PASS] test_initializeRefusesWrongPair() (gas: 9005685)\n[PASS] test_loweredFeesApply() (gas: 558561)\n[PASS] test_nobodyCanOpenOrCloseTradingOtherwise() (gas: 265059)\n[PASS] test_permissionsMatchAddressFlags() (gas: 8732)\n[PASS] test_sellExactInputTakesBothFeeParts() (gas: 372199)\n[PASS] test_sellExactOutputTakesBothFeeParts() (gas: 353339)\n[PASS] test_setFeesBounds() (gas: 169714)\n[PASS] test_zeroLiquidityCollectPassesThrough() (gas: 598333)\nSuite result: ok. 22 passed; 0 failed; 0 skipped; finished in 103.61ms (142.22ms CPU time)\n\nRan 4 test suites in 104.83ms (158.63ms CPU time): 52 tests passed, 0 failed, 0 skipped (52 total tests)\n","passed":true},{"durationMs":130,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"FeeSplitter.burnReserve(address,uint256,uint256)\",\"FeeSplitter.distribute(address)\",\"FeeSplitter.receive()\",\"FeeSplitter.renounceOwnership()\",\"FeeSplitter.setEthPool(address,(address,address,uint24,int24,address))\",\"FeeSplitter.setImdPool((address,address,uint24,int24,address))\",\"FeeSplitter.setKeeper(address)\",\"FeeSplitter.transferOwnership(address)\",\"FeeSplitter.unlockCallback(bytes)\",\"NightshiftHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"NightshiftHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"NightshiftHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"NightshiftHook.redeemFeeClaims()\",\"NightshiftHook.renounceOwnership()\",\"NightshiftHook.setFees(uint16,uint16)\",\"NightshiftHook.transferOwnership(address)\",\"NightshiftHook.unlockCallback(bytes)\",\"ShiftRewards.claimFrogs(uint256,address,uint256,bytes32[])\",\"ShiftRewards.exit()\",\"ShiftRewards.getReward()\",\"ShiftRewards.getReward(address)\",\"ShiftRewards.notifyReceived(address)\",\"ShiftRewards.notifyRewardAmount(address,uint256,uint256)\",\"ShiftRewards.postRoot(uint256,bytes32,address,uint256)\",\"ShiftRewards.receive()\",\"ShiftRewards.renounceOwnership()\",\"ShiftRewards.setDistributor(address)\",\"ShiftRewards.settleEpoch(uint256,address)\",\"ShiftRewards.stake(uint256,uint256)\",\"ShiftRewards.transferOwnership(address)\",\"ShiftRewards.withdraw(uint256)\",\"ShiftToken.approve(address,uint256)\",\"ShiftToken.transfer(address,uint256)\",\"ShiftToken.transferFrom(address,address,uint256)\"],\"files\":{\".gitignore\":19,\"README.md\":210,\"foundry.toml\":17,\"remappings.txt\":4,\"src/FeeSplitter.sol\":225,\"src/HookFlags.sol\":33,\"src/NightshiftHook.sol\":261,\"src/ShiftRewards.sol\":433,\"src/ShiftToken.sol\":15,\"test/FeeSplitter.t.sol\":271,\"test/NightshiftHook.t.sol\":421,\"test/ShiftRewards.t.sol\":368,\"test/ShiftToken.t.sol\":40,\"test/floor/OwnerChecks.sol\":24,\"test/mocks/MockERC20.sol\":15,\"test/mocks/Swapper.sol\":100,\"test/utils/HookMiner.sol\":22},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true},{"durationMs":4032,"exitCode":0,"name":"slither","output":"[high/medium] arbitrary-send-eth at src/ShiftRewards.sol:425: ShiftRewards._send(address,address,uint256) (src/ShiftRewards.sol#425-432) sends eth to arbitrary user\n[high/medium] reentrancy-eth at src/FeeSplitter.sol:88: Reentrancy in FeeSplitter.distribute(address) (src/FeeSplitter.sol#88-117):\n[high/high] uninitialized-state at src/ShiftRewards.sol:142: ShiftRewards._locks (src/ShiftRewards.sol#73) is never initialized. It is used in:\n[medium/high] incorrect-equality at src/FeeSplitter.sol:220: FeeSplitter._sendEth(address,uint256) (src/FeeSplitter.sol#220-224) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/ShiftRewards.sol:333: ShiftRewards.notifyReceived(address) (src/ShiftRewards.sol#333-339) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/FeeSplitter.sol:88: FeeSplitter.distribute(address) (src/FeeSplitter.sol#88-117) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/ShiftRewards.sol:351: ShiftRewards.notifyRewardAmount(address,uint256,uint256) (src/ShiftRewards.sol#351-370) uses a dangerous strict equality:\n[medium/medium] reentrancy-no-eth at src/FeeSplitter.sol:88: Reentrancy in FeeSplitter.distribute(address) (src/FeeSplitter.sol#88-117):\n[medium/medium] tx-origin at src/NightshiftHook.sol:137: NightshiftHook.beforeSwap(address,PoolKey,SwapParams,bytes) (src/NightshiftHook.sol#137-162) uses tx.origin for authorization: ! tradingOpen && tx.origin != FIRST_BUYER (src/NightshiftHook.sol#142)\n[medium/medium] unused-return at src/NightshiftHook.sol:221: NightshiftHook.redeemFeeClaims() (src/NightshiftHook.sol#221-226) ignores return value by poolManager.unlock(abi.encode(amount)) (src/NightshiftHook.sol#224)\n[medium/medium] unused-return at src/FeeSplitter.sol:147: FeeSplitter.unlockCallback(bytes) (src/FeeSplitter.sol#147-173) ignores return value by poolManager.settle() (src/FeeSplitter.sol#161)\n[medium/medium] unused-return at src/FeeSplitter.sol:147: FeeSplitter.unlockCallback(bytes) (src/FeeSplitter.sol#147-173) ignores return value by poolManager.settle{value: ethIn}() (src/FeeSplitter.sol#169)\n[low/medium] missing-zero-check at src/FeeSplitter.sol:190: FeeSplitter.setKeeper(address).keeper_ (src/FeeSplitter.sol#190) lacks a zero-check on :\n[low/medium] missing-zero-check at src/FeeSplitter.sol:77: FeeSplitter.constructor(IPoolManager,address,address,address).shift_ (src/FeeSplitter.sol#77) lacks a zero-check on :\n[low/medium] missing-zero-check at src/ShiftRewards.sol:133: ShiftRewards.setDistributor(address).distributor_ (src/ShiftRewards.sol#133) lacks a zero-check on :\n[low/medium] missing-zero-check at src/NightshiftHook.sol:89: NightshiftHook.constructor(IPoolManager,address,address,address).factory_ (src/NightshiftHook.sol#89) lacks a zero-check on :\n[low/medium] missing-zero-check at src/NightshiftHook.sol:89: NightshiftHook.constructor(IPoolManager,address,address,address).token_ (src/NightshiftHook.sol#89) lacks a zero-check on :\n[low/medium] calls-loop at src/ShiftRewards.sol:425: ShiftRewards._send(address,address,uint256) (src/ShiftRewards.sol#425-432) has external calls inside a loop: (ok,None) = to.call{value: amount}() (src/ShiftRewards.sol#427)\n[low/medium] calls-loop at src/ShiftRewards.sol:425: ShiftRewards._send(address,address,uint256) (src/ShiftRewards.sol#425-432) has external calls inside a loop: (ok,None) = to.call{value: amount}() (src/ShiftRewards.sol#427)\n[low/medium] reentrancy-events at src/NightshiftHook.sol:221: Reentrancy in NightshiftHook.redeemFeeClaims() (src/NightshiftHook.sol#221-226):\n[low/medium] reentrancy-events at src/NightshiftHook.sol:164: Reentrancy in NightshiftHook.afterSwap(address,PoolKey,SwapParams,BalanceDelta,bytes) (src/NightshiftHook.sol#164-193):\n[low/medium] reentrancy-events at src/NightshiftHook.sol:203: Reentrancy in NightshiftHook._collectEth(uint256) (src/NightshiftHook.sol#203-211):\n[low/medium] reentrancy-events at src/NightshiftHook.sol:164: Reentrancy in NightshiftHook.afterSwap(address,PoolKey,SwapParams,BalanceDelta,bytes) (src/NightshiftHook.sol#164-193):\n[low/medium] timestamp at src/ShiftRewards.sol:228: ShiftRewards._lockedOf(address) (src/ShiftRewards.sol#228-234) uses timestamp for comparisons\n[low/medium] timestamp at src/ShiftRewards.sol:303: ShiftRewards._notifyStakers(address,uint256,uint256) (src/ShiftRewards.sol#303-321) uses timestamp for comparisons\n[low/medium] timestamp at src/ShiftRewards.sol:215: ShiftRewards._pruneLocks(address) (src/ShiftRewards.sol#215-226) uses timestamp for comparisons\n[low/medium] timestamp at src/ShiftRewards.sol:391: ShiftRewards.claimFrogs(uint256,address,uint256,bytes32[]) (src/ShiftRewards.sol#391-405) uses timestamp for comparisons\n[low/medium] timestamp at src/ShiftRewards.sol:408: ShiftRewards.settleEpoch(uint256,address) (src/ShiftRewards.sol#408-417) uses timestamp for comparisons\n[low/medium] timestamp at src/ShiftRewards.sol:263: ShiftRewards.lastTimeRewardApplicable(address) (src/ShiftRewards.sol#263-266) uses timestamp for comparisons","passed":true},{"durationMs":1200,"exitCode":0,"name":"aderyn","output":"[high] reentrancy-state-change at src/FeeSplitter.sol:105: Reentrancy: State change after external call (4 places)\n[high] storage-array-memory-edit at src/FeeSplitter.sol:165: Storage Array Edited with Memory\n[high] tx-origin-used-for-auth at src/NightshiftHook.sol:142: Use of `tx.origin` for authentication\n[high] unsafe-casting at src/ShiftRewards.sol:153: Unsafe Casting of integers\n[low] centralization-risk at src/FeeSplitter.sol:21: Centralization Risk (7 places)\n[low] costly-loop at src/ShiftRewards.sol:172: Costly operations inside loop (4 places)\n[low] internal-function-used-once at src/HookFlags.sol:25: Internal Function Used Only Once\n[low] large-numeric-literal at src/FeeSplitter.sol:31: Large Numeric Literal (2 places)\n[low] non-reentrant-not-first at src/ShiftRewards.sol:355: `nonReentrant` is Not the First Modifier\n[low] require-revert-in-loop at src/ShiftRewards.sol:172: Loop Contains `require`/`revert` (2 places)\n[low] state-change-without-event at src/NightshiftHook.sol:125: State Change Without Event\n[low] state-no-address-check at src/FeeSplitter.sol:191: Address State Variable Set Without Checks (2 places)\n[low] unchecked-return at src/FeeSplitter.sol:161: Unchecked Return (2 places)\n[low] unused-import at src/NightshiftHook.sol:11: Unused Import","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"ee236c9e4b2524bc44f98836a5385a9c8d59ff4e64e37a9e77b1e86778b3fd2a","verifiedTreeHash":"3bd896526342ce6f3abda0e6ce04dc500395e99a","verifierVersion":"0.1.0+c3fba9eb"}]}