# Audit report

> Audit the protocol logic and role scenarios of moneycanbuy.fun for abuse. Repository at the pinned commit d9c24e3b2d199548faf236d7bb5e80e4a12b6ada, as it is. Run forge at the repository root (foundry.toml); code in contracts/src/, import as "src/...". Do not modify any existing file; findings go only into the report and NEW files under contracts/test/audit/.
>
> SCOPE. Walk every role through a task from creation to the last payout and look for logic abuse: holding someone else's money, skipping or capturing the dispute, voting twice, collusion against third parties, what a user cannot learn from the chain alone. Separate orders cover code security (reentrancy, invariants, access rights, rounding, gas) and payout amounts per outcome; here a wrong percentage or a bug with no role scenario is one info line.
>
> SOURCE OF TRUTH. The rules below win over docs and comments. Code: contracts/src/ (TaskVault, desks/, VotePower, YoloPredict, YoloSweetener, YoloLockHub, HiddenList).
>
> RULES.
> R1 Task: reward in ETH, USDC, IMD or YOLO (min 0.002 ETH, 5 USDC, 1 IMD, 1,000 YOLO), fixed deadline. Anyone tops up while Open before the deadline; a top-up during a check joins the reward at once. Nobody withdraws while Open; Expired: each contributor takes back his own; a Completed task takes no new money.
> R2 Submit: anyone, the author too; bond 0.5 IMD + 3% of the author's own share; one active, the rest queued and judged even after the deadline; on acceptance or cancel queued ones void, bond back. The doer writes why it is honest at submission. A doer whose YOLO lock (YoloLockHub) runs from submission to payout gets up to 4% of the reward extra; any power counts, no threshold.
> R3 Dispute: 24 h from activation, anyone but the doer, one disputer per submission, bond 0.5 IMD + 1% of the author's own share; reason required, proof link optional.
> R4 Vote: 24 h; DisputeDesk.vote(subId, side, payTo, tokenIds); NFT ids owned now or delegated (delegate registry v2); each id once per dispute; side fixed; own transaction and gas. Instant decision at a 20-vote lead; at the end a 10-vote lead and >= 1 wallet decide; else no decision.
> R5 Swarm: after a decision anyone appeals within 24 h (the doer only a rejection); a dispute side pays the fee from its bond, an outsider posts bond + fee; anyone asks the question in a later call; an appeal not asked within 6 h voids with a full refund. No decision: anyone calls the swarm within 24 h; nobody calls = accepted, disputer bond to the doer. Answer within 6 h (+1 h delivery grace) is final; silence restores the prior state; max 3 calls per submission, then as if never called.
> R6 Predict: YOLO, irrevocable, before the deadline, paused while a submission is active; Completed = WILL DO, Expired = WON'T DO, cancelled = full refund. Anyone predicts, author and doer too.
> R7 The dev wallet may cancel an open task in any phase: everyone takes back exactly what he put in, the dev wallet nothing. The dev wallet (the DevPrices owner) also sets the YOLO, ETH and IMD prices by hand (USDC = $1) and the buyback wallet. The params owner may pause new tasks. HiddenList hides on the site only, money untouched.
> R8 Any frontend reads the chain via public RPCs; the protocol needs no project server, keeper or relayer.
>
> REPORT FORMAT, MANDATORY. One entry per item, same order, even "no issue"; a missing item = INCOMPLETE. Entry 0: VERDICT, max 8 lines, every blocker. Items 1-11: severity, scenario as numbered steps (who calls what, when, at what cost), file:line, loss or delay in tokens at a 100 USDC reward and a 10,000 YOLO prediction pool, smallest fix, and a Foundry PoC (full source, contracts/test/audit/, fails now and passes once fixed, no fork, no ffi) if real, else one line why not. Severity: critical = someone takes or permanently freezes another's money; high = realistic loss, or a rule above broken; medium = bounded loss, a lock over 7 days, or cheap griefing; low = edge case; info = text drift, "no issue".
>
> 1. ROLE WALK. For author, contributor, doer, queued doer, disputer, NFT voter, delegate, appellant, outside swarm caller, predictor, staked doer, dev wallet, params owner: each call in each phase and its result. Flag a call allowed outside its rule, or a rule with no path.
> 2. HOSTAGE. Who can keep another's reward, bond, add-on or position locked, how long, at what cost: junk submissions queued near or after the deadline, unasked appeals, three silent swarm calls, repeated dispute rounds, a phase with no exit. Give the longest path from the deadline to the author's refund.
> 3. SKIPPING THE DISPUTE. Getting a fake accepted with no real check: a friend takes the only dispute slot with a weak reason and stays passive (an outside swarm caller cannot add his own reason), the window opening at a quiet hour through queue order, an instant decision by own NFTs, a cancel or the deadline in between.
> 4. DOUBLE VOTE. One NFT counted twice in a dispute: owner and delegate, transfer after a vote, delegation changed mid-vote, several transactions, payTo tricks; same for HiddenList votes. Can a voter gain by having both sides paid to him?
> 5. COLLUSION VS PREDICTORS. Author + doer, or one person with two wallets, against outside predictors: a fake accepted, a task meant to fail, a submission timed to freeze predictions, the deferred 2.5% + 2.5%, empty-side cases. Give the cheapest profitable plan and what an honest predictor can still do in time.
> 6. COLLUSION VS AUTHOR AND CONTRIBUTORS. Doer + disputer, doer + voters, doer + appellant; top-ups during a check; the author submitting to his own task.
> 7. THE DEV WALLET AS A PLAYER. Cancel as a free option: predict, then cancel when losing. Cancel timed to void a won dispute or a swarm answer. Anything the dev wallet or a friend of it gains.
> 8. ORDER AND RACES. Same-block front-running of dispute, vote, appeal, callSwarm, topUp, predict, cancel; lazy timers (poke) played late changing an outcome or a winner; deadline vs an active submission.
> 9. KNOWING BEFORE PAYING. From views alone, can a user learn before each call its full cost (bond, fee, max IMD in), the end of the current step, what he can claim and when? A view that lies or reverts in some phase is a finding.
> 10. NO SERVER. Can a third-party frontend with only an RPC rebuild every task, text, reason, vote and swarm answer (texts kept as hashes plus events)? Does any rule need someone of the project to send a transaction (keeper, relayer, poke)?
> 11. COVERAGE. Files read in full, what was not reached, forge build and test result (passed/failed).
>
> Not findings (by design, unless worse than stated): self-dispute from a second wallet; a single possible doer predicting WON'T DO and not doing; an author profiting from a task nobody can do; one late position on an empty side taking the other pool; junk submissions on small tasks (defense: dispute and take the bond); NFT holders not voting on small tasks; one stake lock for all submissions; many wallets in predictions; fees of silent swarm calls kept; test/mocks, brand/, landing/.

