# Audit report

> Audit the final smart contract code of moneycanbuy.fun for security bugs 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. 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.
>
> 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, 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. No path takes a user's tokens without their own call. For every transferFrom, safeTransferFrom and Permit2 pull in contracts/src/ prove `from` is the caller (msg.sender or the vault-appended sender). Can anyone (deployer, owners too) make _fundCall (sender = a.appellant) pull from another wallet, spoof the sender via 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; 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. 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 selectors with the sender appended (_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. 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, TrophyCatalog hold no money. Worst case in money for each stolen key. wire 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 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 (repeat, transfer, owner plus delegate), a lying ownerOf, payTo tricks, the minimum-wallets count, an instant decision racing a late vote.
> 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 of an ask (ASK_GAS_RESERVE), price change before the ask.
> 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, points overflow or zero, last-claimant dust, double settle.
> 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: withdraw and relock keeping the bonus, extend-plus-add gaming, lost or double-withdrawn balance.
> 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, tiny top-up griefing, 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, 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 | `84b446c0ff513a39c60486edada40a4a1d781b6b` |
| Job | `30812c29-8868-4f2e-b432-f4ffd11de50c` |
| Judged | 2026-10-19 21:31 UTC |
| Findings | 8 low · 7 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. Low: YoloLockHub: a one-week rolling extension keeps the full 52-week power (extend gaming)

`contracts/src/YoloLockHub.sol:67`

```
        uint256 old = end > block.timestamp ? _last(msg.sender).power : 0;
```

lock(0, weeks_) on a running lock computes pw = max(old + 0, amount * left / 52) where old is the running lock's current power. A lock that served its 52 weeks therefore keeps power == amount for ever by calling lock(0, 1) once a week: after the first year the doer holds the full 4% stake bonus (CaseDesk._stakeBps, DisputeDesk._payReward) with a one-week exit notice, while docs/MECHANICS.md states a lock's power is amount x term in weeks / 52. The doc sentence 'power never drops while the lock runs' produces this, so it is the documented rule being gameable rather than a code slip. Who profits: any doer after one full-term lock; how much: the whole stake share of the fee (on a $100 USDC reward $4 of the 10% fee instead of $0.077 for an honest one-week lock); cost: the YOLO stays locked, but only ever one week ahead. Merged from audit_flow (low) and audit_math (info). Smallest fix: on a pure extension (weeks_ != 0, add == 0) set pw = amount * left / 52 instead of keeping old; keep old + add * left / 52 only for an add without a term change, and document the choice.

**Reproduction**

t0: alice calls lock(1000e18, 52) -> power(alice) == 1000e18. vm.warp(t0 + 52 weeks - 1 days); alice calls lock(0, 1): left = 1, old = 1000e18, all = 19.23e18, pw = 1000e18, end = now + 1 week. Expected under 'amount x term in weeks / 52': 19230769230769230769. Actual: 1000000000000000000000. Verified: test/scratch/LockHubRollingExtension.t.sol (FOUNDRY_TEST=test/scratch forge test --offline --match-path test/scratch/LockHubRollingExtension.t.sol) fails on 84b446c with '1000000000000000000000 != 19230769230769230769'. Repeating lock(0, 1) weekly keeps the power indefinitely and stakePower at every payout returns 1000e18.

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

/// @dev Minimal ERC20 for the test (self-contained: no other test file).
contract TestYolo {
    string public name = "YOLO";
    string public symbol = "YOLO";
    uint8 public decimals = 18;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    function mint(address to, uint256 amount) external {
        balanceOf[to] += amount;
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        return true;
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        uint256 a = allowance[from][msg.sender];
        if (a != type(uint256).max) allowance[from][msg.sender] = a - amount;
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        return true;
    }
}

/// @dev After one served 52-week lock, a one-week rolling extension keeps the full power (amount) while the
///      documented rule for a one-week term is amount x 1 / 52. Passes once a pure extension recomputes the power
///      from the weeks left (pw = amount x left / 52) instead of keeping the old power.
contract LockHubRollingExtension is Test {
    function test_OneWeekExtensionKeepsFullPower() public {
        TestYolo yolo = new TestYolo();
        YoloLockHub hub = new YoloLockHub(address(yolo));
        address alice = makeAddr("alice");
        yolo.mint(alice, 1000e18);
        vm.startPrank(alice);
        yolo.approve(address(hub), type(uint256).max);
        uint256 t0 = block.timestamp;
        hub.lock(1000e18, 52);
        assertEq(hub.power(alice), 1000e18);
        vm.warp(t0 + 52 weeks - 1 days);
        hub.lock(0, 1); // one-week extension, nothing added
        vm.stopPrank();
        (, uint64 end,) = hub.locks(alice);
        assertEq(uint256(end), block.timestamp + 1 weeks);
        uint256 oneWeekPower = uint256(1000e18) / 52;
        assertEq(hub.power(alice), oneWeekPower, "one-week term should carry one-week power");
    }
}
```

### 2. Low: DisputeDesk.vote: minWinnerWallets counts distinct callers, not NFT owners (delegate hot wallets inflate it)

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

```
            if (newWallet) ++v.walletsAccept;
