{"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":"7a954a11-c26a-4d9a-8bed-cd7ea15fb4ed","kind":"audit","nodes":[{"acceptedSubmissionHash":"8bfd08db6fdf4c165a517c72f2bdf282358a7878d4c38f6463f652ec7032a749","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":"22dc59ea9325b40d9daedfa33465699ca3a38f15ce9e86d6b65a18a133e7a654","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":"ee06755e2c9d0ab60b706246db72482b688662ca779eea09783544785f9ec604","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":"a76d83cb266e0510acabfad9bab5f4ae0005e1269ba7378b5fe48c7bd21a511f","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":"1723a989a7bde1b4967d45637e31c730f2bef86c99e9d7a5c7eb23772298ed0e","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 the Foundry contracts in this repository as received (src/ECU.sol, src/FarmVault.sol, script/Distribute.s.sol) against docs/ECONOMY.md v1.0 and docs/MECHANICS.md. Context: My e-farm, a farm game on Base. ECU is a fixed-supply ERC-20 distributed once by the Distribute script (liquidity, team vesting, community vesting, listings multisig, FarmVault). In-game coins stay off-chain; the only ECU outflow to players is FarmVault paying against an EIP-712 voucher signed by an off-chain operator key, with a guardian that can pause and rotate. Priorities: (1) voucher logic — replay, nonce ordering, expiry, domain separator, signature malleability, rotation edge cases; (2) blast radius of a compromised operator key, and whether the vault should enforce a per-day outflow cap equal to the daily Exchange budget in ECONOMY.md; (3) guardian powers and hand-over; (4) Distribute script — allocation math, vesting schedules, leftover tokens, ownership renounce; (5) standard Solidity issues. Report reproducible findings only, each with severity and a minimal repro or test. Do not change code.","parentJobId":null,"planHash":"e3040cbac9ecf42afc24b1b36cdde62944254f4b6dac1842e614e88557953c91","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"7a954a11-c26a-4d9a-8bed-cd7ea15fb4ed","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":"51452","feedbackHash":"a5e12449e74e47bc336b6d4e62fc11e1c7b82133e96e7a19f80244a6aebc8610","nodeKey":"audit_economics","submissionHash":"8bfd08db6fdf4c165a517c72f2bdf282358a7878d4c38f6463f652ec7032a749","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51339","feedbackHash":"8d5be202de616b96c6f668176bbf3b776cb25ed214ae745981a985f7a3827029","nodeKey":"audit_flow","submissionHash":"22dc59ea9325b40d9daedfa33465699ca3a38f15ce9e86d6b65a18a133e7a654","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52623","feedbackHash":"1e0e42573f167e3c5633a615a7dbc4eadea2365d4cb19e3209165b9710539502","nodeKey":"audit_judge","submissionHash":"ee06755e2c9d0ab60b706246db72482b688662ca779eea09783544785f9ec604","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"53924","feedbackHash":"5a9df8a3f48211ba049e6e221faa0aeddda25f06474419eb3cd9a5c831eabdc1","nodeKey":"audit_math","submissionHash":"a76d83cb266e0510acabfad9bab5f4ae0005e1269ba7378b5fe48c7bd21a511f","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52287","feedbackHash":"24fb3b9f00a80423b15e69d4dc95c76b2251a0edd1ac4f2fd7a448b4e287a627","nodeKey":"audit_permissions","submissionHash":"1723a989a7bde1b4967d45637e31c730f2bef86c99e9d7a5c7eb23772298ed0e","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"34be616fc0a971b36a3d8d6f1ed85fe17eaaf1c7502dab57ee81351226ecb29a","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"99c6d0bcc495ad61","findings":[{"citation":"resolved","description":"Inside `vm.startBroadcast()`, every CREATE and CALL the script makes is sent from the broadcaster (the --sender / private-key account), but `address(this)` is still the script contract's own address, which exists only in forge's local EVM and never on chain. The ECU constructor therefore mints all 100,000,000 ECU to that phantom address, and the five `transfer` calls at lines 90-94 are sent from the broadcaster, which holds 0 ECU. `forge script` refuses to continue (\"Usage of `address(this)` detected in script contract. Script contracts are ephemeral and their addresses should not be relied upon.\"), and even without that guard the first transfer reverts with ERC20InsufficientBalance(broadcaster, 0, 15_000_000e18). Nothing can be launched with this script as written. If the guard were bypassed (an older forge, or a hand-edited broadcast), the 100 M would sit at an address with no key and no code: permanent loss of the entire supply. The existing DistributeTest never catches this because it calls `deployAll` directly from a test contract, where the script contract itself is the caller and the mint and the transfers share the same address. Fix: mint to a real actor. Either read the broadcaster with `(, address broadcaster,) = vm.readCallers()` after `startBroadcast` and use `new ECU(broadcaster)` (the transfers then come from an account that actually holds the supply), or, more robustly, deploy a small `Distributor` contract whose constructor creates the ECU with `address(this)` and performs the split atomically, so no EOA ever holds the supply.","line":84,"path":"script/Distribute.s.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Distribute} from \"script/Distribute.s.sol\";\nimport {ECU} from \"src/ECU.sol\";\n\n/// @notice Runs the launch script the way `forge script --broadcast` runs it: through\n///         `run()`, whose own `vm.startBroadcast()` makes every CREATE and CALL come from\n///         the broadcaster rather than from the script contract. Expected per ECONOMY.md\n///         §11.7: the supply ends up split across the five destinations and nothing is\n///         stranded. Actual: `new ECU(address(this))` mints the whole supply to the script\n///         contract's own address, which is not the broadcaster, so the first `transfer`\n///         reverts with ERC20InsufficientBalance(broadcaster, 0, 15_000_000e18).\n///         (forge script itself aborts earlier with \"Usage of `address(this)` detected in\n///         script contract\", so the launch cannot be broadcast at all.)\ncontract DistributeBroadcastTest is Test {\n    function test_runDistributesUnderBroadcast() public {\n        vm.setEnv(\"LIQUIDITY_ADDRESS\", vm.toString(address(0x11)));\n        vm.setEnv(\"COMMUNITY_BENEFICIARY\", vm.toString(address(0x22)));\n        vm.setEnv(\"TEAM_BENEFICIARY\", vm.toString(address(0x33)));\n        vm.setEnv(\"LISTINGS_MULTISIG\", vm.toString(address(0x44)));\n        vm.setEnv(\"VAULT_OPERATOR\", vm.toString(address(0x55)));\n        vm.setEnv(\"VAULT_GUARDIAN\", vm.toString(address(0x66)));\n\n        Distribute script = new Distribute();\n\n        // run() opens its own broadcast; in a test a bare `vm.startBroadcast()` sends\n        // from forge-std's DEFAULT_SENDER, exactly as `forge script` sends from --sender.\n        Distribute.Deployment memory d = script.run();\n\n        assertEq(d.ecu.balanceOf(address(0x11)), 15_000_000e18, \"liquidity slice\");\n        assertEq(d.ecu.balanceOf(address(d.communityVesting)), 15_000_000e18, \"community slice\");\n        assertEq(d.ecu.balanceOf(address(d.teamVesting)), 15_000_000e18, \"team slice\");\n        assertEq(d.ecu.balanceOf(address(0x44)), 5_000_000e18, \"listings slice\");\n        assertEq(d.ecu.balanceOf(address(d.vault)), 50_000_000e18, \"vault slice\");\n        assertEq(d.ecu.balanceOf(address(script)), 0, \"script contract must hold nothing\");\n        assertEq(d.ecu.balanceOf(DEFAULT_SENDER), 0, \"broadcaster must hold nothing\");\n    }\n}","reproduction":"Run `forge script script/Distribute.s.sol:Distribute --rpc-url <anvil> --private-key <key>` with the six env vars set: forge aborts with \"Usage of `address(this)` detected in script contract\". In a Foundry test, call `script.run()` (which opens its own broadcast): the trace shows `Transfer(0x0 -> Distribute script address, 1e26)` followed by `ECU::transfer(0x11, 1.5e25)` reverting with ERC20InsufficientBalance(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, 0, 15000000000000000000000000). Expected: liquidity 15 M, community vesting 15 M, team vesting 15 M, listings 5 M, vault 50 M, script 0. Actual: revert, no distribution.","severity":"high","snippet":"        deployment.ecu = new ECU(address(this));","title":"Distribute.s.sol mints the whole supply to the ephemeral script address, so the launch cannot be broadcast"},{"citation":"resolved","description":"`withdraw` checks pause, nonce, expiry and signature, and then transfers whatever `amount` the operator signed. There is no per-voucher maximum, no per-day or rolling-window cap, and no fraction-of-balance bound. ECONOMY.md fixes the legitimate outflow far below that: §3.3 and §9 say the reserve's only outflow is the Exchange, bounded by B = R*ln2/730 + EMA30 (about 33,000 ECU/day at R = 35 M; the run-10 table shows ~31,000/day), §1 caps each farm at max(500, 1.5*EMA7(daily_revenue)) per day, and gate 6 requires net withdrawals over any 7 days to stay under 15 % of the pool's ECU side (2.25 M). The contract relies entirely on the off-chain backend to apply those rules before signing, and .env.example describes VAULT_OPERATOR as \"the backend's hot key\". If that key leaks (server compromise, CI secret, insider), the attacker signs one voucher to their own address for the vault's entire balance of 50,000,000 ECU (half the supply, 3.3x the whole DEX float) and submits it in one transaction. The guardian's controls are strictly reactive: pause and rotation each need a 2-of-3 Safe execution, which cannot land before a single drain transaction, and a visible pause/rotation transaction in the mempool is itself the signal to drain first. The existing fuzz test `testFuzz_anyAmountTheVaultHoldsCanBeVouchered` documents this as intended, which is why no test fails today. Answering priority (2) of the brief: yes, an on-chain outflow limit is warranted. A cap equal to B alone would break §1's rule that the `deposited` bucket is always withdrawable (a player who deposits 1 M must be able to leave), so the recommended minimal fix is a rolling-window cap set by the guardian, e.g. `maxOutflowPerWindow` over 24 h (sized to B plus expected deposit withdrawals, or to gate 6's 2.25 M per 7 days), checked in `withdraw` before the transfer, plus optionally a per-voucher ceiling. That bounds a key compromise to one window's budget instead of the whole custody, and keeps the operator/guardian design unchanged.","line":136,"path":"src/FarmVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ECU} from \"src/ECU.sol\";\nimport {FarmVault} from \"src/FarmVault.sol\";\n\n/// @notice Blast radius of the operator key. ECONOMY.md §3.3 and §9 bound the reserve's\n///         only outflow to the daily Exchange budget B = R·ln2/730 + EMA30 (about\n///         33,000 ECU/day at R = 35 M), and gate 6 bounds net withdrawals to 15 % of the\n///         pool's ECU side (2.25 M) over any 7 days. The vault enforces no bound at all:\n///         one voucher signed by the (hot, backend) operator key moves the entire\n///         game-side half of the supply in a single transaction, and nothing the guardian\n///         can do lands before it.\n///\n///         Fails on the current code because the 50 M withdrawal succeeds. Passes once\n///         `withdraw` enforces a per-window outflow cap below the vault's whole balance.\ncontract VaultOutflowCapTest is Test {\n    ECU internal ecu;\n    FarmVault internal vault;\n    uint256 internal operatorKey = 0xA11CE5;\n    address internal attacker = address(0xBAD);\n\n    function setUp() public {\n        ecu = new ECU(address(this));\n        vault = new FarmVault(ecu, vm.addr(operatorKey), address(0x6A12D));\n        // Exactly what Distribute.s.sol puts in custody: reserve 35 % + Bank pool 15 %.\n        ecu.transfer(address(vault), 50_000_000e18);\n    }\n\n    function test_oneVoucherCannotMoveTheWholeVault() public {\n        uint256 amount = 50_000_000e18;\n        uint256 expiry = block.timestamp + 1 hours;\n        bytes32 digest = vault.voucherDigest(attacker, amount, 0, expiry);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKey, digest);\n\n        vm.expectRevert();\n        vault.withdraw(attacker, amount, 0, expiry, abi.encodePacked(r, s, v));\n\n        assertEq(ecu.balanceOf(attacker), 0, \"a single voucher must not drain custody\");\n        assertEq(ecu.balanceOf(address(vault)), 50_000_000e18);\n    }\n}","reproduction":"State: vault holds 50_000_000e18 ECU (exactly what Distribute.s.sol deposits), operator key known to the attacker. Input: `withdraw(attacker, 50_000_000e18, 0, block.timestamp + 1 hours, sig)` where sig is the operator's EIP-712 signature over that voucher. Expected under ECONOMY.md: outflow bounded to the daily budget (tens of thousands of ECU), so the call reverts. Actual: the call succeeds, `balanceOf(attacker) == 50_000_000e18`, vault balance 0, in a single transaction, before any guardian action is possible.","severity":"high","snippet":"        ecu.safeTransfer(to, amount);","title":"FarmVault enforces no outflow bound: one voucher from the hot operator key moves all 50 M ECU in one transaction"},{"citation":"resolved","description":"`setGuardian` assigns the new guardian immediately with only a zero-address check; there is no two-step accept, no timelock and no way back. The guardian is the only account that can pause withdrawals or rotate a leaked operator key, so one mistyped or stale address (an old Safe, a Safe on another chain, a copy-paste error) leaves the vault with a working operator key and no emergency control for the life of the contract. Fix: adopt the two-step pattern (`pendingGuardian` + `acceptGuardian()` called by the new address), which proves the new guardian controls the key before the old one loses it. The design (one guardian address, 2-of-3 Safe behind it) is unchanged.","line":177,"path":"src/FarmVault.sol","reproduction":"Guardian calls `setGuardian(0x1234)` where 0x1234 is an address nobody controls. Then the former guardian calls `pauseWithdrawals()`: reverts with NotGuardian. Nobody can ever pause or call `setOperator` again. Expected: the handover should not complete until 0x1234 proves it can act. Actual: completes irreversibly in one call (scratch test test_singleStepGuardianHandoverIsFinal passes on current code).","severity":"low","snippet":"        guardian = newGuardian;","title":"Single-step guardian handover: a wrong address in setGuardian permanently removes the pause and rotation controls"},{"citation":"resolved","description":"Nonces are strictly sequential per recipient and only advance when a voucher is spent. Two consequences. (a) Once the operator has signed a voucher, the only on-chain ways to stop it before expiry are the global `pauseWithdrawals` or a global `setOperator` rotation, both of which stop every player. ECONOMY.md §12 moves a farm to `challenge` (withdrawals blocked) or `frozen` after signals fire; if that happens between signing and submission (the voucher is valid for whatever expiry the backend chose), the backend has no targeted remedy. (b) If the backend issues nonce 0 and nonce 1 for the same recipient and nonce 0 expires unspent, nonce 1 can never be spent: `WrongNonce(0, 1)` until the operator re-signs a voucher with nonce 0. If the backend instead treats the expired voucher as void and continues from nonce 2, the recipient is stuck forever. Fix: add a guardian-or-operator-callable `invalidateNonce(address to)` (or `bumpNonce`) that sets `nonces[to] = nonces[to] + 1`, so one recipient's queue can be cancelled or unstuck without pausing the whole vault. Keep the backend rule: never issue nonce n+1 until nonce n is spent or expired and re-signed.","line":125,"path":"src/FarmVault.sol","reproduction":"Sign voucher A = (player, 100e18, nonce 0, expiry t0+1h) and B = (player, 100e18, nonce 1, expiry t0+30d). Warp to t0+2h. `withdraw` with A reverts VoucherExpired; `withdraw` with B reverts WrongNonce(0, 1) and will keep reverting for 30 days. Separately: sign voucher C with expiry 7 days, then decide off-chain the farm is frozen; there is no call that revokes C alone, and `withdraw` with C still pays 100e18 (scratch tests test_expiredVoucherBlocksTheQueue and test_noPerVoucherRevocation pass on current code).","severity":"low","snippet":"        if (nonce != expected) revert WrongNonce(expected, nonce);","title":"No per-recipient revocation or nonce skip: a signed voucher cannot be cancelled, and an expired voucher blocks later ones for that recipient"},{"citation":"resolved","description":"Both VestingWallets start at `block.timestamp` of the launch transaction and vest linearly from the first second with no cliff. ECONOMY.md §11.7 lists both slices as 'Liquid? No' and states 'the circulating float at launch is the 15 % in the pool', and gate 6 budgets net exits at 15 % of pool ECU per 7 days. With a linear 1-year community vest, 41,095.89 ECU is releasable after one day and 1,232,876 ECU after 30 days; the 3-year team vest adds 13,698.63 per day. Together that is 54,794 ECU/day of non-game supply, roughly 1.7x the ~31,000 ECU/day Exchange budget that the whole game economy is tuned around, and `release()` is callable by anyone. The community schedule of one year appears nowhere in the spec (§11.7 only says 'Vesting contract'). This is a spec-versus-implementation gap rather than a code bug: if the intent is 'nothing liquid outside the pool for a period', pass a later `startAt` (cliff) for each wallet and record the community duration in ECONOMY.md; otherwise amend §11.7 to say both slices unlock linearly from launch. The existing test test_nothingHasVestedAtLaunch only checks the exact launch second.","line":87,"path":"script/Distribute.s.sol","reproduction":"deployAll(to, START); vm.warp(START + 1 days); teamVesting.releasable(ecu) == 13_698.630136986301369863e18, communityVesting.releasable(ecu) == 41_095.890410958904109589e18; vm.warp(START + 30 days): team + community releasable == 1_643_835.616438356164383561e18. Expected per §11.7 'Liquid? No': 0 until an agreed cliff. Actual: linear from second one (scratch test test_vestingDayOne prints these values).","severity":"low","snippet":"        deployment.communityVesting = new VestingWallet(to.communityBeneficiary, startAt, COMMUNITY_VESTING_SECONDS);","title":"Vesting wallets have no cliff: about 54,800 ECU of team plus community tokens become sellable on day 1, more than the daily Exchange budget"},{"citation":"resolved","description":"The guardian is described as the account that can 'pause withdrawals and rotate the operator', but rotation with no timelock means the guardian can appoint a key it controls and sign a voucher for the vault's full balance in the next transaction. This is intended by the design (fast rotation after a leak) and is not a bypass; it is recorded so the requester prices it correctly: the 2-of-3 Safe signers, not only the operator key, are a path to all 50 M ECU, and the combination with the single-step `setGuardian` means a guardian handover is also a custody handover. If this is more power than the guardian role should carry, a short timelock on `setOperator` (pause is still immediate) narrows it; otherwise document it.","line":170,"path":"src/FarmVault.sol","reproduction":"Guardian calls `setOperator(g)` where g is a key it holds, then anyone submits `withdraw(g, vault balance, nonces[g], expiry, sig_by_g)`: succeeds, g receives the entire custody (scratch test test_guardianCanAppointItselfOperatorAndDrain passes on current code).","severity":"info","snippet":"        operator = newOperator;","title":"Trust assumption: the guardian's setOperator power is full custody power, with no delay"},{"citation":"resolved","description":"DistributeTest calls `deployAll` directly, so the script contract is both the ECU distributor and the caller of every transfer; the broadcast path through `run()` (where sender and `address(this)` differ) is never executed and the address(this) defect is invisible to the suite. FarmVaultTest asserts that any amount up to the vault balance can be vouchered (testFuzz_anyAmountTheVaultHoldsCanBeVouchered), encoding the absence of an outflow bound as expected behaviour. Also untested: a signature with high-s (OZ rejects it; a regression test would pin that), a voucher submitted exactly at `expiry` (accepted, by design), vesting amounts after the launch second, and any multi-day sequence of withdrawals. Add a test that drives `run()` under broadcast, a test for the outflow cap once added, and day-1 vesting assertions.","line":40,"path":"test/Distribute.t.sol","reproduction":"`forge test` passes 41/41 on the current tree while `script.run()` reverts with ERC20InsufficientBalance under broadcast and a 50 M single-voucher withdrawal succeeds.","severity":"info","snippet":"        d = script.deployAll(to, START);","title":"Test suite does not exercise the paths where the two high findings live"}],"hash":"1723a989a7bde1b4967d45637e31c730f2bef86c99e9d7a5c7eb23772298ed0e","nodeId":"cd115565-4546-42f5-b42d-0fe615808ce3","outcome":"completed","summary":"The review is complete. The findings file is written and every path, line and snippet was checked against the tree. No tracked file was changed.\n\n**Outcome:** seven findings in `.imd-findings.json`, two of them high with failing Foundry proofs under `test/scratch/`.\n\n- **High: the launch script cannot be broadcast.** `new ECU(address(this))` in the Distribute script mints the whole supply to the ephemeral script address. Under broadcast the transfers come from the deployer, which holds nothing. Forge aborts with its address(this) guard, and the proof test reverts with ERC20InsufficientBalance. The existing tests miss it because they call `deployAll` directly rather than `run()`.\n- **High: no outflow bound in FarmVault.** A single voucher from the hot operator key moves all 50 M ECU in one transaction, while ECONOMY.md bounds legitimate daily outflow to roughly 31,000 ECU and gate 6 to 2.25 M per week. Pause and rotation are reactive 2-of-3 Safe actions and cannot land first. My recommendation is a guardian-settable rolling-window cap, not a cap equal to B alone, because deposited balances must stay withdrawable.\n- **Low: single-step guardian handover** is irreversible on a wrong address and removes the only emergency control.\n- **Low: no per-recipient revocation or nonce skip.** A signed voucher cannot be cancelled for one farm, and an expired voucher strands later nonces for that recipient.\n- **Low: vesting has no cliff.** Team plus community release about 54,800 ECU on day one, above the daily Exchange budget, while §11.7 lists both as not liquid.\n- **Info:** the guardian's rotation power equals full custody power, recorded as a trust assumption. The suite does not exercise either high path.\n\n**Checked and clean:** replay protection, nonce ordering, expiry boundary, domain separator with chain id and contract address, high-s malleability rejection by OpenZeppelin ECDSA, operator rotation invalidating old vouchers, allocation math with zero remainder, and the absence of any owner on ECU. Slither and long fuzz runs were not run.","treeHash":null,"usage":{"cachedInputTokens":891485,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":28469,"runtime":"claude","turns":18,"wallClockMs":411011}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"75052237a39b6e12","findings":[{"citation":"resolved","description":"run() wraps deployAll() in vm.startBroadcast(). Under broadcast every call the script makes is re-issued from the broadcaster EOA, but `address(this)` is still the script contract, which exists only inside the forge simulation and never on chain. So the ECU constructor mints all 100,000,000 ECU to the script contract, and the five `deployment.ecu.transfer(...)` calls on lines 90-94 are sent from the broadcaster, whose ECU balance is zero. Two consequences: (1) with forge 1.8.x the script aborts before the first transaction with \"Usage of `address(this)` detected in script contract\"; (2) under any tooling that does not have that guard, the simulation reverts with ERC20InsufficientBalance(broadcaster, 0, 15e24) after the ECU creation, and had the creation alone been broadcast the entire supply would sit at an address nobody controls, permanently. Either way the README's documented launch command (`forge script script/Distribute.s.sol:Distribute --rpc-url base_sepolia --broadcast`) cannot distribute anything. test/Distribute.t.sol never notices because it calls deployAll() directly, outside broadcast, where the script contract really is the msg.sender of the transfers. Fix: read the broadcaster after startBroadcast with `(, address deployer,) = vm.readCallers();` (or take it as a parameter), mint to `deployer` (`new ECU(deployer)`), and assert `ecu.balanceOf(deployer) == 0` at the end instead of `address(this)`; keep deployAll's test path by passing the same address in.","line":84,"path":"script/Distribute.s.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Distribute} from \"script/Distribute.s.sol\";\n\n/// @notice The launch script must be runnable the way README.md says it is run:\n///         `forge script script/Distribute.s.sol:Distribute --broadcast`. That path goes\n///         through `run()`, which wraps `deployAll` in `vm.startBroadcast()`. Under\n///         broadcast the script contract is ephemeral, so `new ECU(address(this))` would\n///         mint the supply to an address that does not exist on chain, and forge refuses\n///         the script outright. This test calls `run()` exactly as the deployer would.\ncontract DistributeBroadcastTest is Test {\n    function test_runDistributesUnderBroadcast() public {\n        vm.setEnv(\"LIQUIDITY_ADDRESS\", \"0x0000000000000000000000000000000000000011\");\n        vm.setEnv(\"COMMUNITY_BENEFICIARY\", \"0x0000000000000000000000000000000000000022\");\n        vm.setEnv(\"TEAM_BENEFICIARY\", \"0x0000000000000000000000000000000000000033\");\n        vm.setEnv(\"LISTINGS_MULTISIG\", \"0x0000000000000000000000000000000000000044\");\n        vm.setEnv(\"VAULT_OPERATOR\", \"0x0000000000000000000000000000000000000055\");\n        vm.setEnv(\"VAULT_GUARDIAN\", \"0x0000000000000000000000000000000000000066\");\n\n        Distribute script = new Distribute();\n        Distribute.Deployment memory d = script.run();\n\n        assertEq(d.ecu.balanceOf(address(0x11)), 15_000_000e18, \"liquidity slice\");\n        assertEq(d.ecu.balanceOf(address(d.vault)), 50_000_000e18, \"vault slice\");\n        assertEq(d.ecu.balanceOf(address(script)), 0, \"nothing left on the script\");\n    }\n}","reproduction":"Set the six env vars to any non-zero addresses and run `forge script script/Distribute.s.sol:Distribute` (no RPC needed, dry run). Expected: traces showing ECU, FarmVault and two VestingWallets deployed and 15/15/15/5/50 M transferred. Actual (forge 1.8.3): `Error: script failed: Usage of address(this) detected in script contract. Script contracts are ephemeral and their addresses should not be relied upon.` The attached test calls run() under the same broadcast semantics inside forge test and fails with ERC20InsufficientBalance(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, 0, 15000000000000000000000000): the broadcaster holds none of the supply the script contract was minted.","severity":"high","snippet":"        deployment.ecu = new ECU(address(this));","title":"Distribute.run() cannot broadcast: ECU supply is minted to the ephemeral script contract, not the deployer"},{"citation":"resolved","description":"withdraw() checks pause, nonce, expiry and the operator signature and then transfers any `amount` the signature covers, with no per-transaction, per-recipient or per-window limit and no relation to the vault's balance beyond the ERC-20 revert. The operator is, by the project's own description (.env.example: \"the backend's hot key\"), an online key held by a server. ECONOMY.md bounds every legitimate exit: §3.3(3) \"The reserve's only outflow is the Exchange, bounded by B = R·ln2/730 + EMA30(coin-purchase ECU)\" (about 33,000 ECU/day at a 35 M reserve), §1 a per-farm daily cap of max(500, 1.5×EMA7) plus a 48 h delay, and §9 gate 6 net withdrawals over any 7 days ≤ 15 % of the pool's ECU side (2.25 M at launch). None of these survive a key compromise: the attacker signs `to=attacker, amount=50_000_000e18, nonce=0`, submits it, and the 35 M reserve plus the 15 M Bank pool, half the token supply, are gone before the 2-of-3 Safe can assemble signatures to pause. The guardian's pause is reactive and the Safe's latency is measured in minutes to hours; the drain takes one block. The existing suite codifies the absence of a bound: test/FarmVault.t.sol testFuzz_anyAmountTheVaultHoldsCanBeVouchered asserts that any amount up to the entire vault balance clears on one voucher. Recommended fix, preserving the design (operator signs, guardian pauses): a rolling-window outflow cap in the contract, e.g. `uint256 public dailyLimit` and `(uint64 windowStart, uint192 spentInWindow)`, checked in withdraw() with `spentInWindow + amount <= dailyLimit`, set by the guardian (the Safe) and defaulted near the §3.0b Exchange budget plus an allowance for `deposited`-bucket exits, which §1 says are always withdrawable and are not covered by B. Optionally also a per-voucher maximum. The trade-off is that a legitimate day with unusual exits needs the guardian to raise the limit; that is the direction the spec says to fail in (gate 6 would already have the backend throttling). Answer to the requester's question: yes, a cap equal to the daily Exchange budget alone would be too tight because player deposits must remain withdrawable, so size it as B plus a deposited-exit allowance, or make it guardian-settable with B as the default.","line":136,"path":"src/FarmVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ECU} from \"src/ECU.sol\";\nimport {FarmVault} from \"src/FarmVault.sol\";\n\n/// @notice Blast radius of the operator hot key. ECONOMY.md §3.3(3) says the reserve's\n///         only outflow is the Exchange, bounded by B = R·ln2/730 (+ EMA30 of inflows):\n///         about 33,000 ECU a day against a 35 M reserve. §9 gate 6 bounds a week of net\n///         withdrawals to 15 % of the pool's ECU side (2.25 M at launch). The contract\n///         enforces no bound at all: one voucher signed by a leaked operator key moves the\n///         whole 50 M in one transaction, before the guardian's 2-of-3 Safe can pause.\ncontract VaultOutflowCapTest is Test {\n    ECU internal ecu;\n    FarmVault internal vault;\n    uint256 internal operatorKey = 0xA11CE5;\n    address internal guardian = address(0x6A12D);\n    address internal thief = address(0xBAD);\n\n    // The launch balance of the vault per §11.7: Co-op reserve 35 % + Bank pool 15 %.\n    uint256 internal constant VAULT_LAUNCH_BALANCE = 50_000_000e18;\n    // The spec's weekly bound on net exits: 15 % of the 15 M ECU LP slice (§9 gate 6).\n    uint256 internal constant WEEKLY_EXIT_BOUND = 2_250_000e18;\n\n    function setUp() public {\n        ecu = new ECU(address(this));\n        vault = new FarmVault(ecu, vm.addr(operatorKey), guardian);\n        ecu.transfer(address(vault), VAULT_LAUNCH_BALANCE);\n    }\n\n    /// @dev A single voucher for the entire custody must not clear. Any per-day or\n    ///      per-window outflow cap at the scale the spec describes makes this revert.\n    function test_oneVoucherCannotDrainTheWholeVault() public {\n        uint256 expiry = block.timestamp + 1 hours;\n        bytes memory sig = _sign(thief, VAULT_LAUNCH_BALANCE, 0, expiry);\n\n        vm.expectRevert();\n        vault.withdraw(thief, VAULT_LAUNCH_BALANCE, 0, expiry, sig);\n\n        assertEq(ecu.balanceOf(thief), 0, \"custody drained in one transaction\");\n    }\n\n    /// @dev Splitting the drain into many vouchers in one block must not help either:\n    ///      within one day the vault must not pay out more than the spec's weekly bound.\n    function test_manyVouchersInOneDayStayUnderTheSpecBound() public {\n        uint256 expiry = block.timestamp + 1 hours;\n        uint256 chunk = WEEKLY_EXIT_BOUND; // 2.25 M per voucher\n        uint256 paid;\n        for (uint256 n = 0; n < 4; n++) {\n            bytes memory sig = _sign(thief, chunk, n, expiry);\n            (bool ok,) = address(vault).call(\n                abi.encodeWithSelector(FarmVault.withdraw.selector, thief, chunk, n, expiry, sig)\n            );\n            if (ok) paid += chunk;\n        }\n        assertLe(paid, WEEKLY_EXIT_BOUND, \"one day's outflow exceeds the spec's weekly bound\");\n    }\n\n    function _sign(address to, uint256 amount, uint256 nonce, uint256 expiry) internal view returns (bytes memory) {\n        bytes32 digest = vault.voucherDigest(to, amount, nonce, expiry);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKey, digest);\n        return abi.encodePacked(r, s, v);\n    }\n}","reproduction":"State: vault funded with 50_000_000e18 (the §11.7 launch balance). Input: operator signs WithdrawalVoucher(to=0xBAD, amount=50_000_000e18, nonce=0, expiry=now+1h); anyone calls withdraw(0xBAD, 50_000_000e18, 0, expiry, sig). Expected (per ECONOMY §3.3(3) and §9 gate 6): rejected as exceeding any daily or weekly outflow bound. Actual: succeeds, balanceOf(0xBAD) == 50_000_000e18, vault balance 0. Splitting into four 2.25 M vouchers (nonces 0..3) in one block likewise pays 9 M in one day against a spec weekly bound of 2.25 M. The attached test shows both; it fails on the current code and passes once any window cap below the vault balance is enforced.","severity":"high","snippet":"        ecu.safeTransfer(to, amount);","title":"No on-chain outflow bound: one voucher from a leaked operator hot key moves the whole 50 M ECU custody in a single transaction"},{"citation":"resolved","description":"setGuardian() overwrites the only privileged role in a single call after a zero-address check. Any other wrong value (a typo, an EOA instead of the new Safe, a Safe on a different chain, a contract that cannot send transactions) takes effect immediately and there is no pending/accept step, no recovery path and no other role that could restore it. After such a mistake nobody can pause withdrawals or rotate the operator, so a later operator-key leak cannot be contained at all, and the finding above (no outflow cap) becomes unrecoverable rather than merely slow to react to. The function is also the one that will be exercised at the most delicate moment, migrating from the launch Safe to a new signing policy. Fix: two-step transfer (pendingGuardian set by the current guardian, acceptGuardian() called by the new address), i.e. the Ownable2Step pattern on this role; optionally the same for setOperator so a wrongly rotated operator can be confirmed before old vouchers are invalidated.","line":177,"path":"src/FarmVault.sol","reproduction":"State: guardian = 0x6A12D. Input: guardian calls setGuardian(0x5AfE0000000000000000000000000000000000AB), an address nobody controls (one character off from the intended Safe). Expected: the new guardian must first prove control by accepting, or the old guardian keeps the role until then. Actual: guardian() == 0x5AfE...AB immediately; pauseWithdrawals() and setGuardian(oldGuardian) from 0x6A12D now revert NotGuardian(); the vault has no guardian forever. Reproduced in test/scratch/GuardianEdges.t.sol test_mistypedHandoverIsIrrecoverable.","severity":"medium","snippet":"        guardian = newGuardian;","title":"Guardian hand-over is one-step: a mistyped address permanently removes pause and operator rotation"},{"citation":"resolved","description":"The guardian's tools are pause/unpause (global, does not touch vouchers) and setOperator (invalidates every unspent voucher, legitimate ones included). There is no way to invalidate a single voucher or to advance one recipient's nonce. If the backend, with a still-trusted key, signs a wrong voucher (a bug in the toll or cap logic, a wrong amount), pausing buys time but the voucher is still redeemable the moment withdrawals are unpaused, until its expiry; the guardian's only remedy is a full key rotation, which also breaks every in-flight honest voucher and requires the backend to reissue the queue. With long expiries (the contract accepts any expiry, including type(uint256).max) a bad voucher stays live indefinitely. Fix: a guardian (or operator) function `voidNonce(address to)` / `bumpNonce(address to)` that increments nonces[to], or a `cancelled[digest]` set the guardian can write, checked in withdraw().","line":159,"path":"src/FarmVault.sol","reproduction":"State: operator signs WithdrawalVoucher(to=0xF00D, amount=10_000_000e18, nonce=0, expiry=now+30d) by mistake. Guardian calls pauseWithdrawals(), investigates, calls unpauseWithdrawals() without rotating the key. Input: anyone calls withdraw(0xF00D, 10_000_000e18, 0, expiry, sig). Expected: the guardian had a way to void that specific voucher during the pause. Actual: the transfer succeeds, balanceOf(0xF00D) == 10_000_000e18. Reproduced in test/scratch/GuardianEdges.t.sol test_pauseThenUnpauseLeavesBadVoucherRedeemable.","severity":"low","snippet":"        withdrawalsPaused = false;","title":"Pause does not retire a mis-signed voucher; the only way to cancel one voucher is to rotate the whole operator key"},{"citation":"resolved","description":"ECONOMY.md §9's parameter table reads \"15 % DEX LP (locked 2 y)\" and §11.7 says the LP slice is \"the only ECU anyone can buy\". The script transfers the 15 M to a plain address with no vesting wallet, timelock or pool-position lock, and the README's list of deliberate omissions (not creating the pool, not splitting the reserve) does not mention the 2-year lock. Day one therefore has a freely transferable 15 M ECU balance outside any contract, which contradicts the launch-float statement (\"the circulating float at launch is the 15 % in the pool\") until the pool is actually created and the position locked by some other, unspecified, process. Fix: either document the lock as an off-script launch step with its mechanism (LP-NFT locker, VestingWallet over the LP position) in README/ECONOMY, or route the slice through a VestingWallet(liquidity, start, 2 * 365 days) if the lock is meant to be on the tokens rather than the position.","line":90,"path":"script/Distribute.s.sol","reproduction":"State: deployAll() just ran. Observation: ecu.balanceOf(LIQUIDITY_ADDRESS) == 15_000_000e18 and LIQUIDITY_ADDRESS is an arbitrary env-supplied address with no lock; `vm.prank(liquidity); ecu.transfer(anyone, 15_000_000e18)` succeeds at block.timestamp == start. Expected per §9: the LP allocation is locked for 2 years.","severity":"low","snippet":"        deployment.ecu.transfer(to.liquidity, _slice(LP_SHARE));","title":"The 15 % liquidity slice is sent unlocked to an EOA although ECONOMY.md §9 specifies \"DEX LP (locked 2 y)\""},{"citation":"resolved","description":"README and the NatSpec describe the guardian as the party that may \"pause withdrawals and rotate the operator\". Because setOperator accepts any address and there is no delay, the guardian is also, in effect, a full custodian: setOperator(guardianKey) followed by a self-signed voucher moves any amount. This is the intended power structure (the Safe must be able to rotate to a key it chooses), so it is documented here as a trust assumption for the 2-of-3 Safe signers rather than a defect. If the design wants the guardian to be weaker than the operator, add a timelock on setOperator so that a rotation is observable before it is usable.","line":170,"path":"src/FarmVault.sol","reproduction":"State: guardian is an EOA with key 0x6A12D (stands in for a Safe). Input: guardian calls setOperator(guardian), signs WithdrawalVoucher(to=guardian, amount=1_000_000e18, nonce=0, expiry=now+1h), calls withdraw. Actual: succeeds, balanceOf(guardian) == 1_000_000e18. Reproduced in test/scratch/GuardianEdges.t.sol test_guardianCanBecomeOperatorAndDrain.","severity":"info","snippet":"        operator = newOperator;","title":"Guardian can appoint itself operator and sign a voucher for the full custody (trust assumption, not a bypass)"},{"citation":"resolved","description":"Nonces are consumed in order with no gaps allowed. This is documented and is the correct replay guard, but the operational consequence should be stated in the backend's design: if the operator signs voucher n for a recipient and it expires (or the recipient never submits it), voucher n+1 and everything after it is unredeemable until the operator reissues a voucher with nonce n. The backend therefore must never advance its own nonce counter for a recipient until the chain's nonces[to] has moved (watch Withdrawn events or read nonces(to) before signing), and must be prepared to reissue with the same nonce. No contract change is required; an alternative design (bitmap of used nonces, as in Permit2 / Uniswap's unordered nonces) removes the ordering dependency at the cost of the 'cannot be reordered' property the NatSpec wants.","line":125,"path":"src/FarmVault.sol","reproduction":"State: operator signed voucher (to=0xF00D, nonce=0, expiry=t0+1h) and (to=0xF00D, nonce=1, expiry=t0+30d); the first was never submitted. At t0+2h: withdraw with nonce 1 reverts WrongNonce(0, 1) and nonce 0 reverts VoucherExpired; the recipient is stuck until a fresh nonce-0 voucher is signed. Reproduced in test/scratch/GuardianEdges.t.sol test_expiredLowerNonceBlocksLaterVouchers.","severity":"info","snippet":"        if (nonce != expected) revert WrongNonce(expected, nonce);","title":"Strict sequential per-recipient nonce: one expired or unredeemed voucher blocks every later voucher for that recipient until the backend reissues"},{"citation":"resolved","description":"ECONOMY.md §9 and §11.7 give the team slice a 3-year schedule and say only \"Vesting contract\" for the community slice; the script chooses 365 days linear from deployment with no cliff, and its comment says \"unless overridden\" although nothing can override it (the constant is internal and run() takes no parameter). Both VestingWallets start at block.timestamp of the deployment transaction and vest linearly from the first second, so by day 30 the community beneficiary can release about 1.23 M ECU and the team about 411 k ECU, which the launch-float statement in §11.7 (\"everything else is either vesting, held, or game-side\") does not call out. Not a code defect; the spec should either state the community schedule or the script should match whatever is decided, and the 'unless overridden' comment should go.","line":43,"path":"script/Distribute.s.sol","reproduction":"State: deployAll(to, START). Observation: communityVesting.duration() == 365 days, communityVesting.cliff does not exist (OpenZeppelin VestingWallet is linear from start). vm.warp(START + 30 days): communityVesting.releasable(ecu) == 15_000_000e18 * 30 / 365 ≈ 1_232_876e18. Expected: a number ECONOMY.md commits to.","severity":"info","snippet":"    uint64 internal constant COMMUNITY_VESTING_SECONDS = 365 days;","title":"Community vesting schedule (1 year, no cliff) is a script constant that ECONOMY.md never specifies"}],"hash":"22dc59ea9325b40d9daedfa33465699ca3a38f15ce9e86d6b65a18a133e7a654","nodeId":"2e4dad1d-605d-47ee-a2de-8e30abc21ed6","outcome":"completed","summary":"Review complete. The findings file is written at `.imd-findings.json` with eight findings, two of them carrying Foundry proofs that fail on the current code. No repository files were changed; only the findings file and scratch tests under `test/scratch/` were added.\n\n**What I found**\n\n- **High, launch script cannot run.** `Distribute.run()` wraps the distribution in `vm.startBroadcast()` but passes `address(this)` as the ECU distributor. Under broadcast that is the ephemeral script contract, so the supply mints to an address that never exists on chain and the transfers from the broadcaster revert. Forge 1.8.3 aborts the script outright before any transaction. The existing tests miss it because they call `deployAll()` outside broadcast. Proof test attached.\n- **High, no outflow bound in the vault.** A leaked operator hot key can move the whole 50 M ECU custody in one voucher. ECONOMY.md bounds legitimate exits to roughly 33k ECU a day from the reserve and 2.25 M a week overall, and the guardian's pause is reactive. The suite even asserts that any amount up to the full balance clears. I recommend a guardian-settable rolling-window cap defaulting near the Exchange budget plus a deposited-exit allowance, since a cap equal to B alone would block players' own deposits. Proof test attached.\n- **Medium, one-step guardian hand-over.** A mistyped `setGuardian` address permanently removes pause and operator rotation. Reproduced in scratch.\n- **Low, pause cannot void a single bad voucher.** Only a full key rotation does, which also breaks every honest in-flight voucher. Reproduced in scratch.\n- **Low, LP slice unlocked.** Spec says the 15 % DEX LP is locked two years. The script sends it to a plain address.\n- **Info.** Guardian can appoint itself operator and drain (trust assumption). Strict sequential nonces mean one expired voucher blocks later ones for that recipient until reissued. The community one-year vesting schedule is a script constant the spec never states.\n\n**What checked out clean**\n\nReplay, cross-chain and cross-contract replay, expiry boundary, domain separator, high-s malleability, zero-address recovery, reentrancy, allocation math, leftover tokens, and the token having no owner or hooks. OpenZeppelin 5.7 also rejects compact 64-byte signatures, so that path is closed.\n\n**Tools run**\n\nForge build, the existing 41-test suite, a local dry run of the launch script, and the scratch tests. No static analyzer was available or used.","treeHash":null,"usage":{"cachedInputTokens":1141441,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":29638,"runtime":"claude","turns":26,"wallClockMs":443626}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d63ea36a2b809080","findings":[{"citation":"resolved","description":"`deployAll` passes `address(this)` as the ECU distributor. Inside a Foundry script `address(this)` is the script contract, which exists only in the simulation; under `vm.startBroadcast()` every subsequent call (the `new ECU`, the five `transfer`s) is sent from the broadcaster EOA, not from the script contract. The whole 100 M is therefore minted to an address that holds no key and has no code on chain, and the first `transfer` reverts with ERC20InsufficientBalance(broadcaster, 0, 15e24). Forge 1.8.3 goes further and refuses the script at `vm.startBroadcast()`: \"Usage of `address(this)` detected in script contract. Script contracts are ephemeral and their addresses should not be relied upon.\" The existing DistributeTest never sees this because it calls `deployAll` directly (no broadcast), where msg.sender really is the script contract. Consequence: the only implementation of the §11.7 distribution cannot be executed with `forge script --broadcast`; if the forge guard were bypassed (`--skip-simulation`, an older forge) the ECU deployment would land on chain with 100 % of supply at an address nobody controls, unrecoverably. Fix: mint to the broadcaster (e.g. `(, address deployer,) = vm.readCallers(); new ECU(deployer);` and check `balanceOf(deployer) == 0` at the end), or deploy a one-shot distributor contract that holds and splits the supply inside one transaction, and add a test that exercises `run()` under broadcast.","line":84,"path":"script/Distribute.s.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Distribute} from \"script/Distribute.s.sol\";\n\n/// @notice The broadcast entry point `run()` must distribute the whole supply, exactly as\n///         `deployAll` does when a test calls it directly. Today it cannot: `deployAll` mints\n///         the supply to `address(this)` -- the ephemeral script contract -- and under\n///         `vm.startBroadcast()` the transfers are sent from the broadcaster, which holds\n///         nothing. Forge additionally refuses the script outright (\"Usage of `address(this)`\n///         detected in script contract\").\ncontract DistributeRunTest is Test {\n    uint256 internal constant TOTAL = 100_000_000e18;\n\n    address internal liquidity = address(0x11);\n    address internal community = address(0x22);\n    address internal team = address(0x33);\n    address internal listings = address(0x44);\n    address internal operator = address(0x55);\n    address internal guardian = address(0x66);\n\n    function setUp() public {\n        vm.setEnv(\"LIQUIDITY_ADDRESS\", vm.toString(liquidity));\n        vm.setEnv(\"COMMUNITY_BENEFICIARY\", vm.toString(community));\n        vm.setEnv(\"TEAM_BENEFICIARY\", vm.toString(team));\n        vm.setEnv(\"LISTINGS_MULTISIG\", vm.toString(listings));\n        vm.setEnv(\"VAULT_OPERATOR\", vm.toString(operator));\n        vm.setEnv(\"VAULT_GUARDIAN\", vm.toString(guardian));\n    }\n\n    function test_runDistributesTheWholeSupplyUnderBroadcast() public {\n        Distribute script = new Distribute();\n\n        // The same entry point `forge script ... --broadcast` executes.\n        Distribute.Deployment memory d = script.run();\n\n        assertEq(d.ecu.balanceOf(liquidity), 15_000_000e18, \"liquidity\");\n        assertEq(d.ecu.balanceOf(address(d.communityVesting)), 15_000_000e18, \"community\");\n        assertEq(d.ecu.balanceOf(address(d.teamVesting)), 15_000_000e18, \"team\");\n        assertEq(d.ecu.balanceOf(listings), 5_000_000e18, \"listings\");\n        assertEq(d.ecu.balanceOf(address(d.vault)), 50_000_000e18, \"vault\");\n        assertEq(d.ecu.balanceOf(address(script)), 0, \"ephemeral script address must hold nothing\");\n        assertEq(d.ecu.totalSupply(), TOTAL);\n    }\n}","reproduction":"Input: the exact README command `forge script script/Distribute.s.sol:Distribute --rpc-url base_sepolia --broadcast` with any non-zero addresses in .env (e.g. LIQUIDITY_ADDRESS=0x..11, COMMUNITY_BENEFICIARY=0x..22, TEAM_BENEFICIARY=0x..33, LISTINGS_MULTISIG=0x..44, VAULT_OPERATOR=0x..55, VAULT_GUARDIAN=0x..66). Expected: ECU, FarmVault and two VestingWallets deployed and 100 M split 15/15/15/5/50 with the deployer holding 0. Actual (forge 1.8.3, `forge script script/Distribute.s.sol:Distribute` with those env vars): revert at vm.startBroadcast() with \"Usage of `address(this)` detected in script contract\". Actual when the same `run()` is driven from a test (test/scratch/DistributeRun.t.sol): the ECU constructor emits Transfer(0x0 -> Distribute script address, 1e26) and `ECU.transfer(liquidity, 1.5e25)` reverts with ERC20InsufficientBalance(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, 0, 1.5e25). Nothing is distributed.","severity":"high","snippet":"        deployment.ecu = new ECU(address(this));","title":"Launch script mints the supply to the ephemeral script address, so run() cannot broadcast"},{"citation":"resolved","description":"The only bound on `amount` in `withdraw` is `amount != 0`. The operator is, per .env.example, the backend's hot key. ECONOMY.md bounds legitimate game-side outflow tightly: the Exchange budget B = R*ln2/730 (about 33,230 ECU/day for R = 35 M; the run-10 simulation reports ~31,000/day), a per-farm daily cap of max(500, 1.5*EMA7(revenue)) (§1), and gate 6: net ECU withdrawn over any rolling 7 days <= 15 % of the pool's ECU side = 2.25 M (§9). The contract enforces none of this, so the ratio between the largest legitimate day and what one voucher can move is roughly 1,500x. The guardian pause is reactive and human-paced (a 2-of-3 Safe); the drain is a single transaction submitted by anyone holding a signature, so the pause can never arrive in time. The existing fuzz test `testFuzz_anyAmountTheVaultHoldsCanBeVouchered` asserts exactly this unbounded behaviour. The requester asked whether the vault should enforce a per-day cap equal to the daily Exchange budget: a cap exactly equal to B would be too tight, because `deposited` balances (bridged in by players) are always withdrawable and are not Exchange outflow; the cap should cover B plus a trailing-window allowance for deposits (or simply gate 6's 2.25 M / 7 days), be guardian-adjustable, and be checked before the transfer. That keeps the ECONOMY.md rules off-chain while bounding the blast radius of the hot key to one day's budget instead of 50 % of supply.","line":121,"path":"src/FarmVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ECU} from \"src/ECU.sol\";\nimport {FarmVault} from \"src/FarmVault.sol\";\n\n/// @notice Blast radius of a leaked operator key. ECONOMY.md bounds the game-side outflow\n///         (the Exchange budget B = R*ln2/730, about 33,000 ECU/day at launch, and gate 6:\n///         net withdrawals over 7 days <= 15 % of the pool's ECU side = 2.25 M). The vault\n///         enforces no ceiling at all: one voucher signed by the hot key moves the entire\n///         50 M custody in a single transaction, before any guardian can pause.\ncontract VaultOutflowCapTest is Test {\n    ECU internal ecu;\n    FarmVault internal vault;\n\n    uint256 internal operatorKey = 0xA11CE5;\n    address internal operator;\n    address internal guardian = address(0x6A12D);\n    address internal attacker = address(0xBAD);\n\n    uint256 internal constant GAME_SIDE = 50_000_000e18;\n\n    function setUp() public {\n        operator = vm.addr(operatorKey);\n        ecu = new ECU(address(this));\n        vault = new FarmVault(ecu, operator, guardian);\n        ecu.transfer(address(vault), GAME_SIDE); // the launch state of the vault (§11.7)\n    }\n\n    /// @dev A single voucher for the whole custody must not clear. Any per-day / per-voucher\n    ///      ceiling sized anywhere near the spec's daily budget makes this revert.\n    function test_oneVoucherCannotDrainTheEntireCustody() public {\n        uint256 expiry = block.timestamp + 1 hours;\n        bytes32 digest = vault.voucherDigest(attacker, GAME_SIDE, 0, expiry);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKey, digest);\n\n        vm.expectRevert();\n        vault.withdraw(attacker, GAME_SIDE, 0, expiry, abi.encodePacked(r, s, v));\n\n        assertEq(ecu.balanceOf(address(vault)), GAME_SIDE, \"custody drained in one transaction\");\n        assertEq(ecu.balanceOf(attacker), 0);\n    }\n}","reproduction":"State: FarmVault funded with 50_000_000e18 ECU (the §11.7 launch balance), operator key known to the attacker. Input: withdraw(attacker, 50_000_000e18, 0, block.timestamp + 1 hours, sig) where sig is the operator's signature over voucherDigest(attacker, 50_000_000e18, 0, expiry). Expected: a single voucher for 1,500x the daily budget (and 22x the spec's weekly gate-6 ceiling) is refused. Actual: Withdrawn(attacker, 5e25, 0) is emitted and ECU.transfer moves 50,000,000 ECU to the attacker; vault balance 0. See test/scratch/VaultOutflowCap.t.sol (fails on current code with 'next call did not revert as expected').","severity":"high","snippet":"        if (amount == 0) revert ZeroAmount();","title":"No per-voucher or per-day outflow ceiling: a leaked operator key drains the whole 50 M custody in one transaction"},{"citation":"resolved","description":"The guardian is the only address that can pause withdrawals, unpause, rotate the operator or hand over. `setGuardian` writes the new address immediately with only a zero-address check. A hand-over to a mistyped address, to a Safe that exists only on another chain, or to a contract with no way to call the vault leaves custody of 50 M ECU with no pause and no key rotation, forever: there is no recovery path and no upgrade. In that state a leaked operator key cannot be contained at all, which compounds the missing outflow cap. The reference checklists treat two-step ownership transfer as the baseline for a role with this power. Fix: pending-guardian + `acceptGuardian()` (the Ownable2Step shape), so the outgoing guardian keeps its powers until the new one proves it can transact.","line":177,"path":"src/FarmVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ECU} from \"src/ECU.sol\";\nimport {FarmVault} from \"src/FarmVault.sol\";\n\n/// @notice Guardian hand-over is single-step. A hand-over to an address nobody controls\n///         (a typo, a Safe deployed on another chain, a contract without an exec path)\n///         permanently removes the only pause and the only operator-rotation power from\n///         the custody of 50 M ECU. With a two-step hand-over the outgoing guardian keeps\n///         its powers until the new one accepts.\ncontract GuardianHandoverTest is Test {\n    ECU internal ecu;\n    FarmVault internal vault;\n\n    address internal operator = address(0x0A);\n    address internal guardian = address(0x6A12D);\n    address internal mistyped = address(0xDEAD);\n\n    function setUp() public {\n        ecu = new ECU(address(this));\n        vault = new FarmVault(ecu, operator, guardian);\n        ecu.transfer(address(vault), 50_000_000e18);\n    }\n\n    function test_outgoingGuardianKeepsItsPowersUntilTheNewOneAccepts() public {\n        vm.prank(guardian);\n        vault.setGuardian(mistyped);\n\n        // Nobody has accepted at `mistyped`, so the outgoing guardian must still be able to\n        // stop the exit. Today this reverts with NotGuardian and nothing can ever undo it.\n        vm.prank(guardian);\n        vault.pauseWithdrawals();\n        assertTrue(vault.withdrawalsPaused());\n    }\n}","reproduction":"State: vault deployed with guardian G. Input: G calls setGuardian(0x000000000000000000000000000000000000dEaD) (any address whose key nobody holds), then G calls pauseWithdrawals(). Expected (two-step): the hand-over is pending, G is still guardian and the pause succeeds. Actual: pauseWithdrawals reverts NotGuardian(); 0xdEaD can never call anything; setOperator/pause/unpause are unreachable permanently. See test/scratch/GuardianHandover.t.sol (fails on current code with NotGuardian()).","severity":"medium","snippet":"        guardian = newGuardian;","title":"setGuardian is single-step: a hand-over to an uncontrolled address permanently removes pause and operator rotation"},{"citation":"resolved","description":"ECONOMY.md §12 blocks withdrawals at risk >= 70 and makes a freeze at >= 90 terminal, but the chain has no per-recipient or per-voucher kill switch: `expiry` is whatever the backend signed (no on-chain maximum lifetime), nonces only advance by a successful withdrawal, and the only levers are the global pause and a global operator rotation that invalidates every honest player's queue too. The operator can only 'cancel' a voucher by signing and submitting a competing 1-wei voucher at the same nonce, which the holder can front-run on the public mempool. Fix: enforce `expiry <= block.timestamp + MAX_VOUCHER_LIFETIME` on chain, and give the operator (or guardian) a `bumpNonce(address to)` so a specific queue can be invalidated without pausing everyone.","line":122,"path":"src/FarmVault.sol","reproduction":"State: operator signs voucher (to=P, amount=1_000e18, nonce=0, expiry=type(uint256).max) while P is in good standing; the game later freezes P (risk >= 90). Input: anyone submits withdraw(P, 1_000e18, 0, type(uint256).max, sig) at any later time. Expected per §12: the withdrawal is blocked. Actual: `block.timestamp > expiry` is false, nonce matches, signature valid -> 1,000 ECU paid to P. The only on-chain counter-move is pauseWithdrawals(), which stops every other player as well.","severity":"low","snippet":"        if (block.timestamp > expiry) revert VoucherExpired(expiry);","title":"A signed voucher cannot be revoked and its expiry is unbounded, so a farm frozen after signing is still paid"},{"citation":"resolved","description":"OpenZeppelin VestingWallet vests linearly from `start` with no cliff. With start = deploy time, `releasable` for the team wallet is 15 M * elapsed / (3*365 days): about 13,698.6 ECU after one day, about 1.23 M after 90 days, and the community wallet (1 year) unlocks three times faster: about 41,096 ECU/day. §11.7 lists both slices as 'Liquid? No' at launch and §9 says '3 y vest'; whether a cliff was intended is not written down anywhere, so this is reported as an unstated decision rather than a defect in the arithmetic. The existing test `test_nothingHasVestedAtLaunch` only checks the exact start second. If a cliff is wanted, OZ 5.x ships `VestingWalletCliff`; the shares and the 3-year/1-year durations are otherwise exact and leave no remainder (100 M * 15 / 100 is integral).","line":88,"path":"script/Distribute.s.sol","reproduction":"State: deployAll(to, START). Input: vm.warp(START + 1 days); teamVesting.releasable(address(ecu)). Expected if the slice is meant to be illiquid at launch: 0 during an initial cliff. Actual: 13_698_630_136_986_301_369_863 wei (~13,698.63 ECU) releasable by the team beneficiary via the permissionless `release(address)`; communityVesting.releasable at the same instant is ~41,095.89 ECU.","severity":"low","snippet":"        deployment.teamVesting = new VestingWallet(to.teamBeneficiary, startAt, TEAM_VESTING_SECONDS);","title":"Team and community vesting have no cliff: the team slice starts unlocking the second it is deployed"},{"citation":"resolved","description":"ECONOMY.md §9 describes the LP allocation as '15 % DEX LP (locked 2 y)' and §11.7 says the circulating float at launch is exactly this 15 %. The script transfers 15 M ECU to LIQUIDITY_ADDRESS with no lock, no vesting wallet and no requirement that the address be a contract. Deferring pool creation is a documented choice, but it means nothing on chain or in the script enforces the 2-year lock or that the tokens ever reach a pool; the holder of LIQUIDITY_ADDRESS can sell or move the whole float on day one. Fix options that keep the design: send the slice to a timelock/escrow contract the pool creator draws from, or document in ECONOMY.md that the 2-year lock applies to the Uniswap V4 position after pairing and is an off-chain operational commitment, and name who enforces it.","line":90,"path":"script/Distribute.s.sol","reproduction":"State: deployAll with LIQUIDITY_ADDRESS = any EOA. Input: that EOA calls ecu.transfer(anyone, 15_000_000e18) in the next block. Expected per §9: the LP slice is locked for 2 years. Actual: the transfer succeeds; balanceOf(liquidity) == 15_000_000e18 immediately after the script with no restriction.","severity":"low","snippet":"        deployment.ecu.transfer(to.liquidity, _slice(LP_SHARE));","title":"The 15 % DEX liquidity slice is sent unlocked to an EOA although §9 specifies it as locked for 2 years"},{"citation":"resolved","description":"Documented powers, not bypasses. (a) The guardian can call setOperator to a key it holds and then sign a voucher for the entire balance; combined with the missing outflow cap this is unrestricted, untimelocked control of 50 M ECU by whoever controls the 2-of-3 Safe. (b) The guardian can pause withdrawals indefinitely; deposits keep flowing in while paused, so players can be trapped one-way. (c) Voucher verification uses ECDSA.tryRecover only (65-byte signatures, high-s rejected, zero-address rejected because operator != 0): there is no ERC-1271 path, so if VAULT_OPERATOR is ever set to a contract (a Safe, an HSM wrapper contract) no voucher can validate until the guardian rotates to an EOA. (d) Nonces are per-recipient and strictly sequential; a voucher the holder never submits blocks that recipient's queue until the operator re-issues the same nonce, which the backend must handle. These are consistent with the stated design; they are listed so the launch runbook can state them.","line":170,"path":"src/FarmVault.sol","reproduction":"(a) guardian calls setOperator(K) with K its own key, signs voucher (guardian, 50_000_000e18, 0, expiry), calls withdraw -> vault empty in two transactions with no delay. (c) deploy FarmVault(ecu, address(someSafe), guardian): every withdraw reverts BadSignature because tryRecover never returns a contract address. Both run as written on the current code; neither is a permission bypass.","severity":"info","snippet":"        operator = newOperator;","title":"Trust assumptions: the guardian has full, instant control of custody; the operator must be an EOA"}],"hash":"8bfd08db6fdf4c165a517c72f2bdf282358a7878d4c38f6463f652ec7032a749","nodeId":"7249f92e-a351-4447-94a4-1439ac6a6294","outcome":"completed","summary":"The review is complete. The findings file is written at `.imd-findings.json` with seven findings, three of them carrying Foundry proofs under `test/scratch/` that fail on the current code. No repository source was changed.\n\n**What I found**\n\n- **High, Distribute.s.sol line 84.** The launch script mints the supply to `address(this)`, the ephemeral script contract. Under `vm.startBroadcast()` the transfers are sent from the broadcaster, which holds nothing, so the first transfer reverts. Forge 1.8.3 refuses the script outright at `startBroadcast` for exactly this reason. The existing test suite never catches it because it calls `deployAll` directly. Proof test fails with `ERC20InsufficientBalance`; I confirmed it passes under a candidate fix that mints to the broadcaster.\n- **High, FarmVault.sol line 121.** The only bound on a voucher's amount is \"not zero\". One voucher signed by the backend hot key moves the entire game-side custody in a single transaction, before a 2-of-3 Safe could ever pause. ECONOMY.md bounds legitimate daily outflow at roughly 33,000 ECU and weekly net withdrawals at 2.25 M, so the on-chain allowance is about 1,500 times the spec. The finding explains why a cap exactly equal to the Exchange budget would be too tight, since deposited balances are withdrawable but are not Exchange outflow.\n- **Medium, FarmVault.sol line 177.** Guardian hand-over is single-step. A hand-over to an uncontrolled address permanently removes the only pause and operator-rotation powers.\n- **Low.** No voucher revocation and no on-chain maximum expiry, so a farm frozen after signing is still paid. Vesting has no cliff, so the team slice unlocks about 13,699 ECU per day from the deploy second. The 15 % LP slice goes to a plain address with nothing enforcing the 2-year lock that §9 states.\n- **Info.** Trust assumptions: the guardian can rotate the operator to itself and drain without delay, and voucher verification has no ERC-1271 path, so the operator must be an EOA.\n\n**What held up under review**\n\nReplay, nonce ordering, expiry boundary, the EIP-712 domain, signature malleability and zero-address recovery are all handled correctly through OpenZeppelin's ECDSA and EIP712. Rotation invalidates old vouchers immediately. The share arithmetic is exact with no remainder, and ECU genuinely has no owner or mint path.","treeHash":null,"usage":{"cachedInputTokens":1433244,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":29692,"runtime":"claude","turns":33,"wallClockMs":424702}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a7ac9f7a7687c25e","findings":[{"citation":"resolved","description":"run() wraps deployAll() in vm.startBroadcast()/vm.stopBroadcast(). Under broadcast every recorded transaction is sent by the broadcaster EOA, but deployAll() mints the whole 100,000,000 ECU to address(this) (the script contract, which exists only in the simulation) and then calls ecu.transfer(...) five times as the broadcaster, who holds zero. forge aborts the script immediately ('Usage of `address(this)` detected in script contract'); without that guard the first transfer reverts with ERC20InsufficientBalance(broadcaster, 0, 15e24). The launch entry point therefore does nothing. The existing suite only calls deployAll() directly from the test contract, where the caller happens to equal address(this), so run() has never been exercised. If a deployer broadcasts with --skip-simulation, the ECU CREATE is sent with distributor = an address nobody holds a key for and the follow-up transfers revert on chain: the entire fixed supply is stranded permanently and the token must be redeployed. Fix: make the distribution atomic from an account that actually holds the supply, e.g. a one-shot distributor contract deployed in the single broadcast transaction whose constructor deploys ECU to itself, deploys the vault and the two VestingWallets, transfers the five slices and asserts a zero remainder (this also removes any window in which the deployer EOA holds 100 M). Add a test that calls run() with vm.setEnv so the broadcast path is covered.","line":84,"path":"script/Distribute.s.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Distribute} from \"script/Distribute.s.sol\";\n\n/// @notice The launch entry point actually broadcast is `run()`, not `deployAll()`.\n///         Under `vm.startBroadcast()` the sender of every recorded transaction is the\n///         broadcaster, but `deployAll` mints the whole supply to `address(this)` -- the\n///         ephemeral script contract -- and then transfers from the broadcaster, who holds\n///         nothing. Forge refuses the script outright; without that guard the first\n///         transfer would revert with ERC20InsufficientBalance. Either way nothing is\n///         distributed. This test fails on the code as received and passes once `run()`\n///         distributes from an account the broadcaster controls.\ncontract RunBroadcastTest is Test {\n    function test_runDistributesTheSupplyUnderBroadcast() public {\n        vm.setEnv(\"LIQUIDITY_ADDRESS\", \"0x0000000000000000000000000000000000000011\");\n        vm.setEnv(\"COMMUNITY_BENEFICIARY\", \"0x0000000000000000000000000000000000000022\");\n        vm.setEnv(\"TEAM_BENEFICIARY\", \"0x0000000000000000000000000000000000000033\");\n        vm.setEnv(\"LISTINGS_MULTISIG\", \"0x0000000000000000000000000000000000000044\");\n        vm.setEnv(\"VAULT_OPERATOR\", \"0x0000000000000000000000000000000000000055\");\n        vm.setEnv(\"VAULT_GUARDIAN\", \"0x0000000000000000000000000000000000000066\");\n\n        Distribute script = new Distribute();\n        Distribute.Deployment memory d = script.run();\n\n        assertEq(d.ecu.balanceOf(address(d.vault)), 50_000_000e18, \"vault should hold the game-side half\");\n        assertEq(d.ecu.balanceOf(address(0x11)), 15_000_000e18, \"liquidity slice\");\n        assertEq(d.ecu.balanceOf(address(d.communityVesting)), 15_000_000e18, \"community slice\");\n        assertEq(d.ecu.balanceOf(address(d.teamVesting)), 15_000_000e18, \"team slice\");\n        assertEq(d.ecu.balanceOf(address(0x44)), 5_000_000e18, \"listings slice\");\n        assertEq(d.ecu.balanceOf(address(script)), 0, \"nothing may stay in the ephemeral script\");\n    }\n}","reproduction":"Dry run with any non-zero addresses: `LIQUIDITY_ADDRESS=0x..11 COMMUNITY_BENEFICIARY=0x..22 TEAM_BENEFICIARY=0x..33 LISTINGS_MULTISIG=0x..44 VAULT_OPERATOR=0x..55 VAULT_GUARDIAN=0x..66 forge script script/Distribute.s.sol:Distribute`. Expected: five transfers simulated, vault holds 50,000,000e18, deployer holds 0. Actual: `Error: script failed: Usage of address(this) detected in script contract`. In a forge test (test/scratch/RunBroadcast.t.sol) the same call reverts with ERC20InsufficientBalance(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, 0, 15000000000000000000000000): the default broadcaster holds nothing because the mint went to the script address.","severity":"high","snippet":"        deployment.ecu = new ECU(address(this));","title":"Distribute.run() cannot distribute: supply is minted to the ephemeral script address while transfers are sent by the broadcaster"},{"citation":"resolved","description":"withdraw() checks only amount != 0, the nonce, the expiry and the signer. There is no per-voucher maximum, no per-recipient or per-period outflow limit, and no upper bound on expiry. ECONOMY.md bounds every game-side outflow: the Exchange budget B = R*ln2/730 is about 33,233 ECU/day on the 35 M launch reserve (the run-10 simulation shows ~31,000), the per-farm withdrawal cap is max(500, 1.5 x EMA7), and gate 6 requires net withdrawals over any 7 days to stay under 15 % of the pool's ECU side (2,250,000 ECU). The vault enforces none of these, so the blast radius of the operator key is the whole custody: a voucher (to=attacker, amount=50_000_000e18, nonce=0, expiry=type(uint256).max) is accepted in one transaction. The repository itself describes the operator as the backend hot key and 'the one key worth rotating on a schedule', the guardian can only pause after the fact, and Base has no public mempool in which to see the drain coming. Recommendation (keeps the agreed design, mirrors bounds already in the spec): add a rolling 24-hour outflow cap stored in the vault and adjustable by the guardian, sized to the Exchange budget with headroom (for example 2x B, roughly 70,000 ECU/day, or gate 6's 2.25 M per 7 days), and bound expiry (for example <= 7 days ahead). Trade-off: a legitimate surge queues until the next window or until the guardian raises the cap; a compromised key is limited to one window of budget instead of 50 M.","line":121,"path":"src/FarmVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ECU} from \"src/ECU.sol\";\nimport {FarmVault} from \"src/FarmVault.sol\";\n\n/// @notice Blast radius of the operator key. ECONOMY.md bounds every ECU outflow from the\n///         game side: the Exchange budget B = R * ln2 / 730 (~33,000 ECU/day on a 35 M\n///         reserve), the per-farm cap max(500, 1.5 x EMA7), and gate 6 (net withdrawals over\n///         any 7 days <= 15 % of the pool's ECU side = 2.25 M). The vault enforces none of\n///         them: one voucher signed by the operator moves the entire 50 M in a single\n///         transaction. This test fails on the code as received (the drain succeeds) and\n///         passes once the vault refuses an outflow above a per-period bound.\ncontract OutflowCapTest is Test {\n    ECU internal ecu;\n    FarmVault internal vault;\n\n    uint256 internal operatorKey = 0xA11CE5;\n    address internal operator;\n    address internal guardian = address(0x6A12D);\n    address internal attacker = address(0xBAD);\n\n    uint256 internal constant GAME_SIDE = 50_000_000e18;\n\n    function setUp() public {\n        operator = vm.addr(operatorKey);\n        ecu = new ECU(address(this));\n        vault = new FarmVault(ecu, operator, guardian);\n        ecu.transfer(address(vault), GAME_SIDE);\n    }\n\n    function test_oneVoucherCannotDrainTheGameSideHalf() public {\n        uint256 expiry = block.timestamp + 1 hours;\n        bytes32 digest = vault.voucherDigest(attacker, GAME_SIDE, 0, expiry);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKey, digest);\n        bytes memory signature = abi.encodePacked(r, s, v);\n\n        // Expected: a single voucher for 50,000,000 ECU -- 1,500x the daily Exchange\n        // budget and 22x the 7-day gate-6 bound -- is refused by the vault.\n        // Actual on the code as received: it pays out in full.\n        vm.expectRevert();\n        vault.withdraw(attacker, GAME_SIDE, 0, expiry, signature);\n\n        assertEq(ecu.balanceOf(address(vault)), GAME_SIDE, \"the vault must still hold the game-side half\");\n        assertEq(ecu.balanceOf(attacker), 0);\n    }\n}","reproduction":"State: vault holds 50,000,000e18 ECU (the launch allocation). Input: operator signs voucherDigest(attacker, 50_000_000e18, 0, block.timestamp+1h); anyone calls withdraw(attacker, 50_000_000e18, 0, expiry, sig). Expected per ECONOMY.md bounds: refused (1,500x the daily Exchange budget, 22x the 7-day gate-6 bound). Actual: succeeds, vault balance 0, attacker balance 50,000,000e18. test/scratch/OutflowCap.t.sol fails with 'next call did not revert as expected'.","severity":"high","snippet":"        if (amount == 0) revert ZeroAmount();","title":"No on-chain bound on vault outflow: one operator-signed voucher moves the entire 50 M game-side half"},{"citation":"resolved","description":"Both VestingWallets use OpenZeppelin's linear schedule with no cliff. _vestingSchedule returns 0 only while timestamp < start or exactly at start, which is the single instant test_nothingHasVestedAtLaunch checks (boundary x invariant seam). From start+1s the beneficiaries can release: 0.158 ECU (team) and 0.476 ECU (community) at start+1s; 13,698 ECU team + 41,095 ECU community = 54,794 ECU on day 1, which exceeds the whole daily Exchange budget (~31,000-33,000 ECU); 397,260 + 1,191,780 = 1,589,040 ECU by day 30. ECONOMY.md §11.7 marks both slices 'Liquid? No' at launch and §9 says 'team (3 y vest)' without specifying a cliff; ECONOMY.md gives no community vesting duration at all, the 1-year figure exists only as a script constant whose comment says 'unless overridden' although nothing can override it. The arithmetic itself is exact (15e24 * 86400 / 94,608,000 has no overflow and rounds down by at most 1 wei). Fix: either add a cliff (the vendored OpenZeppelin 5.7.0 ships finance/VestingWalletCliff.sol) sized to the launch policy, or amend §11.7 to state that vesting is linear from day one and that about 55k ECU per day becomes releasable; and make the community duration a spec value.","line":88,"path":"script/Distribute.s.sol","reproduction":"deployAll(..., START=1_800_000_000); vm.warp(START+1): teamVesting.releasable(ecu) = 158548959918822932 wei (> 0). vm.warp(START+1 days): team 13,698 ECU, community 41,095 ECU; release() moves them to the beneficiaries. Expected per §11.7: 0 liquid on day one. See test_vestingIsLiquidFromTheSecondAfterStart in test/scratch/Edges.t.sol (passes, logging the numbers).","severity":"low","snippet":"        deployment.teamVesting = new VestingWallet(to.teamBeneficiary, startAt, TEAM_VESTING_SECONDS);","title":"Team and community vesting are liquid from one second after start: no cliff, contradicting §11.7 'Liquid? No'"},{"citation":"resolved","description":"setGuardian writes the new guardian immediately with only a zero-address check. If the Safe passes a wrong address (typo, an address on another chain, a contract that cannot call back), the old guardian loses access in the same transaction and the new one never had it. The vault then has no way to pause withdrawals or rotate the operator for the rest of its life, which removes the only containment for a leaked operator key (and the only remedy for a lost one). Fix: two-step transfer (pendingGuardian + acceptGuardian from the new address), as in OpenZeppelin Ownable2Step which is vendored in lib/.","line":174,"path":"src/FarmVault.sol","reproduction":"guardian calls setGuardian(0x5AFF) intending 0x5AFE. Then guardian.pauseWithdrawals() reverts NotGuardian, guardian.setGuardian(guardian) reverts NotGuardian, vault.guardian() == 0x5AFF. Expected: the hand-over is not final until 0x5AFE accepts. See test_guardianHandoverIsOneStepAndIrrecoverable in test/scratch/Edges.t.sol.","severity":"low","snippet":"    function setGuardian(address newGuardian) external onlyGuardian {","title":"Single-step guardian handover: a mistyped address permanently removes pause and rotation"},{"citation":"resolved","description":"Validity is decided only by signer == operator at submission time; nothing in the digest or storage records which operator generation a voucher belongs to, and expiry has no upper bound. The comment says old-operator vouchers 'stop working immediately', but they are only dormant: a planned roll A -> B -> A, or re-installing a key believed clean, makes every unspent, unexpired voucher signed by A valid again, including ones a leaked copy of A produced with expiry = type(uint256).max. Fix: include an operator epoch (incremented in setOperator) in the signed struct, or keep a per-epoch nonce namespace; and reject expiry more than N days ahead.","line":170,"path":"src/FarmVault.sol","reproduction":"operator A signs (player, 1000e18, nonce 0, expiry max). guardian.setOperator(B): withdraw reverts BadSignature. guardian.setOperator(A): the same voucher now pays 1000e18. Expected: a voucher invalidated by rotation stays invalid. See test_rotatingBackRevivesOldVouchers in test/scratch/Edges.t.sol.","severity":"low","snippet":"        operator = newOperator;","title":"Rotating the operator back to a previous key revives every unspent voucher that key signed; expiry is unbounded"},{"citation":"resolved","description":"Once the operator has signed a voucher there is no on-chain way to cancel it short of the guardian rotating the operator, which invalidates every outstanding voucher for every recipient. ECONOMY.md §12 says withdrawals are blocked at risk >= 70 and that a freeze burns the earned bucket; if the risk state changes after a voucher has been handed out, the vault still pays until expiry. Issuing a corrected voucher for the same nonce does not help: whichever the holder submits first wins. Conversely, if voucher n expires unsubmitted (or the holder withholds it), vouchers n+1.. for that recipient are dead until the operator reissues n. Fix: add an operator-callable (or guardian-callable) cancel that advances nonces[to], or use unordered nonces with explicit cancel.","line":125,"path":"src/FarmVault.sol","reproduction":"Operator signs (player, 1000e18, 0, T+48h) then decides on 500e18 and signs (player, 500e18, 0, T+48h). Holder submits the first: player receives 1000e18; the corrected one reverts WrongNonce(1, 0). Head-of-line: sign (player, 100e18, 0, T+1h) and (player, 100e18, 1, T+48h); warp past T+1h: voucher 0 reverts VoucherExpired, voucher 1 reverts WrongNonce(0, 1). See test_aSignedVoucherCannotBeSuperseded and test_anExpiredNonceBlocksAllLaterVouchers in test/scratch/Edges.t.sol.","severity":"low","snippet":"        if (nonce != expected) revert WrongNonce(expected, nonce);","title":"A signed voucher cannot be revoked or superseded, and an unspent nonce blocks every later voucher for that recipient"},{"citation":"resolved","description":"Documented so the trust model is explicit, not as a permission bypass: setOperator has no delay, so whoever controls the guardian address controls the entire 50 M, not merely the pause switch. The 2-of-3 threshold lives in the Safe, outside this contract. If a narrower guardian is wanted, split the roles: pause stays instant, operator rotation takes effect after a delay the pauser can veto.","line":167,"path":"src/FarmVault.sol","reproduction":"guardian.setOperator(guardianEOA); guardianEOA signs (guardianEOA, vaultBalance, 0, now+1); withdraw pays the whole balance. See test_guardianCanBecomeOperatorAndDrain in test/scratch/Edges.t.sol.","severity":"info","snippet":"    function setOperator(address newOperator) external onlyGuardian {","title":"Guardian is full custody: it can appoint itself operator and sign a drain in two transactions (trust assumption)"},{"citation":"resolved","description":"The 15,000,000 ECU liquidity slice is transferred to LIQUIDITY_ADDRESS with no lock or vesting, and the pool is created later by hand. §9's parameter table calls this slice 'locked 2 y' and §11.7 says it sits in the Uniswap V4 pool on day one. The README documents the deviation deliberately, so this is a spec/implementation mismatch to resolve, not a code defect: either lock the LP position (or the slice until pairing) on chain, or amend §9.","line":90,"path":"script/Distribute.s.sol","reproduction":"deployAll(...): ecu.balanceOf(liquidity) == 15_000_000e18 and the holder can transfer it immediately (plain EOA balance). Expected per §9: locked for 2 years.","severity":"info","snippet":"        deployment.ecu.transfer(to.liquidity, _slice(LP_SHARE));","title":"LP slice is sent unlocked to a plain address; ECONOMY.md §9 says 'DEX LP (locked 2 y)'"}],"hash":"a76d83cb266e0510acabfad9bab5f4ae0005e1269ba7378b5fe48c7bd21a511f","nodeId":"29d1734b-44f0-43da-9f12-c670d3ca0987","outcome":"completed","summary":"Review complete. The findings are in `.imd-findings.json` at the repository root (8 findings, 2 with Foundry proofs). No source, config or library file was changed. The git working tree shows only untracked files: the findings file and my scratch tests under `test/scratch/`.\n\n**What I found**\n\n- **High, launch script.** `run()` in `script/Distribute.s.sol` cannot distribute anything. Under broadcast the transactions are sent by the deployer, but the supply is minted to the script contract's own ephemeral address. Forge aborts the dry run, and in a test harness the first transfer reverts with an insufficient-balance error for the default broadcaster. The existing tests only call the inner function directly, so the real entry point has never run. With simulation skipped, the whole 100 M supply would be stranded at an address nobody controls. Proof test fails now and passes with a one-shot distributor fix I verified in scratch.\n- **High, vault outflow.** The vault has no per-voucher, per-period or expiry bound. A single operator voucher drains all 50 M in one transaction. ECONOMY.md bounds daily game-side outflow at roughly 33k ECU and 7-day net withdrawals at 2.25 M, so the on-chain blast radius is about 1,500 times the spec's daily figure. The proof test asserts a full-vault voucher is refused and fails on current code. Recommended a guardian-adjustable rolling 24-hour cap plus an expiry bound.\n- **Low, vesting.** Both vesting wallets are linear with no cliff. The only moment they hold zero is exactly the start timestamp, which is the one instant the suite checks. One day in, 54,794 ECU is releasable, more than the daily Exchange budget, against §11.7's \"Liquid? No\".\n- **Low, guardian handover.** Single-step with no acceptance. A wrong address permanently removes pause and operator rotation.\n- **Low, rotation.** Rotating back to a previous key revives all its unspent vouchers, and expiry has no upper bound.\n- **Low, revocation.** A signed voucher cannot be cancelled or superseded, and an unspent nonce blocks all later vouchers for that recipient.\n- **Info.** Guardian is effectively full custody via self-appointment as operator. The LP slice is sent unlocked despite §9 saying \"locked 2 y\".\n\n**Math coverage.** The arithmetic itself is clean: the five slices are exact with zero remainder, the vesting formula cannot overflow and rounds down by at most 1 wei, the nonce increment and expiry comparison are safe, and ECDSA malleability and the domain separator are handled by the vendored OpenZeppelin 5.7.0. The original suite of 41 tests still passes unchanged.","treeHash":null,"usage":{"cachedInputTokens":1255125,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":33630,"runtime":"claude","turns":34,"wallClockMs":464751}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"05f71cd1a5206480","findings":[{"citation":"resolved","description":"run() wraps deployAll() in vm.startBroadcast(). Under broadcast every CREATE and CALL is sent from the broadcaster EOA, but address(this) is still the script contract, which exists only inside forge's simulation and never on chain. The ECU constructor therefore mints all 100,000,000 ECU to that phantom address, and the five transfer calls on lines 90-94 are sent by the broadcaster, which holds 0 ECU. Forge 1.8.3 refuses the script at vm.startBroadcast() with 'Usage of address(this) detected in script contract'; under tooling without that guard the first transfer reverts with ERC20InsufficientBalance(broadcaster, 0, 15e24), and if the ECU creation alone were ever landed (for example with --skip-simulation) the whole fixed supply would sit at an address with no key and no code, permanently. The README's documented launch command therefore cannot distribute anything. test/Distribute.t.sol never sees this because it calls deployAll() directly from the test contract, outside broadcast, where the caller really is the script contract. Fix (keeps the §11.7 split and the no-owner token): mint to an account that actually sends the transfers, e.g. `(, address deployer,) = vm.readCallers();` after startBroadcast and `new ECU(deployer)` plus `require(ecu.balanceOf(deployer) == 0)` at the end; or deploy a one-shot distributor contract whose constructor creates the ECU to itself and performs the five transfers atomically so no EOA ever holds the supply. Add a test that drives run() under broadcast. Merged from four specialist reports (permissions, economics, flow, math), all reproduced.","line":84,"path":"script/Distribute.s.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Distribute} from \"script/Distribute.s.sol\";\n\n/// @notice The broadcast entry point `run()` must distribute the whole supply, exactly as\n///         `deployAll` does when a test calls it directly. Today it cannot: `deployAll` mints\n///         the supply to `address(this)` -- the ephemeral script contract -- and under\n///         `vm.startBroadcast()` the transfers are sent from the broadcaster, which holds\n///         nothing. Forge additionally refuses the script outright (\"Usage of `address(this)`\n///         detected in script contract\").\ncontract DistributeRunTest is Test {\n    uint256 internal constant TOTAL = 100_000_000e18;\n\n    address internal liquidity = address(0x11);\n    address internal community = address(0x22);\n    address internal team = address(0x33);\n    address internal listings = address(0x44);\n    address internal operator = address(0x55);\n    address internal guardian = address(0x66);\n\n    function setUp() public {\n        vm.setEnv(\"LIQUIDITY_ADDRESS\", vm.toString(liquidity));\n        vm.setEnv(\"COMMUNITY_BENEFICIARY\", vm.toString(community));\n        vm.setEnv(\"TEAM_BENEFICIARY\", vm.toString(team));\n        vm.setEnv(\"LISTINGS_MULTISIG\", vm.toString(listings));\n        vm.setEnv(\"VAULT_OPERATOR\", vm.toString(operator));\n        vm.setEnv(\"VAULT_GUARDIAN\", vm.toString(guardian));\n    }\n\n    function test_runDistributesTheWholeSupplyUnderBroadcast() public {\n        Distribute script = new Distribute();\n\n        // The same entry point `forge script ... --broadcast` executes.\n        Distribute.Deployment memory d = script.run();\n\n        assertEq(d.ecu.balanceOf(liquidity), 15_000_000e18, \"liquidity\");\n        assertEq(d.ecu.balanceOf(address(d.communityVesting)), 15_000_000e18, \"community\");\n        assertEq(d.ecu.balanceOf(address(d.teamVesting)), 15_000_000e18, \"team\");\n        assertEq(d.ecu.balanceOf(listings), 5_000_000e18, \"listings\");\n        assertEq(d.ecu.balanceOf(address(d.vault)), 50_000_000e18, \"vault\");\n        assertEq(d.ecu.balanceOf(address(script)), 0, \"ephemeral script address must hold nothing\");\n        assertEq(d.ecu.totalSupply(), TOTAL);\n    }\n}","reproduction":"Dry run: `LIQUIDITY_ADDRESS=0x..11 COMMUNITY_BENEFICIARY=0x..22 TEAM_BENEFICIARY=0x..33 LISTINGS_MULTISIG=0x..44 VAULT_OPERATOR=0x..55 VAULT_GUARDIAN=0x..66 forge script script/Distribute.s.sol:Distribute` (forge 1.8.3). Expected: ECU, FarmVault and two VestingWallets deployed and 15/15/15/5/50 M transferred with the deployer holding 0. Actual: `Error: script failed: Usage of address(this) detected in script contract. Script contracts are ephemeral and their addresses should not be relied upon.` In a Foundry test that sets the six env vars and calls script.run() (attached proof), the same path reverts with ERC20InsufficientBalance(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, 0, 15000000000000000000000000): the broadcaster holds none of the supply that was minted to the script address. The existing suite passes 41/41 because it only calls deployAll() directly.","severity":"high","snippet":"        deployment.ecu = new ECU(address(this));","title":"Distribute.run() mints the supply to the ephemeral script address, so the launch cannot be broadcast (and would strand 100 M ECU if it were)"},{"citation":"resolved","description":"withdraw() checks pause, zero address, amount != 0, expiry, nonce and the operator signature, then transfers whatever amount was signed. There is no per-voucher maximum, no per-day or rolling-window cap, no fraction-of-balance bound and no upper bound on expiry. The operator is, per .env.example, 'the backend's hot key'. ECONOMY.md bounds every legitimate exit far below the custody: §3.3(3) 'The reserve's only outflow is the Exchange, bounded by B = R·ln2/730 + EMA30' (about 33,000 ECU/day at R = 35 M), §1 caps each farm at max(500, 1.5×EMA7(daily_revenue)) per day behind a 48 h delay, and §9 gate 6 requires net ECU withdrawn over any rolling 7 days to stay under 15 % of the pool's ECU side (2.25 M at launch). None of that survives a key compromise: the attacker signs to=attacker, amount=50_000_000e18, nonce=0 and submits it in one block. The guardian's controls (pause, setOperator) are reactive and need a 2-of-3 Safe execution, which cannot land before a single drain transaction; the suite's testFuzz_anyAmountTheVaultHoldsCanBeVouchered codifies the unbounded behaviour as expected. Answer to priority (2): yes, an on-chain bound is warranted, but a cap exactly equal to the daily Exchange budget B would be too tight because §1 says the `deposited` bucket is always withdrawable and those exits are not Exchange outflow. Recommended minimal fix that preserves the operator/guardian design: a guardian-settable rolling-window cap (e.g. `maxOutflowPerWindow` over 24 h, sized to B plus a deposit-exit allowance, or gate 6's 2.25 M per 7 days) checked in withdraw() before the transfer, optionally plus a per-voucher ceiling and a maximum expiry horizon. A legitimate surge then waits for the next window or a guardian raise; a leaked key is limited to one window's budget instead of half the supply. Merged from four specialist reports, all reproduced.","line":136,"path":"src/FarmVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ECU} from \"src/ECU.sol\";\nimport {FarmVault} from \"src/FarmVault.sol\";\n\n/// @notice Blast radius of a leaked operator key. ECONOMY.md bounds the game-side outflow\n///         (the Exchange budget B = R*ln2/730, about 33,000 ECU/day at launch, and gate 6:\n///         net withdrawals over 7 days <= 15 % of the pool's ECU side = 2.25 M). The vault\n///         enforces no ceiling at all: one voucher signed by the hot key moves the entire\n///         50 M custody in a single transaction, before any guardian can pause.\ncontract VaultOutflowCapTest is Test {\n    ECU internal ecu;\n    FarmVault internal vault;\n\n    uint256 internal operatorKey = 0xA11CE5;\n    address internal operator;\n    address internal guardian = address(0x6A12D);\n    address internal attacker = address(0xBAD);\n\n    uint256 internal constant GAME_SIDE = 50_000_000e18;\n\n    function setUp() public {\n        operator = vm.addr(operatorKey);\n        ecu = new ECU(address(this));\n        vault = new FarmVault(ecu, operator, guardian);\n        ecu.transfer(address(vault), GAME_SIDE); // the launch state of the vault (§11.7)\n    }\n\n    /// @dev A single voucher for the whole custody must not clear. Any per-day / per-voucher\n    ///      ceiling sized anywhere near the spec's daily budget makes this revert.\n    function test_oneVoucherCannotDrainTheEntireCustody() public {\n        uint256 expiry = block.timestamp + 1 hours;\n        bytes32 digest = vault.voucherDigest(attacker, GAME_SIDE, 0, expiry);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKey, digest);\n\n        vm.expectRevert();\n        vault.withdraw(attacker, GAME_SIDE, 0, expiry, abi.encodePacked(r, s, v));\n\n        assertEq(ecu.balanceOf(address(vault)), GAME_SIDE, \"custody drained in one transaction\");\n        assertEq(ecu.balanceOf(attacker), 0);\n    }\n}","reproduction":"State: FarmVault funded with 50_000_000e18 ECU (exactly what Distribute.s.sol deposits), operator = vm.addr(0xA11CE5). Input: sig = operator's EIP-712 signature over voucherDigest(0xBAD, 50_000_000e18, 0, block.timestamp + 1 hours); anyone calls withdraw(0xBAD, 50_000_000e18, 0, expiry, sig). Expected under ECONOMY.md §3.3 / §9 gate 6: refused as roughly 1,500x the daily budget and 22x the weekly bound. Actual: Withdrawn(0xBAD, 5e25, 0) is emitted, balanceOf(0xBAD) == 50_000_000e18, vault balance 0, in one transaction. The attached proof fails on the current code with 'next call did not revert as expected' and passes once any window or per-voucher cap below the vault balance is enforced.","severity":"high","snippet":"        ecu.safeTransfer(to, amount);","title":"FarmVault enforces no outflow bound: one voucher from the hot operator key moves the entire 50 M ECU custody in a single transaction"},{"citation":"resolved","description":"The guardian is the only account that can pause, unpause, rotate the operator or hand over. setGuardian writes the new address immediately after a zero-address check only; there is no pending/accept step, no timelock and no other role that could restore it. A mistyped address, a Safe that exists only on another chain, or a contract that cannot send transactions takes effect at once and leaves the 50 M custody with a live operator key and no emergency control for the life of the contract, which also makes the missing outflow cap unrecoverable rather than merely slow to react to. The function will be exercised at the most delicate moment (migrating from the launch Safe). Fix: two-step hand-over (`pendingGuardian` set by the current guardian, `acceptGuardian()` called by the new address), the Ownable2Step shape vendored in lib/, so the outgoing guardian keeps its powers until the new one proves it can transact. The one-address/2-of-3 Safe design is unchanged. Merged from four specialist reports (two rated medium, two low); reproduced.","line":177,"path":"src/FarmVault.sol","reproduction":"State: vault with guardian G. Input: G calls setGuardian(0x1234) where nobody holds 0x1234's key. Then G calls pauseWithdrawals() and setGuardian(G). Expected: hand-over pending until 0x1234 accepts, G still able to pause. Actual: guardian() == 0x1234 immediately; both calls from G revert NotGuardian(); 0x1234 can never call anything, so pause and setOperator are unreachable forever. Reproduced in test/scratch/Judge.t.sol test_singleStepGuardianHandoverIsFinal (passes on current code).","severity":"medium","snippet":"        guardian = newGuardian;","title":"setGuardian is single-step: a wrong address permanently removes the only pause and operator-rotation control"},{"citation":"resolved","description":"Validity depends only on the current operator having signed, block.timestamp <= expiry (no on-chain maximum lifetime, type(uint256).max is accepted) and the nonce matching nonces[to], which only advances on a successful withdrawal. Three consequences. (a) Once a voucher is signed the only on-chain ways to stop it are the global pause (which only delays it until unpause) or a global setOperator rotation that invalidates every honest in-flight voucher too; ECONOMY.md §12 blocks withdrawals at risk >= 70 and freezes at >= 90, so a farm frozen after signing is still paid until expiry. (b) A corrected voucher at the same nonce does not supersede the first: whichever the holder submits first wins. (c) If voucher n for a recipient expires or is withheld, vouchers n+1.. are dead until the operator re-signs n; if the backend instead treats the expired voucher as void and moves on, the recipient is stuck forever. Fix: add a guardian-or-operator-callable `bumpNonce(address to)` (or a cancelled-digest set) so one recipient's queue can be voided without pausing everyone, and bound expiry on chain (e.g. `expiry <= block.timestamp + MAX_VOUCHER_LIFETIME`). The backend rule 'never advance the counter until nonces[to] has moved on chain' should be written down regardless. Merged from six specialist entries across four reports; all reproduced.","line":125,"path":"src/FarmVault.sol","reproduction":"(a) Operator signs (player, 10_000_000e18, nonce 0, expiry = type(uint256).max). Guardian calls pauseWithdrawals() then unpauseWithdrawals(). Ten years later anyone calls withdraw with that voucher: succeeds, balanceOf(player) == 10_000_000e18. (b) Sign (player, 1000e18, 0, T+48h) then a corrected (player, 500e18, 0, T+48h); submit the first: player receives 1000e18, the corrected one reverts WrongNonce(1, 0). (c) Sign (player, 100e18, 0, t0+1h) and (player, 100e18, 1, t0+30d); warp to t0+2h: nonce 0 reverts VoucherExpired(t0+1h), nonce 1 reverts WrongNonce(0, 1). Reproduced in test/scratch/Judge.t.sol tests test_noPerVoucherRevocation, test_aSignedVoucherCannotBeSuperseded, test_expiredVoucherBlocksTheQueue (all pass on current code).","severity":"low","snippet":"        if (nonce != expected) revert WrongNonce(expected, nonce);","title":"A signed voucher cannot be revoked, expiry is unbounded, and one unspent nonce blocks every later voucher for that recipient"},{"citation":"resolved","description":"The NatSpec says vouchers signed by the old operator 'stop working immediately', but nothing in the digest or storage records which operator generation a voucher belongs to; validity is decided only by signer == operator at submission time. A planned roll A -> B -> A, or re-installing a key believed clean, makes every unspent, unexpired voucher signed by A valid again, including ones a leaked copy of A produced with expiry = type(uint256).max (see the unbounded-expiry finding). Fix: include an operator epoch (incremented in setOperator) in the signed struct or in a per-epoch nonce namespace, so a voucher invalidated by rotation stays invalid. Reported by the math specialist; reproduced.","line":170,"path":"src/FarmVault.sol","reproduction":"Operator A signs (player, 1000e18, nonce 0, expiry max). Guardian calls setOperator(B): withdraw with the voucher reverts BadSignature. Guardian calls setOperator(A): the same voucher now pays 1000e18 (balanceOf(player) == 1000e18). Expected: a voucher invalidated by rotation stays invalid. Reproduced in test/scratch/Judge.t.sol test_rotatingBackRevivesOldVouchers (passes on current code).","severity":"low","snippet":"        operator = newOperator;","title":"Rotating the operator back to a previous key revives every unspent voucher that key ever signed"},{"citation":"resolved","description":"Both OpenZeppelin VestingWallets start at the launch block's timestamp and vest linearly from the first second with no cliff; release(token) is permissionless. ECONOMY.md §11.7 lists both slices as 'Liquid? No' and states 'the circulating float at launch is the 15 % in the pool'; §9 says 'team (3 y vest)' and gives no community duration at all, so the 365-day constant on line 43 (whose comment says 'unless overridden' although nothing can override it) exists only in the script. After one day 13,698.63 ECU (team) + 41,095.89 ECU (community) = 54,794 ECU is releasable, about 1.7x the ~31,000-33,000 ECU/day Exchange budget the whole economy is tuned around; after 30 days about 1.64 M. The share arithmetic itself is exact. This is a spec-versus-implementation gap, not an arithmetic bug: either pass a later `startAt` or use the vendored OpenZeppelin VestingWalletCliff with a cliff matching the launch policy and record the community duration in ECONOMY.md, or amend §11.7 to say both slices unlock linearly from launch. The existing test test_nothingHasVestedAtLaunch checks only the exact launch second. Merged from five specialist entries; reproduced.","line":88,"path":"script/Distribute.s.sol","reproduction":"deployAll(to, START); vm.warp(START + 1): teamVesting.releasable(ecu) > 0. vm.warp(START + 1 days): teamVesting.releasable(ecu) == 13698630136986301369863 wei, communityVesting.releasable(ecu) == 41095890410958904109589 wei; vm.prank(0xDEAD); communityVesting.release(ecu) moves the community amount to the beneficiary. Expected per §11.7 'Liquid? No': 0 until an agreed cliff. Reproduced in test/scratch/Judge.t.sol test_vestingDayOneAndLpUnlocked (passes on current code and logs the amounts).","severity":"low","snippet":"        deployment.teamVesting = new VestingWallet(to.teamBeneficiary, startAt, TEAM_VESTING_SECONDS);","title":"Vesting wallets have no cliff: about 54,800 ECU of team plus community tokens become releasable on day one, and the 1-year community schedule is not in the spec"},{"citation":"resolved","description":"ECONOMY.md §9's parameter table reads '15 % DEX LP (locked 2 y)' and §11.7 says this slice sits in the Uniswap V4 pool on day one and is 'the only ECU anyone can buy'. The script transfers 15 M ECU to LIQUIDITY_ADDRESS with no lock, vesting wallet or requirement that the address be a contract; the README's list of deliberate omissions (no pool creation, no reserve split) does not mention the 2-year lock. Nothing on chain or in the script enforces that the tokens reach a pool or stay locked, so the holder of LIQUIDITY_ADDRESS can move or sell the entire launch float immediately. Fix options that keep the design: route the slice through an escrow/timelock the pool creator draws from, or document in ECONOMY.md/README that the 2-year lock applies to the V4 position after pairing as an off-chain operational step and name who enforces it. Merged from three specialist entries; reproduced.","line":90,"path":"script/Distribute.s.sol","reproduction":"deployAll with liquidity = 0x11 (an EOA). vm.prank(0x11); ecu.transfer(0x99, 15_000_000e18) succeeds at block.timestamp == start; balanceOf(0x99) == 15_000_000e18. Expected per §9: the LP allocation is locked for 2 years. Reproduced in test/scratch/Judge.t.sol test_vestingDayOneAndLpUnlocked.","severity":"low","snippet":"        deployment.ecu.transfer(to.liquidity, _slice(LP_SHARE));","title":"The 15 % DEX liquidity slice is sent unlocked to a plain address although ECONOMY.md §9 specifies 'DEX LP (locked 2 y)'"},{"citation":"resolved","description":"OpenZeppelin 5.7 VestingWallet is Ownable with the beneficiary as owner, and release(token) pays owner(). .env.example states 'Beneficiaries of the two vesting contracts. These addresses cannot be changed afterwards', which is false: the beneficiary can call transferOwnership (OZ's own NOTE: 'it is possible to sell unvested tokens'), and can call renounceOwnership, after which owner() == address(0) and every release(token) reverts with ERC20InvalidReceiver, locking the remaining 15 M slice permanently with no recovery. Neither property is a contract bug, but the launch runbook and the spec's 'team (3 y vest)' commitment assume the beneficiary cannot move the schedule to a buyer. Fix: document that the beneficiary is a transferable owner and must be a Safe, or wrap VestingWallet with renounceOwnership overridden to revert (and transferOwnership restricted) so the 3-year team commitment is not escapable or accidentally bricked. Judge's own finding; reproduced.","line":87,"path":"script/Distribute.s.sol","reproduction":"deployAll(to, START) with teamBeneficiary = 0x33. vm.prank(0x33); teamVesting.transferOwnership(0x77): owner() == 0x77 (expected per .env.example: not changeable). vm.prank(0x77); teamVesting.renounceOwnership(); vm.warp(START + 30 days); teamVesting.release(ecu) reverts (transfer to address(0)); the 15 M remains unreachable. Reproduced in test/scratch/Judge.t.sol test_vestingBeneficiaryCanRenounceAndStrand (passes on current code).","severity":"low","snippet":"        deployment.communityVesting = new VestingWallet(to.communityBeneficiary, startAt, COMMUNITY_VESTING_SECONDS);","title":"VestingWallet beneficiaries can be changed or renounced, contradicting .env.example, and renouncing strands the slice forever"},{"citation":"resolved","description":"Documented powers, not bypasses. (a) setOperator has no delay, so whoever controls the guardian address can appoint a key it holds and sign a voucher for the whole balance in the next transaction; combined with the missing outflow cap, the 2-of-3 Safe signers are a path to all 50 M ECU, and a guardian hand-over is also a custody hand-over. If a narrower guardian is wanted, put a short timelock on setOperator while keeping pause instant. (b) Verification uses ECDSA.tryRecover only (65-byte signatures, high-s rejected, zero signer rejected since operator != 0); there is no ERC-1271 path, so if VAULT_OPERATOR is ever set to a contract (Safe, HSM wrapper) no voucher can validate until the guardian rotates to an EOA. (c) The guardian can pause withdrawals indefinitely while deposits keep flowing, so players can be trapped one-way. These match the stated design and should appear in the launch runbook. Merged from three specialist entries; reproduced.","line":170,"path":"src/FarmVault.sol","reproduction":"(a) guardian calls setOperator(g) with g a key it holds, then withdraw(g, 50_000_000e18, 0, expiry, sig_by_g) succeeds and g holds the entire custody (test/scratch/Judge.t.sol test_guardianCanAppointItselfOperatorAndDrain). (b) new FarmVault(ecu, address(aContract), guardian): every withdraw reverts BadSignature (test_contractOperatorCanNeverValidate). High-s malleated signatures are rejected with BadSignature (test_highSRejected), so no malleability issue exists.","severity":"info","snippet":"        operator = newOperator;","title":"Trust assumptions: the guardian has instant full custody via setOperator; the operator must be an EOA (no ERC-1271); pause is one-way for deposits"},{"citation":"resolved","description":"DistributeTest calls deployAll directly, so the script contract is both the ECU distributor and the caller of every transfer; the broadcast path through run(), where sender and address(this) differ, is never executed and the address(this) defect is invisible. FarmVaultTest's testFuzz_anyAmountTheVaultHoldsCanBeVouchered asserts that any amount clears on one voucher, encoding the absence of an outflow bound as expected behaviour. Also untested: high-s signatures (OZ rejects them; a regression test would pin it), a voucher submitted exactly at expiry (accepted by design), vesting amounts after the launch second, operator rotate-back, and any multi-day withdrawal sequence. Add a test that drives run() under broadcast, tests for the cap once added, and day-1 vesting assertions. Merged from the permissions specialist; reproduced.","line":40,"path":"test/Distribute.t.sol","reproduction":"`forge test` passes 41/41 on the current tree while test/scratch/Proof_24722058997d.t.sol (script.run() under broadcast) fails with ERC20InsufficientBalance and test/scratch/Proof_a7af1dda5956.t.sol (50 M single voucher) fails with 'next call did not revert as expected'.","severity":"info","snippet":"        d = script.deployAll(to, START);","title":"Test suite never exercises the broadcast path or any outflow bound, so neither high finding fails a test today"}],"hash":"ee06755e2c9d0ab60b706246db72482b688662ca779eea09783544785f9ec604","nodeId":"c29a75d5-a9bb-48e2-91b8-80e844adc500","outcome":"completed","summary":"The findings file is written and every snippet matches its line in the tree. Recap:\n\n**Outcome.** All four specialist reports described the same two real high-severity defects, and every lower claim reproduced too. I merged 30 specialist entries into 10 findings in `.imd-findings.json`, each with a reproduction run on this tree. Only the two highs carry proofs.\n\n**Confirmed and kept**\n- **High, Distribute.s.sol line 84.** The script mints the supply to the ephemeral script address. The forge dry run aborts on the address(this) guard, and driving run() from a test reverts with insufficient balance on the first transfer. The attached specialist proof fails for exactly that reason.\n- **High, FarmVault.sol line 136.** No outflow bound of any kind. A single operator-signed voucher moves all 50 M ECU. The attached proof fails with \"next call did not revert as expected\". I answered the requester's priority (2): a cap is warranted, but one sized exactly to the daily Exchange budget would block always-withdrawable deposits, so a guardian-settable rolling window is the recommended shape.\n- **Medium.** Single-step guardian hand-over is irreversible (rated medium by two specialists, low by two; I kept medium because it removes the only emergency control).\n- **Low.** Merged into one: no per-voucher revocation, unbounded expiry, head-of-line blocking by an unspent nonce, and no supersede. Separate low: rotating the operator back revives old vouchers. Two spec gaps: no vesting cliff with an unspecified community duration, and the LP slice sent unlocked despite the \"locked 2 y\" table entry.\n- **Low, my own addition.** VestingWallet beneficiaries can transfer or renounce ownership, contradicting .env.example, and renouncing strands the slice permanently.\n- **Info.** Guardian trust assumptions (instant full custody, no ERC-1271, one-way pause), and the test-coverage gap.\n\n**Checked and found clean.** EIP-712 domain includes chain id and contract, high-s signatures are rejected, zero-address recovery cannot match a non-zero operator, nonces are consumed before the transfer under a reentrancy guard, the share arithmetic is exact, and zero env addresses revert rather than silently misroute.\n\n**Scratch material.** My reproductions live in test/scratch/Judge.t.sol alongside the copied specialist proofs. Nothing outside test/scratch and the findings file was changed.","treeHash":null,"usage":{"cachedInputTokens":872970,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":20788,"runtime":"claude","turns":27,"wallClockMs":254107}}],"verification":[]}