| | |
|---|---|
| Repository | https://github.com/sergey-mavr-agent/moneycanbuy.fun.git |
| Commit | `d9c24e3b2d199548faf236d7bb5e80e4a12b6ada` |
| Job | `ec600f03-fafc-4953-9534-d25014618b44` |
| Judged | 2026-10-19 11:36 UTC |
| Findings | 1 high · 4 medium · 3 low · 2 info |

Four agents audited the code as it is at `d9c24e3`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. High: R5 broken: askAppealSwarm never plays the timers, so an appeal is still asked after its 6 h void deadline

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

```
        ICaseDesk(caseDesk).askAppeal(subId, funds, rt);
```

Rule R5: an appeal not asked within 6 h voids with a full refund. That void lives only in CaseDesk.step (Phase.Appealed, line 206: block.timestamp >= appealedAt + swarmTimeout -> dropCaller, back to Decided, asks++), and step runs only from TaskVault._advance. Every other user action starts with ICaseDesk(caseDesk).advanced(subId) (dispute, appeal, callSwarm, claims), but askAppealSwarm reads the appeal and calls CaseDesk.askAppeal, which checks only phase == Appealed (CaseDesk.sol:274) and never compares with appealedAt + swarmTimeout. So whenever nobody poked the task, anyone can still ask the swarm hours or days after the 6 h deadline: the appellant's fee is spent, the bond is put at risk, and a fresh 6 h + 1 h swarm window starts from the late ask. Consequences: (a) race (item 8): at 6 h + x a poke voids the appeal with a full refund while askAppealSwarm in the same block spends the fee and asks; whoever lands first decides the outcome of the step. (b) hostage (item 2): an appellant or a friend can file at T and ask whenever they like later, keeping the reward, the doer bond and the YoloPredict positions frozen longer than the rules allow, up to 7 h more per appeal, three times per submission. (c) the appellant loses the 0.5 IMD fee R5 promises back. Loss at a 100 USDC reward: the appellant's 0.5 IMD fee (and the 0.5 IMD + 1 USDC bond if the late answer goes against them); the 100 USDC pot and a 10,000 YOLO prediction pool stay locked up to 21 h longer. Smallest fix: start askAppealSwarm with ICaseDesk(caseDesk).advanced(subId) so the timeout is played first (askAppeal then reverts WrongPhase), or have CaseDesk.askAppeal revert when block.timestamp >= appealedAt + p.swarmTimeout. Merged from audit_economics (high), audit_math item 8 (low) and audit_math verdict.

