{"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":"0de5bb6b-df2a-4d6f-8c95-d4212da50d87","kind":"audit","nodes":[{"acceptedSubmissionHash":"3ab5daafd4cbafb8f358f192682a20011c7051794dd51af0e6ffa7f6b265cb81","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":"7f867fc6a9b4b9a89b1b63ccab13b6b38acadfb3b4cb82c7d10a40d9cb17738e","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":"7d961646ba6a33ae5bf0cb731eba366b285e7c04f096e398d8bbb6d791a70cb3","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":"7d850b53bcc92c6f292c55c913278248737db4274a93268e47a245bdf694c570","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":"bb3425394af3268375f7d9a0a4a124a140ecc0314896099efeb822dc0eb1dfc7","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-v3/ folder only. Ignore msft95-casino/, msft95-vault/, msft95-github-upload/ and the older msft95-contracts-audit/ and msft95-contracts-audit-v2/ folders. README.md has the scope, roles and file hashes. docs/AUDIT-4.md lists the last review's findings and how each was fixed. Earlier reviews were automated, so treat every fix as unverified.\n\nMainnet launch on Robinhood Chain (4663, Arbitrum Orbit L2). In scope: contracts/MSFT95Vault.sol, contracts/MSFT95Agents.sol (generated from tools/MSFT95Agents.sol.tpl), v4/MSFT95Token.sol, MSFT95RewardHook.sol, MSFT95Router.sol, MSFT95Staking.sol, MSFT95Vesting.sol, MSFT95AgentFarm.sol, MSFT95AgentStakes.sol, Create2Deployer.sol. Mocks, test-helpers, WETH9 and vendor/ are out of scope.\n\nLOOK HARDEST AT (changed since the last review):\n\n1. MSFT95RewardHook.openTrading(uint128 minLiquidity). Can trading open before the launch position is in? Can the price differ from launchSqrtPrice at that moment? Any way to move slot0 while trading is closed (donate, modifyLiquidity, initialize, another pool pointing at the hook)? Is half the launch liquidity (computed in scripts/mainnet/lib.js launchLiquidity) the right threshold, and is the maths right for both token orders?\n\n2. MSFT95Staking.sync. An arrival smaller than what is still streaming now joins the running stream without moving its end; a bigger one restarts the hour. Check INV owed + streaming <= held <= balance per token across every path (fresh, idle, dust, setRewardOff on/off, MSFT95 as both stake and reward). Can any arrival size stretch a stream, speed one up, or let a just-in-time staker catch a payout? Does flash staking still earn 0? Also check claimToken now harvests like claim.\n\n3. Hook price floor (_fairOut): the oraclePaused() check, the sequencer feed and grace period (setSequencer), a timestamp in the future, decimals scaling. Can the keeper or a stolen keeper key still convert below fair value?\n\n4. Hook unlockCallback RouteTooShallow: a later hop must take exactly what the previous hop gave. Any route or hook shape where this wrongly reverts or lets a currency go unsettled?\n\n5. MSFT95AgentStakes: claimRewards now also forwards the token's reward() asset (deduped), and the owner has rescue(token, to, amount) for anything but MSFT95. Can rescue or the forwarding loop ever reach principal or MSFT rewards owed to agents in a way the Safe shouldn't?\n\n6. Launch script (scripts/mainnet/lib.js, rehearse.js): every setting is now checked in config() before the first transaction. Any remaining way for a run to stop half way with the deployer holding supply or ownership? Are the printed Safe actions safe if signed late or out of order?\n\nALSO RE-CHECK (unchanged, high value):\n- Hook fee deltas in all four swap shapes.\n- Token dividend accounting with excluded addresses. The vault is eligible and MSFT is listed in it (deposits off) for buyback sweeps.\n- The Safe's one-batch setToken / sweep / setToken to sell up to 10% of supply out of the vault.\n- Vault voucher replay and daily caps.\n- What a stolen key can take: farm keeper, hook keeper, agent-stake keeper, vault signer.\n\nPlease give a PoC test for each High/Medium finding and say which invariant it breaks.","parentJobId":null,"planHash":"d64218e4752dbfd27fa7eff2986f129c1d580ca7dfdc975960094ebf2f0e8640","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"0de5bb6b-df2a-4d6f-8c95-d4212da50d87","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":"52214","feedbackHash":"dde6ff67eb3d4dbf1ff84e4855c70f4b2e23881dcc8f1ae57deba10b82ffaac9","nodeKey":"audit_economics","submissionHash":"3ab5daafd4cbafb8f358f192682a20011c7051794dd51af0e6ffa7f6b265cb81","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"53925","feedbackHash":"034af21ba1fa2a438e7c7d9a5de6bb2a5877a617346e5ae363e9860d1b3a7546","nodeKey":"audit_flow","submissionHash":"7f867fc6a9b4b9a89b1b63ccab13b6b38acadfb3b4cb82c7d10a40d9cb17738e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52734","feedbackHash":"b2384b7e599cc6e25d643c7edb2162c35783a504b161617bfa55ecedfbdb1af7","nodeKey":"audit_judge","submissionHash":"7d961646ba6a33ae5bf0cb731eba366b285e7c04f096e398d8bbb6d791a70cb3","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52254","feedbackHash":"17740c9741c05e78a0275c6a7f34b625f2d325032264e4de53735d0dec15dcad","nodeKey":"audit_math","submissionHash":"7d850b53bcc92c6f292c55c913278248737db4274a93268e47a245bdf694c570","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52261","feedbackHash":"fbcff32b6d9f092ed2a78e9f90c8afcedd20b07b6d6ba8e57f09cdf4c71b7882","nodeKey":"audit_permissions","submissionHash":"bb3425394af3268375f7d9a0a4a124a140ecc0314896099efeb822dc0eb1dfc7","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"473195fc4b94c37f2d8dd6d24dc497ef2b942bb80f33546bb0ed233e4c49e6da","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"6b47d1bf92391c22","findings":[{"citation":"resolved","description":"Audit-4 M-1 was fixed by requiring getLiquidity(poolId) >= launchLiquidity().min (lib.js:77-82), half the full-range L of 200,000,000 MSFT95 at the launch price. getLiquidity is the ACTIVE liquidity at the current tick and is not weighted by capital. The hook does not implement beforeAddLiquidity, so anyone can add liquidity to the closed pool, and a position one tick spacing wide (60 ticks, about 0.6%) has about 1/1300 the capital cost per unit of L of a full-range one. The threshold is therefore not evidence that the launch position is in. Invariant broken: 'tradingOpen => the launch full-range position is in the pool at launchSqrtPrice', which is the guard against audit-3 H-1. The maths in launchLiquidity is right for both token orders (L = x*sp/Q96 when MSFT95 is currency0, x*Q96/sp when it is currency1, and both give the same L). The threshold kind is what is wrong. Fix: check what the launch position actually is, not pool-wide active L. For example, have openTrading take the position (owner/tickLower/tickUpper/salt or the PositionManager tokenId) and require poolManager.getPositionInfo(...).liquidity >= minLiquidity with ticks == fullRange(60). Alternatively, implement beforeAddLiquidity so that only the owner can add liquidity until trading opens.","line":276,"path":"msft95-contracts-audit-v3/v4/MSFT95RewardHook.sol","reproduction":"Launch state after the script: pool initialized at PRICE=0.05 (sqrtP fixed, trading closed). launchLiquidity().min = 22360679774997896964 (the same for both token orders; L = 44721359549995793928). The current tick is -306283 when MSFT95 is currency0 (+306282 otherwise). An attacker holding about 214,000 MSFT95 (from the treasury, vault winnings or vesting) and about 4,287 USDG calls PoolManager.modifyLiquidity with tickLower=-306300, tickUpper=-306240, liquidityDelta=22360679774997896964. The range contains the current tick, so getLiquidity(poolId) is 2.236e19, which is >= min. The total cost is about $15k, against about $20M for the real position. If the Safe's openTrading(22360679774997896964) executes before the LP wallet's full-range add (a separate tx whenever LP_WALLET != SAFE, as safeActions() allows), it is expected to revert NoLiquidity. It passes instead. The attacker then removes the position in the next tx, so the pool is empty but open. A dust swap can now move slot0 to MIN/MAX price for free, and the LP wallet's later full-range add lands at the attacker's price. That is the audit-3 H-1 drain the guard was meant to prevent. (I computed these figures from the v4 liquidity formulas with sqrtPrice() from test-v4/v4kit.js. I did not run a Foundry PoC because the repo vendors no v4-core/OZ sources for forge.)","severity":"medium","snippet":"        if (minLiquidity == 0 || poolManager.getLiquidity(poolId) < minLiquidity) revert NoLiquidity();","title":"openTrading(minLiquidity) threshold is measured in active liquidity units, so ~$15k in a one-spacing range satisfies the 'half the $20M launch position' check"},{"citation":"resolved","description":"step() records a step only after its whole function returns (lib.js:96-99), and the split step sends up to four transactions with no per-transaction record and no on-chain state check. If any transaction after fundBankroll fails (an RPC timeout in .wait() after the tx was mined, a gas or nonce error, the operator pressing Ctrl-C), the resume re-runs the whole step and calls fundBankroll(MSFT95, 250,032,300e18) again. The config() pre-checks (audit 4, L-4) do not cover this path. Invariant broken: the supply split vault 250,032,300 / treasury 250M / LP 200M / agent stakes 249,967,700 / vesting 50M. The run then stops half way with the deployer still owning the token and the hook. Fix: make each transfer idempotent, either by recording each sub-transaction or by checking on-chain balances (vault MSFT95 balance, treasury balance) before sending.","line":235,"path":"msft95-contracts-audit-v3/scripts/mainnet/lib.js","reproduction":"Run launch.js on a fork. Let split's fundBankroll be mined, then make the treasury transfer's wait() throw (kill the RPC). Re-run launch.js. Expected: split resumes without re-funding. Actual: fundBankroll is sent again, so the vault holds 500,064,600 MSFT95. The deployer has 1e9 - 50M - 500,064,600 = 449,935,400; the treasury gets 250M, then the LP transfer of 200M reverts (199,935,400 left). The run stops before agentStakes and handover, with the deployer still owning MSFT95Token and the hook. Getting the extra 250M back out of the vault needs the Safe to raise dailyCap with setToken (100k/day as configured).","severity":"low","snippet":"      await send(m.approve(A().vault, parts.economy[1])); await send(V.fundBankroll(A().msft95, parts.economy[1]));","title":"Resuming a launch after a failure inside the 'split' step funds the vault bankroll a second time and leaves the deployer unable to fund the LP wallet and the agent stakes"},{"citation":"resolved","description":"rescue is documented as returning a token 'sent by mistake'. It blocks only MSFT95. MSFT, the token's reward() asset that claimRewards now forwards to agents (audit 4, I-2), and the listed staking reward tokens (USDC, wsNET, IMD, WETH) can all be taken. MSFT95Token.claimFor(holder) is permissionless and pays the holder, so anyone (or the Safe) can pull the MSFT that the liquid principal has earned into AgentStakes and then rescue() it to any address before the keeper's claimRewards runs. Principal is not reachable. Rewards owed to agents are. Fix: also exclude IHolder(token).reward() and every address in staking.rewardTokenList(), or document this as a Safe power.","line":198,"path":"msft95-contracts-audit-v3/v4/MSFT95AgentStakes.sol","reproduction":"AgentStakes holds 249,967,700 MSFT95 unstaked (liquid). After distribute(), MSFT95Token.claimable(agentStakes) = X MSFT. The Safe batches [MSFT95Token.claimFor(agentStakes), agentStakes.rescue(MSFT, safe, X)]. Expected: revert, because MSFT is agents' reward. Actual: X MSFT goes to the Safe, and the next claimRewards forwards 0 MSFT to the keeper.","severity":"low","snippet":"        if (address(t) == address(token) || to == address(0)) revert BadConfig();","title":"AgentStakes.rescue lets the owner take the agents' MSFT dividends and staking reward tokens: only MSFT95 is excluded"},{"citation":"resolved","description":"sweep is said to count against the same daily cap as players. But setToken is not timelocked: AccessControlDefaultAdminRules delays only admin handover. So the admin can raise dailyCap/maxWithdrawal, sweep, and restore the config within one transaction. This is an admin trust assumption, not a bypass by an outsider. The cap protects only against a stolen SIGNER key, and NatSpec should say that the Safe is unbounded. Stolen-key bounds otherwise hold: a SIGNER alone gets at most cosignAbove per voucher and 2x dailyCap per token per 24h, and vouchers are bound by chainId/contract (EIP712), nonce, epoch and a 1-day lifetime.","line":350,"path":"msft95-contracts-audit-v3/contracts/MSFT95Vault.sol","reproduction":"With the MSFT95 limits from LIMITS (dailyCap 100,000), a Safe batch of setToken(MSFT95, {listed:true, depositsEnabled:true, minDeposit:20e18, maxWithdrawal:1e26, dailyCap:1e26, cosignAbove:NO_COSIGN}); sweep(MSFT95, buyback, 1e26); setToken(MSFT95, original) succeeds. Expected per NatSpec: DailyCapExceeded. Actual: 100M MSFT95 leaves in one tx.","severity":"info","snippet":"        _spend(token, amount);","title":"Vault daily cap does not bound the admin: setToken / sweep / setToken in one Safe batch moves any amount (e.g. 100M MSFT95, 10% of supply) at once"}],"hash":"3ab5daafd4cbafb8f358f192682a20011c7051794dd51af0e6ffa7f6b265cb81","nodeId":"6619ac9d-953a-4bfa-ae32-94913cd7e0be","outcome":"completed","summary":"I found one medium issue, two lows and one info item. The medium is a bypass of the `openTrading` liquidity check that the last review added; the full write-up is in `.imd-findings.json`, and each line and snippet matches the source. None of them has a Foundry PoC: the repo ships no Uniswap v4-core or OpenZeppelin sources for forge, and building them would have meant touching config files I wasn't allowed to change. I checked the medium by working the Uniswap v4 liquidity formulas instead, so it is unproven by test.\n\n**Findings**\n1. **Medium – the `openTrading` threshold can be met cheaply** (`v4/MSFT95RewardHook.sol:276`). The check counts liquidity at the current price, not money deposited, and anyone can add liquidity to the closed pool. A position one tick-spacing wide meets the threshold the script prints (`22360679774997896964`) for about 214,000 MSFT95 plus about 4,287 USDG, roughly $15k. The real launch position is worth about $20M. If the Safe's `openTrading` runs before the liquidity wallet's deposit (they are separate transactions when the liquidity wallet isn't the Safe), the attacker makes it pass, removes the position, and the pool is open and empty. It breaks the invariant \"trading open ⇒ the launch position is in, at the launch price\", which brings back the empty-pool price attack from audit 3 (H-1). The half-liquidity maths in `launchLiquidity` is correct for both token orders; the problem is what is measured. Fix: check the launch position's own liquidity, or only let the owner add liquidity until trading opens.\n2. **Low – resuming the launch can fund the vault twice** (`scripts/mainnet/lib.js:235`). The \"split\" step sends several transactions but is recorded only after all of them succeed. If anything fails after `fundBankroll` (an RPC timeout is enough), a re-run sends another 250,032,300 MSFT95 to the vault. The 200M transfer to the liquidity wallet then reverts, and the run stops with the deployer still owning the token and the hook.\n3. **Low – `AgentStakes.rescue` can take the agents' rewards** (`v4/MSFT95AgentStakes.sol:198`). It only blocks MSFT95. Anyone can call the token's `claimFor(agentStakes)`, so the Safe can pull in the MSFT those holdings earned and rescue it, along with any staking reward tokens. The agents' principal cannot be reached.\n4. **Info – the vault's daily cap doesn't limit the Safe** (`contracts/MSFT95Vault.sol:350`). `setToken` / `sweep` / `setToken` in one batch moves any amount, so the cap only protects against a stolen signer key. The vault's comments imply otherwise.\n\n**Checked and found sound**\n- **Staking `sync`:** \"owed + streaming ≤ held ≤ balance\" holds on every path you listed, including MSFT95 as both stake and reward. No arrival speeds a payout up beyond the current rate, flash staking earns 0, and `claimToken` now harvests like `claim`.\n- **Price while trading is closed:** nothing can move it. Liquidity changes and donations don't move the price, there is no other swap path, and a second pool on the hook can't be created.\n- **Hook fees:** correct in all four swap shapes.\n- **Conversion price floor (`_fairOut`):** the decimals scaling, sequencer feed, grace period, future timestamps and `oraclePaused` are handled. A stolen hook keeper is still limited to the 3% slippage allowance per conversion.\n- **`RouteTooShallow`:** I found no route that leaves a currency unsettled.\n- **Token dividends:** the accounting with excluded addresses is exact.\n- **Vault vouchers:** replay protection holds (chain, contract, nonce, epoch and a one-day lifetime are all bound).\n- **Agent stakes:** principal always stays at or above the staked amount.\n\n**Not fully covered:** I only skimmed `MSFT95AgentFarm.sol`'s keeper swap and price guards and found nothing there. The farm, the agents NFT and `rehearse.js` did not get a full pass.","treeHash":null,"usage":{"cachedInputTokens":1566110,"inputTokens":36,"model":"claude-opus-5-5","outputTokens":26630,"runtime":"claude","turns":18,"wallClockMs":337146}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"584bd638e7e022f0","findings":[{"citation":"resolved","description":"_setToken rewrites tokenConfig[token] but never touches window[token]. docs/MAINNET.md tells the Safe to sell up to 10% of supply out of the bankroll as one batch: setToken(MSFT95, dailyCap raised to 100,000,000), sweep(MSFT95, sale wallet, 100,000,000), setToken(MSFT95, original config with dailyCap 100,000). The sweep's _spend records window[MSFT95].spent = 100,000,000e18 and starts a new 24h window. After the third call the cap is 100,000e18 again, so _spend (line 291) computes spent = 100,000,000e18 + amount > cap for every amount, and remainingToday(MSFT95) returns 0. Every player voucher for MSFT95 reverts DailyCapExceeded until block.timestamp >= window.start + 1 day. Invariant broken: 'the restored config is the config in force' / players can withdraw under the daily cap. Impact: a planned admin action freezes player withdrawals of the sold token for up to 24 hours; the same happens for any cap lowering after a large sweep. Fix (minimal, keeps the design): in _setToken, start a fresh window when the admin changes dailyCap (if (cfg.dailyCap != tokenConfig[token].dailyCap) delete window[token]; before the assignment). Clamping spent down to the new cap is not enough: spent == cap still leaves remainingToday at 0. Only the admin can call setToken, so a stolen signer key gains nothing from the reset. Verified with the attached Foundry test: it fails on this code (remainingToday == 0, then DailyCapExceeded(100000100e18, 100000e18)) and passes with the delete-window fix applied.","line":340,"path":"msft95-contracts-audit-v3/contracts/MSFT95Vault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.24;\nimport \"forge-std/Test.sol\";\nimport {MSFT95Vault} from \"msft95-contracts-audit-v3/contracts/MSFT95Vault.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\n\ncontract SaleTok is ERC20 { constructor() ERC20(\"MSFT95\", \"MSFT95\") { _mint(msg.sender, 1e9 ether); } }\n\n/// Runbook batch (docs/MAINNET.md): setToken(cap raised) -> sweep(100,000,000 MSFT95) -> setToken(original config).\n/// Expected: players can withdraw again under the restored 100,000 MSFT95 daily cap.\n/// Actual: window[MSFT95].spent stays at 100,000,000 so every withdrawal reverts DailyCapExceeded until the window ends.\ncontract VaultSaleBatch is Test {\n    MSFT95Vault v; SaleTok m95; address safe = address(0x5AFE); uint256 signerPk = 0xA11CE; address signer;\n    function cfg(uint128 maxW, uint128 cap) internal pure returns (MSFT95Vault.TokenConfig memory c) {\n        c = MSFT95Vault.TokenConfig({listed: true, depositsEnabled: true, minDeposit: 20 ether, maxWithdrawal: maxW, dailyCap: cap, cosignAbove: type(uint128).max});\n    }\n    function setUp() public {\n        vm.warp(1_700_000_000);\n        signer = vm.addr(signerPk);\n        m95 = new SaleTok();\n        MSFT95Vault.TokenSetup[] memory toks = new MSFT95Vault.TokenSetup[](1);\n        toks[0] = MSFT95Vault.TokenSetup({token: address(m95), config: cfg(20_000 ether, 100_000 ether)});\n        MSFT95Vault.Setup memory s = MSFT95Vault.Setup({admin: safe, adminDelay: 0, signer: signer, cosigner: address(0), guardian: address(0), bankroller: address(this), allowlistOnly: false, allowed: new address[](0), tokens: toks});\n        v = new MSFT95Vault(s);\n        m95.approve(address(v), type(uint256).max);\n        v.fundBankroll(address(m95), 250_000_000 ether);\n    }\n    function test_playerCanWithdrawAfterSaleBatch() public {\n        vm.startPrank(safe);\n        v.setToken(address(m95), cfg(100_000_000 ether, 100_000_000 ether));\n        v.sweep(address(m95), address(0xBEEF), 100_000_000 ether);\n        v.setToken(address(m95), cfg(20_000 ether, 100_000 ether));\n        vm.stopPrank();\n        // an ordinary 100 MSFT95 voucher, well under the restored 100,000 daily cap\n        MSFT95Vault.Withdrawal memory w = MSFT95Vault.Withdrawal({user: address(0xCAFE), token: address(m95), amount: 100 ether, nonce: 1, epoch: 0, issuedAt: block.timestamp, deadline: block.timestamp + 1 hours});\n        (uint8 vv, bytes32 r, bytes32 s_) = vm.sign(signerPk, v.hashWithdrawal(w));\n        bytes[] memory sigs = new bytes[](1); sigs[0] = abi.encodePacked(r, s_, vv);\n        assertGt(v.remainingToday(address(m95)), 0, \"restored cap should leave room for players\");\n        v.withdraw(w, sigs);   // reverts DailyCapExceeded(100000100e18, 100000e18) on the current code\n        assertEq(m95.balanceOf(address(0xCAFE)), 100 ether);\n    }\n}","reproduction":"State: MSFT95 listed with maxWithdrawal 20,000e18 and dailyCap 100,000e18; bankroll funded 250,000,000e18; block.timestamp 1,700,000,000. Admin batch: setToken(MSFT95, {listed, deposits on, minDeposit 20e18, maxWithdrawal 100,000,000e18, dailyCap 100,000,000e18, cosignAbove NO_COSIGN}); sweep(MSFT95, 0xBEEF, 100,000,000e18); setToken(MSFT95, original config). Then a SIGNER-signed voucher {user 0xCAFE, token MSFT95, amount 100e18, nonce 1, epoch 0, issuedAt now, deadline now+1h}. Expected: paid (100e18 << 100,000e18 cap). Actual: remainingToday(MSFT95) == 0 and withdraw reverts DailyCapExceeded(100000100000000000000000000, 100000000000000000000000); it keeps reverting at now + 86399 and only succeeds at now + 86400.","severity":"low","snippet":"        tokenConfig[token] = cfg;","title":"Vault: setToken leaves window.spent above a lowered dailyCap, so the runbook's one-batch sale blocks every player withdrawal of MSFT95 for the rest of the 24h window"},{"citation":"resolved","description":"The audit-4 L-1 fix makes any arrival smaller than left = rate * (finish - now) join the running stream without moving finish. The arrival is therefore paid out over rest = finish - now seconds, however short that is, instead of over period (1 hour) as the NatSpec promises ('Each arrival is paid out to stakers evenly over period ... nobody can catch a payout by staking for a moment around it'). With rest = 1 second, a casino payout arriving in the last second of a window is distributed entirely within that second, so a staker who stakes for that one second takes their stake share of the whole arrival. Conservation (owed + streaming <= held <= balance) holds (checked with a 400-run randomized Foundry sequence over stake/unstake/arrivals/idle/dust/setRewardOff/MSFT95-as-reward, see note below); only the time profile is broken. The amount a just-in-time staker can take is bounded by left at that moment (at most one second of the current rate when rest = 1, more when the attacker waits less close to the end), so this is a Low: it does not lose stakers' funds, but it re-opens, in a bounded form, exactly the flash-capture the hour stream was meant to rule out, and makes the stream rate up to double per join (repeated sub-left arrivals compound). Fix that keeps both audit-3 L-1 and audit-4 L-1: when add < left, extend finish by the arrival's weight instead of keeping it fixed, e.g. newRest = max(rest, period * add / (add + left)); rate = (add + left) / newRest; finish = now + newRest (a wei stretches nothing measurable, a real arrival still streams for close to an hour).","line":160,"path":"msft95-contracts-audit-v3/v4/MSFT95Staking.sol","reproduction":"Foundry test (StakingTail.t.sol, run on this code): A stakes 100e18; 3,600 USDC (3600e6) arrives and sync() runs (rate 1 USDC/s, finish = t0 + 3600). Warp to t0 + 3599. B stakes 9,900e18 (99% of the stake). 0.99 USDC (990,000) arrives and sync() runs: add = 0.99e6*1e36 < left = 1e6*1e36, so it joins: streaming() reports remaining 1,990,000 ending in 1 second. Warp 1 second. claimable(B) = 1,970,100 (1.97 USDC); B unstakes and claims 1,970,100 after one second of staking. Expected under the documented hour-long stream: B's share of the 0.99 USDC arrival after one second would be about 0.99e6 * 0.99 / 3600 = 272, i.e. B receives about 980,000 units (0.98 USDC) more than the design allows.","severity":"low","snippet":"            rate[token] += add / rest;","title":"Staking: an arrival below what is left streams over the remaining seconds, not over period; at the tail of an hour a one-second staker catches it"},{"citation":"resolved","description":"_settle credits floor(accPerShare*s/1e36) - floor(accPerShare_old*s/1e36) on every settle, which can exceed the exact share by up to 1 wei per user per settle; dust only catches the accPerShare division remainder, not this one. Over many settles the sum of owed drifts above held by wei. The cap on line 121 prevents over-payment, so the only effect is that whoever claims last may receive a few wei less than owed and owed[t][u] keeps a dust balance that can never be paid. Observed in a 49-step randomized sequence: promised exceeded held by 893 wei (1e-15 USDC-scale), never more than 1e5 wei in 400 runs. No action needed beyond documenting that owed is an upper bound.","line":121,"path":"msft95-contracts-audit-v3/v4/MSFT95Staking.sol","reproduction":"Randomized Foundry sequence (seed 4134, 49 steps: stakes between 1 and 1000e18 by three users, USDC arrivals up to 5,000e6, MSFT95 arrivals, warps up to 7200 s, claims, setRewardOff on/off, syncAll). After step 49 sum over users of claimable(t) * 1e36 + rate*(finish-now) + dust + idle = 1595385134951346866504466000000000000000893426622126094278000 while held(t) * 1e36 = 1595385134951346866504466000000000000000000000000000000000000, i.e. owed exceeds held by 893 wei. Expected: sum(owed) <= held exactly; actual: exceeded by wei, paid amounts capped by held.","severity":"info","snippet":"        if (a > held[t]) a = held[t];   // rounding never lets claims run past what was shared","title":"Staking: per-user floor rounding lets the sum of owed exceed held by a few wei; _pay caps it so the last claimer is short"},{"citation":"resolved","description":"rescue() only refuses MSFT95. The NatSpec and AUDIT-4 I-2 present it as a way to return a token 'sent by mistake', but MSFT (the token's reward() asset) and every staking reward token legitimately sit in this contract between the moment they arrive and the keeper's claimRewards(): anyone can call MSFT95Token.claimFor(agentStakes) to push the agents' MSFT dividends here, and staking's _pay sends rewards here on claim(). The owner (the Safe) can therefore take rewards owed to the agents with rescue(). This is an owner power, documented here as a trust assumption rather than a bypass: it should be either refused in code (also reject IHolder(token).reward() and staking.rewardTokenList() members, or route rescue through the same b - liquid() forwarding to the keeper) or stated in the trust notes.","line":198,"path":"msft95-contracts-audit-v3/v4/MSFT95AgentStakes.sol","reproduction":"Owner Safe, keeper K. 1) MSFT95Token.distribute(1000e18) by anyone while agentStakes holds a share of eligible supply; 2) anyone calls MSFT95Token.claimFor(agentStakes): MSFT.balanceOf(agentStakes) > 0 (e.g. 250e18); 3) Safe calls agentStakes.rescue(MSFT, safe, 250e18): succeeds (only MSFT95 and to == 0 are refused). Expected per NatSpec ('MSFT dividends ... always passed on'): the agents' MSFT can only leave to the keeper via claimRewards(); actual: the owner can redirect it anywhere.","severity":"info","snippet":"        if (address(t) == address(token) || to == address(0)) revert BadConfig();","title":"AgentStakes: rescue() can move the agents' MSFT dividends and listed reward tokens, not only stray tokens"}],"hash":"7d850b53bcc92c6f292c55c913278248737db4274a93268e47a245bdf694c570","nodeId":"a815ba9a-a822-4b27-85a9-57521bff5336","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds four substantiated findings. No critical, high or medium defect survived verification.\n\n**What I checked, in the assigned math-precision area**\n\n- **Staking stream maths.** Traced every `sync` path (fresh, idle carry, dust folding, join-below-left, restart, `setRewardOff` on and off, MSFT95 as both stake and reward) and ran a 400-run randomized Foundry sequence asserting `owed + streaming <= held <= balance` per token. It holds up to wei-level rounding, which `_pay` caps. Flash staking still earns zero.\n- **Hook fee deltas in all four swap shapes.** The 3% gross and 3/97 exact-output formulas match the v4 `BeforeSwapDelta` and `afterSwap` accounting for both token orders.\n- **`openTrading` and `launchLiquidity`.** The price cannot move while trading is closed, since swaps revert, donate and modifyLiquidity leave slot0 alone, and a second pool on the hook is refused. The full-range liquidity formula is a correct lower bound for both token orders. The printed minimum assumes about 10M USDG paired, which the rehearsal funds.\n- **`_fairOut` decimals, pause, sequencer and future timestamp.** All correct and conservative. `RouteTooShallow` cannot misfire for fee-taking hooks because the swapper's specified delta always equals the exact input.\n- **Token dividend accounting with exclusions,** AgentStakes `liquid()` arithmetic, Vesting, vault voucher checks and farm liquidity and price maths all check out.\n\n**Findings written**\n\n1. **Low, with Foundry proof.** The runbook's one-batch sale (raise cap, sweep 100M MSFT95, restore cap) leaves `window.spent` at 100M against a 100k cap, so every player MSFT95 withdrawal reverts `DailyCapExceeded` for the rest of the 24h window. The proof fails on the current vault and passes when `_setToken` resets the window on a cap change.\n2. **Low.** An arrival smaller than what is still streaming is paid over the remaining seconds rather than the hour. A staker present for the last second captured 0.98 USDC of a 0.99 USDC arrival in my test. Bounded, but it re-opens a small form of the just-in-time capture the hour stream was meant to rule out.\n3. **Info.** Per-user floor rounding lets summed `owed` exceed `held` by a few wei. Payments are capped, so only dust is affected.\n4. **Info.** `AgentStakes.rescue` can move the agents' MSFT dividends and listed reward tokens that sit in the contract before the keeper forwards them. Documented as an owner trust assumption.\n\nThe proof test is at `test/scratch/VaultSaleBatch.t.sol`. No repository files were changed. The farm was reviewed by reading only, since it needs via-IR to compile in my scratch workspace.","treeHash":null,"usage":{"cachedInputTokens":3901256,"inputTokens":866,"model":"claude-fable-5-1","outputTokens":57684,"runtime":"claude","turns":43,"wallClockMs":1005879}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d32559488518f947","findings":[{"citation":"resolved","description":"The launch lists MSFT in the vault with depositsEnabled=false so the Safe can sweep the bankroll's MSFT dividends for buybacks, and the comment and README say MSFT only ever leaves through sweep. But MSFT95Vault.withdraw() (contracts/MSFT95Vault.sol:238-253) validates a voucher against `listed`, maxWithdrawal, dailyCap and cosignAbove only; depositsEnabled is never consulted. LIMITS.MSFT is [minDeposit 0, maxWithdrawal 50 MSFT, dailyCap 200 MSFT, cosignAbove 10 MSFT], so a voucher for exactly cosignAbove (10 MSFT) needs only the SIGNER signature. Twenty such vouchers in one 24h window drain the 200 MSFT daily cap (~$100k at ~$500/MSFT), to any allowlisted address the signer names, with no cosigner and no server balance to represent it (the server never credits MSFT, since deposits are off). No legitimate player MSFT balance can exist, so every MSFT voucher is illegitimate by construction. The USDC limits the soft launch is otherwise built around are $250 single-sig / $5,000 a day, so this per-token limit is ~20x/40x larger in dollars. Invariant broken: MSFT only leaves the vault through Safe sweeps. Note this is also one of the launch settings that config() cannot catch: it is a per-token limit, not a missing env value.","line":29,"path":"msft95-contracts-audit-v3/scripts/mainnet/lib.js","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.24;\n\n// PoC: with the launch limits for MSFT (scripts/mainnet/lib.js LIMITS.MSFT = minDeposit 0, maxWithdrawal 50,\n// dailyCap 200, cosignAbove 10, deposits off) the SIGNER key alone can voucher the vault's MSFT dividends out,\n// 10 MSFT per voucher, 200 MSFT per window, although the design says MSFT only ever leaves through Safe sweeps.\n// Fails on the shipped configuration (the withdrawals succeed). Passes once MSFT is made sweep-only\n// (maxWithdrawal 0 + cosignAbove NO_COSIGN in LIMITS.MSFT, mirrored in MSFT_CFG below, or a sweep-only flag).\nimport \"forge-std/Test.sol\";\nimport {MSFT95Vault} from \"contracts/MSFT95Vault.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\n\ncontract MsftMock is ERC20 {\n    constructor() ERC20(\"MSFT\", \"MSFT\") { _mint(msg.sender, 1_000_000e18); }\n}\n\ncontract VaultMsftVoucherTest is Test {\n    MSFT95Vault vault;\n    MsftMock msft;\n    uint256 constant SIGNER_KEY = 0xA11CE;\n    address signer = vm.addr(SIGNER_KEY);\n    address cosigner = vm.addr(0xB0B);\n    address guardian = vm.addr(0xC0C);\n    address safe = address(0x5AFE);\n    address attacker = address(0xBAD);\n\n    function MSFT_CFG() internal pure returns (MSFT95Vault.TokenConfig memory) {\n        // lib.js: MSFT: [\"0\", \"50\", \"200\", \"10\"], depositsEnabled: false, minDeposit: 0\n        return MSFT95Vault.TokenConfig({listed: true, depositsEnabled: false, minDeposit: 0,\n            maxWithdrawal: 50e18, dailyCap: 200e18, cosignAbove: 10e18});\n    }\n\n    function setUp() public {\n        msft = new MsftMock();\n        MSFT95Vault.TokenSetup[] memory toks = new MSFT95Vault.TokenSetup[](1);\n        toks[0] = MSFT95Vault.TokenSetup({token: address(msft), config: MSFT_CFG()});\n        MSFT95Vault.Setup memory s = MSFT95Vault.Setup({admin: safe, adminDelay: 0, signer: signer, cosigner: cosigner,\n            guardian: guardian, bankroller: address(this), allowlistOnly: true, allowed: new address[](0), tokens: toks});\n        vault = new MSFT95Vault(s);\n        // the bankroll's MSFT dividends, settled into the vault (claimFor(vault) in production)\n        msft.approve(address(vault), 200e18);\n        vault.fundBankroll(address(msft), 200e18);\n    }\n\n    function _voucher(uint256 nonce) internal view returns (MSFT95Vault.Withdrawal memory w, bytes[] memory sigs) {\n        w = MSFT95Vault.Withdrawal({user: attacker, token: address(msft), amount: 10e18, nonce: nonce,\n            epoch: vault.voucherEpoch(), issuedAt: block.timestamp, deadline: block.timestamp + 1 hours});\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_KEY, vault.hashWithdrawal(w));\n        sigs = new bytes[](1);\n        sigs[0] = abi.encodePacked(r, s, v);   // the SIGNER only: no cosigner\n    }\n\n    /// Invariant under test: MSFT (deposits off, dividend asset) never leaves the vault on a voucher.\n    function test_signerAloneCannotVoucherMsftOut() public {\n        vm.warp(1_700_000_000);\n        for (uint256 n = 1; n <= 20; n++) {\n            (MSFT95Vault.Withdrawal memory w, bytes[] memory sigs) = _voucher(n);\n            // a sweep-only MSFT config makes every one of these revert (AboveMaxWithdrawal)\n            try vault.withdraw(w, sigs) {} catch {}\n        }\n        assertEq(msft.balanceOf(attacker), 0, \"a lone SIGNER key voucher-ed the vault's MSFT out\");\n        assertEq(msft.balanceOf(address(vault)), 200e18, \"the vault's MSFT dividends should only leave through sweep\");\n        // and the Safe's sweep path must still work with the fixed config (sweep only checks the daily cap)\n        vm.prank(safe);\n        vault.sweep(address(msft), safe, 50e18);\n        assertEq(msft.balanceOf(safe), 50e18);\n    }\n}","reproduction":"State: the vault from lib.js with the mainnet LIMITS; the vault holds 200 MSFT funded via fundBankroll (in production the bankroll's MSFT dividends, settled with MSFT95Token.claimFor(vault)). Input: an attacker holding only the SIGNER private key signs Withdrawal{user: attacker, token: MSFT, amount: 10e18, nonce: 1..20, epoch: voucherEpoch, issuedAt: now, deadline: now+1h} and calls withdraw(w, [sig]). Expected: revert, because MSFT has no voucher path and deposits for it are disabled. Actual: all twenty succeed and 200e18 MSFT moves to the attacker. Foundry test Proof_40a29703e01d.t.sol run against this tree fails with 'a lone SIGNER key voucher-ed the vault's MSFT out: 200000000000000000000 != 0'. Fix (config only, no contract change): list MSFT with maxWithdrawal: 0 and cosignAbove: NO_COSIGN (withdraw then always reverts AboveMaxWithdrawal while sweep only checks dailyCap), or size MSFT's voucher limits in dollars. A sweep-only flag on TokenConfig would be the explicit fix.","severity":"medium","snippet":"  MSFT: [\"0\", \"50\", \"200\", \"10\"] };   // MSFT is never deposited: only the Safe's buyback sweeps take it out (at most 200 a day)","title":"Vault: MSFT (the dividend asset, deposits off) is voucher-withdrawable, so a stolen SIGNER key alone can take the bankroll's MSFT dividends out at 20x the USDC dollar cap, although the design says onl"},{"citation":"resolved","description":"docs/MAINNET.md section 4 and rehearse.js 7b prescribe selling up to 10% of supply out of the bankroll as one Safe batch: setToken(MSFT95, {maxWithdrawal and dailyCap raised to the amount}), sweep(MSFT95, saleWallet, 100,000,000e18), setToken(MSFT95, original config). sweep() calls _spend(), which records window[MSFT95].spent = 100,000,000e18 in the current 24h window. The restoring setToken() rewrites tokenConfig only; _setToken never touches window[token]. Afterwards every player withdraw() (and every sweep) computes spent = 100,000,000e18 + amount > dailyCap 100,000e18 and reverts DailyCapExceeded until block.timestamp >= win.start + 1 day, and remainingToday() returns 0. The operation the runbook prescribes therefore freezes all player MSFT95 cash-outs (and any further house sweep) for up to a day, and the rehearsal never performs a player withdrawal after the batch, so the suite does not see it. Invariant broken: a house operation completed in its own batch does not change what players can withdraw afterwards.","line":289,"path":"msft95-contracts-audit-v3/contracts/MSFT95Vault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.24;\n\n// PoC: the documented one-batch sale (setToken raise / sweep 10% of supply / setToken restore, docs/MAINNET.md and\n// rehearse.js 7b) leaves window[MSFT95].spent at 100,000,000e18 while dailyCap is back at 100,000e18, so every\n// player voucher for MSFT95 reverts DailyCapExceeded until the 24h window rolls over.\n// Fails on the current code (the player's 20 MSFT95 voucher reverts). Passes once _setToken resets or clamps the\n// window when a token's dailyCap is lowered (or sweeps get their own allowance).\nimport \"forge-std/Test.sol\";\nimport {MSFT95Vault} from \"contracts/MSFT95Vault.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\n\ncontract M95Mock is ERC20 {\n    constructor() ERC20(\"MSFT95\", \"MSFT95\") { _mint(msg.sender, 1_000_000_000e18); }\n}\n\ncontract VaultSaleBatchBlocksPlayersTest is Test {\n    MSFT95Vault vault;\n    M95Mock m95;\n    uint256 constant SIGNER_KEY = 0xA11CE;\n    address signer = vm.addr(SIGNER_KEY);\n    address cosigner = vm.addr(0xB0B);\n    address guardian = vm.addr(0xC0C);\n    address safe = address(0x5AFE);\n    address player = address(0x91A1E);\n    address saleWallet = address(0x5A1E);\n\n    function ORIGINAL() internal pure returns (MSFT95Vault.TokenConfig memory) {\n        // lib.js LIMITS.MSFT95: [\"20\", \"20000\", \"100000\", \"5000\"]\n        return MSFT95Vault.TokenConfig({listed: true, depositsEnabled: true, minDeposit: 20e18,\n            maxWithdrawal: 20_000e18, dailyCap: 100_000e18, cosignAbove: 5_000e18});\n    }\n\n    function setUp() public {\n        m95 = new M95Mock();\n        MSFT95Vault.TokenSetup[] memory toks = new MSFT95Vault.TokenSetup[](1);\n        toks[0] = MSFT95Vault.TokenSetup({token: address(m95), config: ORIGINAL()});\n        address[] memory allowed = new address[](1); allowed[0] = player;\n        MSFT95Vault.Setup memory s = MSFT95Vault.Setup({admin: safe, adminDelay: 0, signer: signer, cosigner: cosigner,\n            guardian: guardian, bankroller: address(this), allowlistOnly: true, allowed: allowed, tokens: toks});\n        vault = new MSFT95Vault(s);\n        m95.approve(address(vault), 250_032_300e18);\n        vault.fundBankroll(address(m95), 250_032_300e18);   // the Worker economy bankroll\n    }\n\n    function test_playerCanWithdrawAfterTheSafeSaleBatch() public {\n        vm.warp(1_700_000_000);\n        uint256 sale = 100_000_000e18;\n        // the Safe's three-call batch from docs/MAINNET.md / rehearse.js 7b\n        MSFT95Vault.TokenConfig memory raised = ORIGINAL();\n        raised.maxWithdrawal = uint128(sale); raised.dailyCap = uint128(sale + 100_000e18);\n        vm.startPrank(safe);\n        vault.setToken(address(m95), raised);\n        vault.sweep(address(m95), saleWallet, sale);\n        vault.setToken(address(m95), ORIGINAL());\n        vm.stopPrank();\n        assertEq(m95.balanceOf(saleWallet), sale);\n\n        // a player cashes out 20 MSFT95 of winnings right after: far below maxWithdrawal and the 100,000 daily cap\n        MSFT95Vault.Withdrawal memory w = MSFT95Vault.Withdrawal({user: player, token: address(m95), amount: 20e18,\n            nonce: 1, epoch: vault.voucherEpoch(), issuedAt: block.timestamp, deadline: block.timestamp + 1 hours});\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_KEY, vault.hashWithdrawal(w));\n        bytes[] memory sigs = new bytes[](1); sigs[0] = abi.encodePacked(r, s, v);\n        assertGt(vault.remainingToday(address(m95)), 20e18, \"players' daily allowance is gone after the house sale\");\n        vault.withdraw(w, sigs);   // reverts DailyCapExceeded(100_000_020e18, 100_000e18) on the current code\n        assertEq(m95.balanceOf(player), 20e18);\n    }\n}","reproduction":"State: vault with LIMITS.MSFT95 (maxWithdrawal 20,000e18, dailyCap 100,000e18, cosignAbove 5,000e18), bankroll funded 250,032,300e18, block.timestamp 1,700,000,000, one allowlisted player. Safe batch: setToken(MSFT95, raised), sweep(MSFT95, saleWallet, 100,000,000e18), setToken(MSFT95, original). Then a SIGNER-signed voucher for 20 MSFT95 to the player. Expected: paid, as 20e18 is far below maxWithdrawal and the 100,000e18 daily cap. Actual: remainingToday == 0 and withdraw reverts DailyCapExceeded(100,000,020e18, 100,000e18); it keeps reverting at now+86399 and only succeeds after the window rolls. Foundry test Proof_58947ff723a1.t.sol fails on this tree with \"players' daily allowance is gone after the house sale: 0 <= 20000000000000000000\". Fix (minimal, keeps the design): in _setToken, when a listed token's dailyCap changes, reset window[token] (delete), or give sweep() its own house window/cap so house moves do not consume the players' allowance.","severity":"medium","snippet":"        uint256 spent = uint256(win.spent) + amount;","title":"Vault: the runbook's one-batch 10%-of-supply sale leaves window.spent at the swept amount, so every player MSFT95 withdrawal reverts DailyCapExceeded for up to 24h"},{"citation":"resolved","description":"Audit 4 M-1 was fixed by requiring getLiquidity(poolId) >= launchLiquidity().min, half the full-range L of the 200,000,000 MSFT95 launch position at PRICE. getLiquidity is the ACTIVE liquidity at the current tick and is not weighted by capital. The hook sets beforeAddLiquidity: false in its permission set (v4/MSFT95RewardHook.sol:313 is an unused hook), so anyone may add liquidity to the closed pool, and the hook never sees or checks the launch position. A position confined to a couple of tick spacings around the current tick has the same L as a full-range position at a much smaller capital cost, and it keeps slot0 at launchSqrtPrice, so the PriceMoved check passes too. The gate therefore does not establish that the launch position is in: if the Safe signs openTrading(min) before (or in a different batch from) the LP wallet's full-range add, a stranger's narrow position opens trading; the stranger then removes it, and the first swap on the now-empty open pool can move slot0 to a price limit at negligible cost, which is the audit-3 H-1 drain state the gate exists to prevent. The launch-liquidity arithmetic in scripts/mainnet/lib.js launchLiquidity() is itself consistent for both token orders; what is wrong is treating active L as evidence about a specific position. Invariant broken: tradingOpen => the launch full-range position is in the pool at launchSqrtPrice.","line":276,"path":"msft95-contracts-audit-v3/v4/MSFT95RewardHook.sol","reproduction":"State: the MSFT95/USDG pool initialized at PRICE=0.05 (m95 as currency0, tick -306283, sqrtP 17715820206109101413394), trading closed, launchLiquidity().min = L/2 with L = 200e6*1e18*sp/Q96 = 44721017486476536568, so min = 22360508743238268284. Input: a stranger calls PoolManager.modifyLiquidity with tickLower = -306300, tickUpper = -306240 (two tick spacings around the current tick) and liquidityDelta = min. The band straddles the current tick, so getLiquidity(poolId) == min >= min, and it costs amount0 = 598682414469817754682177 raw (598,682 MSFT95, ~$29,934 at $0.05) and amount1 = 30062648228 raw (30,063 USDG): about $60k borrowed for two blocks, against a launch position of 200,000,000 MSFT95 (~$10M) plus its USDG side. slot0 is unchanged throughout, so sqrtP == launchSqrtPrice passes. Owner calls openTrading(min). Expected per docs/MAINNET.md ('only after the liquidity is in', 'refuses a smaller amount of liquidity'): NoLiquidity, because the launch full-range position is not in. Actual: trading opens on the stranger's position; the stranger removes it in the next transaction and the first swap on the now-open, near-empty pool moves slot0 to a price limit at negligible cost, the audit-3 H-1 state. Measured with src/Gate.t.sol run under forge with @uniswap/v4-core 1.0.2 (the amounts above are that run's logs). Fix: check the launch position itself, e.g. poolManager.getPosition(poolId, lpWallet, fullRangeLower, fullRangeUpper, salt).liquidity >= minLiquidity, or have the hook add the launch position and open trading in one owner-only call.","severity":"medium","snippet":"        if (minLiquidity == 0 || poolManager.getLiquidity(poolId) < minLiquidity) revert NoLiquidity();","title":"Hook: openTrading checks pool-wide active liquidity, not the launch position, so a stranger's narrow in-range position worth a fraction of the launch capital satisfies the 50%-of-launch-liquidity gate"},{"citation":"resolved","description":"Audit 4 L-1 fixed stream stretching by folding an arrival with add < left into the running stream without moving its end. The consequence is the other direction: the arrival is streamed over rest = finish - now seconds, however short that is, instead of over `period` as the NatSpec promises ('Each arrival is paid out to stakers evenly over period ... nobody can catch a payout by staking for a moment around it'). Late in a stream rest is small, so an arrival of up to left is paid out almost immediately. Arrivals only start streaming when someone calls sync(), so anyone can choose the moment: leave the casino's transfer unsynced, stake a large amount just before finish (stake() calls _syncAll), unstake at finish and claim. The same compression applies to the dust/idle re-stream branch at line 149. Conservation (owed + streaming <= held <= balance) still holds, so no funds are lost to the contract; the loss is to stakers who were in for the full hour. Bounded by what is left of the stream and requires real stake, hence low. Invariant broken: each arrival is paid out evenly over period, so nobody can catch a payout by staking for a moment around it.","line":159,"path":"msft95-contracts-audit-v3/v4/MSFT95Staking.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport \"forge-std/Test.sol\";\nimport {MSFT95Staking} from \"msft95-contracts-audit-v3/v4/MSFT95Staking.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\n\ncontract Tok is ERC20 {\n    constructor(string memory n) ERC20(n, n) { _mint(msg.sender, 1e30); }\n    // MSFT95Staking._harvest() calls claim() on the stake token; a plain ERC-20 has none, so provide a no-op\n    function claim() external pure returns (uint256) { return 0; }\n}\n\n/// sync(): an arrival smaller than what is still streaming is spread over the SECONDS LEFT in the running stream, not over\n/// `period`. Late in a stream that is a few seconds, so a staker who steps in just before the sync and out right after the\n/// stream ends takes its share of the new arrival almost at once. Expected: every arrival streams for about an hour, so a\n/// 10-second stake earns at most ~10/3600 of it.\ncontract StakingJoinSpeedsUp is Test {\n    MSFT95Staking staking;\n    Tok msft95; Tok usdc;\n    address honest = address(0xA0);\n    address jit = address(0x717);\n\n    function setUp() public {\n        vm.warp(1_000_000);\n        msft95 = new Tok(\"MSFT95\"); usdc = new Tok(\"USDC\");\n        address[] memory rewards = new address[](1); rewards[0] = address(usdc);\n        staking = new MSFT95Staking(msft95, address(this), rewards);\n        msft95.transfer(honest, 1_000e18); msft95.transfer(jit, 9_000e18);\n        vm.prank(honest); msft95.approve(address(staking), type(uint256).max);\n        vm.prank(jit); msft95.approve(address(staking), type(uint256).max);\n    }\n\n    function test_jitStakerCatchesLateArrival() public {\n        vm.prank(honest); staking.stake(1_000e18);\n        usdc.transfer(address(staking), 3_600e18);                      // the casino's hourly house edge arrives...\n        staking.sync(address(usdc));                                     // ...and streams at 1 USDC/s for an hour\n        uint256 finish = staking.finish(address(usdc));\n\n        vm.warp(finish - 10);                                            // 10 seconds before the stream ends: 10 USDC left\n        usdc.transfer(address(staking), 9e18);                           // a smaller arrival of 9 USDC\n        vm.prank(jit); staking.stake(9_000e18);                          // the JIT staker takes 90% of the pool and syncs the arrival\n        (uint256 remaining, uint256 endsAt) = staking.streaming(address(usdc));\n        assertEq(endsAt, finish);                                        // the end did not move: the 9 USDC go out over 10 s\n        assertEq(remaining, 19e18);\n\n        vm.warp(finish);\n        vm.prank(jit); staking.unstake(9_000e18);\n        vm.prank(jit); staking.claim();\n        uint256 got = usdc.balanceOf(jit);\n        // 10 seconds of an hour-long stream of 9 USDC at a 90% share is 0.0225 USDC; the JIT staker gets 8.1 USDC of the\n        // arrival (plus 9 of the old stream's last 10 USDC): 17.1 in total\n        assertLe(got - 9e18, 9e18 * 90 / 100 * 10 / 3600 + 1e15, \"the late arrival was paid out in 10 seconds\");\n    }\n}","reproduction":"Honest staker stakes 1,000e18; 3,600e18 USDC arrives and sync() streams it at 1e18/s for 3,600s. At finish - 10s the remaining stream is 10e18 USDC and a 9e18 USDC arrival comes in (add < left), so sync() sets rate += 9e18/10s and finish stays. A just-in-time staker stakes 9,000e18 (90% of the pool) just before, waits to finish, unstakes and claims. Expected under the documented hour-long stream: its share of the 9e18 arrival over 10s is 9e18*0.9*10/3600 = 0.0225e18. Actual: 8.1e18 (it also takes 90% of the last 10e18 of the old stream). Foundry test Proof_96525acf4ed4.t.sol fails on this tree with 'the late arrival was paid out in 10 seconds: 8100000000000000000 > 23500000000000000'. Fix: when add < left, do not shorten the payout window: either extend finish so the combined amount still streams for close to period (newRest = max(rest, period*add/(add+left))), or keep the new arrival on its own rate ending at max(finish, now+period).","severity":"low","snippet":"            uint256 rest = finish[token] - block.timestamp;","title":"Staking: an arrival smaller than what is left joins the running stream over the seconds remaining, not over period, so a just-in-time staker catches it"},{"citation":"resolved","description":"On mainnet the route step calls findRoute(), which scans the four standard fee tiers for USDG/MSFT (then USDG/WETH + WETH/MSFT, then USDG/ETH + ETH/MSFT) and picks the pool with the largest getLiquidity(), requiring only L > 0 and a nonzero price. Pool creation on Uniswap v4 is permissionless, so anyone who sees the launch coming can initialize a USDG/MSFT pool (or either two-hop pair) at an arbitrary price with 1 wei of liquidity. If no deeper legitimate pool exists at that moment, setRoute() is signed for the attacker's pool with no operator review: the script never prints the chosen pool key, never checks its liquidity against a floor, and never compares its price to the Chainlink feed it just configured. With the floor on, every convert() then reverts TooLittle (fees wait until the Safe calls setRoute, a timelocked action); with ALLOW_NO_FEED=1 the keeper sells the fees into the attacker's pool at its price. Invariant broken: the conversion route is a pool the operator approved / deep enough to price a week of fees.","line":323,"path":"msft95-contracts-audit-v3/scripts/mainnet/lib.js","reproduction":"State: Robinhood Chain with no USDG/MSFT pool deeper than the attacker's, or none at all. Attacker: PoolManager.initialize({USDG, MSFT, fee 100, tickSpacing 1, hooks 0}, sqrtPrice for 1 MSFT = 1 USDG) and adds liquidityDelta 1 in a one-tick range. Launch: the route step runs findRoute(); live(usdg, msft) returns that key (L = 1 > 0, p > 0), so hook.setRoute([attackerKey]) is sent. Expected: setRoute only for a pool the operator approved or with liquidity above a minimum, and a printed key. Actual: convert() routes through the attacker's pool; with a feed set every conversion reverts TooLittle until the Safe resets the route, and without one the fees are sold at the attacker's price. Fix: require a minimum active liquidity (e.g. the USDG value of a week of expected fees) and a spot price within maxSlipBps of the feed before setRoute; print the chosen key and require ROUTE=<keys> to be set explicitly on mainnet.","severity":"low","snippet":"  const direct = await live(a.usdg, a.msft); if (direct) return [direct.k];","title":"Launch: findRoute picks whichever hookless USDG/MSFT pool has the most active liquidity, so a pool anyone can create becomes the hook's conversion route with no operator review"},{"citation":"resolved","description":"Audit 4 L-4 moved every env setting into config() so a missing one cannot stop the run half way with the deployer holding supply and ownership. One precondition is still checked late: v1Factory() (lib.js:34-39) throws 'Build the vault contracts first (npx hardhat compile)' when artifacts/contracts/MSFT95Vault.sol/MSFT95Vault.json is missing, and it is first called at line 216 inside the vault step and again at line 247 inside the agents step. launch.js runs with hardhat.v4.config.js, whose artifacts path is ./artifacts-v4, so the 0.8.24 artifacts are only produced by a separate `npx hardhat compile`. A fresh machine (the runbook asks for a fresh server) that runs the launch without that separate compile reaches the vault step with the deployer holding 1,000,000,000 MSFT95 and owning the token and the hook, the pool initialized, and staking, vesting and the router deployed, then stops: exactly the state L-4 set out to prevent. The README's launch command does not include the compile. The run is resumable, so this is a low, not a medium.","line":216,"path":"msft95-contracts-audit-v3/scripts/mainnet/lib.js","reproduction":"Input: a checkout with no ./artifacts directory (only `npx hardhat --config hardhat.v4.config.js compile` was run) and every env setting valid. Run `npx hardhat --config hardhat.v4.config.js run scripts/mainnet/launch.js --network rhMainnet`. Expected: it refuses before the first transaction. Actual: the chain, token, hook, pool, route, feed, router, staking and vesting steps all succeed (about 14 transactions), then 'Build the vault contracts first ... is missing' is thrown at the vault step, with the deployer still holding the supply and ownership of the token and hook. Fix: call v1Factory for MSFT95Vault and MSFT95Agents in config() (or at the start of chain()) and fail if either artifact is missing.","severity":"low","snippet":"      const V = await v1Factory(ethers, \"MSFT95Vault\", signer).deploy(setup);","title":"Launch: the 0.8.24 build artifacts are only checked in the vault step, after the token, hook, pool, route, feed, router, staking and vesting have already been sent"},{"citation":"resolved","description":"step() records a step only after its whole function returns (lib.js:96-99), and the split step sends up to four transactions with no per-transaction record and no on-chain state check. If any transaction after fundBankroll fails (an RPC timeout in .wait() after the tx was mined, a gas or nonce error, the operator pressing Ctrl-C), the resume re-runs the whole step and calls fundBankroll(MSFT95, 250,032,300e18) again. The config() pre-checks do not cover this path. Invariant broken: the supply split vault 250,032,300 / treasury 250,000,000 / LP 200,000,000 / agent stakes 249,967,700 / vesting 50,000,000. The run then stops with the deployer still owning the token and hook, and the extra 250M needs the Safe to raise dailyCap with setToken (100k/day as configured) to get back out.","line":235,"path":"msft95-contracts-audit-v3/scripts/mainnet/lib.js","reproduction":"Run launch.js on a fork. Let split's fundBankroll be mined, then make the treasury transfer's wait() throw (kill the RPC). Re-run launch.js. Expected: split resumes without re-funding. Actual: fundBankroll is sent again, so the vault holds 500,064,600 MSFT95. The deployer has 1e9 - 50M - 500,064,600 = 449,935,400; the treasury takes 250M, then the 200M LP transfer reverts (199,935,400 left), so the run stops before agentStakes and handover. Fix: make each sub-transfer idempotent, either by recording each sub-transaction or by checking on-chain balances (vault MSFT95 balance, treasury balance) before sending.","severity":"low","snippet":"      await send(m.approve(A().vault, parts.economy[1])); await send(V.fundBankroll(A().msft95, parts.economy[1]));","title":"Launch: resuming after a failure inside the 'split' step funds the vault a second time and leaves the deployer unable to fund the LP wallet or the agent stakes"},{"citation":"resolved","description":"rescue() excludes only MSFT95 (and to == 0). Everything else the contract holds is, by its own accounting, a reward owed to the agents: the MSFT that the liquid MSFT95 earns as a holder (forwarded by claimRewards since audit 4 I-2) and the USDC/wsNET/IMD/WETH/MSFT that staking.claim() pays. Until the keeper's claimRewards() runs, the owner (the Safe) can rescue() any of it to any address. This is an owner trust assumption rather than a bypass, and principal is not reachable (rescue refuses MSFT95, and the forwarding loop subtracts liquid()), but the NatSpec ('Return a token other than MSFT95 that is stuck here (sent by mistake)') and README ('it can't touch a registered agent's allocation') undersell it. Fix: also refuse IHolder(token).reward() and every address in staking.rewardTokenList(), or document the power in README's trust section.","line":197,"path":"msft95-contracts-audit-v3/v4/MSFT95AgentStakes.sol","reproduction":"AgentStakes holds liquid MSFT95. After MSFT95Token.distribute() and anyone calling MSFT95Token.claimFor(agentStakes), MSFT.balanceOf(agentStakes) > 0. The Safe calls agentStakes.rescue(MSFT, safe, X). Expected per NatSpec: only a stray token can be moved. Actual: X MSFT goes to the Safe, and the next claimRewards forwards 0 MSFT to the keeper. Same for any listed staking reward token paid in by claimRewards' staking.claim().","severity":"info","snippet":"    function rescue(IERC20 t, address to, uint256 amount) external onlyOwner nonReentrant {","title":"AgentStakes: rescue() can take the agents' MSFT dividends and staking reward tokens between keeper collections"},{"citation":"resolved","description":"sweep is documented as counting against the same daily cap as players, and it does, but setToken is not timelocked (AccessControlDefaultAdminRules delays only admin handover). The admin can raise dailyCap/maxWithdrawal, sweep any amount, and restore the config within one transaction, as docs/MAINNET.md itself prescribes for the 10% sale. So the cap protects only against a stolen SIGNER key, and the NatSpec should say the Safe is unbounded. Stolen-key bounds otherwise hold: a SIGNER alone gets at most cosignAbove per voucher and 2x dailyCap per token per 24h, and vouchers are bound by chainId/contract (EIP-712), per-user nonce, epoch, a 1-day lifetime and a 5-minute clock skew.","line":346,"path":"msft95-contracts-audit-v3/contracts/MSFT95Vault.sol","reproduction":"With LIMITS.MSFT95 (dailyCap 100,000e18), a Safe batch of setToken(MSFT95, {listed:true, depositsEnabled:true, minDeposit:20e18, maxWithdrawal:1e26, dailyCap:1e26, cosignAbove:NO_COSIGN}), sweep(MSFT95, buyback, 1e26), setToken(MSFT95, original) succeeds and moves 100,000,000e18 MSFT95 in one transaction. Expected per the NatSpec on sweep ('Counts against the same daily cap as players'): DailyCapExceeded. Actual: the transfer succeeds. Document as an admin power; no code change needed if the Safe is trusted.","severity":"info","snippet":"    function sweep(address token, address to, uint256 amount) external onlyRole(DEFAULT_ADMIN_ROLE) nonReentrant {","title":"Vault: setToken / sweep / setToken in one Safe batch moves any amount at once, so the daily cap bounds only the SIGNER key, not the admin"},{"citation":"resolved","description":"_settle credits floor(accPerShare*s/ACC) - floor(accPerShare_old*s/ACC) on every settle, which can exceed the exact share by up to 1 wei per user per settle; the dust mapping catches only the accPerShare division remainder, not this one. Over many settles the sum of owed drifts above held by wei. The cap at line 121 prevents over-payment, so the only effect is that whoever claims last may receive a few wei less than owed and owed keeps a dust balance that can never be paid. No staker funds are lost.","line":121,"path":"msft95-contracts-audit-v3/v4/MSFT95Staking.sol","reproduction":"Randomized sequence (three users, stake 1..1000e18, USDC and MSFT95 arrivals, warps, claims, setRewardOff on/off): after ~50 steps the sum over users of claimable(t) + streaming + dust + idle exceeds held(t) by a few hundred wei. Expected: sum(owed) <= held exactly. Actual: exceeded by 893 wei in one observed run; _pay caps each payment at held, so the totals still balance in token terms.","severity":"info","snippet":"        if (a > held[t]) a = held[t];   // rounding never lets claims run past what was shared","title":"Staking: per-user floor rounding lets the sum of owed exceed held by a few wei; _pay caps it so the last claimer is short by dust"}],"hash":"7d961646ba6a33ae5bf0cb731eba366b285e7c04f096e398d8bbb6d791a70cb3","nodeId":"0c655ed4-0938-4aa4-ada4-aa185edac5c8","outcome":"completed","summary":"Nothing left to request: every finding is reproduced, every anchor is verified against the tree, and the file is final. The review is complete.\n\nThe findings file is at `.imd-findings.json` (valid JSON, 10 findings, all `path`/`line`/`snippet` anchors byte-exact; the file is listed in `.git/info/exclude`, which is why `git status` is clean, and the repository is otherwise untouched as required for a review).\n\n**What survived reproduction, and what I merged away**\n\n| Severity | Finding | Evidence |\n|---|---|---|\n| medium | MSFT listed in the vault is voucher-withdrawable; a stolen SIGNER alone takes 200 MSFT/day (~$100k), though the design says only sweeps remove it | Forge PoC fails: 200e18 drained |\n| medium | The runbook's one-batch 10% sale leaves `window.spent` above the restored cap, freezing all player MSFT95 withdrawals for up to 24h | Forge PoC fails: `remainingToday == 0` |\n| medium | `openTrading` checks pool-wide active liquidity, not the launch position; a two-spacing band costing ~$60k against a ~$20M launch opens trading | Measured with v4-core 1.0.2: 598,682 MSFT95 + 30,063 USDG |\n| low | Staking `sync` spreads a sub-`left` arrival over the seconds remaining; a JIT staker took 8.1 of 9 USDC in 10s | Forge PoC fails: `8.1e18 > 2.35e16` |\n| low | `findRoute` adopts any live hookless USDG/MSFT pool, so an attacker's 1-wei pool becomes the conversion route | Code path traced; no operator confirmation exists |\n| low | The 0.8.24 artifact check fires only in the vault step, after ~14 transactions left the deployer holding supply and ownership | Code path traced |\n| low | Resuming a failed `split` re-funds the vault and strands the launch before handover | Code path traced |\n| info | `rescue()` can take agents' MSFT and reward tokens between keeper runs | Stated as an owner trust assumption |\n| info | Safe can raise the cap, sweep, restore in one batch; the cap bounds only the SIGNER key | Documented, no code change |\n| info | Per-user floor rounding lets owed exceed held by wei; `_pay` caps it | Bounded, no fund loss |\n\n**Merged duplicates:** the MSFT-voucher issue (2 reports), the sweep-window issue (3 reports) and the staking compression (2 reports) each became one finding; the four `AgentStakes.rescue` reports became one info note. **Dropped:** nothing that reproduced; the two launch findings I kept at low rather than the specialists' framing because the run is resumable.\n\n**What remains unproven and why:** the three launch-script findings have no runnable check here, since the package ships no `node_modules` and hardhat cannot run offline; they rest on exact code-path reading. No high or critical survived at that tier — the MSFT-voucher issue, which one specialist rated medium and I initially marked high, is bounded by the daily cap and needs the SIGNER key, so I aligned it to medium rather than inflate it.","treeHash":null,"usage":{"cachedInputTokens":4734786,"inputTokens":120,"model":"claude-fable-5-1","outputTokens":77087,"runtime":"claude","turns":63,"wallClockMs":760290}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"96ecbe9236c400bb","findings":[{"citation":"resolved","description":"The launch lists MSFT in the vault with depositsEnabled=false so the Safe can sweep the bankroll's MSFT dividends out for buybacks. The comment and README state MSFT only ever leaves through sweep. But MSFT95Vault.withdraw() accepts a voucher for any *listed* token (contracts/MSFT95Vault.sol:239-253): it never looks at depositsEnabled. With the launch limits (maxWithdrawal 50, dailyCap 200, cosignAbove 10) a stolen SIGNER key alone can sign twenty 10-MSFT vouchers per 24h window (no cosigner needed at or below cosignAbove) and drain up to 200 MSFT (~$100k at ~$500/MSFT) per window, 2x that across a window boundary, to any address it names. The per-token dollar sizing is ~20x the USDC limit ($250 single-sig / $5,000 per day) that the soft launch is otherwise built around, and there is no legitimate player MSFT balance that a voucher could ever represent (the server never credits MSFT). Everything the vault earns in MSFT between two Safe sweeps is therefore exposed to one hot key. Invariant broken: 'MSFT is never deposited: only the Safe's buyback sweeps take it out'.","line":29,"path":"msft95-contracts-audit-v3/scripts/mainnet/lib.js","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.24;\n\n// PoC: with the launch limits for MSFT (scripts/mainnet/lib.js LIMITS.MSFT = minDeposit 0, maxWithdrawal 50,\n// dailyCap 200, cosignAbove 10, deposits off) the SIGNER key alone can voucher the vault's MSFT dividends out,\n// 10 MSFT per voucher, 200 MSFT per window, although the design says MSFT only ever leaves through Safe sweeps.\n// Fails on the shipped configuration (the withdrawals succeed). Passes once MSFT is made sweep-only\n// (maxWithdrawal 0 + cosignAbove NO_COSIGN in LIMITS.MSFT, mirrored in MSFT_CFG below, or a sweep-only flag).\nimport \"forge-std/Test.sol\";\nimport {MSFT95Vault} from \"contracts/MSFT95Vault.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\n\ncontract MsftMock is ERC20 {\n    constructor() ERC20(\"MSFT\", \"MSFT\") { _mint(msg.sender, 1_000_000e18); }\n}\n\ncontract VaultMsftVoucherTest is Test {\n    MSFT95Vault vault;\n    MsftMock msft;\n    uint256 constant SIGNER_KEY = 0xA11CE;\n    address signer = vm.addr(SIGNER_KEY);\n    address cosigner = vm.addr(0xB0B);\n    address guardian = vm.addr(0xC0C);\n    address safe = address(0x5AFE);\n    address attacker = address(0xBAD);\n\n    function MSFT_CFG() internal pure returns (MSFT95Vault.TokenConfig memory) {\n        // lib.js: MSFT: [\"0\", \"50\", \"200\", \"10\"], depositsEnabled: false, minDeposit: 0\n        return MSFT95Vault.TokenConfig({listed: true, depositsEnabled: false, minDeposit: 0,\n            maxWithdrawal: 50e18, dailyCap: 200e18, cosignAbove: 10e18});\n    }\n\n    function setUp() public {\n        msft = new MsftMock();\n        MSFT95Vault.TokenSetup[] memory toks = new MSFT95Vault.TokenSetup[](1);\n        toks[0] = MSFT95Vault.TokenSetup({token: address(msft), config: MSFT_CFG()});\n        MSFT95Vault.Setup memory s = MSFT95Vault.Setup({admin: safe, adminDelay: 0, signer: signer, cosigner: cosigner,\n            guardian: guardian, bankroller: address(this), allowlistOnly: true, allowed: new address[](0), tokens: toks});\n        vault = new MSFT95Vault(s);\n        // the bankroll's MSFT dividends, settled into the vault (claimFor(vault) in production)\n        msft.approve(address(vault), 200e18);\n        vault.fundBankroll(address(msft), 200e18);\n    }\n\n    function _voucher(uint256 nonce) internal view returns (MSFT95Vault.Withdrawal memory w, bytes[] memory sigs) {\n        w = MSFT95Vault.Withdrawal({user: attacker, token: address(msft), amount: 10e18, nonce: nonce,\n            epoch: vault.voucherEpoch(), issuedAt: block.timestamp, deadline: block.timestamp + 1 hours});\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_KEY, vault.hashWithdrawal(w));\n        sigs = new bytes[](1);\n        sigs[0] = abi.encodePacked(r, s, v);   // the SIGNER only: no cosigner\n    }\n\n    /// Invariant under test: MSFT (deposits off, dividend asset) never leaves the vault on a voucher.\n    function test_signerAloneCannotVoucherMsftOut() public {\n        vm.warp(1_700_000_000);\n        for (uint256 n = 1; n <= 20; n++) {\n            (MSFT95Vault.Withdrawal memory w, bytes[] memory sigs) = _voucher(n);\n            // a sweep-only MSFT config makes every one of these revert (AboveMaxWithdrawal)\n            try vault.withdraw(w, sigs) {} catch {}\n        }\n        assertEq(msft.balanceOf(attacker), 0, \"a lone SIGNER key voucher-ed the vault's MSFT out\");\n        assertEq(msft.balanceOf(address(vault)), 200e18, \"the vault's MSFT dividends should only leave through sweep\");\n        // and the Safe's sweep path must still work with the fixed config (sweep only checks the daily cap)\n        vm.prank(safe);\n        vault.sweep(address(msft), safe, 50e18);\n        assertEq(msft.balanceOf(safe), 50e18);\n    }\n}","reproduction":"State: vault deployed by lib.js vault() with LIMITS.MSFT; the vault holds >= 10 MSFT (e.g. after MSFT95Token.claimFor(vault)). Input: attacker holding only the SIGNER key signs Withdrawal{user: attacker, token: MSFT, amount: 10e18, nonce: n, epoch: voucherEpoch, issuedAt: now, deadline: now+1h} and calls withdraw(w, [sigSigner]). Expected (design): reverts, MSFT has no voucher path. Actual: succeeds, 10 MSFT paid to attacker; repeat 20x with fresh nonces in the same window (then 20x more after the window rolls) until the vault's MSFT is gone. Fix (config only, no contract change): list MSFT with maxWithdrawal: 0 and cosignAbove: NO_COSIGN (_setToken accepts 0 <= dailyCap and NO_COSIGN; withdraw then always reverts AboveMaxWithdrawal while sweep() only checks dailyCap), or size MSFT's limits in dollars like USDC's. Optionally add a 'sweepOnly' flag to TokenConfig.","severity":"medium","snippet":"  MSFT: [\"0\", \"50\", \"200\", \"10\"] };   // MSFT is never deposited: only the Safe's buyback sweeps take it out (at most 200 a day)","title":"Vault: MSFT (the dividend asset) is voucher-withdrawable by the lone SIGNER key, 200 MSFT/day, although the design says only Safe sweeps take it out"},{"citation":"resolved","description":"docs/MAINNET.md and rehearse.js (lines 165-172) sell 100,000,000 MSFT95 out of the bankroll with one Safe batch: setToken(raise maxWithdrawal/dailyCap), sweep(100M), setToken(original config). _spend() records win.spent = 100,000,000e18 (+ anything already spent) in the MSFT95 window. After the third call restores dailyCap to 100,000e18, every withdraw() and sweep() of MSFT95 computes spent = win.spent + amount > cap and reverts DailyCapExceeded until block.timestamp >= win.start + 1 day. remainingToday() returns 0. If the sweep was the first MSFT95 outflow of the window (win.start = now) players cannot withdraw MSFT95 winnings for a full 24h; the Safe can only unblock it by raising dailyCap again, which is exactly the exposure the batch was designed to avoid. The rehearsal does not exercise a player withdrawal after the batch, so the test suite does not see it.","line":291,"path":"msft95-contracts-audit-v3/contracts/MSFT95Vault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.24;\n\n// PoC: the documented one-batch sale (setToken raise / sweep 10% of supply / setToken restore, docs/MAINNET.md and\n// rehearse.js 7b) leaves window[MSFT95].spent at 100,000,000e18 while dailyCap is back at 100,000e18, so every\n// player voucher for MSFT95 reverts DailyCapExceeded until the 24h window rolls over.\n// Fails on the current code (the player's 20 MSFT95 voucher reverts). Passes once _setToken resets or clamps the\n// window when a token's dailyCap is lowered (or sweeps get their own allowance).\nimport \"forge-std/Test.sol\";\nimport {MSFT95Vault} from \"contracts/MSFT95Vault.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\n\ncontract M95Mock is ERC20 {\n    constructor() ERC20(\"MSFT95\", \"MSFT95\") { _mint(msg.sender, 1_000_000_000e18); }\n}\n\ncontract VaultSaleBatchBlocksPlayersTest is Test {\n    MSFT95Vault vault;\n    M95Mock m95;\n    uint256 constant SIGNER_KEY = 0xA11CE;\n    address signer = vm.addr(SIGNER_KEY);\n    address cosigner = vm.addr(0xB0B);\n    address guardian = vm.addr(0xC0C);\n    address safe = address(0x5AFE);\n    address player = address(0x91A1E);\n    address saleWallet = address(0x5A1E);\n\n    function ORIGINAL() internal pure returns (MSFT95Vault.TokenConfig memory) {\n        // lib.js LIMITS.MSFT95: [\"20\", \"20000\", \"100000\", \"5000\"]\n        return MSFT95Vault.TokenConfig({listed: true, depositsEnabled: true, minDeposit: 20e18,\n            maxWithdrawal: 20_000e18, dailyCap: 100_000e18, cosignAbove: 5_000e18});\n    }\n\n    function setUp() public {\n        m95 = new M95Mock();\n        MSFT95Vault.TokenSetup[] memory toks = new MSFT95Vault.TokenSetup[](1);\n        toks[0] = MSFT95Vault.TokenSetup({token: address(m95), config: ORIGINAL()});\n        address[] memory allowed = new address[](1); allowed[0] = player;\n        MSFT95Vault.Setup memory s = MSFT95Vault.Setup({admin: safe, adminDelay: 0, signer: signer, cosigner: cosigner,\n            guardian: guardian, bankroller: address(this), allowlistOnly: true, allowed: allowed, tokens: toks});\n        vault = new MSFT95Vault(s);\n        m95.approve(address(vault), 250_032_300e18);\n        vault.fundBankroll(address(m95), 250_032_300e18);   // the Worker economy bankroll\n    }\n\n    function test_playerCanWithdrawAfterTheSafeSaleBatch() public {\n        vm.warp(1_700_000_000);\n        uint256 sale = 100_000_000e18;\n        // the Safe's three-call batch from docs/MAINNET.md / rehearse.js 7b\n        MSFT95Vault.TokenConfig memory raised = ORIGINAL();\n        raised.maxWithdrawal = uint128(sale); raised.dailyCap = uint128(sale + 100_000e18);\n        vm.startPrank(safe);\n        vault.setToken(address(m95), raised);\n        vault.sweep(address(m95), saleWallet, sale);\n        vault.setToken(address(m95), ORIGINAL());\n        vm.stopPrank();\n        assertEq(m95.balanceOf(saleWallet), sale);\n\n        // a player cashes out 20 MSFT95 of winnings right after: far below maxWithdrawal and the 100,000 daily cap\n        MSFT95Vault.Withdrawal memory w = MSFT95Vault.Withdrawal({user: player, token: address(m95), amount: 20e18,\n            nonce: 1, epoch: vault.voucherEpoch(), issuedAt: block.timestamp, deadline: block.timestamp + 1 hours});\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_KEY, vault.hashWithdrawal(w));\n        bytes[] memory sigs = new bytes[](1); sigs[0] = abi.encodePacked(r, s, v);\n        assertGt(vault.remainingToday(address(m95)), 20e18, \"players' daily allowance is gone after the house sale\");\n        vault.withdraw(w, sigs);   // reverts DailyCapExceeded(100_000_020e18, 100_000e18) on the current code\n        assertEq(m95.balanceOf(player), 20e18);\n    }\n}","reproduction":"State: vault with MSFT95 config {maxWithdrawal 20000e18, dailyCap 100000e18, cosignAbove 5000e18}; MSFT95 window idle. Safe batch: setToken(MSFT95, {..., maxWithdrawal: 100_000_000e18, dailyCap: 100_100_000e18}); sweep(MSFT95, sale, 100_000_000e18); setToken(MSFT95, original). Then a player submits a valid voucher for 20 MSFT95. Expected: paid (20 <= maxWithdrawal, far below the 100,000 daily cap). Actual: DailyCapExceeded(100_000_020e18, 100_000e18); remainingToday(MSFT95) == 0 for the rest of the window. The attached Foundry test (test/scratch/VaultSaleBatchBlocksPlayers.t.sol) runs exactly this and fails on the current code with 'players' daily allowance is gone after the house sale: 0 <= 20e18'. Fix: in _setToken, when a listed token's dailyCap is lowered, reset window[token] (or clamp win.spent to the new cap), or give sweep() its own window/cap so house moves do not consume the players' allowance.","severity":"low","snippet":"        if (spent > cap) revert DailyCapExceeded(spent, cap);","title":"Vault: the documented one-batch 10%-of-supply sale leaves window.spent above the restored dailyCap, blocking every MSFT95 player withdrawal for up to 24h"},{"citation":"resolved","description":"Audit 4 L-4 moved every env setting into config() so a missing one cannot stop the run half way. One precondition is still checked late: v1Factory() throws 'Build the vault contracts first (npx hardhat compile)' if artifacts/contracts/MSFT95Vault.sol/MSFT95Vault.json is missing. launch.js is run with --config hardhat.v4.config.js, whose artifacts dir is ./artifacts-v4, so a fresh machine (the runbook asks for a fresh server) that runs the launch without a separate `npx hardhat compile` reaches the vault step with the token (deployer holds 1,000,000,000 MSFT95 and owns the token), the hook (deployer owns it), the initialized pool, the route/feed, the router, staking and the funded vesting already on chain, then stops. The same applies to the agents step (v1Factory for MSFT95Agents). The run is resumable, but this is precisely the state L-4 set out to prevent, and the README's launch command does not include the compile.","line":216,"path":"msft95-contracts-audit-v3/scripts/mainnet/lib.js","reproduction":"Input: a checkout with no ./artifacts directory (only `npx hardhat --config hardhat.v4.config.js compile` was run) and all env settings valid; run `npx hardhat --config hardhat.v4.config.js run scripts/mainnet/launch.js --network rhMainnet`. Expected: refuses before the first transaction. Actual: steps chain, token, hook, pool, route, feed, router, staking, vesting all succeed (about 14 transactions), then 'Build the vault contracts first' is thrown at the vault step with the deployer holding the whole supply and ownership of token and hook. Fix: in config() (or makeLaunch before chain()) call v1Factory for MSFT95Vault and MSFT95Agents and fail if either artifact is missing.","severity":"low","snippet":"      const V = await v1Factory(ethers, \"MSFT95Vault\", signer).deploy(setup);","title":"Launch: the 0.8.24 build artifacts are only checked inside the vault step, after the token, hook, pool, staking and vesting transactions were already sent"},{"citation":"resolved","description":"On mainnet the route step calls findRoute(), which scans the four standard fee tiers for USDG/MSFT (then USDG/WETH + WETH/MSFT, USDG/ETH + ETH/MSFT) and picks the pool with the largest getLiquidity(), requiring only L > 0 and a nonzero price. Pool creation on Uniswap v4 is permissionless, so anyone who sees the launch coming can initialize a USDG/MSFT pool (or any of the two-hop pairs) at an arbitrary price with 1 wei of liquidity. If no deeper legitimate pool exists at that moment, setRoute() is signed for the attacker's pool with no operator review. With the Chainlink floor on, every convert() then reverts TooLittle (fees accumulate unspent until the Safe calls setRoute, a timelocked action); with ALLOW_NO_FEED=1 the keeper's conversions are sold into the attacker's pool at its price. The script never prints or asks confirmation of the pool it chose, and never checks its liquidity against a floor or its price against the feed.","line":323,"path":"msft95-contracts-audit-v3/scripts/mainnet/lib.js","reproduction":"State: Robinhood Chain with no USDG/MSFT pool, or only one shallower than the attacker's. Attacker: PoolManager.initialize({USDG, MSFT, fee 100, tickSpacing 1, hooks 0}, sqrtPrice for 1 MSFT = 1 USDG) and adds liquidityDelta 1 in a 1-tick range around it. Launch: route step -> findRoute -> live(usdg, msft) returns that key (L = 1 > 0, p > 0) -> hook.setRoute([attackerKey]). Expected: a route is only set to a pool the operator approved / with liquidity above a minimum. Actual: hook routes through the attacker's pool; convert() reverts TooLittle(got, floor) for every keeper call until the Safe resets the route. Fix: require a minimum active liquidity (e.g. the USDG value of a week of expected fees) and a spot price within maxSlipBps of the Chainlink feed before setRoute, print the chosen pool and require ROUTE=<keys> to be set explicitly on mainnet.","severity":"low","snippet":"  const direct = await live(a.usdg, a.msft); if (direct) return [direct.k];","title":"Launch: findRoute() takes whichever hookless USDG/MSFT pool has the most active liquidity, so a pool anyone created the day before becomes the hook's conversion route"},{"citation":"resolved","description":"rescue() excludes only MSFT95. Every other token the contract holds is, by the contract's own accounting, a reward owed to the agents: the MSFT the liquid MSFT95 earns as a holder (IHolder.claim in claimRewards) and the USDC/wsNET/IMD/WETH/MSFT that staking.claim() pays. Until the keeper runs claimRewards(), the owner can rescue() any of it to any address. This is a trust assumption on the Safe, not a bypass, but the NatSpec ('Return a token other than MSFT95 that is stuck here (sent by mistake)') and README ('it can't touch a registered agent's allocation') undersell it: the agents' earned rewards are reachable by the Safe. Note also that MSFT95 that arrives outside fund() is counted as reward and goes to the keeper (documented I-4).","line":198,"path":"msft95-contracts-audit-v3/v4/MSFT95AgentStakes.sol","reproduction":"State: AgentStakes holds 1,000 USDC of staking rewards after staking.claim() inside a reverted/omitted claimRewards, or MSFT after MSFT95Token.claimFor(agentStakes) (anyone can call it). Owner calls rescue(USDC, safe, 1000e6). Expected per NatSpec: only stray tokens. Actual: succeeds; the agents' reward is gone from the contract and claimRewards() later forwards nothing. Fix: either document it as a Safe power in README's trust section, or make rescue() refuse staking.rewardTokenList() members and IHolder(token).reward().","severity":"info","snippet":"        if (address(t) == address(token) || to == address(0)) revert BadConfig();","title":"AgentStakes: rescue() can take agents' reward tokens (MSFT, USDC, ...) sitting in the contract between keeper collections"}],"hash":"7f867fc6a9b4b9a89b1b63ccab13b6b38acadfb3b4cb82c7d10a40d9cb17738e","nodeId":"e59400e5-c66d-41c5-ae3b-3a1b331140e9","outcome":"completed","summary":"The review is complete. The findings are in `.imd-findings.json` at the repository root (paths prefixed with the scope folder) and a copy inside `msft95-contracts-audit-v3/` with relative paths. Both verified against the tree: every snippet matches its line, and two findings carry Foundry proofs that fail on the current code and pass once fixed.\n\n**Findings (1 medium, 3 low, 1 info)**\n\n- **Medium, vault MSFT limits.** MSFT is listed in the vault with deposits off so the Safe can sweep dividends, but `withdraw()` accepts a voucher for any listed token. With the launch limits in `scripts/mainnet/lib.js` a lone SIGNER key can voucher 10 MSFT at a time without a cosigner, up to 200 MSFT per window, roughly $100k a day against a $5,000 USDC cap. Proof: `test/scratch/VaultMsftVoucher.t.sol` drains 200 MSFT with one key. Config-only fix: list MSFT with maxWithdrawal 0 and cosignAbove NO_COSIGN, which keeps sweep working.\n- **Low, vault sale batch.** The documented three-call batch that sells 10% of supply leaves `window.spent` at 100M while the cap is restored to 100k, so every MSFT95 player withdrawal reverts for up to 24h. Proof: `test/scratch/VaultSaleBatchBlocksPlayers.t.sol`.\n- **Low, launch script.** The 0.8.24 build artifacts are only checked inside the vault step, after about 14 transactions, leaving the deployer holding supply and ownership if the v1 build was never run.\n- **Low, launch script.** `findRoute()` picks whichever hookless USDG/MSFT pool has the most liquidity, so an attacker-created dust pool can become the hook's conversion route with no operator review.\n- **Info, AgentStakes.** `rescue()` can take the agents' pending reward tokens between keeper collections. Trust assumption on the Safe that the NatSpec undersells.\n\n**Areas checked with no defect found**\n\nThe six changed areas held up: `openTrading` cannot be met or the price moved before the launch position is in, the half-liquidity threshold is correct for both token orders, `sync` keeps owed + streaming ≤ held ≤ balance on every path I traced, flash staking still earns zero, `_fairOut` handles the paused feed, sequencer grace and decimals correctly, `RouteTooShallow` cannot wrongly revert on a hooked route hop, and the hook fee deltas are right in all four swap shapes. Vault voucher replay and the token's exclusion accounting are sound.\n\n**Coverage limits**\n\nThe farm received only a skim of its keeper entry points and guards, not a full trace. The Agents template was read but not tested. Hardhat tests were not run since no node_modules ship with the package.","treeHash":null,"usage":{"cachedInputTokens":2724082,"inputTokens":642,"model":"claude-fable-5-1","outputTokens":49166,"runtime":"claude","turns":37,"wallClockMs":755079}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dedc96e38388cb45","findings":[{"citation":"resolved","description":"docs/MAINNET.md section 4 tells the Safe to sell up to 10% of the supply out of the bankroll as one batch: setToken(MSFT95, caps raised to the amount), sweep(MSFT95, saleWallet, amount), setToken(MSFT95, original config). sweep() runs _spend(), which stores window[MSFT95].spent = amount (e.g. 100,000,000e18) in the current 24h window. The restoring setToken() only rewrites tokenConfig; the window is untouched. From then until win.start + 1 day, every player withdraw() of MSFT95 computes spent = 100,000,000e18 + amount > dailyCap (100,000e18) and reverts DailyCapExceeded; remainingToday() returns 0. The operation the runbook prescribes therefore freezes all player MSFT95 cash-outs for up to a day (and the same happens, pro rata, with any house sweep: it eats the players' cap, as the NatSpec on sweep admits, but the 10% sale makes it total). Invariant broken: a house operation completed inside its own batch must not change what players can withdraw afterwards. Fix (minimal, keeps the design): account house sweeps in their own window (e.g. a separate `houseWindow` or a `sweepCap` per token) instead of the players' window; or let setToken() clamp win.spent to the new dailyCap when the admin lowers it (so the restore call also restores the players' room). A one-line alternative is an admin-only resetWindow(token) the batch can end with.","line":291,"path":"msft95-contracts-audit-v3/contracts/MSFT95Vault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport \"forge-std/Test.sol\";\nimport {MSFT95Vault} from \"msft95-contracts-audit-v3/contracts/MSFT95Vault.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\n\ncontract MSFT95Mock is ERC20 {\n    constructor() ERC20(\"MSFT95\", \"MSFT95\") { _mint(msg.sender, 1_000_000_000e18); }\n}\n\n/// The runbook's one-batch \"setToken(raise) / sweep(10% of supply) / setToken(restore)\" leaves window[MSFT95].spent at the\n/// swept amount while the daily cap goes back to 100,000, so every player voucher for MSFT95 reverts with DailyCapExceeded\n/// until the 24h window ends. Expected: a house operation in its own batch does not block player withdrawals afterwards.\ncontract VaultSweepBlocksPlayers is Test {\n    MSFT95Vault vault;\n    MSFT95Mock msft95;\n    uint256 signerKey = 0xA11CE;\n    address signer = vm.addr(signerKey);\n    address player = address(0xBEEF);\n    address saleWallet = address(0x5A1E);\n\n    function _cfg(uint128 maxW, uint128 cap) internal pure returns (MSFT95Vault.TokenConfig memory c) {\n        c = MSFT95Vault.TokenConfig({ listed: true, depositsEnabled: true, minDeposit: 20e18, maxWithdrawal: maxW, dailyCap: cap, cosignAbove: 5_000e18 });\n    }\n\n    function setUp() public {\n        vm.warp(30 days);\n        msft95 = new MSFT95Mock();\n        MSFT95Vault.TokenSetup[] memory toks = new MSFT95Vault.TokenSetup[](1);\n        toks[0] = MSFT95Vault.TokenSetup({ token: address(msft95), config: _cfg(20_000e18, 100_000e18) });   // LIMITS.MSFT95 in scripts/mainnet/lib.js\n        address[] memory allowed = new address[](1); allowed[0] = player;\n        vault = new MSFT95Vault(MSFT95Vault.Setup({\n            admin: address(this), adminDelay: 0, signer: signer, cosigner: address(0xC0), guardian: address(0x6A),\n            bankroller: address(this), allowlistOnly: true, allowed: allowed, tokens: toks }));\n        msft95.approve(address(vault), type(uint256).max);\n        vault.fundBankroll(address(msft95), 250_000_000e18);          // the bankroll's 25%\n    }\n\n    function _voucher(uint256 amount, uint256 nonce) internal view returns (MSFT95Vault.Withdrawal memory w, bytes[] memory sigs) {\n        w = MSFT95Vault.Withdrawal({ user: player, token: address(msft95), amount: amount, nonce: nonce, epoch: vault.voucherEpoch(),\n            issuedAt: block.timestamp, deadline: block.timestamp + 1 hours });\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, vault.hashWithdrawal(w));\n        sigs = new bytes[](1); sigs[0] = abi.encodePacked(r, s, v);\n    }\n\n    function test_playerWithdrawalAfterSaleBatch() public {\n        // a player cashes out 100 MSFT95 before the house operation: works\n        (MSFT95Vault.Withdrawal memory w0, bytes[] memory s0) = _voucher(100e18, 1);\n        vault.withdraw(w0, s0);\n\n        // the Safe's one-batch sale of 10% of the supply out of the bankroll (docs/MAINNET.md section 4)\n        uint256 amount = 100_000_000e18;\n        vault.setToken(address(msft95), _cfg(uint128(amount), uint128(amount)));\n        vault.sweep(address(msft95), saleWallet, amount);\n        vault.setToken(address(msft95), _cfg(20_000e18, 100_000e18));\n        assertEq(msft95.balanceOf(saleWallet), amount);\n\n        // a minute later another player cashes out 100 MSFT95 (well under maxWithdrawal and the 100,000 daily cap)\n        vm.warp(block.timestamp + 60);\n        assertEq(vault.remainingToday(address(msft95)), 100_000e18 - 100e18, \"the cap should still have room for players\");\n        (MSFT95Vault.Withdrawal memory w1, bytes[] memory s1) = _voucher(100e18, 2);\n        vault.withdraw(w1, s1);                                          // reverts DailyCapExceeded(100000100e18, 100000e18) as shipped\n        assertEq(msft95.balanceOf(player), 200e18);\n    }\n}","reproduction":"Vault with LIMITS.MSFT95 from scripts/mainnet/lib.js (maxWithdrawal 20,000e18, dailyCap 100,000e18, cosignAbove 5,000e18), bankroll funded with 250,000,000e18 MSFT95. Batch: setToken(MSFT95,{maxWithdrawal:1e26,dailyCap:1e26,...}); sweep(MSFT95, sale, 1e26); setToken(MSFT95, original). Then a signer-signed voucher for 100e18 MSFT95 to an allowed player. Expected: paid (100e18 << 100,000e18 cap, remainingToday = 99,900e18). Actual: revert DailyCapExceeded(100000100000000000000000000, 100000000000000000000000000). Test: test/scratch/VaultSweepBlocksPlayers.t.sol.","severity":"medium","snippet":"        if (spent > cap) revert DailyCapExceeded(spent, cap);","title":"Vault: the runbook's one-batch 10%-of-supply sweep leaves window.spent at the swept amount, so every player MSFT95 voucher reverts DailyCapExceeded for up to 24h"},{"citation":"resolved","description":"MSFT (tokenized Microsoft stock, ~$500) is listed in the vault so the Safe can sweep the bankroll's dividends for buybacks. Listing it also enables player withdraw() vouchers for it: MSFT95Vault.withdraw only checks tokenConfig.listed, maxWithdrawal, dailyCap and cosignAbove; depositsEnabled=false does not stop withdrawals. The limits chosen are maxWithdrawal 50 MSFT, dailyCap 200 MSFT, cosignAbove 10 MSFT. So a compromised server SIGNER key (no cosigner needed for amount <= cosignAbove, see withdraw: `_verify(w, sigs, w.amount > c.cosignAbove)`) can issue 20 vouchers of exactly 10 MSFT to any allowlisted address and drain 200 MSFT (~$100,000) per 24h window (up to 400 across two adjacent windows, as the NatSpec notes), and with the cosigner key as well 200 MSFT/day in 4 vouchers. Every other soft-launch cap is a few thousand dollars (USDC 5,000/day, MSFT95 100,000 = ~$5,000/day). Since no player can ever have an MSFT balance, every MSFT voucher is illegitimate by construction and the signer-only path is pure attack surface, contradicting the stated goal that 'a compromised keeper can only cause bounded loss' at the soft-launch scale. Fix: set MSFT cosignAbove to 0 (every MSFT voucher then needs both keys; _setToken accepts 0 < maxWithdrawal) and/or make the voucher limits for MSFT tiny (maxWithdrawal 0 is rejected by `cosignAbove >= maxWithdrawal` unless NO_COSIGN, so the contract would need a `vouchersOff` flag per token, or the sweep path could bypass maxWithdrawal for the admin so that MSFT can be listed with maxWithdrawal = 0).","line":29,"path":"msft95-contracts-audit-v3/scripts/mainnet/lib.js","reproduction":"Deploy the vault with the mainnet LIMITS (lib.js line 27-29): MSFT listed, depositsEnabled false, maxWithdrawal 50e18, dailyCap 200e18, cosignAbove 10e18; fund 200e18 MSFT via fundBankroll (the bankroll's dividend). With only the SIGNER private key, sign 20 Withdrawal vouchers {user: attacker, token: MSFT, amount: 10e18, nonce: 1..20, epoch: voucherEpoch, issuedAt: now, deadline: now+1h} and submit them. Expected per the trust model: MSFT only leaves through Safe sweeps (the comment on the line says so). Actual: all 20 withdraw() calls succeed (amount == cosignAbove does not require the cosigner, 20 x 10e18 == dailyCap) and 200 MSFT leave to the attacker in one window.","severity":"medium","snippet":"  MSFT: [\"0\", \"50\", \"200\", \"10\"] };   // MSFT is never deposited: only the Safe's buyback sweeps take it out (at most 200 a day)","title":"Launch config lists MSFT in the vault with a 50-per-voucher / 200-per-day withdrawal limit (~$25k / ~$100k) although no player ever holds MSFT: a stolen SIGNER key alone takes 10 MSFT per voucher, 20x"},{"citation":"resolved","description":"The audit-4 L-1 fix folds an arrival with add < left into the running stream: rate += add / rest where rest = finish - now. The stream's end does not move, so the new arrival is streamed over `rest` seconds. Late in a stream `rest` is small, so an arrival of up to `left` (= rate * rest) is paid out almost immediately instead of over an hour. Because arrivals only start streaming when someone calls sync() (plain transfers to the contract do nothing by themselves), anyone can pick the moment: let the casino's transfer sit unsynced, stake a large amount a few seconds before `finish`, call sync (stake() does it), unstake at `finish` and claim. The same holds for the dust/idle re-stream branch at line 149. Invariant broken: 'each arrival is paid out to stakers evenly over `period`... nobody can catch a payout by staking for a moment around it'. owed + streaming <= held still holds (no fund loss to the contract), the loss is to honest stakers who were in for the full hour. Severity low: the capturable amount is bounded by what is left of the running stream at the chosen moment, and the attacker must bring real stake. Fix: when add < left, stream it over max(rest, some floor such as period/4), i.e. restart with finish = now + period only if it makes the stream shorter than that, or keep the per-arrival end at max(finish, now + period) while leaving the existing stream's rate unchanged (two rates), so neither stretching (audit 4 L-1) nor compression is possible.","line":160,"path":"msft95-contracts-audit-v3/v4/MSFT95Staking.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport \"forge-std/Test.sol\";\nimport {MSFT95Staking} from \"msft95-contracts-audit-v3/v4/MSFT95Staking.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\n\ncontract Tok is ERC20 {\n    constructor(string memory n) ERC20(n, n) { _mint(msg.sender, 1e30); }\n    // MSFT95Staking._harvest() calls claim() on the stake token; a plain ERC-20 has none, so provide a no-op\n    function claim() external pure returns (uint256) { return 0; }\n}\n\n/// sync(): an arrival smaller than what is still streaming is spread over the SECONDS LEFT in the running stream, not over\n/// `period`. Late in a stream that is a few seconds, so a staker who steps in just before the sync and out right after the\n/// stream ends takes its share of the new arrival almost at once. Expected: every arrival streams for about an hour, so a\n/// 10-second stake earns at most ~10/3600 of it.\ncontract StakingJoinSpeedsUp is Test {\n    MSFT95Staking staking;\n    Tok msft95; Tok usdc;\n    address honest = address(0xA0);\n    address jit = address(0x717);\n\n    function setUp() public {\n        vm.warp(1_000_000);\n        msft95 = new Tok(\"MSFT95\"); usdc = new Tok(\"USDC\");\n        address[] memory rewards = new address[](1); rewards[0] = address(usdc);\n        staking = new MSFT95Staking(msft95, address(this), rewards);\n        msft95.transfer(honest, 1_000e18); msft95.transfer(jit, 9_000e18);\n        vm.prank(honest); msft95.approve(address(staking), type(uint256).max);\n        vm.prank(jit); msft95.approve(address(staking), type(uint256).max);\n    }\n\n    function test_jitStakerCatchesLateArrival() public {\n        vm.prank(honest); staking.stake(1_000e18);\n        usdc.transfer(address(staking), 3_600e18);                      // the casino's hourly house edge arrives...\n        staking.sync(address(usdc));                                     // ...and streams at 1 USDC/s for an hour\n        uint256 finish = staking.finish(address(usdc));\n\n        vm.warp(finish - 10);                                            // 10 seconds before the stream ends: 10 USDC left\n        usdc.transfer(address(staking), 9e18);                           // a smaller arrival of 9 USDC\n        vm.prank(jit); staking.stake(9_000e18);                          // the JIT staker takes 90% of the pool and syncs the arrival\n        (uint256 remaining, uint256 endsAt) = staking.streaming(address(usdc));\n        assertEq(endsAt, finish);                                        // the end did not move: the 9 USDC go out over 10 s\n        assertEq(remaining, 19e18);\n\n        vm.warp(finish);\n        vm.prank(jit); staking.unstake(9_000e18);\n        vm.prank(jit); staking.claim();\n        uint256 got = usdc.balanceOf(jit);\n        // 10 seconds of an hour-long stream of 9 USDC at a 90% share is 0.0225 USDC; the JIT staker gets 8.1 USDC of the\n        // arrival (plus 9 of the old stream's last 10 USDC): 17.1 in total\n        assertLe(got - 9e18, 9e18 * 90 / 100 * 10 / 3600 + 1e15, \"the late arrival was paid out in 10 seconds\");\n    }\n}","reproduction":"Honest staker stakes 1,000e18 MSFT95; 3,600e18 USDC arrives and sync() streams it at 1e18/s for 3,600 s. At finish - 10 s, 9e18 USDC arrives (left = 10e18 > 9e18). Attacker stakes 9,000e18 (90% of stake), which syncs: streaming() still ends at `finish` with 19e18 remaining. At `finish` the attacker unstakes and claims. Expected under an hour-long stream: ~0.0225e18 of the 9e18 arrival (10/3600 of 90%). Actual: 8.1e18 of it (17.1e18 total with the old stream's last 9e18). Test: test/scratch/StakingJoinSpeedsUp.t.sol (fails: 8100000000000000000 > 23500000000000000).","severity":"low","snippet":"            rate[token] += add / rest;","title":"Staking.sync: an arrival smaller than what is left streams over the seconds remaining, not over `period`, so a late small arrival is paid in seconds and a just-in-time staker captures it"},{"citation":"resolved","description":"getLiquidity(poolId) is the active liquidity at the current tick, which concentrated positions reach with far less capital than a full-range one. The printed minimum is half the full-range liquidity of 200,000,000 MSFT95 at the launch price: L_min = 1e26 * sqrtP / 2^96 (MSFT95 as currency0). A position on [tick0 - 60, tick0 + 120) (two tick spacings around the current tick, tickSpacing 60) with that liquidity needs amount0 = L_min * (sqrtPu - sqrtP) / (sqrtP * sqrtPu) <= 1e26 * 0.006 = 600,000 MSFT95 and amount1 <= ~30,000 USDG at $0.05, i.e. about 0.3% of the launch capital. Anyone can add liquidity to the closed pool (the hook has no add-liquidity flag). So the runbook's statement 'it refuses a smaller amount of liquidity (a stranger's dust doesn't count)' is only true for dust: if the Safe signs openTrading(min) before (or in a different batch from) the liquidity wallet's position, trading opens on the stranger's thin position at the launch price; the stranger then removes it, and the first swap on the now-empty open pool moves slot0 to the price limit for free, exactly the audit 3 H-1 state the gate exists to prevent, and the launch position is then added at a wrong price. Note the maths in scripts/mainnet/lib.js launchLiquidity is right for both token orders (amount*sp/Q96 for currency0, amount*Q96/sp for currency1), the weakness is that active liquidity is not a proxy for the launch position. Fix: have openTrading also check that the launch position itself exists, e.g. require poolManager.getPosition(poolId, lpWallet or positionManager, fullRangeLower, fullRangeUpper, salt).liquidity >= minLiquidity, or have the hook add the launch position itself (owner-only addLaunchLiquidity that does modifyLiquidity and sets tradingOpen in one call).","line":276,"path":"msft95-contracts-audit-v3/v4/MSFT95RewardHook.sol","reproduction":"Pool initialised at sqrtPrice for 0.05 USDG/MSFT95 and trading closed. Stranger calls PoolManager.modifyLiquidity(key, {tickLower: floor(tick0/60)*60 - 60, tickUpper: floor(tick0/60)*60 + 120, liquidityDelta: launchLiquidity().min}) paying about 600,000 MSFT95 + 30,000 USDG. Owner calls openTrading(launchLiquidity().min). Expected per the runbook: NoLiquidity because the launch position is not in. Actual: getLiquidity(poolId) == min, price unchanged, trading opens.","severity":"low","snippet":"        if (minLiquidity == 0 || poolManager.getLiquidity(poolId) < minLiquidity) revert NoLiquidity();","title":"openTrading's half-launch-liquidity threshold is met by a stranger's two-tick-spacing position worth well under 1% of the launch capital, so the gate does not prove the launch position is in"},{"citation":"resolved","description":"rescue() excludes only MSFT95. The contract's MSFT95 earns MSFT as an eligible holder, and MSFT95Token.claimFor(agentStakes) is callable by anyone, so MSFT can sit here at any time (also every listed staking reward token after claimRewards' staking.claim(), or after anyone calls the public MSFT95Staking.claimToken path on its behalf is not possible, but MSFT via claimFor is). Those balances are the agents' rewards (NatSpec: 'collect the staking rewards to share between the agents'), yet the owner can send them anywhere with rescue(MSFT, to, amount). This contradicts the contract's own trust statement that the owner 'can't touch a registered agent's allocation' only if allocation is read as MSFT95 principal; the agents' dividend stream is not protected. The forwarding loop itself is safe: for MSFT95 it subtracts liquid() so principal never leaves, and rescue cannot name MSFT95. Fix: also refuse `t == IHolder(token).reward()` and any address in staking.rewardTokenList() (or, simpler, only allow rescue of tokens that are neither MSFT95, nor MSFT, nor listed in staking).","line":198,"path":"msft95-contracts-audit-v3/v4/MSFT95AgentStakes.sol","reproduction":"After launch, anyone calls MSFT95Token.claimFor(agentStakes) (or waits for the keeper's claimRewards to be front-run): agentStakes now holds X MSFT. Safe calls agentStakes.rescue(MSFT, safeAddress, X). Expected: refused, MSFT here belongs to the agents. Actual: X MSFT transferred to the Safe; the keeper's next claimRewards forwards nothing.","severity":"low","snippet":"        if (address(t) == address(token) || to == address(0)) revert BadConfig();","title":"AgentStakes.rescue lets the Safe take the agents' MSFT dividends and other reward tokens that have been claimed into the contract but not yet forwarded"}],"hash":"bb3425394af3268375f7d9a0a4a124a140ecc0314896099efeb822dc0eb1dfc7","nodeId":"5ddea69f-95f4-4148-91c8-a0fe9d31ba45","outcome":"completed","summary":"The review is complete. The findings file at the repository root holds five substantiated findings, each with a concrete failing input, and all five snippets validate against the tree. Two Foundry PoCs under test/scratch/ fail on the shipped code as described.\n\n**Findings**\n\n- **Medium, vault daily cap after the 10% sale batch.** The runbook's one-batch raise/sweep/restore stores the swept 100M in the players' 24h window. After the restore, every player MSFT95 voucher reverts with DailyCapExceeded for up to a day. PoC: `test/scratch/VaultSweepBlocksPlayers.t.sol` fails with that revert.\n- **Medium, MSFT listed in the vault with voucher limits.** Listing MSFT for Safe sweeps also enables player vouchers for it, and `lib.js` sets 50 per voucher, 200 per day, cosigner above 10. A stolen signer key alone can take 20 vouchers of exactly 10 MSFT, about $100k a day, against the $5k scale of every other cap. No player can ever legitimately hold MSFT.\n- **Low, staking stream compression.** An arrival smaller than what is left joins the running stream over the remaining seconds, not the hour. A staker who times the sync near the end captures the arrival almost at once. PoC: `test/scratch/StakingJoinSpeedsUp.t.sol` shows 8.1 USDC captured in 10 seconds where an hourly stream would give 0.02.\n- **Low, openTrading threshold.** Active liquidity equal to half the launch position is reachable with a two-tick-spacing position costing under 1% of the launch capital, so the gate does not prove the launch position is in. The script's liquidity maths is correct for both token orders.\n- **Low, AgentStakes rescue.** Rescue excludes only MSFT95, so the Safe can take the agents' MSFT dividends and other reward tokens once they sit in the contract. Anyone can push MSFT there through the token's public claimFor.\n\n**Checked and found sound**: hook fee deltas in all four swap shapes, slot0 immovable while trading is closed, the oracle pause, sequencer and future-timestamp handling in the price floor, the RouteTooShallow check including fee hooks on route pools, the staking held-versus-owed invariant across fresh, idle, dust and switched-off paths, claimToken harvesting, the AgentStakes forwarding loop protecting principal, token dividend accounting with exclusions, voucher replay protection, and the launch script's up-front config checks.\n\n**Not covered in depth**: the farm's keeper surface was only skimmed, since it is unchanged since the last review and time went to the changed areas first.","treeHash":null,"usage":{"cachedInputTokens":1474551,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":40128,"runtime":"claude","turns":28,"wallClockMs":625564}}],"verification":[]}