# Audit report

> Audit the 84b446c..14c4e30 delta of moneycanbuy.fun for correctness bugs in four changed rules. Repository at commit 14c4e308019a0cff83fdaf2d17425867af3cf301. 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. Code: the four changed spots below, plus anything they call. Docs win over comments. Do not re-report what contracts/test/audit/ proves fixed.
>
> SCOPE. Only these four changes between commit 84b446c and commit 14c4e30. Not a full-contract audit: ignore anything unchanged in this range. Payout amounts are a separate order: one info line here if relevant.
>
> THE FOUR CHANGES.
> 1. YoloLockHub.sol, lock() and its power math (around line 78-90): extending a lock (a call with weeks_ != 0) now sets power to amount * left / MAX_WEEKS (the whole amount, weeks actually left); adding without a new term (weeks_ == 0) now sets power to old + add * left / MAX_WEEKS. The old code took the max of both formulas; the new code picks one branch by whether weeks_ is 0.
> 2. CaseDesk.sol, _stakeBps() (around line 505-531) and DeskApi.sol SubmitTerms.addon: the stake-bonus base changed from t.base (the author's own share) to t.pot (the author's share plus every top-up, i.e. the whole reward at submission), then r += t.addon so the $YOLO add-on from YoloSweetener also enters the base.
> 3. CaseDesk.sol _stakeBps: DevPrices(p.devPrices).yoloValue(t.currency, t.pot) is now called with t.pot instead of t.base.
> 4. YoloPredict.sol, predict() (around line 134-140): before checking task.status, if task.activeSub != 0 and block.timestamp < task.deadline, predict() now calls vault.poke(taskId) and re-reads the task, so a submission whose own timer already lapsed no longer blocks a new position.
>
> KNOWN LIMITATIONS. Every line under Known limitations in docs/MECHANICS.md is an accepted rule, not a finding, including the self-dispute-from-a-second-wallet line with its ~4.5% of R example. Report one only if a changed line above makes a documented rule itself newly or more abusable than its own stated reason admits.
>
> 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 below, same order, even "no issue"; a missing item = INCOMPLETE. Entry 0: VERDICT, max 8 lines, every blocker. Items 1-6: 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; high = loss under realistic conditions or a documented rule broken; medium = bounded loss or cheap griefing; low = edge case; info = drift, "no issue".
>
> 1. LOCK POWER MONOTONICITY. Can extending (new term) or adding (no term) ever make power jump above amount * MAX_WEEKS_LEFT_POSSIBLE / MAX_WEEKS, drop the recorded power below what docs/MECHANICS.md promises ("constant until the end unless the lock is changed"), or let a sequence of lock() calls produce a higher power than locking the same final amount and term once would give? Compare both branches directly: does picking only one of (old+add*left/MAX_WEEKS) or (amount*left/MAX_WEEKS) ever let a doer reach a higher power than the old max()-based formula for the same inputs, and is that reachable cheaply (e.g. lock small, wait, extend)?
> 2. REENTRANCY / CALL ORDER IN lock(). Same checks as before for this function: transferFrom after the checkpoint write, reentrancy through the YOLO token's transferFrom hook, the epoch bump on an ended lock reused for a new term.
> 3. STAKE BASE t.pot VS t.base. Confirm t.pot always includes every currently-known top-up and the author's share at the exact submission block (no stale read, no double count), and that switching the stake bonus's reward base from t.base to t.pot cannot be combined with any other call in the same or an adjacent transaction to inflate t.pot artificially right before submit() (flash top-up then withdraw-like trick, if any exists) to buy a bigger stake share than the doer's real lock power should earn.
> 4. ADDON IN STAKE BASE. r += t.addon after the yoloValue(t.currency, t.pot) call: can addon be set or changed between the price read and this addition, or can a sweetener deposit made in the same transaction as submit() inflate t.addon for one submission's stake-bps calculation beyond what YoloSweetener.sol's own accounting would allow?
> 5. PREDICT'S vault.poke(taskId). Can calling poke from inside predict() be used to reenter YoloPredict (the lock covering vault and desks), to move the task to a state that lets a predict() through or revert when it should not, or to grief gas (poke's cost now always paid by every predictor hitting this branch, even when poke does nothing)? Confirm poke cannot flip status in a way that lets an entry land during what should still be SubmissionActive, and cannot be called in a sequence that lets a position enter at the old points-formula weight after it should have been blocked.
> 6. COVERAGE. Files read in full for these four changes, what was not reached, forge build and test result.
>
> Not findings: anything unchanged between 84b446c and 14c4e30; test/mocks; placeholder wallets; legal; brand/, landing/; Intake, Uniswap, delegate registry, IMD factory themselves; the known-limitations self-dispute line itself.