**Reproduction**

100 USDC task (author), doer submits (sub 1), dave disputes, a wallet holding 20 NFTs votes reject: instant Decided(Reject). The doer calls appeal(1, max, noSwap) at T: phase Appealed, appealedAt = T. No poke. warp to T + 10 h, anyone calls askAppealSwarm(1, rt). Expected (R5): the appeal is void, phase Decided after a poke, the Intake nonce unchanged. Actual: phase 7 (Asked), the Intake was paid (nonce 1), the doer's fee is gone. PoC contracts/test/scratch/LateAppealAsk.t.sol fails on this code ("7 != 5") and passes with ICaseDesk(caseDesk).advanced(subId) added at the top of askAppealSwarm (checked locally, source restored). The PoC deploys the desks and the vault inside one constructor, so it behaves the same with or without forge isolate mode: fails on this code and passes with the fix in both (checked on this machine, whose forge config has isolate = true).

### 2. Medium: Hostage: unasked appeals are free, each void reopens a fresh 24 h window, and the submission queue is unbounded; two queued junk submissions lock the author's refund past 7 days

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

```
            if (block.timestamp < uint256(s.appealedAt) + p.swarmTimeout) return false;
```

Item 2. An appeal nobody asks is refunded in full at appealedAt + 6 h (dropCaller: bond + prepaid fee), the phase goes back to Decided and callFrom = now, so a new 24 h appeal window opens; this is allowed three times (asks counts the void). A losing doer appeals from his own bond (no second bond, _fundCall) and never calls askAppealSwarm, or any stranger does it at zero net cost. Longest path of one disputed submission with every move at the last second: Review 24 h + Vote 24 h + 3 x (24 h window + 6 h unasked appeal) = 138 h; with three silent swarm calls instead 24 + 24 + 3 x (24 + 6 + 1 grace) = 141 h, about 5.9 days. Submissions queued before the deadline are judged one after another after it (TaskVault.closeSubmission line 443-446, rule R2), each with the full machine, and nothing caps the queue. So N junk submissions queued at deadline - 1 s hold the reward, every top-up, the YOLO add-on and the 10,000 YOLO prediction pool about N x 5.9 days; N = 2 is already 11.8 days (the rubric's 'lock over 7 days'). Cost per junk submission: 0.5 IMD + 3 USDC bond at a 100 USDC reward, lost only if someone disputes and wins; the appeals themselves cost nothing. Counter in the code: askAppealSwarm is permissionless, so a watcher can end an unasked appeal within 7 h, but that needs someone to send a transaction (rule R8). Smallest fix: do not refund the prepaid fee (or a part of the bond) of an unasked appeal, do not reset callFrom after a void (keep the original window), and/or cap the pending queue per task or void queued submissions whose activation falls after deadline + one full check. Merged from audit_math item 2, audit_economics, audit_flow (all medium) and audit_permissions item 2 (info).

**Reproduction**

Verified with contracts/test/scratch/Checks.t.sol test_UnaskedAppealsFreeAndReopenWindow: 100 USDC task, Decided(Reject) by 20 NFTs; a stranger appeals at callFrom + 24 h - 1 s, nobody asks, poke at +6 h voids it (phase Decided, new callFrom); repeated three times, then Final/Rejected. The stranger's IMD and USDC balances after withdraw equal the balances before (bond + fee fully back, cost 0); 89 h passed from the NFT decision to the final state. withdrawContribution reverts TaskNotOpen the whole time; a second queued submission starts the same cycle after it.

### 3. Medium: One dispute slot: a doer's accomplice files a weak dispute first, honest reasons never reach the swarm, and the accomplice shares the doer's forfeited bond when an honest outsider pays to win

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

```
        if (d.disputer != address(0)) revert AlreadyDisputed();
```

Items 3, 5, 6. Rule R3 allows one disputer per submission, and a self-dispute from a second wallet is listed as by design; the effect here is worse than stated. 1) Doer D submits a fake; friend F disputes in the same block (or front-runs the honest dispute) with a weak reason and no proof link (bond 0.5 IMD + 1 USDC at a 100 USDC reward). 2) Every later dispute reverts AlreadyDisputed, and questionPart (lines 463-479) builds the swarm question from F's reason only; callSwarm and appeal carry no text, so the honest objection and counter-proof link can never reach the panel. 3) With no NFT votes (small task) the phase is NoDecision; if nobody pays within 24 h, _endUncalled accepts the fake and F's bond goes to D (_splitCurrency, doerWins): the pair's net cost is the 10 % fee. 4) If an honest outsider H pays bond + fee (0.5 IMD + 1 USDC + 0.5 IMD) to callSwarm against the doer and wins, H gets the 0.5 IMD stranger fee back and then shares the doer's forfeited bond pro rata with F (lines 766-775: appPart to H, 'rest' to disputerReward for F): at a 100 USDC reward 1.5 USDC each, so the colluders recover half their penalty. Loss if nobody pays: the 100 USDC pot (90 to the coalition) and, with a prediction market, the WON'T DO pool (see the predictor finding). Smallest fix: let the swarm caller attach one claim of their own (reason + proof link, hash-stored like the dispute, within maxClaims), or allow further disputers while the window is open and exclude the first disputer from the penalty share when a stranger had to pay the swarm. Merged from audit_math item 3, audit_economics, audit_flow and audit_permissions (info).

