{"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":"d944c9f4-820f-4a57-87f6-eca2032d2ede","kind":"audit","nodes":[{"acceptedSubmissionHash":"8e7348f259a5f2689e5fd635d8079de016cda487093196e3fe86756c00b6ba82","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"05a4086f75c72de2049db57712927e1886a25324b574803085146e2a8da0fae1","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"8415df542e737d30b94c4de0564c0fcfa947269d685f7a57f4c7de90056149ab","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","subagents":4,"tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"ab36c325a886559c6d5a1b01e858babb31397edb1c8ac8c7331789b411e062dd","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"5809b9dc6f10ecbd612acc6ab8943d90f6748c2b32ce8402fdb663b95385df05","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit only the two new contracts in this repository, src/IMDuelStaking.sol and src/IMDuelFeeRouter.sol, before their mainnet deployment; the rest of the repository (IMDuelHook, the engine, IMDuelToken) is live and was audited in jobs 6acefe89 and 74895075, and is in scope only where these two contracts depend on it. IMDuelStaking is OpenZeppelin 5.0.2's ERC-4626 over $IMDUEL (shares: sIMDUEL, 18 decimals, no offset, no owner, nothing adjustable). totalAssets() is a stored staked amount plus the release so far; any $IMDUEL sent to the vault joins a reserve at the next action (after the release so far); the reserve releases 1% of itself a day, per second (KEEP^t by squaring in 1e27 fixed point), only while shares exist. The first sync() once the reserve holds 1 $IMDUEL stakes it 1:1 for 0x...dEaD (a stake that can never leave); deposits and mints are closed until then. IMDuelFeeRouter becomes the requester of IMD LaunchFactory launch 2843 (the live main duel, 0xA92F72DcDd90229C32F6d51229F4cc5c916410CC, $IMDUEL 0xc012b8Db13C57beCd950eEfB70540e6f973A3C73): anyone's collect() claims the launch's fees, sends the $IMDUEL to the vault and syncs it, and sends the IMD to the team; the admin can hand the requester on after a 3-day notice (handOn collects first) or lock the router for good; the constructor checks its wiring against the factory's positionOf, the hook's imduel()/imd() and the vault's asset(), and syncs the vault. Planned launch order: deploy both; 1 $IMDUEL to the vault and sync (the dead stake); the team stakes and stays staked; the 10M $IMDUEL seed and sync; claimFees(2843); setRequester(2843, router). Check: solvency (the vault's $IMDUEL always equals totalAssets() + reserve(), every holder can redeem what previewRedeem says); a share's worth never falls; rounding always favours the vault; the release never exceeds the reserve and is never paid while nothing is staked; first-depositor, donation and near-empty vault attacks; flash-loaned stakes around collect or sync; the window between deployment and each launch step (front-running the dead stake, prefunding the vault's address); uint128 and uint64 bounds; the router's wiring checks, notice, handoff and lock, and whether fees can be stuck in the router or the factory; ERC-4626 conformance for integrators. Known and accepted: a stolen admin key is stopped only by lock() within the notice, and no automated watcher is planned; the release emits no event; the dead stake's Deposit names the vault as its sender; a plain transfer starts releasing only at the next action, so the router syncs after forwarding; deposits have no minimum-shares argument (standard ERC-4626; a share's worth can't be moved within a block).","parentJobId":null,"planHash":"2b6134d8f74a2d348b617fb2755035150e00bde067065c38261bf49cd4d73694","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"d944c9f4-820f-4a57-87f6-eca2032d2ede","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51039","feedbackHash":"c030360e4bc818b70551e5556b6c153e264c11c9db92cc057c1853556de3a3d0","nodeKey":"audit_economics","submissionHash":"8e7348f259a5f2689e5fd635d8079de016cda487093196e3fe86756c00b6ba82","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51041","feedbackHash":"a680082dc5a05d06809d69bc18be073a374a81bde7ff7857310737e5eca6d842","nodeKey":"audit_flow","submissionHash":"05a4086f75c72de2049db57712927e1886a25324b574803085146e2a8da0fae1","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51189","feedbackHash":"25a81a5479b3fa471e9c06d49868a8bbe74c291eed4c4a4c36c4f58a09c5c99e","nodeKey":"audit_judge","submissionHash":"8415df542e737d30b94c4de0564c0fcfa947269d685f7a57f4c7de90056149ab","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52189","feedbackHash":"243b0a3a1a77f4329a9abe42666dabfc7d5f3b331397a4644879eb43358774ba","nodeKey":"audit_math","submissionHash":"ab36c325a886559c6d5a1b01e858babb31397edb1c8ac8c7331789b411e062dd","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51880","feedbackHash":"8b81c64e640322bb3eb0ed79ab56e141fe92fa007821bf992008993bb1e08f08","nodeKey":"audit_permissions","submissionHash":"5809b9dc6f10ecbd612acc6ab8943d90f6748c2b32ce8402fdb663b95385df05","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"39f0929d4c5b04f30aa3adb54f99c9bb24675078fc4c08148d14a27b69080cbe","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3bed38612db34f32","findings":[{"citation":"resolved","description":"_withdraw() lowers _pool.staked by `assets` and then super._withdraw() transfers `assets` to `receiver`. When receiver == address(this) the token self-transfer leaves the vault's balance unchanged, so at the next action _now() sees `arrived == assets` and folds the owner's withdrawn $IMDUEL into the reserve, where it releases to all remaining stakers (including 0x...dEaD). The Withdraw event still reports `receiver` as paid. This is consistent with the stated rule that any $IMDUEL sent to the vault joins the reserve, and OpenZeppelin's base ERC-4626 behaves the same way (the assets would be recounted in totalAssets), so it is not a solvency or price defect: the vault's balance stays equal to totalAssets() + reserve() and the share price does not fall. It is recorded for integrators (zappers, routers) that must never pass the vault as receiver; a `receiver != address(this)` guard in _withdraw would close the footgun at the cost of one SLOAD-free comparison.","line":91,"path":"src/IMDuelStaking.sol","reproduction":"State: vault opened (1 $IMDUEL + sync), alice deposits 1_000e18. Input: alice calls vault.withdraw(500e18, address(vault), alice). Expected (integrator's reading of the Withdraw event): alice or the named receiver holds 500e18. Actual: token.balanceOf(vault) unchanged, alice's shares burned for 500e18, vault.reserve() == 500e18, vault.totalAssets() == 501e18, the 500e18 now release 1%/day to the remaining holders. Verified in test/scratch/Probe.t.sol test_withdrawToVault.","severity":"info","snippet":"        uint256 arrived = IERC20(asset()).balanceOf(address(this)) - p.staked - p.reserve;","title":"withdraw/redeem with the vault itself as receiver silently turns the withdrawn assets into reserve for everyone else"}],"hash":"05a4086f75c72de2049db57712927e1886a25324b574803085146e2a8da0fae1","nodeId":"0994111c-7bb7-4f27-86eb-15c3e1dfcbca","outcome":"completed","summary":"Review complete. The findings file holds one informational entry and no defects above that.\n\n**Scope and method.** I audited `src/IMDuelStaking.sol` and `src/IMDuelFeeRouter.sol` under the Execution Trace, Periphery and First Principles lenses, with the vault's OpenZeppelin ERC-4626 base, `IMDuelToken`, the hook's `imduel()`/`imd()` getters, and the live factory's verified `PoolFees.sol` (fetched from Sourcify, exact match) read as dependencies. I wrote adversarial probes under `test/scratch/Probe.t.sol` and ran the project's own staking and router suites, including the invariant suite.\n\n**What held up, with the checks that confirm it:**\n- Solvency: the vault's balance equals `totalAssets() + reserve()` after mint, withdraw, redeem, donations, withdraw-to-self, and a 200-year gap with the whole supply inside. The release never exceeds the reserve because `KEEP^t <= 1e27`, and `KEEP` matches 0.99^(1/86400) to 27 digits, rounded down.\n- Share worth never falls under all four ERC-4626 paths at skewed prices, and every holder redeems exactly what `previewRedeem` says. Rounding favours the vault on each path.\n- No release is paid while no shares exist. Deposits and mints are closed until the dead stake. Prefunding the vault's address or front-running the dead stake only changes who pays the 1 $IMDUEL.\n- Flash-loaned stakes around `collect` or `sync` earn nothing. Fees join the reserve, not the staked amount, and the release is purely time-based.\n- All amounts are bounded by the 1e27 fixed supply, so the uint128 casts cannot truncate; the uint128 and uint64 fields cannot overflow.\n- Router: the wiring checks, notice, handoff and lock behave as specified. The factory's `claimFees` is callable by anyone, pays the requester directly, and cannot be reverted by the main duel's hook, which registers no liquidity hooks. The live IMD token is an OpenZeppelin ERC20 OFT, so the factory's \"owed\" fallback is never taken for the router. Any fees that did land in `owed[router]` after a handoff remain claimable via the factory's `withdraw` and then `collect()`, so nothing gets stuck.\n\n**The one recorded item (info):** `withdraw`/`redeem` with the vault itself as receiver leaves the assets inside, and the next action folds them into the reserve for the remaining stakers while the Withdraw event still names the receiver as paid. This matches the stated rule that anything sent to the vault joins the reserve and matches base OpenZeppelin behaviour, so it is an integrator footgun, not a solvency or price defect.\n\n**Test note.** The project's `test_budgets` gas assertion in `test/Staking.t.sol` fails without `--isolate` (73,354 vs the 70,000 budget for a later stake). Its comment says it is measured with `--isolate`, so this is a measurement condition, not a contract defect.\n\n**Coverage limits.** No mainnet fork was run, so the live `claimFees` path against launch 2843 was checked from the verified source, not executed. Nothing outside the two contracts was reviewed beyond their direct dependencies.","treeHash":null,"usage":{"cachedInputTokens":885533,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":37146,"runtime":"claude","turns":22,"wallClockMs":649353}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"09078b7cdfb673fe","findings":[{"citation":"resolved","description":"The 3-day notice is the router's only defence against the admin key (documented: 'a stolen admin key is stopped only by lock() within the notice'). handOn only checks that readyAt has passed; it never checks how long ago. A proposal the admin made and did not execute, or did not withdraw with propose(0), stays executable forever. Once such a proposal has matured, the notice is zero: whoever holds the admin key at that later time (the team, or a thief after a later compromise) moves the requester of launch 2843 away from the router in a single transaction, and no watcher gets the 3 days the design relies on. The destination is the honestly proposed address, so the damage is bounded to 'the handoff happens when the team no longer wants it' (for example `next` was a router later found faulty, or an EOA whose key was since lost), but the stated notice guarantee does not hold for that state. Fix: give a proposal a window, e.g. revert in handOn when block.timestamp > readyAt + NOTICE (or a GRACE constant), so an old proposal has to be re-proposed and re-waited; keep propose(0) as the withdrawal.","line":96,"path":"src/IMDuelFeeRouter.sol","reproduction":"State: router is the requester of the launch; admin calls propose(next) at time T. Nobody calls handOn or propose(0). At T + 365 days, the admin key calls handOn(): expected (per the notice model) a fresh 3-day notice or a NotReady revert for a stale proposal; actual: handOn succeeds immediately, factory.positionOf(2843).requester == next, all later fees go to next. Verified with a scratch test (test/scratch/StaleProposal.t.sol): propose(next) at 1_762_000_000, warp +365 days, handOn() passes and the requester is moved.","severity":"low","snippet":"        if (next == address(0) || block.timestamp < readyAt) revert NotReady();","title":"A matured handoff proposal never expires: handOn runs with zero notice at any later time"},{"citation":"resolved","description":"Documented for the deployer, not a defect. (1) launch.json sets team and admin to the same wallet (0x6AA302EFf055259c9Cf177f33Ae3cBA5d4135B23), while the tests model admin as 'a key of its own, not the team wallet'. With one key, a compromise takes the IMD stream at once (team is immutable, every collect pays it) and the 3-day notice protects only the $IMDUEL stream; the lock() remedy must come from the same compromised key. (2) team cannot be changed except by handing the launch's requester to a new router after the notice; after lock() the IMD destination is frozen for the life of the launch. (3) admin has no rotation: the only state change to admin is lock() to zero, so a key the team merely wants to retire forces a permanent lock. (4) collect() is permissionless and calls factory.claimFees before the launch wallet has handed the requester over; that pays the launch wallet, which anyone could trigger directly on the factory anyway, so no new power. No action is required by the code for mainnet; these are the powers and their consequences, so the team can decide the admin key's custody before setRequester(2843, router).","line":46,"path":"src/IMDuelFeeRouter.sol","reproduction":"State: router deployed with team == admin == launch wallet as launch.json says. Input: an attacker holding that key calls nothing on the router at all; every collect() already sends the IMD half to that wallet, and the $IMDUEL half can be redirected after propose()+3 days unless the same key calls lock() first. Expected by the documented model: the notice buys time for the team's lock; actual: the IMD half has no notice because team is the compromised wallet itself.","severity":"info","snippet":"    address public immutable team;","title":"Trust assumptions of the router's roles as wired in launch.json (admin == team == launch wallet, team immutable, lock is irreversible)"},{"citation":"resolved","description":"Not a contract defect. `forge test --match-path test/Staking.t.sol` on forge 1.8.3 (the toolchain here) fails test_budgets with 'a later stake: 73354 >= 70000', both with and without --isolate (the comment says it was measured with --isolate). Every other Staking and FeeRouter test passes, and the mainnet-fork rehearsal (test/StakingFork.t.sol) passes against live state. If the launch gate or CI runs the suite, the budget needs raising (or re-measuring on the pinned forge version) so a red suite does not block the deployment or mask a real failure later.","line":340,"path":"test/Staking.t.sol","reproduction":"Run `forge test --match-path test/Staking.t.sol --match-test test_budgets` (with or without --isolate) on forge 1.8.3: expected pass; actual FAIL 'a later stake: 73354 >= 70000'.","severity":"info","snippet":"        assertLt(g - gasleft(), 70_000, \"a later stake\");","title":"The repository's own test_budgets fails on the current toolchain (a later stake costs 73,354 gas against a 70,000 budget)"}],"hash":"5809b9dc6f10ecbd612acc6ab8943d90f6748c2b32ce8402fdb663b95385df05","nodeId":"750d3c38-3a35-4f1e-afbe-a4baebdbbeb1","outcome":"completed","summary":"Review complete. The findings file is written at the repository root with three entries, none above low.\n\n**Outcome.** On the permission lenses (Access Control, Trust Gap, Asymmetry) I found no exploitable defect in either contract. The vault has no roles at all and every storage write goes through one settle path. The router's three admin writes (propose, handOn, lock) are all guarded by the same check, and nothing else can touch pending, readyAt or admin.\n\n**What I verified, not just read:**\n- The existing Staking and FeeRouter suites pass (30 of 31 tests), including the invariant run of roughly 128,000 handler calls with fail-on-revert.\n- The repository's mainnet fork rehearsal passes against live state: constructor wiring against launch 2843, a real claimFees through the factory, a staker in and out, a 3-day handoff and a lock.\n- The live factory's verified source (PoolFees) shows claimFees never reverts on zero fees, pays by a non-reverting send with an owed fallback, and setRequester refuses address zero. The hook has no liquidity hooks, so a zero-delta claim cannot be refused. IMD is a LayerZero OFT over a plain OpenZeppelin ERC-20 with no pause or blocklist. So neither collect nor handOn can be bricked, and fees cannot be stuck in the router or the factory.\n- Solvency, monotonic share worth, vault-favouring rounding, release bounded by the reserve and paid only while shares exist, uint128/uint64 bounds, donation and near-empty attacks, and the launch-window races all hold. The 1e18 dead shares cap any rounding loss per deposit at about 1e-9 $IMDUEL.\n\n**Findings written:**\n1. **Low.** A matured handoff proposal never expires. Once readyAt has passed, whoever holds the admin key can call handOn years later in one transaction, so the 3-day notice the design relies on is zero for that state. Verified with a scratch test. Fix: reject handOn after readyAt plus a grace window.\n2. **Info.** Trust assumptions as wired in launch.json: admin and team are the same wallet, team is immutable, lock is irreversible, and admin has no rotation. The notice protects only the $IMDUEL stream if that key is compromised.\n3. **Info.** The repository's own gas-budget test fails on this toolchain (a later stake costs 73,354 gas against a 70,000 budget), with and without isolate mode. Not a contract defect, but it will turn the suite red.\n\n**Coverage limits.** Depth went to the three assigned guides first, then solvency, rounding, launch order and ERC-4626 conformance from the brief. I did not run Slither or long fuzz campaigns beyond the repository's invariant suite. No high or critical finding exists, so no finding carries a proof file. The scratch test under test/scratch is mine and nothing in it is kept.","treeHash":null,"usage":{"cachedInputTokens":1337151,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":39347,"runtime":"claude","turns":38,"wallClockMs":909576}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ee8bab8367ea6e91","findings":[{"citation":"resolved","description":"_now() releases the reserve whenever totalSupply() != 0. After the first sync, totalSupply() is never zero again because 0x...dEaD holds 1e18 shares that nobody can redeem, so the release also runs when the dead stake is the only holder, and everything paid in that state raises the worth of shares that can never leave: it is lost for good. The code does what the brief says ('only while shares exist') and test_theDeadStakeTakesTheReleaseWhileAlone documents it, but the brief's check 'never paid while nothing is staked' only holds if the dead stake counts as staked; for the team's purposes it does not, since the money is gone. Two states reach it: (a) the launch window, if the 10M seed is synced before the team's deposit, if the team's deposit is delayed, or if someone prefunds the vault's address before deployment (the router's constructor then syncs, makes the dead stake, and the rest of the prefund starts releasing to DEAD at once); (b) operation, if every redeemable staker leaves for a while (the team is expected to stay staked, but nothing enforces it). Solvency is untouched: the vault's balance stays equal to totalAssets() + reserve(). Merged from audit_economics and audit_math, which reported the same mechanism. Fix if wanted (a scope decision, since it changes the stated rule from 'while shares exist' to 'while redeemable shares exist'): gate the release on totalSupply() != balanceOf(DEAD); updated still advances so the reserve simply waits. Verified that the attached proof passes with exactly that one-line change on a copy of the vault. Otherwise keep the design and make 'the team's stake lands before the seed and never fully leaves' an explicit runbook rule.","line":87,"path":"src/IMDuelStaking.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 {IMDuelToken} from \"src/IMDuelToken.sol\";\nimport {IMDuelStaking} from \"src/IMDuelStaking.sol\";\n\n/// While 0x...dEaD is the only holder, the reserve must not be released: whatever it is paid can never be redeemed.\ncontract DeadStakeReleaseTest is Test {\n    IMDuelToken token;\n    IMDuelStaking vault;\n    address constant DEAD = 0x000000000000000000000000000000000000dEaD;\n    address alice = makeAddr(\"alice\");\n    uint256 now_ = 1_762_000_000;\n\n    function setUp() public {\n        vm.warp(now_);\n        token = new IMDuelToken();\n        vault = new IMDuelStaking(IERC20(address(token)));\n        token.transfer(address(vault), 1e18);\n        vault.sync(); // the dead stake\n    }\n\n    function _wait(uint256 s) internal {\n        now_ += s;\n        vm.warp(now_);\n    }\n\n    /// The 10M seed synced one day before the team's stake: 1% of it is paid to the dead stake, lost for good.\n    function test_seedSyncedBeforeAnyStakerIsNotBurned() public {\n        token.transfer(address(vault), 10_000_000e18);\n        vault.sync();\n        _wait(1 days);\n        uint256 deadWorth = vault.convertToAssets(vault.balanceOf(DEAD));\n        assertLt(deadWorth, 2e18, \"the dead stake alone should not receive the release\");\n        assertGe(vault.reserve(), 10_000_000e18 - 1e18, \"the reserve waits for a redeemable staker\");\n    }\n\n    /// Every staker gone for a week: 6.8% of the reserve is paid to the dead stake.\n    function test_reserveWaitsWhileNobodyRedeemableIsStaked() public {\n        token.transfer(alice, 1_000_000e18);\n        vm.startPrank(alice);\n        token.approve(address(vault), 1_000_000e18);\n        uint256 shares = vault.deposit(1_000_000e18, alice);\n        vm.stopPrank();\n        token.transfer(address(vault), 10_000_000e18);\n        vault.sync();\n        vm.prank(alice);\n        vault.redeem(shares, alice, alice);\n        uint256 reserveBefore = vault.reserve();\n        _wait(7 days);\n        assertEq(vault.reserve(), reserveBefore, \"nothing redeemable is staked: nothing should be released\");\n    }\n}","reproduction":"Fresh vault; transfer 1e18 $IMDUEL and sync() (dead stake, totalSupply 1e18). State (a): transfer 10_000_000e18, sync(), warp +1 day. Expected if the reserve waited for a live staker: reserve ~ 10_000_000e18 and convertToAssets(balanceOf(DEAD)) ~ 1e18. Actual: convertToAssets(balanceOf(DEAD)) = 100000.999999999999900977e18 and reserve ~ 9_900_000e18; redeem(1, anyone, DEAD) reverts (no allowance), so ~100,000 $IMDUEL of the seed are unrecoverable after one day. State (b): alice deposits 1_000_000e18, transfer 10_000_000e18, sync(), alice redeems everything, warp +7 days. Expected: reserve unchanged at 10_000_000e18. Actual: reserve = 9320653.479069899999993566e18; 679,346.52 $IMDUEL went to DEAD. Both reproduced with forge test --match-path test/scratch/Proof_dead.t.sol (2 failures on the current code); the same file passes against a copy of the vault with line 87 changed to `if (totalSupply() != balanceOf(DEAD)) {`.","severity":"low","snippet":"        if (totalSupply() != 0) {","title":"The release is paid while only the irredeemable dead stake holds shares, burning 1% of the reserve a day with no live staker"},{"citation":"resolved","description":"The 3-day notice is the router's only defence against a stolen admin key (accepted: 'a stolen admin key is stopped only by lock() within the notice'). handOn checks only that readyAt has passed, never how long ago. A proposal the admin made and neither executed nor withdrew with propose(0) stays executable forever, so once it has matured the notice is zero: whoever holds the admin key at any later time moves launch 2843's requester away from the router in one transaction, and no watcher gets the 3 days the model relies on. The destination is the address the team once proposed, so the damage is bounded to 'the handoff happens when the team no longer wants it' (for example a next router later found faulty, which the team simply never handed on to instead of withdrawing the proposal). Fix: give a proposal a window, e.g. revert in handOn when block.timestamp > readyAt + NOTICE, so a stale proposal must be re-proposed and re-waited; propose(0) stays the withdrawal.","line":96,"path":"src/IMDuelFeeRouter.sol","reproduction":"State: router is the launch's requester (FakePoolFees from test/FeeRouter.t.sol, requester set to the router); admin calls propose(next) at T = 1_762_000_000; nobody calls handOn or propose(0). At T + 365 days the admin key calls handOn(). Expected under the notice model: NotReady for a stale proposal (or a fresh 3-day wait). Actual: handOn succeeds immediately and factory.requester(2843) == next; all later fees go to next. Reproduced in test/scratch/Judge.t.sol::test_staleProposal (passes = the state is reachable).","severity":"low","snippet":"        if (next == address(0) || block.timestamp < readyAt) revert NotReady();","title":"A matured handoff proposal never expires: handOn later runs with zero notice"},{"citation":"resolved","description":"release = reserve - floor(reserve * keep / ONE) rounds the kept amount down and therefore the release up, by at most 1 wei per _settle(). Every action (sync, deposit, mint, withdraw, redeem, the router's collect) settles, so a caller syncing every second can move up to 86,400 wei a day from the reserve into the stake beyond what one settle would. Solvency holds (both sides are the vault's own $IMDUEL and balance == totalAssets() + reserve()), a share's worth still never falls, and the amount is dust; it is the one place where rounding favours shareholders over the reserve, reported because the brief asks that rounding always favour the vault. No fix is required; if wanted, compute the kept amount with Math.mulDiv(reserve, keep, ONE, Math.Rounding.Ceil) so the release rounds down.","line":88,"path":"src/IMDuelStaking.sol","reproduction":"Two vaults, each opened with 1e18 + sync() and alice staking 1_000_000e18, both receiving 10_000_000e18 and synced. Vault A: sync() every second for 3,600 seconds. Vault B: one sync() after 3,600 seconds. Expected if rounding were neutral: equal reserves. Actual: A.reserve() = 9995813236755073749085769, B.reserve() = 9995813236755073749087544; A released 1,775 wei more. balance == totalAssets() + reserve() held in both. Reproduced in test/scratch/Judge.t.sol::test_settleRounding.","severity":"info","snippet":"            uint256 release = p.reserve - p.reserve * _keep(block.timestamp - p.updated) / ONE;","title":"Each settle rounds the release up by one wei, so frequent syncs move dust from the reserve to the stake"},{"citation":"resolved","description":"_withdraw() lowers _pool.staked by `assets` and super._withdraw() then transfers `assets` to `receiver`. When receiver == address(this) the token self-transfer leaves the vault's balance unchanged, so at the next action _now() sees `arrived == assets` and folds the owner's withdrawn $IMDUEL into the reserve, where it releases to the remaining holders (0x...dEaD included). The Withdraw event still names `receiver` as paid. This follows the stated rule that any $IMDUEL sent to the vault joins the reserve, OpenZeppelin's base ERC-4626 behaves the same way (the assets would be recounted in totalAssets), and solvency and the share price are unaffected, so it is an integrator footgun rather than a defect: zappers and routers must never pass the vault as receiver. A `receiver != address(this)` check in _withdraw would close it.","line":76,"path":"src/IMDuelStaking.sol","reproduction":"State: vault opened (1e18 + sync), alice deposits 1_000e18. Input: alice calls vault.withdraw(500e18, address(vault), alice). Expected by an integrator reading the Withdraw event: the receiver holds 500e18. Actual: token.balanceOf(vault) unchanged, alice's shares burned for 500e18, vault.reserve() == 500e18, vault.totalAssets() == 501e18, and the 500e18 now release at 1%/day to the remaining holders. Reproduced in test/scratch/Judge.t.sol::test_withdrawToVault.","severity":"info","snippet":"        super._withdraw(caller, receiver, owner, assets, shares);","title":"withdraw/redeem with the vault itself as receiver silently turns the withdrawn assets into reserve for everyone else"},{"citation":"resolved","description":"Not a contract defect: the constructor accepts either wiring (only team == vault and zero addresses are refused), and the fork rehearsal passes. The deployment manifest passes 0x6AA302EFf055259c9Cf177f33Ae3cBA5d4135B23 as both team (line 18) and admin (line 19), while test/StakingFork.t.sol line 38 deliberately rehearses with the admin as 'a key of its own, not the team wallet'. The accepted model (a stolen admin key is stopped only by lock() within the 3-day notice, no watcher planned) assumes the legitimate holder can still call lock(); with one key, whoever holds it is both the thief and the only party able to lock, that same key receives every collect()'s IMD at once with no notice (team is immutable), and it holds the seed. Also for the record: team cannot change except by handing the requester to a new router after the notice; admin has no rotation (its only state change is lock() to zero), so retiring the key means a permanent lock; and after lock() the IMD destination is frozen for the launch's life. Merged from audit_permissions, audit_economics and audit_math. If wanted: a separate cold key or multisig as admin in launch.json before setRequester(2843, router).","line":19,"path":"launch.json","reproduction":"Deploy with launch.json as is: router.admin() == router.team() == 0x6AA302EFf055259c9Cf177f33Ae3cBA5d4135B23. With that key compromised: propose(attacker) at T, handOn() at T + 3 days moves the $IMDUEL stream; the only stop, lock() before T + 3 days, needs the same compromised key; and every collect() already pays the IMD half to that wallet. Expected per the fork rehearsal's model: admin is a distinct key. Actual per launch.json lines 18 and 19: the same address.","severity":"info","snippet":"        \"0x6AA302EFf055259c9Cf177f33Ae3cBA5d4135B23\"","title":"launch.json wires the router's admin and team to the same launch wallet, so the notice is guarded by the key that would be stolen (trust assumption)"},{"citation":"resolved","description":"Not a contract defect. On the pinned toolchain here (forge 1.8.3, solc 0.8.26, via-IR, 200 runs) a second deposit by the same staker after an hour costs 73,354 gas, above the 70,000 asserted at this line, both with and without --isolate (the comment says the budgets were measured with --isolate). Every other test passes: 17 of 18 StakingTest unit tests, all 3 invariants (256 runs, 128,000 calls, no reverts), all 12 FeeRouterTest tests, and the 3 StakingForkTest tests against live mainnet state. A red suite can block a launch gate or hide a real regression; re-measure and raise the budget (e.g. 80_000) or drop the assertion. Merged from audit_permissions, audit_economics and audit_math.","line":340,"path":"test/Staking.t.sol","reproduction":"Run `forge test --match-path test/Staking.t.sol --match-test test_budgets` and again with `--isolate`. Expected: pass. Actual in both modes: [FAIL: a later stake: 73354 >= 70000] test_budgets() (gas: 649939).","severity":"info","snippet":"        assertLt(g - gasleft(), 70_000, \"a later stake\");","title":"The shipped staking suite is red: test_budgets' later-stake gas budget is exceeded (73,354 >= 70,000) on forge 1.8.3"}],"hash":"8415df542e737d30b94c4de0564c0fcfa947269d685f7a57f4c7de90056149ab","nodeId":"cdfff2f0-5fe9-4e6b-b6b2-e351a98801b3","outcome":"completed","summary":"The review is complete. The findings file holds six merged findings, every one reproduced against the code, and no tracked file was changed.\n\n**What I checked**\n\n- Ran the shipped suites: all router tests, all staking invariants and 17 of 18 staking unit tests pass. The mainnet fork rehearsal passes through the local RPC, which confirms the router's wiring checks, the live hook's token getters and a real collect through the factory.\n- Read the live LaunchFactory source from Sourcify. A failed payout stays owed to the router and is claimable by anyone, so fees cannot be stuck in the factory. IMD is a plain OpenZeppelin ERC-20 (a LayerZero OFT), so the team transfer cannot be blocked.\n- Wrote scratch tests for the specialists' claims and for uint128 bounds, long waits and a huge mint. All behaved as the specialists described.\n- Ran the attached proof: it fails on the current vault for the stated reason and passes on a copy with the one-line gate change.\n\n**Findings kept, after merging duplicates**\n\n- **Low, vault line 87:** the release runs while only the dead stake holds shares, so a seed synced before the team's deposit, or a period with no live staker, burns 1% of the reserve a day irreversibly. This matches the stated design, so the fix is a scope decision. Proof attached.\n- **Low, router line 96:** a matured proposal never expires, so a stale one lets any later holder of the admin key hand the requester on with zero notice.\n- **Info:** each settle rounds the release up by one wei, the only place rounding favours shareholders over the reserve.\n- **Info:** withdrawing with the vault as receiver folds the assets into the reserve, an integrator footgun shared with base ERC-4626.\n- **Info:** launch.json makes admin and team the same wallet, so the notice is guarded by the key that would be stolen. Three specialists reported this; merged into one trust assumption.\n- **Info:** the shipped gas budget test fails on forge 1.8.3 with and without `--isolate`. Three specialists reported this; merged.\n\n**Dropped:** nothing. Every specialist claim reproduced, and I found no additional defect in solvency, share price monotonicity, uint bounds, the dead-stake front-running window, flash-loan timing or the router's handoff and lock paths.","treeHash":null,"usage":{"cachedInputTokens":697974,"chain":[{"calls":121,"chainId":1,"failed":0,"own":109,"public":0,"refused":12}],"inputTokens":290,"model":"claude-fable-5-1","outputTokens":26618,"runtime":"claude","turns":24,"wallClockMs":509074}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9ab27edcfd62be02","findings":[{"citation":"resolved","description":"_now() releases the reserve whenever totalSupply() != 0. After the first sync, totalSupply() is never zero again because 0x...dEaD holds 1e18 shares that can never be redeemed, so the release runs even when the dead stake is the only holder. Everything paid to it in that state is lost for good: the dead stake's worth grows, and nobody can ever redeem it. The vault stays solvent (balance == totalAssets()+reserve()), but the stated property 'nothing is released while nothing is staked' is not what the code enforces: it enforces 'while no shares exist', and the dead shares always exist. Two concrete states reach it: (a) the launch window, if the 10M seed is synced before the team's stake (or the team's stake is delayed, or a stranger syncs an early seed): 1% of the reserve per day goes to DEAD; (b) operation, if every redeemable staker leaves for a while (the team is expected to 'stay staked', but nothing enforces it). A minimal fix that keeps the dead stake as the inflation anchor: release only while totalSupply() != balanceOf(DEAD) (updated still advances, so the reserve simply waits). The dead stake then still earns its pro-rata 1/(all staked) when others are staked, as today. This changes the stated rule from 'only while shares exist' to 'only while redeemable shares exist'; the requester should confirm that tradeoff.","line":87,"path":"src/IMDuelStaking.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 {IMDuelToken} from \"src/IMDuelToken.sol\";\nimport {IMDuelStaking} from \"src/IMDuelStaking.sol\";\n\n/// While 0x...dEaD is the only holder, the reserve must not be released: whatever it is paid can never be redeemed.\ncontract DeadStakeReleaseTest is Test {\n    IMDuelToken token;\n    IMDuelStaking vault;\n    address constant DEAD = 0x000000000000000000000000000000000000dEaD;\n    address alice = makeAddr(\"alice\");\n    uint256 now_ = 1_762_000_000;\n\n    function setUp() public {\n        vm.warp(now_);\n        token = new IMDuelToken();\n        vault = new IMDuelStaking(IERC20(address(token)));\n        token.transfer(address(vault), 1e18);\n        vault.sync(); // the dead stake\n    }\n\n    function _wait(uint256 s) internal {\n        now_ += s;\n        vm.warp(now_);\n    }\n\n    /// The 10M seed synced one day before the team's stake: 1% of it is paid to the dead stake, lost for good.\n    function test_seedSyncedBeforeAnyStakerIsNotBurned() public {\n        token.transfer(address(vault), 10_000_000e18);\n        vault.sync();\n        _wait(1 days);\n        uint256 deadWorth = vault.convertToAssets(vault.balanceOf(DEAD));\n        assertLt(deadWorth, 2e18, \"the dead stake alone should not receive the release\");\n        assertGe(vault.reserve(), 10_000_000e18 - 1e18, \"the reserve waits for a redeemable staker\");\n    }\n\n    /// Every staker gone for a week: 6.8% of the reserve is paid to the dead stake.\n    function test_reserveWaitsWhileNobodyRedeemableIsStaked() public {\n        token.transfer(alice, 1_000_000e18);\n        vm.startPrank(alice);\n        token.approve(address(vault), 1_000_000e18);\n        uint256 shares = vault.deposit(1_000_000e18, alice);\n        vm.stopPrank();\n        token.transfer(address(vault), 10_000_000e18);\n        vault.sync();\n        vm.prank(alice);\n        vault.redeem(shares, alice, alice);\n        uint256 reserveBefore = vault.reserve();\n        _wait(7 days);\n        assertEq(vault.reserve(), reserveBefore, \"nothing redeemable is staked: nothing should be released\");\n    }\n}","reproduction":"Fresh vault; transfer 1e18 $IMDUEL and sync() (dead stake, totalSupply 1e18). State (a): transfer 10_000_000e18, sync(), warp +1 day. Expected: reserve ~ 9_999_999e18, convertToAssets(balanceOf(DEAD)) ~ 1e18. Actual: convertToAssets(balanceOf(DEAD)) = 100000.999999999999900977e18 and reserve = 9_899_999.0e18: 100,000 $IMDUEL is locked forever in one day. State (b): alice deposits 1_000_000e18, transfer 10_000_000e18, sync(), alice redeems everything, warp +7 days. Expected: reserve unchanged (10_000_000e18) because no redeemable share exists. Actual: reserve = 9_320_653.48e18; 679,346.52 $IMDUEL went to DEAD. Verified with the attached test (fails on the current code, passes with `if (totalSupply() != balanceOf(DEAD))` at line 87).","severity":"low","snippet":"        if (totalSupply() != 0) {\n            uint256 release = p.reserve - p.reserve * _keep(block.timestamp - p.updated) / ONE;","title":"Release is paid while only the irredeemable dead stake holds shares, burning 1%/day of the reserve"},{"citation":"resolved","description":"release = reserve - floor(reserve*keep/ONE) rounds the kept amount down and therefore the release up, by at most 1 wei per _settle(). Every action (sync, deposit, mint, withdraw, redeem, the router's collect) settles, so a caller who syncs every second moves up to 86,400 wei per day from the reserve into the stake beyond what one settle would. Solvency is untouched (both sides are the vault's own $IMDUEL and balance == totalAssets()+reserve() holds), a share's worth still never falls, and the amount is dust; it is only the one place where rounding does not favour the reserve. No fix is required; if wanted, round the kept amount up (Math.mulDiv(reserve, keep, ONE, Math.Rounding.Ceil)) so the release rounds down.","line":88,"path":"src/IMDuelStaking.sol","reproduction":"Two vaults, each: 1e18 then sync() (dead stake), alice stakes 1_000_000e18 in the first, both receive 10_000_000e18 and sync(). Vault A: sync() every second for 3,600 seconds. Vault B: one sync() after 3,600 seconds. Expected if rounding were neutral: equal reserves. Actual: A.reserve() = 9995813236755073749085769, B.reserve() = 9995813236755073749087544; A released 1,775 wei more. Balance == totalAssets()+reserve() held in both.","severity":"info","snippet":"            uint256 release = p.reserve - p.reserve * _keep(block.timestamp - p.updated) / ONE;","title":"Each settle rounds the release up by one wei, so sync frequency slightly favours shareholders over the reserve"},{"citation":"resolved","description":"The staking suite as committed does not pass: the gas budget for a second deposit by the same staker is exceeded, with and without --isolate (the comment says the budgets were measured with --isolate). Not a contract defect, but a red suite hides real regressions before deployment. Fix: re-measure and raise the budget (e.g. 80_000) or drop the assertion.","line":340,"path":"test/Staking.t.sol","reproduction":"forge test --match-path test/Staking.t.sol --match-test test_budgets (also with --isolate). Expected: pass. Actual: [FAIL: a later stake: 73354 >= 70000] test_budgets() (gas: 649939); the other 17 tests in the file and the invariant suite pass.","severity":"info","snippet":"        assertLt(g - gasleft(), 70_000, \"a later stake\");","title":"test_budgets fails in the shipped suite: a later stake costs 73,354 gas against a 70,000 budget"},{"citation":"resolved","description":"The deployment manifest passes 0x6AA302EFf055259c9Cf177f33Ae3cBA5d4135B23 as both team and admin, while test/StakingFork.t.sol line 38 rehearses with the admin as 'a key of its own, not the team wallet'. The accepted risk model (a stolen admin key is stopped only by lock() within the 3-day notice) assumes the legitimate holder can still call lock(); with one key, whoever holds it is both the thief and the only party able to lock, and that same key also holds the team's IMD income (paid without any notice) and the seed. This is a trust assumption of the launch wiring, not a contract defect: either wiring is accepted by the constructor (only team_ == vault_ and zero addresses are refused). Fix if wanted: a separate admin key (a cold key or a multisig) in launch.json.","line":19,"path":"launch.json","reproduction":"Deploy with launch.json as is: router.admin() == router.team() == 0x6AA3...5B23. With that single key compromised, propose(attacker) at T and handOn() at T+3 days moves the $IMDUEL fee stream; the legitimate lock() needs the same compromised key. Expected per the fork rehearsal: admin != team.","severity":"info","snippet":"        \"0x6AA302EFf055259c9Cf177f33Ae3cBA5d4135B23\",\n        \"0x6AA302EFf055259c9Cf177f33Ae3cBA5d4135B23\"","title":"launch.json wires the router's admin and team to the same launch wallet, unlike the fork rehearsal"}],"hash":"8e7348f259a5f2689e5fd635d8079de016cda487093196e3fe86756c00b6ba82","nodeId":"599f2473-01d5-4524-b281-4a097c80eb5b","outcome":"completed","summary":"The review is complete. No tracked file was changed; only `.imd-findings.json` and the scratch tests were added.\n\n**Outcome: one low and three informational findings, no critical, high or medium.** Both contracts hold the core properties the brief asked me to check.\n\n**What I verified on the economics (Economic Security, Invariant, Flow Gap passes):**\n- Solvency holds. The vault's balance always equals the stored stake plus the reserve, under the repo's invariant suite and my own fuzz of deposit, top-up, wait, withdraw at maxWithdraw and redeem at maxRedeem.\n- A share's worth never falls. I checked the four ERC-4626 rounding directions algebraically against the (A+1)/(S+1) price and fuzzed small amounts at a price above 1.\n- The release never exceeds the reserve, the per-second KEEP^t squaring stays within 1e27 bounds, and the uint128 casts cannot truncate without the whole call reverting. A deposit or mint past 2^128 reverts.\n- First-depositor, donation and near-empty attacks have nothing to work on: donations join the reserve, not the price, and the 1e18 dead shares keep one share unit worth at most a few wei of rounding.\n- Flash-loaned stakes gain nothing in-block, since the release is purely time-based and settles before pricing.\n- Prefunding the vault's address or front-running the dead stake only produces the same dead stake.\n- The router's claim-and-forward path cannot trap fees. I pulled the live factory's verified source from Sourcify: `claimFees` is anyone-callable, never reverts on zero fees, and records undeliverable payouts as `owed` that anyone can push. The hook has no liquidity permissions, so a zero-delta claim cannot be reverted or made negative. IMD is a LayerZero OFT without pause or blocklist, so forwarding to the team cannot block the $IMDUEL leg.\n\n**Findings written:**\n1. **Low, IMDuelStaking.sol:87.** The release runs whenever shares exist, and the dead stake always exists. So while nobody redeemable is staked, 1% of the reserve a day is paid to 0x...dEaD and lost. Concrete states: the 10M seed synced one day before the team's stake loses 100,000 $IMDUEL; every staker leaving for a week loses 679,346. A proof test is attached that fails now and passes with a one-line condition change, which I confirmed on a patched copy.\n2. **Info, IMDuelStaking.sol:88.** Each settle rounds the release up by one wei. Syncing every second for an hour released 1,775 wei more than one sync. Solvency is unaffected.\n3. **Info, test/Staking.t.sol:340.** `test_budgets` fails as shipped, with and without `--isolate`.\n4. **Info, launch.json:19.** Admin and team are the same launch wallet, unlike the fork rehearsal's separate admin key. A trust-assumption note, not a contract defect.\n\n**Limits:** no fork run was possible, so the factory and IMD behaviour comes from verified source, not execution. Slither, Echidna and long fuzz campaigns did not run. The audited items marked as known and accepted were not reported.","treeHash":null,"usage":{"cachedInputTokens":1413641,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":45825,"runtime":"claude","turns":29,"wallClockMs":954836}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fe5e46fb044c6af1","findings":[{"citation":"resolved","description":"The release gate is totalSupply() != 0, and the dead stake's 1e18 shares satisfy it permanently. Whenever no other holder exists, the whole per-second release (1% of the reserve a day) is credited to the dead stake's worth, which can never be redeemed (0x...dEaD cannot call redeem or approve). This is documented in the tests as intended ('all of it is the dead stake's: locked for good'), so it is reported as an operational hazard, not an exploit: every period with zero live stakers irreversibly converts 1%/day of the reserve into unrecoverable supply. The planned launch order (team stakes before the 10M seed and 'stays staked') is the only protection; if the seed sync ever precedes the team's stake, or the last live staker leaves for any period, the reserve burned during that window is gone. Maths checked: the release itself never exceeds the reserve, is 0 when totalSupply()==0 (reserve untouched for 365 days of prefunding), and the vault's balance always equals totalAssets()+reserve(). Possible fix if the requester wants it: gate the release on totalSupply() > balanceOf(DEAD) (a live staker exists) instead of totalSupply() != 0; this changes the stated design 'only while shares exist' and must be a scope decision. Otherwise keep the design and make the operational rule explicit in the launch runbook.","line":87,"path":"src/IMDuelStaking.sol","reproduction":"State: fresh vault; transfer 10_000_001e18 $IMDUEL to it; sync() (dead stake 1e18, reserve 10_000_000e18, no other holder). Warp 1 day. Expected (if the release were meant only for live stakers): reserve still 10_000_000e18 or the day's 1% still claimable. Actual: totalAssets() ~ 100_001e18, convertToAssets(balanceOf(0x...dEaD)) ~ 100_001e18 (within 1e-6 relative), reserve ~ 9_900_000e18; redeem(1, anyone, 0x...dEaD) reverts (no allowance) so ~100_000 $IMDUEL of the seed are unrecoverable. Reproduced in test/scratch/MathProbe.t.sol::test_releaseToDeadAloneIsLocked (passes = the state is reachable).","severity":"low","snippet":"        if (totalSupply() != 0) {","title":"Release paid while only the dead stake holds shares is locked forever (1% of the reserve per day with no live staker)"},{"citation":"resolved","description":"The repository's own staking suite does not pass on this toolchain (forge 1.8.3, solc 0.8.26, via-IR, 200 runs, as foundry.toml pins): the second deposit after a one-hour wait costs 73,354 gas, above the 70,000 budget asserted at this line. The comment says the budgets were measured with --isolate; the assertion fails identically under --isolate. Every other staking test and the three invariants pass (17/18 unit tests, 3/3 invariants), and all FeeRouter tests pass (12/12). This is a test threshold, not a vault defect: a later deposit does one settle (one storage slot read/write, one balanceOf call, a 12-step squaring for t=3600), a transferFrom, a mint and an event. Fix: raise the budget to e.g. 80_000 or re-measure; a red suite hides real regressions before a mainnet deployment.","line":340,"path":"test/Staking.t.sol","reproduction":"Run `forge test --match-path test/Staking.t.sol --match-test test_budgets` (and again with `--isolate`). Expected: pass. Actual: `[FAIL: a later stake: 73354 >= 70000] test_budgets()` in both modes.","severity":"info","snippet":"        assertLt(g - gasleft(), 70_000, \"a later stake\");","title":"Staking suite is red: test_budgets gas threshold for a later stake is exceeded (73,354 >= 70,000) on forge 1.8.3, with and without --isolate"},{"citation":"resolved","description":"The router's accepted trust model is that a stolen admin key is stopped only by lock() within the 3-day notice (no watcher planned). launch.json passes the launch wallet 0x6AA3...5B23 as both team_ (line 18) and admin_ (line 19), while test/StakingFork.t.sol deliberately uses 'a key of its own, not the team wallet' for the admin. With one key, whoever holds it both receives every collect()'s IMD at once and can propose(next) to redirect the $IMDUEL 1% after the notice; the only counter, lock(), requires that same key, so the team must notice and act before the thief's handOn(). The constructor's wiring checks (pool pairs exactly $IMDUEL/IMD, hook names them, vault.asset()==$IMDUEL, team not the vault, non-zero admin) all pass for this configuration, and nothing in the router can leave fees stuck (the live PoolFees keeps failed payouts as owed[router] and exposes a permissionless withdraw(account, currency), after which anyone's collect() forwards them). This is a deployment choice, reported as a trust assumption rather than a defect; using a separate cold admin key (as the fork test models) keeps the notice meaningful.","line":19,"path":"launch.json","reproduction":"Deploy with launch.json as is: router.admin() == router.team() == 0x6AA302EFf055259c9Cf177f33Ae3cBA5d4135B23. Sequence with that key compromised: propose(attacker) at T; at T+3 days handOn() moves the launch's requester to attacker and future $IMDUEL fees leave the vault's reserve permanently; lock() before T+3 days from the same key is the only stop. Expected per the fork test's model: admin is a distinct key. Actual per launch.json: the same key.","severity":"info","snippet":"        \"0x6AA302EFf055259c9Cf177f33Ae3cBA5d4135B23\"","title":"launch.json deploys the router with admin == team == the launch wallet, so the 3-day notice is guarded by the same key that would be stolen"}],"hash":"ab36c325a886559c6d5a1b01e858babb31397edb1c8ac8c7331789b411e062dd","nodeId":"804c373d-10e7-456a-b187-c08056a5b789","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds the result. No contract files were changed; my probe tests live only under `test/scratch/`.\n\n**Verdict:** no critical, high or medium defect in the math of either contract. The vault's accounting, rounding and release arithmetic hold under every input I could construct.\n\n**What I verified, with concrete checks**\n\n- **KEEP constant and the squaring.** The constant equals 0.99^(1/86400) to all 27 digits. The truncated `KEEP^t` is monotone non-increasing for every second over 4.6 days and in a 300k random sample over 20 years, so `totalAssets()` never falls between seconds. Relative error is below 1e-19 at one year.\n- **Solvency and conservation.** The vault's balance equals `totalAssets() + reserve()` after every mix of deposit, mint, withdraw at `maxWithdraw`, redeem, top-up, sync and waits up to 20 years, with amounts from 1 wei to 400M tokens. Every holder exits at exactly what `previewWithdraw` and `previewRedeem` say, and the dead stake's worth stays backed.\n- **Price monotonicity and rounding.** With OpenZeppelin's +1 virtual offset and the 1e18 dead shares, the price (A+1)/(S+1) never falls on any operation. I proved algebraically and fuzzed that total assets always stay at least total supply, so withdrawals can never drain below the dead stake's 1e18 assets.\n- **Bounds.** Every uint128 cast is bounded by the fixed 1e27 supply; a `deposit` or `mint` past 2^128 reverts in the token's `transferFrom` before the truncated value is ever persisted. The uint64 timestamp and the 3-day notice are safe.\n- **Attacks.** Donations only move the reserve, never the price. A 400M flash-sized stake wrapped around a top-up plus sync, after 30 idle days, exits with no more than it put in and does not dilute the existing staker. Prefunding the vault before deployment or front-running the dead stake yields the same state the launch plan produces. Nothing is released while only tokens sit in an empty vault.\n- **Router.** The live factory source on Sourcify keeps failed payouts as an owed balance with a permissionless `withdraw`, so fees cannot be stranded for the router. The token is a plain OpenZeppelin OFT, so IMD forwarding cannot revert.\n\n**Findings written**\n\n1. **Low.** Release that accrues while only the dead stake holds shares is locked forever. State: 10M seed synced with no live staker; one day later about 100,000 $IMDUEL belong to the dead address and can never be redeemed. This is documented design, so it is reported as an operational hazard and the fix is flagged as a scope decision.\n2. **Info.** The repository's `test_budgets` fails on this toolchain, with and without `--isolate`, on a gas threshold for a later stake. All other staking, invariant and router tests pass.\n3. **Info.** launch.json sets the router's admin and team to the same launch wallet, while the fork test models a separate admin key. A stolen key then controls both the IMD destination and the handoff proposal, leaving `lock()` from that same key as the only stop.\n\n**Limitations.** The mainnet fork test was not run (no RPC in this environment). Slither was not run per the task rules. No proof files were attached because nothing reached high severity.","treeHash":null,"usage":{"cachedInputTokens":1418105,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":45802,"runtime":"claude","turns":31,"wallClockMs":1224121}}],"verification":[]}