| | |
|---|---|
| Repository | https://github.com/sergey-mavr-agent/moneycanbuy.fun.git |
| Commit | `14c4e308019a0cff83fdaf2d17425867af3cf301` |
| Job | `1c02452e-7b87-4e77-9f27-ce856761c31e` |
| Judged | 2026-10-20 10:47 UTC |
| Findings | 1 low · 3 info |

Four agents audited the code as it is at `14c4e30`, 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. Low: lock(): a term that does not move the end still takes the extend branch and cuts the running power (merged: audit_math, audit_permissions, audit_flow, audit_economics)

`contracts/src/YoloLockHub.sol:84`

```
        uint256 pw = weeks_ != 0 ? amount * left / MAX_WEEKS : old + add * left / MAX_WEEKS;
```

Change 1 of 84b446c..14c4e30. The branch is chosen by weeks_ != 0 alone. Line 73 accepts newEnd == end, so a call that moves nothing (lock(0, weeksLeft)) or only adds tokens while re-sending the current remaining term (lock(add, weeksLeft)) is treated as a new term: the running power (amount x original term / 52) is discarded and replaced by amount x weeks left / 52. docs/MECHANICS.md promises the power is 'constant until the end unless the lock is changed'; here the lock (amount, end) is unchanged and the power halves. With an add in the same call the added tokens can earn nothing at all (lock(1000, 26) at the same end: 2000 x 26 / 52 = 1000, where lock(1000, 0) gives 1500). Reproduced against the code: the new formula never exceeds the amount, never exceeds the old max()-based value, and no sequence of lock() calls reaches a higher power than one lock of the final amount and term (a + b x left / 52 <= a + b), so there is no upward exploit; a submission already made keeps its checkpointed power (stakePower reads the point before submittedAt). The harm is only to the caller's future submissions (a doer with power worth $100 who re-terms at week 26 keeps $50 of power: 2% instead of 4% on the next $100 task, $2 per task) and it is reachable by a site or user that sends 'add and keep my term'. Not an attack, a footgun on a documented rule. Smallest fix: take the extend branch only when newEnd > end (treat weeks_ != 0 with newEnd == end as add-only), or keep pw = max(amount * left / MAX_WEEKS, old + add * left / MAX_WEEKS) when the end does not move, or revert BadTerm on a no-op term. Verified by the attached test (fails now: 500e18 and 1000e18; passes under any of the three fixes).

**Reproduction**

YoloLockHub with a plain ERC20. alice: lock(1000e18, 52) -> power(alice) == 1000e18, end = t0 + 52 weeks. vm.warp(t0 + 26 weeks). lock(0, 26): newEnd == end so the call is accepted, locks(alice).end unchanged, amount unchanged. Expected (lock unchanged): power 1000e18. Actual: 1000e18 * 26 / 52 = 500e18. Second case from the same state: lock(1000e18, 26) (same end). Expected at least old + 1000e18 * 26 / 52 = 1500e18 (what lock(1000e18, 0) gives). Actual: 2000e18 * 26 / 52 = 1000e18, the added tokens add no power. Test: contracts/test/scratch/LockSameEndDrop.t.sol, forge test --match-path contracts/test/scratch/LockSameEndDrop.t.sol: both tests fail on commit 14c4e30. Run on commit 14c4e30: both tests FAIL (500e18 != 1000e18; 1000e18 < 1500e18). Run against a copy of YoloLockHub.sol with the one-line fix `(weeks_ != 0 && end > l.end) ? amount * left / MAX_WEEKS : old + add * left / MAX_WEEKS`: both PASS.