**Reproduction**

State: sub S in Phase.Review. F calls dispute(S, 'looks ok', '', noSwap) first; H's dispute(S, 'no DOER CODE', 'https://x.com/h/status/2', noSwap) in the same block reverts AlreadyDisputed (DisputeDesk.sol:102). previewSwarmBody(S, 2, rt) passes the RulesMismatch check only with rt.disputeReason == 'looks ok'. After NoDecision, H calls callSwarm(S, false, max, noSwap, rt); on a Reject answer _settleDecided: penaltyRew = 3 USDC (doer bond), winnerBonds = 1 USDC (F) + 1 USDC (H); appPart = 1.5 USDC to H, rest 1.5 USDC booked as disputerReward for F (claimDisputer pays it, lines 345-357). If nobody calls, poke after callFrom + 24 h: outcome Accepted, D credited 90 USDC plus F's 0.5 IMD + 1 USDC.

### 4. Medium: Author + doer vs predictors: a submission freezes the market and an undisputed fake sends the whole WON'T DO pool and the deferred 5 % to author and doer

`contracts/src/YoloPredict.sol:209`

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

Item 5. predict reverts SubmissionActive while task.activeSub != 0 (line 139), so one submission placed shortly before the deadline stops anyone from joining the empty WILL DO side or hedging. A submission nobody disputes within 24 h is accepted with no vote and no swarm question (CaseDesk.step Review, line 177); YoloPredict then resolves Completed = WILL DO: settle pays the deferred 2.5 % + 2.5 % to author and doer (lines 197-202) and, with nobody on WILL DO, the whole WON'T DO pool half to the author and half to the doer (lines 209-215). Author and doer may be one person with two wallets, or the author himself (R2). Cheapest plan at a 10,000 YOLO WON'T DO pool: a 5 USDC task that looks impossible; outsiders predict WON'T DO 10,000 YOLO (9,000 effective, 500 deferred, 500 burn); the author's second wallet submits any well-formed link (bond 0.5 IMD + 0.15 USDC, returned) at a quiet hour; 24 h later the task is Completed: author 4,750 YOLO, doer 4,750 YOLO, outsiders 0, for a 0.5 USDC fee plus gas. What an honest predictor can still do: dispute within 24 h (0.5 IMD + 1 % of base), then, if the NFT does not decide, callSwarm within the next 24 h paying bond + 0.5 IMD; if nobody does, the fake is accepted anyway. Design-level, not a code bug; smallest hardening: when a market exists on the task, keep predict open (or reopen the empty side) while a submission is under review, or do not pay the no-winner pool to the doer/author of a task that was never checked, or require a swarm question before accepting an undisputed submission on a task with a market larger than the reward. Merged from audit_permissions (medium), audit_flow (medium) and audit_math item 5 (low).

