# Audit report

> Audit the smart contract code of moneycanbuy.fun for security bugs. Repository at commit 5f6af8f1052f555553aad41a0e12fc1d29fa90b6. 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.
>
> ALREADY FIXED IN THIS COMMIT (passing PoC in contracts/test/audit/): maxImdIn caps the IMD bond part too (MaxImdInCapTest); a late askAppealSwarm reverts (LateAppealAskTest); a 0 bps stake keeps the staked split (StakeFloorZeroSplitTest); stepDeadline while Appealed (StepDeadlineAppealedTest).
>
> ACCEPTED BY DESIGN, documented in docs/MECHANICS.md Known limitations. Not findings unless the code differs from the documented rule: 20+ NFTs disputing from a second wallet; undisputed in 24 h = accepted, WON'T DO predictors dispute; an appeal not asked in 6 h void, full refund, new 24 h window; the queue judged in turn; one dispute per submission; cancelTask in any open phase, spent swarm fees kept; a started YoloLockHub week counted whole.
>
> 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 each pull (TaskVault.sol:555, 696, 769, 787, 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 _fundCall (sender = a.appellant, :291) 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 | `5f6af8f1052f555553aad41a0e12fc1d29fa90b6` |
| Job | `afb75675-216c-48d8-9984-452e2869aa25` |
| Judged | 2026-10-19 16:08 UTC |
| Findings | 1 medium · 2 low · 14 info |

Four agents audited the code as it is at `5f6af8f`, 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: DisputeDesk: the disputer may call the swarm (or appeal) FOR the doer and is then paid a share of its own forfeited dispute bond

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

```
        if (sender == s.doer ? sidesDoer : sender == d.disputer && !sidesDoer) {
```

Item 8 / item 11. `appeal` and `callSwarm` bar the doer from standing against their own submission (DoerCannotDispute) but not the disputer from standing WITH the doer. When the one disputer calls `callSwarm(subId, sidesDoer = true, ...)` in NoDecision (or appeals a Reject decision), `_fundCall` (line 296: the condition is false for `sender == d.disputer && sidesDoer`) treats them as a stranger: a second dispute bond plus the fee. In `_settleDecided`, when the swarm sides with the doer, that same wallet is the winning caller: `winnerBonds = doerBond + a.bond` and `_splitCurrency` credits `appPart = winnerShare * a.bond / winnerBonds` to the disputer out of the penalty that is the disputer's OWN forfeited dispute bond (`appellantIsStranger` is false, so no 0.5 IMD carve-out, but the pro-rata split still runs). If the swarm sides against the doer instead, the disputer is the sole winner and `claimDisputer` returns the lost caller bond in full (penalty = doer bond + the caller's bond, 100% to the disputers). So a disputer who expects to lose hedges for one swarm fee: instead of forfeiting the whole dispute bond to the doer they claw back half of the IMD penalty (0.25 of 0.5 IMD) and a quarter of the reward-currency penalty (1% vs 3% weights). Rule broken (docs/MECHANICS.md, Money and fees: 'NFT undecided or wrong ... 100% to the winning side'): part of the loser's bond returns to the loser. Loss at R = 10,000 USDC: the doer receives 75 USDC + 0.25 IMD of the 100 USDC + 0.5 IMD penalty; the disputer's net cost is the 0.5 IMD fee, so the hedge pays from roughly R >= 2,000 USDC. Bounded (25% of the disputer penalty, no vault money created), hence medium. Smallest fix: in `appeal` and `callSwarm` revert when `sender == d.disputer && sidesDoer` (mirror DoerCannotDispute), or in `_settleDecided` give an appellant who is also the losing dispute party no share of its own penalty. Merged from audit_permissions #1; reproduced on this commit.

**Reproduction**