```

The 'distinct wallets on the winning side' requirement (ParamsCore.minWinnerWallets, bounds 1..100, launch value 1) counts voter addresses (newWallet = r.side == 0 for _sender()), while an NFT may be voted by any delegate.xyz delegate of its owner (VotePower.mayVote). One holder who delegateAll()s to N hot wallets and votes from each counts as N wallets, so an instant decision that a later parameter version meant to require several real holders is reached by a single holder for the cost of N free delegations. HiddenList counts owners through ownerOf; DisputeDesk does not. At the launch value of 1 the check is vacuous, so no loss today. Smallest fix: count distinct NFT owners (ownerOf at the vote) in walletsAccept/walletsReject, or document that the parameter counts callers.

**Reproduction**

Version with minWinnerWallets = 3 (instantDelta 20). Holder voters[0] (ids 0..119) calls delegates.delegateAll(w1), delegateAll(w2), delegateAll(w3). w1 votes (subId, 1, w1, ids 0..6), w2 votes ids 7..13: phase still Voting; w3 votes ids 14..19: walletsAccept == 3 >= 3 and accept - reject == 20 >= instantDelta, so tryInstantDecision fires and the phase is Decided with one real holder behind it. Expected: walletsAccept == 1. Verified on 84b446c with test/scratch/Verify.t.sol test_minWinnerWalletsCountsDelegates (Base harness, FOUNDRY_ISOLATE=false FOUNDRY_TEST=test/scratch forge test --offline --match-path 'test/scratch/Verify.t.sol'): the assertion 'one real holder counted as 3 wallets' holds (Phase.Decided).

### 3. Low: TaskVault: a payout or withdrawal addressed to the vault itself is silently lost (liabilities drop, balance stays, no bucket)

`contracts/src/TaskVault.sol:718`

```
        if (to == address(0)) revert WithdrawToZero();
```

_withdrawTo and _pay accept any `to` including address(this) (and the desks). withdrawTo(token, address(vault)), or a vote with payTo = address(vault) followed by claimVoterReward, makes an ERC20 self-transfer succeed (an ETH send to the vault reverts for lack of receive and is credited to the vault, which can never withdraw it): liabilities[token] falls by the amount, the tokens stay in the vault, credits[token][vault] stays 0, so nobody can ever claim them. Only the caller's own money is lost (self-harm), but it breaks the documented 'every amount belongs to a bucket, liabilities == sum of buckets' invariant and leaves balance > liabilities permanently. Smallest fix: revert in _withdrawTo and in vote when to / payTo is address(this), caseDesk or disputeDesk.

**Reproduction**

USDC task 100e6 by author; doer submits; warp 25 h; poke -> Accepted, credits[usdc][doer] = 93e6 (90 reward + 3 bond back). doer calls withdrawTo(usdc, address(vault)): succeeds; usdc.balanceOf(vault) unchanged; liabilities[usdc] == before - 93e6; credits[usdc][vault] == 0. Verified on 84b446c with test/scratch/Verify.t.sol test_withdrawToVaultLosesBucket (all three assertions hold). Same path with vote(subId, side, address(vault), ids) then claimVoterReward(subId, address(vault)).

### 4. Low: ParamsBounds allows nftFeeBps == feeBps, which zeroes the stakers' part and voids every staked doer bonus of that version

`contracts/src/VaultParams.sol:195`

```
        _req(p.nftFeeBps <= p.feeBps, "nftFeeBps");
