# Audit report

> moneycanbuy.fun audit, contracts v6, game rules and $YOLO tokenomics. Audit this repository at the pinned commit, as it is, for a mainnet launch: the smart contracts (contracts/src, 29 files, plus the wiring in contracts/script/LaunchPlan.sol) and the business logic and concept they implement (README.md, docs/MECHANICS.md, docs/TOKENOMICS.md, docs/LAUNCH-TASK.md). The Foundry project is contracts/: foundry.toml and the git submodules in contracts/lib live there, so run every forge command from contracts/ and import contracts as "src/..." from there. Baseline: forge test gives 318 passing, 11 fork tests skipped without an RPC. Do not modify, delete or reformat any existing file. Findings go only into the report and into NEW test files under contracts/test/scratch/.
>
> moneycanbuy.fun is a board of paid dares on Ethereum. An author locks a reward in ETH, USDC, IMD or $YOLO; a doer submits one proof link with an IMD bond; no dispute in 24 h pays the reward minus 10%. Anyone but the doer may dispute with an IMD bond; disputes go to a 24 h EIP-712 vote of IdentityMD NFT holders (margin 10), appealable to the IMD swarm, whose attested answer through the Intake callback is final; rewards of $300 or more are checked by the swarm first, prepaid by the doer. Losers' bonds pay winners, correct NFT voters and the treasury. TaskVault holds all money and forwards to CaseDesk and DisputeDesk; ParamsRegistry has a 48 h timelock, hard bounds and a single EOA owner. $YOLO: 1B fixed supply, no owner, NFT-holders-only first 30 minutes, then a 30% to 0 buy tax until minute 60; YoloSplitter, vestings, YoloSweetener bonuses, DareTrophy burns. Four swarm launches, each under 16,777,216 gas.
>
> Answer each numbered question, including where nothing is wrong:
> 1. MONEY. The liabilities invariant and every bucket: can anyone steal, be paid twice, or leave money stuck in any outcome (accepted, rejected, tie, no decision, swarm yes/no/silence, appeal won/lost/voided, expired, unwound, queued void)? Rounding in every split (fee 6/4, penalty 50/50, tie 50/50, pro rata, relayer gas and shortfall).
> 2. STATE MACHINE. Lazy timers (8 per call), the queue, pending top-ups and busyEpoch, deadlines, emergencyExit and its gas-bounded self-call: any ordering, gas or DoS path that blocks settlement or lets one party choose the outcome.
> 3. SWARM PATH. Callback binding, AttestationChecker (panel, quorum, EIP-712 domain), timeouts and silence, prepayment refunds, voided appeals, Intake price or callback-gas changes, and user text steering the question (TextRules, SwarmQuestion).
> 4. NFT VOTING. VoteLib signatures, ERC-1271 gas cap, delegation, one count per token, relayer reimbursement abuse, borrowed NFTs.
> 5. GAME THEORY. For author, doer, disputer, NFT voter, relayer, swarm caller and appellant: is honest play each one's best response? Price every profitable deviation in IMD at IMD = $7.65 (the docs' example): a large holder swinging votes, Sybil disputers filling the 16 reasons, an author disputing their own dare to get the work free, cheap griefing of honest doers, self-dealing dares to farm YOLO or trophies, profiting from swarm silence or a skipped large-reward check.
> 6. PRICES. ChainlinkUniV3PriceOracle (30-minute TWAP of a thin USDC/IMD pool), YoloPoolSamples (highest of 2 h, at least 5 samples, opening-price floor, $0.01 cap, EOA-only sampling), the swap adapter: the cost to move bonds, thresholds, minimums or sweetener caps, and what it buys.
> 7. ROLES. ParamsRegistry and ProtocolDirectory: can the owner or anyone hurt live tasks, deposits or old tasks through the attester set or the treasury address?
> 8. TOKEN AND LAUNCH. YoloToken, NftGate, YoloSplitter, Vestings, ProjectVesting: amounts to the wei and schedules against docs/TOKENOMICS.md, release math, the first hour, claimFees; LaunchPlan arguments against docs/LAUNCH-TASK.md.
> 9. TOKENOMICS CONCEPT. 54% unlocked on day 0; the 15% "Stabilizer / MM fund", one project wallet unlocked at launch; treasury, incentives, dev and team; what gives $YOLO demand and whether fees fund the project. The Stabilizer / MM fund is an owner decision and stays: do not recommend removing it; you may comment on its risk to holders and recommend controls and disclosure.
> 10. SWEETENER AND TROPHY. Payout conditions, the threshold cap, notice and withdrawal, fees, the burn price, tokenURI content.
> 11. HARMFUL DARES. The contracts accept any condition text. Is there anything that keeps an unsafe or illegal dare from being posted and paid? Propose the cheapest enforceable gate.
> 12. DOCS VERSUS CODE. Every promise in README.md, docs/MECHANICS.md, docs/TOKENOMICS.md or NatSpec that the code does not hold.
>
> Not findings: the placeholder wallets (0xF001 to 0xF005 in LaunchPlan.sol, <TREASURY_WALLET> and the like in docs); test/mocks; items acknowledged in docs/AUDIT-INTERNAL.md, unless the stated reason is wrong, the bound does not hold or a fix regressed; the existence and size of the Stabilizer / MM fund; brand/. External systems (Intake, ProjectFactory, Uniswap, Chainlink, delegate.xyz, the NFT) are out of scope except how this code trusts them.
>
> Severity: critical = theft, permanent loss or a bricked launch; high = loss under realistic conditions or a core public promise broken; medium = bounded loss, cheap griefing or a documented guarantee that fails; low = edge cases; info = docs drift and hardening.
>
> For every code finding: severity; file:line; impact; the call sequence from an external caller; a concrete input or state with expected against actual; whether it is reachable with the launch values; the smallest fix; and a Foundry PoC test, the full source in the report, written under contracts/test/scratch/, that fails on this code and passes once fixed (no fork, no ffi).
>
> Report layout:
> 1. Verdict, at most 6 lines: ready for launch or not, and what blocks it.
> 2. Code findings, most severe first.
> 3. Business logic and concept risks, a separate section: each risk, who gains and who loses, the cost in money, the recommendation.
> 4. Answers to questions 1 to 12.
> 5. What you read in full, what you could not reach, and the forge test result you got.

