# Audit report

> Audit every payout and the token economics of moneycanbuy.fun before mainnet launch. Repository at commit 84b446c0ff513a39c60486edada40a4a1d781b6b. Run forge at the repo root, import as "src/...". Modify no existing file; new files only under contracts/test/audit/.
>
> SOURCE OF TRUTH. The RULES below are the money rules; docs/MECHANICS.md, YOLO.md and TOKENOMICS.md restate them. Code or docs off the RULES = a finding. Do not re-report what contracts/test/audit/ proves fixed.
>
> SCOPE. Amounts: who gets how much in every outcome, and who can game it. Code security and role scenarios are other orders; one info line each.
>
> RULES. R = the reward in its currency (ETH, USDC, IMD or YOLO). A = the author's own share (own top-ups in; others' top-ups and YOLO add-ons out). Payouts only from money the paying contract holds; token funds pay nothing. A user's tokens leave their wallet only in their own call; anyone else spending a user's allowance = critical.
> - Fee 10% of R, only on acceptance. Of it 5% to the right NFT votes if the NFT decided right, else "unearned NFT 5%"; the other 5% = stakers' part.
> - No stake: doer 90%, treasury 10% (5% if the NFT 5% was earned).
> - Staked doer: b = 4% x min(1, power / A in YOLO at the price parameter), floored to whole bps, no threshold. Doer 90% + b; (5% - b) half burn, half treasury; unearned NFT 5% burn. $100, no dispute, doer/burn/treasury: full power 94/5.50/0.50; half 92/6.50/1.50. b is paid on all R and the add-on.
> - Power = amount x weeks / 52 (1-52 weeks), fixed at the lock, never falling; add or extend: max(old + added x weeks left / 52, total x weeks left / 52). Read before the submission block; the lock must still run at payout or b = 0.
> - Burn: YOLO to the BuybackBurner, then burnYolo to 0x...dEaD; ETH, USDC, IMD to the buyback wallet at payout.
> - Doer bond 0.5 IMD + 3% of A in R's currency; dispute bond 0.5 IMD + 1% of A. Never doubled; each currency splits alone.
> - Loser's bond: NFT decided and the outcome matches: 50% winning side (pro rata to bonds), 50% right NFT votes; NFT wrong or undecided: 100% winning side. The winner's own bond comes back.
> - NFT undecided and no swarm call: accepted, the disputer's bond 100% to the doer.
> - Rejected: R stays, open for the next submission.
> - Appeal: a dispute party pays the 0.5 IMD fee from its bond (again: from its wallet); an outsider posts a dispute bond + fee and, winning, first gets 0.5 IMD off the loser's bond if IMD is left in it. Max 3 swarm calls per submission; a silent call's fee is gone; a failed ask or an appeal not asked within 6 h counts, bond and fee back; after the third, outcome as without appeal.
> - Top-ups and YOLO add-ons are locked to the deadline; nobody accepted: each funder takes back their own. A top-up during review joins R. Submitted before the deadline, accepted after = done.
> - Cancel only by the dev wallet, any open phase: all back to who put it in, predictions 100%; swarm fees paid stay gone.
> - YoloPredict, YOLO only, irrevocable, until the deadline, paused during review. Entry 100: 5 burn, 5 deferred, 90 pool. Points = 90 x ((deadline - now)/(deadline - creation))^2. Winners: their 90 back + the losing pool by points. Deferred: done -> 2.5% author, 2.5% doer; else burn. Done with nobody on WILL DO: the losing pool half author, half doer; not done with nobody on WON'T DO: all to author. Cancel = 100% back, no burn.
> - Trophy: 7,777 YOLO, burned.
> - Token: 1,000,000,000 YOLO per docs/TOKENOMICS.md. Buy tax 30% -> 0 in minutes 30-60 after openAt; minutes 0-30 only snapshot wallets buy, 500,000 YOLO per NFT, untaxed. Sell tax 30% in 0-30, 30% -> 0 in 30-60, on top, to the treasury wallet.
>
> Prices (no network): IMD $4.50, ETH $2,398, YOLO $0.001 (parameter), swarm call 0.5 IMD = $2.25, gas 0.12 gwei; in code every price is a dev-wallet parameter, USDC = $1.
>
> KNOWN LIMITATIONS. Every line under Known limitations in docs/MECHANICS.md is an accepted rule, not a finding. Report one only if the documented rule itself is economically abusable: who profits, how much, at what cost, in tokens and $ at a stated reward. Also accepted: the disputer may call the swarm for the doer as an outsider; a winning outsider's 0.5 IMD comes back only from IMD left in the loser's bond; nothing checks that a proof is newer than the task: the burden of proof is the doer's, an old or foreign proof is for the dispute and the swarm.
>
> GOAL. Every high and medium finding with a Foundry PoC; low and info in a separate list at the end, one line each, no PoC needed.
>
> REPORT FORMAT, MANDATORY. One entry per item, same order, even "no issue"; a missing item = INCOMPLETE. Entry 0: VERDICT, max 8 lines: payouts vs RULES, every money blocker. Items 1-15: what you checked, file:line, severity (critical = theft, permanent loss or freeze; high = a RULES amount broken; medium = bounded loss or profitable gaming; low = dust, edge case; info = drift), loss in tokens and $ at a stated R, smallest fix, a Foundry PoC (full source, contracts/test/audit/, exact amounts, fails now, passes once fixed, no fork, no ffi) if real, else one line why not.
>
> 1. ACCEPTED, NO DISPUTE. DisputeDesk._payReward: the $100 examples in USDC, ETH, IMD, YOLO; b = 0.
> 2. DISPUTE, NFT RIGHT. Accepted and rejected: loser's bond 50/50, winner's bond back, voter shares.
> 3. NFT WRONG OR UNDECIDED. Swarm overturns or decides; undecided, no call: unearned NFT 5%, 100% of the loser's bond.
> 4. REJECTED. R after a fake, the next submission's bond and payout, claimVoidSubmission.
> 5. APPEAL MONEY. _penaltyAndWinnerBonds, _splitCurrency, _sideSpent, _charge: party vs outsider, second call, three silences, failed ask. Each party's net in IMD and R per case.
> 6. BONDS. Base A, rounding at the minimum rewards, who keeps dust.
> 7. STAKE BONUS. CaseDesk._stakeBps, YoloLockHub, _lockRuns: base A not R, floor to bps, add/extend after submission, lock ended before payout.
> 8. YOLO ADD-ON. YoloSweetener: same split as R, voters only after a right NFT decision, refunds.
> 9. EXPIRY AND CANCEL. withdrawContribution, cancelTask: each funder their part.
> 10. YOLOPREDICT. settle, claim, _split, _points: claims + burned + deferred = paid in, empty sides, a winning side with zero points, void.
> 11. BURNS. Every burn path to its end; monthly burn vs unlock at 200 tasks/month, R $100.
> 12. TOKENOMICS VS CODE. token/, LaunchPlan: split = 1e27, dust, unlock table, both taxes at minutes 0, 15, 30, 45, 60, tax recipient.
> 13. INCENTIVES. Net $ per action at the minimum rewards and R $100: disputing a fake, an NFT vote, an outsider appeal vs gas; stake recycling via own top-ups, griefing cost.
> 14. DOCS VS CODE. Every amount in the three docs, one line each.
> 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, IMD factory; the name and size of the "Stabilizer / MM fund". By design unless worse than stated: self-dispute from a second wallet; one late position taking the other side; many wallets in predictions; an author profiting from a task nobody does; one lock for all submissions; a stale price moving only the stake bonus; no task limits; 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 | `84b446c0ff513a39c60486edada40a4a1d781b6b` |
| Job | `20f559d5-78bb-4ec6-91f9-54b724d1ecc5` |
| Judged | 2026-10-19 21:35 UTC |
| Findings | 2 medium · 3 low · 5 info |