**Reproduction**

Verified with contracts/test/scratch/Checks.t.sol test_UndisputedFakeTakesWontDoPool: 5 USDC task; predictor predicts WON'T DO 10,000e18 at creation + 1 h; the doer wallet submits at + 2 h; predictor's predict(task, true, 1e18) reverts SubmissionActive; warp 24 h, poke: task Completed, submission Accepted; settle pays author 4,750e18 and doer 4,750e18 YOLO; the predictor's claim pays 0.

### 5. Medium: Dev wallet cancel is a free option on YoloPredict and voids a decided dispute; swarm fees already paid are not returned, against R7's 'exactly what he put in'

`contracts/src/TaskVault.sol:364`

```
    function cancelTask(uint256 taskId) external nonReentrant {
```

Item 7, a trust assumption (R7 allows cancelling an open task in any phase) reported with its value, plus a rule drift. cancelTask works while the task is Open after the timers are played: during Review, Voting, NoDecision, Decided, Appealed and Asked (only an answer already recorded is protected, because _advance runs first). (a) Free option: the dev wallet, or a friend of it, predicts on a 10,000 YOLO market; if its side heads to lose (deadline near with no submission, or a submission about to be accepted/rejected) the dev cancels: YoloPredict resolves Void and claim pays paidIn, 100 % back with no burn and no deferred part (YoloPredict.sol:248-249, 268), while the honest side loses the win it held. The option is worth the whole losing pool on every market the dev wallet is in. (b) Cancel timed against a won dispute: after an NFT Decided(Reject) the disputer about to receive the doer bond (0.5 IMD + 3 USDC at 100 USDC) gets only his own bond back (_settleNeutral); after an NFT Accept the doer who did the work loses the 90 USDC payout. (c) R7 drift: a swarm caller with a question pending loses the 0.5 IMD fee (_settleNeutral refunds bond + unspent funds only, DisputeDesk.sol:566-570; the doer's and disputer's bonds come back minus _sideSpent), so not everyone takes back exactly what he put in. Nothing in code prevents or delays this. Smallest fix if wanted: refuse cancelTask once a submission left Review (Decided/Asked) or once a market exists, add a short timelock so predictors see it coming, bar the DevPrices owner from predict, and document that paid swarm fees are the one exception to R7. Merged from audit_permissions (low), audit_math item 7, audit_economics and audit_flow (medium).

**Reproduction**

Verified with contracts/test/scratch/Checks.t.sol test_DevCancelVoidsWonDispute: 100 USDC task, dave disputes, 20 NFTs vote reject (Decided); devWallet (Prices.owner) calls cancelTask: status Unwound; dave's claimDisputer pays exactly 1 USDC + 0.5 IMD (bond only, no share of the doer bond). Market case by code: WILL DO 5,000 / WON'T DO 5,000 YOLO, no submission at deadline - 1 s; cancelTask -> resolution Void, each side claims paidIn in full, instead of WON'T DO taking 4,500 + 4,500 at the deadline. Fee case: after callSwarm (0.5 IMD paid to the Intake) a cancel credits the caller a.bond + a.funds with funds already 0.