| | |
|---|---|
| Repository | https://github.com/sergey-mavr-agent/moneycanbuy.git |
| Commit | `d9a1ee59da5906c2fe511ddff90c073eada0ab30` |
| Job | `117cf05a-9631-48e7-a7b9-fbd9afc42040` |
| Judged | 2026-10-15 07:25 UTC |
| Findings | 3 medium · 5 low · 3 info |

Four agents audited the code as it is at `d9a1ee5`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Medium: YOLO price sample can be sandwiched inside one block; one pumped sample is the 2 h price (highest-of-window)

`contracts/src/oracles/YoloPoolSamples.sol:60`

```
        if (msg.sender != tx.origin || msg.sender.code.length != 0) revert SamplerNotEoa();
```

The EOA-only rule only stops a swap/sample/swap-back inside ONE transaction. Three transactions of the same EOA in one block (nonce order, or a bundle) do exactly that: swap IMD->YOLO (tick up), sampleYoloPrice(), swap back. The pool holds the pumped price for zero blocks, so the attacker carries no inventory risk and pays only the LP fee twice. Because the oracle price is the HIGHEST sample of the last 2 h (_windowTicks / ChainlinkUniV3PriceOracle._yoloUsd), a single pumped sample is the YOLO price for the next two hours and the honest samples that follow do not displace it. With launch values (opening 1.5e-5 IMD per YOLO, cap $0.01) the price can be moved up to ~87x. Consequences priced in YOLO: DareTrophy.price() falls ~99%; YoloSweetener._cap falls from ~2.6M YOLO to 30,000 YOLO (~$3.45), so a sweetener who pumps one sample and calls the permissionless payout claws back up to 98.8% of an unconfirmed bonus from the doer; every $YOLO task's rewardImd and doer bond grow up to 87x and small YOLO rewards become 'large' (mandatory 0.5 IMD swarm check prepaid by the doer); a $0.06 YOLO task passes the $5 minimum. Cost to reach the cap is about two 1.25% swap fees on ~18,750 IMD notional (~$3,600 at $7.65); a 10x pump about $930. NatSpec ('manipulation-resistant', 'so a sample can't be taken between two swaps') and docs/MECHANICS.md promise more than the code holds. Smallest fix: make a sample count only when confirmed by the next sample taken >= SAMPLE_SPACING later within a tick band (commit now, confirm in a later block), so a price must be held across blocks and exposed to arbitrage; or use the median of the window with a per-sample tick-move cap. Reported by audit_math; reproduced here.

**Reproduction**

State: ChainlinkUniV3PriceOracle with five honest samples at tick -111000 (opening). In one block: setTick(-66400) [swap], vm.prank(EOA,EOA) sampleYoloPrice() -> true, setTick(-111000) [swap back]. Expected: highestYoloTick() == -111000 (the pool never held -66400 past the block). Actual: -66400, and still -66400 after seven further honest samples over 1h45. contracts/test/scratch/SampleSandwich.t.sol: both tests fail on this code ('a sandwiched sample became the price: -66400 != -111000').

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {Test} from "forge-std/Test.sol";
import {ChainlinkUniV3PriceOracle, IAggregatorV3, IUniswapV3PoolObserve} from "src/oracles/ChainlinkUniV3PriceOracle.sol";

