{"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":"640b4951-f512-4415-ab2e-944462a2f5ba","kind":"shape:chain","nodes":[{"acceptedSubmissionHash":"3787550ed2882d55e6566e5265cb6f37ed52d50352007f7b93e18876c4cbe08b","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":"4834b35cd581a1560d1089740af0d41192e42659b19294a8b7b5469318c34fab","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":"5b7b082554fa668f4547107cfe60dab139e081e88a38cfeb65cdb8dadefaba4a","dependsOn":["build_contract_project","write_foundry_tests","manifest","audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"b90bc686aea0cdd395e1970fec9ad6038a204871a27bc9c1cbd865b220208a57","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":"67e6e03ac81e99d4007675cc01ffdbea0f6e9cd313f08882f350ecb613e5bef3","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":"116af9515043a5514dd6b5e89bf22b1daff56bf88195534e368c50dc7db3dcc9","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"5b568a5d9dcb87d3a575906fd2a57a765e2afbdf6ff0870ca20158eaa69999c7","skillId":"build-contract-project","tools":[]},"key":"build_contract_project","kind":"code","role":"implement","skillHash":"5b568a5d9dcb87d3a575906fd2a57a765e2afbdf6ff0870ca20158eaa69999c7","skillId":"build-contract-project","state":"accepted"},{"acceptedSubmissionHash":"488a013b8bdcbbb008cff80a9b85dbd66e63e511b055f0ca93bb777cade3e3f9","dependsOn":["build_contract_project"],"execution":{"network":false,"profile":"foundry","requires":[],"tools":[]},"key":"manifest","kind":"code","role":"integrate","skillHash":null,"skillId":null,"state":"accepted"},{"acceptedSubmissionHash":"4f5984a4a24a3192ef48d207ddf9b1ee17ca56b8f87aa2b7e5c48e1ae22cd064","dependsOn":["build_contract_project"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"73431852439ad3a343f3d2b7db1c43cd363b9f48f9bb51497374a0cf6d50b223","skillId":"write-foundry-tests","tools":[]},"key":"write_foundry_tests","kind":"code","role":"tests","skillHash":"73431852439ad3a343f3d2b7db1c43cd363b9f48f9bb51497374a0cf6d50b223","skillId":"write-foundry-tests","state":"accepted"}],"objective":"Build imd/acc on Sepolia (test run; contracts only, no token or pool): 0.5% trading cashback paid to traders as staked IMD (sIMD), funded from a project's existing fees. Owner: 0x4b91078b2374c956A65F7Af0999CaE0a935E6821\nDEPLOY (Sepolia, in this order):\n1. TestIMD: ERC20 \"Test IMD\"/tIMD, 18 dec, no premint. faucet() mints 10,000 tIMD to the caller, once per 24h per address.\n2. TestSIMD: a fork of POOL4 StakedIMD 0x9efa934d9fad4ae28c998a40195646b965a97247 (Ethereum). Asset TestIMD, owner $owner, name \"Test Staked IMD\"/tsIMD, logic unchanged; the owner keeps pause so integrators can test a failing vault.\n3. Stacker(TestIMD, TestSIMD): immutable; constructor requires sIMD.asset()==IMD, approves the vault once. No owner, admin or fee.\n\nSTACKER:\n- credit(trader, imdAmount) returns shares: pull imdAmount from msg.sender, call sIMD.deposit(imdAmount, trader), so the shares go straight to the trader. project = msg.sender. Trader 0 reverts; amount 0 returns 0, no event.\n- On-chain totals (IMD): traderStacked, projectStacked, stackedBy[project][trader], totalStacked; plus shares per trader and project.\n- event Stacked(address indexed project, address indexed trader, uint256 imd, uint256 shares).\n- IMD path only; the ETH batch route is v2.\n\nPAGE (static, IPFS label imd-acc-test, public RPCs):\n- Your stack: IMD stacked, points, sIMD held and its IMD value now, split per project.\n- Listed projects: a projects.json in the site ({address, name, fromBlock}; starts empty, PLEA's hook added later by a site update). Points and leaderboards count only Stacked events from listed projects at or after their fromBlock; others show as \"direct, no points\".\n- Leaderboards: top stackers and top listed projects by IMD stacked, from chunked Stacked logs.\n- Test tools: faucet, and \"stack to myself\" (approve + credit(self, x)), shown as direct.\n- The three addresses with explorer links.\n\nTESTS (Foundry): shares go only to the trader; totals and event match; many projects and traders; credit reverts cleanly when the vault is paused or allowance/balance is short (integrators use try/catch); faucet limit; fuzz; invariant: Stacker holds 0 IMD and 0 sIMD.\n\nDELIVER: the three addresses in the README; the PLEA job reuses them.","parentJobId":null,"planHash":"ca76470b8ee4b6cc41ac0f8d7cd6762dc14e240768a2930dfa66d786809ccf3f","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"640b4951-f512-4415-ab2e-944462a2f5ba","publication":{"commit":null,"deliveredAt":null,"repoUrl":"https://github.com/identity-md-launches/launch-1129-build-imd-acc-sepolia-test"},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51166","feedbackHash":"5cf1213a3046e624cd1cc5c7a89aab11a63344341e4958b92f442705d24d2405","nodeKey":"audit_economics","submissionHash":"3787550ed2882d55e6566e5265cb6f37ed52d50352007f7b93e18876c4cbe08b","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51872","feedbackHash":"0bba04e5daf4a1dd07073b9a3bf0193b486c10aa04f6ff9f393b84b1b213448f","nodeKey":"audit_flow","submissionHash":"4834b35cd581a1560d1089740af0d41192e42659b19294a8b7b5469318c34fab","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52214","feedbackHash":"0539b5278623a5fd34924421ed2705d9dbe2ec0c39f2d5ee3f02b1e3fb821282","nodeKey":"audit_judge","submissionHash":"5b7b082554fa668f4547107cfe60dab139e081e88a38cfeb65cdb8dadefaba4a","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51041","feedbackHash":"9d3cf68f49e0312293ad3610bfebedcd0c0c1dc3ec74a4008346b371282180e7","nodeKey":"audit_math","submissionHash":"b90bc686aea0cdd395e1970fec9ad6038a204871a27bc9c1cbd865b220208a57","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51423","feedbackHash":"754bd908ae54be7092d7f53c4fb16dedd376625fb8eb2cddfba8b6c33374f193","nodeKey":"audit_permissions","submissionHash":"67e6e03ac81e99d4007675cc01ffdbea0f6e9cd313f08882f350ecb613e5bef3","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51474","feedbackHash":"129565dc78daf52af6e0d596de5173a8c7c135b3402e59760e0bbd6ac831a53d","nodeKey":"build_contract_project","submissionHash":"116af9515043a5514dd6b5e89bf22b1daff56bf88195534e368c50dc7db3dcc9","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"52251","feedbackHash":"b4032fe6f38b6895b4903c73234c14ca2d6beb29031a22bbfcfa2b42892316f6","nodeKey":"manifest","submissionHash":"488a013b8bdcbbb008cff80a9b85dbd66e63e511b055f0ca93bb777cade3e3f9","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"51881","feedbackHash":"f663846702e4127ac03123e93eae06a58356638bce6f17fda9e45bf294622451","nodeKey":"write_foundry_tests","submissionHash":"4f5984a4a24a3192ef48d207ddf9b1ee17ca56b8f87aa2b7e5c48e1ae22cd064","tag1":"verification:checks","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"1301db73ce51fc58f4461b4573ded66eecf216b7b729231c5c52e20457146264","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":"ff9ecaf5232fae51c7dc11ceeae2e468d99d66e47400a23011100423b79808f8","device":"0e3b71e2ffcd200b","findings":[],"hash":"116af9515043a5514dd6b5e89bf22b1daff56bf88195534e368c50dc7db3dcc9","nodeId":"3aa47ee3-9ced-4a3d-acb1-652163ec13fb","outcome":"completed","summary":"The work is complete and committed on `main` as `6da5643`. Build, all 34 tests, and `forge fmt --check` pass with the pinned solc 0.8.26.\n\n**What was built**\n\n- `src/TestIMD.sol`: OpenZeppelin ERC20 \"Test IMD\" / tIMD, 18 decimals, no premint, no owner. `faucet()` mints 10,000 tIMD once per 24h per address and reverts with `FaucetCooldown(nextAt)` otherwise.\n- `src/TestSIMD.sol`: fork of the mainnet POOL4 StakedIMD. I pulled the source from its Sourcify exact match and vendored the five solady files it compiles against. Only name and symbol changed; the owner's pause and rescue powers stay as requested.\n- `src/Stacker.sol`: immutable, ownerless, no fee. The constructor requires `sIMD.asset() == IMD` and approves the vault once. `credit` pulls IMD from the caller, deposits with the trader as share recipient, updates all six totals, and emits `Stacked`. Zero trader reverts, zero amount returns 0 with no event, and all vault and token errors bubble up unchanged for try/catch integrators.\n- `site/`: dependency-free static page using public Sepolia RPCs. It shows your stack split per project, points, tsIMD held and its IMD value, leaderboards from chunked `Stacked` logs, an empty `projects.json`, faucet and \"stack to myself\" tools, and explorer links. Addresses live in `site/config.json`.\n- `README.md`: deployment order and constructor arguments, owner duties, after-launch checklist, assumptions, test coverage, and the build-settings rationale.\n\n**One thing worth knowing**\n\nThe launch probe scans runtime bytecode linearly for forbidden opcodes. At 200 optimizer runs solc places the solady Transfer event hash in a data section, and one of its bytes reads as `CALLCODE` to that scanner. The mainnet vault bytecode has the same artefact. I set `optimizer_runs = 10_000`, which inlines the constant, and `test/Deployment.t.sol` reproduces the probe's size and opcode checks on every run so this cannot regress silently.\n\n**Not done, and why**\n\n- The three Sepolia addresses in the README are marked as pending the launch handoff. I cannot deploy and must not invent addresses. After the handoff, paste them into the README table and `site/config.json`, then pin `site/` to IPFS under `imd-acc-test`.\n- Slither and Mythril were not available here and did not run. Tests passing are not an audit; the mainnet IMD path needs an independent adversarial review.","treeHash":"86d98f69c318a3f25b0eb5a73a589ae52cba7c74","usage":{"cachedInputTokens":1681034,"inputTokens":674,"model":"claude-fable-5-1","outputTokens":69280,"runtime":"claude","turns":45,"wallClockMs":948877}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"45aa937328087de3","findings":[{"citation":"resolved","description":"Invariant guide, 'reset timers via secondary call paths': `_beforeTokenTransfer` unconditionally stamps `lastDepositBlock[to] = block.number` on every positive mint and carries a newer stamp on every positive transfer, and `_withdraw` (line 109: `if (block.number == lastDepositBlock[owner]) revert SameBlockRedeem();`) plus `maxWithdraw`/`maxRedeem` (lines 141, 146) treat the whole balance as held. The stamp is keyed on the recipient, not on the shares received, so a stranger can reset a victim's hold with a mint of 1 wei of IMD. The contract's own comment at lines 113-118 states the guarantee this breaks: 'a third party must not be able to stamp a hold on an account for free (a deposit(0, victim) / dust-transfer withdrawal grief)'. Only the zero-amount case is excluded; a 1-wei deposit, a 1-share mint, or a 1-share transfer still stamps the victim. Four unprivileged routes exist, two of them through the Stacker: (a) `Stacker.credit(victim, 1)` from any address (also pollutes `traderStacked[victim]`, `stackedBy[griefer][victim]` and emits a `Stacked` event the page displays); (b) `TestSIMD.deposit(1, victim)`; (c) `TestSIMD.mint(1, victim)` (previewMint rounds up to 1 wei); (d) `TestSIMD.transfer(victim, 1)` from an address stamped this block. Each costs the griefer 1 wei of IMD (free on Sepolia via `faucet()`) plus gas, and must be repeated each block, so it is a temporary, gas-priced denial of withdrawal rather than a loss of funds. A second consequence for the Stacker flow itself: every cashback credit re-stamps the trader, so a trader whose trade on a listed project is in block N cannot redeem any sIMD in block N (e.g. a bundle that closes a position and redeems). Economic cost to a mainnet griefer with the identical production logic: ~100k gas per block; the victim's capital stays locked for as long as the griefer pays. The existing suite never deposits or transfers to a third party before that party redeems, and the invariant handler clears the hold itself (`vm.roll` inside `redeem`), so this path is untested. Fix (changes vault logic, so it is a scope decision against 'logic unchanged'): hold only the shares received this block (track `heldShares[to]` with the block they arrived in, and in `_withdraw` require `shares <= balanceOf(owner) - heldSharesThisBlock(owner)`), which keeps the anti-JIT property for the freshly minted shares and lets previously held shares exit. The supplied proof passes under that fix and under any fix that stops third-party dust from locking shares the victim already held.","line":122,"path":"src/TestSIMD.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 {TestIMD} from \"src/TestIMD.sol\";\nimport {TestSIMD} from \"src/TestSIMD.sol\";\nimport {Stacker, IStakedIMD} from \"src/Stacker.sol\";\n\n/// @notice A third party can re-stamp any holder's one-block hold for 1 wei of IMD, so a victim's\n/// redemption of shares she has held for many blocks reverts in every block the griefer touches.\n/// Routes: `Stacker.credit(victim, 1)`, `TestSIMD.deposit(1, victim)`, `TestSIMD.transfer(victim, 1)`.\n/// Fails on the current code (the victim's redeem of her OLD shares reverts with RedeemMoreThanMax);\n/// passes once a third-party dust mint/transfer no longer blocks shares the victim already held.\ncontract HoldGriefTest is Test {\n    address internal constant OWNER = 0x4b91078b2374c956A65F7Af0999CaE0a935E6821;\n\n    TestIMD internal imd;\n    TestSIMD internal sImd;\n    Stacker internal stacker;\n\n    address internal victim = makeAddr(\"victim\");\n    address internal griefer = makeAddr(\"griefer\");\n\n    function setUp() public {\n        vm.roll(1_000);\n        vm.warp(1_700_000_000);\n        imd = new TestIMD();\n        sImd = new TestSIMD(address(imd), OWNER);\n        stacker = new Stacker(IERC20(address(imd)), IStakedIMD(address(sImd)));\n\n        // Victim stakes 100 IMD and holds it for 10 blocks.\n        vm.startPrank(victim);\n        imd.faucet();\n        imd.approve(address(sImd), type(uint256).max);\n        vm.stopPrank();\n\n        // Griefer has 10,000 IMD from the faucet and approves both funnels.\n        vm.startPrank(griefer);\n        imd.faucet();\n        imd.approve(address(sImd), type(uint256).max);\n        imd.approve(address(stacker), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function _victimStakesAndWaits() internal returns (uint256 oldShares) {\n        vm.prank(victim);\n        oldShares = sImd.deposit(100e18, victim);\n        vm.roll(block.number + 10);\n        assertEq(sImd.maxRedeem(victim), oldShares, \"hold expired after 10 blocks\");\n    }\n\n    function test_creditOneWeiBlocksVictimRedeem() public {\n        uint256 oldShares = _victimStakesAndWaits();\n\n        // Griefer front-runs the victim's redeem in the same block with credit(victim, 1 wei).\n        vm.prank(griefer);\n        uint256 dust = stacker.credit(victim, 1);\n        assertGt(dust, 0);\n\n        // The victim's shares that she has held for 10 blocks must still be redeemable.\n        vm.prank(victim);\n        uint256 assets = sImd.redeem(oldShares, victim, victim);\n        assertGt(assets, 0);\n    }\n\n    function test_depositOneWeiBlocksVictimRedeem() public {\n        uint256 oldShares = _victimStakesAndWaits();\n\n        vm.prank(griefer);\n        sImd.deposit(1, victim);\n\n        vm.prank(victim);\n        uint256 assets = sImd.redeem(oldShares, victim, victim);\n        assertGt(assets, 0);\n    }\n\n    function test_transferOneShareBlocksVictimRedeem() public {\n        uint256 oldShares = _victimStakesAndWaits();\n\n        // Griefer mints shares now (stamped this block) and sends 1 share to the victim.\n        vm.startPrank(griefer);\n        sImd.deposit(1, griefer);\n        sImd.transfer(victim, 1);\n        vm.stopPrank();\n\n        vm.prank(victim);\n        uint256 assets = sImd.redeem(oldShares, victim, victim);\n        assertGt(assets, 0);\n    }\n}","reproduction":"State: TestIMD, TestSIMD(owner), Stacker deployed; victim and griefer each call faucet(). 1) victim: approve vault, `deposit(100e18, victim)` at block 1000 -> 1e26 shares, lastDepositBlock[victim]=1000. 2) roll to block 1010; `maxRedeem(victim)` == 1e26 (hold expired). 3) griefer (approved Stacker): `Stacker.credit(victim, 1)` -> mints ~1e6 shares to victim and sets lastDepositBlock[victim]=1010. 4) victim, same block: `redeem(1e26, victim, victim)`. Expected: the 1e26 shares held for 10 blocks are redeemable (the hold is meant to stop the griefer-minted shares, not the victim's). Actual: `maxRedeem(victim)` == 0 and the call reverts with `RedeemMoreThanMax()`. Same result with `TestSIMD.deposit(1, victim)`, `TestSIMD.mint(1, victim)` or `deposit(1, griefer)` then `transfer(victim, 1)`. Repeating step 3 at the top of every block keeps the victim locked indefinitely for ~100k gas per block.","severity":"medium","snippet":"            lastDepositBlock[to] = block.number; // mint (deposit)","title":"Any third party re-stamps a holder's one-block hold for 1 wei (credit/deposit/mint/transfer), blocking that holder's redeem in every block the griefer touches"},{"citation":"resolved","description":"Invariant guide, 'exploit emergency transitions' / Economic guide, 'legitimate features turned against the protocol'. `rescueERC20(asset, to, amount)` moves the vault's IMD out without pausing, without touching `totalSupply`, and without any way to restore the previous share price. With `totalAssets() == 0` and `totalSupply() == S`, `convertToShares(a) = a * (S + 1e6) / 1`, so a deposit of 1 wei mints S + 1e6 shares: the depositor instantly owns ~50% of the vault. The README's checklist (step 5) tells the owner to use rescue 'only for stuck-funds emergencies' and the NatSpec calls it a 'move funds to safety in a worst case' hatch; neither requires pausing first, and `setPaused` is a separate call, so the window between rescue and refund is open to the mempool. The unprivileged amplifier is a race: anyone who sees the rescue (or the refund) in the mempool deposits 1 wei directly or via `Stacker.credit(self, 1)` in between; an ordinary cashback `credit` from a listed project landing in that window has the same effect for an innocent trader. When the owner transfers the rescued IMD back, half of it belongs to the 1-wei depositor and every existing staker loses half their value. Concrete numbers from the proof: 1,000 IMD staked (S = 1e27 shares); rescue 1,000 IMD; attacker credits 1 wei and receives 1e27 + 1e6 shares; owner returns 1,000 IMD; `maxWithdraw(attacker)` = 500.000000000000000001 IMD, `maxWithdraw(staker)` = 500 IMD. Cost to the attacker: 1 wei + gas. Severity: medium because it needs the owner to use the documented emergency hatch (privileged, demoted from high), but the loss is half of all staked IMD and the trigger is unprivileged. Fix options (scope decision, both diverge from the mainnet logic or are procedural): (1) make an asset rescue require `paused` (or set `paused = true` inside `rescueERC20` when `token == asset()`), so no deposit can land before the refund; (2) if the logic must stay byte-identical, document in the README and the owner checklist that the asset must only be rescued and refunded while paused. The supplied proof passes under option 1 and remains failing under option 2, which is accurate: the code still allows the race.","line":161,"path":"src/TestSIMD.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 {TestIMD} from \"src/TestIMD.sol\";\nimport {TestSIMD} from \"src/TestSIMD.sol\";\nimport {Stacker, IStakedIMD} from \"src/Stacker.sol\";\n\n/// @notice Rescue-then-refund race. The owner sweeps the vault's IMD with `rescueERC20` (the documented\n/// \"move funds to safety\" hatch) while the vault is open; totalAssets drops to 0 while totalSupply stays.\n/// Any unprivileged 1 wei deposit now mints (totalSupply + 1e6) shares, i.e. ~50% of the vault. When the\n/// owner returns the IMD, the 1 wei depositor owns ~half of it.\n/// Fails on the current code; passes once an asset rescue cannot be followed by an open-vault deposit\n/// (rescue of the asset requires or enforces `paused`), or once a refund restores the old share price.\ncontract RescueRaceTest is Test {\n    address internal constant OWNER = 0x4b91078b2374c956A65F7Af0999CaE0a935E6821;\n\n    TestIMD internal imd;\n    TestSIMD internal sImd;\n    Stacker internal stacker;\n\n    address internal staker = makeAddr(\"staker\");\n    address internal attacker = makeAddr(\"attacker\");\n\n    function setUp() public {\n        vm.roll(1_000);\n        vm.warp(1_700_000_000);\n        imd = new TestIMD();\n        sImd = new TestSIMD(address(imd), OWNER);\n        stacker = new Stacker(IERC20(address(imd)), IStakedIMD(address(sImd)));\n        vm.startPrank(staker);\n        imd.faucet();\n        imd.approve(address(sImd), type(uint256).max);\n        vm.stopPrank();\n        vm.startPrank(attacker);\n        imd.faucet();\n        imd.approve(address(stacker), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function test_oneWeiDepositAfterOpenRescueCapturesHalfOfRefund() public {\n        vm.prank(staker);\n        sImd.deposit(1_000e18, staker);\n        vm.roll(block.number + 1);\n\n        // Owner follows the README hatch: rescue the IMD. Nothing in the contract or the checklist\n        // requires pausing first. If a fix makes an open-vault asset rescue revert, pause and retry.\n        vm.prank(OWNER);\n        try sImd.rescueERC20(address(imd), OWNER, 1_000e18) {}\n        catch {\n            vm.startPrank(OWNER);\n            sImd.setPaused(true);\n            sImd.rescueERC20(address(imd), OWNER, 1_000e18);\n            vm.stopPrank();\n        }\n        assertEq(sImd.totalAssets(), 0, \"vault emptied by the rescue\");\n\n        // Unprivileged race: 1 wei through the Stacker (a direct `deposit(1, attacker)` is the same).\n        vm.prank(attacker);\n        try stacker.credit(attacker, 1) {} catch {}\n\n        // Owner returns the rescued IMD, then reopens the vault if it was paused.\n        vm.prank(OWNER);\n        imd.transfer(address(sImd), 1_000e18);\n        if (sImd.paused()) {\n            vm.prank(OWNER);\n            sImd.setPaused(false);\n        }\n        vm.roll(block.number + 1);\n\n        uint256 attackerValue = sImd.convertToAssets(sImd.balanceOf(attacker));\n        // Current code: attackerValue == 500_000000000000000001 wei (half the vault) for 1 wei in.\n        assertLt(attackerValue, 1e12, \"a 1 wei deposit must not capture the refunded IMD\");\n    }\n}","reproduction":"State: staker has deposited 1_000e18 IMD (1e27 shares), vault open, one block later. 1) OWNER: `rescueERC20(address(imd), OWNER, 1_000e18)` -> totalAssets()=0, totalSupply()=1e27. 2) attacker (any address with 1 wei of tIMD): `Stacker.credit(attacker, 1)` (or `TestSIMD.deposit(1, attacker)`) -> shares = 1 * (1e27 + 1e6) / 1 = 1_000_000_000_000_000_000_001_000_000. 3) OWNER: `imd.transfer(address(sImd), 1_000e18)` to put the funds back. 4) next block: `maxWithdraw(attacker)` = 500_000_000_000_000_000_001 wei (500 IMD) and `maxWithdraw(staker)` = 500e18. Expected: the staker can still withdraw ~1,000 IMD after the owner returns the funds, and 1 wei of deposit is worth ~1 wei. Actual: 1 wei buys half the vault; the staker loses 500 IMD. The vault's `paused` flag is never set during the sequence, so nothing in the contract prevents step 2.","severity":"medium","snippet":"        token.safeTransfer(to, amount);","title":"Asset rescue on an open vault lets the next 1-wei depositor capture ~50% of the IMD the owner returns"},{"citation":"resolved","description":"Invariant guide, 'abuse boundaries / sentinel addresses'. `credit` only rejects `trader == address(0)`. With `trader == address(this)` the vault mints the shares to the Stacker, which has no owner, no sweep and no function that transfers or redeems sIMD, so the shares (and the IMD behind them) are unrecoverable, and the README's invariant 'holds no IMD and no sIMD between calls' (also asserted by `invariant_stackerHoldsNothing`) is false from then on. The IMD is the caller's own, so the loss is self-inflicted (an integrator passing the wrong address, or the 'stack to myself' page tool with the Stacker's address pasted in), which keeps this at low; but it is permanent and the guard costs one comparison. `trader == address(SIMD)` similarly mints to the vault itself; those shares are recoverable only by the owner via `rescueERC20(address(sImd), ...)`. Neither case is tested. Fix: `if (trader == address(this) || trader == address(SIMD)) revert ZeroTrader();` (or a dedicated `InvalidTrader()` error).","line":83,"path":"src/Stacker.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 {TestIMD} from \"src/TestIMD.sol\";\nimport {TestSIMD} from \"src/TestSIMD.sol\";\nimport {Stacker, IStakedIMD} from \"src/Stacker.sol\";\n\n/// @notice `credit(address(stacker), x)` mints the tsIMD shares to the Stacker itself. The Stacker has\n/// no function that can move or redeem shares, so they are stuck forever and the documented\n/// invariant \"the Stacker holds 0 sIMD\" is broken by one unprivileged call.\n/// Fails on the current code (the call succeeds and the Stacker's share balance is non-zero);\n/// passes once `credit` rejects the Stacker itself as `trader`.\ncontract StackerSelfTraderTest is Test {\n    address internal constant OWNER = 0x4b91078b2374c956A65F7Af0999CaE0a935E6821;\n\n    TestIMD internal imd;\n    TestSIMD internal sImd;\n    Stacker internal stacker;\n\n    address internal project = makeAddr(\"project\");\n\n    function setUp() public {\n        vm.roll(1_000);\n        vm.warp(1_700_000_000);\n        imd = new TestIMD();\n        sImd = new TestSIMD(address(imd), OWNER);\n        stacker = new Stacker(IERC20(address(imd)), IStakedIMD(address(sImd)));\n        vm.startPrank(project);\n        imd.faucet();\n        imd.approve(address(stacker), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function test_creditToStackerItselfIsRejected() public {\n        vm.prank(project);\n        (bool ok,) = address(stacker).call(\n            abi.encodeWithSelector(Stacker.credit.selector, address(stacker), 1e18)\n        );\n        assertFalse(ok, \"credit(stacker, x) must revert\");\n        assertEq(sImd.balanceOf(address(stacker)), 0, \"Stacker must hold 0 sIMD\");\n    }\n}","reproduction":"State: project has 10_000e18 tIMD from `faucet()` and has approved the Stacker. Call `Stacker.credit(address(stacker), 1e18)` from project. Expected: revert (the Stacker is never a valid share recipient). Actual: returns 1e24 shares; `sImd.balanceOf(address(stacker)) == 1e24`, `traderStacked[address(stacker)] == 1e18`, `Stacked(project, stacker, 1e18, 1e24)` is emitted; no code path can ever move or redeem those shares.","severity":"low","snippet":"        if (trader == address(0)) revert ZeroTrader();","title":"credit() accepts the Stacker itself as trader: the minted sIMD is stuck forever and the 'Stacker holds 0 sIMD' invariant is broken by one call"},{"citation":"resolved","description":"The override exists 'to avoid bricking the vault' (NatSpec line 35 and 173), but only `renounceOwnership` is guarded. solady's `transferOwnership(newOwner)` (rejects only `address(0)`) and `completeOwnershipHandover(pendingOwner)` are callable while paused, so the owner can hand a paused vault to `0x000...dEaD` or to a contract with no owner-call path, after which `setPaused(false)` can never be called and every deposit, withdraw and redeem reverts forever. This is an owner action against documented intent (no unprivileged trigger), so it is reported as a guard gap and a trust assumption, not as an exploit; it also means the guard does not deliver the guarantee the comment claims. If the guard is meant to be a real guarantee, apply the same `paused` check to `transferOwnership` and `completeOwnershipHandover` (scope decision: logic diverges from mainnet).","line":175,"path":"src/TestSIMD.sol","reproduction":"OWNER: `setPaused(true)`; OWNER: `transferOwnership(0x000000000000000000000000000000000000dEaD)`. Expected per the comment: ownership changes that would leave the vault paused forever are blocked. Actual: succeeds; `owner() == 0xdEaD`, `paused() == true`, `renounceOwnership`/`setPaused` can no longer be called by anyone, `maxDeposit/maxRedeem` return 0 and `Stacker.credit` reverts with `DepositMoreThanMax()` permanently.","severity":"info","snippet":"        if (paused) revert RenounceWhilePaused();","title":"The 'cannot renounce while paused' brick guard is bypassed by transferOwnership / completeOwnershipHandover to an unreachable address while paused"},{"citation":"resolved","description":"Per the review guide ('read the tests just as critically'): (1) the invariant `redeem` handler rolls the block before every redeem, and `credit` always targets one of 6 fixed traders who never redeem in the same block, so the one-block hold is never observed from a victim's point of view and finding 1 cannot be caught; (2) `test_transfer_carriesHoldForward` only checks that the recipient is blocked, never that the recipient's pre-existing shares become blocked; (3) `donate` uses `deal` and no test calls `rescueERC20` on the asset followed by a deposit and a refund, so finding 2 is unreachable by the suite; (4) no test passes `address(stacker)` or `address(sImd)` as trader (finding 3); (5) `test_rescueERC20_onlyOwner` never checks what happens to share price after the rescue. Suggested additions: a handler action `thirdPartyDust(victimSeed)` that deposits 1 wei for a random trader and an invariant that a trader's shares older than one block are always redeemable; a unit test for rescue->deposit(1)->refund share accounting; a unit test that `credit(address(stacker), x)` reverts.","line":93,"path":"test/StackerInvariant.t.sol","reproduction":"Run `forge test`: 34 tests pass while the three scratch proofs in this report fail, demonstrating the gaps. Specifically `test/StackerInvariant.t.sol:93` `vm.roll(block.number + 1)` inside the handler's `redeem` guarantees `block.number != lastDepositBlock[t]` on every redeem the fuzzer attempts.","severity":"info","snippet":"        vm.roll(block.number + 1); // clear the one-block hold","title":"Test suite never exercises the adversarial edges above: the invariant handler clears the hold itself, nobody deposits/transfers to a third party, and no test covers rescue->deposit->refund or credit(s"}],"hash":"3787550ed2882d55e6566e5265cb6f37ed52d50352007f7b93e18876c4cbe08b","nodeId":"ff5f51a0-10aa-4686-9cfd-5a4f1d49e1d3","outcome":"completed","summary":"The review is complete. The findings file is written and validated, every cited snippet matches the tree, all 21 entry points have coverage rows, the original 34 tests still pass, and the five scratch proof tests fail on the current code as the findings state.\n\n**Outcome: 5 findings written to `.imd-findings.json` (2 medium, 1 low, 2 info), 26 coverage rows, 3 with Foundry proofs that fail today.**\n\nFindings in my area (Economic Security, Invariant, Flow Gap):\n\n1. **Medium, hold-stamp griefing.** The vault's one-block hold is keyed on the recipient address, not on the shares received. Anyone can reset a victim's hold with `Stacker.credit(victim, 1)`, `deposit(1, victim)`, `mint(1, victim)` or a 1-share transfer, for 1 wei plus gas per block. The victim's long-held shares then revert on redeem in that block. The contract's own comment says a third party \"must not be able to stamp a hold for free\"; only the zero-amount case is excluded. Proof file has three failing tests, one per route.\n\n2. **Medium, rescue-then-refund race.** An asset rescue on an open vault zeroes assets while supply stays. The next 1-wei deposit mints roughly the whole existing supply again. With 1,000 IMD staked, rescued and refunded, the 1-wei depositor ends up able to withdraw 500 IMD and the original staker loses half. The documented hatch and the README checklist never require pausing first. The proof is written to pass under a fix that forces the asset rescue to happen paused.\n\n3. **Low, shares stranded in the Stacker.** `credit(address(stacker), x)` succeeds and parks the sIMD in a contract with no way to move it, breaking the documented \"holds 0 sIMD\" invariant. Self-inflicted, but permanent and a one-line guard.\n\n4. **Info, brick-guard gap.** The renounce-while-paused guard does not cover ownership transfer or handover completion, so a paused vault can be handed to a dead address. Owner-only, reported as a trust assumption.\n\n5. **Info, test coverage.** The invariant handler rolls the block before every redeem and no test deposits for a third party, exercises rescue then deposit then refund, or credits the Stacker itself, so none of the above is reachable by the suite.\n\nScope notes for the judge: findings 1, 2 and 4 live in logic the brief says is forked unchanged from the mainnet vault, so each fix is a scope decision and I said so in the descriptions. I covered the ERC-4626 max/preview versus execution checks, round-trips, conservation, the Stacker's unlimited approval, fee-on-transfer and zero-share edges, and the page's points filtering; all hold. I did not run Slither or Mythril, and the page JavaScript got only a read for points integrity, not a UI review.","treeHash":null,"usage":{"cachedInputTokens":1105880,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":39586,"runtime":"claude","turns":35,"wallClockMs":560323}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"f5666f1d1aa75678","findings":[{"citation":"resolved","description":"The anti-JIT hold is enforced in _withdraw (src/TestSIMD.sol:109 `if (block.number == lastDepositBlock[owner]) revert SameBlockRedeem();`) and mirrored in maxWithdraw/maxRedeem, which return 0 for the whole block. The stamp is written in _beforeTokenTransfer for every positive mint to `to` and inherited by `to` on every positive transfer from a more recently stamped `from`. The comment above it (lines 113-118) states the design goal that \"a third party must not be able to stamp a hold on an account for free\", but the only guard is `amount == 0`. A positive amount of 1 wei of IMD is effectively free: `Stacker.credit(victim, 1)` (permissionless, msg.sender becomes the \"project\"), `TestSIMD.deposit(1, victim)` or `TestSIMD.transfer(victim, 1 share)` from a freshly stamped account all set `lastDepositBlock[victim] = block.number`. From that moment maxRedeem(victim) and maxWithdraw(victim) are 0 and every redeem/withdraw by or on behalf of the victim in that block reverts with RedeemMoreThanMax()/WithdrawMoreThanMax(), covering the victim's entire position, not just the dust. Repeating it every block the victim tries (front-running the victim's exit) keeps the position locked for as long as the griefer pays gas plus 1 wei; a victim can only escape through a private relay. The Stacker is a new, permissionless path to this: it accepts any trader from any caller, so the cashback funnel itself is the griefing tool, and tIMD is free from the faucet. The suite never tests an unsolicited credit/deposit/transfer to an existing holder, and the invariant handler clears the hold with vm.roll before every redeem, so it cannot observe this. Scope note: this logic is byte-for-byte the mainnet StakedIMD's (verified against the Sourcify exact match), so the brief's \"logic unchanged\" constraint applies; the fix is a design decision for the author (e.g. stamp only when `by == to` for self-deposits and let third-party deposits not reset the recipient, or keep the current behaviour and document that any holder can be re-locked by anyone for a block). Either way, integrators and the README (line 69-70 \"shares minted or received in block N cannot be withdrawn or redeemed in block N\") should state that \"received\" includes unsolicited receipts.","line":122,"path":"src/TestSIMD.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 {TestIMD} from \"src/TestIMD.sol\";\nimport {TestSIMD} from \"src/TestSIMD.sol\";\nimport {Stacker, IStakedIMD} from \"src/Stacker.sol\";\n\n/// @dev A holder who stacked long ago must be able to redeem in a block in which an unrelated\n/// third party pushed 1 wei of IMD to them through `Stacker.credit`. Today the 1-wei credit\n/// re-stamps the holder's `lastDepositBlock`, so the holder's own `redeem` in that block reverts.\ncontract HoldGriefTest is Test {\n    address internal constant OWNER = 0x4b91078b2374c956A65F7Af0999CaE0a935E6821;\n\n    TestIMD internal imd;\n    TestSIMD internal sImd;\n    Stacker internal stacker;\n\n    address internal project = makeAddr(\"project\");\n    address internal trader = makeAddr(\"trader\");\n    address internal griefer = makeAddr(\"griefer\");\n\n    function setUp() public {\n        vm.roll(1_000);\n        vm.warp(1_700_000_000);\n        imd = new TestIMD();\n        sImd = new TestSIMD(address(imd), OWNER);\n        stacker = new Stacker(IERC20(address(imd)), IStakedIMD(address(sImd)));\n\n        deal(address(imd), project, 1_000e18);\n        vm.prank(project);\n        imd.approve(address(stacker), type(uint256).max);\n\n        // The griefer only needs dust; one faucet call is more than enough.\n        vm.prank(griefer);\n        imd.faucet();\n        vm.prank(griefer);\n        imd.approve(address(stacker), type(uint256).max);\n    }\n\n    function test_strangerCreditOfOneWeiMustNotBlockHoldersRedeem() public {\n        // Day 1: a real cashback is paid.\n        vm.prank(project);\n        uint256 shares = stacker.credit(trader, 100e18);\n        assertEq(sImd.balanceOf(trader), shares);\n\n        // Many blocks later the trader wants to exit.\n        vm.roll(block.number + 100);\n        assertEq(sImd.maxRedeem(trader), shares, \"hold cleared, trader can redeem\");\n\n        // Same block, before the trader's tx: an unrelated address credits the trader 1 wei.\n        vm.prank(griefer);\n        stacker.credit(trader, 1);\n\n        // Expected: the trader's redeem of their 100 IMD position still succeeds.\n        // Actual: `maxRedeem(trader)` is now 0 and `redeem` reverts with RedeemMoreThanMax().\n        assertGt(sImd.maxRedeem(trader), 0, \"a 1 wei credit by a stranger re-locked the trader\");\n        vm.prank(trader);\n        uint256 assets = sImd.redeem(shares, trader, trader);\n        assertGe(assets, 100e18 - 1);\n    }\n}","reproduction":"State: block 1000; project credits trader 100e18 tIMD via Stacker.credit(trader, 100e18) -> trader holds 1e26 tsIMD. vm.roll(1100): sImd.maxRedeem(trader) == 1e26 (hold cleared). Same block 1100, griefer (any address holding 1 wei tIMD approved to the Stacker) calls stacker.credit(trader, 1). Expected: trader's position is unaffected and sImd.redeem(1e26, trader, trader) returns ~100e18. Actual: lastDepositBlock[trader] == 1100, sImd.maxRedeem(trader) == 0 and sImd.redeem(1e26, trader, trader) reverts with RedeemMoreThanMax(). Equivalent triggers: sImd.deposit(1, trader) directly, or sImd.deposit(1, griefer) then sImd.transfer(trader, 1). Run: forge test --match-path test/scratch/HoldGrief.t.sol (fails with 'a 1 wei credit by a stranger re-locked the trader: 0 <= 0').","severity":"medium","snippet":"            lastDepositBlock[to] = block.number; // mint (deposit)","title":"Anyone can re-lock any tsIMD holder for a block with a 1 wei credit/deposit/transfer (one-block hold is third-party stampable)"},{"citation":"resolved","description":"The only sentinel rejected is address(0). `credit(address(stacker), x)` pulls x IMD from the caller and `SIMD.deposit(x, address(stacker))` mints the shares to the Stacker, which has no owner, no sweep and no code path that transfers or redeems tsIMD, so the shares (and the IMD backing them) are unrecoverable forever. `credit(address(sImd), x)` mints the shares to the vault itself with the same result (solady ERC20 has no self-transfer restriction, and nothing in the vault can move its own balance). The brief lists \"invariant: Stacker holds 0 IMD and 0 sIMD\" as a required property, and README line 15 asserts it; a single 1 wei call by any address breaks it. Who loses: only the caller's IMD, so impact is misuse-grade, but a project hook that passes a wrong constant (e.g. its own stacker address) would silently burn every cashback. The invariant suite cannot see this: the handler chooses traders from a fixed list of 6 EOAs, so trader == stacker/vault is never generated. Fix (preserves the agreed design): revert when trader == address(this) || trader == address(SIMD), and add that case to the invariant handler.","line":83,"path":"src/Stacker.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 {TestIMD} from \"src/TestIMD.sol\";\nimport {TestSIMD} from \"src/TestSIMD.sol\";\nimport {Stacker, IStakedIMD} from \"src/Stacker.sol\";\n\n/// @dev The brief's invariant is \"Stacker holds 0 IMD and 0 sIMD\". `credit` only rejects the zero\n/// trader, so `credit(address(stacker), x)` mints x worth of tsIMD to the Stacker itself, where no\n/// code path can ever move or redeem it. The same holds for `credit(address(sImd), x)`.\ncontract SelfTraderTest is Test {\n    address internal constant OWNER = 0x4b91078b2374c956A65F7Af0999CaE0a935E6821;\n\n    TestIMD internal imd;\n    TestSIMD internal sImd;\n    Stacker internal stacker;\n\n    address internal project = makeAddr(\"project\");\n\n    function setUp() public {\n        vm.roll(1_000);\n        vm.warp(1_700_000_000);\n        imd = new TestIMD();\n        sImd = new TestSIMD(address(imd), OWNER);\n        stacker = new Stacker(IERC20(address(imd)), IStakedIMD(address(sImd)));\n        deal(address(imd), project, 1_000e18);\n        vm.prank(project);\n        imd.approve(address(stacker), type(uint256).max);\n    }\n\n    function test_creditToStackerItselfMustRevert() public {\n        vm.prank(project);\n        // Expected: revert (the Stacker can never hold or redeem sIMD).\n        // Actual: succeeds, mints 1e18 * 1e6 tsIMD to the Stacker, breaking the 0-sIMD invariant.\n        vm.expectRevert();\n        stacker.credit(address(stacker), 1e18);\n        assertEq(sImd.balanceOf(address(stacker)), 0, \"Stacker must hold 0 sIMD\");\n    }\n\n    function test_creditToVaultItselfMustRevert() public {\n        vm.prank(project);\n        vm.expectRevert();\n        stacker.credit(address(sImd), 1e18);\n        assertEq(sImd.balanceOf(address(sImd)), 0, \"vault must not hold its own shares\");\n    }\n}","reproduction":"project holds 1_000e18 tIMD and approved the Stacker. vm.prank(project); stacker.credit(address(stacker), 1e18). Expected: revert (the Stacker must never hold sIMD). Actual: returns 1e24 shares, sImd.balanceOf(address(stacker)) == 1e24, traderStacked[address(stacker)] == 1e18, and invariant_stackerHoldsNothing would now fail; no function can ever move those shares. Same with stacker.credit(address(sImd), 1e18): sImd.balanceOf(address(sImd)) == 1e24. Run: forge test --match-path test/scratch/SelfTrader.t.sol (both credit tests fail with 'next call did not revert as expected').","severity":"low","snippet":"        if (trader == address(0)) revert ZeroTrader();","title":"credit() accepts the Stacker or the vault itself as trader, permanently locking the shares and breaking the required 'Stacker holds 0 sIMD' invariant"},{"citation":"resolved","description":"The vendored solady ERC20 (lib/solady/src/tokens/ERC20.sol:667-669, `_givePermit2InfiniteAllowance()` returns true, identical to the mainnet StakedIMD's dependency) fixes allowance(owner, 0x000000000022D473030F116dDEE9F6B43aC78BA3) at type(uint256).max for every holder, makes approve/permit to Permit2 with any other value revert with Permit2AllowanceIsFixedAtInfinity(), and lets transferFrom called by Permit2 skip the allowance check. Permit2 is deployed at that address on Sepolia and mainnet. This is standard solady behaviour and not a defect in the fork, but it is a trust assumption on the Permit2 contract that neither the README's \"properties that matter to integrators\" list (lines 64-76) nor the owner-powers section mentions, and the suite does not cover it. Report only so the author can document it; no code change is suggested given the 'logic unchanged' constraint.","line":36,"path":"src/TestSIMD.sol","reproduction":"project credits itself 10e18 via stacker.credit(project, 10e18) -> shares = 1e25. sImd.allowance(project, PERMIT2) == type(uint256).max without any approve. vm.prank(project); sImd.approve(PERMIT2, 0) reverts with Permit2AllowanceIsFixedAtInfinity(). vm.prank(PERMIT2); sImd.transferFrom(project, 0xBEEF, 1e25) succeeds and sImd.balanceOf(0xBEEF) == 1e25. Expected per README: only approvals the holder granted can move tsIMD. Verified by test_permit2HasInfiniteAllowanceOverEveryHolder in test/scratch/SelfTrader.t.sol (passes, i.e. the behaviour is present).","severity":"info","snippet":"contract TestSIMD is ERC4626, Ownable {","title":"Inherited solady default: the canonical Permit2 contract has an unrevocable infinite allowance over every tsIMD holder (undocumented trust assumption)"}],"hash":"4834b35cd581a1560d1089740af0d41192e42659b19294a8b7b5469318c34fab","nodeId":"87940afa-fb9f-4027-b6b2-59e1400e3f8d","outcome":"completed","summary":"The review is complete. The findings file is at the repository root, excluded from git by the harness's own rule, and the tree is otherwise untouched.\n\n**What I audited.** I traced `Stacker.credit`, every `TestSIMD` funnel and owner function, and the `TestIMD` faucet through their guards, state writes and revert paths, following the Execution Trace, Periphery and First Principles guides. For the periphery I pulled the mainnet StakedIMD exact match from Sourcify and diffed it: the fork differs only in pragma, comments, contract name and the name and symbol strings, and all five solady files plus the thirteen vendored OpenZeppelin files are byte-identical to upstream. The page's thirteen selectors and the Stacked topic match `cast sig`. All 34 project tests pass. Every slither and aderyn lead was checked and rejected with a reason.\n\n**Findings written to `.imd-findings.json`** (3 findings, 26 coverage rows, 21 of them the listed entry points):\n\n- **Medium, one-block hold is third-party stampable.** Any positive mint or transfer re-stamps the recipient's `lastDepositBlock`, so a stranger calling `credit(victim, 1)` for one wei of free faucet tIMD sets the victim's `maxRedeem` to 0 and makes their whole-position redeem revert that block. Repeated front-running keeps them locked. The code's own comment claims this grief is prevented. The logic is the mainnet vault's, so the fix is a design decision; a failing proof test is attached.\n- **Low, `credit` accepts the Stacker or the vault as trader.** Only the zero address is rejected, so `credit(address(stacker), 1e18)` mints shares nobody can ever move and breaks the brief's required \"Stacker holds 0 sIMD\" invariant. The invariant handler never generates this case. Failing proof attached.\n- **Info, Permit2 infinite allowance.** The vendored solady ERC20 gives the canonical Permit2 address an unrevocable infinite allowance over every tsIMD holder. Standard and inherited, but undocumented in the README's integrator list.\n\n**Not reached.** I did not run the page in a browser or against live RPCs; its logic was reviewed by reading. No other gaps in the assigned area.","treeHash":null,"usage":{"cachedInputTokens":1466891,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":34919,"runtime":"claude","turns":38,"wallClockMs":490577}},{"artifacts":[],"attempt":1,"bundleHash":"160a024c8a65d34549e1620c29cbb48444c1a33b053c7679a7f2ff743c4b1078","device":"1731fbfe0c4574fb","findings":[],"hash":"488a013b8bdcbbb008cff80a9b85dbd66e63e511b055f0ca93bb777cade3e3f9","nodeId":"1ee00cc8-527c-4801-ac99-2d7e13438aef","outcome":"completed","summary":"Build and all 34 tests pass. The only change in the working tree is the new launch.json.\n\n**Result:** launch.json is written as an evm_contracts manifest naming the three accepted contracts in constructor dependency order.\n\n- **TestIMD** takes no constructor arguments.\n- **TestSIMD** takes the TestIMD reference as its asset and the owner address stated in the brief, written as the static address 0x4b91078b2374c956A65F7Af0999CaE0a935E6821 so the vault owner is that wallet rather than the factory or the payer. This matches the README's deployment table from the earlier accepted work.\n- **Stacker** takes the TestIMD and TestSIMD references, and its constructor checks that the vault's asset equals the token.\n\nThe notes field describes each contract, the owner's retained pause and rescue powers, the Stacker's ownerless and feeless design, and the absence of ETH funding or initialization calls. I checked the manifest shape locally: only the three schema keys, unique identifiers under 32 characters, string arguments, and backward-pointing contract references. No file other than launch.json was created or modified.","treeHash":"22def3c139f40079344fb4dabb788d6003e37575","usage":{"cachedInputTokens":189779,"inputTokens":130,"model":"claude-fable-5-1","outputTokens":4133,"runtime":"claude","turns":11,"wallClockMs":60510}},{"artifacts":[],"attempt":1,"bundleHash":"ba83be48dae3bf76bceea450ae6955979f2e0cf99de76c7c9da3bfc50774e159","device":"1a7ecd03bd365366","findings":[{"description":"The brief states the invariant 'Stacker holds 0 IMD and 0 sIMD' and the README repeats that the Stacker 'holds no IMD and no sIMD between calls'. credit() only rejects trader == address(0). Any project (or anyone using the page's 'stack to myself' tool with the Stacker address pasted as trader) can call credit(address(stacker), amount): the IMD is pulled, the vault mints the shares to the Stacker, the totals and the Stacked event record the Stacker as a trader, and the shares are unrecoverable because the Stacker has no owner, no sweep and never transfers sIMD. The existing invariant suites only hold because their handlers never pick the Stacker as a trader. The same one-line check costs nothing and keeps the stated invariant true under every caller. (Shares minted to the vault's own address are a related mistake but recoverable: the owner can rescueERC20 them out, see test_rescue_canSweepSharesHeldByTheVaultItself.) Suggested fix, preserving the design: in credit(), `if (trader == address(0) || trader == address(this)) revert ZeroTrader();` or a dedicated `SelfTrader()` error.","line":83,"path":"src/Stacker.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 {TestIMD} from \"src/TestIMD.sol\";\nimport {TestSIMD} from \"src/TestSIMD.sol\";\nimport {Stacker, IStakedIMD} from \"src/Stacker.sol\";\n\n/// @notice Proof for the finding \"credit(address(stacker), x) mints tsIMD to the Stacker itself and\n/// locks it forever\". The brief requires the invariant \"Stacker holds 0 IMD and 0 sIMD\"; any project\n/// can break it with one call, and the shares are unrecoverable because the Stacker has no owner and\n/// no sweep. This test FAILS on the current code (the call succeeds and the Stacker ends up holding\n/// shares) and PASSES once `credit` rejects `trader == address(this)`.\ncontract StackerSelfTraderProof is Test {\n    address internal constant OWNER = 0x4b91078b2374c956A65F7Af0999CaE0a935E6821;\n    TestIMD internal imd;\n    TestSIMD internal sImd;\n    Stacker internal stacker;\n    address internal project = makeAddr(\"project\");\n\n    function setUp() public {\n        vm.roll(1_000);\n        vm.warp(1_700_000_000);\n        imd = new TestIMD();\n        sImd = new TestSIMD(address(imd), OWNER);\n        stacker = new Stacker(IERC20(address(imd)), IStakedIMD(address(sImd)));\n        vm.prank(project);\n        imd.faucet();\n        vm.prank(project);\n        imd.approve(address(stacker), type(uint256).max);\n    }\n\n    function test_creditToStackerItselfMustRevert() public {\n        uint256 before = sImd.balanceOf(address(stacker));\n        assertEq(before, 0);\n\n        vm.prank(project);\n        vm.expectRevert();\n        stacker.credit(address(stacker), 1e18);\n\n        assertEq(sImd.balanceOf(address(stacker)), 0, \"Stacker must never hold sIMD\");\n        assertEq(imd.balanceOf(project), 10_000e18, \"a rejected credit moves nothing\");\n    }\n}","reproduction":"Deploy TestIMD, TestSIMD(imd, owner), Stacker(imd, sImd). project: faucet(); approve(stacker, max); credit(address(stacker), 1e18). Expected: revert, nothing moves. Actual: returns 1e24 shares; sImd.balanceOf(stacker) == 1e24; traderStacked[stacker] == 1e18; the shares can never leave the Stacker.","severity":"low","title":"credit(address(stacker), x) mints tsIMD to the Stacker itself and locks it forever, breaking the brief's invariant"},{"description":"This is the mainnet StakedIMD logic, which the brief requires unchanged, so it is reported for integrators and the page rather than as a Stacker defect. The vault stamps lastDepositBlock[to] on every positive mint. Stacker.credit(trader, 1) (or sImd.deposit(1, trader) directly) therefore sets the trader's hold to the current block. Anyone holding dust IMD can front-run a trader's redeem/withdraw in every block for 1 wei plus gas, and maxRedeem/maxWithdraw read 0 for that trader all the while. The vault's own comment only closes the free (zero-amount) version of this grief. Consequence for imd/acc: a project crediting a trader in the same block as the trader exits makes the exit revert with RedeemMoreThanMax, which the trader's wallet will show as a failure; the page's 'your stack' view should surface the hold. No fix is suggested here because the brief fixes the vault logic to mainnet's.","line":121,"path":"src/TestSIMD.sol","reproduction":"trader deposits 10e18 at block N; roll to N+1; stranger (with 1 wei IMD) calls stacker.credit(trader, 1) or sImd.deposit(1, trader) at block N+1; trader calls sImd.redeem(allShares, trader, trader) in block N+1. Expected (from the trader's view): redeem succeeds since their own deposit was a block ago. Actual: lastDepositBlock[trader] == N+1, maxRedeem(trader) == 0, redeem reverts with RedeemMoreThanMax(). Repeating the 1-wei credit every block keeps the trader locked.","severity":"info","title":"A 1-wei credit re-stamps the trader's one-block hold, so a trader's redeem can be blocked block after block at almost no cost"},{"description":"Checked against the Sourcify exact match for 0x9efa934d9fad4ae28c998a40195646b965a97247 (chain 1). src/StakedIMD.sol differs from src/TestSIMD.sol only in the pragma (^0.8.24 vs 0.8.26), the added doc header, the contract name, name() and symbol(). The five solady files (ERC4626, ERC20, Ownable, SafeTransferLib, FixedPointMathLib) are identical to lib/solady. However the mainnet artifact was compiled with solc 0.8.30, viaIR = true, evmVersion = osaka, optimizer runs = 200, while this repository builds with solc 0.8.26, no via_ir, cancun, 10,000 runs (the README explains the runs choice). The logic is the same; the runtime bytecode is not, so the README's phrase 'byte-for-byte logic fork' is accurate only at source level. Nothing to fix in the contracts; a one-word README correction avoids an auditor flagging the mismatch.","line":9,"path":"src/TestSIMD.sol","reproduction":"curl 'https://sourcify.dev/server/v2/contract/1/0x9efa934d9fad4ae28c998a40195646b965a97247?fields=sources,compilation' and diff sources['src/StakedIMD.sol'] against src/TestSIMD.sol; compare compilation.compilerSettings with foundry.toml.","severity":"info","title":"TestSIMD matches the mainnet StakedIMD source exactly, but not its compiler settings; the README's 'byte-for-byte' wording should say source-for-source"}],"hash":"4f5984a4a24a3192ef48d207ddf9b1ee17ca56b8f87aa2b7e5c48e1ae22cd064","nodeId":"494262bf-5a60-4ebc-bd64-b18516e6e6e3","outcome":"completed","summary":"The findings file is in place at the repository root, excluded from git by the harness's own exclude rules as expected. The work is complete.\n\n## Recap\n\n**Delivered**: four new Foundry test files under `test/`, building on the existing 34-test suite rather than rewriting it. The full suite is now 76 tests across 9 suites and passes on two fuzz seeds, with `forge fmt --check` clean on my files.\n\n- `test/StackerEdges.t.sol`: the same call twice with the allowance or balance consumed to the wei, a project paying itself, one wei, maximum uint (reverts cleanly in vault math, nothing moves), a quintillion IMD, guard ordering under pause, stray IMD never spent by credit, re-entry from a hostile vault blocked by the guard, event data equal to balance delta at a non-unit price. Fuzz at 1000 runs: paused always reverts and moves nothing, short balance always reverts, the zero-share guard tracks the vault's own preview, a trader's value is within one share of what the project paid, credit-then-redeem never profits, and repeated cycles extract no dust.\n- `test/TestSIMDProperties.t.sol`: ERC-4626 preview equals minted, conversion round trip never gains, monotonic conversions, deposit/withdraw and two-staker redeem round trips, pause flag versus every max view and every funnel, the hold's zero-amount grief guard, owner rescue and renounce consequences, two-step handover.\n- `test/TestIMDEdges.t.sol`: exact 24h boundary, failed call leaves no trace, contract callers, many addresses, per-address independence, faucet is the only mint path.\n- `test/StackerInvariantExtended.t.sol`: a handler funded only through the faucet (no `deal`), with projects whose allowance and balance run short, a self-paying project, direct vault deposits, share transfers, withdraws, donations, owner pause, rescue and renounce. Six invariants at 128 runs × 48 depth: Stacker holds nothing, totals agree from every angle, full tIMD supply conservation, vault share and asset conservation, solvency, and the pause/ownership state machine. A fixed-sequence test proves every credit outcome the handler classifies is reachable.\n\n**Findings** written to `.imd-findings.json`:\n\n- **Low, with proof**: `credit(address(stacker), x)` mints tsIMD to the Stacker itself and locks it forever, so any caller can break the brief's \"Stacker holds 0 sIMD\" invariant. The proof test fails on the current code and passes once credit rejects its own address.\n- **Info**: a 1-wei credit re-stamps a trader's one-block hold, so their redeem can be blocked every block at near-zero cost. This is the mainnet vault's logic which the brief keeps unchanged, so it is reported for integrators and the page, with a verified reproduction.\n- **Info**: TestSIMD matches the mainnet source exactly per Sourcify, but the mainnet artifact was compiled with solc 0.8.30, via-IR, osaka and 200 runs, so the README's \"byte-for-byte\" wording is accurate only at source level.\n\nI also verified the site's hardcoded selectors and event topic against the compiled ABI and found no mismatch.","treeHash":"eb50b4c88f3211c2757cae2d86cc467baa18db72","usage":{"cachedInputTokens":2174619,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":68093,"runtime":"claude","turns":60,"wallClockMs":921121}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"6b47d1bf92391c22","findings":[{"citation":"resolved","description":"Merged from audit_flow, write_foundry_tests, audit_math, audit_permissions and audit_economics: all five report the same root cause. The only trader value credit() rejects is address(0). With trader == address(this), SIMD.deposit(imdAmount, address(this)) mints the shares to the Stacker. The Stacker has no owner, no sweep and no function that transfers or redeems sIMD, so the shares and the IMD behind them can never be recovered. The brief's invariant ('Stacker holds 0 IMD and 0 sIMD', repeated in README line 15) then stops being true, and the Stacker shows up as a trader in traderStacked and in the page's leaderboard. With trader == address(SIMD) the vault mints shares to itself; only the owner can get those back, through rescueERC20(address(sImd), ...). The loss is the caller's own funds: a project hook with the wrong constant, or a page user who pastes the Stacker address. Severity is low, but the loss is permanent. The invariant suites cannot catch it because the handler only picks traders from fixed EOAs. Fix, keeping the design: `if (trader == address(0) || trader == address(this) || trader == address(SIMD)) revert ...;`. Also add those addresses to the handler's trader pool, expecting a revert.","line":83,"path":"src/Stacker.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {TestIMD} from \"src/TestIMD.sol\";\nimport {TestSIMD} from \"src/TestSIMD.sol\";\nimport {Stacker, IStakedIMD} from \"src/Stacker.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\n\ncontract SelfTraderTest is Test {\n    TestIMD imd;\n    TestSIMD sImd;\n    Stacker stacker;\n    address project = address(0xBEEF);\n\n    function setUp() public {\n        vm.warp(1_000_000);\n        imd = new TestIMD();\n        sImd = new TestSIMD(address(imd), address(0x4b91078b2374c956A65F7Af0999CaE0a935E6821));\n        stacker = new Stacker(IERC20(address(imd)), IStakedIMD(address(sImd)));\n        vm.startPrank(project);\n        imd.faucet();\n        imd.approve(address(stacker), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    /// The brief's invariant: the Stacker holds 0 sIMD. A credit naming the Stacker as trader must\n    /// not succeed, because the shares it mints can never leave the ownerless Stacker.\n    function test_creditToStackerItselfMustNotLockShares() public {\n        vm.prank(project);\n        try stacker.credit(address(stacker), 1e18) {} catch {}\n        assertEq(sImd.balanceOf(address(stacker)), 0, \"Stacker holds sIMD (stuck forever)\");\n        assertEq(imd.balanceOf(address(stacker)), 0, \"Stacker holds IMD\");\n    }\n}","reproduction":"Reproduced with test/scratch/SelfTrader.t.sol (below). Fresh deploy: TestIMD, TestSIMD(imd, 0x4b91...6821), Stacker(imd, sImd). The project calls faucet() and approve(stacker, max), then calls stacker.credit(address(stacker), 1e18). Expected: revert, nothing moves. Actual: the call returns 1e24 shares and sImd.balanceOf(address(stacker)) == 1e24. The test fails with 'Stacker holds sIMD (stuck forever): 1000000000000000000000000 != 0'. It passes once credit reverts for trader == address(this).","severity":"low","snippet":"        if (trader == address(0)) revert ZeroTrader();","title":"credit() accepts the Stacker (or the vault) as trader: shares are minted to the ownerless Stacker and stuck forever, breaking the required 'Stacker holds 0 sIMD' invariant"},{"citation":"resolved","description":"Merged from audit_flow (medium), write_foundry_tests (info), audit_math (low), audit_permissions (medium) and audit_economics (medium): same root cause. _beforeTokenTransfer stamps lastDepositBlock[to] on every positive mint. It also carries a newer stamp forward on every positive transfer. _withdraw (line 109) and maxWithdraw/maxRedeem (lines 141, 146) then block the holder's entire balance for that block. The guard at line 120 (`if (amount == 0 || to == address(0)) return;`) only covers the zero-amount case that the comment at lines 113-118 names. So 1 wei is enough: Stacker.credit(victim, 1), sImd.deposit(1, victim), sImd.mint(1, victim) or a 1-share transfer from a freshly stamped account each reset the victim's hold. The cost is 1 wei of IMD (free from the faucet) plus gas per block. The impact is a temporary denial of withdrawal, renewable each block by front-running. No funds are lost, so I recalibrate it to low. This is the mainnet StakedIMD logic, and the brief requires 'logic unchanged'. Changing the vault is therefore a scope decision for the requester, not a required fix. The required fix is documentation. README lines 69-70 should say that anyone can trigger the hold with a dust deposit, credit or transfer to the holder, and that every Stacker.credit to a trader blocks that trader's redeem in the same block. The page's 'Your stack' view should show the hold. Integrators who bundle trade+credit+redeem need to know this.","line":122,"path":"src/TestSIMD.sol","reproduction":"Reproduced by running the specialists' proofs .imd/reads/proofs/Proof_b8f5ebba42db.t.sol, Proof_bcdb9f6abeba.t.sol and Proof_2d89653c98db.t.sol from test/scratch. All fail on this tree as described. Scenario: the victim deposits 100e18 at block 1000 (1e26 shares). At block 2000, maxRedeem(victim) == 1e26. A stranger with 1 wei tIMD approved to the Stacker calls stacker.credit(victim, 1). lastDepositBlock[victim] becomes 2000 and maxRedeem(victim) == 0. The victim's sImd.redeem(1e26, victim, victim) in the same block reverts RedeemMoreThanMax(). Expected per the vault's own comment: a third party cannot put a hold on the account. sImd.deposit(1, victim), sImd.mint(1, victim) and sImd.deposit(1, griefer)+transfer(victim, 1) give the same result.","severity":"low","snippet":"            lastDepositBlock[to] = block.number; // mint (deposit)","title":"Any third party can reset a holder's one-block hold with a 1-wei credit/deposit/mint/transfer, blocking that holder's whole exit for the block; the README does not document this"},{"citation":"resolved","description":"From audit_economics (medium), reproduced and recalibrated to info. rescueERC20(asset, ...) sets totalAssets to 0 while totalSupply stays the same. A 1-wei deposit then mints totalSupply+1e6 shares, so a refund by the owner is split ~50/50 with that depositor. It only happens after the owner uses a documented power, and that power can already take every staker's IMD outright. The logic is the mainnet StakedIMD's, which the brief requires unchanged. This is a privileged-power trust assumption, not a permission bypass. Document it in the owner checklist: pause before rescuing the asset, and stay paused until any refund.","line":161,"path":"src/TestSIMD.sol","reproduction":"Proof_0b4d031f0d18.t.sol (run from test/scratch) fails as described. The staker holds 1,000e18 (1e27 shares). The owner calls rescueERC20(imd, owner, 1000e18). The attacker calls Stacker.credit(attacker, 1) and gets 1e27+1e6 shares. The owner calls imd.transfer(sImd, 1000e18). After that, maxWithdraw(attacker) == 500000000000000000001 and maxWithdraw(staker) == 500e18.","severity":"info","snippet":"        token.safeTransfer(to, amount);","title":"Trust assumption: the owner's asset rescue on an unpaused vault opens a window where a 1-wei depositor captures ~50% of the IMD the owner later returns"},{"citation":"resolved","description":"Merged from audit_permissions (low) and audit_economics (info). Only renounceOwnership checks `paused`. solady's transferOwnership and completeOwnershipHandover do not, so a paused vault can be handed to 0xdEaD and stay paused forever. Only the owner can do this, and it matches the unchanged mainnet logic. It is an owner footgun and trust assumption, not an exploit.","line":175,"path":"src/TestSIMD.sol","reproduction":"As the owner: setPaused(true), then transferOwnership(0x000000000000000000000000000000000000dEaD). This succeeds, and nobody can call setPaused(false) afterwards. renounceOwnership() in the same state reverts RenounceWhilePaused().","severity":"info","snippet":"        if (paused) revert RenounceWhilePaused();","title":"The renounce-while-paused guard does not cover transferOwnership/completeOwnershipHandover: the owner can hand a paused vault to an unreachable address"}],"hash":"5b7b082554fa668f4547107cfe60dab139e081e88a38cfeb65cdb8dadefaba4a","nodeId":"a1ff3981-955e-4871-8c5f-1632fc26837c","outcome":"completed","summary":"The review is done: the specialists' reports merge into four findings, and the most serious is low severity. They are written to `.imd-findings.json`, with a coverage row for all 21 entry points plus the \"Stacker holds 0 IMD and 0 sIMD\" invariant. I ran every specialist proof and my own test against the current code, and all of them failed the way their findings say. I changed no files; I deleted the scratch tests afterwards. `git status` refused to run because of a directory-ownership setting, so I couldn't double-check that through git.\n\n1. **Low – `credit()` accepts the Stacker or the vault as the trader** (`src/Stacker.sol:83`). Five specialists reported this one cause. Calling `credit(address(stacker), 1e18)` mints 1e24 sIMD to the Stacker, which has no way to ever move it, so the brief's invariant breaks. Naming the vault as trader leaves shares only the owner can rescue. The caller loses only their own IMD, but the loss is permanent. The fix is to also reject `address(this)` and `address(SIMD)`. My test that fails today is attached as the proof.\n2. **Low – anyone can block a holder's withdrawals for a block for 1 wei** (`src/TestSIMD.sol:122`). Also reported by all five specialists, three of them at medium. A 1-wei deposit, mint, transfer or `credit` to someone else blocks that holder's entire withdraw/redeem for that block. An attacker can repeat it every block, paying only 1 wei plus gas each time. No funds are lost, and this is the mainnet vault's logic, which the brief says must stay unchanged. So the required fix is documentation: README lines 69–70 and the page should say this, including that every `credit` blocks that trader's redeem in the same block.\n3. **Info – owner rescue race** (`src/TestSIMD.sol:161`). If the owner rescues the staked IMD while the vault is unpaused, a 1-wei depositor gets about half of whatever the owner later puts back. A specialist called this medium. I treat it as a trust assumption, because it needs the owner's own power and that power can already take all staked IMD. The owner checklist should say to pause before rescuing.\n4. **Info – the pause guard only covers renouncing** (`src/TestSIMD.sol:175`). `transferOwnership` and `completeOwnershipHandover` still work while paused, so the owner could hand a paused vault to an unreachable address and freeze it for good. This is an owner footgun inherited from mainnet, not an exploit.\n\n**Dropped as not defects in this code:**\n- **Rounding loss after a donation:** this is how any ERC-4626 vault behaves, and the recorded IMD totals are correct.\n- **Permit2's infinite allowance:** this is a standard default of the token library (solady) the vault is built on.\n- **README's \"byte-for-byte\" wording:** a doc nit, not a defect.\n\n`launch.json` matches the schema: dependencies are in order, and the brief's owner address is written as a static address.","treeHash":null,"usage":{"cachedInputTokens":467349,"inputTokens":14,"model":"claude-opus-5-5","outputTokens":8336,"runtime":"claude","turns":7,"wallClockMs":99811}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"56e50117311155be","findings":[{"citation":"resolved","description":"Seam access x asymmetry. The anti-JIT hold is stamped in _beforeTokenTransfer on every positive mint, keyed on the share recipient `to`, and the recipient is chosen by the depositor, not by the holder. The guard `if (amount == 0 || to == address(0)) return;` (line 120) only stops the zero-amount case the comment at lines 113-118 worries about (\"a third party must not be able to stamp a hold on an account for free (a `deposit(0, victim)` ... grief)\"). A 1-wei deposit mints a positive share amount (previewDeposit(1) ~= 1e6 shares at the initial price, and stays >= 1 share until the share price exceeds 1e6 wei per 1e6 shares, i.e. never in practice), so `sImd.deposit(1, victim)` from any address sets lastDepositBlock[victim] = block.number. maxWithdraw/maxRedeem (lines 141, 146) then report 0 and _withdraw (line 109) reverts SameBlockRedeem for the victim in that block. The same stamp is reachable through the ownerless Stacker: `credit(victim, 1)` deposits with `to = victim`, so an attacker needs no vault approval, only 1 wei of IMD approved to the Stacker. Cost to the attacker: 1 wei + gas per block; a front-run of the victim's pending redeem (or a bot that re-stamps every block) denies the victim's exit for as long as the attacker pays gas, and each blocked attempt costs the victim a reverted transaction. The hold also propagates: a freshly stamped attacker can transfer 1 share to a victim (line 123-124) with the same effect. This is in the mainnet StakedIMD logic the brief asks to fork unchanged (verified: the fork differs from the Sourcify source only in name/symbol/pragma), so fixing it is a scope decision for the author; the behaviour should at least be documented for integrators, since every `credit` already stamps the trader and a 1-wei credit from anyone does too. Fix options that keep the pause/owner design: ignore third-party stamps by only stamping `to` on mint when `by == to` is not safe (helper-contract JIT bypass); a minimum deposit for `by != to` (e.g. >= 1e18 wei) raises the grief cost; or track the hold per newly-minted balance rather than per account (redeem of pre-existing shares stays allowed).","line":121,"path":"src/TestSIMD.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 {TestIMD} from \"src/TestIMD.sol\";\nimport {TestSIMD} from \"src/TestSIMD.sol\";\nimport {Stacker, IStakedIMD} from \"src/Stacker.sol\";\n\n/// @dev Anyone can re-stamp a victim's one-block hold for 1 wei of IMD, blocking the victim's\n/// withdraw/redeem in that block. The in-code guarantee at TestSIMD.sol:113-118 says a third party\n/// must not be able to stamp a hold on an account; this shows it can, both through the vault\n/// directly (deposit(1, victim)) and through the ownerless Stacker (credit(victim, 1)).\n/// Passes once a third-party 1-wei deposit either reverts or leaves the victim's hold untouched.\ncontract HoldGriefTest is Test {\n    address internal constant OWNER = 0x4b91078b2374c956A65F7Af0999CaE0a935E6821;\n    TestIMD internal imd;\n    TestSIMD internal sImd;\n    Stacker internal stacker;\n    address internal victim = makeAddr(\"victim\");\n    address internal attacker = makeAddr(\"attacker\");\n\n    function setUp() public {\n        vm.roll(1_000);\n        vm.warp(1_700_000_000);\n        imd = new TestIMD();\n        sImd = new TestSIMD(address(imd), OWNER);\n        stacker = new Stacker(IERC20(address(imd)), IStakedIMD(address(sImd)));\n\n        // Victim staked 100 IMD long ago (1000 blocks).\n        deal(address(imd), victim, 100e18);\n        vm.startPrank(victim);\n        imd.approve(address(sImd), type(uint256).max);\n        sImd.deposit(100e18, victim);\n        vm.stopPrank();\n        vm.roll(block.number + 1000);\n        assertEq(sImd.maxRedeem(victim), sImd.balanceOf(victim), \"victim is free to redeem\");\n\n        // Attacker holds 2 wei of IMD in total.\n        deal(address(imd), attacker, 2);\n        vm.startPrank(attacker);\n        imd.approve(address(sImd), type(uint256).max);\n        imd.approve(address(stacker), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    /// Expected: a deposit by `attacker` for `victim` must not move the victim's hold.\n    /// Actual: 1 wei deposited for the victim sets lastDepositBlock[victim] = block.number, so\n    /// maxRedeem(victim) drops to 0 and the victim's redeem in this block reverts.\n    function test_thirdPartyStampsHold_viaVaultDeposit() public {\n        uint256 holdBefore = sImd.lastDepositBlock(victim);\n        uint256 bal = sImd.balanceOf(victim);\n        vm.prank(attacker);\n        try sImd.deposit(1, victim) {\n            assertEq(sImd.lastDepositBlock(victim), holdBefore, \"third party stamped the victim\");\n        } catch {\n            // A vault that refuses the 1-wei third-party deposit is fixed.\n        }\n        vm.prank(victim);\n        uint256 assets = sImd.redeem(bal, victim, victim);\n        assertGt(assets, 0, \"victim could redeem\");\n    }\n\n    /// Same grief routed through the ownerless Stacker: `credit(victim, 1)` from any address.\n    function test_thirdPartyStampsHold_viaStackerCredit() public {\n        uint256 holdBefore = sImd.lastDepositBlock(victim);\n        uint256 bal = sImd.balanceOf(victim);\n        vm.prank(attacker);\n        try stacker.credit(victim, 1) {\n            assertEq(sImd.lastDepositBlock(victim), holdBefore, \"third party stamped the victim\");\n        } catch {\n            // A Stacker/vault that refuses the 1-wei third-party credit is fixed.\n        }\n        vm.prank(victim);\n        uint256 assets = sImd.redeem(bal, victim, victim);\n        assertGt(assets, 0, \"victim could redeem\");\n    }\n}","reproduction":"State: victim deposited 100e18 IMD at block 1000 and holds 1e26 shares; it is now block 2000, maxRedeem(victim) == balanceOf(victim). Attacker holds 2 wei of IMD and has approved the vault and the Stacker. Call 1 (attacker): sImd.deposit(1, victim) -> succeeds, mints ~1e6 shares to victim, lastDepositBlock[victim] becomes 2000 (expected: unchanged at 1000). Call 2 (victim, same block): sImd.redeem(1e26, victim, victim) -> reverts RedeemMoreThanMax() (expected: 100e18 IMD returned). Variant: attacker calls stacker.credit(victim, 1) instead of deposit; identical effect. Reproduced by test/scratch/HoldGrief.t.sol: both tests fail with 'third party stamped the victim: 2000 != 1000'.","severity":"medium","snippet":"        if (from == address(0)) {\n            lastDepositBlock[to] = block.number; // mint (deposit)","title":"Anyone can re-stamp another holder's one-block hold for 1 wei (deposit(1, victim) / credit(victim, 1)), blocking that holder's withdraw/redeem"},{"citation":"resolved","description":"Asymmetry between admin variants of the same action. The contract explicitly guards against bricking the vault by renouncing while paused (comment line 173: \"Block the footgun of renouncing while paused, which would freeze the vault forever\"). The two other inherited paths that end ownership for the current owner, solady Ownable.transferOwnership(newOwner) and completeOwnershipHandover(pendingOwner), are not overridden and carry no paused check, so transferOwnership(0x...dead) (or any address whose key is lost, or a contract that cannot call setPaused) while paused reaches exactly the state the guard exists to prevent: paused == true and nobody able to call setPaused(false); every deposit, withdraw and redeem reverts forever and all staked IMD is frozen (unless the new owner is live and rescues). Only the owner can trigger this, so it is an owner footgun rather than an exploit; reported because the code claims the footgun is closed. Same logic exists on mainnet StakedIMD (fork is unchanged), so a fix is a scope decision. Minimal fix: override transferOwnership and completeOwnershipHandover with the same `if (paused) revert RenounceWhilePaused();` check (or a shared modifier), preserving the owner role and pause power the brief requires.","line":174,"path":"src/TestSIMD.sol","reproduction":"Owner 0x4b91...6821: setPaused(true); renounceOwnership() -> reverts RenounceWhilePaused() (guard works). transferOwnership(0x000000000000000000000000000000000000dEaD) -> succeeds; owner() == 0xdead, paused() == true; 0x4b91...6821 calling setPaused(false) -> reverts Unauthorized(). Variant: 0xdead calls requestOwnershipHandover(); owner calls setPaused(true) then completeOwnershipHandover(0xdead) -> succeeds while paused. Reproduced by test/scratch/OwnerPausedFreeze.t.sol (both tests pass on current code, showing the paths are open).","severity":"low","snippet":"    function renounceOwnership() public payable override onlyOwner {\n        if (paused) revert RenounceWhilePaused();\n        super.renounceOwnership();\n    }","title":"RenounceWhilePaused guard is not mirrored on transferOwnership/completeOwnershipHandover: owner can still leave the vault paused with no live owner"},{"citation":"resolved","description":"Asymmetric sentinel check. The brief's invariant is that the Stacker holds 0 IMD and 0 sIMD; the Stacker has no owner, admin or sweep, so any sIMD it receives is unrecoverable. The only recipient the function rejects is address(0); a project (or the page's 'stack to myself' flow if a user pastes the Stacker address) that passes trader == address(stacker) has its IMD deposited and the shares minted to the Stacker itself, where they are stuck forever, while traderStacked/traderShares[address(stacker)] record them as a trader's cashback. trader == address(sImd) likewise mints the vault its own shares (recoverable only by the owner's rescueERC20(address(sImd), ...)). The loss falls on the caller (the project's cashback budget), so this is a guard gap and a broken stated invariant rather than a theft path; the invariant test cannot catch it because the handler only draws traders from six fresh addresses. Minimal fix: `if (trader == address(0) || trader == address(this) || trader == address(SIMD)) revert ZeroTrader();` (or a dedicated BadTrader error), which keeps every other behaviour of credit unchanged.","line":83,"path":"src/Stacker.sol","reproduction":"Project with 10e18 IMD approved to the Stacker calls stacker.credit(address(stacker), 1e18). Expected: revert (no shares may land on the Stacker). Actual: returns 1e24 shares; sImd.balanceOf(address(stacker)) == 1e24, traderShares[address(stacker)] == 1e24, and no function can move them. Reproduced by test/scratch/SelfTrader.t.sol: test_creditToStackerItself_breaksInvariant fails with 'Stacker holds sIMD: 1000000000000000000000000 != 0'; test_creditToVaultItself shows sImd.balanceOf(address(sImd)) == shares.","severity":"low","snippet":"        if (trader == address(0)) revert ZeroTrader();\n        if (imdAmount == 0) return 0;","title":"credit() rejects trader == address(0) but accepts trader == address(this) or the vault, minting shares the Stacker can never move and breaking the 'Stacker holds 0 sIMD' invariant"},{"citation":"resolved","description":"The invariant suite's trader set is six fresh EOAs, so invariant_stackerHoldsNothing can never see credit(address(stacker), x) (finding 3) and passes vacuously for that input. TestSIMD.t.sol exercises deposit/redeem/pause/renounce/rescueERC20/transfer but not: withdraw(), mint(), transferFrom() hold carry-over, permit(), rescueETH(), transferOwnership(), request/cancel/completeOwnershipHandover(), nor a deposit by one address for another (the path finding 1 abuses, and the path every Stacker.credit takes). All of these were traced in this review (test/scratch/Coverage.t.sol passes: mint reverts MintMoreThanMax while paused, deposit(0) while paused reverts EnforcedPause, withdraw honours the owner's hold even for an approved operator, transferFrom carries the hold, permit cannot be replayed, rescueETH is owner-only, expired/cancelled handovers cannot be completed, the Stacker's max allowance is spendable only by the vault with the Stacker as msg.sender). Suggest adding the scratch cases to the suites and adding address(stacker)/address(sImd) to the invariant handler's trader pool with an expectation of revert.","line":42,"path":"test/StackerInvariant.t.sol","reproduction":"Run `forge test --match-path 'test/StackerInvariant.t.sol'` after changing the handler so one trader is address(stacker): invariant_stackerHoldsNothing fails on the first credit to it. Run test/scratch/SelfTrader.t.sol and test/scratch/HoldGrief.t.sol on the current tree: they fail, yet the existing 34 tests all pass.","severity":"info","snippet":"        for (uint256 i; i < 6; ++i) {\n            traders.push(makeAddr(string.concat(\"ht\", vm.toString(i))));\n        }","title":"Test gaps in the access/asymmetry area: invariant handler never targets the Stacker or vault as trader, and no test covers third-party hold stamping, transferOwnership/handover, withdraw, mint, permit"},{"citation":"resolved","description":"Documented separately as the privileged-power record, not as a defect: the brief keeps the mainnet owner powers so integrators can test a failing vault. Owner 0x4b91078b2374c956A65F7Af0999CaE0a935E6821 can (a) rescueERC20(imd, to, totalAssets()) and take every trader's cashback, after which convertToAssets(shares) rounds to ~0 for all holders while Stacker.traderStacked/totalStacked still report the full amounts; (b) setPaused(true) indefinitely, which also makes every Stacker.credit revert (integrators must try/catch); (c) rescueERC20(address(sImd), ...) to move shares the vault itself holds. renounceOwnership() removes all of it and is blocked while paused. No unprivileged amplifier was found: rescue uses the vault's own balance (safeTransfer, not transferFrom), so the Stacker's unlimited allowance to the vault cannot be spent by the owner or anyone other than the vault with the Stacker as msg.sender (verified in test/scratch/Coverage.t.sol test_stackerAllowanceNotSpendableByOthers).","line":160,"path":"src/TestSIMD.sol","reproduction":"Trader credited 100e18 through the Stacker (shares 1e26). Owner calls sImd.rescueERC20(address(imd), owner, 100e18): imd.balanceOf(owner) == 100e18, sImd.convertToAssets(1e26) == 0, stacker.traderStacked(trader) still 100e18. Existing test test_rescueERC20_onlyOwner shows the call succeeds for the owner and reverts Unauthorized for anyone else.","severity":"info","snippet":"    function rescueERC20(address token, address to, uint256 amount) external onlyOwner {\n        token.safeTransfer(to, amount);\n        emit EmergencyRescue(token, to, amount);\n    }","title":"Trust assumption (by design): TestSIMD owner can sweep all staked IMD and freeze every exit; Stacker accounting does not reflect either"}],"hash":"67e6e03ac81e99d4007675cc01ffdbea0f6e9cd313f08882f350ecb613e5bef3","nodeId":"80b15547-9b29-4c58-a847-979affb25ce1","outcome":"completed","summary":"The review is complete. The findings file holds five findings and coverage rows for all 21 entry points plus four invariant rows. No source, test or configuration file in the tree was changed; the only new files are the ignored findings file and my scratch tests.\n\n**What I checked.** I read the Senior Auditor's Mindset and the Access Control, Trust Gap and Asymmetry guides, mapped every entry point and the roles the constructors hand out, and traced each path with Foundry. I also fetched the verified mainnet StakedIMD source from Sourcify and diffed it: the fork differs only in pragma, title comment, name and symbol, and all five vendored solady files are identical, so the \"logic unchanged\" claim holds. The existing 34 tests pass.\n\n**Findings, most severe first.**\n\n- **Medium, with proof.** Anyone can re-stamp another holder's one-block hold for 1 wei. The vault stamps `lastDepositBlock[to]` on every positive mint, and the depositor picks `to`. So `deposit(1, victim)` or `credit(victim, 1)` from any address sets the victim's hold to the current block, and the victim's redeem in that block reverts. The in-code comment claims a third party cannot stamp a hold. The proof test fails on the current tree and passes once a third-party dust deposit either reverts or leaves the hold untouched. This logic is also on mainnet, so fixing it is a scope decision.\n- **Low.** The renounce-while-paused guard is not mirrored on `transferOwnership` or `completeOwnershipHandover`. The owner can still leave the vault paused with no live owner, the exact state the guard exists to prevent. Owner footgun only.\n- **Low.** `credit` rejects a zero trader but accepts the Stacker or the vault itself as trader. Shares minted to the Stacker are unrecoverable and break the stated \"Stacker holds 0 sIMD\" invariant. The loss falls on the caller.\n- **Info.** Test gaps in this area: the invariant handler never targets the Stacker as trader, and no test covers third-party stamping, ownership transfer or handover, withdraw, mint, permit or rescueETH. My scratch tests cover these and pass where the code is correct.\n- **Info.** Trust assumption record: the vault owner can sweep all staked IMD and freeze every exit, as the brief intends. No unprivileged amplifier exists. The Stacker's unlimited allowance cannot be spent by the owner or anyone other than the vault acting for the Stacker.\n\n**Coverage.** Every listed entry point has a row. Four rows point at findings, the rest hold after tracing. I also recorded rows for the mainnet-fork diff, the deployment roles and the site's listed-project filtering, all of which hold.","treeHash":null,"usage":{"cachedInputTokens":1120658,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":37882,"runtime":"claude","turns":41,"wallClockMs":504927}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3bed38612db34f32","findings":[{"citation":"resolved","description":"Boundary (min input). The vault's own comment states that a third party must not be able to stamp a hold on an account for free, and only the amount==0 case is excluded. The smallest positive mint is not free but is 1 wei of IMD: at the vault's initial price previewMint(1) == 1 wei, and Stacker.credit(victim, 1) (callable by anyone, project == msg.sender) mints 1e6 shares to the victim for 1 wei. Each such call sets lastDepositBlock[victim] = block.number, so every maxWithdraw/maxRedeem of the victim reads 0 and withdraw/redeem revert (RedeemMoreThanMax / WithdrawMoreThanMax, or SameBlockRedeem inside _withdraw) for the rest of that block. An attacker who repeats this each block (1 wei + gas per block) keeps a chosen staker from exiting for as long as they pay gas; on Sepolia gas and tIMD are free. Impact is temporary denial of withdrawal, not loss of funds, and the production StakedIMD has the same behaviour (the brief says logic unchanged), so this is a design trade-off to record rather than a silent fix: any fix (e.g. only stamping when `to == by`, or a minimum mint for third-party recipients) changes the forked logic and needs that scope decision.","line":121,"path":"src/TestSIMD.sol","reproduction":"State: fresh deploy. (1) vm.prank(victim); sImd.deposit(100e18, victim) -> shares = 1e26; roll to next block; sImd.maxRedeem(victim) == 1e26. (2) vm.prank(attacker); sImd.mint(1, victim) costs previewMint(1) == 1 wei of IMD (asserted). Expected: a third party's dust mint does not restamp the victim (per the contract's stated goal). Actual: sImd.lastDepositBlock(victim) == block.number, sImd.maxRedeem(victim) == 0, and victim's sImd.redeem(1e26, victim, victim) reverts with RedeemMoreThanMax(). Second route, same block setup: vm.prank(attacker); stacker.credit(victim, 1) returns 1_000_000 shares and has the identical effect. Verified in a scratch Foundry test (both routes).","severity":"low","snippet":"        if (from == address(0)) {\n            lastDepositBlock[to] = block.number; // mint (deposit)","title":"Minimum-input boundary: a 1-wei mint/deposit/credit by any third party re-stamps a holder's one-block hold, blocking their withdraw/redeem for that block at ~zero cost"},{"citation":"resolved","description":"Math precision / numerical gap (precision x boundary x invariant). shares = floor(amount * (totalSupply + 1e6) / (totalAssets + 1)). The ZeroShares guard only catches the case where the floor is 0; it does not bound the rounding loss when the floor is 1 or a few shares. One faucet grant (10,000 tIMD = 1e22 wei) transferred directly to the empty vault sets totalAssets = 1e22 with totalSupply = 0, so one share is worth ~1e16 wei (0.01 tIMD). From then on every credit below 1e16 wei reverts (a 0.5% cashback on any trade under ~2 tIMD is silently dropped by an integrator's try/catch), and a credit of 1.99e16 wei mints exactly 1 share worth 1.00000099e16 wei: the trader loses 9.9e15 wei (49.7%) of the cashback to the virtual-share pool, yet the Stacked event, traderStacked, projectStacked, stackedBy and totalStacked all record 1.99e16, and the page's points (1 point per IMD in the event) credit the full amount. The per-credit loss is bounded by one share's value, (totalAssets+1)/(totalSupply+1e6), which is unbounded in absolute terms because anyone can raise totalAssets by donation (free tIMD on Sepolia). Nobody profits (the donor's tokens sit behind the virtual shares), so this is griefing/accounting drift, not theft, and it is inherent to the forked vault; a minimal Stacker-side mitigation that keeps the design is to check SIMD.convertToAssets(shares) against imdAmount with a tolerance (or document the threshold) so integrators and the page do not report cashback the trader did not effectively receive.","line":88,"path":"src/Stacker.sol","reproduction":"State: fresh deploy, nothing staked. (1) vm.prank(attacker); imd.faucet(); imd.transfer(address(sImd), 10_000e18) -> sImd.totalAssets() == 1e22, totalSupply == 0. (2) vm.prank(project); stacker.credit(victim, 9.95e15) -> reverts ZeroShares() (0.5% of a 1.99 tIMD trade). stacker.credit(victim, 1e16 - 1) -> reverts ZeroShares(). (3) vm.prank(project); stacker.credit(victim, 1.99e16) -> returns 1 share; sImd.convertToAssets(sImd.balanceOf(victim)) == 10000009899990100 (1.0000e16); lost 9899990100009900 wei (49.7%); stacker.traderStacked(victim) == 19900000000000000 and the Stacked event carries imd = 1.99e16. Expected: either the credit is refused or the recorded/credited amount reflects what the trader received; actual: ~half the cashback is lost and the full amount is recorded. Verified in a scratch Foundry test.","severity":"low","snippet":"        if (shares == 0) revert ZeroShares();","title":"Precision x boundary: after a donation to the (near-)empty vault, credits below one share's price revert and credits just above it lose up to ~50% to rounding while Stacked/traderStacked record the fu"},{"citation":"resolved","description":"Boundary (address parameter). The only trader sentinel rejected is address(0). Passing the Stacker's own address is accepted: the vault mints the shares to the Stacker, which has no owner, no sweep and never calls redeem, so those shares (and the IMD behind them) are stuck forever. This directly violates the brief's invariant 'Stacker holds 0 IMD and 0 sIMD', which the invariant suite cannot catch because its handler only uses six fixed trader addresses. The caller harms only their own funds, so impact is low, but the invariant the integrators and the page rely on ('the Stacker is a pass-through') becomes false for every observer, and the Stacker address then appears as a 'stacker' on the page's tables. Minimal fix preserving the design: revert when trader == address(this) (and, for the same reason, trader == address(SIMD), whose shares would be stuck behind the owner's rescueERC20).","line":83,"path":"src/Stacker.sol","reproduction":"State: fresh deploy; project holds 1_000_000e18 tIMD and approved the Stacker. vm.prank(project); stacker.credit(address(stacker), 1e18). Expected: revert (the Stacker must never hold sIMD). Actual: returns 1e24 shares, sImd.balanceOf(address(stacker)) == 1e24, stacker.traderStacked(address(stacker)) == 1e18, and no function on the Stacker can ever move those shares. Verified in a scratch Foundry test.","severity":"low","snippet":"        if (trader == address(0)) revert ZeroTrader();","title":"Sentinel/self-address boundary: credit(address(stacker), x) mints sIMD to the Stacker itself, permanently breaking the 'Stacker holds 0 sIMD' invariant with no recovery path"}],"hash":"b90bc686aea0cdd395e1970fec9ad6038a204871a27bc9c1cbd865b220208a57","nodeId":"6b7e8b53-9dc5-4ff8-97dd-3db9328a950d","outcome":"completed","summary":"The review is complete. The findings file is written and validated, the scratch tests are removed, and the tree is unchanged apart from the findings file.\n\n**Outcome:** the math in the three contracts is sound in its core. Share rounding directions, overflow safety, faucet boundaries, accumulator conservation and the `withdraw(maxWithdraw)` path all hold, the last one confirmed by a 2,000-run fuzz with donations. I found three low-severity defects, each reproduced in a Foundry scratch test with exact numbers, and no critical or high issue, so no proof files were attached.\n\n**Findings written to `.imd-findings.json`:**\n\n- **One-wei hold-stamp grief (TestSIMD, low).** The vault says a third party must not stamp a hold on someone else for free, but only the zero-amount case is excluded. A `mint(1, victim)` costs 1 wei of IMD, and `credit(victim, 1)` from any address does the same. Either one resets the victim's one-block hold, so their redeem reverts for that block. Repeated per block it is a cheap, temporary withdrawal denial. Production logic is unchanged by design, so I flagged it as a scope decision rather than a silent fix.\n- **Unbounded rounding loss after a donation (Stacker, low).** One faucet grant sent straight to the empty vault prices one share at 0.01 tIMD. Every cashback below that reverts with `ZeroShares`, and a credit of 1.99e16 wei mints one share worth 1.00e16, losing 49.7 percent. The event and all totals still record the full amount, so points overstate value delivered. Nobody profits, so it is griefing and accounting drift.\n- **`credit` accepts the Stacker's own address as trader (Stacker, low).** Shares are minted to the Stacker, which has no sweep and never redeems. This permanently breaks the \"holds 0 sIMD\" invariant the brief requires, and the invariant suite cannot catch it because its handler uses fixed trader addresses. Self-harm only, but a one-line check prevents it.\n\n**Coverage:** all 21 listed entry points have a row, plus three invariant rows. Static-analysis leads were checked and rejected: the strict equalities are correct sentinel and block comparisons, the reentrancy ordering is on trusted immutables with no callback, and the \"locks ether\" lead is wrong because `rescueETH` exists.\n\n**Not reached in depth:** the page's JavaScript was read for BigInt and decimals handling and looked correct, but I did not execute it. Ownership handover and permit paths were covered only as Solady defaults, since they fall outside the math area.","treeHash":null,"usage":{"cachedInputTokens":1193134,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":29610,"runtime":"claude","turns":33,"wallClockMs":412424}}],"verification":[{"checks":[{"durationMs":5061,"exitCode":0,"name":"build","output":"Compiling 47 files with Solc 0.8.26\nSolc 0.8.26 finished in 4.81s\nCompiler run successful!\nwarning[reentrancy-no-eth]: external call can be reentered before `_status` is updated\n   ╭▸ src/Stacker.sol:87:18\n   │\n87 │         shares = SIMD.deposit(imdAmount, trader);\n   │                  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n   │\n   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-no-eth\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n   ╭▸ src/TestIMD.sol:30:13\n   │\n30 │         if (block.timestamp < nextAt) revert FaucetCooldown(nextAt);\n   │             ━━━━━━━━━━━━━━━━━━━━━━━━\n   │\n   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n   ╭▸ src/Stacker.sol:97:9\n   │\n97 │         emit Stacked(msg.sender, trader, imdAmount, shares);\n   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n   │\n   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/TestSIMD.sol:153:9\n    │\n153 │         emit PauseSet(paused_);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/TestSIMD.sol:162:9\n    │\n162 │         emit EmergencyRescue(token, to, amount);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller\n    ╭▸ src/TestSIMD.sol:167:9\n    │\n167 │         to.safeTransferETH(amount);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/TestSIMD.sol:168:9\n    │\n168 │         emit EmergencyRescue(address(0), to, amount);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`\n   ╭▸ test/TestIMD.t.sol:35:26\n   │\n35 │         uint256 nextAt = block.timestamp + 24 hours;\n   │                          ━━━━━━━━━━━━━━━\n   ‡\n42 │         vm.warp(nextAt - 1);\n   │         ─────────────────── `vm.warp` changes this environment here\n   │\n   ├ help: capture it with `vm.getBlockTimestamp()` instead\n   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\nwarning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`\n   ╭▸ test/TestIMD.t.sol:52:17\n   │\n52 │         vm.warp(block.timestamp + 24 hours);\n   │         ────────━━━━━━━━━━━━━━━──────────── `vm.warp` changes this environment here\n   │\n   ├ help: capture it with `vm.getBlockTimestamp()` instead\n   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\nwarning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`\n   ╭▸ test/TestIMD.t.sol:73:26\n   │\n73 │         uint256 nextAt = block.timestamp + 24 hours;\n   │                          ━━━━━━━━━━━━━━━\n74 │         vm.warp(block.timestamp + wait);\n   │         ─────────────────────────────── `vm.warp` changes this environment here\n   │\n   ├ help: capture it with `vm.getBlockTimestamp()` instead\n   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\nwarning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`\n   ╭▸ test/TestSIMD.t.sol:76:17\n   │\n76 │         vm.roll(block.number + 1);\n   │         ────────━━━━━━━━━━━━───── `vm.roll` changes this environment here\n   │\n   ├ help: capture it with `vm.getBlockNumber()` instead\n   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\n","passed":true},{"durationMs":2147,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 6 tests for test/TestIMD.t.sol:TestIMDTest\n[PASS] testFuzz_faucet_cooldownBoundary(uint256) (runs: 256, μ: 148075, ~: 154267)\nLogs:\n  Bound result 245057\n\n[PASS] test_faucet_limitIsPerAddress() (gas: 208528)\n[PASS] test_faucet_mintsToCaller() (gas: 146070)\n[PASS] test_faucet_reopensAfter24h() (gas: 162835)\n[PASS] test_faucet_revertsWithinCooldown() (gas: 159256)\n[PASS] test_metadata_noPremint() (gas: 71443)\nSuite result: ok. 6 passed; 0 failed; 0 skipped; finished in 7.98ms (8.43ms CPU time)\n\nRan 8 tests for test/TestSIMD.t.sol:TestSIMDTest\n[PASS] test_constructor_rejectsZeroArgs() (gas: 4039)\n[PASS] test_deposit_thenRedeemNextBlock() (gas: 299884)\n[PASS] test_metadata_asset_owner() (gas: 81439)\n[PASS] test_pause_blocksDepositAndRedeem_unpauseRestores() (gas: 461743)\n[PASS] test_pause_onlyOwner() (gas: 89208)\n[PASS] test_renounce_blockedWhilePaused() (gas: 168263)\n[PASS] test_rescueERC20_onlyOwner() (gas: 235499)\n[PASS] test_transfer_carriesHoldForward() (gas: 256908)\nSuite result: ok. 8 passed; 0 failed; 0 skipped; finished in 16.81ms (1.95ms CPU time)\n\nRan 3 tests for test/Deployment.t.sol:DeploymentTest\n[PASS] test_deployScriptWiring() (gas: 6054393)\n[PASS] test_launchOrder_factoryIsNotOwner() (gas: 38462)\n[PASS] test_runtimeSizeAndNoEscapeOpcodes() (gas: 5846067)\nSuite result: ok. 3 passed; 0 failed; 0 skipped; finished in 27.19ms (20.19ms CPU time)\n\nRan 16 tests for test/Stacker.t.sol:StackerTest\n[PASS] testFuzz_credit(address,uint256) (runs: 256, μ: 428111, ~: 427939)\nLogs:\n  Bound result 153978972394429544605859\n\n[PASS] testFuzz_credit_shortAllowanceAlwaysReverts(uint256,uint256) (runs: 256, μ: 316846, ~: 318245)\nLogs:\n  Bound result 10754\n  Bound result 3882\n\n[PASS] testFuzz_credit_twoTradersProportional(uint256,uint256) (runs: 256, μ: 515620, ~: 515725)\nLogs:\n  Bound result 64684261010100888366906\n  Bound result 227467618851130258144276\n\n[PASS] test_constructor_revertsOnAssetMismatch() (gas: 8853)\n[PASS] test_constructor_wiringAndOneTimeApproval() (gas: 43859)\n[PASS] test_credit_manyProjectsAndTraders() (gas: 7571613)\n[PASS] test_credit_revertsOnShortAllowance() (gas: 327820)\n[PASS] test_credit_revertsOnShortBalance() (gas: 319949)\n[PASS] test_credit_revertsOnZeroShares() (gas: 337130)\n[PASS] test_credit_revertsWhenVaultPaused() (gas: 480665)\n[PASS] test_credit_sharesFollowVaultPrice() (gas: 647427)\n[PASS] test_credit_sharesGoOnlyToTrader() (gas: 395151)\n[PASS] test_credit_totalsAndEvent() (gas: 576139)\n[PASS] test_credit_zeroAmountReturnsZeroNoEvent() (gas: 92590)\n[PASS] test_credit_zeroTraderReverts() (gas: 66328)\n[PASS] test_integrator_tryCatchSurvivesPausedVault() (gas: 1070027)\nSuite result: ok. 16 passed; 0 failed; 0 skipped; finished in 42.31ms (101.90ms CPU time)\n\nRan 1 test for test/StackerInvariant.t.sol:StackerInvariantTest\n[PASS]\nStackerInvariantTest invariants:\n[PASS] invariant_stackerHoldsNothing\n[PASS] invariant_totalsConsistent\n[PASS] invariant_vaultConservation\n StackerInvariantTest invariants (runs: 64, calls: 2048, reverts: 0)\n\n╭----------------+-------------+-------+---------+----------╮\n| Contract       | Selector    | Calls | Reverts | Discards |\n+===========================================================+\n| StackerHandler | advance     | 418   | 0       | 0        |\n|----------------+-------------+-------+---------+----------|\n| StackerHandler | credit      | 396   | 0       | 0        |\n|----------------+-------------+-------+---------+----------|\n| StackerHandler | donate      | 416   | 0       | 0        |\n|----------------+-------------+-------+---------+----------|\n| StackerHandler | redeem      | 399   | 0       | 0        |\n|----------------+-------------+-------+---------+----------|\n| StackerHandler | togglePause | 419   | 0       | 0        |\n╰----------------+-------------+-------+---------+----------╯\n\nLogs:\n  Bound result 15473\n  Bound result 1000000000000000000000000\n  Bound result 6627\n  Bound result 32982622315876347472087392\n  Bound result 244\n  Bound result 4618\n  Bound result 86400\n  Bound result 206321610455596101381\n  Bound result 2\n  Bound result 10248\n  Bound result 925\n  Bound result 737698949298796564916\n  Bound result 38493\n  Bound result 7\n  Bound result 10000000000000000000\n  Bound result 8\n  Bound result 6546446175\n  Bound result 10\n  Bound result 645001725190712165667\n  Bound result 1000\n  Bound result 8\n  Bound result 2984\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.96s (1.95s CPU time)\n\nRan 5 test suites in 1.96s (2.05s CPU time): 34 tests passed, 0 failed, 0 skipped (34 total tests)\n","passed":true},{"durationMs":83,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"Stacker.credit(address,uint256)\",\"TestIMD.approve(address,uint256)\",\"TestIMD.faucet()\",\"TestIMD.transfer(address,uint256)\",\"TestIMD.transferFrom(address,address,uint256)\",\"TestSIMD.approve(address,uint256)\",\"TestSIMD.cancelOwnershipHandover()\",\"TestSIMD.completeOwnershipHandover(address)\",\"TestSIMD.deposit(uint256,address)\",\"TestSIMD.mint(uint256,address)\",\"TestSIMD.permit(address,address,uint256,uint256,uint8,bytes32,bytes32)\",\"TestSIMD.redeem(uint256,address,address)\",\"TestSIMD.renounceOwnership()\",\"TestSIMD.requestOwnershipHandover()\",\"TestSIMD.rescueERC20(address,address,uint256)\",\"TestSIMD.rescueETH(address,uint256)\",\"TestSIMD.setPaused(bool)\",\"TestSIMD.transfer(address,uint256)\",\"TestSIMD.transferFrom(address,address,uint256)\",\"TestSIMD.transferOwnership(address)\",\"TestSIMD.withdraw(uint256,address,address)\"],\"files\":{\".gitignore\":4,\"README.md\":237,\"foundry.toml\":23,\"remappings.txt\":3,\"script/Deploy.s.sol\":34,\"site/app.js\":386,\"site/config.json\":21,\"site/index.html\":110,\"site/projects.json\":1,\"site/style.css\":110,\"src/Stacker.sol\":99,\"src/TestIMD.sol\":41,\"src/TestSIMD.sol\":178,\"test/Base.t.sol\":33,\"test/Deployment.t.sol\":66,\"test/Stacker.t.sol\":344,\"test/StackerInvariant.t.sol\":162,\"test/TestIMD.t.sol\":85,\"test/TestSIMD.t.sol\":140},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true},{"durationMs":4422,"exitCode":0,"name":"slither","output":"[medium/high] incorrect-equality at src/TestSIMD.sol:104: TestSIMD._withdraw(address,address,address,uint256,uint256) (src/TestSIMD.sol#104-111) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/TestSIMD.sol:145: TestSIMD.maxRedeem(address) (src/TestSIMD.sol#145-148) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/TestIMD.sol:37: TestIMD.nextFaucetAt(address) (src/TestIMD.sol#37-40) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/TestSIMD.sol:140: TestSIMD.maxWithdraw(address) (src/TestSIMD.sol#140-143) uses a dangerous strict equality:\n[low/medium] reentrancy-benign at src/Stacker.sol:78: Reentrancy in Stacker.credit(address,uint256) (src/Stacker.sol#78-98):\n[low/medium] timestamp at src/TestIMD.sol:37: TestIMD.nextFaucetAt(address) (src/TestIMD.sol#37-40) uses timestamp for comparisons\n[low/medium] timestamp at src/TestIMD.sol:28: TestIMD.faucet() (src/TestIMD.sol#28-34) uses timestamp for comparisons","passed":true},{"durationMs":838,"exitCode":0,"name":"aderyn","output":"[high] contract-locks-ether at src/TestSIMD.sol:36: Contract locks Ether without a withdraw function\n[high] reentrancy-state-change at src/Stacker.sol:63: Reentrancy: State change after external call (2 places)\n[low] centralization-risk at src/TestSIMD.sol:36: Centralization Risk (5 places)","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"116af9515043a5514dd6b5e89bf22b1daff56bf88195534e368c50dc7db3dcc9","verifiedTreeHash":"86d98f69c318a3f25b0eb5a73a589ae52cba7c74","verifierVersion":"0.1.0+5d71dfd4"},{"checks":[{"durationMs":2081,"exitCode":0,"name":"build","output":"Compiling 47 files with Solc 0.8.26\nSolc 0.8.26 finished in 1.97s\nCompiler run successful!\nwarning[reentrancy-no-eth]: external call can be reentered before `_status` is updated\n   ╭▸ src/Stacker.sol:87:18\n   │\n87 │         shares = SIMD.deposit(imdAmount, trader);\n   │                  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n   │\n   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-no-eth\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n   ╭▸ src/TestIMD.sol:30:13\n   │\n30 │         if (block.timestamp < nextAt) revert FaucetCooldown(nextAt);\n   │             ━━━━━━━━━━━━━━━━━━━━━━━━\n   │\n   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n   ╭▸ src/Stacker.sol:97:9\n   │\n97 │         emit Stacked(msg.sender, trader, imdAmount, shares);\n   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n   │\n   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/TestSIMD.sol:153:9\n    │\n153 │         emit PauseSet(paused_);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/TestSIMD.sol:162:9\n    │\n162 │         emit EmergencyRescue(token, to, amount);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller\n    ╭▸ src/TestSIMD.sol:167:9\n    │\n167 │         to.safeTransferETH(amount);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/TestSIMD.sol:168:9\n    │\n168 │         emit EmergencyRescue(address(0), to, amount);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`\n   ╭▸ test/TestIMD.t.sol:35:26\n   │\n35 │         uint256 nextAt = block.timestamp + 24 hours;\n   │                          ━━━━━━━━━━━━━━━\n   ‡\n42 │         vm.warp(nextAt - 1);\n   │         ─────────────────── `vm.warp` changes this environment here\n   │\n   ├ help: capture it with `vm.getBlockTimestamp()` instead\n   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\nwarning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`\n   ╭▸ test/TestIMD.t.sol:52:17\n   │\n52 │         vm.warp(block.timestamp + 24 hours);\n   │         ────────━━━━━━━━━━━━━━━──────────── `vm.warp` changes this environment here\n   │\n   ├ help: capture it with `vm.getBlockTimestamp()` instead\n   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\nwarning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`\n   ╭▸ test/TestIMD.t.sol:73:26\n   │\n73 │         uint256 nextAt = block.timestamp + 24 hours;\n   │                          ━━━━━━━━━━━━━━━\n74 │         vm.warp(block.timestamp + wait);\n   │         ─────────────────────────────── `vm.warp` changes this environment here\n   │\n   ├ help: capture it with `vm.getBlockTimestamp()` instead\n   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\nwarning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`\n   ╭▸ test/TestSIMD.t.sol:76:17\n   │\n76 │         vm.roll(block.number + 1);\n   │         ────────━━━━━━━━━━━━───── `vm.roll` changes this environment here\n   │\n   ├ help: capture it with `vm.getBlockNumber()` instead\n   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\n","passed":true},{"durationMs":1266,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 6 tests for test/TestIMD.t.sol:TestIMDTest\n[PASS] testFuzz_faucet_cooldownBoundary(uint256) (runs: 256, μ: 148347, ~: 154267)\nLogs:\n  Bound result 341808\n\n[PASS] test_faucet_limitIsPerAddress() (gas: 208528)\n[PASS] test_faucet_mintsToCaller() (gas: 146070)\n[PASS] test_faucet_reopensAfter24h() (gas: 162835)\n[PASS] test_faucet_revertsWithinCooldown() (gas: 159256)\n[PASS] test_metadata_noPremint() (gas: 71443)\nSuite result: ok. 6 passed; 0 failed; 0 skipped; finished in 5.30ms (5.29ms CPU time)\n\nRan 3 tests for test/Deployment.t.sol:DeploymentTest\n[PASS] test_deployScriptWiring() (gas: 6054393)\n[PASS] test_launchOrder_factoryIsNotOwner() (gas: 38462)\n[PASS] test_runtimeSizeAndNoEscapeOpcodes() (gas: 5846067)\nSuite result: ok. 3 passed; 0 failed; 0 skipped; finished in 11.69ms (10.70ms CPU time)\n\nRan 8 tests for test/TestSIMD.t.sol:TestSIMDTest\n[PASS] test_constructor_rejectsZeroArgs() (gas: 4039)\n[PASS] test_deposit_thenRedeemNextBlock() (gas: 299884)\n[PASS] test_metadata_asset_owner() (gas: 81439)\n[PASS] test_pause_blocksDepositAndRedeem_unpauseRestores() (gas: 461743)\n[PASS] test_pause_onlyOwner() (gas: 89208)\n[PASS] test_renounce_blockedWhilePaused() (gas: 168263)\n[PASS] test_rescueERC20_onlyOwner() (gas: 235499)\n[PASS] test_transfer_carriesHoldForward() (gas: 256908)\nSuite result: ok. 8 passed; 0 failed; 0 skipped; finished in 19.62ms (1.13ms CPU time)\n\nRan 16 tests for test/Stacker.t.sol:StackerTest\n[PASS] testFuzz_credit(address,uint256) (runs: 256, μ: 428064, ~: 427939)\nLogs:\n  Bound result 230278489582342250818813\n\n[PASS] testFuzz_credit_shortAllowanceAlwaysReverts(uint256,uint256) (runs: 256, μ: 317061, ~: 318299)\nLogs:\n  Bound result 6000000000000000000\n  Bound result 21815\n\n[PASS] testFuzz_credit_twoTradersProportional(uint256,uint256) (runs: 256, μ: 515682, ~: 515731)\nLogs:\n  Bound result 1288\n  Bound result 500000000000000000000000\n\n[PASS] test_constructor_revertsOnAssetMismatch() (gas: 8853)\n[PASS] test_constructor_wiringAndOneTimeApproval() (gas: 43859)\n[PASS] test_credit_manyProjectsAndTraders() (gas: 7571613)\n[PASS] test_credit_revertsOnShortAllowance() (gas: 327820)\n[PASS] test_credit_revertsOnShortBalance() (gas: 319949)\n[PASS] test_credit_revertsOnZeroShares() (gas: 337130)\n[PASS] test_credit_revertsWhenVaultPaused() (gas: 480665)\n[PASS] test_credit_sharesFollowVaultPrice() (gas: 647427)\n[PASS] test_credit_sharesGoOnlyToTrader() (gas: 395151)\n[PASS] test_credit_totalsAndEvent() (gas: 576139)\n[PASS] test_credit_zeroAmountReturnsZeroNoEvent() (gas: 92590)\n[PASS] test_credit_zeroTraderReverts() (gas: 66328)\n[PASS] test_integrator_tryCatchSurvivesPausedVault() (gas: 1070027)\nSuite result: ok. 16 passed; 0 failed; 0 skipped; finished in 24.95ms (64.33ms CPU time)\n\nRan 1 test for test/StackerInvariant.t.sol:StackerInvariantTest\n[PASS]\nStackerInvariantTest invariants:\n[PASS] invariant_stackerHoldsNothing\n[PASS] invariant_totalsConsistent\n[PASS] invariant_vaultConservation\n StackerInvariantTest invariants (runs: 64, calls: 2048, reverts: 0)\n\n╭----------------+-------------+-------+---------+----------╮\n| Contract       | Selector    | Calls | Reverts | Discards |\n+===========================================================+\n| StackerHandler | advance     | 412   | 0       | 0        |\n|----------------+-------------+-------+---------+----------|\n| StackerHandler | credit      | 394   | 0       | 0        |\n|----------------+-------------+-------+---------+----------|\n| StackerHandler | donate      | 437   | 0       | 0        |\n|----------------+-------------+-------+---------+----------|\n| StackerHandler | redeem      | 406   | 0       | 0        |\n|----------------+-------------+-------+---------+----------|\n| StackerHandler | togglePause | 399   | 0       | 0        |\n╰----------------+-------------+-------+---------+----------╯\n\nLogs:\n  Bound result 3\n  Bound result 8\n  Bound result 999999999999999999981\n  Bound result 10000000000000000000\n  Bound result 892\n  Bound result 222793672808980388581658\n  Bound result 9\n  Bound result 5000000000000000000\n  Bound result 999999999999999999998\n  Bound result 10\n  Bound result 48\n  Bound result 7829\n  Bound result 42000000000000000000\n  Bound result 7\n  Bound result 864000\n  Bound result 18\n  Bound result 2\n  Bound result 3\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.19s (1.18s CPU time)\n\nRan 5 test suites in 1.19s (1.25s CPU time): 34 tests passed, 0 failed, 0 skipped (34 total tests)\n","passed":true},{"durationMs":42,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"Stacker.credit(address,uint256)\",\"TestIMD.approve(address,uint256)\",\"TestIMD.faucet()\",\"TestIMD.transfer(address,uint256)\",\"TestIMD.transferFrom(address,address,uint256)\",\"TestSIMD.approve(address,uint256)\",\"TestSIMD.cancelOwnershipHandover()\",\"TestSIMD.completeOwnershipHandover(address)\",\"TestSIMD.deposit(uint256,address)\",\"TestSIMD.mint(uint256,address)\",\"TestSIMD.permit(address,address,uint256,uint256,uint8,bytes32,bytes32)\",\"TestSIMD.redeem(uint256,address,address)\",\"TestSIMD.renounceOwnership()\",\"TestSIMD.requestOwnershipHandover()\",\"TestSIMD.rescueERC20(address,address,uint256)\",\"TestSIMD.rescueETH(address,uint256)\",\"TestSIMD.setPaused(bool)\",\"TestSIMD.transfer(address,uint256)\",\"TestSIMD.transferFrom(address,address,uint256)\",\"TestSIMD.transferOwnership(address)\",\"TestSIMD.withdraw(uint256,address,address)\"],\"files\":{\".gitignore\":4,\"README.md\":237,\"foundry.toml\":23,\"launch.json\":24,\"remappings.txt\":3,\"script/Deploy.s.sol\":34,\"site/app.js\":386,\"site/config.json\":21,\"site/index.html\":110,\"site/projects.json\":1,\"site/style.css\":110,\"src/Stacker.sol\":99,\"src/TestIMD.sol\":41,\"src/TestSIMD.sol\":178,\"test/Base.t.sol\":33,\"test/Deployment.t.sol\":66,\"test/Stacker.t.sol\":344,\"test/StackerInvariant.t.sol\":162,\"test/TestIMD.t.sol\":85,\"test/TestSIMD.t.sol\":140},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"488a013b8bdcbbb008cff80a9b85dbd66e63e511b055f0ca93bb777cade3e3f9","verifiedTreeHash":"22def3c139f40079344fb4dabb788d6003e37575","verifierVersion":"0.1.0+5d71dfd4"},{"checks":[{"durationMs":3568,"exitCode":0,"name":"build","output":"Compiling 51 files with Solc 0.8.26\nSolc 0.8.26 finished in 3.42s\nCompiler run successful!\nwarning[reentrancy-no-eth]: external call can be reentered before `_status` is updated\n   ╭▸ src/Stacker.sol:87:18\n   │\n87 │         shares = SIMD.deposit(imdAmount, trader);\n   │                  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n   │\n   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-no-eth\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n   ╭▸ src/TestIMD.sol:30:13\n   │\n30 │         if (block.timestamp < nextAt) revert FaucetCooldown(nextAt);\n   │             ━━━━━━━━━━━━━━━━━━━━━━━━\n   │\n   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n   ╭▸ src/Stacker.sol:97:9\n   │\n97 │         emit Stacked(msg.sender, trader, imdAmount, shares);\n   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n   │\n   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/TestSIMD.sol:153:9\n    │\n153 │         emit PauseSet(paused_);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/TestSIMD.sol:162:9\n    │\n162 │         emit EmergencyRescue(token, to, amount);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller\n    ╭▸ src/TestSIMD.sol:167:9\n    │\n167 │         to.safeTransferETH(amount);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/TestSIMD.sol:168:9\n    │\n168 │         emit EmergencyRescue(address(0), to, amount);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`\n    ╭▸ test/StackerEdges.t.sol:387:21\n    │\n387 │             vm.roll(block.number + 1);\n    │             ────────━━━━━━━━━━━━───── `vm.roll` changes this environment here\n    │\n    ├ help: capture it with `vm.getBlockNumber()` instead\n    ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\nwarning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`\n   ╭▸ test/TestIMD.t.sol:35:26\n   │\n35 │         uint256 nextAt = block.timestamp + 24 hours;\n   │                          ━━━━━━━━━━━━━━━\n   ‡\n42 │         vm.warp(nextAt - 1);\n   │         ─────────────────── `vm.warp` changes this environment here\n   │\n   ├ help: capture it with `vm.getBlockTimestamp()` instead\n   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\nwarning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`\n   ╭▸ test/TestIMD.t.sol:52:17\n   │\n52 │         vm.warp(block.timestamp + 24 hours);\n   │         ────────━━━━━━━━━━━━━━━──────────── `vm.warp` changes this environment here\n   │\n   ├ help: capture it with `vm.getBlockTimestamp()` instead\n   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\nwarning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`\n   ╭▸ test/TestIMD.t.sol:73:26\n   │\n73 │         uint256 nextAt = block.timestamp + 24 hours;\n   │                          ━━━━━━━━━━━━━━━\n74 │         vm.warp(block.timestamp + wait);\n   │         ─────────────────────────────── `vm.warp` changes this environment here\n   │\n   ├ help: capture it with `vm.getBlockTimestamp()` instead\n   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\nwarning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`\n   ╭▸ test/TestIMDEdges.t.sol:81:17\n   │\n81 │         vm.warp(block.timestamp + wait);\n   │         ────────━━━━━━━━━━━━━━━──────── `vm.warp` changes this environment here\n   │\n   ├ help: capture it with `vm.getBlockTimestamp()` instead\n   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\nwarning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`\n   ╭▸ test/TestSIMD.t.sol:76:17\n   │\n76 │         vm.roll(block.number + 1);\n   │         ────────━━━━━━━━━━━━───── `vm.roll` changes this environment here\n   │\n   ├ help: capture it with `vm.getBlockNumber()` instead\n   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\nwarning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`\n    ╭▸ test/TestSIMDProperties.t.sol:206:17\n    │\n206 │         vm.roll(block.number + 1);\n    │         ────────━━━━━━━━━━━━───── `vm.roll` changes this environment here\n    │\n    ├ help: capture it with `vm.getBlockNumber()` instead\n    ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\nwarning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`\n    ╭▸ test/TestSIMDProperties.t.sol:242:17\n    │\n242 │         vm.roll(block.number + 1);\n    │         ────────━━━━━━━━━━━━───── `vm.roll` changes this environment here\n    │\n    ├ help: capture it with `vm.getBlockNumber()` instead\n    ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\nwarning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`\n    ╭▸ test/TestSIMDProperties.t.sol:258:17\n    │\n258 │         vm.roll(block.number + 1);\n    │         ────────━━━━━━━━━━━━───── `vm.roll` changes this environment here\n    │\n    ├ help: capture it with `vm.getBlockNumber()` instead\n    ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation\n\n","passed":true},{"durationMs":6222,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 6 tests for test/TestIMDEdges.t.sol:TestIMDEdgesTest\n[PASS] testFuzz_faucet_cooldownIsPerAddressIndependent(uint256) (runs: 1000, μ: 202250, ~: 202472)\nLogs:\n  Bound result 3200\n\n[PASS] testFuzz_faucet_manyAddressesOnceEach(uint8) (runs: 1000, μ: 1034609, ~: 638542)\nLogs:\n  Bound result 5\n\n[PASS] test_faucet_doesNotStartAtTimestampZero() (gas: 140236)\n[PASS] test_faucet_exactlyAt24hSucceeds() (gas: 150064)\n[PASS] test_faucet_failedCallLeavesNoTrace() (gas: 169401)\n[PASS] test_faucet_fromContract() (gas: 280546)\nSuite result: ok. 6 passed; 0 failed; 0 skipped; finished in 246.90ms (452.69ms CPU time)\n\nRan 6 tests for test/TestIMD.t.sol:TestIMDTest\n[PASS] testFuzz_faucet_cooldownBoundary(uint256) (runs: 256, μ: 147733, ~: 154267)\nLogs:\n  Bound result 714277\n\n[PASS] test_faucet_limitIsPerAddress() (gas: 208528)\n[PASS] test_faucet_mintsToCaller() (gas: 146070)\n[PASS] test_faucet_reopensAfter24h() (gas: 162835)\n[PASS] test_faucet_revertsWithinCooldown() (gas: 159256)\n[PASS] test_metadata_noPremint() (gas: 71443)\nSuite result: ok. 6 passed; 0 failed; 0 skipped; finished in 269.82ms (270.72ms CPU time)\n\nRan 8 tests for test/TestSIMD.t.sol:TestSIMDTest\n[PASS] test_constructor_rejectsZeroArgs() (gas: 4039)\n[PASS] test_deposit_thenRedeemNextBlock() (gas: 299884)\n[PASS] test_metadata_asset_owner() (gas: 81439)\n[PASS] test_pause_blocksDepositAndRedeem_unpauseRestores() (gas: 461743)\n[PASS] test_pause_onlyOwner() (gas: 89208)\n[PASS] test_renounce_blockedWhilePaused() (gas: 168263)\n[PASS] test_rescueERC20_onlyOwner() (gas: 235499)\n[PASS] test_transfer_carriesHoldForward() (gas: 256908)\nSuite result: ok. 8 passed; 0 failed; 0 skipped; finished in 284.27ms (3.49ms CPU time)\n\nRan 3 tests for test/Deployment.t.sol:DeploymentTest\n[PASS] test_deployScriptWiring() (gas: 6054393)\n[PASS] test_launchOrder_factoryIsNotOwner() (gas: 38462)\n[PASS] test_runtimeSizeAndNoEscapeOpcodes() (gas: 5846067)\nSuite result: ok. 3 passed; 0 failed; 0 skipped; finished in 284.45ms (26.88ms CPU time)\n\nRan 19 tests for test/StackerEdges.t.sol:StackerEdgesTest\n[PASS] testFuzz_credit_pausedAlwaysRevertsAndMovesNothing(address,uint256) (runs: 1000, μ: 192084, ~: 191867)\nLogs:\n  Bound result 566286863523773954249005\n\n[PASS] testFuzz_credit_repeatedCyclesExtractNoDust(uint256,uint8) (runs: 1000, μ: 1662179, ~: 1495803)\nLogs:\n  Bound result 11\n\n[PASS] testFuzz_credit_shortBalanceAlwaysReverts(uint256,uint256) (runs: 1000, μ: 338109, ~: 338085)\nLogs:\n  Bound result 24171\n  Bound result 0\n\n[PASS] testFuzz_credit_thenRedeem_noFreeProfit(uint256,uint256,uint256) (runs: 1000, μ: 817451, ~: 847269)\nLogs:\n  Bound result 5435493813\n  Bound result 90504214816743296444459\n  Bound result 1\n\n[PASS] testFuzz_credit_traderValueWithinOneShareOfAmount(uint256,uint256,uint256) (runs: 1000, μ: 761603, ~: 774367)\nLogs:\n  Bound result 1048557580339317989705\n  Bound result 99999999999999999999998\n  Bound result 98387764208043887368156\n\n[PASS] testFuzz_credit_zeroSharesGuardTracksVaultPreview(uint256,uint256) (runs: 1000, μ: 495476, ~: 531281)\nLogs:\n  Bound result 9999999999999999501\n  Bound result 999999999999999999\n\n[PASS] test_credit_allowanceConsumedExactly_secondCallReverts() (gas: 634522)\n[PASS] test_credit_astronomicalButRepresentable() (gas: 381790)\n[PASS] test_credit_balanceConsumedExactly_secondCallReverts() (gas: 609680)\n[PASS] test_credit_eventMatchesReturnAndBalanceDeltaAtNonUnitPrice() (gas: 748258)\n[PASS] test_credit_maxUint_revertsCleanlyInVaultMath() (gas: 200337)\n[PASS] test_credit_oneWei() (gas: 382524)\n[PASS] test_credit_pausedBeatsShortAllowance_nothingPulled() (gas: 420347)\n[PASS] test_credit_projectIsTrader_countedOnBothSides() (gas: 385175)\n[PASS] test_credit_reentrancyFromVaultIsBlocked() (gas: 753324)\n[PASS] test_credit_zeroAmountWhilePaused_returnsZeroWithoutTouchingVault() (gas: 91092)\n[PASS] test_credit_zeroTraderWhilePaused_stillZeroTrader() (gas: 89556)\n[PASS] test_credit_zeroTraderWithShortAllowance_stillZeroTrader() (gas: 285188)\n[PASS] test_strayImdInStacker_isNeverSpentByCredit() (gas: 543080)\nSuite result: ok. 19 passed; 0 failed; 0 skipped; finished in 285.44ms (1.58s CPU time)\n\nRan 15 tests for test/TestSIMDProperties.t.sol:TestSIMDPropertiesTest\n[PASS] testFuzz_conversionIsMonotonic(uint256,uint256,uint256) (runs: 1000, μ: 670271, ~: 670071)\nLogs:\n  Bound result 48557580339317989704\n  Bound result 999999999999999999999998\n  Bound result 788748506420104393643894\n\n[PASS] testFuzz_convertRoundTripNeverGains(uint256,uint256,uint256) (runs: 1000, μ: 658022, ~: 657887)\nLogs:\n  Bound result 5435493813\n  Bound result 652522158241283319417794\n  Bound result 1\n\n[PASS] testFuzz_depositWithdrawRoundTrip(uint256,uint256,uint256) (runs: 1000, μ: 1037048, ~: 1051700)\nLogs:\n  Bound result 18872\n  Bound result 33866\n  Bound result 19971\n\n[PASS] testFuzz_pauseFlagMatchesMaxViews(bool,uint256) (runs: 1000, μ: 455713, ~: 448430)\nLogs:\n  Bound result 3\n\n[PASS] testFuzz_previewDepositEqualsMinted(uint256,uint256,uint256) (runs: 1000, μ: 923882, ~: 930892)\nLogs:\n  Bound result 1000000000000000000000000\n  Bound result 5000000000000000000\n  Bound result 19284\n\n[PASS] testFuzz_redeemAllRoundTrip_twoStakers(uint256,uint256,uint256) (runs: 1000, μ: 1051239, ~: 1067249)\nLogs:\n  Bound result 1048557580339317989705\n  Bound result 999999999999999999999998\n  Bound result 788748506420104393643894\n\n[PASS] test_hold_burnDoesNotStamp() (gas: 513063)\n[PASS] test_hold_transferFromOlderHolderDoesNotLowerRecipientHold() (gas: 765047)\n[PASS] test_hold_zeroAmountDepositDoesNotStampVictim() (gas: 543270)\n[PASS] test_ownershipHandover_twoStep_onlyOwnerCompletes() (gas: 119702)\n[PASS] test_pause_everyFunnelAndEveryMaxView() (gas: 697236)\n[PASS] test_renounce_isPermanent_andPauseCannotReturn() (gas: 127437)\n[PASS] test_rescueETH_onlyOwner_sweepsForcedEth() (gas: 71715)\n[PASS] test_rescue_canSweepSharesHeldByTheVaultItself() (gas: 451245)\n[PASS] test_rescue_sweepsStakedImd_andDepositsStillWork() (gas: 870014)\nSuite result: ok. 15 passed; 0 failed; 0 skipped; finished in 285.44ms (1.65s CPU time)\n\nRan 16 tests for test/Stacker.t.sol:StackerTest\n[PASS] testFuzz_credit(address,uint256) (runs: 256, μ: 428061, ~: 427915)\nLogs:\n  Bound result 214680424038909401835660\n\n[PASS] testFuzz_credit_shortAllowanceAlwaysReverts(uint256,uint256) (runs: 256, μ: 317270, ~: 318311)\nLogs:\n  Bound result 999990000000000000000000\n  Bound result 100000000000000000000\n\n[PASS] testFuzz_credit_twoTradersProportional(uint256,uint256) (runs: 256, μ: 515654, ~: 515737)\nLogs:\n  Bound result 100000000000000000000\n  Bound result 499990000000000000000000\n\n[PASS] test_constructor_revertsOnAssetMismatch() (gas: 8853)\n[PASS] test_constructor_wiringAndOneTimeApproval() (gas: 43859)\n[PASS] test_credit_manyProjectsAndTraders() (gas: 7571613)\n[PASS] test_credit_revertsOnShortAllowance() (gas: 327820)\n[PASS] test_credit_revertsOnShortBalance() (gas: 319949)\n[PASS] test_credit_revertsOnZeroShares() (gas: 337130)\n[PASS] test_credit_revertsWhenVaultPaused() (gas: 480665)\n[PASS] test_credit_sharesFollowVaultPrice() (gas: 647427)\n[PASS] test_credit_sharesGoOnlyToTrader() (gas: 395151)\n[PASS] test_credit_totalsAndEvent() (gas: 576139)\n[PASS] test_credit_zeroAmountReturnsZeroNoEvent() (gas: 92590)\n[PASS] test_credit_zeroTraderReverts() (gas: 66328)\n[PASS] test_integrator_tryCatchSurvivesPausedVault() (gas: 1070027)\nSuite result: ok. 16 passed; 0 failed; 0 skipped; finished in 285.54ms (271.95ms CPU time)\n\nRan 1 test for test/StackerInvariant.t.sol:StackerInvariantTest\n[PASS]\nStackerInvariantTest invariants:\n[PASS] invariant_stackerHoldsNothing\n[PASS] invariant_totalsConsistent\n[PASS] invariant_vaultConservation\n StackerInvariantTest invariants (runs: 64, calls: 2048, reverts: 0)\n\n╭----------------+-------------+-------+---------+----------╮\n| Contract       | Selector    | Calls | Reverts | Discards |\n+===========================================================+\n| StackerHandler | advance     | 412   | 0       | 0        |\n|----------------+-------------+-------+---------+----------|\n| StackerHandler | credit      | 380   | 0       | 0        |\n|----------------+-------------+-------+---------+----------|\n| StackerHandler | donate      | 416   | 0       | 0        |\n|----------------+-------------+-------+---------+----------|\n| StackerHandler | redeem      | 415   | 0       | 0        |\n|----------------+-------------+-------+---------+----------|\n| StackerHandler | togglePause | 425   | 0       | 0        |\n╰----------------+-------------+-------+---------+----------╯\n\nLogs:\n  Bound result 2691\n  Bound result 15675\n  Bound result 14401\n  Bound result 687318922514747724353\n  Bound result 12127\n  Bound result 7337\n  Bound result 3000000000000000000\n  Bound result 3465988982\n  Bound result 9\n  Bound result 695206224643648457568313\n  Bound result 5947\n  Bound result 10\n  Bound result 5638\n  Bound result 117056706116819704312\n  Bound result 999999999999999999999997\n  Bound result 5000000000000000000000\n  Bound result 10\n  Bound result 3600\n  Bound result 7\n  Bound result 705225459105957904421\n  Bound result 9\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.29s (1.29s CPU time)\n\nRan 2 tests for test/StackerInvariantExtended.t.sol:StackerInvariantExtendedTest\n[PASS]\nStackerInvariantExtendedTest invariants:\n[PASS] invariant_imdSupplyConservation\n[PASS] invariant_pauseAndOwnershipStateMachine\n[PASS] invariant_stackerHoldsNothing\n[PASS] invariant_stackerTotals\n[PASS] invariant_vaultConservation\n[PASS] invariant_vaultSolvent\n StackerInvariantExtendedTest invariants (runs: 128, calls: 6144, reverts: 0)\n\n╭-----------------+----------------+-------+---------+----------╮\n| Contract        | Selector       | Calls | Reverts | Discards |\n+===============================================================+\n| ExtendedHandler | advance        | 479   | 0       | 0        |\n|-----------------+----------------+-------+---------+----------|\n| ExtendedHandler | credit         | 448   | 0       | 0        |\n|-----------------+----------------+-------+---------+----------|\n| ExtendedHandler | creditTiny     | 463   | 0       | 0        |\n|-----------------+----------------+-------+---------+----------|\n| ExtendedHandler | depositDirect  | 465   | 0       | 0        |\n|-----------------+----------------+-------+---------+----------|\n| ExtendedHandler | donate         | 483   | 0       | 0        |\n|-----------------+----------------+-------+---------+----------|\n| ExtendedHandler | faucet         | 462   | 0       | 0        |\n|-----------------+----------------+-------+---------+----------|\n| ExtendedHandler | redeem         | 481   | 0       | 0        |\n|-----------------+----------------+-------+---------+----------|\n| ExtendedHandler | renounce       | 466   | 0       | 0        |\n|-----------------+----------------+-------+---------+----------|\n| ExtendedHandler | rescue         | 492   | 0       | 0        |\n|-----------------+----------------+-------+---------+----------|\n| ExtendedHandler | setAllowance   | 489   | 0       | 0        |\n|-----------------+----------------+-------+---------+----------|\n| ExtendedHandler | togglePause    | 500   | 0       | 0        |\n|-----------------+----------------+-------+---------+----------|\n| ExtendedHandler | transferShares | 443   | 0       | 0        |\n|-----------------+----------------+-------+---------+----------|\n| ExtendedHandler | withdraw       | 473   | 0       | 0        |\n╰-----------------+----------------+-------+---------+----------╯\n\nLogs:\n  Bound result 5206\n  Bound result 9794\n  Bound result 1000000000000000000\n  Bound result 3600\n  Bound result 3959986388524215550789\n  Bound result 10\n  Bound result 7818052901187502203985\n  Bound result 7\n  Bound result 443\n  Bound result 39245116598307144099399\n  Bound result 10803\n  Bound result 796\n  Bound result 4000000000000000000\n  Bound result 999941\n  Bound result 7300576799377637140829\n  Bound result 0\n  Bound result 276738\n  Bound result 1000000000000000000000\n  Bound result 86401\n  Bound result 42000000000000000000\n  Bound result 12508890658619950758891\n  Bound result 4495\n  Bound result 908\n  Bound result 7\n\n[PASS] test_handlerReachesEveryCreditOutcome() (gas: 5719284)\nLogs:\n  Bound result 1000000000000000000\n  Bound result 5\n  Bound result 100000000000000000000\n  Bound result 1\n  Bound result 50000000000000000000\n  Bound result 7\n  Bound result 24000000000000000000000\n  Bound result 100000000000000000000\n  Bound result 10000000000000000000\n  Bound result 1000000000000000000000\n  Bound result 372757101\n  Bound result 684498244\n  Bound result 1000000000000000000\n  Bound result 2949193105425070115\n\nSuite result: ok. 2 passed; 0 failed; 0 skipped; finished in 6.13s (6.12s CPU time)\n\nRan 9 test suites in 6.13s (9.36s CPU time): 76 tests passed, 0 failed, 0 skipped (76 total tests)\n","passed":true},{"durationMs":41,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"Stacker.credit(address,uint256)\",\"TestIMD.approve(address,uint256)\",\"TestIMD.faucet()\",\"TestIMD.transfer(address,uint256)\",\"TestIMD.transferFrom(address,address,uint256)\",\"TestSIMD.approve(address,uint256)\",\"TestSIMD.cancelOwnershipHandover()\",\"TestSIMD.completeOwnershipHandover(address)\",\"TestSIMD.deposit(uint256,address)\",\"TestSIMD.mint(uint256,address)\",\"TestSIMD.permit(address,address,uint256,uint256,uint8,bytes32,bytes32)\",\"TestSIMD.redeem(uint256,address,address)\",\"TestSIMD.renounceOwnership()\",\"TestSIMD.requestOwnershipHandover()\",\"TestSIMD.rescueERC20(address,address,uint256)\",\"TestSIMD.rescueETH(address,uint256)\",\"TestSIMD.setPaused(bool)\",\"TestSIMD.transfer(address,uint256)\",\"TestSIMD.transferFrom(address,address,uint256)\",\"TestSIMD.transferOwnership(address)\",\"TestSIMD.withdraw(uint256,address,address)\"],\"files\":{\".gitignore\":4,\"README.md\":237,\"foundry.toml\":23,\"remappings.txt\":3,\"script/Deploy.s.sol\":34,\"site/app.js\":386,\"site/config.json\":21,\"site/index.html\":110,\"site/projects.json\":1,\"site/style.css\":110,\"src/Stacker.sol\":99,\"src/TestIMD.sol\":41,\"src/TestSIMD.sol\":178,\"test/Base.t.sol\":33,\"test/Deployment.t.sol\":66,\"test/Stacker.t.sol\":344,\"test/StackerEdges.t.sol\":394,\"test/StackerInvariant.t.sol\":162,\"test/StackerInvariantExtended.t.sol\":476,\"test/TestIMD.t.sol\":85,\"test/TestIMDEdges.t.sol\":88,\"test/TestSIMD.t.sol\":140,\"test/TestSIMDProperties.t.sol\":323},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"4f5984a4a24a3192ef48d207ddf9b1ee17ca56b8f87aa2b7e5c48e1ae22cd064","verifiedTreeHash":"eb50b4c88f3211c2757cae2d86cc467baa18db72","verifierVersion":"0.1.0+5d71dfd4"}]}