### 6. Low: stepDeadline returns appealedAt (already past) in Phase.Appealed instead of the void moment appealedAt + swarmTimeout

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

```
        if (ph == Phase.Appealed) return s.appealedAt;
```

Item 9 (knowing before paying). stepDeadline is 'when the current step of an active submission times out'. For a filed, unasked appeal the only real timer is the void at appealedAt + p.swarmTimeout (step, line 206), but the view returns appealedAt, a moment already in the past one block after filing. A frontend reading only the chain shows the step as overdue for 6 h, and no view tells the appellant how long they have to call askAppealSwarm, or the other side when the Decided window reopens. Smallest fix: return uint256(s.appealedAt) + p.swarmTimeout. Reported by all four specialists; merged.

**Reproduction**

Verified with contracts/test/scratch/Checks.t.sol test_StepDeadlineAppealed: Decided(Reject), the doer appeals at t0; stepDeadline(subId) == t0 (expected t0 + 21600); poke at t0 + 5 h leaves phase Appealed, poke at t0 + 6 h moves it to Decided.

### 7. Low: The doer's own or delegated NFTs decide the dispute instantly, lock out other voters and earn the 5 % NFT fee plus half the disputer's bond

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

```
            if (diff >= instantDelta && wallets >= minWallets) ICaseDesk(caseDesk).tryInstantDecision(subId, v);
```

Items 3, 4, 6. vote does not exclude the doer, the disputer or their delegates (VotePower.mayVote accepts the owner or any delegate.xyz delegate), the instant rule needs only a 20-vote lead from 1 wallet (instantDelta 20, minWinnerWallets 1) and ownership is read live (no snapshot: NFTs can be rented). At a 100 USDC reward: doer D submits, friend F disputes with any reason (0.5 IMD + 1 USDC), D's NFT wallet V (20 ids) votes accept in the next transaction: Decided/Accept in the dispute's first seconds, every later vote reverts WrongPhase. If no appeal lands within 24 h the outcome is Accepted with nftRight = true: _payReward sends 5 USDC of the 10 USDC fee to the voter bucket instead of the treasury (line 715) and _settleDecided gives 50 % of F's bond to the voters and 50 % to D. The group keeps 95.5 USDC of 100 against 90 for an honest acceptance; the same holds against an honest disputer, who always loses half his bond to the doer's NFTs unless he pays to appeal (0.5 IMD + 1 USDC bond + 0.5 IMD fee). Rule R4 allows the instant 20-vote decision and docs/MECHANICS.md says a large holder can swing it, so this is a documented design limit, reported as the cheapest fee-halving path. Smallest fix: refuse votes from the doer and the current disputer, raise minWinnerWallets for the instant rule in the next parameter version, and/or pay the NFT fee share only when both sides got votes. Merged from audit_permissions (low) and audit_math item 6.

**Reproduction**

State: USDC task 100e6; D submits (sub 1); F dispute(1, 'weak', '', noSwap) at T; V holding ids 0..19 calls vote(1, 1, V, [0..19]) at T. Actual: getSubmission(1).phase == Decided, nftDecision == Accept (the 20-id instant path is the same one contracts/test/scratch/Checks.t.sol uses for its reject decisions); any other holder's vote reverts WrongPhase. After callFrom + 24 h and poke: getSettlement(1).voterFee == 5e6, voterBond.rew == 0.5e6, voterBond.imd == 0.25e18, treasuryPot(usdc) == 5e6 (not 10e6); claimVoterReward(1, V) pays 5.5 USDC + 0.25 IMD.

### 8. Low: Doer stake bonus depends on when the final step is poked and on the lock running now, not on the lock running from submission to payout

`contracts/src/YoloLockHub.sol:111`

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