/// @dev A v4 PoolManager that answers `extsload` for one pool's slot0 (sqrtPriceX96 | tick << 160).
contract ScratchPoolManager {
    mapping(bytes32 => bytes32) private _slots;

    function setTick(bytes32 poolId, int24 tick) external {
        bytes32 slot = keccak256(abi.encodePacked(poolId, bytes32(uint256(6))));
        uint256 packed = uint256(uint24(tick)) << 160 | 1; // sqrtPriceX96 = 1 (initialized), tick
        _slots[slot] = bytes32(packed);
    }

    function extsload(bytes32 slot) external view returns (bytes32) {
        return _slots[slot];
    }
}

/// @dev YoloPoolSamples: "Only a plain EOA may sample, so no contract can swap the pool, sample and swap back inside one
///      transaction". Three transactions of one EOA in one block (nonce order, or a bundle) do exactly that: swap up,
///      sample, swap back. The highest sample of 2 h is the price, so one sandwiched sample is the YOLO price for the
///      next two hours: with the launch values (opening 1.5e-5 IMD per YOLO, cap $0.01) up to 87x. Expected: a sample
///      the pool does not hold past the block is not the price. Actual: it is, for 2 hours.
contract SampleSandwichTest is Test {
    address constant KEEPER = address(0xBEEF);
    address constant ATTACKER = address(0xBAD);
    int24 constant OPENING_TICK = -111_000; // ~1.5e-5 IMD per YOLO (1.0001^-111000)
    int24 constant PUMPED_TICK = -66_400; // ~87x the opening price (the $0.01 cap)
    uint256 constant T0 = 1_000_000;

    ScratchPoolManager pm;
    ChainlinkUniV3PriceOracle oracle;

    function setUp() public {
        pm = new ScratchPoolManager();
        address yolo = address(0x1001);
        address imd = address(0x2002);
        oracle = new ChainlinkUniV3PriceOracle(
            address(0x3003),
            imd,
            IAggregatorV3(address(0x4004)),
            IAggregatorV3(address(0x5005)),
            IUniswapV3PoolObserve(address(0x6006)),
            30 minutes,
            2 hours,
            48 hours,
            yolo,
            address(pm),
            address(0x7007),
            12_500,
            60,
            0.01e18,
            15_000e18 * 1e18 / 1_000_000_000e18
        );
        pm.setTick(oracle.yoloPoolId(), OPENING_TICK);
        // an honest keeper: five samples, 15 minutes apart (explicit times: via-IR may merge block.timestamp reads)
        for (uint256 i; i < 5; ++i) {
            vm.warp(T0 + i * 15 minutes);
            vm.prank(KEEPER, KEEPER);
            assertTrue(oracle.sampleYoloPrice());
        }
        vm.warp(T0 + 5 * 15 minutes);
        (bool ok, int24 highest) = oracle.highestYoloTick();
        assertTrue(ok);
        assertEq(highest, OPENING_TICK);
    }

    function test_sampleSandwichedInOneBlockIsThePriceForTwoHours() public {
        // one block, three transactions of the attacker's EOA (nonce order or a bundle):
        pm.setTick(oracle.yoloPoolId(), PUMPED_TICK); // 1. swap IMD -> YOLO, tick up
        vm.prank(ATTACKER, ATTACKER);
        assertTrue(oracle.sampleYoloPrice()); // 2. sample (plain EOA: allowed)
        pm.setTick(oracle.yoloPoolId(), OPENING_TICK); // 3. swap back: the pool is where it was

        (bool ok, int24 highest) = oracle.highestYoloTick();
        assertTrue(ok);
        // the pool never held the pumped price past this block: the price must still be the opening tick
        assertEq(highest, OPENING_TICK, "a sandwiched sample became the price");
    }

    function test_sandwichedSampleOutlivesHonestSamples() public {
        pm.setTick(oracle.yoloPoolId(), PUMPED_TICK);
        vm.prank(ATTACKER, ATTACKER);
        oracle.sampleYoloPrice();
        pm.setTick(oracle.yoloPoolId(), OPENING_TICK);
        // honest keeper keeps sampling the real price for 1h45
        for (uint256 i = 6; i < 13; ++i) {
            vm.warp(T0 + i * 15 minutes);
            vm.prank(KEEPER, KEEPER);
            oracle.sampleYoloPrice();
        }
        (, int24 highest) = oracle.highestYoloTick();
        assertEq(highest, OPENING_TICK, "pumped sample still the price after 1h45 of honest samples");
    }
}
```

### 2. Medium: Vesting clocks can be started by anyone with 1 wei of YOLO before the split: 'clocks start at the first split' (A-03) does not hold

`contracts/src/token/ProjectVesting.sol:63`

```
        if (startOf[token] == 0 && IERC20(token).balanceOf(address(this)) != 0) {
```

docs/TOKENOMICS.md and docs/LAUNCH-TASK.md promise that dev, team, incentives and treasury vest 'linear from the first YoloSplitter.split' and that 'the first split starts every clock' (audit fix A-03). release(token) is permissionless and starts the clock at the first call that finds ANY nonzero balance. YoloSplitter.split is not part of the launch-1 transaction (docs/LAUNCH-TASK.md: 'the project calls it in the first minute if nobody else has'), and YOLO is tradeable from the launch block. Any holder of 1 wei of YOLO (an NFT holder buying in minute 0-30, or a vesting beneficiary) can transfer 1 wei to a vesting and call release(yolo): the clock starts then, and when the 77,777,777 / 50,000,000 / 187,500,000 / 148,148,149 shares arrive, the elapsed head start vests at once (share x gap / duration). A one-day gap frees ~213,090 YOLO of the dev share; a 30-day gap ~6.39M. Bounded by the launch-to-split gap, which the project controls, but it is exactly the regression surface A-03 was meant to close and the published Day-0 schedule can be broken by anyone with dust. Smallest fix: start the clock only from a trusted starter (the splitter address as a constructor argument, predictable in the same launch), or only when the balance is at least the expected share; or call split inside the launch-1 transaction. Reported by audit_math, audit_permissions, audit_flow and audit_economics (merged).

**Reproduction**

DevVesting(dev) deployed; YoloSplitter(..., dev, ...) deployed; 750,000,000e18 YOLO minted to the splitter, split not yet called. Attacker transfers 1 wei YOLO to DevVesting and calls release(yolo) at t=1. Warp +30 days; anyone calls splitter.split(yolo). Expected (docs): startOf(yolo) == split time, beneficiary balance 0, releasable 0. Actual: startOf(yolo) == 1 and split's release pays the beneficiary 77,777,777e18 x 30/365 ~ 6.39M YOLO at once. contracts/test/scratch/VestingClockPreStart.t.sol fails on this code with 'clock must start at the split: 1 != 2592001'.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {DevVesting} from "src/token/Vestings.sol";
import {YoloSplitter} from "src/token/YoloSplitter.sol";

contract DustToken is ERC20 {
    constructor() ERC20("YOLO", "YOLO") {}

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

/// @notice docs/TOKENOMICS.md and docs/LAUNCH-TASK.md: "the vesting clocks start when the tokens arrive: the first
///         split right after launch pays the at-once parts and starts every clock" (audit A-03). ProjectVesting.release
///         starts the clock at the first call that finds ANY balance, by anyone. Between the token launch and the split
///         anybody can send 1 wei of YOLO to a vesting and call release: the clock then runs before the share arrives,
///         and the share vests pro rata to that head start the moment the split pays it.
contract VestingClockPreStartTest is Test {
    DustToken yolo;
    DevVesting dev;
    YoloSplitter splitter;
    address beneficiary = address(0xBEEF);

    function setUp() public {
        yolo = new DustToken();
        dev = new DevVesting(beneficiary);
        // the other three recipients are plain wallets here (the splitter skips release on addresses without code)
        splitter = new YoloSplitter(address(0xA1), address(dev), address(0xA3), address(0xA4), address(0xA5));
    }

    function test_clockCannotBeStartedBeforeTheShareArrives() public {
        // launch 1: the token exists, the project's 75% sits in the splitter, nobody has called split yet
        yolo.mint(address(splitter), 750_000_000e18);

        // anyone (the dev wallet itself) buys dust and primes the dev vesting's clock
        yolo.mint(address(this), 1);
        yolo.transfer(address(dev), 1);
        dev.release(address(yolo));

        // the split happens 30 days later (nothing in the code forces it earlier)
        vm.warp(block.timestamp + 30 days);
        splitter.split(address(yolo));

        // docs: the clock starts at the split, so nothing of the 77,777,777 is free at that moment
        // (on this code 30/365 of the share, about 6.39M YOLO, is already releasable)
        assertEq(dev.startOf(address(yolo)), uint64(block.timestamp), "clock must start at the split");
        assertEq(yolo.balanceOf(beneficiary), 0, "no dev YOLO free at the split");
        assertEq(dev.releasable(address(yolo)), 0, "nothing releasable at the split");
    }
}
```

### 3. Medium: Relayer gas shortfall is paid from the loser's penalty before winners and correct voters, whatever side the relayer voted: a losing voter relaying their own ballots one NFT per batch drains the penalt

`contracts/src/desks/DisputeDesk.sol:507`

```
        uint256 shortPay = d.shortfallTotal < penalty ? d.shortfallTotal : penalty;
```

_settleDecided takes the whole recorded relayer shortfall out of the losing side's penalty (step 1) before the 50/50 split between the winning side and the correct NFT votes. _reimburseRelayer (line 431-446) records a shortfall for any relayer whose batch counted at least one vote, regardless of the side voted, and NFT voters put up no stake. A voter on the losing side can relay their own ballots one NFT per batch: each batch is reimbursed up to (relayBaseGas 30k + 60k per counted NFT) x basefee x 1.1, first from the small dispute ETH pot (0.0003 to 0.01 ETH), the rest as IMD shortfall at the submission's IMD/ETH rate. With 20 NFTs at 20 gwei that is 12.4 IMD of shortfall against a 1 IMD penalty on a $100 task (launch values: dispute bond max(1, 0.5 + 1% R)). The winning doer then gets only their own bond back and the correct votes receive nothing, while the losing voter pockets the loser's bond as 'gas' at about zero net cost (reimbursement basefee x 1.1 vs real cost basefee + tip). The same cap can also be filled with deliberately wasted gas (appending ERC-1271 ballots that burn their 500k allowance and are skipped but still metered: C-03 makes them harmless to the batch, not to the metering). This removes the only reward that makes NFT voting worth doing and is cheap griefing of every winner. AUDIT-INTERNAL C-04 / R-5 acknowledge the per-batch over-reimbursement but bound it by the per-vote cap; the number of batches and the side of the relayer are unbounded. Smallest fix: record shortfall per side (the side of the votes the batch counted) and pay at settlement only the shortfall of batches whose counted votes were on the final side; or cap the total shortfall taken from the penalty (e.g. at most winnerBondBps of it) so winners and correct voters always keep a share; and measure only the gas of ballots that counted. Reported by audit_economics (and the gas-burn route by audit_permissions); reproduced here with the project's Base harness (contracts/test/scratch/JudgeChecks.t.sol, test_losingVoterDrainsPenaltyByRelayingOwnBallots).

**Reproduction**

State (Base harness: IMD $8, ETH $2500): USDC task 100e6, doer submits (bond 1 IMD), dave disputes with 0.001 ETH fee (bond 1 IMD). basefee 1 gwei: voter0 (120 NFTs) votes accept in one honest batch relayed by 'relayer' -> shortfall 0.0068 IMD. basefee 20 gwei: voter1 (20 NFTs) relays 20 batches of one reject ballot each from their own wallet (vm.prank(voters[1]); vault.submitVotes(subId, oneBallot)) -> relayerShortfall[subId][voters[1]] = 12.375e18. Vote end: accept wins by 100 (NFT decided right); 24 h later poke(taskId) -> Final Accepted. Expected (docs/MECHANICS.md: NFT right -> 50% of the penalty to the winners, 50% to the correct votes): Settlement.voterImd = 0.5e18. Actual: Settlement.shortfallPay = 1e18 (the whole penalty), Settlement.voterImd = 0; voters[1].claimRelayerShortfall receives ~0.999 IMD. Test fails with 'expected half of the penalty for the correct voters: 0 != 500000000000000000'.

### 4. Low: The doer can appeal the NFT decision that accepted their own submission, take the single appeal slot and answer their own appeal as the opponent

`contracts/src/desks/DisputeDesk.sol:127`

```
        if (s.phase != Phase.Decided) revert WrongPhase();
```

C-01 blocked the doer from disputing (DoerCannotDispute) but appeal() and callSwarm() accept any sender including the doer. After an NFT decision 'Accept', the doer appeals (a.sidesDoer = false), writes a feeble 'appeal' text, and because the opponent of an appeal that sides against the doer is the doer (appealReply line 190), writes the 'appeal_reply' too or lets 12 h pass. The appeal is single and first-come, so honest disputers can no longer appeal (WrongPhase) and the swarm question carries both party texts from one party. In NoDecision the doer can take the callSwarm slot against themselves the same way. Cost at launch values: a dispute bond (~1 IMD) plus the 0.5 IMD fee, of which the doer gets back 50% of the bond plus the fee if the swarm agrees with the NFT (nftRight). The 16 dispute reasons still reach the panel, so this is the C-01c slot capture done by the doer in person rather than through a puppet, one line away from the existing guard. Smallest fix: in appeal() and callSwarm() revert when sender == s.doer and the caller sides against the doer (the doer may still appeal a Reject in their own favour). Reported by audit_permissions, audit_flow and audit_economics (merged); reproduced with the Base harness (JudgeChecks.t.sol, test_doerAppealsAgainstOwnAcceptance).

**Reproduction**

USDC task 100e6; doer submits; dave disputes; voter0 votes accept with 120 NFTs; at vote end phase = Decided (Accept). vm.prank(doer); vault.appeal(subId, 'ipfs://weak-argument', max, noSwap) succeeds: getAppeal(subId).appellant == doer, sidesDoer == false. vm.prank(dave); vault.appeal(...) reverts WrongPhase; vm.prank(dave); vault.appealReply(...) reverts NotOpponent; vm.prank(doer); vault.appealReply(subId, 'ipfs://my-own-reply') succeeds (replied == true). Expected: the doer cannot be the appellant against their own submission, as they cannot be a disputer.

### 5. Low: One wallet may vote both sides with disjoint token sets and collects the correct-voter reward whichever side wins

`contracts/src/libraries/VoteLib.sol:103`

```
            if (seen & sideBit == 0) {
```

walletSides is a bitmask and nothing stops one signer from relaying an accept ballot with tokenIds [a..b] and a reject ballot with tokenIds [c..d]. Each NFT counts once, so the hedger's influence on the margin is zero, but the wallet is counted as a distinct winner wallet on both sides (walletsAccept and walletsReject each +1, which also feeds minWinnerWallets) and claimVoterReward pays payTo pro rata to the counted NFTs on the final side, so a hedged holder collects a share of the voter pool (50% of the loser bonds plus 6% of R when the NFT decided right) in every decided case without judging anything, diluting voters who actually judge. At IMD $7.65 on a $300 USDC task with 20 honest NFTs on the winning side, a 20-NFT hedger with 10 per side takes 1/3 of the voter buckets (~$8 per case) for nothing. Smallest fix: skip a ballot whose wallet already voted the other side on this submission (if (seen != 0 && seen != sideBit) -> BallotSkipped), so hedging needs a second wallet and an NFT transfer. Reported by audit_permissions and audit_flow (merged); reproduced with the Base harness (JudgeChecks.t.sol, test_walletVotesBothSidesAndIsPaid).

**Reproduction**

Voting phase; voters[1] owns tokenIds 120..139. submitVotes(subId, [Ballot(voters[1], side 1, payTo voters[1], [120..129], sig1), Ballot(voters[1], side 2, payTo voters[1], [130..139], sig2)]). Expected: a wallet votes one side. Actual: votesOf(subId, voters[1]) == (10, 10), Dispute.walletsAccept == 1 and walletsReject == 1. voter0 then votes accept with 120 NFTs; after Final Accepted, claimVoterReward(subId, voters[1]) pays 0.0385 IMD (10/130 of the 0.5 IMD voter bucket) to the hedger.

### 6. Low: Without a live YOLO price an unconfirmed sweetener bonus is returned in full to the sweeteners; payout is permissionless so anyone picks the moment

`contracts/src/YoloSweetener.sol:170`

```
        if (cap < bonus && !_swarmConfirmed(subId)) {
```

_cap returns 0 whenever hasPrice(yolo) is false (fewer than 5 samples in the last 2 h, i.e. the optional EOA keeper lapsed for about 25 minutes) or any oracle call reverts (a stale Chainlink feed). payout is permissionless, so for every accepted submission the swarm did not confirm (every small task without a dispute: no mandatory check under $300 and the doer cannot order one) the sweetener, or anyone, can call payout at a moment without a price: pay = 0, the whole bonus returns to the sweeteners, the doer gets 0 and payout cannot be rerun (returnBase != 0). The NatSpec says 'no price = 0' but this turns a promised bonus into one the giver can take back after the work is done, with no notice, by timing a call. Together with the sandwich finding on YoloPoolSamples (a pumped sample lowers the cap to 30,000 YOLO, about $3.45 at the opening price) the bonus a doer can rely on without a swarm 'yes' is small. Smallest fix: when no price is available keep the bonus (revert NothingToPay until a price exists, or fall back to paying the full bonus when the bonus value is unknowable), and let only the doer or the task's accepted doer trigger payout; let the doer order the mandatory check for a sweetened task whose bonus value is at or above largeRewardUsd. Reported by audit_permissions (and the cap remark by audit_math); reproduced with the Base harness (JudgeChecks.t.sol, test_sweetenerPayoutWithoutPriceReturnsAllToSweetener).

**Reproduction**

USDC task 100e6 (small); alice sweetens 100,000 YOLO; doer submits; 24 h later poke -> Completed (no dispute, no swarm answer). oracle.set(yolo, 0) (hasPrice false: keeper lapsed). alice calls payout(taskId, subId). Expected (docs: the doer gets the bonus up to the $300 cap): doer receives 100,000 YOLO minus the 10% fee. Actual: doer balance change 0, returnBase == 100,000e18, alice withdraws all 100,000 YOLO, bonusOf == 0.

### 7. Low: A swarm answer signed inside the timeout but delivered after it counts only if nobody poked first: whoever pokes at the deadline chooses silence over the answer

`contracts/src/desks/CaseDesk.sol:358`

```
        if (r.answered && r.answerTime <= deadline) return (SwarmStatus.Answered, r.answer, r.recordedAt);
```

_swarmStatus treats an answer as valid when its signed issuedAt (clamped to [askedAt, now] by TaskVault._recordAnswer) is within swarmTimeout, regardless of when the callback landed, but the step that turns a pending question into Silent is lazy and irreversible. For a question asked at T (timeout 6 h), an attestation with issuedAt = T+1h delivered at T+6h+60s is the swarm's final answer if the first call on the task comes after the delivery, and is ignored (silence: window reopens, caller's fee lost, mandatory check skipped) if anyone called poke(taskId) in [T+6h, delivery). The pending result is visible off chain before the writer's transaction, so the party the answer goes against can poke at exactly T+6h and turn a 'no' into silence (doer: check skipped, dispute window opens) or a 'yes' into silence (disputers on an appeal). Only matters when delivery is late, hence low. AttestationChecker checks no time field at all, so nothing else bounds it. Smallest fix: decide on on-chain time only: compare r.recordedAt with the deadline in _swarmStatus, or in onOracleResult ignore an answer whose request is already past askedAt + swarmTimeout. Reported by audit_flow; reproduced with the Base harness (JudgeChecks.t.sol, test_pokeAtDeadlineTurnsLateAnswerIntoSilence and test_lateAnswerCountsWhenNobodyPoked).