Four agents audited the code as it is at `84b446c`, 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: Self-dispute by a 10-NFT holder takes the NFT 5% of R and half of its own bond: +4.5% of R per task from the treasury, linear in R

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

```
    function vote(uint256 subId, uint8 side, address payTo, uint256[] calldata tokenIds) external routed {
```

Merged from audit_math (medium) and audit_economics (medium). vote() checks only the phase, the side, payTo and NFT ownership: nothing excludes the submission's disputer (or its payTo) from voting, and nothing stops it voting ACCEPT against its own dispute. dispute() excludes only the doer. With the launch parameters (VaultParams.sol LaunchParams.initial: instantDelta 20, endDelta 10, minWinnerWallets 1) one wallet holding 10 identity.md NFTs disputes an honest submission (bond 0.5 IMD + 1% of A), votes ACCEPT with its 10 ids, and is the only 'right' voter at the vote end (CaseDesk._tryDecide -> _decide with endDelta). settle() then sets nftRight, _payReward books nftFeeBps = 5% of R into voterFee, and _settleDecided books 50% of the loser's bond (the wallet's own) into voterBond; _claimVoter pays all of it to the same wallet (100% of the accept votes). Net per task in R's currency: +5% R - 1% A + 0.5% A = +4.5% of R when R = A, against -0.25 IMD ($1.13 at $4.50) and cents of gas at 0.12 gwei. At R = 10,000 USDC: +450 USDC net; at R = $100: +$3.38. Loser: the treasury (500 USDC instead of 1,000 on the $10k task: the whole NFT 5% leaves it). The doer gains 0.5% A + 0.25 IMD and a 48 h delay. docs/MECHANICS.md 'Known limitations' lists this with '20+ NFTs', 'from a second wallet' and 'about $3 on a $100 task'; the code needs 10 NFTs, no second wallet, and the gain is unbounded and linear in R, repeatable on every large task by any 10-NFT holder, so the documented rule is economically abusable. Smallest fix: in vote(), revert when the sender or payTo is the submission's disputer (d.disputer); or in settle(), when the disputer lost, route recs[disputer]'s votes out of voterVotesLeft / voterFee / voterBond to the treasury. Then also fix the doc line (10 NFTs, gain linear in R).

**Reproduction**

Launch prices, 10 NFTs (ids 0..9) in one wallet 'whale'. author createTask(USDC, 10,000e6). doer submit -> s. whale dispute(s, 'ipfs://reason', '') paying 0.5e18 IMD + 100e6 USDC. whale vote(s, SIDE_ACCEPT, whale, [0..9]): phase stays Voting (10 < instantDelta 20). warp +24h+1, poke -> Decided(Accept) (10 >= endDelta, 1 >= minWinnerWallets). warp +24h+1, poke -> Final, Accepted, nftRight. claimVoterReward(s, whale) + withdraw(USDC): whale USDC 10,000e6 -> 10,450e6 (500e6 voterFee + 50e6 voterBond.rew - 100e6 bond), IMD -0.25e18; treasuryPot(USDC) = 500e6 instead of 1,000e6. Expected (RULES: a losing disputer forfeits its bond; the NFT 5% is for voters who decided right, not the party that lost): whale USDC after <= before. Ran contracts/test/scratch/SelfDisputeVoterFee.t.sol: FAIL 'the losing disputer must not leave with more USDC than it came with: 10450000000 > 10000000000'.

**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 {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 {VoteLib} from "src/libraries/VoteLib.sol";
import "src/VaultTypes.sol";

contract Coin is ERC20 {
    uint8 private immutable _d;

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

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

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

contract Nft is ERC721 {
    constructor() ERC721("identity.md", "IMDNFT") {}

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

/// @dev The core launch in one frame: the desks accept `wire` only inside the transaction that deployed them.
contract Core {
    YoloLockHub public hub;
    address public vault;

    constructor(address registry, address imd, address usdc, address yolo, address prices, address buyback, address nft) {
        TextRules text = new TextRules();
        hub = new YoloLockHub(yolo);
        BuybackBurner burner = new BuybackBurner(yolo, imd, usdc, prices, buyback);
        CaseDesk caseDesk =
            new CaseDesk(registry, imd, usdc, address(text), address(new SwarmQuestionBuilder()), yolo, address(hub));
        VotePower vp = new VotePower(nft, address(0));
        DisputeDesk 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)
            )
        );
    }
}

/// @notice A holder of endDelta (10) NFTs disputes an honest submission itself (DisputeDesk.dispute only excludes the
///         doer), votes ACCEPT against its own dispute with its 10 NFTs (DisputeDesk.vote does not exclude the
///         disputer), and is the only "right" voter at the vote end. settle() then books the NFT 5% of R
///         (voterFee) and 50% of the loser's bond (its own bond) to the accept votes, all of which the same wallet
///         claims. On a 10,000 USDC task: +500 USDC fee + 50 USDC of its own bond back - 100 USDC bond = +450 USDC
///         net (and -0.25 IMD), paid by the treasury, which gets 500 instead of 1,000 USDC.
///         Fails now (the disputer leaves with more USDC than it came with); passes once the disputer's own votes
///         cannot earn the voter share (revert in vote(), or route their share to the treasury).
contract SelfDisputeVoterFeeTest is Test {
    Coin imd;
    Coin usdc;
    Coin yolo;
    Nft nft;
    ITaskVault vault;
    DevPrices prices;

    address author = makeAddr("author");
    address doer = makeAddr("doer");
    address whale = makeAddr("whale");
    address treasury = makeAddr("treasury");
    address devWallet = makeAddr("devWallet");
    address buyback = makeAddr("buyback");

    function setUp() public {
        vm.warp(1_800_000_000);
        imd = new Coin("IMD", 18);
        usdc = new Coin("USDC", 6);
        yolo = new Coin("YOLO", 18);
        nft = new Nft();
        prices = new DevPrices(devWallet, address(usdc), address(imd), address(yolo), 0.001e18, 2398e18, 4.5e18);
        address intake = makeAddr("intake");
        ParamsRegistry registry = new ParamsRegistry(
            makeAddr("multisig"), intake, address(prices), address(0), address(nft), address(0), treasury, makeAddr("attester")
        );
        Core core = new Core(address(registry), address(imd), address(usdc), address(yolo), address(prices), buyback, address(nft));
        vault = ITaskVault(core.vault());
        address[3] memory people = [author, doer, whale];
        for (uint256 i; i < 3; ++i) {
            imd.mint(people[i], 100e18);
            usdc.mint(people[i], 10_000e6);
            vm.startPrank(people[i]);
            imd.approve(address(vault), type(uint256).max);
            usdc.approve(address(vault), type(uint256).max);
            vm.stopPrank();
        }
        // the launch rule: endDelta = 10 NFTs decide at the vote end, one wallet is enough (minWinnerWallets = 1)
        for (uint256 id; id < 10; ++id) {
            nft.mint(whale, id);
        }
    }

    function test_DisputerVotesForTheDoerAndTakesTheNftFee() public {
        ImdPay memory noSwap;
        vm.prank(author);
        uint256 t = vault.createTask(
            address(usdc),
            10_000e6,
            uint64(vm.getBlockTimestamp() + 30 days),
            "Title",
            TaskRules("do the thing", "PROOF: one https link", "NO if: unclear"),
            0,
            PROOF_LINK,
            "",
            noSwap
        );
        vm.prank(doer);
        uint256 s = vault.submit(t, "https://x.com/doer/status/1", Evidence("", ""), 0, type(uint256).max, noSwap);

        uint256 whaleUsdcBefore = usdc.balanceOf(whale);
        uint256 whaleImdBefore = imd.balanceOf(whale);

        // the NFT holder disputes the honest submission itself: bond 0.5 IMD + 1% of A = 100 USDC
        vm.prank(whale);
        vault.dispute(s, "ipfs://reason", "", noSwap);

        // ... and votes ACCEPT against its own dispute with its 10 NFTs (10 < instantDelta 20: no instant decision)
        uint256[] memory ids = new uint256[](10);
        for (uint256 i; i < 10; ++i) {
            ids[i] = i;
        }
        vm.prank(whale);
        try vault.vote(s, VoteLib.SIDE_ACCEPT, whale, ids) {} catch {}

        // vote end: 10 >= endDelta -> Decided(Accept); appeal window: nobody appeals -> Final, Accepted, NFT right
        vm.warp(vm.getBlockTimestamp() + 24 hours + 1);
        vault.poke(t);
        vm.warp(vm.getBlockTimestamp() + 24 hours + 1);
        vault.poke(t);
        assertEq(uint256(vault.getSubmission(s).outcome), uint256(Outcome.Accepted), "accepted");

        // the whale takes everything the "right" NFT votes earned: the fee and half of its own bond
        try vault.claimVoterReward(s, whale) {} catch {}
        try vault.claimDisputer(s) {} catch {}
        try vault.withdraw(address(usdc)) {} catch {}
        vm.startPrank(whale);
        try vault.withdraw(address(usdc)) {} catch {}
        try vault.withdraw(address(imd)) {} catch {}
        vm.stopPrank();

        uint256 whaleUsdcAfter = usdc.balanceOf(whale);
        // RULES: a losing disputer forfeits its bond; the NFT 5% is for voters who decided right, not for the
        // party that lost the dispute. Now: whale +450 USDC (500 fee + 50 of its own bond - 100 bond).
        assertLe(whaleUsdcAfter, whaleUsdcBefore, "the losing disputer must not leave with more USDC than it came with");
        // the treasury keeps the 10% nobody earned (no stake): 1,000 USDC, not 500
        assertGe(vault.treasuryPot(address(usdc)), 1_000e6, "treasury keeps the unearned NFT 5%");
        assertLe(imd.balanceOf(whale), whaleImdBefore, "no IMD gain either");
    }
}
```

### 2. Medium: Stake bonus and bonds are sized on the author's share A while the bonus is paid on the whole pot R and the add-on: a $5 lock takes 4% of any crowd-funded reward

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

```
        return (uint16(power >= r ? MAX_STAKE_BONUS_BPS : MAX_STAKE_BONUS_BPS * power / r), true);
```

Merged from audit_permissions (medium, with proof) and audit_economics (medium). CaseDesk._stakeBps compares the doer's lock power with DevPrices.yoloValue(t.currency, t.base), where t.base is only the author's own contribution (TaskVault.submitTerms), and CaseDesk.submit sizes the doer bond with _doerBond(t.base, p) (line 113) on the same base. DisputeDesk._payReward then pays bonus = f.reward * bps / BPS on the whole pot (others' top-ups included, also those made during the review), and YoloSweetener.payout pays the same bps on the whole YOLO add-on. The code matches the RULES (b on A, paid on R and the add-on; bonds 3% / 1% of A), so this is the rule itself being abusable: on every crowd-funded task the author keeps its own share at the per-currency minimum (5 USDC / 0.002 ETH / 1 IMD / 1,000 YOLO) and the rest comes from a second wallet or Sweeten; a doer with a lock worth only A then takes the full 4% of R and of the add-on, and a fake submission on such a pot risks only 0.5 IMD + 3% of A. Who profits: the doer (or the author-doer pair) by 4% of R; who loses: the treasury, whose stakers' half falls from 5% of R to 0.5% of R (on a YOLO reward also the burn). At R = 1,000 USDC with A = 5 USDC: lock 5,000 YOLO ($5 at the $0.001 parameter, about $0.39 at the pool's opening price) for 52 weeks -> doer 940 USDC instead of 900, treasury 5 USDC instead of 50, burn 55 USDC; an honest measure against the 1,000 USDC the bonus is paid on gives 2 bps = 0.20 USDC. With an add-on: a 1,000 YOLO ($1) task sweetened with 10,000,000 YOLO ($10,000) pays a doer with 1,000 YOLO locked 400,000 YOLO ($400) of the add-on's fee. A fake on the 1,005 USDC pot risks $2.40 (0.15 USDC + 0.5 IMD) for a 904.5 USDC payout if nobody disputes in 24 h, and a disputer's upside is at most that $2.40. Cost of the attack: the lock (returned after its term) and one top-up transaction; the doer need not collude with the author. Smallest fix: measure the power (and size the bonds) against the pot plus the add-on at submission (t.pot + t.addon converted) or cap the bonus at 4% x min(1, power / (R + add-on) in YOLO) at payout, and update MECHANICS 'Doer stake condition', TOKENOMICS and YOLO.md accordingly; this is a change to a RULES amount, so it needs the requester's decision.

**Reproduction**

Launch prices (YOLO $0.001). doer: hub.lock(5_000e18, 52); warp +1 s. author: createTask(USDC, 5e6, ...). topper: topUp(t, 995e6) -> pot 1,000e6. doer: submit -> getSubmission(s).stakeBps == 400. warp +24h, poke: credits(USDC, doer) = 900e6 + 40e6 bonus + 0.15e6 bond; treasuryPot(USDC) = 5e6; buyback wallet 55e6. Expected with the power measured against the 1,000 USDC the bonus is paid on: stakeBps 2, bonus 0.20 USDC, treasury 29.9 USDC. Ran the specialist's proof contracts/test/scratch/Proof_93b5a2f4713c.t.sol: FAIL 'bonus must be proportional to power / the reward it is paid on: 40000000 > 200000'. quoteSubmission(t).rew == 150000 (0.15 USDC) on the 1,000 USDC pot follows from _doerBond(t.base, p) with t.base = 5e6.

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

contract Coin is ERC20 {
    uint8 private immutable _d;

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

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

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


/// @dev The core launch in one frame: the desks accept `wire` only inside the transaction that deployed them.
contract Core {
    YoloLockHub public hub;
    address public vault;

    constructor(address registry, address imd, address usdc, address yolo, address prices, address buyback) {
        TextRules text = new TextRules();
        hub = new YoloLockHub(yolo);
        BuybackBurner burner = new BuybackBurner(yolo, imd, usdc, prices, buyback);
        CaseDesk caseDesk =
            new CaseDesk(registry, imd, usdc, address(text), address(new SwarmQuestionBuilder()), yolo, address(hub));
        VotePower vp = new VotePower(address(0xabcd), address(0));
        DisputeDesk 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)
            )
        );
    }
}

/// @notice Stake bonus leverage: the power is measured against the author's own share A only (CaseDesk._stakeBps),
///         but the bonus b is paid on the whole pot R (DisputeDesk._payReward). A task funded 5 USDC by the author
///         and 995 USDC by a second wallet lets a doer with 5,000 YOLO ($5 at the $0.001 parameter) locked take
///         4% of 1,000 USDC = 40 USDC out of the stakers' 5% (the treasury drops from 50 to 5 USDC).
///         Expected (power against the reward the bonus is paid on): b = 4% x 5,000 / 1,000,000 = 0.02% -> 2 bps,
///         0.20 USDC. Fails now (doer bonus 40 USDC), passes once the share is measured against R.
contract StakeBonusLeverageTest is Test {
    Coin imd;
    Coin usdc;
    Coin yolo;
    ITaskVault vault;
    YoloLockHub hub;
    DevPrices prices;

    address author = makeAddr("author");
    address topper = makeAddr("topper");
    address doer = makeAddr("doer");
    address treasury = makeAddr("treasury");
    address devWallet = makeAddr("devWallet");
    address buyback = makeAddr("buyback");

    function setUp() public {
        vm.warp(1_800_000_000);
        imd = new Coin("IMD", 18);
        usdc = new Coin("USDC", 6);
        yolo = new Coin("YOLO", 18);
        // launch values: YOLO $0.001, ETH $2,398, IMD $4.50
        prices = new DevPrices(devWallet, address(usdc), address(imd), address(yolo), 0.001e18, 2398e18, 4.5e18);
        address nft = makeAddr("nft");
        address intake = makeAddr("intake");
        ParamsRegistry registry = new ParamsRegistry(
            makeAddr("multisig"), intake, address(prices), address(0), nft, address(0), treasury, makeAddr("attester")
        );
        Core core = new Core(address(registry), address(imd), address(usdc), address(yolo), address(prices), buyback);
        hub = core.hub();
        vault = ITaskVault(core.vault());
        address[3] memory people = [author, topper, doer];
        for (uint256 i; i < 3; ++i) {
            imd.mint(people[i], 100e18);
            usdc.mint(people[i], 10_000e6);
            yolo.mint(people[i], 1_000_000e18);
            vm.startPrank(people[i]);
            imd.approve(address(vault), type(uint256).max);
            usdc.approve(address(vault), type(uint256).max);
            yolo.approve(address(hub), type(uint256).max);
            vm.stopPrank();
        }
    }

    function test_StakeBonusPaidOnOthersTopUps_WithPowerWorthOnlyTheAuthorsShare() public {
        // the doer locks 5,000 YOLO ($5) for 52 weeks: power = 5,000 YOLO
        vm.prank(doer);
        hub.lock(5_000e18, 52);
        vm.warp(vm.getBlockTimestamp() + 1);

        // the author funds the minimum 5 USDC (A = 5 USDC = 5,000 YOLO at $0.001); a second wallet tops up 995 USDC
        ImdPay memory noSwap;
        vm.prank(author);
        uint256 t = vault.createTask(
            address(usdc),
            5e6,
            uint64(vm.getBlockTimestamp() + 30 days),
            "Title",
            TaskRules("do the thing", "PROOF: one https link", "NO if: unclear"),
            0,
            PROOF_LINK,
            "",
            noSwap
        );
        vm.prank(topper);
        vault.topUp(t, 995e6, noSwap);
        assertEq(vault.getTask(t).pot, 1_000e6, "R = 1,000 USDC");

        vm.prank(doer);
        uint256 s = vault.submit(t, "https://x.com/doer/status/1", Evidence("", ""), 0, type(uint256).max, noSwap);
        assertEq(vault.getSubmission(s).stakeBps, 400, "full 4% share from a $5 lock on a $1,000 reward");

        // nobody disputes: accepted after 24 h
        vm.warp(vm.getBlockTimestamp() + 24 hours);
        vault.poke(t);

        uint256 doerBondRew = 5e6 * 300 / 10_000; // 3% of A = 0.15 USDC, back to the doer
        uint256 doerGot = vault.credits(address(usdc), doer) - doerBondRew;
        uint256 bonus = doerGot - 900e6;
        // the power is worth 5,000 YOLO; the reward the bonus is paid on is 1,000 USDC = 1,000,000 YOLO:
        // 4% x 5,000 / 1,000,000 = 2 bps of R = 0.20 USDC. Now: 40 USDC (treasury 5 USDC instead of 50).
        assertLe(bonus, 0.2e6, "bonus must be proportional to power / the reward it is paid on");
        assertGe(vault.treasuryPot(address(usdc)), 5e6 + 24.9e6, "treasury keeps its half of (5% - b)");
    }
}
```

### 3. Low: Docs vs code: the known limitation says 20+ NFTs from a second wallet decide a self-dispute; the vote-end rule needs 10 (endDelta), the disputer itself may vote, and the gain is linear in R

`docs/MECHANICS.md:341`

```
- A holder of 20+ NFTs can dispute an honest submission from a second wallet and vote alone to take the NFT 5% of the
```

From audit_math (low). LaunchParams.initial (contracts/src/VaultParams.sol:131-133) sets instantDelta = 20, endDelta = 10 and minWinnerWallets = 1: a wallet voting alone with 10 NFTs decides at the vote end (CaseDesk._tryDecide). The doc states 20+ NFTs, a second wallet and 'about $3 on a $100 task'; the code needs 10 NFTs, no second wallet (DisputeDesk.dispute excludes only the doer, vote excludes nobody) and the yield is 5% of R - 0.5% of A - 0.25 IMD on every task ($450 on a $10,000 task). Fix the text or the rule (see the medium finding on DisputeDesk.vote).

**Reproduction**

Same flow as the self-dispute proof: after dispute + vote(ACCEPT, 10 ids) the phase is Voting; after warp 24h+1 and poke it is Decided(Accept) with 10 votes from the disputer's own wallet. Expected per the doc: 10 NFTs cannot decide alone and a second wallet is needed. Actual: they decide and the disputer votes itself (contracts/test/scratch/SelfDisputeVoterFee.t.sol reaches Outcome.Accepted with nftRight).

### 4. Low: Winning outsider's 0.5 IMD refund ignores the IMD 'rew' part of the loser's bond on IMD-reward tasks

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

```
        if (appellantIsStranger && callerWins && penaltyImd != 0) {
```

From audit_flow (low). RULES: a winning outsider first gets 0.5 IMD off the loser's bond 'if IMD is left in it'. _settleDecided computes penaltyImd from the fixed IMD parts only (f.bond.imd / d.bondTotal.imd / a.bond.imd, lines 640-644) and the stranger fee from that (line 649). On a task whose reward currency is IMD the loser's bond also holds an IMD 'rew' part (1% or 3% of A, the same token, collected in the same collectImd call) which sits in penaltyRew and is never considered for the refund. When the loser spent its fixed 0.5 IMD on its own silent swarm call (sideSpent), penaltyImd is 0, the branch is skipped and the outsider's 0.5 IMD fee is not refunded although IMD is still left in the loser's bond; that IMD goes 50/50 (or 100%) to the winning side instead. Bounded: 0.5 IMD (about $2.25) per case, needs an IMD-reward task plus a silent swarm. Smallest fix: when f.currency == address(imd), take what is left of the stranger fee (bondFixedImd - strangerFee) from penaltyRew before _splitCurrency.

**Reproduction**

Task reward 100 IMD (author alone, A = 100 IMD); doer submits (bond 0.5 IMD fixed + 3 IMD rew); disputer D disputes (0.5 IMD + 1 IMD rew); NFT vote ends undecided (NoDecision). D calls the swarm against the doer: _fundCall takes the 0.5 IMD fee from D's fixed bond (fromBond = 0.5e18), _charge sets sideSpent.disputer = 0.5e18. Swarm silent (7 h) -> NoDecision again. Outsider O calls the swarm siding with the doer (bond 0.5 IMD + 1 IMD rew, fee 0.5 IMD from its wallet); swarm answers accept -> settle(Accepted). Line 644: penaltyImd = 0.5e18 - 0.5e18 = 0, so line 648 is skipped: O gets no 0.5 IMD refund although 1 IMD (D's rew part) is in penaltyRew and is split pro rata (O 0.25 IMD, doer 0.75 IMD). Expected (RULES): O first gets 0.5 IMD off that 1 IMD, then 0.5 IMD is split (O 0.125, doer 0.375). Traced in code; no Foundry test written (low).