Items 1 and 8, rule R2 ('a doer whose YOLO lock runs from submission to payout gets up to 4 %'). DisputeDesk._payReward (line 722) checks _lockRuns at the moment someone plays the final step, and stakePower requires l.end > block.timestamp then. Two drifts: (a) a lock that ends between the moment the step became due (window end, decision) and the first poke loses the bonus (up to 4 USDC at a 100 USDC reward plus 4 % of the add-on; the lost share goes half to burn, half to treasury), so the amount depends on who pokes when; the doer can protect himself by poking. (b) the opposite: stakePower checks only the epoch (no withdraw) and that the lock runs now, so a lock that lapsed during the check and was set again with lock(0, weeks) before the payout still pays the bonus although it did not run the whole time. Tokens never left the hub in (b), so the money effect is small either way. Smallest fix: check the lock against the time the step became due (windowEnd / decidedAt / answer time) and require no history point with end <= t between since and that time. Merged from audit_economics (low) and audit_flow (info).

**Reproduction**

(a) Doer locks YOLO with end E = submit + 25 h and submits; Review ends at submit + 24 h undisputed; the first poke is at submit + 26 h: outcome Accepted, Settlement.stakeBps == 0 (stakePower returns 0 because l.end <= block.timestamp). A poke at submit + 24 h instead pays the bonus. (b) Doer locks 1 week, submits on day 6, the lock ends on day 7; on day 8 before the payout the doer calls lock(0, 1) (allowed: add == 0 with a term, same epoch); payout on day 9: stakePower returns p.power (p.epoch == l.epoch, l.end > now, p.end > since), the bonus is paid.

### 9. Info: A payTo address accumulates votes of both sides while hasVoted/sideOf report it as not having voted

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

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

Item 4 (double vote): no issue found with counting. Each NFT id is marked in the per-dispute bitmap before the ownership check (lines 148-154), so owner + delegate, a transfer after the vote, a changed delegation or several transactions cannot count one id twice; a wallet's side is fixed at its first vote; wallets are counted per voter, not per payTo. View drift only: votes for another payTo are added to recs[payTo] while the side is stored for the voter, so P can be the payTo of an accept vote by A and of a reject vote by B; votesOf(P) shows both counts, hasVoted(P)/sideOf(P) stay false/0, and P is paid for whichever side wins. No gain beyond what two wallets voting both sides would get (pro rata to winning votes). Frontends should treat votesOf as 'paid to', not 'voted by'. Merged from audit_economics and audit_math item 4.

**Reproduction**

A votes accept with 5 ids, payTo = P; B votes reject with 5 ids, payTo = P: votesOf(S, P) == (5, 5), sideOf(S, P) == 0, hasVoted(S, P) == false. Owner O votes id 5 then delegate Dl votes id 5 for the other side: reverts TokenVoted(5); O transfers id 5 to X, X votes: TokenVoted(5); O votes the other side with id 8: AlreadyVoted.

### 10. Info: Parameter drift: voteParams nft and delegateRegistry are ignored; votes always use the immutable VotePower

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

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

Item 1 (params owner). ParamsRegistry stores nft and delegateRegistry per version and validate requires nft != 0, but vote reads only instantDelta and minWinnerWallets from voteParams and checks ownership through the VotePower contract fixed at deployment (its own nft and delegateRegistry immutables); HiddenList likewise. A new parameter version naming another collection or registry (48 h timelock) changes nothing for votes, which docs/MECHANICS.md states but the Params field comments ('identity.md ERC-721', 'delegate.xyz v2; address(0) disables delegated votes') do not. No money effect; text drift. Role walk otherwise clean: no call was found allowed outside its rule and no rule without a path (doer cannot dispute or appeal his own acceptance, one disputer, queued doers claim only on Completed/Unwound, predict only Open and before the deadline with no active submission, dev wallet only cancelTask while Open, params owner only pause and timelocked versions). From audit_permissions; audit_math item 1 agrees.

**Reproduction**

The registry owner proposes Params p with p.nft = otherCollection and p.delegateRegistry = address(0), activates after 48 h; a task created under the new version is disputed; a holder of an otherCollection token calls vote(subId, 1, self, [id]): reverts NotTokenHolder; an identity.md holder still votes. paramsOf(v).nft reports otherCollection.

---

Judge's submission `3357c6c8cc1e1bae40e0801ee91f823a084e27dc8a1562cdd29188e5f24117b5`, 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.