**Reproduction**

Large USDC task 300e6: doer submits, CheckState.Requested asked at T. Sequence A: at T+6h anyone calls poke(taskId); at T+6h+60s the Intake delivers a valid 'no' with issuedAt = T+1h; 24 h later poke -> outcome Accepted. Sequence B: no poke; the same 'no' delivered at T+6h+60s; poke -> outcome Rejected. Same inputs, opposite outcomes, chosen by whoever pokes.

### 8. Low: Author can pull the whole reward the moment the public proof appears, before the doer's submit lands (no notice on active shares)

`contracts/src/TaskVault.sol:305`

```
        if (st == TaskStatus.Expired || st == TaskStatus.Unwound || (st == TaskStatus.Open && t.activeSub == 0)) {
```

Business-logic asymmetry, documented in docs/MECHANICS.md ('Active share: while the task is Open with no active submission ... the author withdraws like any contributor') but not disclosed as a risk. Every proof type is a public artefact (an X post, a video, a mainnet transaction carrying the DOER CODE) that exists before submit() can be called. The author watches for it (or for the submit transaction in the mempool) and calls withdrawContribution first: the doer's submit reverts RewardTooSmall / RewardChanged and the author has the work for the 0.0005 ETH author fee (~$2). Unlike the sweetener (R-3: 1 hour public notice via announceWithdraw) the vault's active share has no notice. The doer loses no money (minReward protects the bond) but loses the work; a dishonest author's dominant strategy on every dare whose proof is public before submission. Recommendation: the same notice rule as YoloSweetener.announceWithdraw for active shares while a task is Open, or lock active shares until the deadline; state the current rule plainly on the site. Reported by audit_permissions; confirmed by code reading (withdrawContribution has no notice; existing test/Tasks.t.sol exercises the path).