**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 "forge-std/Test.sol";
import {YoloLockHub} from "src/YoloLockHub.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

/// @dev Self-contained: a plain ERC20 and the hub.
contract PlainYolo is ERC20 {
    constructor() ERC20("YOLO", "YOLO") {}

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

/// @dev Change 1 of 84b446c..14c4e30 (YoloLockHub.lock, the one-branch power): a call with a term whose new end is
///      the current end (line 73 accepts newEnd == end) changes nothing about the lock, yet it takes the "new term"
///      branch and drops the running power to amount x weeks left / 52. docs/MECHANICS.md: the power is "constant
///      until the end unless the lock is changed". Both tests fail on the current code and pass once a call that
///      does not move the end keeps the running power (add-only branch, max(), or a BadTerm revert: any of the
///      three passes here).
contract LockSameEndDropTest is Test {
    uint256 internal constant WEEK = 7 days;
    PlainYolo internal yolo;
    YoloLockHub internal hub;
    address internal alice = makeAddr("alice");

    function setUp() public {
        vm.warp(1_700_000_000);
        yolo = new PlainYolo();
        hub = new YoloLockHub(address(yolo));
        yolo.mint(alice, 10_000e18);
        vm.prank(alice);
        yolo.approve(address(hub), type(uint256).max);
    }

    function _end(address who) internal view returns (uint256 e) {
        (, e,) = hub.locks(who);
    }

    /// lock(1000, 52) -> power 1000. 26 weeks later lock(0, 26): the end is the same, the amount is the same, the
    /// lock is not changed. Actual: power 500.
    function test_SameEndReTerm_KeepsThePower() public {
        vm.prank(alice);
        hub.lock(1_000e18, 52);
        uint256 end0 = _end(alice);
        assertEq(hub.power(alice), 1_000e18);
        vm.warp(block.timestamp + 26 * WEEK);
        vm.prank(alice);
        (bool ok,) = address(hub).call(abi.encodeCall(YoloLockHub.lock, (0, 26)));
        if (!ok) return; // a fix that refuses the no-op term is fine too
        assertEq(_end(alice), end0, "the end did not move");
        assertEq(hub.power(alice), 1_000e18, "the lock is unchanged: the power must be too");
    }

    /// lock(1000, 52) -> power 1000. 26 weeks later lock(1000, 26) (same end): the added 1000 should count with the
    /// 26 weeks left (+500, as lock(1000, 0) gives). Actual: 2000 x 26 / 52 = 1000, the added tokens earn nothing
    /// and the running power is thrown away.
    function test_SameEndAdd_CountsTheAddedTokens() public {
        vm.prank(alice);
        hub.lock(1_000e18, 52);
        uint256 end0 = _end(alice);
        vm.warp(block.timestamp + 26 * WEEK);
        vm.prank(alice);
        (bool ok,) = address(hub).call(abi.encodeCall(YoloLockHub.lock, (1_000e18, 26)));
        if (!ok) return; // a fix that refuses the no-op term is fine too
        assertEq(_end(alice), end0, "the end did not move");
        assertGe(hub.power(alice), 1_500e18, "old power + added x 26 / 52");
    }
}
```

### 2. Info: Stake share fixed against pot + add-on at submission is paid on the pot and add-on as they stand at payout (payout amounts: separate order; merged from audit_math, audit_permissions, audit_flow)

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

```
        r += t.addon;
```

Changes 2-4 verified: submit() calls tasks.advance(taskId) then submitTerms(), which reads t.pot, the author's active contribution and the sweetener's bonusOf in the same call, so pot and add-on are fresh at the submission block, read once, and no external call sits between the yoloValue read and the r += t.addon line. There is no flash inflation: TaskVault.withdrawContribution reverts TaskNotOpen while the task is Open and YoloSweetener.withdraw needs Expired/Unwound or an Open task past its deadline with no submission in flight (submit needs block.timestamp < deadline), so nothing put into a task before submit() can come out after it; a larger pot or add-on only raises r and lowers the bps. A self-funded top-up pays the 10% fee and earns at most 4% back. What remains is the documented rule (MECHANICS.md 'the share fixed at the submission'): DisputeDesk._payReward applies stakeBps to f.reward (the pot at finalisation) and YoloSweetener.payout to the bonus at payout, so a top-up or sweeten that arrives during the review is paid at a share measured against a smaller base (power worth $100, pot $100 at submission = 400 bps, $10,000 topped up during the review by a third party: bonus $404 instead of $4, bounded by the stakers' 5% and paid from burn/treasury). Pre-existing before this range (then the base was the author's share only) and smaller after it. One info line, no fix proposed here.

**Reproduction**

Base setup, YOLO at $0.0001: doer lock(1_000_000e18, 52); author creates a 100e6 USDC task; doer submits (Submission.stakeBps == 400); a third party topUp(taskId, 10_000e6) during the 24 h review; warp 24 h; poke: credits(usdc, doer) == 9_090e6 (90% of 10_100e6) + 404e6 (4% of 10_100e6); the specialists' tests test_StakeBase_FixedAtSubmission_PaidOnLaterTopUp report the same numbers. No position changes with the four changes except that the base now includes top-ups and the add-on at submission.

### 3. Info: predict() pays a vault.poke on every call with an active submission before the deadline, and a poke that completes the task is rolled back by the TaskNotOpen revert (merged from audit_math, audit_flow

`contracts/src/YoloPredict.sol:138`

```
            vault.poke(taskId); // play the timers: a submission ended by its timer does not pause predictions
```

Change 4 verified, no correctness issue. The branch runs only where the old code reverted SubmissionActive, so it can only turn a revert into a legitimate position: _advance (TaskVault.sol:636) advances a submission only when its own timer lapsed (CaseDesk.step returns false before windowEnd/voteEnd/callFrom + appealWindow/appealedAt + swarmTimeout), the next queued submission becomes activeSub and still blocks, and an expiry cannot happen since the branch needs block.timestamp < deadline. YoloPredict writes nothing before the poke; every state-changing vault call inside the poke runs under the vault's transient lock, so a nested predict/settle/claim that reaches vault.poke reverts ReentrancyGuardReentrantCall, and a nested predict that finds activeSub == 0 is the same as a sequential call at the same block (points use block.timestamp only). Cost only: a predictor on a task whose submission is still in its window pays one CaseDesk.step (storage reads, in Phase.Asked one Intake view) before the SubmissionActive revert; when the poke accepts the submission (24 h review lapsed) predict reverts TaskNotOpen and the acceptance is undone with it, the task stays Open until any other call pokes it. No state inconsistency, no fix needed.

**Reproduction**

Base setup: create a USDC task, submit, warp 24 h without a poke; alice predict(taskId, true, 1e18) reverts TaskNotOpen and vault.getTask(taskId).status is still Open afterwards; a separate vault.poke(taskId) completes it. Inside the 24 h window the same predict reverts SubmissionActive after the poke returned without a transition (the specialists' test_Predict_PokeAcceptsByTimer_ThenNotOpen; contracts/test/audit/PredictAfterTimer.t.sol covers the allowed case).

### 4. Info: Test header comment still says the stake base has no add-on (drift; from audit_math)

`contracts/test/YoloLockHub.t.sol:13`

```
/// whole pot at the submission (no add-on), converted at the DevPrices $YOLO price; any power counts, no threshold. The DisputeDesk pays it if
```

docs/MECHANICS.md ('Doer stake share' row) and CaseDesk._stakeBps (r += t.addon, CaseDesk.sol:527) add the $YOLO add-on to the base since 14c4e30; the test file's header still says 'no add-on'. Comment only.

**Reproduction**

Compare contracts/test/YoloLockHub.t.sol line 12-13 with docs/MECHANICS.md 'Doer stake share' and contracts/src/desks/CaseDesk.sol:527.

---

Judge's submission `425f6630bea99763265169dd84fe2ce51df4bce4edfedb4d4d4a363ee391ae06`, 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.