```

docs/MECHANICS.md promises the staked doer b = 4% x min(1, power / reward) of R out of the stakers' 5%. DisputeDesk._payReward computes stakers = fee - nftPart and caps bonus at stakers; YoloSweetener uses the same cap. ParamsBounds only requires nftFeeBps <= feeBps, so a future version with nftFeeBps > feeBps - 400 pays less than the documented 4% and at equality exactly 0: a doer with a running 52-week lock worth the whole reward gets r - fee + 0, while Submission.stakeBps still says 400. Owner-only, 48 h timelock, future tasks only: a parameter hardening item, not a theft path. Smallest fix: _req(p.nftFeeBps + MAX_STAKE_BONUS_BPS <= p.feeBps, "nftFeeBps") or document that the bonus is min(4%, feeBps - nftFeeBps).

**Reproduction**

Owner proposes defaultParams with nftFeeBps = 1000 (feeBps 1000): validate passes; warp 48 h; activateParams. Doer locks 2,000,000 YOLO for 52 weeks (worth more than 100 USDC at the test price), submits on a new 100 USDC task: stakeBps == 400, staked == true. Warp 25 h, poke -> Accepted. Expected per docs: credits[usdc][doer] == 97e6 (90 reward + 4 bonus + 3 bond back; the control test under the launch split returns exactly 97e6). Actual: 93e6, bonus 0. Verified on 84b446c: test/scratch/Verify.t.sol test_nftFeeEqualsFeeZeroesStakeBonus fails with '93000000 != 94000000' (the assertion omitted the 3e6 bond, the bonus is 0 either way) and VerifyControl.test_defaultSplitPaysBonus passes with 97e6.

### 5. Low: YoloPredict.predict reads activeSub without poking: a submission already final by timer keeps the market closed until someone pokes

`contracts/src/YoloPredict.sol:139`

```
        if (task.activeSub != 0) revert SubmissionActive();
```

predict reads vault.getTask without vault.poke, while settle, sweeten and YoloSweetener.withdraw poke first. A submission whose rejection is already due (vote decided reject, appeal window elapsed) but unpoked leaves activeSub != 0, so predict reverts SubmissionActive although the task is free and before its deadline. No funds at risk: anyone may call vault.poke(taskId) first. Smallest fix: call vault.poke(taskId) at the top of predict.

**Reproduction**

Task t (deadline +365 d); doer submits (sub s); dave disputes; voter1 casts 20 reject votes -> instant decision Reject (Phase.Decided); warp 25 h with no call: vault.getTask(t).activeSub == s. alice calls predict(t, true, 1e18): reverts SubmissionActive. After vault.poke(t) the same call succeeds and activeSub == 0. Verified on 84b446c with test/scratch/Verify.t.sol test_predictWithoutPoke (expectRevert SubmissionActive, then success after poke).

### 6. Low: BuybackBurner.forward(ETH) reverts for the same wallet that made the vault fall back, so fallen-back ETH waits on the dev wallet

`contracts/src/BuybackBurner.sol:100`

```
            if (!ok) revert EthSendFailed();
```

TaskVault._burn sends ETH burn shares to buybackWallet with 30,000 gas and falls back to the burner (_pay(token, burner, amount)) when that send fails. forward(ETH) sends to the very same buybackWallet and reverts on failure, so when the fallback was taken because the wallet has no receive (the reason the code names) forward(ETH) reverts as well, and keeps reverting until the dev wallet calls setBuybackWallet. docs/MECHANICS.md says the money 'then wait[s] for forward(token)', implying anyone can move it on. Bounded: only the burn shares of ETH tasks with a staked doer, no user money, and recoverable by the dev. Smallest fix: let the vault fall back to the treasury pot, or have forward(ETH) tolerate a failed send (keep the balance, no revert) so a changed wallet is not the only way out.

**Reproduction**

Deploy with buybackWallet = a contract without receive/fallback. ETH task 1 ETH, staked doer, accepted without dispute: _burn tries the wallet with 30k gas (fails), pays the share to the burner. forward(address(0)) reverts EthSendFailed; it keeps reverting until setBuybackWallet names a receiving address. Verified by reading BuybackBurner.forward lines 95-100 against TaskVault._burn lines 666-680 (the fallback is taken only when the wallet rejected the send; forward retries the identical recipient with no fallback of its own).

### 7. Low: BuybackBurner.pull makes the burner call withdraw(address) on any address the caller names

`contracts/src/BuybackBurner.sol:122`

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

pull(address vault, address token) is permissionless and vault is not checked, so anyone can make a money-holding contract issue an external call with selector withdraw(address) (0x51cff8d9) to an arbitrary contract, with msg.sender == burner. The burner grants no approvals and the call carries no value; none of YOLO, IMD, USDC or ETH exposes a withdraw(address) that moves the burner's balance, so nothing moves today. It is a hardening gap (an arbitrary-call primitive from the burner's address) that a future token with hooks or a withdraw(address) entry point would turn into a loss. Merged from audit_flow (info) and audit_permissions (low). Smallest fix: restrict vault to one whose burner() == address(this), or let the dev wallet set it once.

**Reproduction**

Deploy any contract C with function withdraw(address) external that records msg.sender; call burner.pull(address(C), address(0)): C.withdraw runs with msg.sender == burner. Expected: only the configured TaskVault may be called; actual: any address. Verified by reading: no check on `vault` in pull (lines 121-123).

### 8. Low: cancelTask by the dev wallet voids every YoloPredict market on the task: a losing predictor allied with the dev key recovers 100%

`contracts/src/YoloPredict.sol:271`

```
        if (task.status == TaskStatus.Unwound) return Resolution.Void; // cancelled by the dev wallet: all back
