{"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":"d4fc488d-e4fc-4240-a4c9-a8cc8896b51e","kind":"audit","nodes":[{"acceptedSubmissionHash":"9e8d91bf18bc2d01290b77bcb2edb43d9e8d1406b84890f34c532b51df43632e","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":"5baebf47061695b2824cbf53d8cb8ef1ee1405b9a574763be04f669c3f3bcd8e","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":"059650338e18194b949eaebdea608852419c65b206a089690da844e2e5534826","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":"fc5bad660f032d7041db8ade1b21b22e9f8163015ef09530f093ba98f4f9393b","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":"d3481dc7a3dc826aa1820135e9faeefb6241f6b12506b41772094cc86c1345bd","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":"# Audit brief: MSFT95Vault\n\n**Scope:** `contracts/MSFT95Vault.sol`. The mocks in `contracts/mocks/` are test-only.\n\n**Compiler and libraries:** Solidity 0.8.24, optimizer 500 runs, EVM target shanghai, OpenZeppelin Contracts 5.1.0.\n\n**Chain:** Robinhood Chain, an Arbitrum Orbit chain with a single sequencer. Testnet is 46630 and mainnet is 4663.\n\n## Trust model\n\n- **Admin** is fully trusted. It is a Safe, plus a timelock on mainnet. It can:\n  - list and unlist tokens;\n  - change limits;\n  - grant SIGNER and COSIGNER;\n  - sweep funds, subject to `dailyCap`.\n- **SIGNER** is trusted to sign only withdrawals the off-chain ledger allows. A compromised SIGNER can take, per token per 24h window, up to `dailyCap`, in pieces no larger than `cosignAbove`. Anything above `cosignAbove` also needs COSIGNER.\n- **GUARDIAN** can only pause.\n- **Players** are untrusted.\n\n## Invariants to check\n\n1. A voucher pays at most once: `nonceUsed[user][nonce]` is set before the external transfer, and a reverted payout leaves it unset.\n2. Funds from `withdraw` only ever go to `w.user`, whoever calls it.\n3. A voucher is only valid on this chain and this vault, before its `deadline`, and at most 24h ahead of `block.timestamp`.\n4. `amount > cosignAbove` requires two distinct keys, one with SIGNER and one with COSIGNER. One key can never hold both roles (`_grantRole` override). The same signature or key can't be counted twice.\n5. Per token, within one window, player withdrawals plus `sweep` never exceed `dailyCap`. At most 2× `dailyCap` can leave inside any 24 hours, across a window boundary.\n6. `Deposited.amount` equals the real balance increase. This holds for fee-on-transfer tokens; rebasing tokens are not supported.\n7. Only an admin can unpause, change config, sweep, edit the allowlist or grant roles. A GUARDIAN can only pause.\n8. Plain ETH sent to the vault reverts. ETH can only enter through `depositETH` or `fundBankroll`.\n9. The token balance always equals deposits plus bankroll minus withdrawals minus sweeps. A randomized test covers this over 300 operations.\n\n## Areas worth extra attention\n\n- `_verify`: signature-ordering edge cases, role revocation between signing and submission, and `ECDSA.recover` with malformed signatures.\n- `_spend`: window rollover at exactly `start + WINDOW`, and `uint192` / `uint64` casts.\n- Reentrancy through ERC-777-style hooks or ETH receivers. Every external entry point is `nonReentrant`, and `deposit` reads the balance before and after the transfer.\n- `AccessControlDefaultAdminRules` interaction with the `_grantRole` override, so admin handover still works.\n- Tokens with blocklists (USDC): a blocklisted user's withdrawal reverts. Confirm it fails cleanly and the nonce stays unused.\n\n## Off-chain assumptions (for the step-2 backend review)\n\n- **Deposit indexing.** The indexer credits each `Deposited` event exactly once, keyed by `(chainId, txHash, logIndex)` or `depositId`, after enough confirmations. Reorgs are handled.\n- **Ledger before signing.** The ledger debits the player before signing a voucher. If the voucher expires unused, it re-credits only after `deadline` has passed and `nonceUsed` is confirmed false at a final block.\n- **Unique nonces.** Nonces are unique per user and never reused across voucher re-issues.\n- **Key custody.** SIGNER and COSIGNER keys live in separate KMS/HSM accounts. The GUARDIAN bot pauses withdrawals when reconciliation fails, that is, when the sum of ledger liabilities is greater than the vault balance.","parentJobId":null,"planHash":"5d4c497331c6030f78c3ccb51292705069910be0ee7fecdcf2569b0962b5b549","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"d4fc488d-e4fc-4240-a4c9-a8cc8896b51e","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":"52267","feedbackHash":"c312e9e8bcbb5b9305dd0f344104872b236b0548bf3be5b75dbd2dfb5d2e5c9d","nodeKey":"audit_economics","submissionHash":"9e8d91bf18bc2d01290b77bcb2edb43d9e8d1406b84890f34c532b51df43632e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50988","feedbackHash":"474d4216d55a07ccff4904207d5ea9ab79159881b2af3cd856107fb529d44d69","nodeKey":"audit_flow","submissionHash":"5baebf47061695b2824cbf53d8cb8ef1ee1405b9a574763be04f669c3f3bcd8e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51041","feedbackHash":"82758c48b8b06338f54de84a27e599c6331c47d9e6f698808f77066248378895","nodeKey":"audit_judge","submissionHash":"059650338e18194b949eaebdea608852419c65b206a089690da844e2e5534826","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51227","feedbackHash":"a9fd85ff7932a666ff6e68eebc2cfacc464bf5f2d8dd117352caed2f2cf166d6","nodeKey":"audit_math","submissionHash":"fc5bad660f032d7041db8ade1b21b22e9f8163015ef09530f093ba98f4f9393b","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52598","feedbackHash":"b26b1d14b6710786d757cb6d95fb7371bb937c04eb0f4dbf0fc19e46c5d029b6","nodeKey":"audit_permissions","submissionHash":"d3481dc7a3dc826aa1820135e9faeefb6241f6b12506b41772094cc86c1345bd","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"37a890f2bee00756c23d373e98193d5bee41012b28426fe4a991ae82ff654162","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3bed38612db34f32","findings":[{"citation":"resolved","description":"deposit() and depositETH() go through _checkDeposit(), which enforces depositsPaused, TokenConfig.depositsEnabled and allowlistOnly/allowed[msg.sender]. fundBankroll() moves value into the same vault but checks only `listed`, has no role check, and emits BankrollFunded, which the indexer does not credit. Consequences: (1) the KYC allowlist (README: 'only listed wallets can deposit') and the GUARDIAN's pauseDeposits() brake cover only one of the two inbound paths; any wallet can still push ETH or any listed ERC-20 into custody while deposits are paused or the token has depositsEnabled=false. (2) A player who hits this selector by mistake (wrong UI button, wallet routing) loses the funds to house bankroll with no Deposited event and no on-chain claim, which is exactly the outcome receive() reverts to prevent. The function's NatSpec calls it 'house bankroll', while the Hardhat suite has alice call it successfully, so the intended caller set is ambiguous and must be decided. Access-control classification: public, unrestricted, state-changing (moves funds). Merged from audit_flow, audit_permissions and audit_economics (same function, same mechanism). Fix preserving the design: restrict fundBankroll to DEFAULT_ADMIN_ROLE or a dedicated BANKROLL_ROLE granted to the treasury wallet; if donations must stay open, at minimum apply the depositsPaused and allowlistOnly checks and document that this path is never credited.","line":138,"path":"msft95-vault/contracts/MSFT95Vault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.24;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {MSFT95Vault} from \"msft95-vault/contracts/MSFT95Vault.sol\";\nimport {TestToken} from \"msft95-vault/contracts/mocks/Mocks.sol\";\n\n/// fundBankroll() is callable by anyone and skips every inbound gate that deposit()/depositETH()\n/// enforce (allowlistOnly, depositsPaused, depositsEnabled). A non-allowlisted wallet can push value\n/// into the vault while the guardian has paused deposits, and gets no Deposited event, so the ledger\n/// never credits it. This test fails on the current code and passes once fundBankroll is either\n/// restricted to the house (admin / dedicated role) or made to honour the same gates as deposit().\ncontract FundBankrollGatesTest is Test {\n    MSFT95Vault vault;\n    TestToken tok;\n    address admin = address(0xAD);\n    address guardian = address(0x6A);\n    address stranger = address(0x5717);\n    address constant ETH = address(0);\n\n    function setUp() public {\n        vault = new MSFT95Vault(admin, 0);\n        tok = new TestToken(\"T\", \"T\", 18);\n        MSFT95Vault.TokenConfig memory c = MSFT95Vault.TokenConfig({\n            listed: true, depositsEnabled: true, minDeposit: 1, maxWithdrawal: 1000e18, dailyCap: 5000e18, cosignAbove: 100e18\n        });\n        vm.startPrank(admin);\n        vault.grantRole(vault.GUARDIAN_ROLE(), guardian);\n        vault.setToken(address(tok), c);\n        vault.setToken(ETH, c);\n        vault.setAllowlistOnly(true); // stranger is NOT allowlisted\n        vm.stopPrank();\n        vm.prank(guardian);\n        vault.pauseDeposits(); // guardian brake on inflows\n        tok.mint(stranger, 10e18);\n        vm.deal(stranger, 1 ether);\n        vm.prank(stranger);\n        tok.approve(address(vault), type(uint256).max);\n    }\n\n    function test_strangerCannotPushTokensWhileDepositsPausedAndNotAllowlisted() public {\n        // deposit() correctly refuses the stranger\n        vm.prank(stranger);\n        vm.expectRevert(MSFT95Vault.DepositsClosed.selector);\n        vault.deposit(address(tok), 10e18);\n\n        // fundBankroll() must refuse the same caller in the same state\n        vm.prank(stranger);\n        vm.expectRevert();\n        vault.fundBankroll(address(tok), 10e18);\n        assertEq(tok.balanceOf(address(vault)), 0, \"tokens entered the vault through fundBankroll while deposits were paused\");\n    }\n\n    function test_strangerCannotPushEthWhileDepositsPausedAndNotAllowlisted() public {\n        vm.prank(stranger);\n        vm.expectRevert(MSFT95Vault.DepositsClosed.selector);\n        vault.depositETH{value: 1 ether}();\n\n        vm.prank(stranger);\n        vm.expectRevert();\n        vault.fundBankroll{value: 1 ether}(ETH, 0);\n        assertEq(address(vault).balance, 0, \"ETH entered the vault through fundBankroll while deposits were paused\");\n    }\n}","reproduction":"State: admin calls setAllowlistOnly(true); guardian calls pauseDeposits(); stranger 0x5717 is not in `allowed`, holds 10e18 tok (approved) and 1 ether. Input: stranger calls deposit(tok, 10e18) -> reverts DepositsClosed (expected). stranger calls fundBankroll(tok, 10e18) -> expected: revert (same caller, same paused/non-allowlisted state); actual: succeeds, vault tok balance +10e18, stranger balance 0, BankrollFunded(stranger, tok, 10e18) emitted, no Deposited event. stranger calls fundBankroll{value: 1 ether}(address(0), 0) -> expected revert; actual: succeeds, vault ETH +1 ether. Same result with depositsEnabled=false on the token. Reproduced in Foundry (solc 0.8.24, OZ 5.1.0, evm shanghai): test_fundBankrollBypass passes on current code; the attached proof fails on current code and passes with an onlyRole(DEFAULT_ADMIN_ROLE) gate added to fundBankroll.","severity":"low","snippet":"    function fundBankroll(address token, uint256 amount) external payable nonReentrant {\n        if (!tokenConfig[token].listed) revert TokenNotListed(token);","title":"fundBankroll is permissionless and skips every inbound gate (allowlistOnly, depositsPaused, depositsEnabled), so uncredited value can enter from anyone while deposits are closed"},{"citation":"resolved","description":"The signed struct carries no issuance time, so the contract can only enforce that a voucher is not redeemed more than 24h before its deadline. It cannot enforce the README promise that every voucher 'expires within 24 hours at most' after it was signed. A voucher with deadline = now + 365 days is rejected with VoucherTooLong today and is accepted unchanged during the whole day [deadline - 1 day, deadline]. Invariant 3 as literally worded ('at most 24h ahead of block.timestamp') holds; the gap is between the documented lifetime and the mechanism. Practical consequences: a backend TTL bug or a briefly compromised SIGNER key can bank dormant vouchers that only become payable later; because the ledger re-credits only after `deadline`, the player's ledger debit stays locked for the whole period. Role revocation at submission (hasRole in _verify) is the only kill switch and does work, but re-granting the same key later revives every voucher it signed (confirmed). Merged from audit_math, audit_flow, audit_permissions and audit_economics. Fix options that keep the design: add an `issuedAt` field to the EIP-712 Withdrawal struct and require issuedAt <= block.timestamp && w.deadline <= issuedAt + MAX_VOUCHER_LIFETIME; or document that the 24h figure is a redemption horizon, enforce TTL in the signing service, and rotate (never re-grant) compromised keys.","line":184,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: signer holds SIGNER_ROLE, tok listed (maxWithdrawal 1000e18, cosignAbove 100e18), vault funded, T0 = block.timestamp. Input: Withdrawal{user: alice, token: tok, amount: 5e18, nonce: 1, deadline: T0 + 365 days} signed by signer. Submit at T0 -> reverts VoucherTooLong (expected). Warp to T0 + 364 days - 1 -> still VoucherTooLong. Warp to T0 + 364 days -> withdraw() succeeds and alice receives 5e18. Expected per README ('expires within 24 hours at most'): never payable 364 days after signing. Actual: paid. Reproduced in Foundry (test_sleeperVoucher). Also reproduced: revoking COSIGNER after signing makes [s, c] revert BadSignatures, and re-granting the role makes the same two signatures pay (test_sigEdges).","severity":"low","snippet":"        if (w.deadline > block.timestamp + MAX_VOUCHER_LIFETIME) revert VoucherTooLong();","title":"MAX_VOUCHER_LIFETIME bounds the deadline relative to submission time, not signing time: a far-future voucher is a sleeper that activates in its last 24h"},{"citation":"resolved","description":"For token == ETH the function never reads `amount`; it records and emits msg.value. The ERC-20 branch is stricter (rejects a stray msg.value), so a caller who passes the intended amount but attaches a different value gets a smaller or larger top-up with no error. The event reports what actually arrived, so no funds are mis-accounted; this is an input-validation asymmetry. Fix: on the ETH branch require amount == msg.value (or amount == 0), or document that `amount` is ignored for ETH.","line":142,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: ETH listed. Input: alice calls fundBankroll{value: 1 ether}(address(0), 999 ether). Expected by analogy with the ERC-20 branch's msg.value check: revert because amount and msg.value disagree. Actual: succeeds and emits BankrollFunded(alice, address(0), 1 ether). Reproduced in Foundry (test_fundBankrollEthIgnoresAmount, vm.expectEmit on the 1 ether value).","severity":"info","snippet":"            received = msg.value;","title":"fundBankroll ignores the `amount` argument on the ETH branch and silently records msg.value"},{"citation":"resolved","description":"The expiry comparison is strict, so a block whose timestamp equals the deadline still pays the voucher, while AUDIT.md invariant 3 says 'before its deadline'. Harmless on-chain, but the step-2 backend's definition of 'deadline has passed' must be finalBlock.timestamp > deadline, not >=; a ledger that re-credits at timestamp == deadline while a withdraw() in that same block pays out would double-pay the player. Merged from audit_math and audit_flow. Fix: change the check to `>=` to match the wording, or document that the deadline second is itself valid.","line":183,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: tok listed, signer holds SIGNER_ROLE. Input: Withdrawal{alice, tok, 5e18, nonce 1, deadline T} with vm.warp(T), then withdraw(). Expected per the brief's literal wording: VoucherExpired. Actual: succeeds, alice receives 5e18. A second voucher (nonce 2, same deadline T) submitted at T + 1 reverts VoucherExpired. Reproduced in Foundry (test_deadlineInclusive).","severity":"info","snippet":"        if (block.timestamp > w.deadline) revert VoucherExpired();","title":"Voucher deadline is inclusive: a voucher pays at block.timestamp == deadline, so the ledger must only re-credit when a final block has timestamp strictly greater than deadline"},{"citation":"resolved","description":"withdraw() reverts WithdrawalsClosed while withdrawalsPaused is true; sweep() checks only `listed`, non-zero amount/to and the daily cap. The brief says the GUARDIAN bot pauses withdrawals when ledger liabilities exceed the vault balance, which is exactly when the house's own outflow (up to dailyCap per token per window, 2x across a boundary, ignoring maxWithdrawal and cosignAbove) worsens the shortfall for players. The admin is fully trusted and timelocked on mainnet, so this is a trust-assumption gap, not a bypass. Merged from audit_flow, audit_permissions and audit_economics. Fix if the pause is meant as a full outflow brake: add `if (withdrawalsPaused) revert WithdrawalsClosed();` to sweep() so an admin unpause (visible through the timelock) is required first; otherwise state in README/AUDIT.md that sweeps are exempt from the pause.","line":272,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: guardian calls pauseWithdrawals(); tok listed with dailyCap 5000e18, maxWithdrawal 1000e18; vault holds 20000e18 tok. Input: a valid SIGNER voucher for 10e18 -> reverts WithdrawalsClosed. admin calls sweep(tok, 0xBEEF, 5000e18) in the same state -> expected if the pause is a full brake: WithdrawalsClosed; actual: succeeds, 0xBEEF receives 5000e18 (five times maxWithdrawal, no cosigner) while player withdrawals are frozen. Reproduced in Foundry (test_sweepWhilePaused).","severity":"info","snippet":"    function sweep(address token, address to, uint256 amount) external onlyRole(DEFAULT_ADMIN_ROLE) nonReentrant {\n        if (!tokenConfig[token].listed) revert TokenNotListed(token);","title":"sweep is not stopped by withdrawalsPaused: the GUARDIAN's reconciliation pause does not cover house outflows (trust assumption to document or close)"},{"citation":"resolved","description":"withdraw() rejects amount > maxWithdrawal before deciding whether a cosigner is needed (amount > cosignAbove), so with cosignAbove == maxWithdrawal no payable voucher ever needs the second key: a single SIGNER can take maxWithdrawal per voucher and dailyCap per window. The validation already rejects cosignAbove > maxWithdrawal, so it is clearly meant to keep the limits consistent, but it is off by one for the equality. This is admin-only configuration (trusted role), the project's own Hardhat suite deliberately uses cosignAbove == maxWithdrawal (E(40)/E(40)) to disable cosigning in a test, and the mainnet limits in scripts/deploy.js all keep cosignAbove < maxWithdrawal, so this is reported as a configuration hazard rather than a defect (downgraded from audit_economics' low). Fix: reject `cfg.cosignAbove >= cfg.maxWithdrawal` unless an explicit sentinel (e.g. type(uint128).max) is used to opt out of cosigning for a token, and update the suite accordingly.","line":265,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: admin calls setToken(tok, {listed, depositsEnabled, minDeposit 1, maxWithdrawal 1000e18, dailyCap 5000e18, cosignAbove 1000e18}). Expected if the limits must keep the cosigner reachable: BadConfig. Actual: accepted. Then Withdrawal{alice, tok, 1000e18, nonce 1, deadline now+3600} signed by the SIGNER key only is paid in full (alice +1000e18) with no COSIGNER signature. Reproduced in Foundry (test_cosignEqualMax).","severity":"info","snippet":"        if (cfg.cosignAbove > cfg.maxWithdrawal || cfg.maxWithdrawal > cfg.dailyCap) revert BadConfig();","title":"setToken accepts cosignAbove == maxWithdrawal, which silently makes the COSIGNER requirement unreachable for that token"},{"citation":"resolved","description":"receive() rejecting plain transfers achieves its stated purpose, but it cannot stop value entering by other routes: a contract that SELFDESTRUCTs with the vault as beneficiary (unconditional on the shanghai target; still allowed same-transaction after EIP-6780) credits the balance with no code executed and no event, and any ERC-20 can be transfer()ed to the vault address directly. Neither emits Deposited or BankrollFunded, so 'ETH can only enter through depositETH or fundBankroll' (invariant 8) and 'balance always equals deposits plus bankroll minus withdrawals minus sweeps' (invariant 9) hold only for the contract's own paths. No funds are at risk (surplus is sweepable by the admin within dailyCap), but the GUARDIAN reconciliation bot must compare liabilities <= balance, not ==, or an outsider could trigger a withdrawal pause with a 1 wei transfer. Merged from audit_flow and audit_permissions. Fix: reword the two invariants and the bot's rule; no contract change needed.","line":154,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: vault deployed with ETH listed, balance 500 ether. Input: deploy `contract Bomb { constructor(address payable to) payable { selfdestruct(to); } }` with 1 ether and to = address(vault). Expected per invariant 8: revert. Actual: address(vault).balance becomes 501 ether, no event, receive() never runs; a plain 1 wei call to the vault in the same test does revert. Reproduced in Foundry (test_forcedEth, evm_version shanghai).","severity":"info","snippet":"    receive() external payable {\n        revert UseDepositETH();\n    }","title":"Invariants 8 and 9 are not strict on-chain: ETH can be forced in by SELFDESTRUCT and ERC-20s sent directly, so reconciliation must use balance >= liabilities"},{"citation":"resolved","description":"The 29 tests cover happy paths and the main revert paths, but none pins the cases AUDIT.md singles out: (a) _spend rollover at exactly start + WINDOW (time.increase(DAY) after several mined blocks lands at start + DAY + k, so the >= branch is only confirmed by inspection) and the resulting 2x dailyCap within one second; (b) ECDSA.recover with 64-byte, empty and high-s signatures and that nonceUsed stays false; (c) two distinct SIGNER keys for amount > cosignAbove (must revert), [signer, outsider], cosigner-only, and amount == cosignAbove with one signature; (d) COSIGNER revocation between signing and submission, and re-grant; (e) an ERC-777-style token whose sender hook re-enters deposit(); (f) a USDC-style blocklisted w.user leaving nonceUsed false and window.spent unchanged; (g) fundBankroll from a non-allowlisted wallet during a deposit pause and sweep during a withdrawal pause; (h) ETH in the randomized invariant test. All of (a)-(f) were run in Foundry during this review and the contract behaves correctly (reentrancy reverts ReentrancyGuardReentrantCall, malformed signatures revert in ECDSA rather than BadSignatures, blocklist revert keeps the nonce unused, window.start is set to exactly start + WINDOW), so this is a coverage gap, not a defect; (g) are the findings above. Merged from audit_flow, audit_permissions and audit_economics. Fix: add these as regression tests so a refactor of _verify or _spend cannot regress silently.","line":148,"path":"msft95-vault/test/vault.test.js","reproduction":"Example untested input: setToken(tok, {maxWithdrawal 100e18, dailyCap 100e18, cosignAbove 100e18}); withdraw 100e18 at T (window.start = T); at T + 1 days - 1 a 1 wei withdraw reverts DailyCapExceeded(100e18 + 1, 100e18); at exactly T + 1 days remainingToday(tok) == 100e18 and a 100e18 withdraw succeeds with window(tok) == (T + 1 days, 100e18). Expected and actual agree (documented 2x behaviour) but no test in test/vault.test.js asserts it; likewise none passes a 64-byte or high-s signature, two SIGNER keys, a blocklisting token or a hook token. Verified in Foundry (test_windowBoundary, test_sigEdges, test_blocklist, test_hookReentrancy).","severity":"info","snippet":"      await time.increase(DAY);","title":"Hardhat suite does not exercise the edges the brief lists (exact window boundary, malformed/high-s signatures, two SIGNER keys, cosigner revocation, blocklisted recipient, hook-token reentrancy, fundB"},{"citation":"resolved","description":"Outside the contract scope, reported as an open deployment item. The vault is constructed with the hot deployer key as default admin, configured, and then beginDefaultAdminTransfer(ADMIN) is called. With AccessControlDefaultAdminRules the Safe can only call acceptDefaultAdminTransfer() once block.timestamp is strictly past schedule = now + delay (2 days on mainnet); until then, or forever if the Safe never accepts, the deployer EOA holds full admin power (setToken, sweep up to dailyCap per token per window, grantRole). The script also only rejects SIGNER == COSIGNER, not SIGNER == GUARDIAN or COSIGNER == GUARDIAN. Mitigation that keeps the design: deploy with the Safe as the constructor admin and run configuration from the Safe (or deploy with delay 0, hand over, then raise it with changeDefaultAdminDelay), and extend the equality check to all three role addresses.","line":52,"path":"msft95-vault/scripts/deploy.js","reproduction":"State: vault deployed with delay 2 days by `admin`, beginDefaultAdminTransfer(newAdmin) called at time T. Input: newAdmin calls acceptDefaultAdminTransfer() at exactly T + 2 days -> reverts AccessControlEnforcedDefaultAdminDelay (OpenZeppelin requires schedule < block.timestamp); the same call at T + 2 days + 1 succeeds and defaultAdmin() == newAdmin. During the delay hasRole(DEFAULT_ADMIN_ROLE, deployer) is true. Also verified that handing admin to an account already holding SIGNER_ROLE works through the _grantRole override. Reproduced in Foundry (test_adminHandover).","severity":"info","snippet":"  const vault = await Vault.deploy(deployer.address, delay);","title":"Deploy script leaves the deployer EOA as DEFAULT_ADMIN until the Safe accepts, and acceptance is only possible strictly after the full ADMIN_DELAY (open deployment item)"}],"hash":"059650338e18194b949eaebdea608852419c65b206a089690da844e2e5534826","nodeId":"44407c9e-3d1d-4a61-a1e2-eb0b7e0fb6ed","outcome":"completed","summary":"The review is complete. The findings file holds 9 findings and no repository files were changed.\n\n**Outcome.** No critical or high defect exists in `msft95-vault/contracts/MSFT95Vault.sol`. Every specialist claim reproduced against the compiled contract (solc 0.8.24, OpenZeppelin 5.1.0, shanghai) in a scratch Foundry project I set up outside the repo. The 17 specialist findings merged into 9, with two kept at low and seven at info.\n\n**Kept findings, in `.imd-findings.json`:**\n- **Low: fundBankroll is permissionless and skips every inbound gate.** A non-allowlisted wallet can push tokens or ETH into the vault while the guardian has paused deposits, with no Deposited event, so the ledger never credits it. Carries a proof test that fails on the current code and passes with an admin gate added. Merged from three specialists.\n- **Low: voucher lifetime is bounded at submission, not signing.** A voucher with a one-year deadline is rejected today and paid in its final 24 hours. Role re-grant revives dormant vouchers. Merged from four specialists.\n- **Info:** ETH branch of fundBankroll ignores `amount`; deadline is inclusive; sweep ignores the withdrawal pause; setToken accepts cosignAbove equal to maxWithdrawal; forced ETH via selfdestruct breaks invariants 8 and 9 as equalities; Hardhat suite leaves the brief's listed edges untested; deploy script leaves the deployer EOA as admin until the Safe accepts strictly after the delay.\n\n**Judgement calls.** I downgraded the cosignAbove equality from low to info because the project's own suite configures it deliberately and only the trusted admin can set it. I downgraded the test-coverage item from low to info because every untested edge behaves correctly when exercised. The fundBankroll ETH `amount` issue stays separate from the gating finding because the fix differs.\n\n**Verified clean.** Signature edge cases (two SIGNER keys, cosigner-only, outsider, 64-byte, empty, high-s), cosigner revocation between signing and submission, window rollover at exactly start plus WINDOW, blocklisted recipient leaving the nonce unused, ERC-777-style hook reentrancy during deposit, and admin handover through the `_grantRole` override all behave as the brief requires.","treeHash":null,"usage":{"cachedInputTokens":1259219,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":34716,"runtime":"claude","turns":33,"wallClockMs":642185}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fa2b0b9c7ebc1547","findings":[{"citation":"resolved","description":"fundBankroll only checks tokenConfig[token].listed. It does not run _checkDeposit, so it ignores allowlistOnly/allowed, depositsPaused and depositsEnabled, and anyone may call it. Two consequences. (1) A player who sends funds through this entry point (wrong button in a UI, or a wallet that routes 'deposit' to the wrong selector) loses them: no Deposited event is emitted, the indexer never credits the ledger, and the tokens become house bankroll that only the admin can sweep. The README says 'Allowlist mode is on at deploy, so only listed wallets can deposit' and the NatSpec calls this 'house bankroll', so the current behaviour contradicts both. (2) The GUARDIAN's pauseDeposits() is documented as an instant brake on inflows, but it does not stop value from entering through this path, so during an incident (for example a listed token whose contract is found to be compromised) funds can still be pushed into the vault. Fix that preserves the design: gate fundBankroll with onlyRole(DEFAULT_ADMIN_ROLE) (or a dedicated BANKROLL_ROLE granted to the treasury wallet), and additionally revert when depositsPaused is set. If the house really wants anyone to be able to donate, at minimum honour depositsPaused and document that this path is not credited.","line":138,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: setAllowlistOnly(true); pauseDeposits() called by the GUARDIAN; setToken(tok, {listed:true, depositsEnabled:false, ...}); alice is NOT on the allowlist and holds 10e18 tok with approval to the vault. Input: alice calls fundBankroll(tok, 10e18). Expected (per README/brief: only allowlisted wallets can deposit, deposits are paused): revert with NotAllowed or DepositsClosed. Actual: the call succeeds, alice's balance goes to 0, BankrollFunded(alice, tok, 10e18) is emitted and no Deposited event exists, so the ledger never credits alice. Verified with a Foundry test (test_fundBankrollBypass) against the current code.","severity":"low","snippet":"    function fundBankroll(address token, uint256 amount) external payable nonReentrant {","title":"fundBankroll is permissionless and bypasses the allowlist, the deposit pause and depositsEnabled"},{"citation":"resolved","description":"withdraw() reverts with WithdrawalsClosed while withdrawalsPaused is true, but sweep() has no such check. The brief says the GUARDIAN bot pauses withdrawals when reconciliation fails (ledger liabilities exceed the vault balance). In that state the only outflow still possible is the admin's sweep, which is exactly the outflow that makes the shortfall worse. The admin is trusted and timelocked on mainnet, so this is reported as a design decision to confirm, not as a bypass: either document that 'pause withdrawals' means player withdrawals only, or add `if (withdrawalsPaused) revert WithdrawalsClosed();` to sweep so that the pause is a complete outflow brake and an unpause (admin, visible through the timelock) is required first.","line":272,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: GUARDIAN calls pauseWithdrawals(); tok is listed with dailyCap 5000e18 and the vault holds 20000e18 tok. Input: admin calls sweep(tok, 0xBEEF, 100e18). Expected if the pause is meant as a full outflow brake: revert WithdrawalsClosed. Actual: succeeds, 100e18 tok leaves the vault while player withdrawals are frozen. Verified with a Foundry test (test_sweepWhilePaused).","severity":"info","snippet":"    function sweep(address token, address to, uint256 amount) external onlyRole(DEFAULT_ADMIN_ROLE) nonReentrant {","title":"sweep is not stopped by withdrawalsPaused; the guardian's emergency pause does not cover house withdrawals"},{"citation":"resolved","description":"Invariant 3 in the brief says a voucher is valid 'before its deadline', and the off-chain assumption says the ledger re-credits an unused voucher 'only after deadline has passed'. The contract accepts the voucher when block.timestamp equals deadline. That is a common and acceptable convention, but the backend's definition of 'passed' must be `finalBlock.timestamp > deadline`, not `>=`; if the ledger re-credits at a block whose timestamp equals the deadline while a withdraw() in that same block pays out, the player is paid twice (once on-chain, once in ledger balance). Either change the check to `>=` to match the wording 'before its deadline', or keep it and state in AUDIT.md/README that the deadline second is itself valid.","line":183,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: tok listed, signer holds SIGNER_ROLE, vault funded. Input: voucher {user: alice, token: tok, amount: 10e18, nonce: 1, deadline: T} submitted in a block with block.timestamp == T. Expected per the brief's wording ('before its deadline'): revert VoucherExpired. Actual: the voucher pays 10e18 to alice. Verified with a Foundry test (test_deadlineInclusive). The same block cannot also be past the deadline, so a ledger that re-credits at ts >= deadline would double pay.","severity":"info","snippet":"        if (block.timestamp > w.deadline) revert VoucherExpired();","title":"Voucher deadline is inclusive: a voucher pays at block.timestamp == deadline, so the ledger must only re-credit when a final block has timestamp strictly greater than deadline"},{"citation":"resolved","description":"The check compares deadline with the submitting block's timestamp. A SIGNER can therefore sign a voucher with any deadline (for example one year out); the voucher is rejected with VoucherTooLong until 24h before that deadline and is then paid. Invariant 3 as written ('at most 24h ahead of block.timestamp') holds literally, but the README statement 'expires within 24 hours at most' does not: nothing on-chain bounds voucher lifetime from signing. The practical consequence is for the ledger: the 'ledger debits before signing and re-credits only after deadline' rule means a long-deadline voucher (signer bug or misconfiguration of TTL) locks the player's ledger balance for the whole period, and a compromised-then-revoked signer's long-dated vouchers only stay dead because hasRole is re-checked at submission (which it is). Either include an `issuedAt` field in the typed data and require `deadline - issuedAt <= MAX_VOUCHER_LIFETIME`, or document that the 24h bound is a submission-window bound and have the backend enforce the TTL.","line":184,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: signer holds SIGNER_ROLE, tok listed, now = T. Input: voucher {alice, tok, 10e18, nonce 1, deadline: T + 365 days}, signed by signer, submitted at T. Expected per README ('expires within 24 hours at most'): a voucher with a one-year deadline is never valid. Actual: at T it reverts VoucherTooLong; the same voucher and signature submitted at T + 364 days pays 10e18 to alice. Verified with a Foundry test (test_farFutureDeadline).","severity":"info","snippet":"        if (w.deadline > block.timestamp + MAX_VOUCHER_LIFETIME) revert VoucherTooLong();","title":"MAX_VOUCHER_LIFETIME bounds the deadline relative to submission time, not signing time, so 'expires within 24 hours at most' is not enforced on-chain"},{"citation":"resolved","description":"receive() reverting stops accidental plain transfers, which is the stated purpose, but it cannot stop value entering by other routes: a contract that SELFDESTRUCTs with the vault as beneficiary (still credits balance on Shanghai/Cancun targets), or a direct ERC-20 transfer() to the vault address. Neither emits Deposited or BankrollFunded, so 'token balance always equals deposits plus bankroll minus withdrawals minus sweeps' (invariant 9) and 'ETH can only enter through depositETH or fundBankroll' (invariant 8) do not hold as equalities. This is harmless for the contract (surplus is sweepable by the admin, subject to dailyCap) but matters for the GUARDIAN reconciliation bot described in the brief: it should pause when liabilities > balance, and must not treat balance != tracked-sum as a reconciliation failure, otherwise an outsider can trigger a withdrawal pause with a 1 wei transfer.","line":154,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: vault deployed, tok listed, vault tok balance B. Input: any EOA calls tok.transfer(vault, 1) (or deploys a contract holding 1 wei that selfdestructs to the vault). Expected per invariant 9: balance == deposits + bankroll - withdrawals - sweeps == B. Actual: vault.balanceOf(tok) == B + 1 with no Deposited/BankrollFunded event, so the tracked sum and the balance diverge by 1 wei; a reconciliation rule based on equality would fire. The randomized invariant test in test/vault.test.js never exercises this path.","severity":"info","snippet":"    receive() external payable {","title":"Invariants 8 and 9 are not strict equalities: ETH can be forced in by SELFDESTRUCT and any ERC-20 can be sent directly, so reconciliation must use balance >= liabilities"},{"citation":"resolved","description":"The suite is good on happy paths but does not cover the inputs the brief singles out. Concretely absent: (a) rollover at exactly start + WINDOW (the test advances DAY after several mined blocks, so it lands at start + DAY + k, never on the boundary second) and the resulting 2x dailyCap inside two seconds; (b) ECDSA.recover with malformed signatures (length != 65, v = 0/1, high-s) and that the nonce stays unused afterwards; (c) a signature set of two different SIGNER keys [s1, s2] for an amount above cosignAbove (must revert), [s, c] for an amount at or below cosignAbove (must pass), and amount == cosignAbove (single signature suffices); (d) COSIGNER revocation between signing and submission; (e) an ERC-777-style token whose transfer hook re-enters withdraw() or deposit() during deposit(); (f) a USDC-style blocklisted w.user (revert must leave nonceUsed false and window.spent unchanged); (g) sweep() while withdrawalsPaused and fundBankroll() while allowlistOnly/depositsPaused; (h) ETH in the randomized invariant test (only the ERC-20 is covered). I ran all of these in a scratch Foundry suite against the current code and the contract behaves correctly in every case (reentrancy reverts with ReentrancyGuardReentrantCall, blocklist revert keeps the nonce unused, malformed signatures revert with ECDSAInvalidSignature/ECDSAInvalidSignatureLength rather than BadSignatures), so this is a coverage gap, not a defect. Adding these as regression tests protects the reviewed properties against future edits.","line":148,"path":"msft95-vault/test/vault.test.js","reproduction":"Example of an untested input: setToken(tok, {maxWithdrawal: 100e18, dailyCap: 100e18, cosignAbove: 100e18}); withdraw 100e18 at time T (window.start = T); at T + 1 days - 1 a 1 wei withdraw reverts DailyCapExceeded(100e18 + 1, 100e18); at exactly T + 1 days remainingToday(tok) returns 100e18 and a 100e18 withdraw succeeds, so 200e18 leaves within one second of wall-clock. Expected and actual agree (documented 2x behaviour), but no test in test/vault.test.js asserts it.","severity":"info","snippet":"      await time.increase(DAY);","title":"Hardhat suite leaves several brief-listed edge cases unexercised (exact window boundary, malformed signatures, two-signer sets, hook-token reentrancy, blocklisted recipient, sweep/fundBankroll under p"},{"citation":"resolved","description":"Outside the contract scope, reported as an open deployment item. The vault is constructed with the deployer as default admin, configured, and then beginDefaultAdminTransfer(ADMIN) is called. With AccessControlDefaultAdminRules the Safe can only call acceptDefaultAdminTransfer() once block.timestamp is strictly past schedule = now + delay (2 days on mainnet), and until then, or forever if the Safe never accepts, a hot EOA key holds full admin power (setToken, sweep up to dailyCap, grantRole). The script also does not reject SIGNER == GUARDIAN or COSIGNER == GUARDIAN. Mitigation that keeps the design: deploy with the Safe as the constructor admin and have the Safe run the configuration transactions (or use a short delay at deploy and raise it via changeDefaultAdminDelay after handover), and extend the equality check at deploy.js line 47 to all three role addresses.","line":52,"path":"msft95-vault/scripts/deploy.js","reproduction":"State: mainnet deploy with ADMIN=0xSafe and default delay 172800. Input: run scripts/deploy.js, then have the Safe call acceptDefaultAdminTransfer() at deployTime + 172800. Expected (README: 'needs acceptDefaultAdminTransfer() from the Safe after the delay'): success. Actual: reverts AccessControlEnforcedDefaultAdminDelay(schedule) because OpenZeppelin requires schedule < block.timestamp; it succeeds one second later. During those two days hasRole(DEFAULT_ADMIN_ROLE, deployer) is true and the deployer can sweep up to dailyCap per token per window. Verified the strict-greater rule with a Foundry test (test_adminHandover with delay 0 needed warp(+1)).","severity":"info","snippet":"  const vault = await Vault.deploy(deployer.address, delay);","title":"Deploy script leaves the deployer EOA as DEFAULT_ADMIN until the Safe accepts; acceptance is only possible after the full ADMIN_DELAY"}],"hash":"5baebf47061695b2824cbf53d8cb8ef1ee1405b9a574763be04f669c3f3bcd8e","nodeId":"79e7c059-fb9f-449f-ae2b-628ef68211be","outcome":"completed","summary":"The files are in place and intentionally excluded from version control by the harness, which is expected. No repository source or configuration files were changed.\n\n## Review outcome\n\n`msft95-vault/contracts/MSFT95Vault.sol` holds up well. I found no critical, high or medium defect, so no proof tests are attached. The findings file holds one low and six informational items.\n\n**What I verified with scratch Foundry tests** (OpenZeppelin 5.1.0, solc 0.8.24, shanghai, matching the brief):\n- Replay and ordering: nonce is set before payout, and a reverted ERC-20 transfer (blocklisted recipient) or ETH send leaves the nonce and `window.spent` untouched.\n- Signatures: `[s1, s2]` with two SIGNER keys is rejected above `cosignAbove`, `[c]` alone is rejected, `[s, c]` and `[c, s]` both work, same signature twice is rejected, cosigner revocation kills in-flight vouchers, malformed signatures revert cleanly with OpenZeppelin's `ECDSAInvalidSignature*` errors.\n- Window: rollover at exactly `start + WINDOW` resets, giving the documented 2× cap. The `uint192` and `uint64` casts are safe because the cap check precedes the cast, and an oversized `sweep` amount reverts before casting.\n- Reentrancy: a hook token re-entering `withdraw` from inside `deposit`, and an ETH receiver re-entering `withdraw`, both hit `ReentrancyGuardReentrantCall`.\n- Admin handover to an address already holding COSIGNER works through the `_grantRole` override.\n\n**Findings written to `.imd-findings.json`:**\n1. **Low.** `fundBankroll` is permissionless and skips the allowlist, `depositsPaused` and `depositsEnabled`. A non-allowlisted player calling it during a deposit pause loses funds with no `Deposited` event.\n2. **Info.** `sweep` ignores `withdrawalsPaused`, so the guardian's pause is not a full outflow brake. Design decision to confirm.\n3. **Info.** Deadline is inclusive. The ledger must re-credit only when a final block timestamp is strictly past the deadline.\n4. **Info.** The 24h lifetime bound is measured at submission, so a one-year voucher becomes payable 24h before its deadline.\n5. **Info.** Invariants 8 and 9 are not strict equalities. ETH can be forced in by SELFDESTRUCT and ERC-20s sent directly, so the reconciliation bot must use \"balance ≥ liabilities\".\n6. **Info.** Test gaps in the Hardhat suite, listing the exact untested inputs.\n7. **Info.** Deploy script keeps the deployer EOA as admin until the Safe accepts, which OpenZeppelin only allows strictly after the delay.\n\n**Trust assumptions documented, not reported as defects:** admin can raise `dailyCap` or relist tokens at will, a compromised SIGNER can drain up to 2× `dailyCap` per token across a window boundary, and a GUARDIAN pause can only be lifted through the timelocked admin.","treeHash":null,"usage":{"cachedInputTokens":1609832,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":51385,"runtime":"claude","turns":30,"wallClockMs":706393}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3baa2091028c32c1","findings":[{"citation":"resolved","description":"deposit()/depositETH() go through _checkDeposit, which enforces the allowlist (the README says allowlist mode is on at deploy and must stay on until the licence and KYC are in place), the guardian's deposit pause, the per-token depositsEnabled flag and minDeposit. fundBankroll() moves funds into the same vault but only checks `listed`, and anyone may call it. Two consequences: (1) the KYC/licence gate and the guardian's deposit pause only cover one of the two inbound paths, so any wallet, allowlisted or not, paused or not, can push ETH or any listed token into the vault's custody by calling fundBankroll; (2) a player who uses this entry point by mistake gets a BankrollFunded event instead of Deposited, and the brief's off-chain model credits only Deposited, so the funds are not credited and the player has no on-chain recourse. The function is documented as the house's way to add bankroll, so it does not need to be open to everyone. Minimal fix that preserves the design: restrict fundBankroll to DEFAULT_ADMIN_ROLE (or a dedicated FUNDER role), or at least run the same allowlist/pause checks as _checkDeposit on the non-admin path.","line":139,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: admin calls setAllowlistOnly(true); guardian calls pauseDeposits(); stranger 0x5717 is not in `allowed`. Input: stranger calls depositETH{value: 1 ether}() -> reverts DepositsClosed (expected). Stranger then calls fundBankroll{value: 1 ether}(address(0), 0). Expected (per README: only listed wallets can deposit while allowlist is on, and deposits are paused): revert. Actual: succeeds, vault ETH balance rises by 1 ether and BankrollFunded(0x5717, 0x0, 1e18) is emitted. Same with an ERC-20: stranger approves and calls fundBankroll(tok, 5e18) while allowlistOnly is on -> succeeds. Verified with a Foundry test (test_fundBankroll_bypasses_allowlist_and_pause) against the contract.","severity":"low","snippet":"        if (!tokenConfig[token].listed) revert TokenNotListed(token);","title":"fundBankroll is permissionless and bypasses allowlistOnly, depositsPaused, depositsEnabled and minDeposit"},{"citation":"resolved","description":"withdraw() rejects amount > maxWithdrawal before it decides whether a cosigner is needed (amount > cosignAbove). If cosignAbove == maxWithdrawal, the predicate `amount > cosignAbove` can never be true for a payable voucher, so the two-key requirement of invariant 4 is vacuous for that token and a single SIGNER key can take up to maxWithdrawal per voucher and dailyCap per window (2x dailyCap across a window boundary). The validation at this line is clearly meant to keep the three limits consistent (it already rejects cosignAbove > maxWithdrawal) but is off by one: the only configurations that keep the cosigner reachable are cosignAbove < maxWithdrawal. Because `cosignAbove` is a uint128, an operator who wants to disable cosigning for a token can already express it explicitly; the silent case is the hazard. Minimal fix: change `cfg.cosignAbove > cfg.maxWithdrawal` to `cfg.cosignAbove >= cfg.maxWithdrawal` (optionally allowing an explicit sentinel such as cosignAbove == type(uint128).max if cosigning is meant to be optional per token).","line":265,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: admin calls setToken(tok, {listed:true, depositsEnabled:true, minDeposit:1, maxWithdrawal:1000e18, dailyCap:5000e18, cosignAbove:1000e18}). Expected: BadConfig, since no withdrawal can ever be above cosignAbove while staying <= maxWithdrawal. Actual: accepted. Then a voucher {user: alice, token: tok, amount: 1000e18, nonce: 1, deadline: now+3600} signed by the SIGNER key only is paid in full (alice receives 1000e18) with no COSIGNER signature. Verified with a Foundry test (test_cosign_equal_max).","severity":"low","snippet":"        if (cfg.cosignAbove > cfg.maxWithdrawal || cfg.maxWithdrawal > cfg.dailyCap) revert BadConfig();","title":"setToken accepts cosignAbove == maxWithdrawal, which makes the COSIGNER requirement unreachable for that token"},{"citation":"resolved","description":"The brief says the GUARDIAN bot pauses withdrawals when reconciliation fails (ledger liabilities exceed the vault balance). withdraw() checks withdrawalsPaused, but sweep() does not, so while the vault is in a 'liabilities > balance' state the admin can still move up to dailyCap per token per window out of the vault, worsening the shortfall for players. The admin is fully trusted in the stated trust model, and on mainnet it is a timelocked Safe, so this is reported as a trust-assumption gap rather than a bypass: the pause is documented as a stop on outflows, yet one outflow path ignores it. Minimal fix if the intent is that no funds leave while paused: add `if (withdrawalsPaused) revert WithdrawalsClosed();` to sweep(). If sweeping during a pause is intentional (e.g. to move funds to a rescue address), document it in AUDIT.md/README.","line":272,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: guardian calls pauseWithdrawals(). Input: admin calls sweep(tok, admin, 5000e18) (the full dailyCap). Expected if the pause is meant to freeze outflows: revert WithdrawalsClosed. Actual: succeeds; admin receives 5000e18 and HouseWithdrawal is emitted while withdrawalsPaused == true. Verified with a Foundry test (test_sweep_during_pause).","severity":"info","snippet":"    function sweep(address token, address to, uint256 amount) external onlyRole(DEFAULT_ADMIN_ROLE) nonReentrant {","title":"sweep() is not blocked by withdrawalsPaused, so the guardian's pause does not stop outflows"},{"citation":"resolved","description":"Invariant 3 ('at most 24h ahead of block.timestamp') is checked at submission. The contract therefore cannot distinguish a voucher signed one hour ago with a 24h deadline from one signed a year ago with deadline = signing_time + 365 days: the latter is simply unredeemable until deadline - 24h and then valid for a full day. Nothing on-chain breaks (the nonce and the hasRole check at redemption still hold, so key rotation kills such vouchers), but the step-2 backend must not reason 'vouchers die 24h after signing'. A voucher produced with a wrong or malicious deadline keeps the player's ledger debit locked until that deadline, and if the backend re-credits earlier than `deadline` it creates a double-spend window. This is reported as information for the backend review and the signing-service input validation; a contract-side mitigation that preserves the design would be to add an `issuedAt` field to the EIP-712 struct and require block.timestamp - issuedAt <= MAX_VOUCHER_LIFETIME.","line":184,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"Input: SIGNER signs {user: alice, token: tok, amount: 1e18, nonce: 4, deadline: T + 365 days} at time T. Submitting at T reverts VoucherTooLong (as documented). Warp to T + 364 days: the same voucher and signature are paid. Expected by a reader of 'vouchers live at most 24h': rejected; actual: accepted 364 days after signing. Verified with a Foundry test (test_deadline_edges).","severity":"info","snippet":"        if (w.deadline > block.timestamp + MAX_VOUCHER_LIFETIME) revert VoucherTooLong();","title":"MAX_VOUCHER_LIFETIME bounds deadline relative to redemption time, not issuance time: far-future vouchers are latent liabilities"},{"citation":"resolved","description":"The 29 Hardhat tests cover the happy paths and the main revert paths, but these brief-listed edges have no test: (a) `_spend` rollover at exactly start + WINDOW (the suite uses time.increase(DAY) after several blocks, so it lands strictly past the boundary); (b) ECDSA.recover with malformed input (64-byte, empty, v=0, high-s malleated signatures); (c) a blocklisting token (the brief's USDC case: withdrawal reverts cleanly and the nonce stays unused); (d) fundBankroll called by a non-allowlisted wallet or during a deposit pause; (e) cosignAbove == maxWithdrawal; (f) a two-signature voucher where the second key has neither role, and the [cosigner-only] / [signer, outsider] orderings; (g) sweep while withdrawals are paused. All of these were run in Foundry during this review (test/scratch/Explore.t.sol) and the contract behaves as the brief expects for (a), (b), (c) and (f); (d), (e) and (g) are the findings above. Adding them to the suite would lock in the invariants the brief asks for.","line":160,"path":"msft95-vault/test/vault.test.js","reproduction":"Run the suite: no test asserts behaviour at block.timestamp == window.start + 1 days, none passes a 64-byte or high-s signature to withdraw(), none uses a token whose transfer reverts for the recipient, and none calls fundBankroll from a non-admin while allowlistOnly is true. Expected: coverage of the edges named in AUDIT.md 'Areas worth extra attention'; actual: 100% line coverage but these branches are only hit on the happy side.","severity":"info","snippet":"    it(\"blocks re-entry from an ETH receiver\", async function () {","title":"Test suite does not exercise several edges the brief lists"}],"hash":"9e8d91bf18bc2d01290b77bcb2edb43d9e8d1406b84890f34c532b51df43632e","nodeId":"0ebdd29d-ddd2-4cc9-ac21-75d8a6a31d76","outcome":"completed","summary":"Both files are in place; the harness excludes them from git by design, so nothing else in the tree changed. I did not modify any repository file.\n\n**Outcome: no critical or high defects in `MSFT95Vault.sol`.** The nine invariants in the brief hold under test. Findings are recorded in `.imd-findings.json`: two low, three informational.\n\n**What I verified with Foundry tests** (in `test/scratch/Explore.t.sol`, run against OpenZeppelin 5.1.0 and solc 0.8.24):\n- Signature handling: every ordering of signer, cosigner and outsider keys behaves as specified. Duplicate keys, cosigner-only, and signer-plus-stranger all revert. Malformed signatures (64-byte, empty, v=0, high-s malleated) revert cleanly.\n- Window rollover: a window fills at `start + 1 day - 1` and resets at exactly `start + 1 day`, giving the documented 2x cap across the boundary and nothing more.\n- Blocklisted recipient: the withdrawal reverts, the nonce stays unused and the window spend is unchanged.\n- Admin handover completes with the `_grantRole` override in place, and the override still refuses a key holding both signing roles.\n\n**Findings reported:**\n- **Low:** `fundBankroll` is permissionless and skips the allowlist, the deposit pause, `depositsEnabled` and `minDeposit`. A non-allowlisted wallet can push funds into custody while deposits are paused, and a player who uses it by mistake is never credited. Fix: gate it to admin or apply the deposit checks.\n- **Low:** `setToken` accepts `cosignAbove == maxWithdrawal`. Since any payable amount is at most `maxWithdrawal`, the cosigner condition can never be true and one SIGNER key pays the full per-voucher limit alone. The validation is off by one; use `>=`.\n- **Info:** `sweep` ignores `withdrawalsPaused`, so the guardian's reconciliation pause does not stop admin outflows. Reported as a trust-assumption gap since admin is trusted.\n- **Info:** the 24h voucher lifetime is measured against redemption time, not signing time. A voucher with a deadline a year out becomes valid for one day a year later. Relevant for the backend's re-credit logic, not an on-chain break.\n- **Info:** test gaps. The Hardhat suite does not cover the exact window boundary, malformed signatures, blocklist tokens, non-admin `fundBankroll`, or the `cosignAbove == maxWithdrawal` case.\n\nNo proofs were attached because no finding reached high severity. Scope reviewed: the full contract, mocks, tests, scripts and the four supplied reference guides. Not run: Slither and long fuzz campaigns, which the task does not provide.","treeHash":null,"usage":{"cachedInputTokens":1081967,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":41488,"runtime":"claude","turns":25,"wallClockMs":629913}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2b170a76e3d9adca","findings":[{"citation":"resolved","description":"Branch/pair asymmetry between deposit()/depositETH() and fundBankroll(). deposit() and depositETH() route through _checkDeposit(), which enforces depositsPaused, TokenConfig.depositsEnabled and allowlistOnly/allowed[msg.sender]. fundBankroll() is callable by anyone, checks only `listed`, and credits nobody (it emits BankrollFunded, which the indexer does not credit). The contract's own receive() reverts specifically 'so nobody loses funds by sending without depositETH()', yet this public function has exactly that outcome for any wallet that calls it: the funds become house bankroll with no on-chain record of a player claim. The gating asymmetry also means a guardian deposit pause and the KYC allowlist do not stop value from entering through this path. On the ETH branch the `amount` argument is silently ignored (received = msg.value) while the ERC-20 branch rejects a stray msg.value, so a caller passing amount=123e18 with msg.value=1 ether gets a BankrollFunded event for 1 ether and no error. Access-control classification: public/unrestricted state-changing entry point whose intended caller is the house. Fix that preserves the design: restrict fundBankroll to DEFAULT_ADMIN_ROLE (or a dedicated BANKROLL role granted to the treasury), and on the ETH branch require amount == msg.value (or amount == 0). Alternatively, at minimum apply depositsPaused and allowlist checks so the pause and allowlist mean what the README says.","line":138,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: allowlistOnly = true, bob not in `allowed`, guardian called pauseDeposits(), admin set tokenConfig[tok].depositsEnabled = false. Input: bob (holding 100e18 tok, approved) calls deposit(tok, 100e18) -> reverts DepositsClosed (expected). bob calls fundBankroll(tok, 100e18) -> succeeds, vault tok balance +100e18, bob balance 0, event BankrollFunded(bob, tok, 100e18), no Deposited event, no player credit possible. bob calls fundBankroll{value: 1 ether}(address(0), 123e18) -> succeeds and emits BankrollFunded(bob, 0x0, 1e18); the 123e18 argument is never checked. Expected per the receive() rationale and the README role table ('Admin ... sweep house funds', players deposit only through deposit/depositETH): a non-house caller, a paused vault, or a disabled token should be rejected. Confirmed with a Foundry probe (test_fundBankrollSkipsGates) against the compiled contract with OpenZeppelin 5.1.0.","severity":"low","snippet":"    function fundBankroll(address token, uint256 amount) external payable nonReentrant {","title":"fundBankroll is unguarded and skips every deposit gate (allowlist, pause, depositsEnabled); ETH branch ignores `amount`"},{"citation":"resolved","description":"Admin-variant asymmetry (sweep vs withdraw). withdraw() checks withdrawalsPaused on line 179; sweep() checks only `listed`, amount/to != 0 and the daily cap. The GUARDIAN pause exists for the case 'ledger liabilities > vault balance' (AUDIT.md key custody section), i.e. the moment the house most needs outflows frozen, yet the pause does not freeze the house's own outflow of up to dailyCap per token per window (2x dailyCap across a window boundary). sweep() also ignores maxWithdrawal and cosignAbove. This is consistent with the brief's trust model (admin fully trusted, timelocked Safe) and invariant 7, so it is reported as a trust assumption and not as a bypass: the pause bounds SIGNER-key compromise only; Safe/timelock compromise is bounded only by dailyCap. If the intent is that a reconciliation pause freezes all outflows, add `if (withdrawalsPaused) revert WithdrawalsClosed();` to sweep(); otherwise state in README/AUDIT.md that sweeps are exempt from the pause.","line":272,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: guardian called pauseWithdrawals(); tokenConfig[tok] = {listed, dailyCap 5000e18, maxWithdrawal 1000e18, cosignAbove 100e18}; vault holds 20000e18 tok. Input: alice submits a valid SIGNER voucher for 10e18 -> reverts WithdrawalsClosed. admin calls sweep(tok, admin, 5000e18) in the same paused state -> succeeds, 5000e18 leaves the vault (five times maxWithdrawal, no cosigner, while paused). Confirmed by Foundry probe test_sweepUnderPause.","severity":"info","snippet":"    function sweep(address token, address to, uint256 amount) external onlyRole(DEFAULT_ADMIN_ROLE) nonReentrant {","title":"sweep is not subject to withdrawalsPaused: admin outflow continues under a GUARDIAN pause (trust assumption to document)"},{"citation":"resolved","description":"receive() rejects plain value transfers, which is correct, but the brief's invariant 8 ('ETH can only enter through depositETH or fundBankroll') and invariant 9 (exact balance equality) cannot hold for ETH: a contract created and self-destructed in the same transaction (still allowed after EIP-6780; the project targets shanghai, where selfdestruct is unconditional) transfers its balance to the vault with no code executed and no event. ERC-20s can likewise be transferred directly to the vault address. No funds are at risk; the effect is balance >= ledger rather than ==. The relevance is to the off-chain reconciliation described in AUDIT.md: the GUARDIAN bot must compare liabilities <= balance, not liabilities == balance, and the randomized invariant test (test/vault.test.js, 'tokens in == tokens out + balance') should be understood as an accounting property of the contract's own paths, not a chain-level guarantee. Unattributed surplus can be recovered by the admin via sweep() within the daily cap (if the token is listed), so no fix is strictly needed beyond rewording the invariant.","line":154,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: fresh vault with ETH listed, balance 500 ether. Input: deploy `contract Bomb { constructor(address payable to) payable { selfdestruct(to); } }` with value 1 ether and `to = address(vault)`. Actual: address(vault).balance becomes 501 ether, no Deposited/BankrollFunded event, receive() never runs. Expected per invariant 8: revert. Confirmed by Foundry probe test_forcedEth (evm_version shanghai, solc 0.8.24).","severity":"info","snippet":"    receive() external payable {\n        revert UseDepositETH();","title":"Invariants 8 and 9 are not enforceable on-chain: ETH can be forced in via SELFDESTRUCT, so balance can exceed deposits+bankroll-withdrawals-sweeps"},{"citation":"resolved","description":"README states every voucher 'expires within 24 hours at most'. The check compares deadline to block.timestamp at submission, so a voucher with deadline = now + 365 days is rejected today (VoucherTooLong) but is accepted on any block in [deadline - 1 day, deadline]. Nothing binds the issue time into the signed struct. Invariant 3 as literally worded ('at most 24h ahead of block.timestamp') does hold, so this is not a violation; it is a gap between the README wording and the mechanism. Practical consequence for the key-compromise model: an attacker who briefly held the SIGNER key can bank dormant vouchers that only activate later. Role revocation at submission time (hasRole in _verify) is the sole kill switch and does work (probe test_revokeRegrant), so key rotation after any suspected compromise is mandatory, and re-granting the same key later (test_revokeRegrant) revives every voucher signed while it was revoked. If a true issue-time bound is wanted, add an `issuedAt` field to the Withdrawal struct and require issuedAt <= block.timestamp <= issuedAt + MAX_VOUCHER_LIFETIME, or document that lifetime is submission-relative and rotate keys rather than re-grant them.","line":184,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"State: signer holds SIGNER_ROLE, tok listed. Input: voucher {alice, tok, 10e18, nonce 1, deadline = T0 + 365 days} signed by signer. Submit at T0 -> reverts VoucherTooLong (expected). vm.warp to T0 + 364 days and submit the same voucher and signature -> succeeds, alice receives 10e18. Expected per README ('expires within 24 hours at most'): the voucher should never be payable 364 days after signing. Confirmed by Foundry probe test_farFutureVoucher.","severity":"info","snippet":"        if (w.deadline > block.timestamp + MAX_VOUCHER_LIFETIME) revert VoucherTooLong();","title":"Voucher lifetime is bounded at submission time only: a SIGNER can pre-sign vouchers with a deadline years ahead that become redeemable in the final 24h"},{"citation":"resolved","description":"README claims 100% line / 90% branch coverage, but the following cases named in AUDIT.md 'Areas worth extra attention' have no test, so the claims rest on reading the code rather than on the suite: (1) ECDSA.recover with a malformed signature: a 64-byte EIP-2098 compact signature, a 4-byte blob, and a high-s malleated copy of a valid signature (all three revert in OZ 5.1.0; verified in a Foundry probe, but no Hardhat test asserts it). (2) Two different keys that both hold SIGNER_ROLE presented together for amount > cosignAbove (must revert BadSignatures; the suite only tests the same key twice). (3) _spend rollover at exactly start + WINDOW: the suite uses time.increase(DAY) after several transactions so the boundary block is start + WINDOW + k, never exactly start + WINDOW; the >= branch at line 224 is confirmed only by inspection. (4) A blocklisting token (USDC-style) where safeTransfer reverts for w.user, asserting nonceUsed stays false. (5) An ERC-777-style token whose sender hook re-enters deposit()/withdraw() during the before/after balance read. (6) Forced ETH (selfdestruct) showing invariant 8 is not chain-enforceable. (7) Admin handover to an account that already holds SIGNER_ROLE through the _grantRole override (works; untested). Each of these was confirmed against the compiled contract in a Foundry probe during this review; the finding is that the shipped suite does not pin them, so a future refactor of _verify or _spend could regress silently.","line":92,"path":"msft95-vault/test/vault.test.js","reproduction":"Concrete inputs the suite lacks: sigs = [compact64(r, vs)] -> expect revert ECDSAInvalidSignatureLength(64); sigs = [sig(signerA), sig(signerB)] with both holding SIGNER_ROLE and amount = 150e18 (> cosignAbove 100e18) -> expect BadSignatures; withdraw at block.timestamp == window.start + 86400 exactly -> expect window.start updated and spent == amount; blocklist mock that reverts transfer(to == alice) -> expect revert and nonceUsed[alice][1] == false; selfdestruct 1 wei into vault -> expect balanceOf(ETH) to increase without an event. Actual: none of these appear in test/vault.test.js (29 tests).","severity":"low","snippet":"    it(\"rejects tampered vouchers and strangers' signatures\", async function () {","title":"Test suite does not exercise the edges the audit brief itself lists (malformed/compact signatures, two distinct SIGNER keys, exact window boundary, blocklist and ERC-777 tokens, forced ETH)"}],"hash":"d3481dc7a3dc826aa1820135e9faeefb6241f6b12506b41772094cc86c1345bd","nodeId":"0b115dfd-32a1-46d7-91b2-928c9f9054b0","outcome":"completed","summary":"Review complete. The findings are in `.imd-findings.json` at the repository root, with every path, line and snippet verified against the tree. The repository itself is untouched.\n\n**Outcome.** The permission model of `MSFT95Vault.sol` holds up. I found no critical or high defect in the assigned area, so no proof tests are attached. I confirmed every access-control claim in the brief by compiling the contract against OpenZeppelin 5.1.0 in a scratch Foundry workspace outside the repo and running ten probes:\n\n- SIGNER and COSIGNER exclusivity is enforced on grant, and admin handover through the `_grantRole` override works, including handover to a key that already holds SIGNER.\n- Two distinct SIGNER keys cannot stand in for a cosigner. A cosigner alone cannot authorize even a small amount. Order of the two signatures does not matter.\n- Compact 64-byte, garbage, and high-s malleated signatures all revert.\n- GUARDIAN can only pause. Nobody can grant or revoke the default admin role directly.\n- The window resets at exactly `start + WINDOW`, and the casts cannot truncate because spent is bounded by a `uint128` cap.\n- Role revocation kills old vouchers at submission time.\n\n**Reported findings, by severity:**\n\n1. **Low.** `fundBankroll` is public and skips every deposit gate (allowlist, deposit pause, `depositsEnabled`), and its ETH branch ignores the `amount` argument. A non-allowlisted wallet can push funds in during a pause and gets no claim, the exact outcome `receive()` was written to prevent. Suggested fix keeps the design: restrict to admin or a bankroll role, and require `amount == msg.value` on the ETH branch.\n2. **Low.** The test suite does not pin the edges the brief itself lists: malformed and compact signatures, two distinct SIGNER keys, the exact window boundary, blocklist and ERC-777 tokens, forced ETH. Concrete inputs are given for each.\n3. **Info.** `sweep` is exempt from the guardian's withdrawal pause and from `maxWithdrawal` and `cosignAbove`. Consistent with the brief's trust model, so reported as a trust assumption to document, not a bypass.\n4. **Info.** Invariants 8 and 9 are not chain-enforceable: a same-transaction selfdestruct forces ETH in without an event. No funds at risk. The reconciliation bot should check liabilities are at most the balance, not equal to it.\n5. **Info.** Voucher lifetime is bounded at submission, not at signing. A voucher dated a year out becomes payable in its final 24 hours. Key rotation, not re-grant, is the only kill switch for vouchers signed by a once-compromised key.\n\n**Open item outside the contract.** The mainnet deploy script leaves the hot deployer key as sole admin for the full handover delay, with sweep and role-grant powers. That is operational, not a code defect, and is already noted in the README.","treeHash":null,"usage":{"cachedInputTokens":1160221,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":37948,"runtime":"claude","turns":22,"wallClockMs":532733}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"11e4593175677f7e","findings":[{"citation":"resolved","description":"The check compares the deadline with the submission-time block.timestamp, and the signed struct carries no issuance time. So the contract only guarantees that a voucher cannot be redeemed more than 24h before its deadline; it does not guarantee that a voucher stops being valid 24h after it was signed, which is what the README ('expires within 24 hours at most') and AUDIT.md invariant 3 are read to promise. A voucher signed now with deadline = now + 365 days is rejected today with VoucherTooLong but is accepted, unchanged, during the whole day [now + 364 days, now + 365 days]. Because the ledger re-credits an unused voucher only after its deadline has passed, a backend bug or a briefly compromised SIGNER that produces far-future deadlines locks the player's ledger balance for the whole period, and any such vouchers remain live until the key is rotated (hasRole is checked at submission, so revoking the key does kill them). Boundary lens: the guard at line 184 is a moving window relative to block.timestamp, not an absolute bound. Fix options that keep the design: add an `issuedAt` field to the EIP-712 struct and require `w.deadline <= issuedAt + MAX_VOUCHER_LIFETIME && block.timestamp >= issuedAt`, or document that the 24h figure is a redemption horizon and keep deadlines short in the backend.","line":184,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"Deploy vault, list token with cfg(listed, deposits, min 1e18, max 40e18, cap 100e18, cosign 10e18), fund bankroll, grant SIGNER. Sign Withdrawal{user: alice, token, amount: 5e18, nonce: 1, deadline: T0 + 365 days} at T0. withdraw() at T0 reverts VoucherTooLong (expected). Warp to T0 + 364 days - 1 s: still VoucherTooLong. Warp to T0 + 364 days: withdraw() succeeds and alice receives 5e18. Expected per README: a voucher is never valid more than 24h after it was signed. Actual: valid 364 days after signing. Verified with a Foundry test (test_sleeperVoucher) against the contract compiled with solc 0.8.24 and OpenZeppelin 5.1.0.","severity":"low","snippet":"        if (w.deadline > block.timestamp + MAX_VOUCHER_LIFETIME) revert VoucherTooLong();","title":"VoucherTooLong bounds the redemption horizon, not the voucher lifetime: a far-future deadline becomes a sleeper voucher"},{"citation":"resolved","description":"The expiry comparison is strict, so a block whose timestamp equals the deadline still pays the voucher. AUDIT.md invariant 3 says a voucher is valid 'before its deadline'. This is harmless as long as the backend's re-credit rule treats 'deadline has passed' as block.timestamp > deadline at a finalized block (AUDIT.md off-chain assumptions say exactly that), but the backend reviewer must not read 'before' literally and re-credit at timestamp == deadline, otherwise a voucher redeemed in the deadline block would be paid twice (on-chain and by the ledger re-credit). Either change the check to `>=` or document that deadline is inclusive.","line":183,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"Sign Withdrawal{user: alice, token, amount: 5e18, nonce: 1, deadline: T0 + 100}. vm.warp(T0 + 100) and call withdraw(): it succeeds and alice receives 5e18 (verified in Foundry test test_deadlineEqualAccepted). Expected by the literal brief: VoucherExpired at the deadline itself; actual: paid.","severity":"info","snippet":"        if (block.timestamp > w.deadline) revert VoucherExpired();","title":"Voucher is accepted at block.timestamp == deadline (inclusive expiry), while the brief says 'before its deadline'"},{"citation":"resolved","description":"For token == ETH the function records and emits msg.value and never reads `amount`, so a call with amount != msg.value does not revert. The event is still correct (it reports what actually arrived), so no funds are mis-accounted; the only effect is that an operator who passes the intended amount but attaches the wrong value gets a smaller (or larger) bankroll top-up than they asked for without any error. The ERC-20 path is stricter: it rejects msg.value != 0. For symmetry, require `amount == msg.value` on the ETH branch, or document that amount is ignored for ETH.","line":142,"path":"msft95-vault/contracts/MSFT95Vault.sol","reproduction":"With ETH listed, call fundBankroll{value: 1 ether}(address(0), 999 ether). Expected (by analogy with the ERC-20 branch's msg.value check): revert because amount and msg.value disagree. Actual: succeeds and emits BankrollFunded(caller, address(0), 1 ether). Verified in Foundry test test_fundBankrollEthIgnoresAmount.","severity":"info","snippet":"            received = msg.value;","title":"fundBankroll ignores the amount argument on the ETH path and silently uses msg.value"}],"hash":"fc5bad660f032d7041db8ade1b21b22e9f8163015ef09530f093ba98f4f9393b","nodeId":"d691c651-d753-4e75-8a9a-4466a5aed1ca","outcome":"completed","summary":"The findings file exists at the repository root with valid JSON (it is excluded by git's local exclude list, which is why status is silent), and the working tree has no stray files. Nothing else is pending, so here is the recap.\n\n**Outcome.** The math, boundary and numerical-gap review of `msft95-vault/contracts/MSFT95Vault.sol` found no critical, high or medium defects. I recorded one low and two info findings in `.imd-findings.json`, each verified with a Foundry test run in a scratch project under `/tmp` against solc 0.8.24 and OpenZeppelin 5.1.0.\n\n**What was checked in the assigned area.**\n- The contract has no division, no scaling between decimals and no unchecked arithmetic apart from the deposit counter, so rounding and truncation items do not apply.\n- The `uint192` and `uint64` casts in the window logic are bounded: spent never exceeds `dailyCap`, which is a `uint128`, and timestamps fit `uint64`.\n- Window rollover at exactly `start + WINDOW` resets correctly, and at most twice the cap leaves within any 24 hours. `remainingToday` agrees with `_spend`, including after the admin lowers the cap below the amount already spent.\n- Sweep with `type(uint256).max` fails with `DailyCapExceeded` rather than a panic.\n- Signature boundaries: 64-byte, bad-v and high-s signatures revert inside `ECDSA.recover`, two SIGNER keys are rejected, the same key twice is rejected, and the `_grantRole` override still lets the default-admin handover complete.\n- A blocklisted recipient's withdrawal reverts cleanly with the nonce and window untouched. Fee-on-transfer deposits are credited net and compared against `minDeposit` after the fee.\n- Payable branches: `receive` reverts, `deposit` rejects ETH, the ERC-20 bankroll path rejects attached value.\n\n**Findings recorded.**\n- Low: the `VoucherTooLong` check limits how far ahead of the current block a voucher may be redeemed, not its lifetime from signing. A voucher with a deadline a year out is rejected today but is paid during the last day before that deadline.\n- Info: a voucher is accepted when the block timestamp equals its deadline, so the backend must treat \"deadline passed\" as strictly later before re-crediting.\n- Info: the ETH path of `fundBankroll` ignores the `amount` argument and uses the attached value without complaint.\n\n**Limits.** The three probe tests and the scratch project live only in `/tmp` and are not part of the submission. No proofs were attached since no finding reached high severity. Static analysers were not run.","treeHash":null,"usage":{"cachedInputTokens":1272892,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":36174,"runtime":"claude","turns":36,"wallClockMs":493351}}],"verification":[]}