### 5. Low: Launch YOLO price parameter ($0.001) is 12.7x the pool's opening price, so a full 4% stake bonus needs a lock worth about $7.87 of YOLO on a $100 task

`contracts/script/LaunchPlan.sol:77`

```
    uint256 internal constant YOLO_PRICE_USD = 0.001e18;
```

From audit_economics (low). The stake bonus compares the lock power with A converted at DevPrices.yoloPriceUsd. LaunchPlan opens the YOLO/IMD pool at YOLO_OPENING_MCAP_IMD = 15,000 IMD for 1e27 YOLO (line 89), i.e. 1.5e-5 IMD per YOLO, about $0.0000787 at IMD_USD_START = 5.2477 (line 79), while YOLO_PRICE_USD starts at $0.001 (line 77). A $100 USDC task needs 100,000 YOLO locked 52 weeks for the full 4%: $7.87 of YOLO at the opening price for +$4 per task, instead of the $100 the parameter implies. The dev wallet can only correct it in x10 steps (DevPrices.MAX_STEP). A stale price moving only the stake bonus is accepted by the brief; the inconsistent starting value is a launch-plan defect. Fix: set YOLO_PRICE_USD to the opening price (about 0.0000787e18) or raise the opening market cap to about 190,000 IMD.

**Reproduction**