```

cancelTask (TaskVault.sol:365-376) is allowed in any open phase by a single key, the DevPrices owner. The vault pays the dev nothing, as the Known limitation says, but an Unwound task resolves every YoloPredict market on it as Void, which refunds 100% of every position with no burn and no deferred part. A WON'T DO position that is about to lose (an honest submission inside its 24 h dispute window, or Decided with an NFT accept until callFrom + appealWindow) is therefore convertible into a full refund by one dev-wallet transaction sent before the lazy acceptance is poked. Who profits: whoever the dev key shields, including the dev wallet from a second address; how much: the whole losing stake plus its 10% entry cost; the honest doer loses the reward they earned and the 2.5% deferred share, WILL DO winners lose the losing pool. This is the stolen-key / colluding-key worst case for the dev wallet, which the perms specialist listed as 'pure griefing, no take'; it is listed as a trust assumption with a money path, not a permission bypass. Smallest fix: document it in Trust assumptions, or put cancelTask behind the registry's 48 h timelock, or resolve a market whose submission was unwound after the deadline by the task outcome rather than Void.

**Reproduction**

Task T (100 USDC) with WILL DO 1000 YOLO by A and WON'T DO 1000 YOLO by B (B = a dev-controlled second wallet). Doer submits an honest proof at t0; nobody disputes. At t0 + 23h59m (inside the dispute window, so _advance does not finalize) dev calls vault.cancelTask(T): CaseDesk.unwind closes the submission Unwound and the task becomes Unwound. YoloPredict.settle(T) resolves Void (line 271); B.claim(T) pays B 1000 YOLO (paidIn). Expected had the window run out: Completed, B gets 0, A gets 900 + 900, doer gets 25 YOLO deferred. Verified by reading cancelTask (t.status Open in any phase, no timelock) and _resolve/claim (Void -> pay = paidIn).

### 9. Info: dispute(): the Phase.Voting branch is unreachable (doc 'allowed in Review or Voting' vs AlreadyDisputed)

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

```
        if (ph != Phase.Review && ph != Phase.Voting) revert WrongPhase();
```

A submission is in Voting only after a dispute, and line 102 (`if (d.disputer != address(0)) revert AlreadyDisputed();`) then always reverts, so the Voting arm and the MECHANICS wording 'Allowed in Review or Voting while now < windowEnd' describe a second dispute that can never land. No money effect; align the doc ('Review only; a second dispute reverts AlreadyDisputed') or drop the arm.

**Reproduction**

Review -> dave disputes (phase Voting). erin calls dispute(subId, ...) during the vote: passes line 98, reverts AlreadyDisputed at line 102 in every case.

### 10. Info: vote(): a payTo record aggregates both sides from different voters, keeps side 0, and may later vote the other side itself

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

```
            VoterRec storage pr = book.recs[payTo];
```

When voter != payTo the counted NFTs are added to book.recs[payTo].accept/reject while the side lock (AlreadyVoted) lives on the voter's record. Two voters on opposite sides may name the same payTo, so one record carries accept > 0 and reject > 0 with side == 0; a payTo that votes later sees newWallet == true, is counted as a fresh wallet and may take the opposite side with its own NFTs. _claimVoter then pays payTo for whichever side wins. Each NFT still votes once and the totals stay exact, so no money is created; votesOf(payTo)/sideOf(payTo) can disagree with the one-side-per-wallet rule the site shows. Merged from audit_flow and audit_economics. Hardening: document that payTo pools are side-agnostic, or refuse a payTo that already holds counted votes of the other side.

**Reproduction**

voter A votes (subId, 1, P, [id1]); voter B votes (subId, 2, P, [id2]); then P votes (subId, 2, P, [idP]) successfully. votesOf(subId, P) == (1, 2), sideOf(subId, P) == 2 only after P's own vote; claimVoterReward(subId, P) pays for the winning side's count whichever it is.

### 11. Info: createTask: no upper bound on the deadline locks every contributor's top-up until then

`contracts/src/TaskVault.sol:267`

```
        if (deadline <= block.timestamp) revert BadDeadline();
