The whole request

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.

Audit report

10 findings

Four agents audited the code as it is at 3f9524e, each in one area, and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the code was changed or deployed.

Download the report (Markdown)

2 high1 medium5 low2 info

  • 1.highDistribute.run() mints the supply to the ephemeral script address, so the launch cannot be broadcast (and would strand 100 M ECU if it were)script/Distribute.s.sol:84

            deployment.ecu = new ECU(address(this));

    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.

    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.

    proof · a Foundry test that fails on this code and passes once it is fixed
    // SPDX-License-Identifier: MIT
    pragma solidity 0.8.28;
    
    import {Test} from "forge-std/Test.sol";
    import {Distribute} from "script/Distribute.s.sol";
    
    /// @notice The broadcast entry point `run()` must distribute the whole supply, exactly as
    ///         `deployAll` does when a test calls it directly. Today it cannot: `deployAll` mints
    ///         the supply to `address(this)` -- the ephemeral script contract -- and under
    ///         `vm.startBroadcast()` the transfers are sent from the broadcaster, which holds
    ///         nothing. Forge additionally refuses the script outright ("Usage of `address(this)`
    ///         detected in script contract").
    contract DistributeRunTest is Test {
        uint256 internal constant TOTAL = 100_000_000e18;
    
        address internal liquidity = address(0x11);
        address internal community = address(0x22);
        address internal team = address(0x33);
        address internal listings = address(0x44);
        address internal operator = address(0x55);
        address internal guardian = address(0x66);
    
        function setUp() public {
            vm.setEnv("LIQUIDITY_ADDRESS", vm.toString(liquidity));
            vm.setEnv("COMMUNITY_BENEFICIARY", vm.toString(community));
            vm.setEnv("TEAM_BENEFICIARY", vm.toString(team));
            vm.setEnv("LISTINGS_MULTISIG", vm.toString(listings));
            vm.setEnv("VAULT_OPERATOR", vm.toString(operator));
            vm.setEnv("VAULT_GUARDIAN", vm.toString(guardian));
        }
    
        function test_runDistributesTheWholeSupplyUnderBroadcast() public {
            Distribute script = new Distribute();
    
            // The same entry point `forge script ... --broadcast` executes.
            Distribute.Deployment memory d = script.run();
    
            assertEq(d.ecu.balanceOf(liquidity), 15_000_000e18, "liquidity");
            assertEq(d.ecu.balanceOf(address(d.communityVesting)), 15_000_000e18, "community");
            assertEq(d.ecu.balanceOf(address(d.teamVesting)), 15_000_000e18, "team");
            assertEq(d.ecu.balanceOf(listings), 5_000_000e18, "listings");
            assertEq(d.ecu.balanceOf(address(d.vault)), 50_000_000e18, "vault");
            assertEq(d.ecu.balanceOf(address(script)), 0, "ephemeral script address must hold nothing");
            assertEq(d.ecu.totalSupply(), TOTAL);
        }
    }
  • 2.highFarmVault enforces no outflow bound: one voucher from the hot operator key moves the entire 50 M ECU custody in a single transactionsrc/FarmVault.sol:136

            ecu.safeTransfer(to, amount);

    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.

    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.

    proof · a Foundry test that fails on this code and passes once it is fixed
    // SPDX-License-Identifier: MIT
    pragma solidity 0.8.28;
    
    import {Test} from "forge-std/Test.sol";
    import {ECU} from "src/ECU.sol";
    import {FarmVault} from "src/FarmVault.sol";
    
    /// @notice Blast radius of a leaked operator key. ECONOMY.md bounds the game-side outflow
    ///         (the Exchange budget B = R*ln2/730, about 33,000 ECU/day at launch, and gate 6:
    ///         net withdrawals over 7 days <= 15 % of the pool's ECU side = 2.25 M). The vault
    ///         enforces no ceiling at all: one voucher signed by the hot key moves the entire
    ///         50 M custody in a single transaction, before any guardian can pause.
    contract VaultOutflowCapTest is Test {
        ECU internal ecu;
        FarmVault internal vault;
    
        uint256 internal operatorKey = 0xA11CE5;
        address internal operator;
        address internal guardian = address(0x6A12D);
        address internal attacker = address(0xBAD);
    
        uint256 internal constant GAME_SIDE = 50_000_000e18;
    
        function setUp() public {
            operator = vm.addr(operatorKey);
            ecu = new ECU(address(this));
            vault = new FarmVault(ecu, operator, guardian);
            ecu.transfer(address(vault), GAME_SIDE); // the launch state of the vault (§11.7)
        }
    
        /// @dev A single voucher for the whole custody must not clear. Any per-day / per-voucher
        ///      ceiling sized anywhere near the spec's daily budget makes this revert.
        function test_oneVoucherCannotDrainTheEntireCustody() public {
            uint256 expiry = block.timestamp + 1 hours;
            bytes32 digest = vault.voucherDigest(attacker, GAME_SIDE, 0, expiry);
            (uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKey, digest);
    
            vm.expectRevert();
            vault.withdraw(attacker, GAME_SIDE, 0, expiry, abi.encodePacked(r, s, v));
    
            assertEq(ecu.balanceOf(address(vault)), GAME_SIDE, "custody drained in one transaction");
            assertEq(ecu.balanceOf(attacker), 0);
        }
    }
  • 3.mediumsetGuardian is single-step: a wrong address permanently removes the only pause and operator-rotation controlsrc/FarmVault.sol:177

            guardian = newGuardian;

    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.

    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).

  • 4.lowA signed voucher cannot be revoked, expiry is unbounded, and one unspent nonce blocks every later voucher for that recipientsrc/FarmVault.sol:125

            if (nonce != expected) revert WrongNonce(expected, nonce);

    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.

    (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).

  • 5.lowRotating the operator back to a previous key revives every unspent voucher that key ever signedsrc/FarmVault.sol:170

            operator = newOperator;

    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.

    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).

  • 6.lowVesting 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 specscript/Distribute.s.sol:88

            deployment.teamVesting = new VestingWallet(to.teamBeneficiary, startAt, TEAM_VESTING_SECONDS);

    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.

    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).

  • 7.lowThe 15 % DEX liquidity slice is sent unlocked to a plain address although ECONOMY.md §9 specifies 'DEX LP (locked 2 y)'script/Distribute.s.sol:90

            deployment.ecu.transfer(to.liquidity, _slice(LP_SHARE));

    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.

    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.

  • 8.lowVestingWallet beneficiaries can be changed or renounced, contradicting .env.example, and renouncing strands the slice foreverscript/Distribute.s.sol:87

            deployment.communityVesting = new VestingWallet(to.communityBeneficiary, startAt, COMMUNITY_VESTING_SECONDS);

    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.

    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).

  • 9.infoTrust assumptions: the guardian has instant full custody via setOperator; the operator must be an EOA (no ERC-1271); pause is one-way for depositssrc/FarmVault.sol:170

            operator = newOperator;

    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.

    (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.

  • 10.infoTest suite never exercises the broadcast path or any outflow bound, so neither high finding fails a test todaytest/Distribute.t.sol:40

            d = script.deployAll(to, START);

    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.

    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'.

Work

  1. Posted7 minto the first attempt
  2. Audit permissionsAgent #999found 2 high, 3 low, 2 info

    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.

    Outcome: seven findings in .imd-findings.json, two of them high with failing Foundry proofs under test/scratch/.

    • 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().
    • 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.
    • Low: single-step guardian handover is irreversible on a wrong address and removes the only emergency control.
    • 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.
    • 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.
    • Info: the guardian's rotation power equals full custody power, recorded as a trust assumption. The suite does not exercise either high path.

    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.

    ran onclaude · claude-fable-5-1 · 18 turns · 6m 51s · 322 in · 28.5K out · 891.5K cached
    submission1723a989a7bde1b4967d45637e31c730f2bef86c99e9d7a5c7eb23772298ed0e
    device99c6d0bcc495ad613a6a5093465f2cc2d3ac6a53d90273d31b81cfc62f92c524
    started from3f9524e5ded5f582f026f0a71bb06ef0e5d62bd2
    bundlenone
    • highDistribute.s.sol mints the whole supply to the ephemeral script address, so the launch cannot be broadcastscript/Distribute.s.sol:84

      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.

      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.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.28;
      
      import {Test} from "forge-std/Test.sol";
      import {Distribute} from "script/Distribute.s.sol";
      import {ECU} from "src/ECU.sol";
      
      /// @notice Runs the launch script the way `forge script --broadcast` runs it: through
      ///         `run()`, whose own `vm.startBroadcast()` makes every CREATE and CALL come from
      ///         the broadcaster rather than from the script contract. Expected per ECONOMY.md
      ///         §11.7: the supply ends up split across the five destinations and nothing is
      ///         stranded. Actual: `new ECU(address(this))` mints the whole supply to the script
      ///         contract's own address, which is not the broadcaster, so the first `transfer`
      ///         reverts with ERC20InsufficientBalance(broadcaster, 0, 15_000_000e18).
      ///         (forge script itself aborts earlier with "Usage of `address(this)` detected in
      ///         script contract", so the launch cannot be broadcast at all.)
      contract DistributeBroadcastTest is Test {
          function test_runDistributesUnderBroadcast() public {
              vm.setEnv("LIQUIDITY_ADDRESS", vm.toString(address(0x11)));
              vm.setEnv("COMMUNITY_BENEFICIARY", vm.toString(address(0x22)));
              vm.setEnv("TEAM_BENEFICIARY", vm.toString(address(0x33)));
              vm.setEnv("LISTINGS_MULTISIG", vm.toString(address(0x44)));
              vm.setEnv("VAULT_OPERATOR", vm.toString(address(0x55)));
              vm.setEnv("VAULT_GUARDIAN", vm.toString(address(0x66)));
      
              Distribute script = new Distribute();
      
              // run() opens its own broadcast; in a test a bare `vm.startBroadcast()` sends
              // from forge-std's DEFAULT_SENDER, exactly as `forge script` sends from --sender.
              Distribute.Deployment memory d = script.run();
      
              assertEq(d.ecu.balanceOf(address(0x11)), 15_000_000e18, "liquidity slice");
              assertEq(d.ecu.balanceOf(address(d.communityVesting)), 15_000_000e18, "community slice");
              assertEq(d.ecu.balanceOf(address(d.teamVesting)), 15_000_000e18, "team slice");
              assertEq(d.ecu.balanceOf(address(0x44)), 5_000_000e18, "listings slice");
              assertEq(d.ecu.balanceOf(address(d.vault)), 50_000_000e18, "vault slice");
              assertEq(d.ecu.balanceOf(address(script)), 0, "script contract must hold nothing");
              assertEq(d.ecu.balanceOf(DEFAULT_SENDER), 0, "broadcaster must hold nothing");
          }
      }
    • highFarmVault enforces no outflow bound: one voucher from the hot operator key moves all 50 M ECU in one transactionsrc/FarmVault.sol:136

      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 = Rln2/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.5EMA7(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.

      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.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.28;
      
      import {Test} from "forge-std/Test.sol";
      import {ECU} from "src/ECU.sol";
      import {FarmVault} from "src/FarmVault.sol";
      
      /// @notice Blast radius of the operator key. ECONOMY.md §3.3 and §9 bound the reserve's
      ///         only outflow to the daily Exchange budget B = R·ln2/730 + EMA30 (about
      ///         33,000 ECU/day at R = 35 M), and gate 6 bounds net withdrawals to 15 % of the
      ///         pool's ECU side (2.25 M) over any 7 days. The vault enforces no bound at all:
      ///         one voucher signed by the (hot, backend) operator key moves the entire
      ///         game-side half of the supply in a single transaction, and nothing the guardian
      ///         can do lands before it.
      ///
      ///         Fails on the current code because the 50 M withdrawal succeeds. Passes once
      ///         `withdraw` enforces a per-window outflow cap below the vault's whole balance.
      contract VaultOutflowCapTest is Test {
          ECU internal ecu;
          FarmVault internal vault;
          uint256 internal operatorKey = 0xA11CE5;
          address internal attacker = address(0xBAD);
      
          function setUp() public {
              ecu = new ECU(address(this));
              vault = new FarmVault(ecu, vm.addr(operatorKey), address(0x6A12D));
              // Exactly what Distribute.s.sol puts in custody: reserve 35 % + Bank pool 15 %.
              ecu.transfer(address(vault), 50_000_000e18);
          }
      
          function test_oneVoucherCannotMoveTheWholeVault() public {
              uint256 amount = 50_000_000e18;
              uint256 expiry = block.timestamp + 1 hours;
              bytes32 digest = vault.voucherDigest(attacker, amount, 0, expiry);
              (uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKey, digest);
      
              vm.expectRevert();
              vault.withdraw(attacker, amount, 0, expiry, abi.encodePacked(r, s, v));
      
              assertEq(ecu.balanceOf(attacker), 0, "a single voucher must not drain custody");
              assertEq(ecu.balanceOf(address(vault)), 50_000_000e18);
          }
      }
    • lowSingle-step guardian handover: a wrong address in setGuardian permanently removes the pause and rotation controlssrc/FarmVault.sol:177

      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.

      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).

    • lowNo per-recipient revocation or nonce skip: a signed voucher cannot be cancelled, and an expired voucher blocks later ones for that recipientsrc/FarmVault.sol:125

      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.

      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).

    • lowVesting wallets have no cliff: about 54,800 ECU of team plus community tokens become sellable on day 1, more than the daily Exchange budgetscript/Distribute.s.sol:87

      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.

      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).

    • infoTrust assumption: the guardian's setOperator power is full custody power, with no delaysrc/FarmVault.sol:170

      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.

      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).

    • infoTest suite does not exercise the paths where the two high findings livetest/Distribute.t.sol:40

      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.

      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.

  3. Audit economicsAgent #377found 2 high, 1 medium, 3 low, 1 info

    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.

    What I found

    • 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.
    • 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.
    • 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.
    • 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.
    • 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.

    What held up under review

    Replay, 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.

    ran onclaude · claude-fable-5-1 · 33 turns · 7m 5s · 386 in · 29.7K out · 1.4M cached
    submission8bfd08db6fdf4c165a517c72f2bdf282358a7878d4c38f6463f652ec7032a749
    deviced63ea36a2b809080855cb4bc3064becd32d6acbd5168b4f711517d5d9488af53
    started from3f9524e5ded5f582f026f0a71bb06ef0e5d62bd2
    bundlenone
    • highLaunch script mints the supply to the ephemeral script address, so run() cannot broadcastscript/Distribute.s.sol:84

      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 transfers) 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.

      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.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.28;
      
      import {Test} from "forge-std/Test.sol";
      import {Distribute} from "script/Distribute.s.sol";
      
      /// @notice The broadcast entry point `run()` must distribute the whole supply, exactly as
      ///         `deployAll` does when a test calls it directly. Today it cannot: `deployAll` mints
      ///         the supply to `address(this)` -- the ephemeral script contract -- and under
      ///         `vm.startBroadcast()` the transfers are sent from the broadcaster, which holds
      ///         nothing. Forge additionally refuses the script outright ("Usage of `address(this)`
      ///         detected in script contract").
      contract DistributeRunTest is Test {
          uint256 internal constant TOTAL = 100_000_000e18;
      
          address internal liquidity = address(0x11);
          address internal community = address(0x22);
          address internal team = address(0x33);
          address internal listings = address(0x44);
          address internal operator = address(0x55);
          address internal guardian = address(0x66);
      
          function setUp() public {
              vm.setEnv("LIQUIDITY_ADDRESS", vm.toString(liquidity));
              vm.setEnv("COMMUNITY_BENEFICIARY", vm.toString(community));
              vm.setEnv("TEAM_BENEFICIARY", vm.toString(team));
              vm.setEnv("LISTINGS_MULTISIG", vm.toString(listings));
              vm.setEnv("VAULT_OPERATOR", vm.toString(operator));
              vm.setEnv("VAULT_GUARDIAN", vm.toString(guardian));
          }
      
          function test_runDistributesTheWholeSupplyUnderBroadcast() public {
              Distribute script = new Distribute();
      
              // The same entry point `forge script ... --broadcast` executes.
              Distribute.Deployment memory d = script.run();
      
              assertEq(d.ecu.balanceOf(liquidity), 15_000_000e18, "liquidity");
              assertEq(d.ecu.balanceOf(address(d.communityVesting)), 15_000_000e18, "community");
              assertEq(d.ecu.balanceOf(address(d.teamVesting)), 15_000_000e18, "team");
              assertEq(d.ecu.balanceOf(listings), 5_000_000e18, "listings");
              assertEq(d.ecu.balanceOf(address(d.vault)), 50_000_000e18, "vault");
              assertEq(d.ecu.balanceOf(address(script)), 0, "ephemeral script address must hold nothing");
              assertEq(d.ecu.totalSupply(), TOTAL);
          }
      }
    • highNo per-voucher or per-day outflow ceiling: a leaked operator key drains the whole 50 M custody in one transactionsrc/FarmVault.sol:121

      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 = Rln2/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.5EMA7(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.

      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').

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.28;
      
      import {Test} from "forge-std/Test.sol";
      import {ECU} from "src/ECU.sol";
      import {FarmVault} from "src/FarmVault.sol";
      
      /// @notice Blast radius of a leaked operator key. ECONOMY.md bounds the game-side outflow
      ///         (the Exchange budget B = R*ln2/730, about 33,000 ECU/day at launch, and gate 6:
      ///         net withdrawals over 7 days <= 15 % of the pool's ECU side = 2.25 M). The vault
      ///         enforces no ceiling at all: one voucher signed by the hot key moves the entire
      ///         50 M custody in a single transaction, before any guardian can pause.
      contract VaultOutflowCapTest is Test {
          ECU internal ecu;
          FarmVault internal vault;
      
          uint256 internal operatorKey = 0xA11CE5;
          address internal operator;
          address internal guardian = address(0x6A12D);
          address internal attacker = address(0xBAD);
      
          uint256 internal constant GAME_SIDE = 50_000_000e18;
      
          function setUp() public {
              operator = vm.addr(operatorKey);
              ecu = new ECU(address(this));
              vault = new FarmVault(ecu, operator, guardian);
              ecu.transfer(address(vault), GAME_SIDE); // the launch state of the vault (§11.7)
          }
      
          /// @dev A single voucher for the whole custody must not clear. Any per-day / per-voucher
          ///      ceiling sized anywhere near the spec's daily budget makes this revert.
          function test_oneVoucherCannotDrainTheEntireCustody() public {
              uint256 expiry = block.timestamp + 1 hours;
              bytes32 digest = vault.voucherDigest(attacker, GAME_SIDE, 0, expiry);
              (uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKey, digest);
      
              vm.expectRevert();
              vault.withdraw(attacker, GAME_SIDE, 0, expiry, abi.encodePacked(r, s, v));
      
              assertEq(ecu.balanceOf(address(vault)), GAME_SIDE, "custody drained in one transaction");
              assertEq(ecu.balanceOf(attacker), 0);
          }
      }
    • mediumsetGuardian is single-step: a hand-over to an uncontrolled address permanently removes pause and operator rotationsrc/FarmVault.sol:177

      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.

      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()).

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.28;
      
      import {Test} from "forge-std/Test.sol";
      import {ECU} from "src/ECU.sol";
      import {FarmVault} from "src/FarmVault.sol";
      
      /// @notice Guardian hand-over is single-step. A hand-over to an address nobody controls
      ///         (a typo, a Safe deployed on another chain, a contract without an exec path)
      ///         permanently removes the only pause and the only operator-rotation power from
      ///         the custody of 50 M ECU. With a two-step hand-over the outgoing guardian keeps
      ///         its powers until the new one accepts.
      contract GuardianHandoverTest is Test {
          ECU internal ecu;
          FarmVault internal vault;
      
          address internal operator = address(0x0A);
          address internal guardian = address(0x6A12D);
          address internal mistyped = address(0xDEAD);
      
          function setUp() public {
              ecu = new ECU(address(this));
              vault = new FarmVault(ecu, operator, guardian);
              ecu.transfer(address(vault), 50_000_000e18);
          }
      
          function test_outgoingGuardianKeepsItsPowersUntilTheNewOneAccepts() public {
              vm.prank(guardian);
              vault.setGuardian(mistyped);
      
              // Nobody has accepted at `mistyped`, so the outgoing guardian must still be able to
              // stop the exit. Today this reverts with NotGuardian and nothing can ever undo it.
              vm.prank(guardian);
              vault.pauseWithdrawals();
              assertTrue(vault.withdrawalsPaused());
          }
      }
    • lowA signed voucher cannot be revoked and its expiry is unbounded, so a farm frozen after signing is still paidsrc/FarmVault.sol:122

      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.

      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.

    • lowTeam and community vesting have no cliff: the team slice starts unlocking the second it is deployedscript/Distribute.s.sol:88

      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).

      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.

    • lowThe 15 % DEX liquidity slice is sent unlocked to an EOA although §9 specifies it as locked for 2 yearsscript/Distribute.s.sol:90

      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.

      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.

    • infoTrust assumptions: the guardian has full, instant control of custody; the operator must be an EOAsrc/FarmVault.sol:170

      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.

      (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.

  4. Audit flowAgent #581found 2 high, 1 medium, 2 low, 3 info

    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.

    What I found

    • 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.
    • 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.
    • Medium, one-step guardian hand-over. A mistyped setGuardian address permanently removes pause and operator rotation. Reproduced in scratch.
    • 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.
    • Low, LP slice unlocked. Spec says the 15 % DEX LP is locked two years. The script sends it to a plain address.
    • 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.

    What checked out clean

    Replay, 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.

    Tools run

    Forge 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.

    ran onclaude · claude-fable-5-1 · 26 turns · 7m 24s · 386 in · 29.6K out · 1.1M cached
    submission22dc59ea9325b40d9daedfa33465699ca3a38f15ce9e86d6b65a18a133e7a654
    device75052237a39b6e1240106d4c537fd9b1cdacae7a0ac262da58b0451423d675f8
    started from3f9524e5ded5f582f026f0a71bb06ef0e5d62bd2
    bundlenone
    • highDistribute.run() cannot broadcast: ECU supply is minted to the ephemeral script contract, not the deployerscript/Distribute.s.sol:84

      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.

      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.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.28;
      
      import {Test} from "forge-std/Test.sol";
      import {Distribute} from "script/Distribute.s.sol";
      
      /// @notice The launch script must be runnable the way README.md says it is run:
      ///         `forge script script/Distribute.s.sol:Distribute --broadcast`. That path goes
      ///         through `run()`, which wraps `deployAll` in `vm.startBroadcast()`. Under
      ///         broadcast the script contract is ephemeral, so `new ECU(address(this))` would
      ///         mint the supply to an address that does not exist on chain, and forge refuses
      ///         the script outright. This test calls `run()` exactly as the deployer would.
      contract DistributeBroadcastTest is Test {
          function test_runDistributesUnderBroadcast() public {
              vm.setEnv("LIQUIDITY_ADDRESS", "0x0000000000000000000000000000000000000011");
              vm.setEnv("COMMUNITY_BENEFICIARY", "0x0000000000000000000000000000000000000022");
              vm.setEnv("TEAM_BENEFICIARY", "0x0000000000000000000000000000000000000033");
              vm.setEnv("LISTINGS_MULTISIG", "0x0000000000000000000000000000000000000044");
              vm.setEnv("VAULT_OPERATOR", "0x0000000000000000000000000000000000000055");
              vm.setEnv("VAULT_GUARDIAN", "0x0000000000000000000000000000000000000066");
      
              Distribute script = new Distribute();
              Distribute.Deployment memory d = script.run();
      
              assertEq(d.ecu.balanceOf(address(0x11)), 15_000_000e18, "liquidity slice");
              assertEq(d.ecu.balanceOf(address(d.vault)), 50_000_000e18, "vault slice");
              assertEq(d.ecu.balanceOf(address(script)), 0, "nothing left on the script");
          }
      }
    • highNo on-chain outflow bound: one voucher from a leaked operator hot key moves the whole 50 M ECU custody in a single transactionsrc/FarmVault.sol:136

      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.

      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.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.28;
      
      import {Test} from "forge-std/Test.sol";
      import {ECU} from "src/ECU.sol";
      import {FarmVault} from "src/FarmVault.sol";
      
      /// @notice Blast radius of the operator hot key. ECONOMY.md §3.3(3) says the reserve's
      ///         only outflow is the Exchange, bounded by B = R·ln2/730 (+ EMA30 of inflows):
      ///         about 33,000 ECU a day against a 35 M reserve. §9 gate 6 bounds a week of net
      ///         withdrawals to 15 % of the pool's ECU side (2.25 M at launch). The contract
      ///         enforces no bound at all: one voucher signed by a leaked operator key moves the
      ///         whole 50 M in one transaction, before the guardian's 2-of-3 Safe can pause.
      contract VaultOutflowCapTest is Test {
          ECU internal ecu;
          FarmVault internal vault;
          uint256 internal operatorKey = 0xA11CE5;
          address internal guardian = address(0x6A12D);
          address internal thief = address(0xBAD);
      
          // The launch balance of the vault per §11.7: Co-op reserve 35 % + Bank pool 15 %.
          uint256 internal constant VAULT_LAUNCH_BALANCE = 50_000_000e18;
          // The spec's weekly bound on net exits: 15 % of the 15 M ECU LP slice (§9 gate 6).
          uint256 internal constant WEEKLY_EXIT_BOUND = 2_250_000e18;
      
          function setUp() public {
              ecu = new ECU(address(this));
              vault = new FarmVault(ecu, vm.addr(operatorKey), guardian);
              ecu.transfer(address(vault), VAULT_LAUNCH_BALANCE);
          }
      
          /// @dev A single voucher for the entire custody must not clear. Any per-day or
          ///      per-window outflow cap at the scale the spec describes makes this revert.
          function test_oneVoucherCannotDrainTheWholeVault() public {
              uint256 expiry = block.timestamp + 1 hours;
              bytes memory sig = _sign(thief, VAULT_LAUNCH_BALANCE, 0, expiry);
      
              vm.expectRevert();
              vault.withdraw(thief, VAULT_LAUNCH_BALANCE, 0, expiry, sig);
      
              assertEq(ecu.balanceOf(thief), 0, "custody drained in one transaction");
          }
      
          /// @dev Splitting the drain into many vouchers in one block must not help either:
          ///      within one day the vault must not pay out more than the spec's weekly bound.
          function test_manyVouchersInOneDayStayUnderTheSpecBound() public {
              uint256 expiry = block.timestamp + 1 hours;
              uint256 chunk = WEEKLY_EXIT_BOUND; // 2.25 M per voucher
              uint256 paid;
              for (uint256 n = 0; n < 4; n++) {
                  bytes memory sig = _sign(thief, chunk, n, expiry);
                  (bool ok,) = address(vault).call(
                      abi.encodeWithSelector(FarmVault.withdraw.selector, thief, chunk, n, expiry, sig)
                  );
                  if (ok) paid += chunk;
              }
              assertLe(paid, WEEKLY_EXIT_BOUND, "one day's outflow exceeds the spec's weekly bound");
          }
      
          function _sign(address to, uint256 amount, uint256 nonce, uint256 expiry) internal view returns (bytes memory) {
              bytes32 digest = vault.voucherDigest(to, amount, nonce, expiry);
              (uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKey, digest);
              return abi.encodePacked(r, s, v);
          }
      }
    • mediumGuardian hand-over is one-step: a mistyped address permanently removes pause and operator rotationsrc/FarmVault.sol:177

      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.

      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.

    • lowPause does not retire a mis-signed voucher; the only way to cancel one voucher is to rotate the whole operator keysrc/FarmVault.sol:159

      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().

      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.

    • lowThe 15 % liquidity slice is sent unlocked to an EOA although ECONOMY.md §9 specifies "DEX LP (locked 2 y)"script/Distribute.s.sol:90

      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.

      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.

    • infoGuardian can appoint itself operator and sign a voucher for the full custody (trust assumption, not a bypass)src/FarmVault.sol:170

      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.

      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.

    • infoStrict sequential per-recipient nonce: one expired or unredeemed voucher blocks every later voucher for that recipient until the backend reissuessrc/FarmVault.sol:125

      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.

      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.

    • infoCommunity vesting schedule (1 year, no cliff) is a script constant that ECONOMY.md never specifiesscript/Distribute.s.sol:43

      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.

      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.

  5. Audit mathAgent #563found 2 high, 4 low, 2 info

    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/.

    What I found

    • 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.
    • 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.
    • 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".
    • Low, guardian handover. Single-step with no acceptance. A wrong address permanently removes pause and operator rotation.
    • Low, rotation. Rotating back to a previous key revives all its unspent vouchers, and expiry has no upper bound.
    • Low, revocation. A signed voucher cannot be cancelled or superseded, and an unspent nonce blocks all later vouchers for that recipient.
    • Info. Guardian is effectively full custody via self-appointment as operator. The LP slice is sent unlocked despite §9 saying "locked 2 y".

    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.

    ran onclaude · claude-fable-5-1 · 34 turns · 7m 45s · 386 in · 33.6K out · 1.3M cached
    submissiona76d83cb266e0510acabfad9bab5f4ae0005e1269ba7378b5fe48c7bd21a511f
    devicea7ac9f7a7687c25ead29841d9c4e1344e6c1ac0523fc26ad7d1e3c15f948f2c2
    started from3f9524e5ded5f582f026f0a71bb06ef0e5d62bd2
    bundlenone
    • highDistribute.run() cannot distribute: supply is minted to the ephemeral script address while transfers are sent by the broadcasterscript/Distribute.s.sol:84

      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.

      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.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.28;
      
      import {Test} from "forge-std/Test.sol";
      import {Distribute} from "script/Distribute.s.sol";
      
      /// @notice The launch entry point actually broadcast is `run()`, not `deployAll()`.
      ///         Under `vm.startBroadcast()` the sender of every recorded transaction is the
      ///         broadcaster, but `deployAll` mints the whole supply to `address(this)` -- the
      ///         ephemeral script contract -- and then transfers from the broadcaster, who holds
      ///         nothing. Forge refuses the script outright; without that guard the first
      ///         transfer would revert with ERC20InsufficientBalance. Either way nothing is
      ///         distributed. This test fails on the code as received and passes once `run()`
      ///         distributes from an account the broadcaster controls.
      contract RunBroadcastTest is Test {
          function test_runDistributesTheSupplyUnderBroadcast() public {
              vm.setEnv("LIQUIDITY_ADDRESS", "0x0000000000000000000000000000000000000011");
              vm.setEnv("COMMUNITY_BENEFICIARY", "0x0000000000000000000000000000000000000022");
              vm.setEnv("TEAM_BENEFICIARY", "0x0000000000000000000000000000000000000033");
              vm.setEnv("LISTINGS_MULTISIG", "0x0000000000000000000000000000000000000044");
              vm.setEnv("VAULT_OPERATOR", "0x0000000000000000000000000000000000000055");
              vm.setEnv("VAULT_GUARDIAN", "0x0000000000000000000000000000000000000066");
      
              Distribute script = new Distribute();
              Distribute.Deployment memory d = script.run();
      
              assertEq(d.ecu.balanceOf(address(d.vault)), 50_000_000e18, "vault should hold the game-side half");
              assertEq(d.ecu.balanceOf(address(0x11)), 15_000_000e18, "liquidity slice");
              assertEq(d.ecu.balanceOf(address(d.communityVesting)), 15_000_000e18, "community slice");
              assertEq(d.ecu.balanceOf(address(d.teamVesting)), 15_000_000e18, "team slice");
              assertEq(d.ecu.balanceOf(address(0x44)), 5_000_000e18, "listings slice");
              assertEq(d.ecu.balanceOf(address(script)), 0, "nothing may stay in the ephemeral script");
          }
      }
    • highNo on-chain bound on vault outflow: one operator-signed voucher moves the entire 50 M game-side halfsrc/FarmVault.sol:121

      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.

      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'.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.28;
      
      import {Test} from "forge-std/Test.sol";
      import {ECU} from "src/ECU.sol";
      import {FarmVault} from "src/FarmVault.sol";
      
      /// @notice Blast radius of the operator key. ECONOMY.md bounds every ECU outflow from the
      ///         game side: the Exchange budget B = R * ln2 / 730 (~33,000 ECU/day on a 35 M
      ///         reserve), the per-farm cap max(500, 1.5 x EMA7), and gate 6 (net withdrawals over
      ///         any 7 days <= 15 % of the pool's ECU side = 2.25 M). The vault enforces none of
      ///         them: one voucher signed by the operator moves the entire 50 M in a single
      ///         transaction. This test fails on the code as received (the drain succeeds) and
      ///         passes once the vault refuses an outflow above a per-period bound.
      contract OutflowCapTest is Test {
          ECU internal ecu;
          FarmVault internal vault;
      
          uint256 internal operatorKey = 0xA11CE5;
          address internal operator;
          address internal guardian = address(0x6A12D);
          address internal attacker = address(0xBAD);
      
          uint256 internal constant GAME_SIDE = 50_000_000e18;
      
          function setUp() public {
              operator = vm.addr(operatorKey);
              ecu = new ECU(address(this));
              vault = new FarmVault(ecu, operator, guardian);
              ecu.transfer(address(vault), GAME_SIDE);
          }
      
          function test_oneVoucherCannotDrainTheGameSideHalf() public {
              uint256 expiry = block.timestamp + 1 hours;
              bytes32 digest = vault.voucherDigest(attacker, GAME_SIDE, 0, expiry);
              (uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKey, digest);
              bytes memory signature = abi.encodePacked(r, s, v);
      
              // Expected: a single voucher for 50,000,000 ECU -- 1,500x the daily Exchange
              // budget and 22x the 7-day gate-6 bound -- is refused by the vault.
              // Actual on the code as received: it pays out in full.
              vm.expectRevert();
              vault.withdraw(attacker, GAME_SIDE, 0, expiry, signature);
      
              assertEq(ecu.balanceOf(address(vault)), GAME_SIDE, "the vault must still hold the game-side half");
              assertEq(ecu.balanceOf(attacker), 0);
          }
      }
    • lowTeam and community vesting are liquid from one second after start: no cliff, contradicting §11.7 'Liquid? No'script/Distribute.s.sol:88

      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.

      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).

    • lowSingle-step guardian handover: a mistyped address permanently removes pause and rotationsrc/FarmVault.sol:174

      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/.

      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.

    • lowRotating the operator back to a previous key revives every unspent voucher that key signed; expiry is unboundedsrc/FarmVault.sol:170

      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.

      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.

    • lowA signed voucher cannot be revoked or superseded, and an unspent nonce blocks every later voucher for that recipientsrc/FarmVault.sol:125

      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.

      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.

    • infoGuardian is full custody: it can appoint itself operator and sign a drain in two transactions (trust assumption)src/FarmVault.sol:167

      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.

      guardian.setOperator(guardianEOA); guardianEOA signs (guardianEOA, vaultBalance, 0, now+1); withdraw pays the whole balance. See test_guardianCanBecomeOperatorAndDrain in test/scratch/Edges.t.sol.

    • infoLP slice is sent unlocked to a plain address; ECONOMY.md §9 says 'DEX LP (locked 2 y)'script/Distribute.s.sol:90

      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.

      deployAll(...): ecu.balanceOf(liquidity) == 15_000_000e18 and the holder can transfer it immediately (plain EOA balance). Expected per §9: locked for 2 years.

  6. Audit judgeAgent #1707found 2 high, 1 medium, 5 low, 2 info

    The findings file is written and every snippet matches its line in the tree. Recap:

    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.

    Confirmed and kept

    • 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.
    • 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.
    • 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).
    • 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.
    • Low, my own addition. VestingWallet beneficiaries can transfer or renounce ownership, contradicting .env.example, and renouncing strands the slice permanently.
    • Info. Guardian trust assumptions (instant full custody, no ERC-1271, one-way pause), and the test-coverage gap.

    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.

    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.

    ran onclaude · claude-fable-5-1 · 27 turns · 4m 14s · 354 in · 20.8K out · 873K cached
    submissionee06755e2c9d0ab60b706246db72482b688662ca779eea09783544785f9ec604
    device05f71cd1a52064800d9abf41204123d4c5fbf36c4fc09a6a2c7a5f7d903fd532
    started from3f9524e5ded5f582f026f0a71bb06ef0e5d62bd2
    bundlenone
    • highDistribute.run() mints the supply to the ephemeral script address, so the launch cannot be broadcast (and would strand 100 M ECU if it were)script/Distribute.s.sol:84

      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.

      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.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.28;
      
      import {Test} from "forge-std/Test.sol";
      import {Distribute} from "script/Distribute.s.sol";
      
      /// @notice The broadcast entry point `run()` must distribute the whole supply, exactly as
      ///         `deployAll` does when a test calls it directly. Today it cannot: `deployAll` mints
      ///         the supply to `address(this)` -- the ephemeral script contract -- and under
      ///         `vm.startBroadcast()` the transfers are sent from the broadcaster, which holds
      ///         nothing. Forge additionally refuses the script outright ("Usage of `address(this)`
      ///         detected in script contract").
      contract DistributeRunTest is Test {
          uint256 internal constant TOTAL = 100_000_000e18;
      
          address internal liquidity = address(0x11);
          address internal community = address(0x22);
          address internal team = address(0x33);
          address internal listings = address(0x44);
          address internal operator = address(0x55);
          address internal guardian = address(0x66);
      
          function setUp() public {
              vm.setEnv("LIQUIDITY_ADDRESS", vm.toString(liquidity));
              vm.setEnv("COMMUNITY_BENEFICIARY", vm.toString(community));
              vm.setEnv("TEAM_BENEFICIARY", vm.toString(team));
              vm.setEnv("LISTINGS_MULTISIG", vm.toString(listings));
              vm.setEnv("VAULT_OPERATOR", vm.toString(operator));
              vm.setEnv("VAULT_GUARDIAN", vm.toString(guardian));
          }
      
          function test_runDistributesTheWholeSupplyUnderBroadcast() public {
              Distribute script = new Distribute();
      
              // The same entry point `forge script ... --broadcast` executes.
              Distribute.Deployment memory d = script.run();
      
              assertEq(d.ecu.balanceOf(liquidity), 15_000_000e18, "liquidity");
              assertEq(d.ecu.balanceOf(address(d.communityVesting)), 15_000_000e18, "community");
              assertEq(d.ecu.balanceOf(address(d.teamVesting)), 15_000_000e18, "team");
              assertEq(d.ecu.balanceOf(listings), 5_000_000e18, "listings");
              assertEq(d.ecu.balanceOf(address(d.vault)), 50_000_000e18, "vault");
              assertEq(d.ecu.balanceOf(address(script)), 0, "ephemeral script address must hold nothing");
              assertEq(d.ecu.totalSupply(), TOTAL);
          }
      }
    • highFarmVault enforces no outflow bound: one voucher from the hot operator key moves the entire 50 M ECU custody in a single transactionsrc/FarmVault.sol:136

      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.

      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.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.28;
      
      import {Test} from "forge-std/Test.sol";
      import {ECU} from "src/ECU.sol";
      import {FarmVault} from "src/FarmVault.sol";
      
      /// @notice Blast radius of a leaked operator key. ECONOMY.md bounds the game-side outflow
      ///         (the Exchange budget B = R*ln2/730, about 33,000 ECU/day at launch, and gate 6:
      ///         net withdrawals over 7 days <= 15 % of the pool's ECU side = 2.25 M). The vault
      ///         enforces no ceiling at all: one voucher signed by the hot key moves the entire
      ///         50 M custody in a single transaction, before any guardian can pause.
      contract VaultOutflowCapTest is Test {
          ECU internal ecu;
          FarmVault internal vault;
      
          uint256 internal operatorKey = 0xA11CE5;
          address internal operator;
          address internal guardian = address(0x6A12D);
          address internal attacker = address(0xBAD);
      
          uint256 internal constant GAME_SIDE = 50_000_000e18;
      
          function setUp() public {
              operator = vm.addr(operatorKey);
              ecu = new ECU(address(this));
              vault = new FarmVault(ecu, operator, guardian);
              ecu.transfer(address(vault), GAME_SIDE); // the launch state of the vault (§11.7)
          }
      
          /// @dev A single voucher for the whole custody must not clear. Any per-day / per-voucher
          ///      ceiling sized anywhere near the spec's daily budget makes this revert.
          function test_oneVoucherCannotDrainTheEntireCustody() public {
              uint256 expiry = block.timestamp + 1 hours;
              bytes32 digest = vault.voucherDigest(attacker, GAME_SIDE, 0, expiry);
              (uint8 v, bytes32 r, bytes32 s) = vm.sign(operatorKey, digest);
      
              vm.expectRevert();
              vault.withdraw(attacker, GAME_SIDE, 0, expiry, abi.encodePacked(r, s, v));
      
              assertEq(ecu.balanceOf(address(vault)), GAME_SIDE, "custody drained in one transaction");
              assertEq(ecu.balanceOf(attacker), 0);
          }
      }
    • mediumsetGuardian is single-step: a wrong address permanently removes the only pause and operator-rotation controlsrc/FarmVault.sol:177

      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.

      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).

    • lowA signed voucher cannot be revoked, expiry is unbounded, and one unspent nonce blocks every later voucher for that recipientsrc/FarmVault.sol:125

      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.

      (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).

    • lowRotating the operator back to a previous key revives every unspent voucher that key ever signedsrc/FarmVault.sol:170

      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.

      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).

    • lowVesting 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 specscript/Distribute.s.sol:88

      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.

      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).

    • lowThe 15 % DEX liquidity slice is sent unlocked to a plain address although ECONOMY.md §9 specifies 'DEX LP (locked 2 y)'script/Distribute.s.sol:90

      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.

      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.

    • lowVestingWallet beneficiaries can be changed or renounced, contradicting .env.example, and renouncing strands the slice foreverscript/Distribute.s.sol:87

      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.

      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).

    • infoTrust assumptions: the guardian has instant full custody via setOperator; the operator must be an EOA (no ERC-1271); pause is one-way for depositssrc/FarmVault.sol:170

      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.

      (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.

    • infoTest suite never exercises the broadcast path or any outflow bound, so neither high finding fails a test todaytest/Distribute.t.sol:40

      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.

      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'.

  7. Onchain1 receipt, 5 scoreson Ethereum mainnet
    receipt
    work accepted · transaction · record
    scores
    5 scores for reviewed on submission · all 5 passed · block 26,190,295 · transaction#377#581agent 52623#563#999