contracts/script/LaunchPlan.sol constants: YOLO_PRICE_USD = 0.001e18 (line 77), IMD_USD_START = 5.247665725934671441e18 (line 79), YOLO_OPENING_MCAP_IMD = 15_000e18 (line 89), YOLO_SUPPLY = 1e27. 15,000 x 5.2477 / 1e9 = $0.0000787 per YOLO at the pool opening vs the $0.001 parameter: 12.7x. Arithmetic on the constants; no test needed.

### 6. Info: YoloSweetener pays the stake share from Settlement.stakeBps even if the lock ended between the vault payout and sweetener.payout

`contracts/src/YoloSweetener.sol:146`

```
        uint256 bps = vault.getSettlement(subId).stakeBps;
```

From audit_permissions (info). RULES: the lock must still run at payout or b = 0. The vault checks the hub at its own payout (DisputeDesk._lockRuns, line 735) and stores stakeBps; the sweetener's payout is a separate permissionless call that re-reads only the stored bps, never the hub. A doer whose lock ends the day after the acceptance (or who withdraws it in between) still gets up to 4% of the add-on. Bounded by 4% of the add-on and documented in YOLO.md ('if the vault paid the doer the stake bonus on this submission'): drift between the RULES wording and the code.

**Reproduction**

Lock ends at T; the acceptance is poked at T-1 (st.stakeBps = 400 stored); the doer withdraws the lock at T; sweetener.payout called at T+1: extra = bonus * 400 / 10000 still paid to the doer (lines 146-152). Traced in code.