```

Only deadline > now is checked. Other contributors' top-ups (topUp is open to anyone) are locked until the deadline like the author's reward (withdrawContribution reverts TaskNotOpen while Open), so a task with a deadline decades away keeps every top-up locked unless the dev wallet cancels. Not a loss (each contributor chose the task); a sanity cap inside ParamsBounds (for example 2 years) would bound the lock-up and the YoloPredict weight term.

**Reproduction**

createTask(..., deadline = block.timestamp + 100 * 365 days, ...) succeeds; alice.topUp(taskId, 100e6); alice.withdrawContribution(taskId) reverts TaskNotOpen until the deadline or a cancelTask.

### 12. Info: YoloSweetener.withdraw: the 'Open, not busy, past deadline' clause is unreachable after vault.poke

`contracts/src/YoloSweetener.sol:112`

```
            || (t.status == TaskStatus.Open && !t.busy && block.timestamp >= t.deadline);
```

vault.poke(taskId) on the previous line runs _advance, which closes an Open task with no active submission past its deadline as Expired (TaskVault.sol:642), so submitTerms never reports Open && !busy && now >= deadline afterwards. Dead code, harmless; the notice text 'or it is past its deadline with no submission in flight' describes a state the function never sees. Merged from audit_economics and audit_permissions.

**Reproduction**

Any task past its deadline with no submission: after poke, t.status == Expired; the third disjunct never decides the outcome.

### 13. Info: Comment drift: the TaskVault header says doers post IMD bonds; a bond is 0.5 IMD plus 3% of the author's reward in the task currency

`contracts/src/TaskVault.sol:36`

```
/// @notice Escrow for real-world tasks. Rewards in ETH, USDC, IMD or $YOLO; doers post IMD bonds; anyone can dispute;
```

Desk._doerBond books b.imd = bondFixedImd and b.rew = doerBondBps of the author's own reward in the task currency (ETH, USDC, IMD or YOLO), collected by _collectBond. The header understates what a doer locks. Documentation only.

**Reproduction**

quoteSubmission on a 100 USDC task returns Bond(0.5e18, 3e6); an accepted doer's USDC credit is 90e6 + 3e6 (the bond part back), as the scratch tests show.

### 14. Info: Comment vs code: the registry owner can redirect the accrued treasury pot by naming a new treasury (sweepTreasury reads the current version)

`contracts/src/TaskVault.sol:501`

```
        address treasury = reg.core(reg.currentVersion()).treasury; // the current address, even before a new task
```

ParamsRegistry.sol:16 says 'The owner has no function that moves, freezes or redirects deposited money'. That holds for deposits, but protocol fees already accrued in treasuryPot are credited to the treasury of the version current at the sweep, so a new version (48 h timelock) with another treasury redirects every fee accrued so far, not only future ones. A documented trust assumption of the owner key, listed for the risk register; no user money is involved. Other stolen-key worst cases match docs/MECHANICS.md Trust assumptions: a new version's intake or an added attester decides the swarm answers of tasks under it; the dev wallet's cancelTask case is the separate low entry.

**Reproduction**

Owner proposes Params with treasury = X, waits 48 h, anyone calls activateParams, then sweepTreasury(token): credits[token][X] += treasuryPot[token] (all accrued fees).

### 15. Info: YoloPredict: an Expired task with nobody on WON'T DO pays the whole WILL DO pool to the author (documented rule, by design)

`contracts/src/YoloPredict.sol:219`

```
            yolo.safeTransfer(task.author, pool);
```

docs/YOLO.md line 228 documents it: 'if Expired and nobody predicted WON'T DO, the whole losing pool (90% of WILL DO) goes to the author'. An author who posts a condition nobody can meet earns 90% of every WILL DO position at the cost of gas (the reward comes back on expiry). The same profit is already reachable through the accepted Known limitation (the author takes WON'T DO from a second wallet), so this is a design note for the requester, not a defect. Scope note: payout amounts and role scenarios are separate orders; the splits in _payReward, _splitCurrency, YoloPredict.claim and YoloSweetener.payout were checked only for conservation.

**Reproduction**

Author creates a 5 USDC task with an impossible rule and a 7-day deadline; three users predict WILL DO 1000 YOLO each; nobody predicts WON'T DO; no submission. After the deadline settle(T) runs the WontDoWon branch with wontDoPool == 0: the author receives 2700 YOLO, the predictors nothing, and withdraws the 5 USDC.

---

Judge's submission `00bc3ce314ed42d994e7754299fade3e0f488fb93db93ca302b7b7f70a9078d0`, 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.