**Reproduction**

Author creates an ETH task (pot 1 ETH, no deadline). Doer publishes the proof and sends submit(taskId, proofUri, evidence, minReward = 1 ether, ...). Author front-runs with withdrawContribution(taskId): pot becomes 0. Expected by a doer reading 'An author locks a reward': the reward is held until the dispute process ends. Actual: submit reverts RewardTooSmall (pot == 0) in the same block; the author keeps 1 ETH and the completed dare.

### 9. Info: AttestationChecker does not check expiresAt (nor a.chainId): an expired signed answer is recorded as final

`contracts/src/helpers/AttestationChecker.sol:51`

```
        if (a.agreed < a.quorum || a.agreed > a.panelSize) return 4;
```

The oracle-consumer reference's _verifyAttestation 'checks the window and the signature'. check() verifies answer type, panel, quorum, agreed and the signer; TaskVault clamps the signed issuedAt into [askedAt, now] to decide 'in time', but an attestation whose expiresAt has passed is accepted. Today the only sender is the Intake (msg.sender == via.intake), which delivers at once, so this is hardening: if the Intake ever re-delivers a stale signed answer (validForSeconds is 30 days in bodyFields) the vault records it. Smallest fix: return a new code when a.expiresAt != 0 && a.expiresAt < block.timestamp (fits the 200k callback budget). Reported by audit_economics; confirmed by code reading (no expiresAt read anywhere in check()).

