{"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":"6af15c5b-019c-497a-9f24-e7b15366601d","kind":"audit","nodes":[{"acceptedSubmissionHash":"bc8dac79142d82f94709af8d9b369279d4d1bb1c0f1ff2ea11f475abe0666a6a","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"b3ad57e35403158830c452d7bd886272e130cc9e38d6c983c3b5435570d0e562","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"973b40ad783b556f43501309881150125c0af2effa343dab663f402bd6b70d2f","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"f88cd14c81bd07ac4e26e20c6befd574af8fe4396c2b18b9e4477c118859a9c2","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"88212a0346bfabcf29d026848aa8b9bfdbb4b4f93616ffe1c951b8ab873b3acd","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Scope is the msft95-contracts-audit-v4/ folder only. The README’s “look hardest at” section already covers this round’s changes.","parentJobId":null,"planHash":"083b2d5490c6c8a70f6423585b779cb697bd5d962f14ea50f7f82d9e78d4f16d","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"6af15c5b-019c-497a-9f24-e7b15366601d","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":"50974","feedbackHash":"01382b5e62e5c975e53a560be6a8d0eabb86d89364507703f9b75b8d66c58da9","nodeKey":"audit_economics","submissionHash":"bc8dac79142d82f94709af8d9b369279d4d1bb1c0f1ff2ea11f475abe0666a6a","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52120","feedbackHash":"88dea08bf4f7bb2a642acda6c0f00fd20584dfb533375f49765f14bb18c6953d","nodeKey":"audit_flow","submissionHash":"b3ad57e35403158830c452d7bd886272e130cc9e38d6c983c3b5435570d0e562","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52158","feedbackHash":"0aef749402046c57eea9b1bf9811c8244c9e62c718505a235bb9b94799c57831","nodeKey":"audit_judge","submissionHash":"973b40ad783b556f43501309881150125c0af2effa343dab663f402bd6b70d2f","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52397","feedbackHash":"5e7d6a52ba5370ba4614e17175336bf1025ad6e8fd03f83a87bb43db43457a29","nodeKey":"audit_math","submissionHash":"f88cd14c81bd07ac4e26e20c6befd574af8fe4396c2b18b9e4477c118859a9c2","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51193","feedbackHash":"bd875cb4f3be4e741ce098d5d915a2913bfbe443ad9a9e1a321fda0c4ed10374","nodeKey":"audit_permissions","submissionHash":"88212a0346bfabcf29d026848aa8b9bfdbb4b4f93616ffe1c951b8ab873b3acd","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"dcc1ce121108121d63cf2738145dd58328540d8578731a08318d5a20e1c05d3a","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ef31844bb462de78","findings":[],"hash":"4544c2724de3cf9d87ca29c8b7ad8cebc68d0f8e5e39bede66a88799e9cce66c","nodeId":"bf6f43ca-29bf-469a-a836-96f7a3377086","outcome":"failed","summary":"Not logged in · Please run /login","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":null,"outputTokens":0,"runtime":"claude","turns":1,"wallClockMs":1027}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fedfd42dfee2f4e6","findings":[{"citation":"resolved","description":"The audit-5 L-4 idempotency fix decides whether each part of the supply split has already been paid by asking whether the destination wallet's MSFT95 balance is already >= that part (`has(who, amt)`, line 253). That test is only sound when every destination is a distinct wallet that holds nothing else. `config()` (line 57) defaults `TREASURY`, `LP_WALLET`, `TEAM` and `OPS` to `SAFE`, and MAINNET.md says the wallets may be 'separate addresses or the Safe'. With the defaults the treasury transfer (line 255) sends 250,000,000 MSFT95 to the Safe; the liquidity check on line 256 then sees the Safe holding 250,000,000 >= 200,000,000 and skips the transfer. The Safe ends the launch with 250,000,000 instead of 450,000,000 and the deployer EOA keeps 200,000,000 MSFT95 (20% of supply) after `handover`, contradicting the README's 'The deployer ends up owning nothing' and the launch step's own description. The same mis-skip happens whenever LP_WALLET == TREASURY, or whenever a destination already holds MSFT95 for any other reason. The step records itself as done, so a re-run does not repair it. The rehearsal (rehearse.js line 42) sets five distinct wallets, which is why its check on line 76 passes and never exercises the default. Suggested fix: track what was sent per part in the launch record (e.g. the transfer tx hash per key) instead of inferring it from balances, or at minimum compute the expected balance as the sum of all parts routed to that address before comparing.","line":256,"path":"msft95-contracts-audit-v4/scripts/mainnet/lib.js","reproduction":"Run `launch.js` with SAFE set and TREASURY / LP_WALLET unset (or both set to the same address). After `split`: balanceOf(SAFE) == 250,000,000e18 (expected 450,000,000e18); balanceOf(deployer) == 200,000,000e18 + the agents' 249,967,700e18 (expected only the agents' 249,967,700e18, which `agentStakes` then moves). After `handover` the deployer still holds 200,000,000e18 MSFT95. Minimal local reproduction: in rehearse.js set `LP_WALLET: treasury.address` (or `TREASURY: lpWallet.address`) and rerun; the check `liquidity: 200,000,000 to the LP wallet` (rehearse.js line 76) fails because the LP wallet holds 250,000,000 and the deployer holds 200,000,000 more than expected.","severity":"medium","snippet":"      if (parts.liquidity[0].toLowerCase() !== me.toLowerCase() && !(await has(parts.liquidity[0], parts.liquidity[1]))) await send(m.transfer(parts.liquidity[0], parts.liquidity[1]));","title":"Launch split skips the 200,000,000 MSFT95 liquidity transfer when LP_WALLET and TREASURY resolve to the same address (the mainnet default), leaving 20% of supply on the deployer key"},{"citation":"resolved","description":"`vesting` deploys MSFT95Vesting with `start = latest.timestamp + VEST_DELAY` (default 3600 s, line 203) and saves the address before funding (line 205), so that a resumed run reuses it (audit 5, L-4). But MSFT95Vesting.fund() refuses any deposit at or after `start` (`if (block.timestamp >= start) revert AfterStart();`, v4/MSFT95Vesting.sol line 60). If the launch stops between the deploy and the fund transaction (RPC failure, out of gas, operator interruption) and is resumed more than one hour later, every retry of `v.fund(amt - have)` reverts, `rec.steps.vesting` is never set, and because `A().vesting` is already pinned the script never deploys a fresh schedule. The operator cannot continue the launch without hand-editing the deployment record; meanwhile the deployer holds the 50,000,000 MSFT95 that should have been staked in vesting. The resume logic for `agentStakes` has no such time bound, which is the asymmetry. Suggested fix: on resume, if `block.timestamp >= v.start()` and `v.total() < amt`, deploy a new vesting contract (and log the abandoned one), or set `start` relative to funding (fund first, then a long enough delay) and guard the deploy/fund pair in one step.","line":209,"path":"msft95-contracts-audit-v4/scripts/mainnet/lib.js","reproduction":"Dry run: run the launch until the `vesting` step has deployed (deployments/<net>Launch.json has addrs.vesting but steps.vesting is absent), e.g. by killing the process after the deploy transaction. Advance the chain by 3601 s (evm_increaseTime) and rerun. Expected: the step completes and vesting.total() == 50,000,000e18. Actual: `v.fund(50,000,000e18)` reverts with `AfterStart()`, the step fails on every rerun, and balanceOf(deployer) keeps the 50,000,000e18.","severity":"low","snippet":"      if (have < amt) { await send(m.approve(v.target, amt - have)); await send(v.fund(amt - have)); }","title":"Vesting step cannot be resumed once VEST_DELAY has elapsed: fund() reverts AfterStart and the recorded vesting address is never replaced, so the 5% allocation stays on the deployer"},{"citation":"resolved","description":"Trust gap (access x economics). The README asks reviewers to test the assumption that 'a compromised keeper can only cause bounded loss'. `swap()` is onlyRole(KEEPER_ROLE); its floor on ORACLE pools is the feed value less `slipBps` (line 311) and on PEG pools 1:1 less `slipBps` (line 314). On a chain where the keeper can also place its own trades around the farm's swap, the keeper (or whoever holds its key) is the natural counterparty: it pushes the pool price `slipBps` away from the feed (still inside `maxDevBps`, so `_checkPrice` passes), swaps the agent's full balance (`amountIn = balanceOf[agentId][tIn]`), and unwinds, pocketing up to slipBps of the agent's balance per swap. Nothing limits this per agent per day except `SWAPS_PER_DAY = 8` (line 304), and each swap can be the whole balance, so losses compound: with the values the tests use (slipBps 150, maxDevBps 200) 1 - 0.985^8 = 11.4% of every agent's cash per day, 57% per week; at the admin maximum slipBps 2000, 83% per day. On OPEN pools (the MSFT95/USDG pool) there is no oracle floor at all: the only floor is the keeper's own `minOut > 0` (line 316) and the per-pool daily `cap0/cap1`, so a stolen keeper key sells each agent's balance to itself for 1 wei until the pool cap is used. The holder's only defence is `setFrozen` (line 246), which requires the holder to notice. Documented powers of the keeper role are not themselves findings; this is reported because the stated bound ('bounded loss') is materially weaker than it reads and the cap is per swap. Suggested fix, preserving the design: add a per-agent daily value limit for swaps (e.g. cumulative `amountIn` per agent per day as a fraction of its balance, or a per-agent daily cap in dollar terms using the feed), and require a non-zero floor derived from the pool's own price at listing time for OPEN pools (or a hard per-agent daily cap for OPEN swaps).","line":311,"path":"msft95-contracts-audit-v4/v4/MSFT95AgentFarm.sol","reproduction":"State: pool idx listed ORACLE with slipBps 150, maxDevBps 200, feed fresh at $100; agent 1 has balanceOf[1][USDG] = 10,000e6, not frozen. Keeper (attacker) tx bundle, 8 times in one UTC day: (1) sell stock into the pool so price = $98.6 (gap 1.4% < 2%); (2) farm.swap(1, idx, usdgIsToken0, 10,000e6, 0): _checkPrice passes, floor = 10,000/100 * 0.985 = 98.5 stock, pool returns ~101.4 stock at $98.6 so the swap succeeds; the agent now holds stock bought at the depressed price; (3) keeper buys the stock back, restoring the price. Then swap the agent's stock back to USDG the same way. Expected: the agent keeps >= 98.5% of value per day as the comment on line 71 ('bounds what a compromised keeper could bleed') suggests. Actual after 8 swaps: the agent's USDG balance is about 8,860e6 (11.4% moved to the keeper); after 7 days about 4,300e6. On the OPEN MSFT95 pool with cap1 = 1,000e6 per day: farm.swap(1, iM, false, 1,000e6, 1) succeeds with out = 1 wei if the keeper has first drained the pool side, moving $1,000 per agent-day with no oracle check.","severity":"low","snippet":"            floor_ = _fairOut(p, zeroForOne, amountIn) * (10000 - p.slipBps) / 10000;","title":"Farm keeper's loss bound is per swap, not per agent per day: with slipBps 150 and SWAPS_PER_DAY 8 a compromised keeper can move about 11% of an agent's balances to itself every day on ORACLE pools, an"},{"citation":"resolved","description":"MSFT95 is a listed farm token (the MSFT95/USDG OPEN pool in the tests and MAINNET.md), so agents' MSFT95 cash sits in the farm, which is then an eligible MSFT95 holder: every `distribute` (hook convert) credits it `balanceOf(farm) * perShare` of MSFT. Anyone can call `MSFT95Token.claimFor(farm)` (v4/MSFT95Token.sol line 102), which transfers that MSFT to the farm. The farm only pays out through `withdraw` (needs `balanceOf[agentId][MSFT] > 0`, which is never credited for MSFT), `payTreasury` (pays `treasuryOwed[MSFT]`, which is 0) and `_remove`/`_credit` (only the pool's token0/token1). MSFT is not a farm token unless a MSFT pool is listed, and even then `owed[MSFT]` is only increased by deposits, fees and swap outputs, never by claimed dividends, so the balance above `owed` is unreachable by anyone. `MSFT95Token.setExcluded(farm, true)` stops future earnings but freezes what was already earned as `credit`, which `claimFor` still pays to the farm, where it is equally stuck. The vault (MSFT listed sweep-only), staking (`_harvest` streams it), vesting (staked, so staking harvests) and AgentStakes (`claimRewards` forwards `reward()`) all have a route for this MSFT; the farm is the one holder that does not, and the exclusion MAINNET.md promises (line 81: 'only the pool manager, the hook, the token and the farm are excluded') is not performed by any script in this package (lib.js excludes only the PoolManager at deploy and the hook at line 154). Suggested fix: exclude the farm in the farm deployment step (and document it as a required Safe action), or give the farm a `claimDividends` path that credits the MSFT to agents pro rata to their MSFT95 balances or forwards it to the treasury.","line":62,"path":"msft95-contracts-audit-v4/v4/MSFT95AgentFarm.sol","reproduction":"State: farm deployed and MSFT95/USDG listed as an OPEN pool; the farm is not in `excluded`. Agent 1's holder deposits 1,000,000e18 MSFT95 (`deposit(1, MSFT95, 1e24)`); eligibleSupply includes it. Hook keeper runs `convert`, which distributes 100e18 MSFT. Anyone calls `MSFT95Token.claimFor(farm)`: MSFT.balanceOf(farm) rises by 100e18 * 1e24 / eligibleSupply. Expected: that MSFT reaches the agent (or the treasury). Actual: `farm.withdraw(1, MSFT, type(uint256).max, holder)` transfers 0 (balanceOf[1][MSFT] == 0), `farm.payTreasury(MSFT)` transfers 0, no other function moves MSFT; the balance stays in the farm forever and grows with every distribution while agents hold MSFT95 there.","severity":"low","snippet":"///     freeze the keeper for their agent or leave. There is no way to sweep tokens out: nothing held here belongs to the admin.","title":"MSFT95AgentFarm has no path out for the MSFT dividends it earns as an MSFT95 holder; unless the Safe excludes it before agents deposit MSFT95, its share of every distribution is permanently stuck"}],"hash":"88212a0346bfabcf29d026848aa8b9bfdbb4b4f93616ffe1c951b8ab873b3acd","nodeId":"bf6f43ca-29bf-469a-a836-96f7a3377086","outcome":"completed","summary":"Review complete. The findings file is written, validated, and the tree is otherwise untouched.\n\n**Outcome:** no critical or high defects in the on-chain permission model. The access-control surface of all ten in-scope contracts (vault, agents, token, hook, router, staking, vesting, farm, agent stakes, deployer) holds up: every state-changing entry point has the guard its effect needs, role exclusivity in the vault is enforced in `_grantRole`, the hook's trading gate reads the named launch position and launch price, and the keeper roles cannot reach locked principal. Four substantiated findings are recorded in `.imd-findings.json`, all with concrete failing inputs and states:\n\n- **Medium, launch script.** The supply `split` decides whether a part was paid by checking the destination's balance. With the mainnet defaults, `TREASURY` and `LP_WALLET` both resolve to the Safe, so after the treasury transfer the liquidity transfer is skipped. The Safe ends with 250M instead of 450M MSFT95 and the deployer keeps 200M after handover. The rehearsal uses five distinct wallets, so its 48 checks never exercise this path.\n- **Low, launch script.** The `vesting` step pins the contract address before funding, but `MSFT95Vesting.fund` reverts once `start` has passed. A resume more than one hour after the deploy fails on every rerun and never deploys a replacement, leaving 50M MSFT95 on the deployer.\n- **Low, farm, trust gap.** The keeper's loss bound is per swap, not per agent per day. With the tested settings a compromised keeper sandwiching every allowed swap moves about 11% of each agent's cash per day on oracle pools, and on OPEN pools only its own `minOut` and the pool cap apply. Reported because the README asks for this assumption to be tested and the stated bound is weaker than it reads.\n- **Low, farm.** The farm earns MSFT dividends on agents' deposited MSFT95 but has no function that can move MSFT out. The exclusion MAINNET.md promises is not performed by any script in this package, so that MSFT is stuck.\n\n**Coverage and limits.** All three named guides were applied as review passes over the whole v4 folder, with the README's \"look hardest at\" items traced first (house window, `openTrading` keys and ticks, weighted stream end, route check and idempotent resume). The project is Hardhat with no `node_modules` installed, so no tests were executed. No finding reached the severity that requires a Foundry proof, so none was written and no scratch directory exists. Documented owner, Safe and keeper powers were treated as trust assumptions, not findings.","treeHash":null,"usage":{"cachedInputTokens":1061663,"inputTokens":46,"model":"claude-fable-5-1","outputTokens":52359,"runtime":"claude","turns":32,"wallClockMs":740543}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"4dd67dae195771b6","findings":[{"citation":"resolved","description":"Merged from audit_permissions and audit_flow (same root cause, same fix). The review-5 idempotency fix (AUDIT-5 L-4) decides whether each part of the supply split has already been paid by checking whether the destination already holds at least that part: `has(who, amt) = balanceOf(who) >= amt` (line 253). That is only sound when every destination is a distinct wallet holding nothing else. `config()` (line 57) defaults TREASURY, LP_WALLET, TEAM and OPS to SAFE, and docs/MAINNET.md section 0 explicitly allows the wallets to be 'separate addresses or the Safe'. With the defaults the treasury transfer on line 255 sends 250,000,000 MSFT95 to the Safe, and the liquidity check on line 256 then sees the Safe holding 250,000,000 >= 200,000,000 and skips the transfer. The step records itself as done (rec.steps.split) and prints the normal summary with liquidity: {to: SAFE, amount: 200000000.0}, so a re-run does not repair it; handover() only transfers ownership. Result: the Safe receives 250,000,000 instead of 450,000,000 and the deployer EOA keeps 200,000,000 MSFT95 (20% of supply, the whole launch-liquidity allocation) after handover, contradicting README 'The deployer ends up owning nothing' and leaving the Safe unable to add the launch position until someone moves the tokens by hand with the deployer key. The same mis-skip fires for any two parts that share a wallet, or for any destination that already holds MSFT95 for another reason. rehearse.js uses five distinct wallets (line 42), so its check on line 76 never exercises the default. Fix: record each transfer as its own sub-step (or by tx hash) in the launch record instead of inferring it from balances, or compute the expected balance per destination as the sum of all parts routed to that address; and assert at the end of split() that the deployer holds exactly AGENT_STAKE.","line":256,"path":"msft95-contracts-audit-v4/scripts/mainnet/lib.js","reproduction":"Reproduced by replaying lines 244-256 with the defaults (TREASURY and LP_WALLET unset, so both equal SAFE) and the deployer holding 950,000,000 after the vesting step: economy 250,032,300 -> vault; treasury 250,000,000 -> SAFE; then has(SAFE, 200,000,000) is true, so the liquidity transfer is skipped. Expected after split: balanceOf(SAFE) = 450,000,000e18, balanceOf(deployer) = 249,967,700e18. Actual: balanceOf(SAFE) = 250,000,000e18, balanceOf(deployer) = 449,967,700e18; after agentStakes and handover the deployer still holds 200,000,000e18 MSFT95 while steps.split is recorded as done. Minimal live reproduction: in rehearse.js set LP_WALLET: treasury.address (or run launch.js with SAFE set and TREASURY/LP_WALLET unset); the check 'liquidity: 200,000,000 to the LP wallet' (rehearse.js line 76) and 'the deployer keeps nothing' (line 78) fail.","severity":"medium","snippet":"      if (parts.liquidity[0].toLowerCase() !== me.toLowerCase() && !(await has(parts.liquidity[0], parts.liquidity[1]))) await send(m.transfer(parts.liquidity[0], parts.liquidity[1]));","title":"Launch split skips the 200,000,000 MSFT95 liquidity transfer when LP_WALLET and TREASURY resolve to the same address (the mainnet default), leaving 20% of supply on the deployer key"},{"citation":"resolved","description":"Merged from audit_permissions, audit_flow and audit_economics (one mechanism). The step deploys MSFT95Vesting with start = latest.timestamp + VEST_DELAY (default 3600 s, line 203), saves the address at once (line 205) and on every run tops up amt - total() through v.fund(). MSFT95Vesting.fund() refuses any deposit once block.timestamp >= start (v4/MSFT95Vesting.sol line 60) and the contract has no owner or other funding path. If the run stops between the deploy and the fund transaction (RPC error, out of gas, operator interruption, sequencer pause) and is resumed more than an hour later, every retry reverts, rec.steps.vesting is never set, and because A().vesting is pinned the script never deploys a fresh schedule. The launch cannot continue past step 2 without hand-editing deployments/<net>Launch.json, which is the manual intervention the resume logic (AUDIT-5 L-4) was meant to remove; meanwhile 50,000,000 MSFT95 sit on the deployer key. agentStakes resume has no such time bound. Fix (keeps the design): when A().vesting is recorded, total() < amt and block.timestamp >= v.start(), log the stale address and deploy a fresh MSFT95Vesting instead of calling fund(); or fund in the same transaction as the deploy via a small deployer contract; or pick a VEST_DELAY long enough for any plausible resume and assert it up front.","line":209,"path":"msft95-contracts-audit-v4/scripts/mainnet/lib.js","reproduction":"Foundry test test/scratch/VestingResume.t.sol (passes, demonstrating the revert): deploy MSFT95Staking and MSFT95Vesting(token, staking, ops, start = block.timestamp + 3600, 365 days) with total() == 0; warp 3660 s (a resume 61 minutes later); replay line 208-209: have = total() = 0, approve 50,000,000e18, v.fund(50,000,000e18). Expected (what the resume assumes): total() == 50,000,000e18. Actual: fund reverts with AfterStart(), total() stays 0, and no later call can ever fund this contract; the step throws identically on every rerun.","severity":"low","snippet":"      if (have < amt) { await send(m.approve(v.target, amt - have)); await send(v.fund(amt - have)); }","title":"Launch `vesting` step cannot be resumed once VEST_DELAY has elapsed: MSFT95Vesting.fund() reverts AfterStart and the recorded address is never replaced, so the 5% allocation stays on the deployer"},{"citation":"resolved","description":"audit_flow. config() allows a mainnet launch with no Chainlink feed when ALLOW_NO_FEED=1 (MSFT_FEED is then '', line 61) and the `feed` step handles that case by logging and skipping (line 180). The `route` step does not: with no ROUTE given, findRoute() (line 168) finds the deepest live USDG/MSFT v4 pool (anyone can create one), and line 170 calls routeProblem(), which unconditionally constructs new ethers.Contract(cfg.MSFT_FEED, ...) with the empty string and calls latestRoundData(). ethers v6 treats a non-address string target as an ENS name; the call rejects with UNCONFIGURED_NAME, so the step throws after the token, hook and pool have been created and while the deployer still owns the token and hook. Every resume hits the same throw until the operator sets ROUTE explicitly, which the message on line 172 never gets to print. Fix: in routeProblem(), when cfg.MSFT_FEED is empty, return a reason such as 'no MSFT feed to check the price against: set ROUTE explicitly' so the route is skipped and the fees wait for the Safe, instead of building the feed contract.","line":351,"path":"msft95-contracts-audit-v4/scripts/mainnet/lib.js","reproduction":"Reproduced the throwing call: with ethers 6.13.4 (the pinned version) against a local anvil chain with chain id 4663 and a signer runner, new ethers.Contract('', [latestRoundData ABI], signer).latestRoundData() rejects with code UNCONFIGURED_NAME: 'an ENS name used for a contract target must be correctly configured'. Trigger state: mainnet launch (dry = false) with ALLOW_NO_FEED=1, MSFT_FEED unset, ROUTE unset, and at least one initialised USDG/MSFT pool with liquidity (findRoute returns it, so line 170 calls routeProblem). Expected: the route is accepted or skipped with a logged reason and the launch continues. Actual: step('route') throws from line 351-352, nothing is recorded, and the launch aborts on every resume with the deployer still owning the token and hook.","severity":"low","snippet":"  const feed = new ethers.Contract(cfg.MSFT_FEED, [\"function latestRoundData() view returns (uint80,int256,uint256,uint256,uint80)\", \"function decimals() view returns (uint8)\"], signer);","title":"Launch `route` step crashes instead of skipping when ALLOW_NO_FEED=1 and a live USDG/MSFT pool exists: routeProblem() builds the feed contract from an empty MSFT_FEED"},{"citation":"resolved","description":"audit_math. sync() reads a reward token's balance with a guarded staticcall (handles a reverting token, AUDIT-2 H-2) but then scales the new amount by ACC = 1e36 with checked arithmetic and nothing bounds it. When a listed, switched-on reward token reports a balance for the staking contract above held + type(uint256).max / 1e36 (about 1.158e41 raw units), `fresh * ACC` panics with 0x11. stake(), unstake(), claim() and claimToken() all run _syncAll() first, so every one of them reverts for every user, including the Vesting and AgentStakes positions. The NatSpec promises 'Unstaking is instant and never blocked, not even by a broken reward token'. Recovery needs the owner (the timelocked Safe) to call setRewardOff(token, true), which only runs _accrue and so lets sync return before the multiplication. None of the six launch reward tokens can reach that balance honestly; the trigger is a token that misreports after an upgrade, or a token the owner later lists with a very large supply. Fix: treat an amount that would overflow like a failed read, e.g. `if (fresh > (type(uint256).max - back - d - left) / ACC) return;` (or clamp), so a bad reward token can never block unstake.","line":155,"path":"msft95-contracts-audit-v4/v4/MSFT95Staking.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\nimport \"forge-std/Test.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {MSFT95Staking} from \"msft95-contracts-audit-v4/v4/MSFT95Staking.sol\";\n\ncontract Tok is ERC20 { constructor() ERC20(\"T\",\"T\") {} function mint(address a, uint256 v) external { _mint(a, v); } }\n\ncontract StakingOverflowTest is Test {\n    function test_hugeRewardBalanceBlocksUnstake() public {\n        Tok stakeT = new Tok();\n        Tok rew = new Tok();\n        address[] memory r = new address[](1); r[0] = address(rew);\n        MSFT95Staking s = new MSFT95Staking(IERC20(address(stakeT)), address(this), r);\n        address alice = address(0xA11CE);\n        stakeT.mint(alice, 10e18);\n        vm.startPrank(alice); stakeT.approve(address(s), 10e18); s.stake(10e18); vm.stopPrank();\n        rew.mint(address(s), type(uint256).max / 1e36 + 1);\n        vm.prank(alice);\n        s.unstake(10e18);\n        assertEq(stakeT.balanceOf(alice), 10e18);\n    }\n}","reproduction":"Ran the specialist's proof test/scratch/Proof_7371cd4786e1.t.sol with forge (solc 0.8.26, via-ir, as the project builds): deploy MSFT95Staking with stake token T and one plain ERC-20 reward token R; Alice stakes 10e18 T; R mints type(uint256).max / 1e36 + 1 to the staking contract; Alice calls unstake(10e18). Expected: she gets 10e18 T back. Actual: the call reverts with 'panic: arithmetic underflow or overflow (0x11)' at `fresh * ACC` in sync(), reached through _syncAll(). The same test passes against a copy of the contract where sync() returns early when fresh would overflow the multiplication.","severity":"low","snippet":"        uint256 add = fresh * ACC + back + d;","title":"MSFT95Staking.sync: unbounded `fresh * ACC` overflows and blocks stake/unstake/claim for every user until the owner switches the token off"},{"citation":"resolved","description":"Merged from audit_permissions (farm) and audit_economics (any non-claiming contract): one mechanism in MSFT95Token, with the farm as the in-scope instance. Every non-excluded address holding MSFT95 is credited balanceOf * perShare of MSFT at each distribute, and anyone can push it to the holder with claimFor (v4/MSFT95Token.sol line 102). MSFT95 is a listed farm token (the MSFT95/USDG OPEN pool in the tests and MAINNET.md), so agents' MSFT95 cash sits in the farm, which therefore earns MSFT. The farm's only outward transfers are withdraw (bounded by balanceOf[agentId][token], never credited for MSFT), payTreasury (treasuryOwed[MSFT], 0), and pool settlement of the pool's own token0/token1 (lines 235, 255, 543-561); there is no rescue, and `owed[MSFT]` is never increased by claimed dividends, so the MSFT above `owed` is unreachable by anyone, forever, and grows with every distribution. docs/MAINNET.md line 81-82 states 'only the pool manager, the hook, the token and the farm are excluded', but no script in this package performs that exclusion (lib.js excludes only the PoolManager in the constructor and the hook on line 154; the farm is deployed outside this package). setExcluded(farm, true) stops future accrual but freezes what was already earned as `credit`, which claimFor still pays into the farm where it is equally stuck. The same applies to any third-party contract that comes to hold MSFT95 and cannot call claim (a permissionless Uniswap v3 MSFT95 pool, a bridge escrow, a lending market): its share is removed from the dividend flow and, once excluded, stays frozen as credit with no way to return it to perShare. Note: the economics specialist's claim that a surplus in a MSFT-paired v3 pool is 'taken by the next swapper' does not hold (v3 measures balanceBefore after the surplus exists), so that part is dropped. Fix: exclude the farm in its deployment step and list it as a required Safe action; or give the farm a claimDividends path that credits MSFT pro rata or forwards it to the treasury; and consider letting the owner redistribute an excluded address's frozen credit back into perShare.","line":62,"path":"msft95-contracts-audit-v4/v4/MSFT95AgentFarm.sol","reproduction":"State: farm deployed, MSFT95/USDG listed as an OPEN pool (listedToken[MSFT95] = true), farm not in `excluded`. Agent 1's holder calls farm.deposit(1, MSFT95, 1e24): eligibleSupply includes the farm's 1e24. The hook keeper runs convert, which calls distribute(100e18) MSFT. Anyone calls MSFT95Token.claimFor(farm): MSFT.balanceOf(farm) rises by 100e18 * 1e24 / eligibleSupply. Expected: that MSFT reaches the agent or the treasury. Actual, by enumeration of every transfer in the farm (grep safeTransfer/take: lines 235, 255, 543, 546, 553, 554, 560, 561): farm.withdraw(1, MSFT, type(uint256).max, holder) transfers 0 because balanceOf[1][MSFT] == 0; farm.payTreasury(MSFT) transfers 0 because treasuryOwed[MSFT] == 0; the pool paths only move the pool's token0/token1 by the pool's deltas. No function moves the balance above owed[MSFT]. After setExcluded(farm, true), credit[farm] holds the accrued amount and claimFor(farm) still sends it into the farm.","severity":"low","snippet":"///     freeze the keeper for their agent or leave. There is no way to sweep tokens out: nothing held here belongs to the admin.","title":"MSFT dividends accrued to a contract holder without a claim path are unrecoverable; the in-scope MSFT95AgentFarm is such a holder and nothing in the package excludes it"},{"citation":"resolved","description":"audit_economics. The NatSpec promises 'the fee is the same 3% of the gross USDG either way' and AUDIT-3 I-3 documents the deviation only for exact-input buys. The same gap exists for exact-output swaps whose specified currency is USDG (a sell for exact USDG out): beforeSwap computes fee = amt * 300 / 9700 on the requested output, takes it immediately with poolManager.take, and returns +fee as the specified delta, so v4-core (Hooks.beforeSwap line 275) asks the pool for amt + fee. If the swapper's sqrtPriceLimitX96 stops the swap early, the pool delivers only P < amt + fee; in Hooks.afterSwap (line 306-311) the caller's delta becomes swapDelta - hookDelta = P - fee, which is below amt and negative when P < fee, so the swapper pays MSFT95 and USDG in the same swap while the hook books a fee far above 3% of what traded. The MSFT95Router never sets a tight limit and only does exact input, so site users are unaffected; aggregators, bots or any direct PoolManager caller using exact output with a limit are. Fix: for exact-output swaps move the USDG fee to afterSwap where the realised specified delta is known (compute on the actual output), or document the limitation for exact output as I-3 does for exact input.","line":156,"path":"msft95-contracts-audit-v4/v4/MSFT95RewardHook.sol","reproduction":"Trace against v4-core 1.0.2 and the hook (no Foundry pool harness built for this informational item). State: pool open at the launch price, feeBps 300. Input: a direct PoolManager swap selling MSFT95 for exact USDG out, amountSpecified = +1_000e6 with USDG the specified currency, sqrtPriceLimitX96 one tick away so the pool can only move about 20e6 USDG. beforeSwap (line 154-162): fee = 1_000e6 * 300 / 9_700 = 30_927_835 taken by the hook; amountToSwap = 1_030_927_835. The pool stops at the limit after delivering ~20e6 USDG. afterSwap in v4-core: hookDelta specified = +30_927_835, swapper USDG delta = 20_000_000 - 30_927_835 = -10_927_835. Expected: fee = 3% of the USDG that actually traded (~0.62 USDG) and the swapper receives USDG. Actual: the hook keeps 30.93 USDG (about 150% of the fill) and the swapper owes 10.93 USDG on top of the MSFT95 sold.","severity":"info","snippet":"        uint256 fee = _fee(amt, exactIn);","title":"Hook fee on exact-output swaps naming USDG is charged on the amount named, not the amount filled: a price-limited partial fill pays more than 3% and can leave the swapper paying USDG as well"},{"citation":"resolved","description":"audit_permissions; reported as a trust-assumption quantification, not a permission bypass (AUDIT-3 I-5 already accepts 'up to slipBps per swap, or the daily cap on OPEN pools' with no code change). The README asks reviewers to test that 'a compromised keeper can only cause bounded loss'. swap() is onlyRole(KEEPER_ROLE); on ORACLE pools its floor is the feed value less slipBps (line 311), on PEG pools 1:1 less slipBps (line 314), and the only per-agent limit is SWAPS_PER_DAY = 8 (line 304); each swap may be the agent's whole balance of the input token. A keeper that is also the counterparty pushes the pool price slipBps away from the feed (inside maxDevBps, so _checkPrice passes), swaps the agent's full balance, and unwinds, pocketing up to slipBps per swap; losses compound across the 8 swaps: with the values the tests use (slipBps 150, maxDevBps 200) 1 - 0.985^8 = 11.4% of each agent's cash per day, about 57% per week; at the admin maximum slipBps 2000, 83% per day. On OPEN pools (MSFT95/USDG) there is no oracle floor at all: only the keeper's own minOut > 0 (line 316) and the per-pool daily cap0/cap1 (line 317), so a stolen key can sell each agent's balance for 1 wei until the pool cap is used. The holder's only defence is setFrozen (line 246), which requires noticing. The comment on line 71 ('bounds what a compromised keeper could bleed') reads as a daily bound; the actual bound is per swap times eight. Fix preserving the design: add a per-agent daily value limit for swaps (cumulative amountIn per agent per day as a fraction of its balance, or in feed dollars), and for OPEN pools a per-agent daily cap in addition to the per-pool one.","line":311,"path":"msft95-contracts-audit-v4/v4/MSFT95AgentFarm.sol","reproduction":"Code trace (the farm needs live v3/v4 pools, so no Foundry harness was built for this informational item). State: pool idx listed ORACLE with slipBps 150, maxDevBps 200, feed fresh at $100; agent 1 has balanceOf[1][USDG] = 10,000e6, not frozen. Keeper bundle, 8 times in one UTC day: (1) sell stock into the pool so its price is $98.6 (gap 1.4% < 2%); (2) farm.swap(1, idx, usdgIsToken0, 10,000e6, 0): _checkPrice passes, floor = 10,000/100 * 0.985 = 98.5 stock (line 311), the pool returns ~101.4 stock, so the swap succeeds; (3) keeper buys the stock back, restoring the price; then swap the agent's stock back to USDG the same way. Expected per the comment on line 71: loss bounded to about slipBps of value per day. Actual: after 8 swaps the agent's USDG is about 8,860e6 (11.4% moved to the keeper); after 7 days about 4,300e6. On the OPEN MSFT95 pool with cap1 = 1,000e6 per day: farm.swap(1, iM, false, 1,000e6, 1) succeeds with out = 1 wei once the keeper has first drained that side of the pool, moving $1,000 per agent per day with no oracle check.","severity":"info","snippet":"            floor_ = _fairOut(p, zeroForOne, amountIn) * (10000 - p.slipBps) / 10000;","title":"Farm keeper loss bound is per swap, not per agent per day: with slipBps 150 and SWAPS_PER_DAY 8 a compromised keeper can move about 11% of an agent's cash to itself per day on ORACLE pools, and the da"}],"hash":"973b40ad783b556f43501309881150125c0af2effa343dab663f402bd6b70d2f","nodeId":"0848e6b4-828a-4ff3-822c-c03625dfe8cc","outcome":"completed","summary":"Review complete. The findings file is written with 7 findings, every snippet verified against the tree, and no repository files were changed beyond `.imd-findings.json` and the disposable `test/scratch/` directory.\n\n**What the specialists reported, and what survived**\n\nThe four specialists submitted 11 findings. After reproduction and merging, 7 remain:\n\n- **Medium, kept and merged (2 to 1).** The launch split skips the 200,000,000 MSFT95 liquidity transfer when LP_WALLET and TREASURY both default to the Safe. I replayed the logic in `scripts/mainnet/lib.js:256` with the mainnet defaults. The deployer ends up with 200,000,000 extra tokens after handover, and the step records itself as done.\n- **Low, kept and merged (3 to 1).** The vesting step cannot resume once the hour-long delay has passed. A Foundry test confirms the recorded contract reverts with AfterStart on every retry, with no other funding path.\n- **Low, kept.** The route step crashes under ALLOW_NO_FEED=1 when a USDG/MSFT pool exists. I reproduced the ethers v6 rejection against a local anvil chain with the pinned ethers version.\n- **Low, kept with proof.** The staking sync overflow. The specialist's proof fails on the current code with panic 0x11 and passes against a copy where sync bounds the multiplication. It is attached.\n- **Low, kept and merged (2 to 1).** MSFT dividends accruing to the AgentFarm are unreachable. I enumerated every outbound transfer in the farm to confirm no path exists. I dropped the economics specialist's claim that a v3 pool's surplus is stealable by the next swapper, since v3 measures the balance before the callback.\n- **Info, kept.** The exact-output hook fee on a price-limited partial fill. Traced against v4-core's delta accounting rather than a live pool harness.\n- **Info, downgraded from low.** The farm keeper's per-swap loss bound compounds to about 11% per day. AUDIT-3 I-5 already accepts this mechanism, so it is reported as a trust-assumption quantification, not a bypass.\n\n**Corrections I made to specialist claims**\n\nThe permissions specialist's split numbers were right. My first replay omitted the vesting deduction and showed 250,000,000 instead of 200,000,000, so I corrected it before writing the finding. The farm-keeper finding was downgraded because its mechanism is documented as accepted. The v3 stealability claim was removed as unfounded.\n\n**What I did not do**\n\nI did not run the full Hardhat rehearsal, since the repository has no installed node modules and installing into the tree was out of bounds. The split and route findings rest on exact replays of the script logic rather than a live launch run. The two informational findings have code traces but no pool harness.","treeHash":null,"usage":{"cachedInputTokens":1614414,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":31616,"runtime":"claude","turns":35,"wallClockMs":458666}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a5c5e95a2ed07117","findings":[{"citation":"resolved","description":"The review-5 fix for the non-idempotent split (AUDIT-5 L-4) decides whether to send each part by checking whether the destination wallet already holds at least that amount: `has(who, amt) = balanceOf(who) >= amt`. The treasury part (250,000,000) is sent first; the liquidity part (200,000,000) is then sent only if `has(LP_WALLET, 200,000,000)` is false. When the liquidity wallet is the same address as the treasury wallet, the treasury transfer has just raised that wallet to 250,000,000, so the liquidity check is already satisfied and the transfer is silently skipped. This is the default configuration: config() (line 57) sets `TREASURY: env.TREASURY || SAFE` and `LP_WALLET: env.LP_WALLET || SAFE`, and docs/MAINNET.md section 0 explicitly allows \"separate addresses or the Safe\" for these wallets. The step then records itself as done, the launch prints the normal success output, and nothing later re-checks it: handover() only transfers ownership, and the only balance assertions live in rehearse.js, which uses distinct treasury/lpWallet signers (rehearse.js line 42) and so never exercises the shared-wallet case. Result: the Safe receives 250,000,000 instead of 450,000,000, and 200,000,000 MSFT95 (20% of supply, the whole launch-liquidity allocation) stays on the hot deployer key, contradicting the README/MAINNET.md invariant that \"the deployer ends up owning nothing\" and leaving the Safe unable to add the launch position from the liquidity wallet until someone notices and moves the tokens by hand with the deployer key. The same bug fires for any pair of parts that share a wallet (e.g. TREASURY set to the Safe and LP_WALLET left unset). Fix: make the resume check independent of the destination balance, e.g. record each transfer in rec.steps as its own sub-step (or by tx hash) before/after sending, or compute the expected cumulative balance per destination when wallets coincide (sum the parts that go to the same address and compare against that sum), and assert at the end of split() that the deployer holds exactly AGENT_STAKE.","line":256,"path":"msft95-contracts-audit-v4/scripts/mainnet/lib.js","reproduction":"Inputs: mainnet.env with SAFE=0xSafe and TREASURY and LP_WALLET unset (or both set to 0xSafe); run scripts/mainnet/launch.js through step `split` with a fresh deployment record. Expected (README \"Supply at launch\"): vault 250,032,300; 0xSafe 450,000,000 (250,000,000 treasury + 200,000,000 liquidity); deployer left with exactly 249,967,700 for the agentStakes step. Actual: after `m.transfer(0xSafe, 250,000,000)` the check `has(0xSafe, 200,000,000)` on line 256 returns true (250,000,000 >= 200,000,000), the liquidity transfer is not sent, 0xSafe holds 250,000,000 and the deployer holds 449,967,700; after the agentStakes step the deployer still holds 200,000,000 MSFT95 while rec.steps.split is marked done and the printed summary still lists liquidity: {to: 0xSafe, amount: 200000000.0}. The rehearsal (rehearse.js line 76 expects lpWallet == SUPPLY/5) passes only because it uses a distinct lpWallet signer.","severity":"medium","snippet":"      if (parts.liquidity[0].toLowerCase() !== me.toLowerCase() && !(await has(parts.liquidity[0], parts.liquidity[1]))) await send(m.transfer(parts.liquidity[0], parts.liquidity[1]));","title":"Launch split: the 200,000,000 MSFT95 liquidity transfer is skipped whenever LP_WALLET equals TREASURY (the default, both fall back to SAFE), leaving 20% of supply on the deployer key"},{"citation":"resolved","description":"The review-5 idempotent `vesting` step deploys MSFT95Vesting with `start = latest block timestamp + VEST_DELAY` (default 3600 s), records the address immediately, and on every run tops up `amt - total()` through `v.fund()`. MSFT95Vesting.fund() (v4/MSFT95Vesting.sol line 60) reverts with AfterStart once `block.timestamp >= start`, and the contract has no owner and no other way to put tokens in. So if the funding transaction fails or the run is stopped between the deploy and the fund (RPC error, out of gas, operator interruption) and the operator resumes more than an hour later, the step reverts on every retry, the recorded vesting address points at a contract that can never be funded, and the 50,000,000 MSFT95 operations allocation stays on the deployer key. The launch cannot proceed past this step without editing deployments/<network>Launch.json by hand to drop `addrs.vesting`, which is exactly the manual intervention the resume logic was added to avoid. Fix: in the vesting step, if `A().vesting` is recorded but `total() < amt` and `block.timestamp >= start` (read `v.start()`), log it and deploy a fresh MSFT95Vesting (recording the stale address for the audit trail) instead of calling fund(); alternatively choose VEST_DELAY large enough to cover any plausible resume and assert it up front.","line":209,"path":"msft95-contracts-audit-v4/scripts/mainnet/lib.js","reproduction":"State: a launch record where steps.vesting is absent but addrs.vesting is set (the deploy on line 204 succeeded and save() on line 205 ran, then the fund() on line 209 failed or the process was killed), VEST_DELAY left at the default 3600. Input: rerun launch.js 61 minutes or more later. Expected: the step resumes and funds the missing 50,000,000. Actual: `v.total()` is 0, `v.fund(50,000,000e18)` reverts with AfterStart because block.timestamp >= start, the step throws, and every later rerun throws the same way; MSFT95Vesting has no fund path after start, so the recorded contract is permanently empty.","severity":"low","snippet":"      if (have < amt) { await send(m.approve(v.target, amt - have)); await send(v.fund(amt - have)); }","title":"Launch resume: the `vesting` step can never complete if it is resumed after the vesting start (VEST_DELAY, 1 hour by default), because MSFT95Vesting.fund() reverts AfterStart"},{"citation":"resolved","description":"config() allows a mainnet launch with no Chainlink feed when ALLOW_NO_FEED=1 (MSFT_FEED is then the empty string, line 61), and the `feed` step handles that case by logging and skipping. The `route` step does not: when no ROUTE is given and findRoute() finds a live USDG/MSFT pool, line 170 calls routeProblem(), which unconditionally constructs `new ethers.Contract(cfg.MSFT_FEED, ...)` with the empty string (line 351) and calls latestRoundData(). ethers v6 treats a non-address string target as an ENS name and the call rejects (the Robinhood Chain network has no ENS), so the step throws after the token, hook and pool have already been created and while the deployer still owns the token and hook. Re-running hits the same throw until the operator sets ROUTE explicitly, which is not documented as the way out. Fix: in routeProblem(), when cfg.MSFT_FEED is empty, return a reason such as \"no MSFT feed to check the price against: set ROUTE explicitly\" (so the route is skipped and the fees wait for the Safe) instead of building the feed contract.","line":170,"path":"msft95-contracts-audit-v4/scripts/mainnet/lib.js","reproduction":"Inputs: mainnet launch with ALLOW_NO_FEED=1, MSFT_FEED unset, ROUTE unset, and at least one initialised USDG/MSFT v4 pool with liquidity on the chain (anyone can create one). Expected: the route is either accepted or skipped with a logged reason, as the comments on lines 169 to 172 describe, and the launch continues. Actual: routeProblem() throws from feed.latestRoundData() on the empty-address contract, step(\"route\") does not record, the launch aborts with the pool created and ownership of the token and hook still on the deployer, and it aborts again on every resume until ROUTE is set by hand.","severity":"low","snippet":"        if (route && !dry) { const why = await routeProblem(ethers, signer, A(), route, cfg, env); if (why) { log(`    !! found ${JSON.stringify(route)} but ${why}`); route = null; } }","title":"Launch `route` step crashes instead of skipping when ALLOW_NO_FEED=1 is used and a USDG/MSFT pool exists: routeProblem() builds the feed contract from an empty MSFT_FEED"}],"hash":"b3ad57e35403158830c452d7bd886272e130cc9e38d6c983c3b5435570d0e562","nodeId":"557fde2c-ecde-4004-9f4e-e529af5bfe30","outcome":"completed","summary":"Review complete. The findings file is written at the repository root and validated against the tree. No contract or configuration file was modified.\n\n**Outcome**\n\nThree substantiated findings, all in the launch wiring the README's \"look hardest at\" list names (item 0, idempotent `split`/`vesting`, and item 7, deployer holding supply). The on-chain v4 contracts held up under the Execution Trace, Periphery and First Principles passes. None of the findings is high or critical, so no Foundry proof file was required.\n\n- **Medium, `scripts/mainnet/lib.js:256`.** The resumable `split` step decides whether to send each part by checking the destination's balance. With the default config, treasury and liquidity wallets both fall back to the Safe. The treasury transfer raises the Safe to 250M, the liquidity check then sees \"already holds at least 200M\" and skips. The step records success, and 200M MSFT95 (20% of supply) silently stays on the hot deployer key. The rehearsal passes only because it uses distinct wallets.\n- **Low, `scripts/mainnet/lib.js:209`.** If the `vesting` step is resumed more than an hour after the contract was deployed, `fund()` reverts with `AfterStart` forever. The contract has no other funding path, so the step can never complete without hand-editing the launch record.\n- **Low, `scripts/mainnet/lib.js:170`.** With `ALLOW_NO_FEED=1` and an existing USDG/MSFT pool, the route check builds a feed contract from an empty address and throws, aborting the launch after the pool exists and while the deployer still owns the token and hook.\n\n**What was checked and found sound**\n\n- Vault: `houseWindow` separation, MSFT sweep-only config, voucher replay and signature paths, cap persistence across unlist/relist.\n- Hook: `openTrading` position key and full-range tick derivation, fee deltas for exact-in and exact-out in both directions, route settlement, floor and feed checks.\n- Staking: the weighted-end formula never exceeds the period, dust and idle folding keep `streaming <= held`, ordering of sync against stake and unstake transfers.\n- AgentStakes and Vesting: principal accounting stays consistent through stake, unstake, release, claimRewards and returnUnallocated. Rewards cannot carry principal out.\n- Agents: the three generated tier lists are disjoint and cover ids 0 to 3332 exactly.\n\n**Limits**\n\nThe Farm was reviewed for control flow and keeper bounds but not exhaustively for liquidity maths. The ethers ENS-resolution behaviour in the third finding was reasoned from the library's source, not executed, since the package dependencies are not installed here.","treeHash":null,"usage":{"cachedInputTokens":1493018,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":38371,"runtime":"claude","turns":26,"wallClockMs":600537}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"98b4506bef931d13","findings":[{"citation":"resolved","description":"Flow gap (execution x first principles) between the contract and the launch wiring the README asks to review (idempotent `vesting` step, audit 5 L-4). `MSFT95Vesting.fund` refuses any deposit once `block.timestamp >= start`, and there is no other way to put principal in: `total` stays 0, `vestedAt` returns 0 forever, and the contract has no owner who could fix it. `scripts/mainnet/lib.js` (step `vesting`, lines 198-211) computes `start = latest block timestamp + VEST_DELAY` with `VEST_DELAY` defaulting to 3600 s, deploys, saves the address, then sends `approve` and `fund` as separate transactions. The resume logic deliberately reuses the saved address and only tops up `amt - total()`. So if the run stops between the deploy and the `fund` transaction (RPC outage, out of gas, operator interruption, a sequencer pause) and is resumed more than an hour later, every resume reverts with `AfterStart` and the launch cannot get past step 2: the 50,000,000 MSFT95 stays in the deployer wallet, the vesting that the Safe actions and the tokenomics assume (5% staked and vesting over a year) never exists, and the operator has to hand-edit the deployment record to deploy a second vesting contract. The regression test 'can't be topped up once it runs' covers the revert but not the launch flow. Fix options that keep the design: (a) in the script, when `total() == 0 && block.timestamp >= start`, deploy a fresh vesting instead of funding the dead one (and refuse to proceed otherwise), or fund in the same block as the deploy via a small deployer contract; (b) in the contract, allow `fund` while `total == 0` regardless of `start` (the schedule still cannot be changed once it holds anything), or take the first `fund` as the schedule start.","line":60,"path":"msft95-contracts-audit-v4/v4/MSFT95Vesting.sol","reproduction":"State: Vesting deployed with start = T0 + 3600 (the launch default), total() == 0. Input: at any block with timestamp >= T0 + 3600 the deployer calls token.approve(vesting, 50_000_000e18); vesting.fund(50_000_000e18). Expected (what the launch resume assumes): the allocation is accepted and staked, total() == 50_000_000e18. Actual: fund reverts AfterStart(); total() stays 0; vestedAt(any) == 0; releasable() == 0; locked() == 0; no function can ever fund this contract again. Minimal Hardhat/Foundry check: deploy MSFT95Vesting(token, staking, ops, start=block.timestamp+3600, 365 days); warp 3600 s; fund(1) -> AfterStart.","severity":"low","snippet":"        if (block.timestamp >= start) revert AfterStart();","title":"Vesting can only be funded before `start`; the launch's 1-hour delay makes a resumed run unable to fund the 5% allocation"},{"citation":"resolved","description":"Economic leak (periphery x first principles). The token's stated purpose is that MSFT paid in is shared across every eligible holder; only the v4 PoolManager, the hook, the token itself, DEAD and whatever the owner later excludes do not earn. Every other contract that ends up holding MSFT95 earns a pro-rata share of each distribution whether or not it can ever call `claim()`. Anyone can create such a holder without the owner's involvement: a Uniswap v3 MSFT95/USDC pool (the farm explicitly supports v3 pools on this chain, and `v3Factory.createPool` is permissionless), a lending market, a bridge escrow, a multisig without arbitrary-call support, or a plain contract wallet. Its share of every `distribute` is then accounted to it and never claimable by anyone; it is not carried back into `perShare` for the other holders, so it is simply removed from the dividend flow (the MSFT sits in the token contract as that address's `claimable`). Worse, `claimFor(holder)` is permissionless and `_pay` transfers to `holder`: anyone can push the accrued MSFT into the pool contract, where for a v3 pool whose pair does not include MSFT it is unrecoverable, and for a v3 pool that does include MSFT the surplus balance is taken by the next swapper that pays nothing in its callback (v3 only checks `balanceBefore + amountIn <= balance`). The owner can mitigate with `setExcluded(pool, true)` but only after noticing, and what accrued before the exclusion is frozen as `credit` for the pool, i.e. still lost. With 200M MSFT95 (20% of supply) earmarked for liquidity, a secondary v3 pool holding even 10% of that captures 2.5% of every MSFT distribution for nobody. Fix that preserves the design: let the owner (or anyone, after a delay) redistribute the frozen `credit` of an excluded address back into `perShare`/`pending`, or make `claimFor` refuse to pay a holder that has code and does not opt in; and document that any new MSFT95-holding contract must be excluded at listing time.","line":102,"path":"msft95-contracts-audit-v4/v4/MSFT95Token.sol","reproduction":"State: MSFT95 deployed as at launch (PoolManager excluded; eligibleSupply ~1e27). Input: (1) any user creates a Uniswap v3 MSFT95/USDC pool and adds liquidity so the pool contract holds 20_000_000e18 MSFT95; (2) the hook (or anyone) calls distribute(1_000e18) MSFT. Expected per the contract's purpose: 1,000 MSFT shared among holders who can collect it. Actual: claimable(pool) == 1_000e18 * 20e24 / eligibleSupply (about 20-25 MSFT) and nobody can collect it: the pool has no claim path. (3) Anyone calls claimFor(pool): the MSFT is transferred to the v3 pool contract and `claimed[pool]` increases; the pool has no function that returns it. Expected: funds stay claimable by a holder; actual: permanently stuck (or, for a MSFT-paired v3 pool, taken by the next swapper). After the owner calls setExcluded(pool, true), credit[pool] holds the accrued amount and future distributions skip the pool, but the already-accrued MSFT is still unreachable by any holder.","severity":"low","snippet":"    function claimFor(address holder) external nonReentrant returns (uint256) { return _pay(holder); }","title":"MSFT dividends earned by non-excluded contracts that cannot call `claim` (e.g. any Uniswap v3 MSFT95 pool) are lost, and `claimFor` pushes them into the contract where they are stuck or stealable"},{"citation":"resolved","description":"The NatSpec promises 'the fee is the same 3% of the gross USDG either way', and audit 3 I-3 documents the deviation only for exact-input buys. The same gap exists for exact-output swaps that name USDG (a sell for exact USDG out): `beforeSwap` computes `fee = amt * 300 / 9700` on the requested output, takes it immediately with `poolManager.take`, and returns `+fee` as the specified delta so the pool is asked for `amt + fee`. If the swapper's `sqrtPriceLimitX96` stops the swap early, the pool delivers only `P < amt + fee` but the hook has already kept `fee`; the swapper's USDG delta is `P - fee`, which is below `amt` and becomes negative when `P < fee`, so the swapper pays MSFT95 and USDG in the same swap while the hook books a fee far above 3% of what traded. The MSFT95Router never sets a tight limit (and only does exact-input trades), so site users are unaffected; aggregators, bots or any direct PoolManager caller using exact-output with a limit are. Fix: for exact-output swaps move the USDG-specified fee to `afterSwap` where the realised specified delta is known (the hook already has afterSwapReturnDelta on the unspecified side; the specified-side fee can be charged there via the unspecified-side delta or by computing on the actual output), or document the limitation for exact-output as I-3 does for exact-input.","line":156,"path":"msft95-contracts-audit-v4/v4/MSFT95RewardHook.sol","reproduction":"State: pool open at the launch price with the hook (feeBps 300). Input: a direct PoolManager swap selling MSFT95 for exact USDG out, amountSpecified = +1_000e6 (USDG is the specified currency), with sqrtPriceLimitX96 set one tick away from the current price so the pool can only move about 20e6 USDG. Trace: beforeSwap: fee = 1_000e6 * 300 / 9_700 = 30_927_835 (30.93 USDG) is taken by the hook; amountToSwap = 1_030_927_835; the pool stops at the limit after delivering ~20e6 USDG; swapper's USDG delta = 20e6 - 30_927_835 = -10_927_835. Expected: fee = 3% of the gross USDG that actually traded (~0.62 USDG) and the swapper receives USDG. Actual: the hook keeps 30.93 USDG (about 150% of the fill) and the swapper must pay 10.93 USDG in addition to the MSFT95 sold. The analogous exact-input buy case (fee on the named input even if a limit fills part of it) is the documented I-3.","severity":"info","snippet":"        uint256 fee = _fee(amt, exactIn);","title":"Hook fee on exact-output swaps is computed on the amount named, not the amount filled: a price-limited partial fill pays more than 3% and the specified side can go negative"}],"hash":"bc8dac79142d82f94709af8d9b369279d4d1bb1c0f1ff2ea11f475abe0666a6a","nodeId":"b3b838f1-6027-4720-9906-d54b6f7ec680","outcome":"completed","summary":"The findings file is in place and is excluded by the harness's own `.git/info/exclude`, which is why it did not show as untracked. The `.claude/` directory is the harness's, not mine. No contract or config file was changed, and nothing else needs fetching. The review is complete.\n\n**Outcome.** The scoped folder `msft95-contracts-audit-v4/` was reviewed against the Economic Security, Invariant and Flow Gap guides, with the README's \"look hardest at\" items taken first. Three findings were substantiated and written to `.imd-findings.json`. None is high or critical, so no Foundry proof file was required and none was written.\n\n**Findings, most severe first.**\n\n- **Low. Vesting is unfundable after `start`, and the launch resume can hit that.** `MSFT95Vesting.fund` reverts once the schedule has started. The launch script sets start to one hour after deploy and sends the deploy, approve and fund as separate transactions. A run that stops between deploy and fund and resumes an hour later can never fund the saved contract, so the 5% allocation stays with the deployer and the launch cannot pass step 2 without hand-editing the deployment record.\n- **Low. MSFT dividends accrued by contracts that cannot claim are lost.** Any non-excluded contract holding MSFT95, such as a permissionlessly created Uniswap v3 MSFT95 pool, earns a pro-rata share of every distribution with no way to collect it. The share is not returned to other holders. The permissionless `claimFor` can push that MSFT into the pool contract where it is stuck, or, for a MSFT-paired v3 pool, taken by the next swapper. Excluding the address only stops future accrual.\n- **Info. Exact-output hook fee is charged on the amount named, not filled.** A sell for exact USDG out with a tight price limit still pays the full 3/97 fee computed on the requested output. The swapper's USDG delta can go negative, so they pay both tokens. The router never does this, and the mirror case for exact-input buys is already documented as I-3.\n\n**What was checked and held up.** Staking's amount-weighted stream end (no stretch, speed-up or just-in-time catch found; dust and idle carry conserve `owed + streaming <= held`). AgentStakes principal accounting (`staked <= principal` on every path, keeper reach bounded to unlocked amounts and rewards). Vault house window versus player window (independent caps, no voucher path for MSFT). Hook fee deltas for all four swap shapes and the `openTrading` position key. Token dividend accounting across transfers and exclusions. Farm fee and balance conservation, and the compromised-keeper bound, which the shipped attack test already quantifies.\n\n**Limits.** The review was static reading plus arithmetic checks. The Hardhat suites were not run, since the package ships without `node_modules` and the tree has no Foundry project. One Bash probe that searched the whole filesystem for libraries was blocked by the task guard and not retried.","treeHash":null,"usage":{"cachedInputTokens":1757701,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":57971,"runtime":"claude","turns":32,"wallClockMs":928213}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"827152cce766ca0d","findings":[{"citation":"resolved","description":"sync() reads a reward token's balance with a guarded staticcall, which handles a token whose balanceOf reverts (audit 2, H-2). It then scales the new amount by ACC = 1e36 with checked arithmetic, and nothing bounds that amount. When a listed, switched-on reward token reports a balance for the staking contract above held + type(uint256).max / 1e36 (about 1.158e41 raw units), `fresh * ACC` panics with 0x11. stake(), unstake(), claim() and claimToken() all run _syncAll() before doing anything else, so every one of them reverts for every user. The NatSpec promises 'Unstaking is instant and never blocked, not even by a broken reward token'. That promise breaks until the owner calls setRewardOff(token, true): that call only runs _accrue, so sync then returns before the multiplication. A misbehaving token (one that reports a garbage balance after an upgrade, or one with a supply that large) therefore freezes all staked MSFT95, including the Vesting and AgentStakes positions, until the owner (a timelocked Safe) acts. Fix: bound or clamp the scaled amount instead of reverting, for example `if (fresh > (type(uint256).max - back - d - left) / ACC) return;`, or treat such a balance like a failed read (skip the token), so a bad reward token can never block unstake.","line":155,"path":"msft95-contracts-audit-v4/v4/MSFT95Staking.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\nimport \"forge-std/Test.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {MSFT95Staking} from \"msft95-contracts-audit-v4/v4/MSFT95Staking.sol\";\n\ncontract Tok is ERC20 { constructor() ERC20(\"T\",\"T\") {} function mint(address a, uint256 v) external { _mint(a, v); } }\n\ncontract StakingOverflowTest is Test {\n    function test_hugeRewardBalanceBlocksUnstake() public {\n        Tok stakeT = new Tok();\n        Tok rew = new Tok();\n        address[] memory r = new address[](1); r[0] = address(rew);\n        MSFT95Staking s = new MSFT95Staking(IERC20(address(stakeT)), address(this), r);\n        address alice = address(0xA11CE);\n        stakeT.mint(alice, 10e18);\n        vm.startPrank(alice); stakeT.approve(address(s), 10e18); s.stake(10e18); vm.stopPrank();\n        rew.mint(address(s), type(uint256).max / 1e36 + 1);\n        vm.prank(alice);\n        s.unstake(10e18);\n        assertEq(stakeT.balanceOf(alice), 10e18);\n    }\n}","reproduction":"Deploy MSFT95Staking with stakeToken T and one reward token R (a plain ERC-20). Alice stakes 10e18 T. Then R's balanceOf(staking) becomes type(uint256).max/1e36 + 1 (in the test, R simply mints that much to the staking contract). Alice calls unstake(10e18). Expected: she gets her 10e18 T back, since a broken reward token must not block unstaking. Actual: the call reverts with panic 0x11 (arithmetic overflow) at `fresh * ACC` in sync(), reached through _syncAll(). Verified with forge (solc 0.8.26, via-ir): the test below fails with 'panic: arithmetic underflow or overflow (0x11)'. After the owner calls setRewardOff(R, true), unstake works again.","severity":"low","snippet":"        uint256 add = fresh * ACC + back + d;","title":"MSFT95Staking.sync: unbounded `fresh * ACC` overflows and blocks stake/unstake/claim for every user"}],"hash":"f88cd14c81bd07ac4e26e20c6befd574af8fe4396c2b18b9e4477c118859a9c2","nodeId":"19e0692d-f13b-4245-8e47-ce090609fa80","outcome":"completed","summary":"I found one defect, rated **low**. The rest of the maths in `msft95-contracts-audit-v4/` held up under review. The finding is in `.imd-findings.json`; no repository files were changed.\n\n**Finding (low): `MSFT95Staking.sync` can overflow and block all stake, unstake and claim calls** (`v4/MSFT95Staking.sol:155`, `uint256 add = fresh * ACC + back + d;`)\n- **Cause:** a token's balance is read safely, but the new amount is then multiplied by 1e36 with no upper bound.\n- **Trigger:** a listed, switched-on reward token reports a balance for the staking contract above about 1.158e41 raw units (`type(uint256).max / 1e36`). This would take a broken token, or one with a very large supply.\n- **Effect:** `stake`, `unstake`, `claim` and `claimToken` all sync every reward token first, so they all revert for every user. The Vesting and AgentStakes positions are frozen too. This breaks the contract's promise that unstaking is never blocked, even by a broken reward token. It lasts until the owner calls `setRewardOff(token, true)`.\n- **Fix:** clamp or skip an amount that would overflow, treating it like a failed balance read.\n- **Proof:** I ran a Foundry test in a temp directory outside the repo (solc 0.8.26, via-ir, OpenZeppelin 5.1.0). `unstake` reverts with panic 0x11. The test source is in the finding. I haven't run it from the path in the finding, because the repo has no Foundry setup or dependencies.\n\n**What I checked and found sound, with concrete values:**\n- **Staking streams:**\n  - Stream amounts are conserved: each arrival splits exactly into rate and leftover, and rounding leftovers and amounts streamed while too little was staked go back into the stream.\n  - A stream can never end earlier than it would have, and never runs past one hour.\n  - A tiny arrival can stretch a stream by at most about 1 second.\n  - Precision is fine with the 1e36 scale, even for 6-decimal tokens against about 3e26 staked.\n- **Token dividends:** the per-share counter can't realistically overflow (it would take about 1.7e17 MSFT). Exclusion and re-inclusion credit holders correctly.\n- **Hook:** the 3% fee is correct for exact-in and exact-out swaps in both directions. Each swap charges it exactly once. The price-floor decimals are right, and the `openTrading` full-range ticks match Uniswap's.\n- **Vault:** the player and house daily windows are separate. MSFT can't be paid out by any voucher, because its maximum withdrawal is 0.\n- **Farm:** the price, fair-value, peg and liquidity maths are correct, fee accrual rounds in the farm's favour, and the hook-flag mask is right.\n- **AgentStakes and Vesting:** the unlock and release maths is right, and principal stays separate from rewards.\n- **Launch script:** the liquidity, depth and price conversions in `launchLiquidity` and `routeProblem` are correct.\n\n**Left out on purpose:** the hook's fee and the farm's protocol cut round down, so a swap of 32 wei of USDG or less pays no fee. That is worth far less than the gas, so it isn't reported.\n\n**Coverage:** I read all ten in-scope contracts and the maths in the launch script. I didn't run the project's own Hardhat tests (no `node_modules`), and no fuzzing was done.","treeHash":null,"usage":{"cachedInputTokens":2350370,"inputTokens":48,"model":"claude-opus-5-5","outputTokens":34850,"runtime":"claude","turns":25,"wallClockMs":414858}}],"verification":[]}