### 7. Info: Lock ended before payout falls back to the no-stake split (treasury 10%) instead of the staked split with b = 0

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

```
        if (f.staked && _lockRuns(f)) {
```

From audit_economics (info). RULES: 'the lock must still run at payout or b = 0'. Read literally, a stake with b = 0 keeps the staked split (unearned NFT 5% burned, stakers' 5% half burn half treasury); the code takes the no-stake branch (all 10% to the treasury, burn 0). MECHANICS 'Known limitations' documents it ('the fee goes as without a stake'): accepted drift, no user loses.

**Reproduction**

Doer locks 1 week, submits, lock ends, poke after 24 h on a 100 USDC task: treasuryPot += 10 USDC, burn 0; under the RULES wording burn 7.5 USDC and treasury 2.5 USDC. Traced in code.

### 8. Info: _splitCurrency sends the winner's share to the treasury when the winning side has no bond in that currency (unreachable at the launch minimums)

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

```
        if (winnerBonds == 0) return (toVoters, winnerShare, 0, 0);
```

From audit_permissions (info). With the launch minimums (5 USDC, 0.002 ETH, 1 IMD, 1,000 YOLO) every 1% / 3% bond part is non-zero, so the branch never fires; it would only on a registry version with a minimum below 100 base units (ParamsBounds allows minRewardUsdc down to 1e6, still 10,000 units). Edge case, no fix needed.

**Reproduction**

base < 100 units -> _pctBond returns 0 -> winnerBonds == 0 -> the loser's rew penalty goes to the treasury, not the winner. Not reachable with any launch-era minimum.

### 9. Info: Monthly YOLO burn is a few percent of the monthly unlock at 200 tasks/month and R $100

`contracts/src/token/Vestings.sol:15`

```
    uint256 public constant AT_ONCE = 62_500_000e18;
```

Merged from audit_permissions (info) and audit_economics (info). Unlock per month in months 1-12 after the split: incentives 187.5M/24 = 7.81M, treasury (222,222,223 - 74,074,074)/12 = 12.35M, dev 77,777,777/12 = 6.48M, team 50M/12 = 4.17M, about 30.8M YOLO ($30.8k at $0.001). Burn per accepted $100 task: 0 without a stake (the treasury takes 10%), $5.50 at full power, up to $7.50 near zero power, $10 on a YOLO reward without a stake (addTreasury burns YOLO). 200 tasks/month all YOLO-denominated and unstaked: 2.0M YOLO (6.5% of the unlock); 200 staked USDC tasks: $1,100 to the buyback wallet, 1.1M YOLO only if it buys and burns (a manual promise, no on-chain buyback). Drift, not a defect.

**Reproduction**

Arithmetic from Vestings.sol / YoloSplitter.sol constants and DisputeDesk._payReward (burn = half + nftPart - toVoters; TaskVault.addTreasury burns YOLO).

### 10. Info: Outsider appeal and swarm call are net negative at the minimum rewards; disputing a fake pays about $2.60 on a $100 task

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

```
            uint256 strangerFee = p.bondFixedImd > penaltyImd ? penaltyImd : p.bondFixedImd;
```

From audit_permissions (low, kept as info: no RULES amount is broken). A third-party appellant posts 0.5 IMD + 1% of A plus the 0.5 IMD fee ($2.25) and, winning, gets the fee back only from what is left of the loser's IMD bond, then a pro-rata share of the loser's rew bond. On a 5 USDC task the loser's doer bond rew part is 0.15 USDC: the appellant recovers 0.5 IMD and 0.075-0.15 USDC, below the gas of appeal + askAppealSwarm (about 350k gas, $0.10 at 0.12 gwei and ETH $2,398), and loses the whole fee if the loser already spent its bond IMD on a silent call. On a $100 task: +$1.50 (NFT wrong) or +$0.75 (NFT right) net of gas. Disputing a fake on $100: +$2.63 net (3% of A = $3 minus gas) with the NFT undecided. An NFT vote: 5% of R + half the loser's bond shared among the right votes. Worth a note in the docs: outsider appeals pay only above a few hundred dollars of reward.

**Reproduction**

R = 5 USDC, NFT decides Accept wrongly, outsider appeals (0.5 IMD + 0.05 USDC bond + 0.5 IMD fee), swarm says Reject: _settleDecided credits the stranger 0.5 IMD (strangerFee, line 649) then splits 0.15 USDC between disputer (bond 0.05) and appellant (bond 0.05): appellant +0.075 USDC, gas about $0.10. Arithmetic on the code paths.

---

Judge's submission `1b3fc3c792cf93a9018bc575868f1bce8f8b33b388da9b7d437dc812820e96cd`, 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.
