{"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":"91ed3906-da4a-4ecd-80c2-30d2a1fa6d37","kind":"audit","nodes":[{"acceptedSubmissionHash":"288503a3223f5018f3ee02edf2a4e3bc84b52be2e7410e51a61824915ec62cda","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":"eb611691c3d75c054436587be51a34f702ffa667266da5b16ec1eaab3dc613a1","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":"c7d3743d2f5928ee0f1440cffcc281ed1bc7be24e43292f8ad9fb24483735943","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":"8bc15d6bec6f220881a40ac55399601c0946c488c6035479acbc6593997c496f","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":"5825825e3d90bb02452bb16f2d2150d173727cf559f406a03f48b6e8e60b1cac","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":"MSFT95Vault v2: custody vault for a Stake-style crypto casino on Robinhood Chain (Arbitrum Orbit, single sequencer). Players deposit ETH or ERC-20s (MSFT95, USDC, NET, IMD), play off-chain against a backend ledger, and withdraw with EIP-712 vouchers the backend signs. Large withdrawals need a second COSIGNER signature. Per-token limits (maxWithdrawal, dailyCap, cosignAbove) cap losses if a signing key is stolen.\n\nAudit ONLY the MSFT95Vault.sol inside the msft95-casino folder (v2). Ignore any older msft95-vault folder (v1). v2 fixes all 9 findings from audit job d4fc488d; AUDIT-RESPONSE.md (same folder) maps each finding to its fix and test, and AUDIT.md is the updated brief with trust model and invariants. Tests: npm install --legacy-peer-deps && npx hardhat test (43 tests).\n\nLook hardest at:\n1. Fund theft or double payout: can any voucher pay twice, pay someone other than w.user, pay after deadline, or pay without the required signer/cosigner signatures? Check _verify ordering, role revocation and re-grant, and malformed or high-s signatures.\n2. The new voucher-time rules: issuedAt and deadline (deadline <= issuedAt + 24h, issuedAt <= block.timestamp + 5 min skew, block.timestamp >= deadline is expired) and voidVouchersIssuedBefore. Can a voucher outlive 24h from signing? Can the guardian, or anyone, brick withdrawals forever?\n3. Outflow limits: can player withdrawals plus sweeps exceed dailyCap per window (beyond the documented 2x across a boundary)? Check the cast to uint192, rollover at exactly start + 1 day, and the NO_COSIGN sentinel versus the cosignAbove < maxWithdrawal rule.\n4. Access control after the fixes: fundBankroll restricted to admin or BANKROLL_ROLE; sweep blocked while withdrawals are paused; GUARDIAN can only pause and void; only admin unpauses. Does the _grantRole override (SIGNER, COSIGNER and GUARDIAN must be three different keys) break AccessControlDefaultAdminRules admin handover, or let a role escape the rule?\n5. The new Setup constructor: can it be deployed in a dangerous state (zero admin, inconsistent token limits, overlapping keys)? Does it apply exactly the same validation as setToken?\n6. Reentrancy and odd tokens: ERC-777-style hooks, fee-on-transfer, blocklisting tokens like USDC, ETH receivers that revert or re-enter.\n7. Anything that would make the off-chain ledger disagree with the chain: events missing or wrong (Deposited, Withdrawn, HouseWithdrawal, BankrollFunded), or value moving without an event.","parentJobId":null,"planHash":"94e859049d03646fe6405ed169f7344bdcaff1fe461c725e94639808a9f4ef78","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"91ed3906-da4a-4ecd-80c2-30d2a1fa6d37","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":"51082","feedbackHash":"45c955d1b055feb4a4d1c032fc86c6030ea32bdb16a8ab4a88e39ca9b8951334","nodeKey":"audit_economics","submissionHash":"288503a3223f5018f3ee02edf2a4e3bc84b52be2e7410e51a61824915ec62cda","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51473","feedbackHash":"1d9f8650bd59edc6c2c787fdb40683aae8629413228aa98611ee3b01b3214773","nodeKey":"audit_flow","submissionHash":"eb611691c3d75c054436587be51a34f702ffa667266da5b16ec1eaab3dc613a1","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52190","feedbackHash":"ca45e243095247033beaba39d6b9cb05ec3ea97a77442fc7487cb24f1d214fdb","nodeKey":"audit_judge","submissionHash":"c7d3743d2f5928ee0f1440cffcc281ed1bc7be24e43292f8ad9fb24483735943","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52482","feedbackHash":"a3a9979b10b41493bffcd2d9b776b7cca3a5556070e7fa2433f21d5b3ed46075","nodeKey":"audit_math","submissionHash":"8bc15d6bec6f220881a40ac55399601c0946c488c6035479acbc6593997c496f","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51071","feedbackHash":"9dd5161cb28925e24992e561c1b145491424351c8859f1608d404511a7f41eff","nodeKey":"audit_permissions","submissionHash":"5825825e3d90bb02452bb16f2d2150d173727cf559f406a03f48b6e8e60b1cac","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"ef16cffbe6db96f4b9b606b73ac809abdde591f13b4cf707db7b9c2175d4dd6f","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"f47f953f8e35921e","findings":[{"citation":"resolved","description":"issuedAt is a signer-chosen field. The vault only checks it at submission (issuedAt <= block.timestamp + 5 min, deadline <= issuedAt + 24h), so a holder of the SIGNER key can sign today a voucher with issuedAt = now + 30 days and deadline = now + 31 days. It is rejected as VoucherNotYetValid until 30 days minus 5 minutes have passed, then becomes payable for 24 hours. voidVouchersIssuedBefore(t) rejects any t > block.timestamp for every caller, including the admin, so the documented emergency procedure ('call voidVouchersIssuedBefore(now) if outstanding vouchers might be forged') and the AUDIT-RESPONSE claim that the void makes re-granting a key safe do not hold for such vouchers. The only thing that kills them is keeping the key revoked forever. Two realistic ways this bites: (a) the signing API was abused but the key itself lives in an HSM and is kept in service after the operator 'voided everything'; (b) the key is rotated and later re-granted (the test 'F2: re-granting a revoked key cannot revive vouchers older than 24h' only covers vouchers with an honest issuedAt). Loss is bounded by the per-token limits (up to dailyCap per window, 2x across a boundary, per token) but repeats for every pre-dated batch and surfaces weeks after the incident was closed. Minor related note: with issuedAt = signing time + 5 min a voucher lives 24h + 5min from signing, slightly beyond the invariant-3 wording. Fix: let the DEFAULT_ADMIN (not the guardian) raise minIssuedAt beyond block.timestamp (e.g. keep the `issuedBefore > block.timestamp` bound only for non-admins); the admin can already freeze withdrawals via pause so this adds no new power, and it gives a real kill switch. Alternatively document that key rotation with no re-grant is the only remedy and drop the claim that the void handles it.","line":381,"path":"msft95-casino/contracts/contracts/MSFT95Vault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.24;\n\nimport \"forge-std/Test.sol\";\nimport {MSFT95Vault} from \"msft95-casino/contracts/contracts/MSFT95Vault.sol\";\n\n/// A compromised SIGNER signs a voucher whose issuedAt lies 30 days in the future. The emergency kill switch\n/// voidVouchersIssuedBefore() can never reach it (issuedBefore must be <= block.timestamp), so once the\n/// operator has \"voided everything\" and kept (or re-granted) the key, the voucher still pays 30 days later.\n/// Fails on the current code (the sleeper pays). Passes once the admin can void vouchers with a future\n/// issuedAt (or an equivalent mechanism makes a pre-dated voucher unpayable after the void).\ncontract FutureDatedSleeperTest is Test {\n    MSFT95Vault vault;\n    uint256 signerPk = 0xA11CE;\n    address signer = vm.addr(signerPk);\n    address cosigner = address(0xC0);\n    address guardian = address(0x6A);\n    address admin = address(0xAD);\n    address alice = address(0xA1);\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        MSFT95Vault.TokenSetup[] memory ts = new MSFT95Vault.TokenSetup[](1);\n        ts[0] = MSFT95Vault.TokenSetup({\n            token: address(0),\n            config: MSFT95Vault.TokenConfig({listed: true, depositsEnabled: true, minDeposit: 1, maxWithdrawal: 1 ether, dailyCap: 5 ether, cosignAbove: 0.1 ether})\n        });\n        vault = new MSFT95Vault(MSFT95Vault.Setup({\n            admin: admin, adminDelay: 0, signer: signer, cosigner: cosigner, guardian: guardian,\n            bankroller: address(0), allowlistOnly: false, allowed: new address[](0), tokens: ts\n        }));\n        vm.deal(address(vault), 100 ether);\n    }\n\n    function test_voidCannotKillFutureDatedVoucher() public {\n        uint256 t0 = block.timestamp;\n        // 1. the leaked signer key signs a voucher pre-dated 30 days ahead (deadline - issuedAt == 24h, so it is well-formed)\n        MSFT95Vault.Withdrawal memory w = MSFT95Vault.Withdrawal({\n            user: alice, token: address(0), amount: 0.1 ether, nonce: 1, issuedAt: t0 + 30 days, deadline: t0 + 31 days\n        });\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, vault.hashWithdrawal(w));\n        bytes[] memory sigs = new bytes[](1);\n        sigs[0] = abi.encodePacked(r, s, v);\n\n        // it is not payable yet\n        vm.expectRevert(MSFT95Vault.VoucherNotYetValid.selector);\n        vault.withdraw(w, sigs);\n\n        // 2. one day later the operator detects the leak and voids \"every voucher signed so far\"\n        vm.warp(t0 + 1 days);\n        vm.prank(guardian);\n        vault.voidVouchersIssuedBefore(block.timestamp);\n        // the strongest void the admin can attempt: a cut-off beyond the sleeper's issuedAt (reverts on current code)\n        vm.prank(admin);\n        try vault.voidVouchersIssuedBefore(t0 + 365 days) {} catch {}\n\n        // 3. the key is kept in service (HSM key, only the signing API was abused) or re-granted later\n        assertTrue(vault.hasRole(vault.SIGNER_ROLE(), signer));\n\n        // 4. thirty days later the pre-dated voucher must not pay\n        vm.warp(t0 + 30 days);\n        uint256 before = alice.balance;\n        vm.expectRevert();\n        vault.withdraw(w, sigs);\n        assertEq(alice.balance, before, \"pre-dated voucher paid after the operator voided every voucher\");\n    }\n}","reproduction":"Setup: ETH listed with maxWithdrawal 1 ether, dailyCap 5 ether, cosignAbove 0.1 ether; SIGNER key S. At t0, S signs Withdrawal{user: alice, token: 0, amount: 0.1 ether, nonce: 1, issuedAt: t0 + 30 days, deadline: t0 + 31 days}. withdraw() at t0 reverts VoucherNotYetValid. At t0 + 1 day the guardian calls voidVouchersIssuedBefore(block.timestamp) (succeeds); the admin calling voidVouchersIssuedBefore(t0 + 365 days) reverts BadConfig. At t0 + 30 days, withdraw(w, [sigS]) succeeds and alice receives 0.1 ETH. Expected: a voucher the operator voided after a key leak can never pay. Actual: it pays 30 days later. Proof test: test/scratch/FutureDatedSleeper.t.sol fails on current code and passes once the admin may set minIssuedAt past block.timestamp.","severity":"medium","snippet":"        if (issuedBefore <= minIssuedAt || issuedBefore > block.timestamp) revert BadConfig();","title":"Pre-dated (future issuedAt) vouchers cannot be voided: voidVouchersIssuedBefore is capped at block.timestamp, so a leaked signer key leaves sleeper vouchers the kill switch never reaches"},{"citation":"resolved","description":"The trust model says the admin can 'list and unlist tokens'. The natural unlist call, setToken(token, TokenConfig(false,false,0,0,0,0)), reverts BadConfig because cosignAbove (0) >= maxWithdrawal (0) and the sentinel check does not consider `listed`. The only accepted way to unlist is to also set cosignAbove = NO_COSIGN (2^128-1), which leaves a stale opt-out sentinel in storage: if the token is later re-listed with a config copied from the stored struct, the cosigner is silently disabled for that token. Under time pressure (e.g. delisting a token whose contract was found to be malicious) an admin multisig proposal with the zeroed struct fails and must be re-queued through the timelock. Fix: skip the maxWithdrawal/cosignAbove consistency checks when cfg.listed is false (or treat an all-zero config as valid), and consider clearing the whole struct on unlist.","line":326,"path":"msft95-casino/contracts/contracts/MSFT95Vault.sol","reproduction":"Deploy with any listed token T (e.g. the test fixture config). As admin call setToken(T, {listed:false, depositsEnabled:false, minDeposit:0, maxWithdrawal:0, dailyCap:0, cosignAbove:0}). Expected: T is unlisted. Actual: revert BadConfig. Verified with a Foundry test (test_zeroConfigUnlistReverts): the call reverts with MSFT95Vault.BadConfig. The same call with cosignAbove = type(uint128).max succeeds, leaving tokenConfig[T].cosignAbove == NO_COSIGN.","severity":"low","snippet":"        if (cfg.cosignAbove != NO_COSIGN && cfg.cosignAbove >= cfg.maxWithdrawal) revert BadConfig();","title":"setToken rejects the all-zero config, so a token cannot be unlisted by clearing it; the cosignAbove < maxWithdrawal rule is enforced even when listed == false"},{"citation":"resolved","description":"The contract's WITHDRAWAL_TYPEHASH is Withdrawal(address user,address token,uint256 amount,uint256 nonce,uint256 issuedAt,uint256 deadline) (MSFT95Vault.sol:48-50) and hashWithdrawal encodes six fields. The shipped signing script in the same v2 folder still uses the five-field v1 type and builds a voucher without issuedAt, so its EIP-712 digest differs from the on-chain one; ECDSA.recover returns an unrelated address, hasRole fails and withdraw() reverts BadSignatures. The script is documented as the testnet signing path, and AUDIT-RESPONSE.md says the backend 'picked up' the new type, but this file was not updated. Any operator or integrator who follows README/testnet instructions produces unusable vouchers; worse, if the script's voucher JSON (no issuedAt) is fed to a client that fills issuedAt = 0, the tx reverts VoucherVoided/BadSignatures with a confusing error. Fix: add { name: 'issuedAt', type: 'uint256' } before deadline in TYPES, set issuedAt = now and deadline = issuedAt + TTL, and print issuedAt in the output (mirror test/helpers.js TYPES).","line":12,"path":"msft95-casino/contracts/scripts/sign-withdrawal.js","reproduction":"Run the script against a v2 vault: SIGNER_KEY=<signer> VAULT=<v2 vault> USER=<alice> TOKEN=0x0 AMOUNT=0.01 NONCE=1 npx hardhat run scripts/sign-withdrawal.js. Submit the printed voucher plus signature to vault.withdraw({...voucher, issuedAt: <any value>}, [sig]). Expected: payout. Actual: revert BadSignatures, because the signed digest used the five-field type string while the contract hashes the six-field Withdrawal type.","severity":"low","snippet":"  { name: \"nonce\", type: \"uint256\" }, { name: \"deadline\", type: \"uint256\" } ] };","title":"v2 scripts/sign-withdrawal.js still signs the v1 Withdrawal type (no issuedAt), so every voucher it produces is rejected by the v2 vault with BadSignatures"},{"citation":"resolved","description":"withdraw() is designed to be submitted by a relayer on the player's behalf (the Withdrawn event records the relayer). _pay forwards all remaining gas to w.user. A player whose address is a contract can make receive() burn all forwarded gas (or revert) so the relayer pays the full gas limit of a transaction that reverts with EthTransferFailed, as often as the relayer retries. The nonce stays unused and vault funds are not at risk; only the relayer's gas is lost. The stake-style flow makes this cheap for the griefer (one deposit above minDeposit, then request withdrawals) and the backend relayer has no on-chain signal to blacklist. Mitigation: cap the gas forwarded for the ETH payout (e.g. a fixed stipend large enough for smart-wallets), or have the relayer simulate the call and set an explicit gas limit; alternatively require w.user to be an EOA-like receiver in the backend before signing.","line":291,"path":"msft95-casino/contracts/contracts/MSFT95Vault.sol","reproduction":"Deploy a contract R whose receive() loops until out of gas. Sign Withdrawal{user: R, token: ETH, amount: 1 ether, ...}. A relayer submits withdraw(w, [sig]) with gas limit 1,000,000. Expected: either payout or a cheap revert. Actual: the sub-call consumes ~63/64 of the gas, _pay reverts EthTransferFailed, the relayer pays ~1M gas, nonceUsed[R][nonce] stays false (verified in test_ethReceiverGasAndRevert with a reverting receiver: revert EthTransferFailed, nonce unused).","severity":"info","snippet":"            (bool ok, ) = to.call{value: amount}(\"\");","title":"ETH payout forwards all remaining gas to w.user, letting a player contract grief gas relayers"}],"hash":"288503a3223f5018f3ee02edf2a4e3bc84b52be2e7410e51a61824915ec62cda","nodeId":"447f0df0-245c-41be-887b-c42b5441b768","outcome":"completed","summary":"The review is complete. Findings are in `.imd-findings.json` at the repository root, with the proof test also placed at `test/scratch/FutureDatedSleeper.t.sol`. No project files were changed.\n\n**Outcome.** The v2 vault holds up well on the areas you asked about. I found one medium defect in the new voucher-time rules, two low operational defects, and one informational gas-griefing note. No fund theft, double payout, or cap bypass was reachable.\n\n**Findings written**\n\n- **Medium, line 381.** Pre-dated vouchers escape the kill switch. A leaked SIGNER key can sign a voucher with issuedAt thirty days ahead, and voidVouchersIssuedBefore refuses any cut-off past block.timestamp for every caller. The voucher pays when its date arrives if the key is still in service or re-granted. The proof test fails on current code and passes once the admin may raise minIssuedAt past the present.\n- **Low, line 326.** The all-zero config is rejected by setToken, so unlisting requires setting cosignAbove to the NO_COSIGN sentinel, which then lingers in storage.\n- **Low, scripts/sign-withdrawal.js.** The v2 folder's signing script still signs the five-field v1 type, so every voucher it produces reverts with BadSignatures.\n- **Info, line 291.** The ETH payout forwards all gas to the recipient, letting a player contract burn relayer gas on reverting attempts.\n\n**Verified clean, with Foundry tests in a scratch copy**\n\n- Signature ordering: cosigner-first, same key twice, cosigner twice, and granting SIGNER to the guardian all behave as documented. High-s and malformed signatures revert through OpenZeppelin's ECDSA.\n- Admin handover through AccessControlDefaultAdminRules works with the role override, including handover to an address that holds SIGNER.\n- Constructor rejects a zero admin, overlapping keys, and inconsistent token limits, using the same validation path as setToken.\n- Sweep and player withdrawals share one window. The cap holds at start plus one day minus one second, resets at exactly start plus one day, and the uint192 cast cannot truncate since spent never exceeds a uint128 cap. Lowering the cap below spent leaves the view at zero.\n- Reverting ETH receivers fail cleanly with the nonce unused. All value movements emit an event.\n\n**Limits.** Slither and the Hardhat suite were not run. Checks were done with Foundry against a copy of the contracts using OpenZeppelin 5.1.0 as pinned in the lockfile. The backend was read only where needed to confirm the voucher type and refund rule.","treeHash":null,"usage":{"cachedInputTokens":1279451,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":42309,"runtime":"claude","turns":28,"wallClockMs":602937}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"39da99ded7f125c8","findings":[{"citation":"resolved","description":"issuedAt is a self-declared field of the signed struct; the contract only checks it at submission (issuedAt <= block.timestamp + 5 min, line 237) and never learns when the voucher was really signed. voidVouchersIssuedBefore (line 379-384) is the documented emergency kill switch (\"After revoking, call voidVouchersIssuedBefore(now) if outstanding vouchers might be forged\", AUDIT.md; \"so re-granting a key can't revive old vouchers\", AUDIT-RESPONSE.md #2) but it can only raise minIssuedAt up to block.timestamp (line 381) and withdraw only rejects issuedAt < minIssuedAt (line 239). Whoever holds or can drive the SIGNER key (and COSIGNER, for amounts above cosignAbove) therefore signs, today, vouchers with issuedAt = now + N days and deadline = issuedAt + 24h, one batch per future day, each piece <= cosignAbove (or <= maxWithdrawal with NO_COSIGN), to any w.user it controls. They are invisible on-chain, pass VoucherTooLong/VoucherNotYetValid when their day arrives, and no void issued before that day reaches them. Invariant 3 of AUDIT.md (\"no voucher can pay more than 24h after it was signed\") is thus not enforceable, and the only complete remedy is revoking the key and never re-granting it, which the kill switch was meant to make unnecessary. Realistic trigger: the backend signing service (KMS/HSM signs what the backend asks) is abused for a short time; the operator fixes the backend and calls voidVouchersIssuedBefore(now) believing all forged vouchers are dead, or rotates and later re-grants the same key. Up to dailyCap per token per day then drains on the attacker's chosen days. The same root cause gives honest vouchers a 24h+5min life (issuedAt = signing + 5 min is accepted immediately).\n\nFix (keeps the design): commit to something the void changes. Add `uint256 public voucherEpoch` to the signed data (either a new `epoch` field in Withdrawal/WITHDRAWAL_TYPEHASH, or mixed into the digest in hashWithdrawal) and require `w.epoch == voucherEpoch` in withdraw; let voidVouchersIssuedBefore (or a new voidAllVouchers, guardian or admin) increment voucherEpoch. Every outstanding voucher dies regardless of its dates and the backend re-issues with the new epoch. If the interface must stay, document that voidVouchersIssuedBefore does not cover future-dated vouchers and that key revocation without re-grant is the only complete remedy, and bound issuedAt at submission to `block.timestamp - MAX_VOUCHER_LIFETIME <= issuedAt` so a void can at least be reasoned about per 24h window.","line":381,"path":"msft95-casino/contracts/contracts/MSFT95Vault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.24;\n\nimport \"forge-std/Test.sol\";\nimport {MSFT95Vault} from \"msft95-casino/contracts/contracts/MSFT95Vault.sol\";\n\n/// A voucher whose issuedAt is set in the future is not covered by voidVouchersIssuedBefore(now),\n/// so the documented emergency kill switch cannot kill it. It pays when its self-declared date arrives.\n/// Fails on the current code (the withdrawal succeeds); passes once a void invalidates every\n/// outstanding voucher regardless of its issuedAt (e.g. a voucher epoch committed in the digest).\ncontract FutureDatedVoucherTest is Test {\n    MSFT95Vault vault;\n    uint256 signerPk = 0xA11CE;\n    uint256 cosignerPk = 0xB0B;\n    address signer = vm.addr(signerPk);\n    address cosigner = vm.addr(cosignerPk);\n    address admin = address(0xAD);\n    address guardian = address(0x6A);\n    address alice = address(0xA1);\n    address constant ETH = address(0);\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        MSFT95Vault.Setup memory s;\n        s.admin = admin;\n        s.adminDelay = 0;\n        s.signer = signer;\n        s.cosigner = cosigner;\n        s.guardian = guardian;\n        s.tokens = new MSFT95Vault.TokenSetup[](1);\n        s.tokens[0].token = ETH;\n        s.tokens[0].config = MSFT95Vault.TokenConfig({\n            listed: true, depositsEnabled: true, minDeposit: 1, maxWithdrawal: 10 ether, dailyCap: 50 ether, cosignAbove: 1 ether\n        });\n        vault = new MSFT95Vault(s);\n        vm.deal(admin, 1000 ether);\n        vm.prank(admin);\n        vault.fundBankroll{value: 500 ether}(ETH, 500 ether);\n    }\n\n    function _sign(uint256 pk, MSFT95Vault.Withdrawal memory w) internal view returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, vault.hashWithdrawal(w));\n        return abi.encodePacked(r, s, v);\n    }\n\n    function test_voidDoesNotKillFutureDatedVoucher() public {\n        uint256 t0 = block.timestamp;\n        // Signed now (key leaked / signing service abused), but dated 30 days ahead.\n        MSFT95Vault.Withdrawal memory w = MSFT95Vault.Withdrawal({\n            user: alice, token: ETH, amount: 0.5 ether, nonce: 1, issuedAt: t0 + 30 days, deadline: t0 + 31 days\n        });\n        bytes[] memory sigs = new bytes[](1);\n        sigs[0] = _sign(signerPk, w);\n\n        // Operator's documented response: void everything signed before now.\n        vm.prank(guardian);\n        vault.voidVouchersIssuedBefore(t0);\n        assertEq(vault.minIssuedAt(), t0);\n\n        // 30 days later the sleeper is submitted. Expected: it was voided; actual: it pays.\n        vm.warp(t0 + 30 days);\n        vm.expectRevert();\n        vault.withdraw(w, sigs);\n        assertEq(alice.balance, 0, \"sleeper voucher paid despite the void\");\n    }\n}","reproduction":"State: vault with ETH listed (maxWithdrawal 10 ether, dailyCap 50 ether, cosignAbove 1 ether), signer S, guardian G, 500 ether bankroll, t0 = block.timestamp.\n1. S (leaked) signs Withdrawal{user: attacker, token: ETH, amount: 0.5 ether, nonce: 1, issuedAt: t0 + 30 days, deadline: t0 + 31 days}.\n2. Operator reacts: G calls voidVouchersIssuedBefore(t0) -> minIssuedAt = t0. (Optionally admin revokes SIGNER from S and re-grants it later; same result.)\n3. At t0 + 30 days anyone calls withdraw(w, [sigS]).\nExpected: VoucherVoided (the voucher existed before the void). Actual: all checks pass (t0+30d < deadline; issuedAt <= ts + 5 min; deadline - issuedAt = 1 day; issuedAt >= minIssuedAt) and 0.5 ether is paid to attacker. Repeating with one voucher per day and amounts <= cosignAbove pays up to dailyCap per day with no on-chain warning. Reproduced with test/scratch/FutureDatedVoucher.t.sol (fails on current code: \"next call did not revert as expected\").","severity":"medium","snippet":"        if (issuedBefore <= minIssuedAt || issuedBefore > block.timestamp) revert BadConfig();","title":"voidVouchersIssuedBefore cannot kill future-dated vouchers: a leaked SIGNER key can plant sleeper vouchers that survive the emergency void (and a later re-grant) and pay months later"},{"citation":"resolved","description":"The lifetime bound is measured from the self-declared issuedAt, and issuedAt may be up to MAX_CLOCK_SKEW (5 min) ahead of block.timestamp (line 237). A voucher signed at real time T with issuedAt = T + 5 min and deadline = issuedAt + 1 day is accepted immediately and stays payable until T + 24h + 5 min - 1 s. The brief says \"no voucher can pay more than 24h after it was signed\". Harmless for an honest backend; document the real bound (24h + 5 min) or set the lifetime to MAX_VOUCHER_LIFETIME - MAX_CLOCK_SKEW. This is the benign tail of the medium finding above (self-declared issuedAt).","line":238,"path":"msft95-casino/contracts/contracts/MSFT95Vault.sol","reproduction":"t0 = block.timestamp. Signer signs {amount: 0.5 ether, nonce 1, issuedAt: t0 + 300, deadline: t0 + 300 + 86400}. withdraw at t0 succeeds (issuedAt <= t0 + 300); the same voucher, if unused, also succeeds at t0 + 86400 + 299. Expected per AUDIT.md: not payable after t0 + 86400. Actual: pays (probe test_skewExtendsLifetime passed on current code).","severity":"info","snippet":"        if (w.deadline <= w.issuedAt || w.deadline - w.issuedAt > MAX_VOUCHER_LIFETIME) revert VoucherTooLong();","title":"Honest voucher lifetime is 24h + MAX_CLOCK_SKEW, not the 24h stated in AUDIT.md invariant 3"}],"hash":"5825825e3d90bb02452bb16f2d2150d173727cf559f406a03f48b6e8e60b1cac","nodeId":"af77230c-d8ef-4dac-8ee5-7d08bba21c11","outcome":"completed","summary":"Review complete. The findings file is written and the proof test is in place.\n\n**Result:** one medium and one informational finding in `.imd-findings.json`. No critical or high defects: I could not pay a voucher twice, pay anyone but `w.user`, pay after the deadline, pay without the required keys, exceed `dailyCap` within a window, or bypass any admin gate.\n\n**[M-1] The emergency void cannot kill future-dated vouchers** (`MSFT95Vault.sol:381`, proof at `test/scratch/FutureDatedVoucher.t.sol`). `issuedAt` is self-declared and only checked at submission time. A leaked SIGNER key (or an abused signing service) signs vouchers today with `issuedAt = now + 30 days`. The guardian's `voidVouchersIssuedBefore(now)` cannot reach them because it is capped at `block.timestamp`, and a revoke followed by a later re-grant revives them. The voucher pays when its date arrives. Confirmed with forge: the proof fails on the current code because the withdrawal succeeds. Suggested fix is a voucher epoch committed in the signed digest and bumped by the void.\n\n**[I-1] Honest voucher lifetime is 24h plus the 5-minute clock skew**, not the 24h stated in invariant 3 of AUDIT.md. Same root cause, documentation-level impact.\n\n**What I checked and found sound:**\n- `_verify` ordering: cosigner-first, same key twice, two signers, cosigner-only, outsider all behave as documented. OpenZeppelin 5.1.0 `ECDSA.recover` reverts on malformed, 64-byte and high-s signatures, so the nonce stays unused.\n- `_grantRole` override: admin handover to the SIGNER key succeeds, and that admin still cannot grant itself COSIGNER. Zero admin reverts in the OpenZeppelin constructor. The constructor runs the same `_setToken` validation as `setToken`.\n- `_spend`: rollover at exactly `start + 1 day` confirmed, `uint192` cast is bounded by `dailyCap` of type `uint128`, sweeps and withdrawals share the window, and the 2x-across-boundary bound holds.\n- Access control after the fixes: `fundBankroll`, `sweep` under pause, guardian pause and void only, admin-only unpause all enforced.\n- Reentrancy and odd tokens: every value-moving entry point is `nonReentrant`, state is settled before `_pay`, fee-on-transfer is credited net, blocklisted recipients and reverting ETH receivers fail cleanly with the nonce unused.\n- Events: every value movement through the contract's own functions emits an event.\n\n**Observations, not defects:** a guardian calling the void every block has the same effect as a pause, which it already holds. A sweep of an ERC-20 to the vault itself consumes the cap and emits `HouseWithdrawal` without any outflow, so the reconciler should treat `to == vault` as a no-op. Sweeps are bounded only by `dailyCap`, not `maxWithdrawal`, as documented.\n\nThe hardhat suite passed (43 tests). I removed the hardhat build output I generated; `node_modules` under the contracts folder is git-ignored and was left in place so the proof compiles locally.","treeHash":null,"usage":{"cachedInputTokens":1073410,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":46384,"runtime":"claude","turns":28,"wallClockMs":678196}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e8e860c7f2306473","findings":[{"citation":"resolved","description":"withdraw() bounds a voucher's life to 24h after `w.issuedAt` (line 238) and refuses `issuedAt` more than 5 minutes ahead of the *submission* block (line 237). Nothing ties `issuedAt` to the moment the signature was actually produced: whoever holds the SIGNER key picks it. A voucher signed at time T with `issuedAt = T + 30 days, deadline = T + 31 days` is simply dormant for 30 days and then becomes valid for 24h. This breaks three documented guarantees: AUDIT.md invariant 3 ('no voucher can pay more than 24h after it was signed'), the natspec on voidVouchersIssuedBefore ('void every voucher signed before issuedBefore') and the AUDIT-RESPONSE claim for finding 2 ('re-granting a key can't revive old vouchers'). The kill switch cannot reach such vouchers at all: line 381 forbids `issuedBefore > block.timestamp`, so `minIssuedAt` can never be raised above `now`, while the sleeper's `issuedAt` is always in the future until the day it fires. The same mechanism gives the void a permanent 5-minute blind spot: a voucher signed just before the void with `issuedAt = now + 5 min` (allowed by line 237) also survives `voidVouchersIssuedBefore(block.timestamp)` (verified: it pays). Preconditions: a SIGNER key that was readable at some point (backup, insider, temporary KMS misconfiguration) and that is still, or again, granted SIGNER_ROLE when the chosen date arrives. The operator's fast response (guardian: pause + void) therefore does not neutralise pre-signed vouchers; only the slow one (admin via Safe + timelock: revokeRole) does, and the key must never be re-granted. Per-key damage is still bounded by maxWithdrawal/cosignAbove/dailyCap, which is why this is medium rather than high. The test suite's F2 case only submits a future-dated voucher immediately (VoucherNotYetValid) and never warps to its issuedAt, so this path is untested. Fix (keeps the design and the Withdrawal ABI): add a `uint256 voucherEpoch` to storage, mix it into the signed digest in hashWithdrawal (or a new typed-data field), and have voidVouchersIssuedBefore (or a new guardian/admin `voidAllVouchers()`) increment it so every outstanding voucher dies regardless of its claimed issuedAt; keep issuedAt only for the 24h bound and update AUDIT.md invariant 3 to say '24h after the issuedAt it claims'. The backend reads the epoch when signing.","line":237,"path":"msft95-casino/contracts/contracts/MSFT95Vault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.24;\n\nimport \"forge-std/Test.sol\";\nimport {MSFT95Vault} from \"msft95-casino/contracts/contracts/MSFT95Vault.sol\";\n\n/// Finding: `issuedAt` is chosen by whoever holds the SIGNER key and is only checked against the\n/// *submission* block (`issuedAt <= block.timestamp + 5 min`). A voucher signed today with\n/// `issuedAt` 30 days ahead is a sleeper: it is dormant for 30 days, it survives\n/// `voidVouchersIssuedBefore(now)` (which can never be raised past `now`), it survives a\n/// revoke + re-grant of the key, and it then pays. AUDIT.md invariant 3 (\"no voucher can pay more\n/// than 24h after it was signed\") and the AUDIT-RESPONSE claim that the void \"kills every voucher\n/// signed before T\" / \"re-granting a key can't revive old vouchers\" do not hold.\ncontract SleeperVoucherTest is Test {\n    MSFT95Vault vault;\n    uint256 signerPk = 0xA11CE;\n    uint256 cosignerPk = 0xB0B;\n    address signer = vm.addr(signerPk);\n    address cosigner = vm.addr(cosignerPk);\n    address guardian = address(0x6A);\n    address attacker = address(0xBAD);\n\n    function setUp() public {\n        vm.warp(1_760_000_000);\n        MSFT95Vault.Setup memory s;\n        s.admin = address(this);\n        s.adminDelay = 2 days;\n        s.signer = signer;\n        s.cosigner = cosigner;\n        s.guardian = guardian;\n        s.tokens = new MSFT95Vault.TokenSetup[](1);\n        s.tokens[0] = MSFT95Vault.TokenSetup({\n            token: address(0),\n            config: MSFT95Vault.TokenConfig({listed: true, depositsEnabled: true, minDeposit: 1, maxWithdrawal: 0.4 ether, dailyCap: 2 ether, cosignAbove: 0.1 ether})\n        });\n        vault = new MSFT95Vault(s);\n        vm.deal(address(this), 100 ether);\n        vault.fundBankroll{value: 50 ether}(address(0), 50 ether);\n    }\n\n    function sign(uint256 pk, MSFT95Vault.Withdrawal memory w) internal view returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, vault.hashWithdrawal(w));\n        return abi.encodePacked(r, s, v);\n    }\n\n    /// Signed at T. Guardian voids everything \"issued before\" T+1h (the documented emergency response).\n    /// Expected: the voucher, signed before the void, can never pay. Actual: it pays at T+30d.\n    function test_sleeperSurvivesVoid() public {\n        uint256 T = block.timestamp;\n        MSFT95Vault.Withdrawal memory w = MSFT95Vault.Withdrawal({\n            user: attacker, token: address(0), amount: 0.1 ether, nonce: 1,\n            issuedAt: T + 30 days, deadline: T + 31 days\n        });\n        bytes[] memory sigs = new bytes[](1);\n        sigs[0] = sign(signerPk, w); // signed at T with a (then) compromised key\n\n        // T+1h: leak suspected, guardian pulls the kill switch as high as it can go\n        vm.warp(T + 1 hours);\n        vm.prank(guardian);\n        vault.voidVouchersIssuedBefore(block.timestamp);\n        vm.prank(guardian);\n        vm.expectRevert(MSFT95Vault.BadConfig.selector); // cannot void anything issued \"in the future\"\n        vault.voidVouchersIssuedBefore(T + 30 days + 1);\n\n        // T+30d: 30 days after signing, the voucher pays\n        vm.warp(T + 30 days);\n        uint256 before = attacker.balance;\n        (bool paid,) = address(vault).call(abi.encodeCall(vault.withdraw, (w, sigs)));\n        assertFalse(paid, \"sleeper voucher paid 30 days after it was signed, after the void\");\n        assertEq(attacker.balance, before, \"attacker received funds\");\n    }\n\n    /// Same sleeper, but the operator also revoked and later re-granted the key (the scenario the\n    /// void was added for). Re-grant revives it.\n    function test_sleeperRevivedByRegrant() public {\n        uint256 T = block.timestamp;\n        MSFT95Vault.Withdrawal memory w = MSFT95Vault.Withdrawal({\n            user: attacker, token: address(0), amount: 0.1 ether, nonce: 2,\n            issuedAt: T + 30 days, deadline: T + 31 days\n        });\n        bytes[] memory sigs = new bytes[](1);\n        sigs[0] = sign(signerPk, w);\n\n        vm.warp(T + 1 hours);\n        bytes32 SR = vault.SIGNER_ROLE();\n        vault.revokeRole(SR, signer);\n        vm.prank(guardian);\n        vault.voidVouchersIssuedBefore(block.timestamp);\n\n        vm.warp(T + 29 days);\n        vault.grantRole(SR, signer); // key put back into service\n\n        vm.warp(T + 30 days);\n        uint256 before = attacker.balance;\n        (bool paid,) = address(vault).call(abi.encodeCall(vault.withdraw, (w, sigs)));\n        assertFalse(paid, \"re-grant revived a voucher signed 30 days earlier\");\n        assertEq(attacker.balance, before, \"attacker received funds\");\n    }\n}","reproduction":"State: vault with ETH listed (maxWithdrawal 0.4 ETH, dailyCap 2 ETH, cosignAbove 0.1 ETH), SIGNER = key S, GUARDIAN = G. At T, S signs Withdrawal{user: attacker, token: ETH, amount: 0.1 ETH, nonce: 1, issuedAt: T + 30 days, deadline: T + 31 days}. At T + 1h the guardian calls voidVouchersIssuedBefore(T + 1h) (succeeds); voidVouchersIssuedBefore(T + 30 days + 1) reverts BadConfig. At T + 30 days anyone submits withdraw(w, [sigS]). Expected (AUDIT.md invariant 3 and the void's documented meaning): revert. Actual: the checks on lines 236-239 all pass (block.timestamp < deadline; issuedAt == block.timestamp, not > block.timestamp + 5 min; deadline - issuedAt == 1 day; issuedAt = T+30d >= minIssuedAt = T+1h) and attacker receives 0.1 ETH, 30 days after the signature was made. Variant: revoke S at T+1h, void, re-grant S at T+29d: the voucher pays at T+30d. Variant: voucher signed at T with issuedAt = T + 5 min; guardian voids at T + 10s with issuedBefore = T + 10s; the voucher still pays. Proof: test/scratch/SleeperVoucher.t.sol (both tests fail on this code with 'sleeper voucher paid 30 days after it was signed, after the void' / 're-grant revived a voucher signed 30 days earlier').","severity":"medium","snippet":"        if (w.issuedAt > block.timestamp + MAX_CLOCK_SKEW) revert VoucherNotYetValid();","title":"issuedAt is signer-chosen and only checked against the submission block, so future-dated sleeper vouchers outlive 24h, survive voidVouchersIssuedBefore and are revived by a key re-grant"},{"citation":"resolved","description":"The constructor validates roles (three distinct keys), every token config (same _setToken as setToken) and relies on OpenZeppelin to reject a zero admin, but `s.adminDelay` is passed straight through. AccessControlDefaultAdminRules 5.1.0 computes `SafeCast.toUint48(block.timestamp) + delay` in uint48 for both beginDefaultAdminTransfer and changeDefaultAdminDelay, and when the delay is *decreased* the waiting period is `currentDelay - newDelay`. So an absurd initial delay is unrecoverable: with type(uint48).max both functions revert on checked uint48 overflow forever, and with a plausible unit mistake (2 days written in milliseconds = 172,800,000, or `ADMIN_DELAY` in deploy.js read via Number() with no bound) a handover or a delay reduction waits about 5.5 years. The Safe stays admin, but the documented ability to rotate the admin (and the deploy.js promise that handover 'takes the configured delay') is lost. Fix: `if (s.adminDelay > 30 days) revert BadConfig();` (or the bound the operator chooses) in the constructor, mirroring the sanity checks already applied to token limits, and the same bound in deploy.js.","line":150,"path":"msft95-casino/contracts/contracts/MSFT95Vault.sol","reproduction":"Deploy MSFT95Vault(Setup{admin: safe, adminDelay: type(uint48).max, ...}). Then admin calls beginDefaultAdminTransfer(newSafe): expected a scheduled transfer after the delay; actual revert (Panic 0x11, uint48 overflow in _beginDefaultAdminTransfer). admin calls changeDefaultAdminDelay(0): actual revert for the same reason, so the state can never be corrected. With adminDelay = 172_800_000 (2 days in ms), changeDefaultAdminDelay(0) succeeds but pendingDefaultAdminDelay().schedule is ~5.5 years ahead (verified in test/scratch/Sanity.t.sol: test_maxAdminDelayBricksHandover, test_largeAdminDelayTakesCenturies). By contrast setToken-style inputs with the same kind of mistake are rejected with BadConfig.","severity":"low","snippet":"        AccessControlDefaultAdminRules(s.adminDelay, s.admin)","title":"Setup accepts any adminDelay; a mistyped value makes admin handover and delay changes impossible or multi-year, and it cannot be corrected afterwards"},{"citation":"resolved","description":"Outside the .sol scope but in the same folder and relevant to the voucher interface: the helper the README points testnet operators to builds TYPES without `issuedAt` and never sets `w.issuedAt`, so its EIP-712 digest differs from hashWithdrawal() (WITHDRAWAL_TYPEHASH includes `uint256 issuedAt`). Not exploitable (the vault reverts BadSignatures), but any testnet procedure or backend code copied from this script will fail against v2. Fix: add `{ name: 'issuedAt', type: 'uint256' }` before `deadline` and set `issuedAt = now`, `deadline <= issuedAt + 24h`, matching test/helpers.js.","line":10,"path":"msft95-casino/contracts/scripts/sign-withdrawal.js","reproduction":"Run the script against a v2 vault and submit the printed voucher with issuedAt filled in by hand: withdraw() recovers an address that is not the SIGNER (digest mismatch) and reverts BadSignatures. Expected: a payable voucher.","severity":"info","snippet":"const TYPES = { Withdrawal: [","title":"sign-withdrawal.js still signs the v1 Withdrawal type (no issuedAt), so vouchers it produces are rejected by the v2 vault"}],"hash":"8bc15d6bec6f220881a40ac55399601c0946c488c6035479acbc6593997c496f","nodeId":"2c43f012-5e0b-4854-911b-a7ab00069930","outcome":"completed","summary":"Review complete. The findings file at `.imd-findings.json` holds three substantiated findings, and the working tree is otherwise untouched (my scratch tests and the node_modules install are git-excluded).\n\n**What I found**\n\n- **Medium: future-dated sleeper vouchers.** `issuedAt` is chosen by whoever holds the SIGNER key and is only checked against the submission block. A voucher signed today with `issuedAt` 30 days ahead lies dormant, cannot be reached by `voidVouchersIssuedBefore` (which can never be raised past `now`), is revived by a revoke-and-regrant, and then pays. This contradicts AUDIT.md invariant 3 and the stated purpose of the void. The same mechanism gives the void a 5-minute blind spot. The Foundry proof in `test/scratch/SleeperVoucher.t.sol` fails on the current code for exactly this reason. Suggested fix keeps the ABI: mix a `voucherEpoch` into the signed digest and bump it on void.\n- **Low: unbounded `adminDelay` in `Setup`.** Every other constructor input gets setToken-grade validation, but a mistyped delay is permanent. With the maximum value, both handover and delay change revert on uint48 overflow forever. With \"2 days in milliseconds\" they take about 5.5 years. Verified against OpenZeppelin 5.1.0's delay arithmetic.\n- **Info: stale signing script.** `sign-withdrawal.js` still signs the v1 type without `issuedAt`, so its vouchers are rejected by v2. Noted as outside the .sol scope.\n\n**What held up under testing (no finding)**\n\n- `_verify` ordering, same-key-twice, two signers, cosigner-only, malformed and high-s signatures, role revocation.\n- Admin handover with the `_grantRole` override, including a new admin who is also GUARDIAN. Zero admin reverts in the constructor.\n- `_spend` casts and the rollover at exactly start + 1 day. The documented 2x-across-a-boundary bound is the true worst case. Oversized sweep amounts revert.\n- NO_COSIGN versus the `cosignAbove < maxWithdrawal` rule, pause and sweep gating, fundBankroll access, events on every value-moving path.\n- Reentrancy via ETH receivers and ERC-777 style hooks, blocklisted recipients, fee-on-transfer deposits.\n\nThe project's own 43 Hardhat tests pass.","treeHash":null,"usage":{"cachedInputTokens":1091485,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":51157,"runtime":"claude","turns":24,"wallClockMs":720295}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3beb94a990617d46","findings":[{"citation":"resolved","description":"withdraw() bounds a voucher's life to MAX_VOUCHER_LIFETIME after w.issuedAt (line 238) and refuses issuedAt more than MAX_CLOCK_SKEW ahead of the submission block (line 237). Nothing ties issuedAt to when the signature was actually produced: whoever holds the SIGNER key (plus COSIGNER for amounts above cosignAbove) picks it. A voucher signed at T with issuedAt = T + 30 days, deadline = T + 31 days is rejected (VoucherNotYetValid) until that day and then becomes payable for 24h. The documented kill switch cannot reach it: voidVouchersIssuedBefore refuses any issuedBefore > block.timestamp for every caller, including the admin, while the sleeper's issuedAt stays in the future until the day it fires. The same root cause gives the void a permanent 5-minute blind spot (a voucher with issuedAt = now + 5 min, allowed by line 237, survives voidVouchersIssuedBefore(now)) and lets an honest voucher live 24h + 5 min from real signing. This contradicts AUDIT.md invariant 3 ('no voucher can pay more than 24h after it was signed'), the natspec of voidVouchersIssuedBefore ('void every voucher signed before issuedBefore') and AUDIT-RESPONSE #2 ('re-granting a key can't revive old vouchers'). The suite's F2 test submits a future-dated voucher only immediately (VoucherNotYetValid) and never warps to its issuedAt. Preconditions: SIGNER signatures obtainable at some point (abused signing API in front of a KMS/HSM, insider, backup) and the key still, or again, holding SIGNER_ROLE when the chosen date arrives. The operator's documented fast response (guardian: pause + void(now)) therefore does not neutralise pre-signed vouchers; only revoking the key and never re-granting it does. Loss per token is still bounded by maxWithdrawal/cosignAbove/dailyCap per window, which is why this is medium, but one batch per future day drains up to dailyCap per day with no on-chain warning, weeks after the incident was 'closed'. Merged from four specialists (audit_permissions, audit_economics, audit_flow, audit_math) plus the two sub-notes on the 5-minute skew band and the 24h+5min lifetime, which share this root cause. Fix that keeps the design and the Withdrawal ABI: add uint256 public voucherEpoch to storage, mix it into the digest in hashWithdrawal (or a new typed-data field), and have voidVouchersIssuedBefore (or a new guardian/admin voidAllVouchers()) increment it so every outstanding voucher dies regardless of its claimed issuedAt; the backend reads the epoch when signing and re-issues. Alternatively let the admin (not the guardian) set minIssuedAt past block.timestamp. In either case reword invariant 3 to say what is actually enforced ('24h after the issuedAt it claims'). Verified: the attached proof (3 tests) fails on this code and passes with the epoch patch applied to a copy of the contract.","line":381,"path":"msft95-casino/contracts/contracts/MSFT95Vault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.24;\n\nimport \"forge-std/Test.sol\";\nimport {MSFT95Vault} from \"msft95-casino/contracts/contracts/MSFT95Vault.sol\";\n\n/// Finding: `issuedAt` is chosen by whoever holds the SIGNER key and is only checked against the\n/// submission block (`issuedAt <= block.timestamp + 5 min`). A voucher signed today with `issuedAt`\n/// 30 days ahead is dormant for 30 days, survives `voidVouchersIssuedBefore(now)` (which cannot be\n/// raised past `now`, by guardian or admin), survives a revoke + re-grant of the key, and then pays.\n/// AUDIT.md invariant 3 (\"no voucher can pay more than 24h after it was signed\") and the documented\n/// meaning of the void (\"void every voucher signed before issuedBefore\") do not hold.\n///\n/// Fails on the current code (the sleeper pays). Passes once a void makes every voucher signed\n/// before it unpayable regardless of its self-declared issuedAt (e.g. a voucher epoch committed in\n/// the signed digest and bumped by the void, or an admin-only void that may be set past `now`).\ncontract SleeperVoucherTest is Test {\n    MSFT95Vault vault;\n    uint256 signerPk = 0xA11CE;\n    uint256 cosignerPk = 0xB0B;\n    address signer = vm.addr(signerPk);\n    address cosigner = vm.addr(cosignerPk);\n    address guardian = address(0x6A);\n    address admin = address(0xAD);\n    address attacker = address(0xBAD);\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        MSFT95Vault.TokenSetup[] memory ts = new MSFT95Vault.TokenSetup[](1);\n        ts[0] = MSFT95Vault.TokenSetup({\n            token: address(0),\n            config: MSFT95Vault.TokenConfig({listed: true, depositsEnabled: true, minDeposit: 1, maxWithdrawal: 0.4 ether, dailyCap: 2 ether, cosignAbove: 0.1 ether})\n        });\n        vault = new MSFT95Vault(MSFT95Vault.Setup({\n            admin: admin, adminDelay: 0, signer: signer, cosigner: cosigner, guardian: guardian,\n            bankroller: address(0), allowlistOnly: false, allowed: new address[](0), tokens: ts\n        }));\n        vm.deal(admin, 100 ether);\n        vm.prank(admin);\n        vault.fundBankroll{value: 50 ether}(address(0), 50 ether);\n    }\n\n    function _sign(uint256 pk, MSFT95Vault.Withdrawal memory w) internal view returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, vault.hashWithdrawal(w));\n        return abi.encodePacked(r, s, v);\n    }\n\n    /// Signed at T with a (then) compromised key. At T+1h the operator voids as far as the contract\n    /// allows (guardian: now; admin: also tries a far-future cut-off). The key stays in service\n    /// (HSM key, only the signing API was abused). Expected: the voucher can never pay. Actual: pays at T+30d.\n    function test_sleeperSurvivesVoid() public {\n        uint256 T = block.timestamp;\n        MSFT95Vault.Withdrawal memory w = MSFT95Vault.Withdrawal({\n            user: attacker, token: address(0), amount: 0.1 ether, nonce: 1,\n            issuedAt: T + 30 days, deadline: T + 31 days\n        });\n        bytes[] memory sigs = new bytes[](1);\n        sigs[0] = _sign(signerPk, w);\n\n        // not payable yet\n        vm.expectRevert(MSFT95Vault.VoucherNotYetValid.selector);\n        vault.withdraw(w, sigs);\n\n        vm.warp(T + 1 hours);\n        vm.prank(guardian);\n        vault.voidVouchersIssuedBefore(block.timestamp);\n        vm.prank(admin);\n        try vault.voidVouchersIssuedBefore(T + 365 days) {} catch {}\n\n        vm.warp(T + 30 days);\n        uint256 before = attacker.balance;\n        (bool paid,) = address(vault).call(abi.encodeCall(vault.withdraw, (w, sigs)));\n        assertFalse(paid, \"sleeper voucher paid 30 days after it was signed, after the void\");\n        assertEq(attacker.balance, before, \"attacker received funds\");\n    }\n\n    /// Same sleeper; the operator revokes the key, voids, and later re-grants the same key.\n    /// AUDIT-RESPONSE #2: \"re-granting a key can't revive old vouchers\". Actual: it does.\n    function test_sleeperRevivedByRegrant() public {\n        uint256 T = block.timestamp;\n        MSFT95Vault.Withdrawal memory w = MSFT95Vault.Withdrawal({\n            user: attacker, token: address(0), amount: 0.1 ether, nonce: 2,\n            issuedAt: T + 30 days, deadline: T + 31 days\n        });\n        bytes[] memory sigs = new bytes[](1);\n        sigs[0] = _sign(signerPk, w);\n\n        bytes32 SR = vault.SIGNER_ROLE();\n        vm.warp(T + 1 hours);\n        vm.prank(admin);\n        vault.revokeRole(SR, signer);\n        vm.prank(guardian);\n        vault.voidVouchersIssuedBefore(block.timestamp);\n        vm.prank(admin);\n        try vault.voidVouchersIssuedBefore(T + 365 days) {} catch {}\n\n        vm.warp(T + 29 days);\n        vm.prank(admin);\n        vault.grantRole(SR, signer); // key put back into service\n\n        vm.warp(T + 30 days);\n        uint256 before = attacker.balance;\n        (bool paid,) = address(vault).call(abi.encodeCall(vault.withdraw, (w, sigs)));\n        assertFalse(paid, \"re-grant revived a voucher signed 30 days earlier\");\n        assertEq(attacker.balance, before, \"attacker received funds\");\n    }\n\n    /// The same root cause gives the void a permanent 5-minute blind spot: a voucher with\n    /// issuedAt = now + MAX_CLOCK_SKEW (accepted by withdraw) survives voidVouchersIssuedBefore(now)\n    /// and pays until 24h + 5 min after the void block.\n    function test_skewBandSurvivesVoid() public {\n        uint256 T = block.timestamp;\n        MSFT95Vault.Withdrawal memory w = MSFT95Vault.Withdrawal({\n            user: attacker, token: address(0), amount: 0.1 ether, nonce: 3,\n            issuedAt: T + 5 minutes, deadline: T + 5 minutes + 1 days\n        });\n        bytes[] memory sigs = new bytes[](1);\n        sigs[0] = _sign(signerPk, w);\n\n        vm.warp(T + 10);\n        vm.prank(guardian);\n        vault.voidVouchersIssuedBefore(block.timestamp);\n        vm.prank(admin);\n        try vault.voidVouchersIssuedBefore(T + 365 days) {} catch {}\n\n        vm.warp(T + 5 minutes + 1 days - 1);\n        uint256 before = attacker.balance;\n        (bool paid,) = address(vault).call(abi.encodeCall(vault.withdraw, (w, sigs)));\n        assertFalse(paid, \"voucher inside the skew band paid after the void\");\n        assertEq(attacker.balance, before, \"attacker received funds\");\n    }\n}","reproduction":"State: vault with ETH listed (maxWithdrawal 0.4 ETH, dailyCap 2 ETH, cosignAbove 0.1 ETH), SIGNER key S, GUARDIAN G, admin A, 50 ETH bankroll, T = block.timestamp. 1) S signs Withdrawal{user: attacker, token: ETH, amount: 0.1 ETH, nonce: 1, issuedAt: T + 30 days, deadline: T + 31 days}; withdraw() now reverts VoucherNotYetValid. 2) At T + 1h: G calls voidVouchersIssuedBefore(block.timestamp) (succeeds, minIssuedAt = T+1h); A calls voidVouchersIssuedBefore(T + 365 days): reverts BadConfig. 3) At T + 30 days anyone calls withdraw(w, [sigS]). Expected: revert (voucher signed before the void; 30 days after signing). Actual: all checks on lines 236-239 pass (T+30d < deadline; issuedAt == block.timestamp; deadline - issuedAt == 1 day; issuedAt >= minIssuedAt) and attacker receives 0.1 ETH. Variant: A revokes SIGNER from S at T+1h, G voids, A re-grants S at T+29d: the voucher pays at T+30d. Variant: voucher signed at T with issuedAt = T + 5 min; G voids at T+10s; the voucher pays at T + 5 min + 24h - 1s. Ran test/scratch/SleeperVoucher.t.sol with forge (OZ 5.1.0, solc 0.8.24): 3 failed on current code ('sleeper voucher paid 30 days after it was signed, after the void', 're-grant revived a voucher signed 30 days earlier', 'voucher inside the skew band paid after the void'); 3 passed on a copy with voucherEpoch mixed into hashWithdrawal and incremented by voidVouchersIssuedBefore. The three specialist proofs (Proof_ce3a71b5a9b9, Proof_83137df81cd9, Proof_d05ed22c1989) were also run and all fail on this code for the stated reason.","severity":"medium","snippet":"        if (issuedBefore <= minIssuedAt || issuedBefore > block.timestamp) revert BadConfig();","title":"issuedAt is signer-chosen and only checked against the submission block: future-dated sleeper vouchers outlive 24h, survive voidVouchersIssuedBefore (which can never pass block.timestamp) and are revi"},{"citation":"resolved","description":"The constructor validates the three role keys and every token config with the same _setToken rules as setToken, and OpenZeppelin rejects a zero admin, but s.adminDelay is passed straight through unchecked. AccessControlDefaultAdminRules 5.1.0 computes SafeCast.toUint48(block.timestamp) + delay in checked uint48 arithmetic in both _beginDefaultAdminTransfer (line 221 of the OZ file) and _changeDefaultAdminDelay (line 287), and when the delay is decreased the waiting period is currentDelay - newDelay. An absurd initial delay is therefore permanent: with type(uint48).max both beginDefaultAdminTransfer and changeDefaultAdminDelay revert on uint48 overflow forever; with a plausible unit mistake (2 days written in milliseconds = 172,800,000; deploy.js reads ADMIN_DELAY through Number() with no bound) a handover or a delay reduction waits about 5.5 years. The Safe stays admin, so funds are not at risk, but the documented ability to rotate the admin (README: 'Handing over admin later takes ADMIN_DELAY and must be accepted') is lost and the contract must be redeployed and migrated. Fix: bound it in the constructor like the token limits, e.g. if (s.adminDelay > 30 days) revert BadConfig(); and mirror the bound in deploy.js.","line":150,"path":"msft95-casino/contracts/contracts/MSFT95Vault.sol","reproduction":"Deploy MSFT95Vault(Setup{admin: 0xAD, adminDelay: type(uint48).max, signer, cosigner, guardian, one ETH token}). Admin calls beginDefaultAdminTransfer(alice): expected a scheduled transfer; actual revert (Panic 0x11, uint48 overflow). Admin calls changeDefaultAdminDelay(0): actual revert for the same reason, so the state can never be corrected. With adminDelay = 172_800_000, changeDefaultAdminDelay(0) succeeds but pendingDefaultAdminDelay().schedule is 172,800,000 s (about 5.5 years) ahead. Both verified with forge against OZ 5.1.0 (test_maxAdminDelayBricksHandover, test_largeAdminDelayMs passed, i.e. the reverts and the 5.5-year schedule were observed). By contrast a token config with the same kind of mistake is rejected with BadConfig.","severity":"low","snippet":"        AccessControlDefaultAdminRules(s.adminDelay, s.admin)","title":"Setup accepts any adminDelay: an oversized value makes admin handover and delay changes impossible or multi-year, and cannot be corrected afterwards"},{"citation":"resolved","description":"The trust model says the admin can 'list and unlist tokens'. The natural unlist call, setToken(token, TokenConfig(false, false, 0, 0, 0, 0)), reverts BadConfig because cosignAbove (0) >= maxWithdrawal (0) and the consistency checks do not consider cfg.listed. Unlisting only works with a non-zero maxWithdrawal/dailyCap left in storage, or with cosignAbove = NO_COSIGN, which leaves a stale opt-out sentinel that silently disables the cosigner if the token is later re-listed from the stored struct. Under time pressure (delisting a token found to be malicious) a Safe/timelock proposal with the zeroed struct fails and must be re-queued. Fix: skip the maxWithdrawal/cosignAbove consistency checks when cfg.listed is false (or accept an all-zero config), and consider deleting the struct on unlist.","line":326,"path":"msft95-casino/contracts/contracts/MSFT95Vault.sol","reproduction":"Vault with ETH listed (test fixture config). Admin calls setToken(ETH, {listed: false, depositsEnabled: false, minDeposit: 0, maxWithdrawal: 0, dailyCap: 0, cosignAbove: 0}). Expected: ETH unlisted. Actual: revert MSFT95Vault.BadConfig. The same call with maxWithdrawal = 1, dailyCap = 1 succeeds (listed becomes false), showing the rejection is only the cosignAbove >= maxWithdrawal comparison on zeros. Verified with forge (test_zeroConfigUnlistReverts).","severity":"low","snippet":"        if (cfg.cosignAbove != NO_COSIGN && cfg.cosignAbove >= cfg.maxWithdrawal) revert BadConfig();","title":"setToken rejects the all-zero config, so a token cannot be unlisted by clearing it; the cosignAbove < maxWithdrawal rule is enforced even when listed == false"},{"citation":"resolved","description":"Outside the .sol scope but shipped in the same v2 folder and referenced by README ('Voucher signing example' and testnet step 4). The contract's WITHDRAWAL_TYPEHASH is Withdrawal(address user,address token,uint256 amount,uint256 nonce,uint256 issuedAt,uint256 deadline) and hashWithdrawal encodes six fields. The script's EIP-712 type omits issuedAt and the voucher JSON it prints has no issuedAt, so withdraw() cannot be called with it as-is, and if issuedAt is filled in by hand the digest differs from hashWithdrawal, ECDSA.recover yields an unrelated address, hasRole fails and withdraw reverts BadSignatures. test/helpers.js and backend/src/chain.ts carry the correct six-field type, which is why the suite does not notice. Merged from three specialists (audit_economics, audit_flow, audit_math). Fix: add { name: 'issuedAt', type: 'uint256' } before deadline, set issuedAt = now and deadline = issuedAt + TTL (TTL <= 86400), print issuedAt, or import the type from one shared module.","line":10,"path":"msft95-casino/contracts/scripts/sign-withdrawal.js","reproduction":"Sign Withdrawal{alice, ETH, 0.1 ether, nonce 1, deadline t+3600} with the SIGNER key over the five-field type string keccak256('Withdrawal(address user,address token,uint256 amount,uint256 nonce,uint256 deadline)') and the vault's domainSeparator(), then call withdraw({alice, ETH, 0.1 ether, 1, issuedAt: t, deadline: t+3600}, [sig]). Expected (per README): payout. Actual: revert BadSignatures. Verified with forge (test_v1TypeSignatureRejected) by hashing exactly the type the script uses against the deployed vault's domain separator.","severity":"low","snippet":"const TYPES = { Withdrawal: [\n  { name: \"user\", type: \"address\" }, { name: \"token\", type: \"address\" }, { name: \"amount\", type: \"uint256\" },\n  { name: \"nonce\", type: \"uint256\" }, { name: \"deadline\", type: \"uint256\" } ] };","title":"scripts/sign-withdrawal.js still signs the v1 Withdrawal type (no issuedAt); every voucher it produces is rejected by the v2 vault with BadSignatures"},{"citation":"resolved","description":"Unlike pauseWithdrawals(), which the admin can undo, raising minIssuedAt has no admin counterpart: nothing lowers it. One call with issuedBefore = block.timestamp from the monitoring bot's key invalidates every voucher already handed to players; those vouchers keep nonceUsed == false, and the backend refund rule (backend/src/withdrawals.ts expireWithdrawals, 'w.deadline < confirmed block timestamp') only re-credits a voucher once a confirmed block is strictly past its deadline, so affected players wait up to WITHDRAWAL_TTL (<= 24h) with their balance debited while the funds sit in the vault. Repeated calls each block amount to a withdrawal pause the admin can lift only by revoking the guardian (Safe + timelock). This is within the documented GUARDIAN powers and is reported as a trust assumption, not a bypass: the blast radius of a stolen guardian key is 'all pending withdrawals delayed up to 24h and the key must be revoked', not merely 'paused until admin unpauses'. If that is not intended, let the backend refund a voucher as soon as a confirmed block shows issuedAt < minIssuedAt (it is provably unpayable), which removes the 24h wait without touching the contract.","line":379,"path":"msft95-casino/contracts/contracts/MSFT95Vault.sol","reproduction":"Signer signs W = {alice, ETH, 0.05 ether, nonce 1, issuedAt t, deadline t+3600}. Guardian calls voidVouchersIssuedBefore(t+1). withdraw(W, [sigS]) reverts VoucherVoided; nonceUsed(alice, 1) == false; the ABI has no function that lowers minIssuedAt (voidVouchersIssuedBefore reverts BadConfig for issuedBefore <= minIssuedAt). Backend query 'deadline < confirmedBlock.timestamp' is false until t+3600, so alice's balance stays debited until then. Contract behaviour verified with forge; backend rule read from withdrawals.ts lines 59-65.","severity":"info","snippet":"    function voidVouchersIssuedBefore(uint256 issuedBefore) external {","title":"Trust assumption: a GUARDIAN key (or a stolen one) can irreversibly void every outstanding honest voucher, and the backend only re-credits those players after each voucher's deadline"}],"hash":"c7d3743d2f5928ee0f1440cffcc281ed1bc7be24e43292f8ad9fb24483735943","nodeId":"df4b7788-fd5e-4ae9-afbd-3320fc4dabd3","outcome":"completed","summary":"Verification done. All five snippets match the tree at the stated lines, the findings file is valid JSON, and the proof fails on the current code and passes on an epoch-patched copy. Nothing else is needed, so here is the recap.\n\n## Review of MSFT95Vault v2: result\n\nThe findings file at `.imd-findings.json` holds 5 verified findings. One medium, three low, one info. I reproduced every specialist claim in a scratch Foundry project (OpenZeppelin 5.1.0, solc 0.8.24, built outside the repo since there is no foundry.toml and no node_modules), ran all three specialist proofs, and dropped or merged what did not hold up.\n\n**Kept**\n\n- **Medium, merged from 4 specialists:** future-dated sleeper vouchers. `issuedAt` is signer-chosen and only compared to the submission block, while `voidVouchersIssuedBefore` can never be raised past `block.timestamp`, by guardian or admin. A voucher signed today with `issuedAt` 30 days ahead survives the void, survives revoke plus re-grant, and pays on its day. The 5-minute skew blind spot and the 24h+5min lifetime are the same root cause and are folded in. Proof attached at `test/scratch/SleeperVoucher.t.sol`: 3 tests fail on current code, 3 pass with a voucher-epoch patch.\n- **Low:** `Setup.adminDelay` is unchecked. `type(uint48).max` bricks `beginDefaultAdminTransfer` and `changeDefaultAdminDelay` forever via uint48 overflow in OZ; a milliseconds mistake makes handover take 5.5 years. Verified against OZ 5.1.0.\n- **Low:** all-zero `setToken` config reverts `BadConfig`, so a token cannot be unlisted by clearing it. Verified.\n- **Low, merged from 3 specialists:** `scripts/sign-withdrawal.js` still uses the five-field v1 type; its signatures recover to a stranger and revert `BadSignatures`. Verified by hashing the exact v1 type against the vault's domain separator. Flagged as outside the .sol scope but shipped in the same folder and referenced by the README.\n- **Info:** trust assumption that a guardian void is irreversible and the backend refund rule waits for the deadline, so a stolen guardian key delays all pending withdrawals up to 24h.\n\n**Dropped**\n\n- ETH gas-griefing of relayers. The backend hands the voucher to the player, who submits it themselves, so the griefed party would be the griefer. No third-party harm reproduced.\n\n**Checked clean**\n\n`_verify` ordering and duplicate-key handling, role revocation, high-s and malformed signatures, the `uint192` cast and window rollover, `NO_COSIGN` with maximum limits, sweep under pause, `fundBankroll` access, the `_grantRole` override with admin handover (works, and the override cannot be bypassed since all grants pass through it), constructor validation parity with `setToken`, reentrancy on ETH and hook tokens, and event coverage for every value movement. No defects found there.","treeHash":null,"usage":{"cachedInputTokens":1102225,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":28593,"runtime":"claude","turns":29,"wallClockMs":455034}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dc34db8e17664ebd","findings":[{"citation":"resolved","description":"withdraw() accepts issuedAt up to block.timestamp + MAX_CLOCK_SKEW (line 237), but voidVouchersIssuedBefore() refuses any issuedBefore above block.timestamp. The signer (or whoever can obtain signer signatures: a stolen key, a compromised API in front of the HSM) therefore controls a 5-minute band of issuedAt values that no single void call can cover. AUDIT.md says 'After revoking, call voidVouchersIssuedBefore(now) if outstanding vouchers might be forged' and AUDIT-RESPONSE #2 says the void exists 'so re-granting a key can't revive old vouchers'; both assume void(now) voids every voucher signed so far. It does not: a voucher with issuedAt = now+300 survives void(now), survives a re-grant of the same key, and stays payable until now+300+86400, i.e. 24h5m after the chain time at which it was actually produced, which also exceeds the 'no voucher can pay more than 24h after it was signed' wording of invariant 3. The operator must call void a second time five minutes later (killing legitimate vouchers issued in between) to close the band. Suggested minimal fix that preserves the design: accept issuedBefore <= block.timestamp + MAX_CLOCK_SKEW (the same bound withdraw() applies), and document that an emergency void should pass block.timestamp + MAX_CLOCK_SKEW. Optionally also make the backend clamp issuedAt to max(now, minIssuedAt).","line":381,"path":"msft95-casino/contracts/contracts/MSFT95Vault.sol","reproduction":"Deploy with the test helpers (signer S, guardian G, ETH listed). Chain time t. S signs W = {user: alice, token: ETH, amount: 1 ether, nonce: 1, issuedAt: t+300, deadline: t+300+86400}. At block time t, G calls voidVouchersIssuedBefore(t): succeeds, minIssuedAt = t. G calls voidVouchersIssuedBefore(t+300): reverts BadConfig, so no larger value is possible. At block time t+2, anyone calls withdraw(W, [sigS]): expected (per AUDIT.md key-rotation guidance) VoucherVoided; actual: Withdrawn emitted and 1 ether paid. Sibling voucher with nonce 2 and the same times submitted at block time t+300+86400-1 also pays: 24h4m59s after the void block. Verified with a hardhat scratch test against this commit (3 passing).","severity":"low","snippet":"        if (issuedBefore <= minIssuedAt || issuedBefore > block.timestamp) revert BadConfig();","title":"voidVouchersIssuedBefore cannot reach vouchers forward-dated inside MAX_CLOCK_SKEW, so one void call does not kill everything already signed, and such a voucher pays up to 24h+5min after real signing"},{"citation":"resolved","description":"The contract's WITHDRAWAL_TYPEHASH is Withdrawal(address user,address token,uint256 amount,uint256 nonce,uint256 issuedAt,uint256 deadline). The signing helper shipped in the same folder (and referenced from README as 'how the backend signs') was not updated with the rest of v2: its EIP-712 type omits issuedAt and the voucher object it prints has no issuedAt field, so withdraw() cannot even be called with it as-is, and if an issuedAt is added by hand the signature recovers to a stranger and reverts BadSignatures. test/helpers.js and backend/src/chain.ts carry the correct type, which is why the suite does not notice. Fix: import WITHDRAWAL_TYPES from one shared place (or copy the six-field type) and set issuedAt = now, deadline = issuedAt + TTL with TTL <= 86400.","line":10,"path":"msft95-casino/contracts/scripts/sign-withdrawal.js","reproduction":"const legacy = require('scripts/sign-withdrawal.js'); sig = await legacy.signVoucher(signerWallet, vaultAddr, chainId, {user, token: ETH, amount: 1 ether, nonce: 1, deadline: t+3600}); vault.withdraw({user, token: ETH, amount: 1 ether, nonce: 1, issuedAt: t, deadline: t+3600}, [sig]) -> expected Withdrawn, actual revert BadSignatures (recovered address holds no role). Verified with a hardhat scratch test against this commit.","severity":"low","snippet":"const TYPES = { Withdrawal: [\n  { name: \"user\", type: \"address\" }, { name: \"token\", type: \"address\" }, { name: \"amount\", type: \"uint256\" },\n  { name: \"nonce\", type: \"uint256\" }, { name: \"deadline\", type: \"uint256\" } ] };","title":"scripts/sign-withdrawal.js still signs the v1 Withdrawal type (no issuedAt); every voucher it produces is rejected by the v2 vault"},{"citation":"resolved","description":"Unlike pauseWithdrawals(), which the admin can undo, raising minIssuedAt has no admin counterpart: there is no setter that lowers it. One call with issuedBefore = block.timestamp by the monitoring bot's key invalidates every voucher already handed to players; those vouchers keep nonceUsed == false, and the backend (withdrawals.ts refund rule) only re-credits a voucher once a confirmed block is strictly past its deadline, so affected players wait up to WITHDRAWAL_TTL (<= 24h) before they can even request a new voucher, while the funds sit in the vault. Repeated calls each block are equivalent to a withdrawal pause the admin cannot lift except by revoking the guardian. This is within the documented GUARDIAN powers (AUDIT.md trust model) and is reported as a trust assumption, not a bypass: the blast radius of a stolen guardian key is 'all pending withdrawals delayed up to 24h', not only 'paused until admin unpauses'. If that is not intended, let the backend refund a voucher as soon as a confirmed block shows issuedAt < minIssuedAt (it is provably unpayable), which removes the 24h wait without touching the contract.","line":379,"path":"msft95-casino/contracts/contracts/MSFT95Vault.sol","reproduction":"Signer signs W = {alice, ETH, 1 ether, nonce 1, issuedAt t, deadline t+86400}. Guardian calls voidVouchersIssuedBefore(t+1). withdraw(W, [sigS]) reverts VoucherVoided; nonceUsed(alice, 1) == false; the ABI has no function that lowers minIssuedAt (checked vault.interface). Backend refund query 'deadline < confirmedBlock.timestamp' is false until t+86400, so alice's 1 ether stays debited in the ledger for up to 24h. Verified with a hardhat scratch test against this commit.","severity":"info","snippet":"    function voidVouchersIssuedBefore(uint256 issuedBefore) external {","title":"Trust assumption: a GUARDIAN key (or a stolen one) can irreversibly void every outstanding voucher, and the ledger re-credits those players only after each voucher's deadline"}],"hash":"eb611691c3d75c054436587be51a34f702ffa667266da5b16ec1eaab3dc613a1","nodeId":"87634474-f416-43e7-b6ae-8f1701c84166","outcome":"completed","summary":"The review is complete. The findings file holds three verified items, none critical or high, so no Foundry proof files were required.\n\n**Outcome.** MSFT95Vault v2 holds up on all seven focus areas. The full hardhat suite passed (43 tests) with OpenZeppelin 5.1.0 resolved from the lockfile, and I confirmed the three leads below with a scratch hardhat test that has since been removed. Nothing in the tree was changed apart from writing `.imd-findings.json`.\n\n**Findings written to `.imd-findings.json`:**\n\n- **Low, void gap.** `voidVouchersIssuedBefore` refuses any value above `block.timestamp`, while `withdraw` accepts `issuedAt` up to five minutes ahead. A voucher dated `now+300` survives `void(now)`, survives a re-grant of the same key, and pays until 24h5m after the void block. The AUDIT.md key-rotation guidance assumes one void call covers everything signed so far. Suggested fix: accept `issuedBefore <= block.timestamp + MAX_CLOCK_SKEW`.\n- **Low, stale signing helper.** `scripts/sign-withdrawal.js` still uses the five-field v1 EIP-712 type without `issuedAt`. Every voucher it produces reverts with `BadSignatures` on v2. The test helpers and backend carry the correct type, which is why the suite misses it.\n- **Info, guardian trust assumption.** The void is irreversible and the backend only re-credits a voided voucher after its deadline passes. A stolen guardian key therefore delays every pending withdrawal by up to 24h, which is wider than \"pause until admin unpauses\". Fixable backend-side by refunding as soon as `issuedAt < minIssuedAt` is observed.\n\n**Checked and found sound:** `_verify` ordering, same-key and same-role rejection, high-s and malformed signatures, nonce set before payout, funds always to `w.user`, strict deadline, 24h lifetime bound, `uint192` cast and rollover at exactly `start + 1 day`, `NO_COSIGN` versus `cosignAbove < maxWithdrawal`, `fundBankroll` gating, sweep blocked under pause, the `_grantRole` override with admin handover, zero-admin rejection in the constructor, identical validation in constructor and `setToken`, reentrancy on every entry point, blocklist and fee-on-transfer handling, and event coverage for every value movement.\n\n**One backend note outside scope:** `withdrawals.ts` sets `issuedAt` from the server clock without clamping to `minIssuedAt`, so a void while the server clock lags the chain would produce dead vouchers until the clock catches up.","treeHash":null,"usage":{"cachedInputTokens":1014024,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":31267,"runtime":"claude","turns":19,"wallClockMs":677421}}],"verification":[]}