Reproduced with a scratch test on the repository's Base fixture (contracts/test/Base.t.sol, USDC task, no votes): author creates a 10,000 USDC task; doer submits; dave disputes (bond 0.5 IMD + 100 USDC); nobody votes; warp 24 h + 1 s and poke -> NoDecision; dave calls `vault.callSwarm(subId, true, type(uint256).max, noSwap, genericRuleTexts)` (posts a second 0.5 IMD + 100 USDC bond plus the 0.5 IMD fee); the MockIntake delivers answer = true; poke -> Final/Accepted. Expected (100% of the loser's bond to the winning side): credits(usdc, doer) = 9,400e6 (9,000 reward + 300 own bond + 100 penalty), credits(imd, doer) = 1.0e18, credits(usdc, dave) = 100e6 (caller bond only), credits(imd, dave) = 0.5e18. Actual: credits(usdc, doer) = 9,375,000,000, credits(imd, doer) = 0.75e18, credits(usdc, dave) = 125,000,000, credits(imd, dave) = 0.75e18 (the test assertion `doer should get the whole penalty: 9375000000 != 9400000000` fails). Second run with answer = false: Rejected; `claimDisputer(dave)` pays dave 1.5 IMD + 500 USDC, i.e. his dispute bond, the doer's bond and his own lost caller bond (0.5 IMD + 100 USDC) back in full, so the only cost of the hedge is the 0.5 IMD fee.

### 2. Low: YoloLockHub.stakePower: a lock that ended after the submission and was re-armed with lock(0, 1) (no withdrawal) still returns the submission-time power, reviving the doer bonus

`contracts/src/YoloLockHub.sol:111`

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

Item 10 / item 14. The payout rule (docs/MECHANICS.md 'Doer stake condition': `stakePower(doer, submittedAt)` must not be 0, 'the same lock, never withdrawn, still running'; desks/DeskApi.sol:110: '0 once that lock ended or was withdrawn') is enforced only through `epoch`, which grows in `withdraw()` alone. `lock()` on a lock whose `end` passed starts a new term with `old = 0` (the comment at line 65: 'a lock set again starts afresh') but keeps `l.epoch`, so `_pointBefore(who, since)` still finds the original point (same epoch, `p.end > since`) and `l.end` is in the future again: `stakePower` returns the ORIGINAL power although the lock behind the submission ended and the new term may be a one-week lock of the same tokens. A doer whose lock lapses during a long case (dispute, vote, appeals) restores the up-to-4% bonus with one cheap `lock(0, 1)` call right before the finalizing poke, while a doer who withdrew loses it. Bounded: at most 4% of R, taken from the stakers' 5% fee share (half burn, half treasury), and the tokens stay in the hub, so low. At R = 100 USDC with full power: 4 USDC that the rule sends 2 to the burn + 2 to the treasury go to the doer. Smallest fix: in `lock()` increment the epoch when the previous term ended (`l.end <= block.timestamp` before the new term is set), or in `stakePower` also require `l.end == p.end`. Merged from audit_economics #1, audit_math #1 and audit_permissions #3 (same mechanism, same fix); the attached PoC is the specialists' and fails on this commit.

**Reproduction**

contracts/test/scratch/StakePowerRelock.t.sol (attached): t0: doer `lock(1000e18, 1)` -> end t0+7d, power 1000e18/52 = 19230769230769230769, epoch 0; since = t0+6d: `powerAt(doer, since)` = 19230769230769230769 (what CaseDesk._stakeBps reads at the submission). t0+7d: `stakePower(doer, since)` = 0 (ended). Doer calls `lock(0, 1)`: end t0+14d, epoch still 0. Expected: `stakePower(doer, since)` = 0 (as after `withdraw()` + `lock(1000e18, 1)`, the reference test). Actual: 19230769230769230769 (forge output: `[FAIL: re-locked ended lock must not revive the old power: 19230769230769230769 != 0]`). In the protocol: the next `poke` runs DisputeDesk._payReward -> _lockRuns -> stakePower != 0 and credits the doer `r * stakeBps / 10000` on top of 90% of R.