**Reproduction**

Build an attestation with expiresAt = block.timestamp - 1 (Base._attestation sets expiresAt = now + 1 days; set a.expiresAt = uint64(block.timestamp - 1)), sign it with the attester key and deliver it through MockIntake.complete for a pending request. Expected: ignored (AttestationIgnored) and the case stays pending until the timeout. Actual: check returns 0 and _recordAnswer stores it as the final answer.

### 10. Info: Registry owner levers not disclosed: removing the attester (48 h) turns every live swarm answer into silence, and a future version may point the judge (intake, nft, delegateRegistry, priceOracle) at o

`contracts/src/ParamsRegistry.sol:16`

```
///         - The owner has no function that moves, freezes or redirects deposited money.
```

Trust assumptions to disclose rather than bypasses. (1) The attester set is global and applies to the Intake callbacks of tasks already running (AttestationChecker.check -> registry.isAttester). The single-EOA owner can proposeAttester(signer, false) and after 48 h anyone executes it; from then on every answer returns code 5 and CaseDesk treats each question as silent: mandatory checks pass, appeals are voided (NFT decision stands), NoDecision ends in the tie rule (accepted), callers' fees are lost. The owner thus biases all open cases toward doers without touching money. MECHANICS.md lists the attester set as owner-changeable but not this effect. (2) A proposed version (48 h public, in bounds) may change intake, nft, delegateRegistry and priceOracle; pointing a future version at an Intake or NFT contract the owner controls decides every case (and so every reward) created under that version. README.md 'there is no admin over funds' and this NatSpec line are true for live tasks and money already deposited, but the owner's power over future judging is the largest lever and should be in 'Trust assumptions'. Recommendation: disclose both; refuse removal of the last attester; put the owner key behind a hardware key or multisig with monitored proposals. Reported by audit_permissions and audit_economics (merged); confirmed by code reading.

