# Audit report

> Audit the smart contract code of moneycanbuy.fun for security bugs. Repository at commit d9c24e3b2d199548faf236d7bb5e80e4a12b6ada. Run forge at the repo root, import as "src/...". Modify no existing file; new files only under contracts/test/audit/.
>
> SOURCE OF TRUTH. Rules: docs/MECHANICS.md, then docs/YOLO.md and docs/TOKENOMICS.md. Code: all of contracts/src/. Docs win over comments. Do not re-report what contracts/test/audit/ proves fixed.
>
> SCOPE. Code security only. Payout amounts and role scenarios are separate orders: one info line here.
>
> REPORT FORMAT, MANDATORY. One entry per item, same order, even "no issue"; a missing item = INCOMPLETE. Entry 0: VERDICT, deployable or not, every blocker, max 8 lines. Items 1-14: severity, file:line, attack (who calls what, when), loss in tokens at a stated reward, smallest fix, and a Foundry PoC (full source, contracts/test/audit/, fails now, passes once fixed, no fork, no ffi) if real, else one line why not. Severity: critical = theft, permanent loss or freeze, a privileged wallet taking or redirecting user money; high = loss under realistic conditions or a documented rule broken; medium = bounded loss or cheap griefing; low = edge case; info = drift, hardening, "no issue".
>
> 1. USER APPROVALS, the project's first rule. Users approve our contracts; no path may take a user's tokens without their own call. For each pull (TaskVault.sol:555, 696, 769, 787, desks only via onlyDesk; YoloSweetener.sol:102, YoloPredict.sol:167, YoloLockHub.sol:85, DareTrophy.sol:114, adapters/UniswapV3SwapAdapter.sol:65) prove `from` is the caller (msg.sender or the vault-appended sender). Can anyone (deployer, owners too) make DisputeDesk._fundCall (sender = a.appellant, :287) pull from another wallet with an allowance, spoof the sender via the vault forwarding, or spend an allowance or Permit2 signature via admin, upgrade, delegatecall or arbitrary call? Any such path = critical.
> 2. BALANCE INVARIANT. TaskVault books every amount in one bucket (task pot, bonds, prepayments, dispute pot, settlement, treasury pot, credit); liabilities[token] = their sum <= balance. Find a path that books without moving money, moves without booking, books twice, or leaves a bucket nobody can claim. Same per market in YoloPredict (claims + burned + deferred + dust == positions), YoloSweetener, YoloLockHub, BuybackBurner.
> 3. STUCK MONEY. Every pull payment ends paid: claimDisputer, claimVoterReward(s), claimVoidSubmission, withdrawContribution, withdraw/withdrawTo, sweepTreasury, YoloPredict.claim, YoloSweetener, BuybackBurner forward/burnYolo. Rewards in ETH, USDC (blacklisted recipient), IMD, YOLO; reverting or gas-eating ETH receivers; the 30,000-gas send to the buyback wallet and its fallback. Can one party's revert block another's payout?
> 4. REENTRANCY AND ROUTING. One lock covers vault and desks; the vault forwards whitelisted selectors with sender and value appended (fallback, _route, Desk.routed, _sender). Reach a desk function meant only for the vault or reenter via ETH sends, tokens, onOracleResult, the delegate registry or ownerOf?
> 5. ACCESS RIGHTS. Prove what each privileged call can and cannot do. Rules: the vault has no owner. The ParamsRegistry owner changes parameters for future tasks only, inside ParamsBounds, after 48 h (same for swarm-answer attesters), pauses new task creation only, binds the Sweetener once. The dev wallet (DevPrices owner of the task's version) sets the YOLO, ETH and IMD prices by hand within 10x per step (USDC = $1), sets the buyback wallet, may cancel an open task. ProtocolDirectory, HiddenList and TrophyCatalog (owner = the ParamsRegistry owner) hold no money. Worst case in money for each stolen key. wire and initializers callable twice or by anyone?
> 6. CANCEL. cancelTask in every phase (no submission, dispute window, voting, appeal filed, swarm silent, queue, deadline passed with timers not played). Rule: everyone takes back exactly what he put in (reward, top-ups, YOLO add-ons, all bonds, unspent prepayment, queued doers, predictions 100%); paid swarm fees stay gone; the dev wallet gets nothing. Double refund, missed party, cancel blockable by a user?
> 7. NFT VOTE BY IDS. DisputeDesk.vote(subId, side, payTo, tokenIds), VotePower: ids owned now or delegated (delegate registry v2); each id once per dispute; side fixed; several transactions per side; power = ids; own transaction only. Break: one id twice (list repeat, transfer after vote, owner plus delegate), a lying ownerOf, huge ids, payTo on the other side or without a side, the minimum-wallets count, the instant decision racing a late vote, a list too long for a block.
> 8. APPEAL AND SWARM. appeal, askAppealSwarm, callSwarm, _fundCall, CaseDesk._ask, TaskVault.ask/onOracleResult, AttestationChecker. Rules: anyone appeals; a dispute party pays the fee from its bond, an outsider posts bond plus fee, capped by maxImdIn; the question goes out in a later call by anyone; max 3 swarm calls per submission; a failed ask, or an appeal not asked within 6 h, counts and refunds. Break: forged or replayed answer, answer after a cancel, gas starvation to fail an ask (ASK_GAS_RESERVE), price change before the ask, an appeal freezing the task.
> 9. YOLOPREDICT. predict, settle, claim, _split, _points. Rules: YOLO only, irrevocable, before the deadline, paused during review; 5% burn held to the outcome, 5% deferred, 90% pool, points = amount x ((deadline - now)/(deadline - creation))^2; Unwound = full refund. Break: a position during review or on a stale status, points overflow or zero, last-claimant dust, double settle, an empty side.
> 10. YOLOLOCKHUB. lock(add, 1..52 weeks), withdraw after the end, checkpoints, epoch; CaseDesk reads powerAt at submission (any power, no threshold), DisputeDesk re-checks stakePower at payout. Break: same-block lock, withdraw and relock keeping the bonus, extend-plus-add gaming, lost or double-withdrawn balance, costly checkpoint reads.
> 11. ROUNDINGS. Bonds (0.5 IMD + 3% or 1% of the author's own reward), stake share in bps, fee splits, prediction splits, voter shares: direction, who gets dust, a last claim that reverts.
> 12. DOS AND GAS. Unbounded loops, griefing with tiny top-ups, a reverting receiver blocking settlement, block gas limit on must-succeed paths, vote and claim at 2,000 NFTs; gas waste over 20% on a hot path.
> 13. TOKEN AND SIDE CONTRACTS. token/ (YoloToken first-hour buy and sell tax only on Uniswap v4 PoolManager transfers, NftGate, vestings, YoloSplitter), DareTrophy (7,777 YOLO burned per mint). TrophyCatalog: owner-only designs, a minted trophy keeps its design. Locked or stealable balances, wrong recipients, time math at openAt.
> 14. COMMENTS VS CODE. Each promise the code does not keep, one line, info.
> 15. COVERAGE. Files read in full, what was not reached, forge build and test result.
>
> Not findings: test/mocks; placeholder wallets; legal; brand/, landing/; Intake, Uniswap, delegate registry, IMD factory themselves. By design unless worse than stated: self-dispute from a second wallet; one late position taking the other side; many wallets in predictions; one lock for all submissions; a stale dev price moving only the stake bonus; ERC-6909 buys before openAt; untaxed v2/v3 pools; fees of silent swarm calls kept.

| | |
|---|---|
| Repository | https://github.com/sergey-mavr-agent/moneycanbuy.fun.git |
| Commit | `d9c24e3b2d199548faf236d7bb5e80e4a12b6ada` |
| Job | `fb6f02ff-22e6-404d-bfb9-a4db823cda0d` |
| Judged | 2026-10-19 11:31 UTC |
| Findings | 1 high · 1 medium · 4 low · 10 info |

Four agents audited the code as it is at `d9c24e3`, 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. High: appeal/callSwarm: maxImdIn does not cap the IMD 'rew' bond part on IMD-reward tasks (documented 'at most maxImdIn IMD' broken)

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

```
        if (bond.imd + pay > maxImdIn) revert CostAboveMax();
```

ITaskVault.appeal/callSwarm (ITaskVault.sol:97,107) and docs/MECHANICS.md:148 promise the caller pays 'at most maxImdIn IMD'. DisputeDesk._fundCall compares only bond.imd + pay (fixed 0.5 IMD bond + wallet-paid fee) with maxImdIn. When the task's reward currency is IMD, the dispute bond's 'rew' part (disputeBondBps = 1% of the author's own reward, also IMD) is pulled in the same collectImd call (_collectBond, DisputeDesk.sol:75: b.imd + extraImd + (rewIsImd ? b.rew : 0)) but is never counted against the cap. A stranger appellant or NoDecision caller who sets maxImdIn to the quoted fixed bond + fee has 1% of the author's reward pulled on top without a revert, and forfeits it with the bond if their side loses. Example at an IMD task with the author's reward 1000 IMD and fee 0.5 IMD: maxImdIn = 1 IMD, 11 IMD leave the wallet (10 IMD above the stated ceiling); at a 100,000 IMD reward it is 1,000 IMD. ETH/USDC/YOLO rew parts are not IMD and are outside the IMD cap by design (submit has its own maxBondRew for them). Reproduced with contracts/test/scratch/MaxImdInCap.t.sol: the appeal with maxImdIn = 1e18 does not revert. Smallest fix: in _fundCall compute the IMD cost as bond.imd + pay + (s.currency == address(imd) ? bond.rew : 0) and revert CostAboveMax when it exceeds maxImdIn. Merged: audit_permissions #169aee6e (reproduced).

**Reproduction**

State: IMD task, author's own reward 1000 IMD; a submission; one dispute by dave; 20 NFT votes reject (instantDelta 20, minWinnerWallets 1) -> Phase.Decided. quoteDisputeBond(subId) = {imd: 0.5e18, rew: 10e18}, quoteSwarmFee = 0.5e18. Input: stranger erin calls vault.appeal(subId, maxImdIn = 1e18, ImdPay(0,...)). Expected: revert CostAboveMax (IMD cost 0.5 + 10 + 0.5 = 11 IMD > 1 IMD). Actual: the call succeeds and transferFrom pulls 11e18 IMD from erin. `forge test --match-path contracts/test/scratch/MaxImdInCap.t.sol` -> FAIL 'next call did not revert as expected'; passes once the cap includes the IMD rew part.

**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 {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {TaskVault} from "src/TaskVault.sol";
import {ITaskVault} from "src/interfaces/ITaskVault.sol";
import {ParamsRegistry} from "src/ParamsRegistry.sol";
import {CaseDesk} from "src/desks/CaseDesk.sol";
import {DisputeDesk} from "src/desks/DisputeDesk.sol";
import {TextRules} from "src/helpers/TextRules.sol";
import {SwarmQuestionBuilder} from "src/helpers/SwarmQuestion.sol";
import {AttestationChecker} from "src/helpers/AttestationChecker.sol";
import {VotePower} from "src/VotePower.sol";
import {YoloLockHub} from "src/YoloLockHub.sol";
import {BuybackBurner} from "src/BuybackBurner.sol";
import {DevPrices} from "src/DevPrices.sol";
import {IIntake} from "src/interfaces/IIntake.sol";
import {VoteLib} from "src/libraries/VoteLib.sol";
import "src/VaultTypes.sol";

contract Tok is ERC20 {
    uint8 internal immutable _dec;

    constructor(string memory n, uint8 d) ERC20(n, n) {
        _dec = d;
    }

    function decimals() public view override returns (uint8) {
        return _dec;
    }

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

contract Nft is ERC721("id", "ID") {
    function mint(address to, uint256 id) external {
        _mint(to, id);
    }
}

/// @dev Minimal Intake: sells every action for `price` IMD, pulls it from the caller, never answers.
contract Intake is IIntake {
    address public immutable asset;
    uint256 public price;
    uint256 public nonce;
    mapping(bytes32 => bool) public known;

    constructor(address asset_, uint256 price_) {
        asset = asset_;
        price = price_;
    }

    function priceOf(bytes32, address token) external view returns (uint256) {
        return token == asset ? price : 0;
    }

    function requests(bytes32 id) external view returns (address, address, bytes4, bool) {
        return (known[id] ? address(1) : address(0), address(0), bytes4(0), false);
    }

    function request(bytes32, bytes calldata body, Callback calldata, address token, uint256 amount)
        external
        returns (bytes32 requestId)
    {
        require(token == asset && amount >= price && body.length != 0, "bad");
        IERC20(token).transferFrom(msg.sender, address(this), amount);
        requestId = keccak256(abi.encode(block.chainid, address(this), ++nonce));
        known[requestId] = true;
    }
}

/// @dev Deploys desks and vault inside one constructor (one transaction frame): Desk.wire's transient flag must still
///      be raised when the vault's constructor calls it.
contract Core {
    YoloLockHub public hub;
    CaseDesk public caseDesk;
    DisputeDesk public disputeDesk;
    address public vault;

    constructor(address registry, address imd, address usdc, address yolo, address prices, address nft) {
        TextRules text = new TextRules();
        hub = new YoloLockHub(yolo);
        BuybackBurner burner = new BuybackBurner(yolo, imd, usdc, prices, address(0xb0b));
        caseDesk = new CaseDesk(registry, imd, usdc, address(text), address(new SwarmQuestionBuilder()), yolo, address(hub));
        VotePower vp = new VotePower(nft, address(0));
        disputeDesk = new DisputeDesk(registry, imd, usdc, address(text), address(vp), yolo, address(hub));
        vault = address(
            new TaskVault(
                registry,
                imd,
                usdc,
                yolo,
                address(new AttestationChecker(registry)),
                address(text),
                address(caseDesk),
                address(disputeDesk),
                address(burner)
            )
        );
    }
}

/// @dev Finding: DisputeDesk._fundCall caps only `bond.imd + pay` by `maxImdIn`, but on an IMD-reward task the bond's
///      "rew" part (1% of the author's own reward, also IMD) is pulled in the same collectImd call. The documented
///      "at most maxImdIn IMD" (ITaskVault.appeal / callSwarm, MECHANICS "capped by their maxImdIn") is exceeded.
contract MaxImdInCapTest is Test {
    Tok imd;
    Tok usdc;
    Tok yolo;
    Nft nft;
    Intake intake;
    ParamsRegistry registry;
    ITaskVault vault;
    CaseDesk caseDesk;
    DisputeDesk disputeDesk;
    YoloLockHub hub;

    address multisig = makeAddr("multisig");
    address treasury = makeAddr("treasury");
    address devWallet = makeAddr("devWallet");
    address author = makeAddr("author");
    address doer = makeAddr("doer");
    address dave = makeAddr("dave");
    address erin = makeAddr("erin");
    address voter = makeAddr("voter");

    function setUp() public {
        vm.warp(1_800_000_000);
        imd = new Tok("IMD", 18);
        usdc = new Tok("USDC", 6);
        yolo = new Tok("YOLO", 18);
        nft = new Nft();
        intake = new Intake(address(imd), 0.5e18);
        DevPrices prices = new DevPrices(devWallet, address(usdc), address(imd), address(yolo), 0.0001e18, 2500e18, 8e18);
        registry = new ParamsRegistry(
            multisig,
            address(intake),
            address(prices),
            makeAddr("swapAdapter"),
            address(nft),
            makeAddr("delegates"),
            treasury,
            makeAddr("attester")
        );
        Core core = new Core(address(registry), address(imd), address(usdc), address(yolo), address(prices), address(nft));
        hub = core.hub();
        caseDesk = core.caseDesk();
        disputeDesk = core.disputeDesk();
        vault = ITaskVault(core.vault());
        address[4] memory people = [author, doer, dave, erin];
        for (uint256 i; i < 4; ++i) {
            imd.mint(people[i], 10_000e18);
            vm.prank(people[i]);
            imd.approve(address(vault), type(uint256).max);
        }
        for (uint256 id; id < 20; ++id) {
            nft.mint(voter, id);
        }
    }

    function _noSwap() internal pure returns (ImdPay memory p) {}

    function test_AppealPullsMoreImdThanMaxImdIn() public {
        // IMD task: the author's own reward is 1000 IMD
        vm.prank(author);
        uint256 taskId = vault.createTask(
            address(imd),
            1_000e18,
            uint64(block.timestamp + 365 days),
            "Title",
            TaskRules("ipfs://task-condition", "PROOF: one https link, DOER CODE on the page", "NO if: unclear"),
            0,
            PROOF_LINK,
            "",
            _noSwap()
        );
        vm.prank(doer);
        uint256 subId =
            vault.submit(taskId, "https://x.com/doer/status/1", Evidence("", ""), 0, type(uint256).max, _noSwap());
        vm.prank(dave);
        vault.dispute(subId, "ipfs://reason", "ipfs://reason-proof", _noSwap());
        // 20 NFTs reject: instantDelta reached, the NFT decides (Phase.Decided)
        uint256[] memory ids = new uint256[](20);
        for (uint256 i; i < 20; ++i) {
            ids[i] = i;
        }
        vm.prank(voter);
        vault.vote(subId, VoteLib.SIDE_REJECT, voter, ids);
        assertEq(uint8(vault.getSubmission(subId).phase), uint8(Phase.Decided), "not decided");

        // the quoted cost: fixed bond 0.5 IMD + 1% of 1000 IMD = 10 IMD (also IMD here) + fee 0.5 IMD = 11 IMD
        Bond memory b = vault.quoteDisputeBond(subId);
        assertEq(b.imd, 0.5e18);
        assertEq(b.rew, 10e18);
        assertEq(vault.quoteSwarmFee(subId), 0.5e18);

        // a stranger caps the call at 1 IMD: "at most maxImdIn IMD" (ITaskVault.appeal) must refuse it
        uint256 before = imd.balanceOf(erin);
        vm.prank(erin);
        vm.expectRevert(CostAboveMax.selector);
        vault.appeal(subId, 1e18, _noSwap());
        assertEq(imd.balanceOf(erin), before, "IMD left the stranger's wallet above maxImdIn");
    }
}
```

### 2. Medium: askAppealSwarm plays no timers: an appeal past its 6 h ask deadline is still asked (documented rule broken, outcome depends on tx ordering)

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

```
        if (_subs[subId].phase != Phase.Appealed) revert WrongPhase();
```

docs/MECHANICS.md:104 and :144 fix the boundary: an appeal not asked within swarmTimeout (6 h) is void, bond and fee refunded. CaseDesk.step enforces it only when a timer is played (Phase.Appealed branch, CaseDesk.sol:206-212). DisputeDesk.askAppealSwarm (DisputeDesk.sol:232-236) is the one user action that does not call advanced()/advance() first, and CaseDesk.askAppeal checks only phase == Appealed. After the deadline two different outcomes are reachable from the same state depending on which transaction lands first: poke(taskId) voids the appeal (bond + fee credited back, asks += 1, new 24 h appeal window) while askAppealSwarm spends the appellant's prepaid fee and asks the swarm, whose answer then decides the case. The protocol's own principle ('the outcome is the same whenever anyone pokes', CaseDesk._swarmStatus) is broken for this step, and anyone can keep a Decided submission unfinalised past the documented 6 h as long as nobody pokes, then still trigger the swarm. Money per occurrence: the 0.5 IMD fee refunded vs spent, and possibly a different final outcome (swarm answer vs NFT decision), i.e. the whole loser-bond split. Reproduced with contracts/test/scratch/LateAppealAsk.t.sol. Smallest fix: play the task's timers first, e.g. ICaseDesk(caseDesk).advanced(subId) in DisputeDesk.askAppealSwarm before askAppeal (or _bank().advance(_subs[subId].taskId) at the top of CaseDesk.askAppeal), so a late ask reverts WrongPhase after step() voided the appeal. Merged: audit_math #84e6065a (medium) and audit_flow #354c6097 (info); both reproduced, severity kept at medium because a documented rule is broken and the final outcome of a submission can change.

**Reproduction**

Fixture: IMD task 1000 IMD, doer submits, dave disputes, 20 NFTs vote reject -> Decided (Reject); erin (stranger) calls vault.appeal(subId, max, noSwap) -> Phase.Appealed, fee 0.5 IMD prepaid. Warp 6 h + 1 s with no poke. Input: anyone calls vault.askAppealSwarm(subId, correct RuleTexts). Expected (docs): the appeal is void, the ask reverts WrongPhase, the Intake gets nothing, poke credits erin 0.5 + 10 + 0.5 IMD. Actual: phase becomes Asked, the Intake receives 0.5 IMD, the swarm answer will decide the submission. `forge test --match-path contracts/test/scratch/LateAppealAsk.t.sol` -> FAIL 'next call did not revert as expected' on this code.

### 3. Low: YoloLockHub.stakePower pays the submission-time power of a lock that already ended and was merely re-set with a 1-week term (same epoch)

`contracts/src/YoloLockHub.sol:111`

```
        if (p.epoch != l.epoch || l.end <= block.timestamp || p.end <= since) return 0;
```

The payout check requires the current lock to run and the submission-time point to belong to the same epoch; the epoch only grows in withdraw (YoloLockHub.sol:94). A lock whose term ended but was not withdrawn can be set again with lock(0, 1) (lines 62-87: old = 0 because the lock ended, the comment says 'a lock set again starts afresh'), which creates a new 1-week lock under the SAME epoch. For a submission made under the old 52-week term, stakePower then returns the old full power although the only lock running at the payout is a 1-week lock with 1/52 of it; without the re-set the same doer gets 0. Example: 1,000 YOLO locked 52 weeks (power 1,000), submission for a task whose author reward is worth 1,000 YOLO -> stakeBps 400; the lock ends during the dispute/appeal/swarm; the doer calls lock(0,1) one block before the payout and still receives 4% of R from the stakers' share (and 4% of the YOLO add-on, YoloSweetener.payout reads the same Settlement.stakeBps). Bounded: at most 4% of R, taken from the treasury/burn part of the fee, not from users, and the YOLO stays locked a week; hence low. Reproduced with contracts/test/scratch/RelockKeepsPower.t.sol. Smallest fix: in lock, when the previous term ended (l.end <= block.timestamp) start a new epoch (epoch + 1 in the stored Lock and the checkpoint), matching the comment; or in stakePower also require p.end > block.timestamp. Merged: audit_math #8a3928e1 (reproduced).

**Reproduction**

Deploy YoloLockHub(yolo). doer: lock(1_000e18, 52) at T; since = T+1: powerAt(doer, since) == 1_000e18. warp to since + 52 weeks: stakePower(doer, since) == 0 (ended). doer: lock(0, 1). Now power(doer) == 1_000e18/52 but stakePower(doer, since) == 1_000e18. Expected: 0 (or at most the running 1-week power). `forge test --match-path contracts/test/scratch/RelockKeepsPower.t.sol` -> FAIL '1000000000000000000000 > 19230769230769230769'.

**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 {YoloLockHub} from "src/YoloLockHub.sol";

contract YoloTok is ERC20("YOLO", "YOLO") {
    function mint(address to, uint256 a) external {
        _mint(to, a);
    }
}

/// @dev Finding: a lock whose term ended but was not withdrawn can be set again with `lock(0, 1)` under the SAME
///      epoch (the epoch only grows in `withdraw`). For a submission made under the old 52-week term,
///      `stakePower(who, since)` then returns the old full power although the only lock running at the payout is a
///      1-week lock with 1/52 of it. Without the re-set the same doer gets 0.
contract RelockKeepsPowerTest is Test {
    YoloTok yolo;
    YoloLockHub hub;
    address doer = makeAddr("doer");

    function setUp() public {
        vm.warp(1_800_000_000);
        yolo = new YoloTok();
        hub = new YoloLockHub(address(yolo));
        yolo.mint(doer, 1_000e18);
        vm.prank(doer);
        yolo.approve(address(hub), type(uint256).max);
    }

    function test_EndedLockRelockedOneWeek_KeepsOldStakePower() public {
        vm.prank(doer);
        hub.lock(1_000e18, 52);
        uint64 since = uint64(vm.getBlockTimestamp() + 1); // the submission, one second later
        vm.warp(since);
        assertEq(hub.powerAt(doer, since), 1_000e18, "submission-time power");

        // the 52-week term ends before the payout; the lock is not withdrawn
        vm.warp(since + 52 weeks);
        assertEq(hub.stakePower(doer, since), 0, "ended lock pays nothing");

        // the doer sets a new 1-week lock on the same amount (no withdrawal: same epoch)
        vm.prank(doer);
        hub.lock(0, 1);
        assertEq(hub.power(doer), uint256(1_000e18) / 52, "the running lock is a 1-week lock");

        // expected: the lock behind the submission ended, the payout check returns 0 (or at most the running power);
        // actual: the full 52-week power is paid out of a 1-week lock
        assertLe(hub.stakePower(doer, since), uint256(1_000e18) / 52, "stakePower pays the ended 52-week lock's power");
    }
}
```

### 4. Low: BuybackBurner.pull lets anyone make the burner call withdraw(address) on an arbitrary target

`contracts/src/BuybackBurner.sol:122`

```
        IVaultWithdraw(vault).withdraw(token);
```

`vault` is caller-supplied, so the burner (which holds ETH, USDC, IMD and YOLO) issues an attacker-chosen external call with msg.sender = burner. No contract in the repository exposes a withdraw(address) that would move the burner's funds and the burner grants no allowances, so no loss was found with the in-scope code; it is an unnecessary arbitrary-call surface (a future token or contract that acts on withdraw(address) from the burner would be reachable). Hardening: pin the vault (constructor argument or ProtocolDirectory lookup), or drop pull. Merged: audit_permissions #2737fd78 and audit_flow #8b2e73ac (reproduced by code reading: lines 120-123 take `vault` from calldata with no check).

**Reproduction**

Deploy any contract A with `function withdraw(address) external { emit Called(msg.sender); }`. Any EOA calls burner.pull(address(A), address(0)). Expected: the burner only talks to the project's vault. Actual: A receives the call with msg.sender == burner. No funds move with the in-scope contracts.

### 5. Low: claimVoterRewards skips the YoloSweetener voter share when the add-on arrived after the submission

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

```
        if (s.addon != 0 && s.outcome == Outcome.Accepted) bonusTask = s.taskId;
```

Submission.addon is a snapshot of YoloSweetener.bonusOf(taskId) at submit (CaseDesk.submit:112, s.addon = t.addon). YoloSweetener.sweeten (YoloSweetener.sol:92-104) accepts deposits while a submission is under review (it checks only Open and the deadline) and payout pays the whole bonusOf at Completed with an NFT voter pool. If the add-on was 0 at submission and arrived during the dispute, bonusTask stays 0 and claimVoterRewards never calls YoloSweetener.claimVoter, contrary to its NatSpec (DisputeDesk.sol:377: 'An accepted submission with a $YOLO add-on also claims payTo's share'). Nothing is lost: payTo or anyone can call YoloSweetener.claimVoter(taskId, payTo) directly, so low (UX/consistency). Fix: set bonusTask = s.taskId whenever outcome is Accepted and a sweetener is bound (the call is already inside a try). Merged: audit_economics #0674bda5 and audit_flow #3aa681ec (reproduced by code reading).

**Reproduction**

Task 1 (1,000 USDC, no add-on). Doer submits (s.addon = 0). Dave disputes; during the vote Carol calls YoloSweetener.sweeten(1, 10_000e18). NFT holders vote accept -> Accepted; sweetener.payout(1): voterPool = 500e18. vault.claimVoterRewards([subId], V): V receives the IMD/USDC voter shares, voterClaimed(1, V) stays false and V receives 0 YOLO; a direct sweetener.claimVoter(1, V) then pays it.

### 6. Low: DevPrices '10x per step' bound has no cooldown: any price is reachable in one transaction

`contracts/src/DevPrices.sol:89`

```
        if (next > current * MAX_STEP || next * MAX_STEP < current) revert PriceSanity();
```

_check compares only with the current stored price and no delay separates steps, so the dev wallet can chain setYoloPriceUsd calls in one transaction and move a price by any factor; the documented 'one change moves it at most 10x' (MECHANICS.md:252,303) is a typo guard, not a bound. The only money effect is CaseDesk._stakeBps (DevPrices.yoloValue): with yoloPriceUsd raised 1000x any lock power >= base/1000 in YOLO gets the full 4% stake bonus. The bonus comes out of the stakers' 5% (treasury + burn), not out of user deposits, so a stolen dev key redirects at most 4% of each accepted reward from treasury/burn to a colluding doer; a stale price moving only the stake bonus is listed as by design, so low/hardening. Fix: a minimum interval between price changes per token, or bound the change relative to a price at least N hours old. Merged: audit_economics #ddd60b59 (reproduced by code reading).

**Reproduction**

DevPrices with yoloPriceUsd = 0.01e18. Owner calls setYoloPriceUsd(0.1e18), setYoloPriceUsd(1e18), setYoloPriceUsd(10e18) in one transaction: all three pass _check, the price is 1000x. A doer with 1 YOLO locked 52 weeks (power 1e18) submitting to a 1,000 USDC task now gets bps = 4 (yoloValue = 100e18 instead of 100,000e18); at a 100,000 USDC base it reaches the full 4%. Expected per a strict reading of 'at most 10x per step': a cooldown; actual: unlimited move in one transaction.

### 7. Info: Swarm callback does not bind the attestation to the asked question (questionHash) nor consume a.requestId

`contracts/src/TaskVault.sol:477`

```
        uint8 code = attestationChecker.check(address(this), via.minPanelSize, via.minQuorum, a, signature);
```

onOracleResult accepts any attestation signed for the vault's EIP-712 domain with a bool answer and enough panel, provided msg.sender is the Intake stored for requestId (lines 463-476 check r.subId, via.intake and r.answered). SwarmRequest.questionHash is stored by ask() but never compared with a.questionHash, and a.requestId (the oracle UUID) is not recorded as consumed. The oracle-consumer rules recommend comparing a.questionHash first, since anyone can pay 0.5 IMD for their own question naming the vault as consumer and obtain a signature in the vault's domain. Because the intake requestId must be one the vault created and the caller must be that Intake, a mis-pairing needs the trusted Intake writer to deliver another request's attestation under the vault's id; MECHANICS.md:398 records that keccak256(question) may not equal the plane's hash, so the fix needs the plane-compatible hash (OracleQuestion.hash with an absolute window). Hardening: store that hash at ask and require a.questionHash == r.questionHash; keep a mapping of consumed a.requestId. Merged: audit_economics #ecf89f0e (code reading confirms no questionHash comparison).

**Reproduction**

Mock Intake records request rid1 for subId 7 (questionHash Q7). An attestation A with questionHash Qx != Q7, answer true, panel ok, signed by an attester for the vault's domain, delivered by the Intake as onOracleResult(rid1, A, sig): TaskVault records answered = true for subId 7 (expected under a binding check: AttestationIgnored). Requires the Intake (trusted) to deliver it under rid1.

### 8. Info: A disputer who appeals for the doer's side pays a full stranger bond but gets no stranger fee refund

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

```
        bool appellantIsStranger = hasCaller && a.appellant != f.doer && a.appellant != d.disputer;
```

_fundCall (line 292) treats the disputer as 'a side' only when !sidesDoer; a disputer appealing a Reject decision for the doer's side (sidesDoer = true) posts the full second bond and pays the whole fee from the wallet like a stranger. At settlement the stranger fee carve-out (lines 636-640) excludes any appellant equal to d.disputer, so this caller gets no 0.5 IMD fee back while an unrelated wallet in the same position does. Asymmetry only, bounded by bondFixedImd. Fix: define 'stranger' in _settleDecided by the test _fundCall used, e.g. a.fromBond == 0 && a.bond.imd != 0. From audit_permissions #d68add12 (reproduced by code reading).

**Reproduction**

Dispute by D, NFT decides Reject, D appeals with sidesDoer = true (only the doer is restricted), posts 0.5 IMD + 1% and the fee, swarm answers Accept: D is credited a.funds + a.bond + appPart but not strangerFee; an unrelated appellant in the same flow is also credited min(bondFixedImd, penaltyImd).

### 9. Info: DisputeDesk.vote: a payTo record accumulates votes without a side, so one payTo can hold accept and reject counts

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

```
            if (side == VoteLib.SIDE_ACCEPT) pr.accept += uint120(power);
```

`side` is fixed per voter wallet (lines 145-146), but a payTo named by another voter only gets its accept/reject counter incremented (lines 162-166) and its own side stays 0. The payTo wallet may later vote itself for the other side and is paid whichever side wins. Each NFT still counts once on one side and voterVotesLeft equals the sum of recs on the final side, so no money is created; a hedging possibility the 'one side per wallet' wording does not cover. From audit_permissions #d2d8b09e (reproduced by code reading).

**Reproduction**

A votes 5 ids accept with payTo = B; B votes 3 ids reject with payTo = B. votesOf(subId, B) = (5, 3); B claims on whichever side is final. No double payment.

### 10. Info: Params.nft and Params.delegateRegistry are not read by the vote path; VotePower's immutables decide

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

```
            (,, uint32 instantDelta, uint32 minWallets) = ParamsRegistry(registry).voteParams(version);
```

VaultParams.sol:16-17 says a version's nft / delegateRegistry govern voting and address(0) disables delegated votes. DisputeDesk.vote discards those two return values of voteParams and checks holders only through the immutable VotePower(nft_, delegateRegistry_) fixed at launch (VotePower.sol:21-24, DisputeDesk.sol:155). A proposed version with delegateRegistry = address(0) changes nothing. MECHANICS 'Trust assumptions' states the VotePower rule correctly; the struct comments and bounds are the drift. No money impact. From audit_flow #108192c3 (reproduced by code reading).

**Reproduction**

Owner proposes and after 48 h activates params identical to version 1 except delegateRegistry = address(0); a task under version 2; a delegate of an NFT owner votes with the owner's ids. Expected per the struct comment: NotTokenHolder. Actual: the vote counts.

### 11. Info: Burned event comment says $YOLO goes straight to 0x…dEaD; the vault sends it to the BuybackBurner

`contracts/src/VaultTypes.sol:364`

```
/// @dev `amount` of `token` left for burning: $YOLO straight to 0x…dEaD, any other token to the BuybackBurner.
```

TaskVault._burn (TaskVault.sol:665-669) pays $YOLO to `burner` where it waits for anyone's burnYolo; ETH/USDC/IMD try the buyback wallet first and only fall back to the burner. The event doc states the opposite for both branches. MECHANICS.md:192 matches the code; comment-vs-code drift only. From audit_flow #9aba5700 (reproduced by code reading).

**Reproduction**

Accept a $YOLO task with a staked doer: Burned(yolo, half) and Paid(yolo, burner, half) are emitted; the balance of 0x…dEaD is unchanged until BuybackBurner.burnYolo().

### 12. Info: collectBondPart/_collect book `amount` for USDC/YOLO without a received-amount check (unlike _collectImd)

`contracts/src/TaskVault.sol:555`

```
        IERC20(currency).safeTransferFrom(from, address(this), amount);
```

_collectImd checks the balance delta (ImdShort) but collectBondPart (line 555-556) and _collect (line 696-697) book `amount` after a plain safeTransferFrom. Correct for USDC and for YOLO today (YoloToken taxes only transfers to/from the PoolManager; a wallet -> vault transfer is untaxed), so liabilities <= balance holds; it would break the balance invariant if YOLO ever taxed wallet transfers. Hardening only. From audit_permissions #940fec58.

**Reproduction**

No failing input with the in-scope tokens: a wallet -> vault YOLO transfer delivers exactly `amount`.

### 13. Info: cancelTask cannot cancel a task whose deadline passed unpoked: it expires first and YoloPredict pays WON'T DO instead of 100% refunds

`contracts/src/TaskVault.sol:370`

```
        if (t.status != TaskStatus.Open) revert TaskNotOpen();
```

cancelTask runs _advance before the status check (lines 369-370); a task with no active submission and deadline <= now becomes Expired in that call and cancelTask reverts TaskNotOpen. This matches MECHANICS.md:37 ('cancelTask of an Open task') and the NatSpec, so no rule is broken, but the dev cancellation cannot give predictors 100% back once the deadline passed: YoloPredict resolves the WON'T DO side as winner. Contributions, add-ons and bonds still come back through the Expired paths. Document it. From audit_economics #83adc12e (reproduced by code reading).

**Reproduction**

Task 1 deadline T. At T+1 (nobody poked) the dev wallet calls cancelTask(1): _advance closes it as Expired and the call reverts TaskNotOpen; WILL DO predictions get 0.

### 14. Info: YoloLockHub: weeks left round up, so YOLO added in the last second of a term earns a full week of power

`contracts/src/YoloLockHub.sol:79`

```
        uint256 left = (end - block.timestamp + WEEK - 1) / WEEK; // whole weeks, rounded up
```

lock(add, 0) on a running lock computes `left` as ceil((end - now) / WEEK). With 1 second left the added amount gets add * 1 / 52 power although it is locked for one second. No payout can be collected inside that second (stakePower needs the lock running at the payout, >= 24 h after any submission), so the only use is to re-set the lock for >= 1 week, which lock(add, 1) would have cost anyway. Hardening only. From audit_math #cd7afe3e (reproduced by code reading).

**Reproduction**

lock(1_000e18, 1) at T; warp T + 7 days - 1; lock(1_000e18, 0): Locked reports power 2 * 1_000e18 / 52 while the second 1,000 YOLO is withdrawable one second later.

### 15. Info: Trust assumptions and access matrix: no path pulls a user's tokens except from the vault-appended sender; the dev wallet can redirect every buyback share, the registry owner the attester set after 48

`contracts/src/BuybackBurner.sol:24`

```
///           with one transaction and an event (`setBuybackWallet`); the dev cannot send the money anywhere else.
```

Verified by reading all of DisputeDesk, CaseDesk, Desk, YoloLockHub, BuybackBurner, DevPrices and the vault's money paths: every pull (TaskVault collectImd/collectBondPart/_collect via onlyDesk; sweetener, lock hub, trophy, adapter, predict use msg.sender) takes `from` from msg.sender or the vault-appended _sender(); DisputeDesk._fundCall's a.appellant is set from _sender() in the same call (DisputeDesk.sol:217, 262). No delegatecall, selfdestruct, Permit2 or arbitrary-call path in the core; desk-only entry points are guarded by only(vault|caseDesk|disputeDesk); Desk.wire is one-shot behind a transient flag raised in the constructor (works only in the deployment transaction, cannot be repeated). Privileged powers (trust assumptions, not bypasses): (a) the dev wallet's setBuybackWallet (BuybackBurner.sol:75-80) has no timelock and TaskVault._burn reads buybackWallet() at every payout, so the dev can point every ETH/USDC/IMD burn share (the staked-doer half of the stakers' 5% and the unearned NFT 5%) at any address including itself; the notice 'cannot send the money anywhere else' and MECHANICS.md:287 overstate the constraint; user deposits, rewards and bonds are never touched. (b) The ParamsRegistry owner adds attesters after 48 h; the attester set is global (AttestationChecker.sol:55 reads registry.isAttester at delivery), so an added attester signs for tasks of older versions too, but an answer still has to be delivered by the Intake stored for the request (TaskVault.sol:468-472), so deciding a swarm question also needs a version whose intake is an owner contract (future tasks only, 48 h notice). (c) cancelTask refunds only; DevPrices moves only the stake bonus; bindSweetener is once; ProtocolDirectory, HiddenList and TrophyCatalog hold no money. Worst case per stolen key: dev wallet = all future burn shares plus what sits in the burner; registry owner = the pots of tasks created under a version it configured after 48 h of public notice. Merged: audit_permissions #03d71602, audit_economics #a7dee71c, audit_flow #230eddf2.

**Reproduction**

As the DevPrices owner call burner.setBuybackWallet(devEOA); finalize an accepted USDC task with a staked doer: Paid(usdc, devEOA, half + nftPart) lands in the dev's wallet in the same transaction. Access matrix: n/a (no issue).

### 16. Info: Verdict and coverage: deployable after the maxImdIn cap fix; forge build OK; scratch proofs fail as stated

`contracts/src/TaskVault.sol:1`

```
// SPDX-License-Identifier: MIT
```

VERDICT: deployable once the IMD cap in DisputeDesk._fundCall counts the IMD 'rew' bond part (high, documented rule broken; one-line fix) and askAppealSwarm plays the timers first (medium). No critical path found: no theft, no permanent freeze, no privileged wallet taking user money. COVERAGE: read in full contracts/src/desks/DisputeDesk.sol, desks/CaseDesk.sol, desks/Desk.sol (wiring), YoloLockHub.sol, BuybackBurner.sol, VotePower.sol; in part TaskVault.sol (constructor, topUp, withdrawContribution, cancelTask, bank, onOracleResult, _burn), DevPrices.sol, YoloSweetener.sol, VaultTypes.sol, VaultParams.sol, ParamsRegistry constructor, interfaces. Not re-read: token/, DareTrophy, TrophyCatalog, HiddenList, ProtocolDirectory, YoloPredict, helpers/SwarmQuestion, helpers/TextRules, adapters (the specialists reported nothing there that needed reproduction). forge build (via_ir, solc 0.8.28) succeeded in 8 min 54 s; contracts/test/CancelTask.t.sol: 8 passed; the full suite was not run in the time budget. The three scratch proofs (MaxImdInCap, LateAppealAsk, RelockKeepsPower) all fail on this code for the stated reasons. Payout amounts and role scenarios are separate orders and were not re-derived.

**Reproduction**

n/a (coverage statement).

---

Judge's submission `04d58be6a3be12bc1ff5ae71b3ea1db6a678b1ba117bfd3719ae39d56e4df9ac`, 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.