**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 MockYolo is ERC20 {
    constructor() ERC20("YOLO", "YOLO") {}

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

/// @notice A lock that ended and was re-locked without a withdrawal keeps its old `epoch`, so `stakePower(doer, since)`
///         still answers the power of the ended lock for a submission made under it. Expected (desks/DeskApi.sol
///         IDoerStake: "0 once that lock ended or was withdrawn"): 0, as after a withdraw + new lock.
contract StakePowerRelockTest is Test {
    MockYolo yolo;
    YoloLockHub hub;
    address doer = address(0xD0E7);

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

    function test_RelockOfAnEndedLockKeepsTheOldStakePower() public {
        // t0: one-week lock, power = 1000 * 1 / 52
        vm.prank(doer);
        hub.lock(1_000e18, 1);
        uint64 since = uint64(block.timestamp + 6 days); // the submission, inside the term
        uint256 atSubmission = hub.powerAt(doer, since);
        assertEq(atSubmission, uint256(1_000e18) / 52, "power read at the submission");

        // the lock ends at t0 + 7d; without any new lock the payout rule returns 0
        vm.warp(block.timestamp + 7 days);
        assertEq(hub.stakePower(doer, since), 0, "ended lock pays no bonus");

        // the doer starts a new one-week term on the same tokens (no withdrawal in between)
        vm.prank(doer);
        hub.lock(0, 1);

        // the lock behind the submission ended: the payout check must still be 0 (it is 0 after withdraw + lock)
        assertEq(hub.stakePower(doer, since), 0, "re-locked ended lock must not revive the old power");
    }

    function test_WithdrawThenLockGivesZero_reference() public {
        vm.prank(doer);
        hub.lock(1_000e18, 1);
        uint64 since = uint64(block.timestamp + 6 days);
        vm.warp(block.timestamp + 7 days);
        vm.startPrank(doer);
        hub.withdraw();
        hub.lock(1_000e18, 1);
        vm.stopPrank();
        assertEq(hub.stakePower(doer, since), 0, "epoch bumped by the withdrawal");
    }
}
```

### 3. Low: TaskVault.onOracleResult never compares the signed questionHash with the one stored at ask time: an attestation for another question settles the case

`contracts/src/TaskVault.sol:477`

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

Item 8. `ask()` stores `t.questionHash` in `SwarmRequest.questionHash` (VaultTypes.sol:283: 'checks nothing') but `onOracleResult` binds an answer to a submission only by the Intake request id, `msg.sender == via.intake`, the panel numbers and the signature in the vault's domain; `a.questionHash` is never compared and `a.requestId` (the oracle UUID) is never consumed. The pairing of an answer with its question is therefore entirely the Intake writer's: an attestation issued for submission A's question, delivered under submission B's request id, is recorded as B's final verdict (`_recordAnswer`), and the next `poke` settles B's reward and bonds on it. The pinned oracle-consumer reference ('Binding an answer to its request') asks a consumer that asks a different question per request to compare `a.questionHash` with the stored hash first, because the signature only proves that the oracle signed an answer for this contract. docs/MECHANICS.md 'Answer binding' documents the request-id binding only, and the Intake is a trusted party there, so this is a trust gap, not a broken documented rule: low. Caveat: SwarmQuestion.sol:25-26 says the on-chain `keccak256(question)` is an assumed formula ('UNVERIFIED'); adding `if (a.questionHash != r.questionHash) { emit AttestationIgnored(requestId, 4); return; }` is right only once the plane's derivation is confirmed, otherwise every answer becomes silence. Merged from audit_economics #2 and audit_permissions #2.

**Reproduction**

State: two submissions A and B in Phase.Asked with Intake request ids ridA and ridB and different questions (e.g. two `callSwarm` on two tasks, Base fixture). Action: the Intake (MockIntake.complete in the test suite) calls `vault.onOracleResult(ridB, attA, sigA)` where attA has `questionHash` = A's (any value, the fixture uses keccak256('unknown formula')) and `answer` = yes, signed by the registry attester for the vault. Expected per the oracle-consumer reference: `AttestationIgnored` because the signed question is not the one stored for ridB. Actual: `attestationChecker.check` returns 0 (domain, panel, signer, window all fine), `SwarmAnswered` is emitted for ridB and `poke(taskB)` finalizes B as Accepted (doer credited R - 10%). No check in the code path reads `_requests[ridB].questionHash`.

### 4. Info: BuybackBurner.pull makes an unrestricted outbound call withdraw(address) to any caller-chosen address from the burner

`contracts/src/BuybackBurner.sol:122`

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

Item 5 / item 13, hardening. `pull(vault, token)` is permissionless and takes `vault` from the caller, so anyone can make the BuybackBurner (which holds unburned $YOLO and the fallback ETH/USDC/IMD) call `withdraw(address)` (selector 0x51cff8d9) on any contract with the burner as msg.sender. No money moves today: the burner grants no approvals, sends no value, and none of the four tokens exposes a `withdraw(address)` acting on the caller's balance. Fix: take the vault as an immutable constructor argument (ProtocolDirectory lists one vault) and drop the parameter. Merged from audit_flow #1 and audit_permissions #4; downgraded to info because no loss is reachable on this deployment.

**Reproduction**

Deploy any contract C with `function withdraw(address) external` that records msg.sender; call `burner.pull(address(C), address(0))` from an EOA. Expected: revert (not the protocol vault). Actual: C.withdraw executes with msg.sender == address(burner). Balances unchanged.

### 5. Info: Doer stake bonus depends on when the payout is poked, not only on the submission and the judging period

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

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

Item 10. `_lockRuns` -> `YoloLockHub.stakePower` requires `l.end > block.timestamp` at the moment `_payReward` runs, which is whenever someone plays the timer (`step`/`poke`), not the window's end. A doer whose lock ends after the dispute window but before the first poke loses the up-to-4% bonus although the lock ran through the whole judging period; it goes to the treasury instead. docs/MECHANICS.md says explicitly 'At payout: ... still running', so this is the documented rule; it only sits uneasily with 'the outcome never depends on who pokes when' (swarm section). The doer can protect themself by poking at `windowEnd`. No loss of locked money; info. From audit_flow #3.

**Reproduction**

Base fixture: USDC task 100e6; doer locks 1,000,000 YOLO for 1 week, submits one second later, nobody disputes. Poke at submittedAt + 25 h: `stakePower` != 0, Settlement.stakeBps = 7, doer credited 93,070,000. Same state, first poke at submittedAt + 8 days (lock ended): stakeBps = 0, doer credited 93,000,000, the 70,000 to the treasury pot.

### 6. Info: Dead parameters: a version's nft and delegateRegistry are never used; votes always go through the immutable VotePower

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

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

Item 14 / item 5. ParamsRegistry.sol:18-19 says a new version 'may name other counterparties (intake, nft, delegateRegistry, devPrices), which then judge every task created under it', and ParamsBounds requires `nft != 0`. `DisputeDesk.vote` discards the first two fields of `voteParams(version)` and asks the immutable `votePower` (constructor argument, NFT and delegate registry fixed at launch 1). docs/MECHANICS.md ('NFT votes are counted by VotePower (launch 1)') matches the code; the registry comment and the two fields do not. No money effect. From audit_economics #3.

**Reproduction**

Propose and activate a version 2 with nft = X and delegateRegistry = Y; create a task under it, dispute a submission, vote: ownership is still read from `VotePower.nft` (launch 1), delegations from Y are not honoured. `grep -rn 'voteParams' contracts/src/desks` shows the only reader discards `nft` and `delegateRegistry`.

### 7. Info: Comment drift: Appeal.spent promises a reimbursement to a winning caller that no code pays

`contracts/src/VaultTypes.sol:245`

```
    uint256 spent; // paid to the Intake for his question (reimbursed from the loser if he wins)
```

Item 14. `spent` is written in `DisputeDesk._charge` (line 325) and read nowhere (`grep -rn '\.spent' contracts/src`). `_settleDecided` credits a winning stranger `min(bondFixedImd, penaltyImd)` (lines 641-645) and a winning doer or disputer who paid the fee from their bond gets nothing back, as docs/MECHANICS.md states ('Swarm fees are not refunded ... the one exception: a third party ... gets 0.5 IMD'). Docs win; fix the comment. From audit_economics #4.

**Reproduction**

Doer appeals a Reject decision (fee 0.5 IMD out of the doer bond, a.spent = 0.5e18), the swarm answers yes: the doer is credited `f.bond.imd - sideSpent.doer` = 0 IMD of bond and `a.funds` = 0; nothing of `spent` comes back.

### 8. Info: Comment drift: Contribution.ever says a contributor who wins gets only their bond back; every winner gets the full share

`contracts/src/VaultTypes.sol:181`

```
    bool ever; // ever contributed to this task: as a winner they get only their own bond back
```

Item 14. `_settleDecided`/`_splitCurrency` give the author, a contributor or the doer-contributor the same winner share as anyone (DisputeDesk.sol:614-615; docs/MECHANICS.md 'Winner who put money into the task gets their share like any winner'). `IVault.isContributor` is exposed but no desk reads it; `ever` gates nothing in the money paths. Merged from audit_economics #5 and audit_flow #5.

**Reproduction**

`grep -rn isContributor contracts/src/desks` returns only the interface declaration (DeskApi.sol:100). Author A disputes a submission on their own task and wins: `claimDisputer(A)` pays A's bond plus the pro-rata `disputerReward`, not only the bond.

### 9. Info: Comment drift: the Burned event says $YOLO goes straight to 0x...dEaD and other tokens to the BuybackBurner; the vault does the reverse

`contracts/src/VaultTypes.sol:364`

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

Item 14. `TaskVault._burn` (lines 665-679) pays $YOLO to the BuybackBurner (it waits there for anyone's `burnYolo`) and tries the buyback wallet first for ETH/USDC/IMD with 30,000 gas, falling back to the burner. docs/MECHANICS.md 'Where a burn goes' matches the code. Merged from audit_flow #6 and audit_permissions #6.

**Reproduction**

Accept a $YOLO-reward submission without a dispute and read the `Paid` event next to `Burned`: the $YOLO fee goes to `vault.burner()`, not to 0x...dEaD; a USDC burn share of a staked doer goes to `burner.buybackWallet()`.

### 10. Info: Comment drift: the money-rules comment lists a 'dispute ETH pot' bucket that does not exist

`contracts/src/TaskVault.sol:49`

```
///           check or appeal prepayments, a dispute ETH pot, a settlement bucket waiting for lazy claims, a treasury
```

Item 14. There is no ETH dispute fee (docs/MECHANICS.md: 'No ETH fee') and no such bucket: `dispute()` books only `bond.imd` and `bond.rew` through `collectImd`/`collectBondPart`. From audit_flow #4.

**Reproduction**

`grep -rn 'ETH pot' contracts/src` matches only this comment line; no state variable or write exists.

### 11. Info: Comment drift: quoteSubmission promises a 3% part on the $YOLO add-on that the doer bond does not have

`contracts/src/interfaces/ITaskVault.sol:56`

```
    /// @notice The bond a new submission to `taskId` would need now: 0.5 IMD fixed + 3% R (and 3% of a $YOLO
```

Item 14. `Desk._doerBond` (Desk.sol:127-130) takes 0.5 IMD + 3% of the author's own reward only; docs/MECHANICS.md 'Doer bond' says 'no $YOLO add-on'. The public interface comment is wrong. From audit_permissions #5.

**Reproduction**

Create a 100 USDC task, sweeten it with 1,000,000 YOLO, call `vault.quoteSubmission(taskId)`: Bond{imd: 0.5e18, rew: 3e6}, no add-on part.

### 12. Info: Comment drift: a winning stranger's 0.5 IMD is capped by what the loser's IMD bond has left after that side's own swarm call

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

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

Item 14. The comment at lines 628-630 promises the stranger 'gets 0.5 IMD off the top of the loser's bond first'. `penaltyImd` was already reduced at line 637 by the losing side's own bond-funded swarm spend, so when the loser's fixed 0.5 IMD paid its own earlier (silent) call the remaining IMD penalty is 0, the branch is skipped and the stranger recovers nothing. docs/MECHANICS.md does not promise more ('from the loser's IMD bond'); the comment does. From audit_math #2.

**Reproduction**

Doer bond 0.5 IMD; NoDecision; the doer calls `callSwarm(sidesDoer = true)` paid from its bond (fromBond 0.5e18), the swarm stays silent (_sideSpent.doer = 0.5e18); a stranger calls `callSwarm(sidesDoer = false)` and the swarm answers no. `_settleDecided`: penaltyImd = 0.5e18 - 0.5e18 = 0, `strangerFee` never credited; the stranger gets only its own bond back.

### 13. Info: Trust assumption: the dev wallet redirects every future ETH, USDC and IMD burn share at once with setBuybackWallet, read live at payout

`contracts/src/TaskVault.sol:670`

```
            address wallet = IBurnerWallet(burner).buybackWallet();
```

Item 5, worst case per key. A stolen DevPrices-owner key can (a) point the buyback wallet anywhere in one transaction and from then on receive, in the same transaction as each staked-doer payout, `half + nftPart - toVoters` (up to 7.5% of R on an undisputed staked task) in ETH, USDC or IMD, plus everything waiting in the BuybackBurner through `forward`; (b) `cancelTask` any open task (everyone refunded, YOLO Predict markets unwound, a way to save a losing position); (c) move the three prices 10x per transaction, changing only the stake bonus of new submissions. It cannot take rewards, bonds, credits or $YOLO. docs/MECHANICS.md names `setBuybackWallet` as a dev parameter, so this is documented, not a defect. From audit_economics #6.

**Reproduction**

Dev key calls `BuybackBurner.setBuybackWallet(attacker)`. Next accepted submission of a staked doer on a 100 USDC task with no dispute: `_payReward` -> `bank.burn(USDC, 0.5e6 + 5e6)` -> `_burn` sends 5.5 USDC to `attacker` in the payout transaction (Paid event).

### 14. Info: Stale attester proposals never expire: a forgotten removal stays executable by anyone forever

`contracts/src/ParamsRegistry.sol:186`

```
    function executeAttester(address attester, bool allowed) external {
```

Item 5 hardening. `attesterChangeEta[(attester, allowed)]` stays executable once its eta passed (no expiry) and execution is permissionless. If the owner proposes a removal, never executes or cancels it, anyone can execute it at any later moment, e.g. right after the same key was re-added, turning every pending answer signed by it into silence (fees spent, at most 3 calls per submission). `LastAttester` only protects the final key. The owner can always cancel, so operational hygiene; a grace window (`eta + 7 days`) would remove the foot-gun. From audit_economics #7.

**Reproduction**

t0: owner proposes (A, false) and (B, true); t0 + 48 h: anyone executes (B, true); the owner forgets (A, false). Later, with a third key C live, anyone executes the old (A, false): `attesterCount != 1`, so A is removed and every open case whose answer A signs becomes silence.

### 15. Info: YoloPredict: a WILL DO position whose points floor to 0 is treated as absent by settle, so the WON'T DO pool goes to author and doer

`contracts/src/YoloPredict.sol:209`

```
        if (res == Resolution.WillDoWon && m.willDoPointsTotal == 0 && m.wontDoPool != 0) {
```

Item 9. `_points` floors twice (line 286-287); with the 1 YOLO minimum (effPosition 0.9e18) and `remaining / duration < ~1e-9` the points are 0 (a deadline more than ~30 years away and a position in its last second). The position still pays 5% burn and 5% deferred, and if every WILL DO position is like that `willDoPointsTotal == 0` makes `settle` hand the whole WON'T DO pool to the author and doer while those predictors only get their 90% back through `claim`. Accounting stays balanced; only reachable with absurd deadlines. If wanted: revert in `predict` when `points == 0`. Merged from audit_economics #8 and audit_flow #7.

**Reproduction**

createTask with deadline = now + 40 years. Q predicts WON'T DO 100e18 at creation; at deadline - 1 s, P predicts WILL DO 1e18: mid = 0.9e18 * 1 / 1.26e9 = 714285714, points = 714285714 / 1.26e9 = 0. Task Completed: `settle` sees willDoPointsTotal == 0 and wontDoPool = 90e18 -> 45e18 to the author, 45e18 to the doer; `claim(P)` pays 0.9e18. Expected per docs/YOLO.md: P, the only winner, takes the losing pool.

### 16. Info: _trySend's ERC20 branch would count a call to a code-less token address as a successful transfer

`contracts/src/TaskVault.sol:754`

```
        return ok && (ret.length == 0 || (ret.length == 32 && abi.decode(ret, (bool))));
```

Item 3 hardening. A raw `token.call` returning ok with empty data is accepted, so a token address without code would reduce `liabilities` and emit `Paid` with nothing moving. Not reachable here: `usdc`, `imd` and `yolo` are immutables with code at launch, cannot self-destruct post-Cancun, and no other token is ever paid. A `token.code.length != 0` check or SafeERC20's pattern closes it. From audit_flow #2.

**Reproduction**

Only with a code-less token address, which the constructor cannot produce for the three live ERC-20s. No failing input on this deployment.

### 17. Info: Scope note: payout amounts and role scenarios are separate orders and were not audited as a deliverable here

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

```
    function _settleDecided(uint256 subId, Facts calldata f, bool doerWins, bool nftRight, ParamsCore memory p)
```

As the assignment asks, one line: the exact payout arithmetic of `_settleDecided`/`_payReward`/`_splitCurrency` and the role-by-role scenario matrix were not checked as their own deliverable; only the access, trust and asymmetry aspects of these paths were reviewed (the medium finding above came out of that). No issue is claimed about the amounts themselves.

**Reproduction**

Not applicable (scope note).

---

Judge's submission `4b6342f7575b9b9ba8eb6e494fcd2e35e4fe7bc983f4485c8141e3e0a2174f65`, 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.