**Reproduction**

(1) Registry with one attester A; task in Phase.Asked waiting for an appeal answer. Owner: proposeAttester(A, false); 48 h later anyone: executeAttester(A, false). Intake delivers the signed 'no'. Expected: Rejected. Actual: AttestationIgnored(rid, 15); at askedAt + 6 h step() marks the request Silent, drops the caller (fee lost), the NFT 'accept' decision stands and finalizes Accepted. (2) Owner: proposeParams(p) with p.intake = an owner-controlled contract (other fields in bounds); 48 h later activateParams(). Every task created afterwards asks that contract and accepts its callbacks (TaskVault.onOracleResult checks msg.sender == via.intake, the version's intake).

### 11. Info: LaunchPlan pins the version 1 Intake; the oracle-consumer reference says new consumers should use version 2

`contracts/script/LaunchPlan.sol:52`

```
    address internal constant INTAKE = 0x1397434cd35e8a9C8aC312A61D3A285EB31dea56;
```

LaunchPlan.INTAKE and the launch texts in docs/LAUNCH-TASK.md point ParamsRegistry version 1 at 0x1397434c..., which the oracle-consumer reference lists as 'Intake (version 1, still served)'; version 2 is 0xa43e6f75ee006411f79ac1c84120606c2330de82 and the reference says 'Use version 2 for anything new'. The code copes with a refused body or a non-agreeing panel by polling requests(rid).completed (silence), so no money is lost, but every task keeps its version's Intake for life (B-06), so a later retirement of version 1 would turn every large-task check, escalation and appeal of those tasks into silence (window again, caller's fee lost). The vault has no onOracleFailure hook, so 'disagreed' / 'body_refused' are indistinguishable from a timeout until the request is marked completed. Recommendation: launch version 1 of the registry against the version 2 Intake and verify priceOf(action, IMD) == 0.5e18 on it; consider requestWithFailure behind an owner flag in a later core version. Reported by audit_economics; confirmed against LaunchPlan.sol:52 and the reference table.

**Reproduction**

Compare contracts/script/LaunchPlan.sol:52 and docs/LAUNCH-TASK.md ('0x1397434cd35e8a9c8ac312a61d3a285eb31dea56') with the oracle-consumer reference table: version 2 is 0xa43e6f75ee006411f79ac1c84120606c2330de82. Expected: a new mainnet consumer points at version 2. Actual: version 1.

---

Judge's submission `b7d64a399b4e4790369d100bcf33ff2913efd559cae75c384751bd7800c9c1e1`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
