{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"fb6f02ff-22e6-404d-bfb9-a4db823cda0d","kind":"audit","nodes":[{"acceptedSubmissionHash":"68ec8ed8a0efffa47dd436154f0116ab5f607420f72f0e0fbc803cc404966413","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"379328e842a2d6162e99e1c8f5c760ceab9bf86dbedc38d9f95e84d212422c0c","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"04d58be6a3be12bc1ff5ae71b3ea1db6a678b1ba117bfd3719ae39d56e4df9ac","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"dc807c046c284bfbd5b60d3a3d0d4c1e666ad8b8861a050a1a01decd03eefe7c","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"b25c8d159d112fe174fcafa75f1c439a10b3f185446db05eb3493e78036e35dd","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit the smart contract code of moneycanbuy.fun for security bugs. Repository at commit d9c24e3b2d199548faf236d7bb5e80e4a12b6ada. Run forge at the repo root, import as \"src/...\". Modify no existing file; new files only under contracts/test/audit/.\n\nSOURCE 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.\n\nSCOPE. Code security only. Payout amounts and role scenarios are separate orders: one info line here.\n\nREPORT 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\".\n\n1. USER APPROVALS, the project's first rule. Users approve our contracts; no path may take a user's tokens without their own call. For each pull (TaskVault.sol:555, 696, 769, 787, desks only via onlyDesk; 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 DisputeDesk._fundCall (sender = a.appellant, :287) pull from another wallet with an allowance, spoof the sender via the vault forwarding, or spend an allowance or Permit2 signature via admin, upgrade, delegatecall or arbitrary call? Any such path = critical.\n2. BALANCE INVARIANT. TaskVault books every amount in one bucket (task pot, bonds, prepayments, dispute pot, settlement, treasury pot, credit); 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.\n3. STUCK MONEY. Every pull payment ends paid: claimDisputer, claimVoterReward(s), claimVoidSubmission, withdrawContribution, withdraw/withdrawTo, sweepTreasury, YoloPredict.claim, YoloSweetener, BuybackBurner forward/burnYolo. 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?\n4. REENTRANCY AND ROUTING. One lock covers vault and desks; the vault forwards whitelisted selectors with sender and value appended (fallback, _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?\n5. ACCESS RIGHTS. Prove what each privileged call can and cannot do. 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 and TrophyCatalog (owner = the ParamsRegistry owner) hold no money. Worst case in money for each stolen key. wire and initializers callable twice or by anyone?\n6. 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 exactly 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?\n7. 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 (list repeat, transfer after vote, owner plus delegate), a lying ownerOf, huge ids, payTo on the other side or without a side, the minimum-wallets count, the instant decision racing a late vote, a list too long for a block.\n8. 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 to fail an ask (ASK_GAS_RESERVE), price change before the ask, an appeal freezing the task.\n9. 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 or on a stale status, points overflow or zero, last-claimant dust, double settle, an empty side.\n10. 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: same-block lock, withdraw and relock keeping the bonus, extend-plus-add gaming, lost or double-withdrawn balance, costly checkpoint reads.\n11. 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.\n12. DOS AND GAS. Unbounded loops, griefing with tiny top-ups, a reverting receiver blocking settlement, block gas limit on must-succeed paths, vote and claim at 2,000 NFTs; gas waste over 20% on a hot path.\n13. 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, wrong recipients, time math at openAt.\n14. COMMENTS VS CODE. Each promise the code does not keep, one line, info.\n15. COVERAGE. Files read in full, what was not reached, forge build and test result.\n\nNot 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.\n","parentJobId":null,"planHash":"9580d72feb9e3c8463c4c8092a2f461af07ba6a837236d5f007b8f9ea98f83d0","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"fb6f02ff-22e6-404d-bfb9-a4db823cda0d","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51135","feedbackHash":"93d72d0d760a071cd8b5fcfe9bea45d8ce3a27c100651f16b4fd6584955d1715","nodeKey":"audit_economics","submissionHash":"68ec8ed8a0efffa47dd436154f0116ab5f607420f72f0e0fbc803cc404966413","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50907","feedbackHash":"f28d0262e675326f6ddcfcd49554aadffb7d2f3411f09e50ad1901ce8b98cab1","nodeKey":"audit_flow","submissionHash":"379328e842a2d6162e99e1c8f5c760ceab9bf86dbedc38d9f95e84d212422c0c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52600","feedbackHash":"53d3dc12edb658f205d242ca06771b480e57bf59352e434271f1d5f6afe2f515","nodeKey":"audit_judge","submissionHash":"04d58be6a3be12bc1ff5ae71b3ea1db6a678b1ba117bfd3719ae39d56e4df9ac","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52222","feedbackHash":"0dcae304f34815558e4a5265d40982feb361a05884dc9fa9a0b61625ef74b0d3","nodeKey":"audit_math","submissionHash":"dc807c046c284bfbd5b60d3a3d0d4c1e666ad8b8861a050a1a01decd03eefe7c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52396","feedbackHash":"71affad700a324a25af3eefe59efb107e46c5d0ecffeaceb7b3f164caad8d162","nodeKey":"audit_permissions","submissionHash":"b25c8d159d112fe174fcafa75f1c439a10b3f185446db05eb3493e78036e35dd","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"a5683dabc3794539204cc09182ba3f1b17321e95a4b82b9a930a23fdb09261d6","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fa4b91aed45c94fa","findings":[{"citation":"resolved","description":"ITaskVault.appeal/callSwarm (ITaskVault.sol:97,107) and docs/MECHANICS.md:148 promise the caller pays 'at most maxImdIn IMD'. DisputeDesk._fundCall compares only bond.imd + pay (fixed 0.5 IMD bond + wallet-paid fee) with maxImdIn. When the task's reward currency is IMD, the dispute bond's 'rew' part (disputeBondBps = 1% of the author's own reward, also IMD) is pulled in the same collectImd call (_collectBond, DisputeDesk.sol:75: b.imd + extraImd + (rewIsImd ? b.rew : 0)) but is never counted against the cap. A stranger appellant or NoDecision caller who sets maxImdIn to the quoted fixed bond + fee has 1% of the author's reward pulled on top without a revert, and forfeits it with the bond if their side loses. Example at an IMD task with the author's reward 1000 IMD and fee 0.5 IMD: maxImdIn = 1 IMD, 11 IMD leave the wallet (10 IMD above the stated ceiling); at a 100,000 IMD reward it is 1,000 IMD. ETH/USDC/YOLO rew parts are not IMD and are outside the IMD cap by design (submit has its own maxBondRew for them). Reproduced with contracts/test/scratch/MaxImdInCap.t.sol: the appeal with maxImdIn = 1e18 does not revert. Smallest fix: in _fundCall compute the IMD cost as bond.imd + pay + (s.currency == address(imd) ? bond.rew : 0) and revert CostAboveMax when it exceeds maxImdIn. Merged: audit_permissions #169aee6e (reproduced).","line":303,"path":"contracts/src/desks/DisputeDesk.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {ERC721} from \"@openzeppelin/contracts/token/ERC721/ERC721.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {TaskVault} from \"src/TaskVault.sol\";\nimport {ITaskVault} from \"src/interfaces/ITaskVault.sol\";\nimport {ParamsRegistry} from \"src/ParamsRegistry.sol\";\nimport {CaseDesk} from \"src/desks/CaseDesk.sol\";\nimport {DisputeDesk} from \"src/desks/DisputeDesk.sol\";\nimport {TextRules} from \"src/helpers/TextRules.sol\";\nimport {SwarmQuestionBuilder} from \"src/helpers/SwarmQuestion.sol\";\nimport {AttestationChecker} from \"src/helpers/AttestationChecker.sol\";\nimport {VotePower} from \"src/VotePower.sol\";\nimport {YoloLockHub} from \"src/YoloLockHub.sol\";\nimport {BuybackBurner} from \"src/BuybackBurner.sol\";\nimport {DevPrices} from \"src/DevPrices.sol\";\nimport {IIntake} from \"src/interfaces/IIntake.sol\";\nimport {VoteLib} from \"src/libraries/VoteLib.sol\";\nimport \"src/VaultTypes.sol\";\n\ncontract Tok is ERC20 {\n    uint8 internal immutable _dec;\n\n    constructor(string memory n, uint8 d) ERC20(n, n) {\n        _dec = d;\n    }\n\n    function decimals() public view override returns (uint8) {\n        return _dec;\n    }\n\n    function mint(address to, uint256 a) external {\n        _mint(to, a);\n    }\n}\n\ncontract Nft is ERC721(\"id\", \"ID\") {\n    function mint(address to, uint256 id) external {\n        _mint(to, id);\n    }\n}\n\n/// @dev Minimal Intake: sells every action for `price` IMD, pulls it from the caller, never answers.\ncontract Intake is IIntake {\n    address public immutable asset;\n    uint256 public price;\n    uint256 public nonce;\n    mapping(bytes32 => bool) public known;\n\n    constructor(address asset_, uint256 price_) {\n        asset = asset_;\n        price = price_;\n    }\n\n    function priceOf(bytes32, address token) external view returns (uint256) {\n        return token == asset ? price : 0;\n    }\n\n    function requests(bytes32 id) external view returns (address, address, bytes4, bool) {\n        return (known[id] ? address(1) : address(0), address(0), bytes4(0), false);\n    }\n\n    function request(bytes32, bytes calldata body, Callback calldata, address token, uint256 amount)\n        external\n        returns (bytes32 requestId)\n    {\n        require(token == asset && amount >= price && body.length != 0, \"bad\");\n        IERC20(token).transferFrom(msg.sender, address(this), amount);\n        requestId = keccak256(abi.encode(block.chainid, address(this), ++nonce));\n        known[requestId] = true;\n    }\n}\n\n/// @dev Deploys desks and vault inside one constructor (one transaction frame): Desk.wire's transient flag must still\n///      be raised when the vault's constructor calls it.\ncontract Core {\n    YoloLockHub public hub;\n    CaseDesk public caseDesk;\n    DisputeDesk public disputeDesk;\n    address public vault;\n\n    constructor(address registry, address imd, address usdc, address yolo, address prices, address nft) {\n        TextRules text = new TextRules();\n        hub = new YoloLockHub(yolo);\n        BuybackBurner burner = new BuybackBurner(yolo, imd, usdc, prices, address(0xb0b));\n        caseDesk = new CaseDesk(registry, imd, usdc, address(text), address(new SwarmQuestionBuilder()), yolo, address(hub));\n        VotePower vp = new VotePower(nft, address(0));\n        disputeDesk = new DisputeDesk(registry, imd, usdc, address(text), address(vp), yolo, address(hub));\n        vault = address(\n            new TaskVault(\n                registry,\n                imd,\n                usdc,\n                yolo,\n                address(new AttestationChecker(registry)),\n                address(text),\n                address(caseDesk),\n                address(disputeDesk),\n                address(burner)\n            )\n        );\n    }\n}\n\n/// @dev Finding: DisputeDesk._fundCall caps only `bond.imd + pay` by `maxImdIn`, but on an IMD-reward task the bond's\n///      \"rew\" part (1% of the author's own reward, also IMD) is pulled in the same collectImd call. The documented\n///      \"at most maxImdIn IMD\" (ITaskVault.appeal / callSwarm, MECHANICS \"capped by their maxImdIn\") is exceeded.\ncontract MaxImdInCapTest is Test {\n    Tok imd;\n    Tok usdc;\n    Tok yolo;\n    Nft nft;\n    Intake intake;\n    ParamsRegistry registry;\n    ITaskVault vault;\n    CaseDesk caseDesk;\n    DisputeDesk disputeDesk;\n    YoloLockHub hub;\n\n    address multisig = makeAddr(\"multisig\");\n    address treasury = makeAddr(\"treasury\");\n    address devWallet = makeAddr(\"devWallet\");\n    address author = makeAddr(\"author\");\n    address doer = makeAddr(\"doer\");\n    address dave = makeAddr(\"dave\");\n    address erin = makeAddr(\"erin\");\n    address voter = makeAddr(\"voter\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        imd = new Tok(\"IMD\", 18);\n        usdc = new Tok(\"USDC\", 6);\n        yolo = new Tok(\"YOLO\", 18);\n        nft = new Nft();\n        intake = new Intake(address(imd), 0.5e18);\n        DevPrices prices = new DevPrices(devWallet, address(usdc), address(imd), address(yolo), 0.0001e18, 2500e18, 8e18);\n        registry = new ParamsRegistry(\n            multisig,\n            address(intake),\n            address(prices),\n            makeAddr(\"swapAdapter\"),\n            address(nft),\n            makeAddr(\"delegates\"),\n            treasury,\n            makeAddr(\"attester\")\n        );\n        Core core = new Core(address(registry), address(imd), address(usdc), address(yolo), address(prices), address(nft));\n        hub = core.hub();\n        caseDesk = core.caseDesk();\n        disputeDesk = core.disputeDesk();\n        vault = ITaskVault(core.vault());\n        address[4] memory people = [author, doer, dave, erin];\n        for (uint256 i; i < 4; ++i) {\n            imd.mint(people[i], 10_000e18);\n            vm.prank(people[i]);\n            imd.approve(address(vault), type(uint256).max);\n        }\n        for (uint256 id; id < 20; ++id) {\n            nft.mint(voter, id);\n        }\n    }\n\n    function _noSwap() internal pure returns (ImdPay memory p) {}\n\n    function test_AppealPullsMoreImdThanMaxImdIn() public {\n        // IMD task: the author's own reward is 1000 IMD\n        vm.prank(author);\n        uint256 taskId = vault.createTask(\n            address(imd),\n            1_000e18,\n            uint64(block.timestamp + 365 days),\n            \"Title\",\n            TaskRules(\"ipfs://task-condition\", \"PROOF: one https link, DOER CODE on the page\", \"NO if: unclear\"),\n            0,\n            PROOF_LINK,\n            \"\",\n            _noSwap()\n        );\n        vm.prank(doer);\n        uint256 subId =\n            vault.submit(taskId, \"https://x.com/doer/status/1\", Evidence(\"\", \"\"), 0, type(uint256).max, _noSwap());\n        vm.prank(dave);\n        vault.dispute(subId, \"ipfs://reason\", \"ipfs://reason-proof\", _noSwap());\n        // 20 NFTs reject: instantDelta reached, the NFT decides (Phase.Decided)\n        uint256[] memory ids = new uint256[](20);\n        for (uint256 i; i < 20; ++i) {\n            ids[i] = i;\n        }\n        vm.prank(voter);\n        vault.vote(subId, VoteLib.SIDE_REJECT, voter, ids);\n        assertEq(uint8(vault.getSubmission(subId).phase), uint8(Phase.Decided), \"not decided\");\n\n        // the quoted cost: fixed bond 0.5 IMD + 1% of 1000 IMD = 10 IMD (also IMD here) + fee 0.5 IMD = 11 IMD\n        Bond memory b = vault.quoteDisputeBond(subId);\n        assertEq(b.imd, 0.5e18);\n        assertEq(b.rew, 10e18);\n        assertEq(vault.quoteSwarmFee(subId), 0.5e18);\n\n        // a stranger caps the call at 1 IMD: \"at most maxImdIn IMD\" (ITaskVault.appeal) must refuse it\n        uint256 before = imd.balanceOf(erin);\n        vm.prank(erin);\n        vm.expectRevert(CostAboveMax.selector);\n        vault.appeal(subId, 1e18, _noSwap());\n        assertEq(imd.balanceOf(erin), before, \"IMD left the stranger's wallet above maxImdIn\");\n    }\n}","reproduction":"State: IMD task, author's own reward 1000 IMD; a submission; one dispute by dave; 20 NFT votes reject (instantDelta 20, minWinnerWallets 1) -> Phase.Decided. quoteDisputeBond(subId) = {imd: 0.5e18, rew: 10e18}, quoteSwarmFee = 0.5e18. Input: stranger erin calls vault.appeal(subId, maxImdIn = 1e18, ImdPay(0,...)). Expected: revert CostAboveMax (IMD cost 0.5 + 10 + 0.5 = 11 IMD > 1 IMD). Actual: the call succeeds and transferFrom pulls 11e18 IMD from erin. `forge test --match-path contracts/test/scratch/MaxImdInCap.t.sol` -> FAIL 'next call did not revert as expected'; passes once the cap includes the IMD rew part.","severity":"high","snippet":"        if (bond.imd + pay > maxImdIn) revert CostAboveMax();","title":"appeal/callSwarm: maxImdIn does not cap the IMD 'rew' bond part on IMD-reward tasks (documented 'at most maxImdIn IMD' broken)"},{"citation":"resolved","description":"docs/MECHANICS.md:104 and :144 fix the boundary: an appeal not asked within swarmTimeout (6 h) is void, bond and fee refunded. CaseDesk.step enforces it only when a timer is played (Phase.Appealed branch, CaseDesk.sol:206-212). DisputeDesk.askAppealSwarm (DisputeDesk.sol:232-236) is the one user action that does not call advanced()/advance() first, and CaseDesk.askAppeal checks only phase == Appealed. After the deadline two different outcomes are reachable from the same state depending on which transaction lands first: poke(taskId) voids the appeal (bond + fee credited back, asks += 1, new 24 h appeal window) while askAppealSwarm spends the appellant's prepaid fee and asks the swarm, whose answer then decides the case. The protocol's own principle ('the outcome is the same whenever anyone pokes', CaseDesk._swarmStatus) is broken for this step, and anyone can keep a Decided submission unfinalised past the documented 6 h as long as nobody pokes, then still trigger the swarm. Money per occurrence: the 0.5 IMD fee refunded vs spent, and possibly a different final outcome (swarm answer vs NFT decision), i.e. the whole loser-bond split. Reproduced with contracts/test/scratch/LateAppealAsk.t.sol. Smallest fix: play the task's timers first, e.g. ICaseDesk(caseDesk).advanced(subId) in DisputeDesk.askAppealSwarm before askAppeal (or _bank().advance(_subs[subId].taskId) at the top of CaseDesk.askAppeal), so a late ask reverts WrongPhase after step() voided the appeal. Merged: audit_math #84e6065a (medium) and audit_flow #354c6097 (info); both reproduced, severity kept at medium because a documented rule is broken and the final outcome of a submission can change.","line":274,"path":"contracts/src/desks/CaseDesk.sol","reproduction":"Fixture: IMD task 1000 IMD, doer submits, dave disputes, 20 NFTs vote reject -> Decided (Reject); erin (stranger) calls vault.appeal(subId, max, noSwap) -> Phase.Appealed, fee 0.5 IMD prepaid. Warp 6 h + 1 s with no poke. Input: anyone calls vault.askAppealSwarm(subId, correct RuleTexts). Expected (docs): the appeal is void, the ask reverts WrongPhase, the Intake gets nothing, poke credits erin 0.5 + 10 + 0.5 IMD. Actual: phase becomes Asked, the Intake receives 0.5 IMD, the swarm answer will decide the submission. `forge test --match-path contracts/test/scratch/LateAppealAsk.t.sol` -> FAIL 'next call did not revert as expected' on this code.","severity":"medium","snippet":"        if (_subs[subId].phase != Phase.Appealed) revert WrongPhase();","title":"askAppealSwarm plays no timers: an appeal past its 6 h ask deadline is still asked (documented rule broken, outcome depends on tx ordering)"},{"citation":"resolved","description":"The payout check requires the current lock to run and the submission-time point to belong to the same epoch; the epoch only grows in withdraw (YoloLockHub.sol:94). A lock whose term ended but was not withdrawn can be set again with lock(0, 1) (lines 62-87: old = 0 because the lock ended, the comment says 'a lock set again starts afresh'), which creates a new 1-week lock under the SAME epoch. For a submission made under the old 52-week term, stakePower then returns the old full power although the only lock running at the payout is a 1-week lock with 1/52 of it; without the re-set the same doer gets 0. Example: 1,000 YOLO locked 52 weeks (power 1,000), submission for a task whose author reward is worth 1,000 YOLO -> stakeBps 400; the lock ends during the dispute/appeal/swarm; the doer calls lock(0,1) one block before the payout and still receives 4% of R from the stakers' share (and 4% of the YOLO add-on, YoloSweetener.payout reads the same Settlement.stakeBps). Bounded: at most 4% of R, taken from the treasury/burn part of the fee, not from users, and the YOLO stays locked a week; hence low. Reproduced with contracts/test/scratch/RelockKeepsPower.t.sol. Smallest fix: in lock, when the previous term ended (l.end <= block.timestamp) start a new epoch (epoch + 1 in the stored Lock and the checkpoint), matching the comment; or in stakePower also require p.end > block.timestamp. Merged: audit_math #8a3928e1 (reproduced).","line":111,"path":"contracts/src/YoloLockHub.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {YoloLockHub} from \"src/YoloLockHub.sol\";\n\ncontract YoloTok is ERC20(\"YOLO\", \"YOLO\") {\n    function mint(address to, uint256 a) external {\n        _mint(to, a);\n    }\n}\n\n/// @dev Finding: a lock whose term ended but was not withdrawn can be set again with `lock(0, 1)` under the SAME\n///      epoch (the epoch only grows in `withdraw`). For a submission made under the old 52-week term,\n///      `stakePower(who, since)` then returns the old full power although the only lock running at the payout is a\n///      1-week lock with 1/52 of it. Without the re-set the same doer gets 0.\ncontract RelockKeepsPowerTest is Test {\n    YoloTok yolo;\n    YoloLockHub hub;\n    address doer = makeAddr(\"doer\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        yolo = new YoloTok();\n        hub = new YoloLockHub(address(yolo));\n        yolo.mint(doer, 1_000e18);\n        vm.prank(doer);\n        yolo.approve(address(hub), type(uint256).max);\n    }\n\n    function test_EndedLockRelockedOneWeek_KeepsOldStakePower() public {\n        vm.prank(doer);\n        hub.lock(1_000e18, 52);\n        uint64 since = uint64(vm.getBlockTimestamp() + 1); // the submission, one second later\n        vm.warp(since);\n        assertEq(hub.powerAt(doer, since), 1_000e18, \"submission-time power\");\n\n        // the 52-week term ends before the payout; the lock is not withdrawn\n        vm.warp(since + 52 weeks);\n        assertEq(hub.stakePower(doer, since), 0, \"ended lock pays nothing\");\n\n        // the doer sets a new 1-week lock on the same amount (no withdrawal: same epoch)\n        vm.prank(doer);\n        hub.lock(0, 1);\n        assertEq(hub.power(doer), uint256(1_000e18) / 52, \"the running lock is a 1-week lock\");\n\n        // expected: the lock behind the submission ended, the payout check returns 0 (or at most the running power);\n        // actual: the full 52-week power is paid out of a 1-week lock\n        assertLe(hub.stakePower(doer, since), uint256(1_000e18) / 52, \"stakePower pays the ended 52-week lock's power\");\n    }\n}","reproduction":"Deploy YoloLockHub(yolo). doer: lock(1_000e18, 52) at T; since = T+1: powerAt(doer, since) == 1_000e18. warp to since + 52 weeks: stakePower(doer, since) == 0 (ended). doer: lock(0, 1). Now power(doer) == 1_000e18/52 but stakePower(doer, since) == 1_000e18. Expected: 0 (or at most the running 1-week power). `forge test --match-path contracts/test/scratch/RelockKeepsPower.t.sol` -> FAIL '1000000000000000000000 > 19230769230769230769'.","severity":"low","snippet":"        if (p.epoch != l.epoch || l.end <= block.timestamp || p.end <= since) return 0;","title":"YoloLockHub.stakePower pays the submission-time power of a lock that already ended and was merely re-set with a 1-week term (same epoch)"},{"citation":"resolved","description":"`vault` is caller-supplied, so the burner (which holds ETH, USDC, IMD and YOLO) issues an attacker-chosen external call with msg.sender = burner. No contract in the repository exposes a withdraw(address) that would move the burner's funds and the burner grants no allowances, so no loss was found with the in-scope code; it is an unnecessary arbitrary-call surface (a future token or contract that acts on withdraw(address) from the burner would be reachable). Hardening: pin the vault (constructor argument or ProtocolDirectory lookup), or drop pull. Merged: audit_permissions #2737fd78 and audit_flow #8b2e73ac (reproduced by code reading: lines 120-123 take `vault` from calldata with no check).","line":122,"path":"contracts/src/BuybackBurner.sol","reproduction":"Deploy any contract A with `function withdraw(address) external { emit Called(msg.sender); }`. Any EOA calls burner.pull(address(A), address(0)). Expected: the burner only talks to the project's vault. Actual: A receives the call with msg.sender == burner. No funds move with the in-scope contracts.","severity":"low","snippet":"        IVaultWithdraw(vault).withdraw(token);","title":"BuybackBurner.pull lets anyone make the burner call withdraw(address) on an arbitrary target"},{"citation":"resolved","description":"Submission.addon is a snapshot of YoloSweetener.bonusOf(taskId) at submit (CaseDesk.submit:112, s.addon = t.addon). YoloSweetener.sweeten (YoloSweetener.sol:92-104) accepts deposits while a submission is under review (it checks only Open and the deadline) and payout pays the whole bonusOf at Completed with an NFT voter pool. If the add-on was 0 at submission and arrived during the dispute, bonusTask stays 0 and claimVoterRewards never calls YoloSweetener.claimVoter, contrary to its NatSpec (DisputeDesk.sol:377: 'An accepted submission with a $YOLO add-on also claims payTo's share'). Nothing is lost: payTo or anyone can call YoloSweetener.claimVoter(taskId, payTo) directly, so low (UX/consistency). Fix: set bonusTask = s.taskId whenever outcome is Accepted and a sweetener is bound (the call is already inside a try). Merged: audit_economics #0674bda5 and audit_flow #3aa681ec (reproduced by code reading).","line":441,"path":"contracts/src/desks/DisputeDesk.sol","reproduction":"Task 1 (1,000 USDC, no add-on). Doer submits (s.addon = 0). Dave disputes; during the vote Carol calls YoloSweetener.sweeten(1, 10_000e18). NFT holders vote accept -> Accepted; sweetener.payout(1): voterPool = 500e18. vault.claimVoterRewards([subId], V): V receives the IMD/USDC voter shares, voterClaimed(1, V) stays false and V receives 0 YOLO; a direct sweetener.claimVoter(1, V) then pays it.","severity":"low","snippet":"        if (s.addon != 0 && s.outcome == Outcome.Accepted) bonusTask = s.taskId;","title":"claimVoterRewards skips the YoloSweetener voter share when the add-on arrived after the submission"},{"citation":"resolved","description":"_check compares only with the current stored price and no delay separates steps, so the dev wallet can chain setYoloPriceUsd calls in one transaction and move a price by any factor; the documented 'one change moves it at most 10x' (MECHANICS.md:252,303) is a typo guard, not a bound. The only money effect is CaseDesk._stakeBps (DevPrices.yoloValue): with yoloPriceUsd raised 1000x any lock power >= base/1000 in YOLO gets the full 4% stake bonus. The bonus comes out of the stakers' 5% (treasury + burn), not out of user deposits, so a stolen dev key redirects at most 4% of each accepted reward from treasury/burn to a colluding doer; a stale price moving only the stake bonus is listed as by design, so low/hardening. Fix: a minimum interval between price changes per token, or bound the change relative to a price at least N hours old. Merged: audit_economics #ddd60b59 (reproduced by code reading).","line":89,"path":"contracts/src/DevPrices.sol","reproduction":"DevPrices with yoloPriceUsd = 0.01e18. Owner calls setYoloPriceUsd(0.1e18), setYoloPriceUsd(1e18), setYoloPriceUsd(10e18) in one transaction: all three pass _check, the price is 1000x. A doer with 1 YOLO locked 52 weeks (power 1e18) submitting to a 1,000 USDC task now gets bps = 4 (yoloValue = 100e18 instead of 100,000e18); at a 100,000 USDC base it reaches the full 4%. Expected per a strict reading of 'at most 10x per step': a cooldown; actual: unlimited move in one transaction.","severity":"low","snippet":"        if (next > current * MAX_STEP || next * MAX_STEP < current) revert PriceSanity();","title":"DevPrices '10x per step' bound has no cooldown: any price is reachable in one transaction"},{"citation":"resolved","description":"onOracleResult accepts any attestation signed for the vault's EIP-712 domain with a bool answer and enough panel, provided msg.sender is the Intake stored for requestId (lines 463-476 check r.subId, via.intake and r.answered). SwarmRequest.questionHash is stored by ask() but never compared with a.questionHash, and a.requestId (the oracle UUID) is not recorded as consumed. The oracle-consumer rules recommend comparing a.questionHash first, since anyone can pay 0.5 IMD for their own question naming the vault as consumer and obtain a signature in the vault's domain. Because the intake requestId must be one the vault created and the caller must be that Intake, a mis-pairing needs the trusted Intake writer to deliver another request's attestation under the vault's id; MECHANICS.md:398 records that keccak256(question) may not equal the plane's hash, so the fix needs the plane-compatible hash (OracleQuestion.hash with an absolute window). Hardening: store that hash at ask and require a.questionHash == r.questionHash; keep a mapping of consumed a.requestId. Merged: audit_economics #ecf89f0e (code reading confirms no questionHash comparison).","line":477,"path":"contracts/src/TaskVault.sol","reproduction":"Mock Intake records request rid1 for subId 7 (questionHash Q7). An attestation A with questionHash Qx != Q7, answer true, panel ok, signed by an attester for the vault's domain, delivered by the Intake as onOracleResult(rid1, A, sig): TaskVault records answered = true for subId 7 (expected under a binding check: AttestationIgnored). Requires the Intake (trusted) to deliver it under rid1.","severity":"info","snippet":"        uint8 code = attestationChecker.check(address(this), via.minPanelSize, via.minQuorum, a, signature);","title":"Swarm callback does not bind the attestation to the asked question (questionHash) nor consume a.requestId"},{"citation":"resolved","description":"_fundCall (line 292) treats the disputer as 'a side' only when !sidesDoer; a disputer appealing a Reject decision for the doer's side (sidesDoer = true) posts the full second bond and pays the whole fee from the wallet like a stranger. At settlement the stranger fee carve-out (lines 636-640) excludes any appellant equal to d.disputer, so this caller gets no 0.5 IMD fee back while an unrelated wallet in the same position does. Asymmetry only, bounded by bondFixedImd. Fix: define 'stranger' in _settleDecided by the test _fundCall used, e.g. a.fromBond == 0 && a.bond.imd != 0. From audit_permissions #d68add12 (reproduced by code reading).","line":626,"path":"contracts/src/desks/DisputeDesk.sol","reproduction":"Dispute by D, NFT decides Reject, D appeals with sidesDoer = true (only the doer is restricted), posts 0.5 IMD + 1% and the fee, swarm answers Accept: D is credited a.funds + a.bond + appPart but not strangerFee; an unrelated appellant in the same flow is also credited min(bondFixedImd, penaltyImd).","severity":"info","snippet":"        bool appellantIsStranger = hasCaller && a.appellant != f.doer && a.appellant != d.disputer;","title":"A disputer who appeals for the doer's side pays a full stranger bond but gets no stranger fee refund"},{"citation":"resolved","description":"`side` is fixed per voter wallet (lines 145-146), but a payTo named by another voter only gets its accept/reject counter incremented (lines 162-166) and its own side stays 0. The payTo wallet may later vote itself for the other side and is paid whichever side wins. Each NFT still counts once on one side and voterVotesLeft equals the sum of recs on the final side, so no money is created; a hedging possibility the 'one side per wallet' wording does not cover. From audit_permissions #d2d8b09e (reproduced by code reading).","line":164,"path":"contracts/src/desks/DisputeDesk.sol","reproduction":"A votes 5 ids accept with payTo = B; B votes 3 ids reject with payTo = B. votesOf(subId, B) = (5, 3); B claims on whichever side is final. No double payment.","severity":"info","snippet":"            if (side == VoteLib.SIDE_ACCEPT) pr.accept += uint120(power);","title":"DisputeDesk.vote: a payTo record accumulates votes without a side, so one payTo can hold accept and reject counts"},{"citation":"resolved","description":"VaultParams.sol:16-17 says a version's nft / delegateRegistry govern voting and address(0) disables delegated votes. DisputeDesk.vote discards those two return values of voteParams and checks holders only through the immutable VotePower(nft_, delegateRegistry_) fixed at launch (VotePower.sol:21-24, DisputeDesk.sol:155). A proposed version with delegateRegistry = address(0) changes nothing. MECHANICS 'Trust assumptions' states the VotePower rule correctly; the struct comments and bounds are the drift. No money impact. From audit_flow #108192c3 (reproduced by code reading).","line":185,"path":"contracts/src/desks/DisputeDesk.sol","reproduction":"Owner proposes and after 48 h activates params identical to version 1 except delegateRegistry = address(0); a task under version 2; a delegate of an NFT owner votes with the owner's ids. Expected per the struct comment: NotTokenHolder. Actual: the vote counts.","severity":"info","snippet":"            (,, uint32 instantDelta, uint32 minWallets) = ParamsRegistry(registry).voteParams(version);","title":"Params.nft and Params.delegateRegistry are not read by the vote path; VotePower's immutables decide"},{"citation":"resolved","description":"TaskVault._burn (TaskVault.sol:665-669) pays $YOLO to `burner` where it waits for anyone's burnYolo; ETH/USDC/IMD try the buyback wallet first and only fall back to the burner. The event doc states the opposite for both branches. MECHANICS.md:192 matches the code; comment-vs-code drift only. From audit_flow #9aba5700 (reproduced by code reading).","line":364,"path":"contracts/src/VaultTypes.sol","reproduction":"Accept a $YOLO task with a staked doer: Burned(yolo, half) and Paid(yolo, burner, half) are emitted; the balance of 0x…dEaD is unchanged until BuybackBurner.burnYolo().","severity":"info","snippet":"/// @dev `amount` of `token` left for burning: $YOLO straight to 0x…dEaD, any other token to the BuybackBurner.","title":"Burned event comment says $YOLO goes straight to 0x…dEaD; the vault sends it to the BuybackBurner"},{"citation":"resolved","description":"_collectImd checks the balance delta (ImdShort) but collectBondPart (line 555-556) and _collect (line 696-697) book `amount` after a plain safeTransferFrom. Correct for USDC and for YOLO today (YoloToken taxes only transfers to/from the PoolManager; a wallet -> vault transfer is untaxed), so liabilities <= balance holds; it would break the balance invariant if YOLO ever taxed wallet transfers. Hardening only. From audit_permissions #940fec58.","line":555,"path":"contracts/src/TaskVault.sol","reproduction":"No failing input with the in-scope tokens: a wallet -> vault YOLO transfer delivers exactly `amount`.","severity":"info","snippet":"        IERC20(currency).safeTransferFrom(from, address(this), amount);","title":"collectBondPart/_collect book `amount` for USDC/YOLO without a received-amount check (unlike _collectImd)"},{"citation":"resolved","description":"cancelTask runs _advance before the status check (lines 369-370); a task with no active submission and deadline <= now becomes Expired in that call and cancelTask reverts TaskNotOpen. This matches MECHANICS.md:37 ('cancelTask of an Open task') and the NatSpec, so no rule is broken, but the dev cancellation cannot give predictors 100% back once the deadline passed: YoloPredict resolves the WON'T DO side as winner. Contributions, add-ons and bonds still come back through the Expired paths. Document it. From audit_economics #83adc12e (reproduced by code reading).","line":370,"path":"contracts/src/TaskVault.sol","reproduction":"Task 1 deadline T. At T+1 (nobody poked) the dev wallet calls cancelTask(1): _advance closes it as Expired and the call reverts TaskNotOpen; WILL DO predictions get 0.","severity":"info","snippet":"        if (t.status != TaskStatus.Open) revert TaskNotOpen();","title":"cancelTask cannot cancel a task whose deadline passed unpoked: it expires first and YoloPredict pays WON'T DO instead of 100% refunds"},{"citation":"resolved","description":"lock(add, 0) on a running lock computes `left` as ceil((end - now) / WEEK). With 1 second left the added amount gets add * 1 / 52 power although it is locked for one second. No payout can be collected inside that second (stakePower needs the lock running at the payout, >= 24 h after any submission), so the only use is to re-set the lock for >= 1 week, which lock(add, 1) would have cost anyway. Hardening only. From audit_math #cd7afe3e (reproduced by code reading).","line":79,"path":"contracts/src/YoloLockHub.sol","reproduction":"lock(1_000e18, 1) at T; warp T + 7 days - 1; lock(1_000e18, 0): Locked reports power 2 * 1_000e18 / 52 while the second 1,000 YOLO is withdrawable one second later.","severity":"info","snippet":"        uint256 left = (end - block.timestamp + WEEK - 1) / WEEK; // whole weeks, rounded up","title":"YoloLockHub: weeks left round up, so YOLO added in the last second of a term earns a full week of power"},{"citation":"resolved","description":"Verified by reading all of DisputeDesk, CaseDesk, Desk, YoloLockHub, BuybackBurner, DevPrices and the vault's money paths: every pull (TaskVault collectImd/collectBondPart/_collect via onlyDesk; sweetener, lock hub, trophy, adapter, predict use msg.sender) takes `from` from msg.sender or the vault-appended _sender(); DisputeDesk._fundCall's a.appellant is set from _sender() in the same call (DisputeDesk.sol:217, 262). No delegatecall, selfdestruct, Permit2 or arbitrary-call path in the core; desk-only entry points are guarded by only(vault|caseDesk|disputeDesk); Desk.wire is one-shot behind a transient flag raised in the constructor (works only in the deployment transaction, cannot be repeated). Privileged powers (trust assumptions, not bypasses): (a) the dev wallet's setBuybackWallet (BuybackBurner.sol:75-80) has no timelock and TaskVault._burn reads buybackWallet() at every payout, so the dev can point every ETH/USDC/IMD burn share (the staked-doer half of the stakers' 5% and the unearned NFT 5%) at any address including itself; the notice 'cannot send the money anywhere else' and MECHANICS.md:287 overstate the constraint; user deposits, rewards and bonds are never touched. (b) The ParamsRegistry owner adds attesters after 48 h; the attester set is global (AttestationChecker.sol:55 reads registry.isAttester at delivery), so an added attester signs for tasks of older versions too, but an answer still has to be delivered by the Intake stored for the request (TaskVault.sol:468-472), so deciding a swarm question also needs a version whose intake is an owner contract (future tasks only, 48 h notice). (c) cancelTask refunds only; DevPrices moves only the stake bonus; bindSweetener is once; ProtocolDirectory, HiddenList and TrophyCatalog hold no money. Worst case per stolen key: dev wallet = all future burn shares plus what sits in the burner; registry owner = the pots of tasks created under a version it configured after 48 h of public notice. Merged: audit_permissions #03d71602, audit_economics #a7dee71c, audit_flow #230eddf2.","line":24,"path":"contracts/src/BuybackBurner.sol","reproduction":"As the DevPrices owner call burner.setBuybackWallet(devEOA); finalize an accepted USDC task with a staked doer: Paid(usdc, devEOA, half + nftPart) lands in the dev's wallet in the same transaction. Access matrix: n/a (no issue).","severity":"info","snippet":"///           with one transaction and an event (`setBuybackWallet`); the dev cannot send the money anywhere else.","title":"Trust assumptions and access matrix: no path pulls a user's tokens except from the vault-appended sender; the dev wallet can redirect every buyback share, the registry owner the attester set after 48 "},{"citation":"resolved","description":"VERDICT: deployable once the IMD cap in DisputeDesk._fundCall counts the IMD 'rew' bond part (high, documented rule broken; one-line fix) and askAppealSwarm plays the timers first (medium). No critical path found: no theft, no permanent freeze, no privileged wallet taking user money. COVERAGE: read in full contracts/src/desks/DisputeDesk.sol, desks/CaseDesk.sol, desks/Desk.sol (wiring), YoloLockHub.sol, BuybackBurner.sol, VotePower.sol; in part TaskVault.sol (constructor, topUp, withdrawContribution, cancelTask, bank, onOracleResult, _burn), DevPrices.sol, YoloSweetener.sol, VaultTypes.sol, VaultParams.sol, ParamsRegistry constructor, interfaces. Not re-read: token/, DareTrophy, TrophyCatalog, HiddenList, ProtocolDirectory, YoloPredict, helpers/SwarmQuestion, helpers/TextRules, adapters (the specialists reported nothing there that needed reproduction). forge build (via_ir, solc 0.8.28) succeeded in 8 min 54 s; contracts/test/CancelTask.t.sol: 8 passed; the full suite was not run in the time budget. The three scratch proofs (MaxImdInCap, LateAppealAsk, RelockKeepsPower) all fail on this code for the stated reasons. Payout amounts and role scenarios are separate orders and were not re-derived.","line":1,"path":"contracts/src/TaskVault.sol","reproduction":"n/a (coverage statement).","severity":"info","snippet":"// SPDX-License-Identifier: MIT","title":"Verdict and coverage: deployable after the maxImdIn cap fix; forge build OK; scratch proofs fail as stated"}],"hash":"04d58be6a3be12bc1ff5ae71b3ea1db6a678b1ba117bfd3719ae39d56e4df9ac","nodeId":"92ef869d-f428-425e-8764-126fbf02143d","outcome":"completed","summary":"The review is complete. The findings file is written and every path, line and snippet matches the tree.\n\n**Outcome.** I kept 16 entries in `.imd-findings.json`, merged from the 20 specialist findings, after reproducing each against the code. No critical issue exists. The verdict entry says the code is deployable once two fixes land.\n\n**Findings that carry a Foundry proof** (each written under `contracts/test/scratch/`, self-contained, and failing on this code for the stated reason):\n\n- **High, DisputeDesk.sol:303.** `maxImdIn` ignores the IMD-denominated 1% bond part on IMD-reward tasks. A stranger capping an appeal at 1 IMD has 11 IMD pulled on a 1,000 IMD reward. The interface and MECHANICS promise \"at most maxImdIn IMD\". Proof: `MaxImdInCap.t.sol`.\n- **Medium, CaseDesk.sol:274.** `askAppealSwarm` plays no timers, so an appeal older than the 6-hour ask window is either voided by a poke or asked by a late ask, depending on transaction order. The fee and possibly the final outcome differ. Two specialists reported it at different severities. I merged them and kept medium because a documented rule is broken. Proof: `LateAppealAsk.t.sol`.\n- **Low, YoloLockHub.sol:111.** An ended lock re-set with a 1-week term keeps its epoch, so `stakePower` pays the old 52-week power at the payout. Bounded to 4% of the reward from the stakers' share. Proof: `RelockKeepsPower.t.sol`.\n\n**Kept without proof** (reproduced by code reading): the arbitrary-target `BuybackBurner.pull`, the sweetener voter share skipped for add-ons made after submission, the DevPrices 10x step with no cooldown, plus nine info entries covering the unbound `questionHash`, the disputer-as-stranger fee asymmetry, payTo hedging, the unread `Params.nft`/`delegateRegistry`, the Burned event comment, the unchecked USDC/YOLO bond booking, cancel-after-deadline behaviour, the week rounding, and one merged trust-assumption entry.\n\n**Dropped or downgraded.** Nothing was dropped outright. The three overlapping trust-assumption and access-matrix entries became one info entry, and the two BuybackBurner and two sweetener entries each became one.\n\n**Checks run.** The full build with via_ir took about 9 minutes. The project's CancelTask suite passed. The full suite was not run inside the time budget. The three scratch proofs all fail as stated. No tracked file was modified. The standalone harness deploys the desks and vault through one helper constructor, since the desks' one-shot wiring flag lives in transient storage and must be raised in the same frame as the vault constructor.","treeHash":null,"usage":{"cachedInputTokens":2231463,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":37885,"runtime":"claude","turns":30,"wallClockMs":1050548}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d5e40d018a0986f3","findings":[{"citation":"resolved","description":"`pull(vault, token)` takes any `vault` address and calls `withdraw(address)` on it from the burner's own address. The burner holds ETH, USDC, IMD and YOLO and has no approvals, so today no balance can be moved this way (the only selector sent is withdraw(address), which none of the four tokens implements), but the contract issues attacker-chosen external calls under its own identity. Hardening: pin the vault (constructor argument or a dev-set address) or drop `pull` since the vault's `withdrawTo` cannot be called for the burner anyway and the burner's credit can only ever be sent to itself.","line":122,"path":"contracts/src/BuybackBurner.sol","reproduction":"Deploy any contract A with `function withdraw(address) external { emit Called(msg.sender); }`. Call `burner.pull(address(A), address(0))` from an unprivileged EOA. Expected: the burner only talks to the TaskVault. Actual: A receives a call with msg.sender == burner; no funds move.","severity":"low","snippet":"        IVaultWithdraw(vault).withdraw(token);","title":"BuybackBurner.pull lets anyone make the burner call an arbitrary contract"},{"citation":"resolved","description":"The ParamsRegistry comment (and ParamsBounds requiring nft != 0) says a version's `nft` / `delegateRegistry` govern voting and that address(0) disables delegated votes. DisputeDesk.vote discards those two return values of `voteParams` (DisputeDesk.sol:185 `(,, uint32 instantDelta, uint32 minWallets) = ...`) and checks holders only through the immutable `VotePower(nft_, delegateRegistry_)` fixed at launch 1. A proposed version with delegateRegistry = address(0) changes nothing. docs/MECHANICS.md 'Trust assumptions' states the VotePower rule correctly; the struct comments and bounds are the drift. No money impact.","line":17,"path":"contracts/src/VaultParams.sol","reproduction":"Owner proposes and (48h later) activates params identical to version 1 except delegateRegistry = address(0); create a task under version 2; a delegate of an NFT owner (MockDelegateRegistry) votes with the owner's ids. Expected per the struct comment: NotTokenHolder. Actual: the vote counts.","severity":"info","snippet":"    address delegateRegistry; // delegate.xyz v2; address(0) disables delegated votes","title":"Params.nft and Params.delegateRegistry are not read by the vote path; VotePower's immutables decide"},{"citation":"resolved","description":"TaskVault._burn pays $YOLO to `burner` (TaskVault.sol:668 `_pay(token, burner, amount)`) where it waits for anyone's `burnYolo`; ETH/USDC/IMD try the buyback wallet first and only fall back to the burner. The event doc states the opposite for both branches. docs/MECHANICS.md ('Where a burn goes') matches the code, so this is a comment-vs-code drift only.","line":364,"path":"contracts/src/VaultTypes.sol","reproduction":"Accept a $YOLO task with a staked doer; observe `Burned(yolo, half)` and `Paid(yolo, burner, half)`: the $YOLO balance of 0x…dEaD is unchanged until `BuybackBurner.burnYolo()`.","severity":"info","snippet":"/// @dev `amount` of `token` left for burning: $YOLO straight to 0x…dEaD, any other token to the BuybackBurner.","title":"Burned event comment says $YOLO goes straight to 0x…dEaD; the vault sends it to the BuybackBurner"},{"citation":"resolved","description":"`setBuybackWallet(wallet)` (lines 75-80) accepts any non-zero address with no timelock, and TaskVault._burn reads `buybackWallet()` at every payout. The DevPrices owner can therefore point every ETH/USDC/IMD burn share (the staked-doer half of the stakers' 5% and the unearned NFT 5%) at a wallet it controls, before or after the share arrives. This is a documented dev power (docs/MECHANICS.md 'Dev wallet' row) and never touches user deposits, rewards or bonds, so it is a trust assumption, not a bypass; the 'cannot send the money anywhere else' wording overstates the constraint. Worst case per stolen dev key: all future burn shares plus whatever sits uncollected in the burner.","line":24,"path":"contracts/src/BuybackBurner.sol","reproduction":"As the DevPrices owner call `burner.setBuybackWallet(devEOA)`; then finalize an accepted USDC task with a staked doer. Expected per the notice: the share can only reach the project buyback wallet. Actual: `Paid(usdc, devEOA, half + nftPart)` lands in the dev's own wallet in the same transaction.","severity":"info","snippet":"///           with one transaction and an event (`setBuybackWallet`); the dev cannot send the money anywhere else.","title":"BuybackBurner notice: the dev can redirect every buyback share to any address, including itself"},{"citation":"resolved","description":"`Submission.addon` is the add-on fixed at submission. YoloSweetener accepts `sweeten` while the submission is under review (task Open, before the deadline) and pays the whole `bonusOf` at Completed with an NFT voter pool, but the batch claim only auto-triggers `claimVoter` when the add-on already existed at submission. Voters of such a submission must call `YoloSweetener.claimVoter(taskId, payTo)` themselves; nothing is lost (the pool waits), so this is a UX/consistency note.","line":441,"path":"contracts/src/desks/DisputeDesk.sol","reproduction":"Submit on a task with no add-on; during the dispute window `sweeten(taskId, 10_000e18)`; dispute, NFT decides Accept, finalize, `payout(taskId)`. `claimVoterRewards([subId], voter)` pays the vault share but leaves `voterPool[taskId]` untouched; a direct `claimVoter(taskId, voter)` then pays it.","severity":"info","snippet":"        if (s.addon != 0 && s.outcome == Outcome.Accepted) bonusTask = s.taskId;","title":"claimVoterRewards skips the YoloSweetener voter share when the add-on arrived after the submission"},{"citation":"resolved","description":"Every other user action goes through `advanced()` (timers first). `askAppealSwarm` reads the appeal and asks directly; `CaseDesk.askAppeal` only checks `phase == Appealed`. After the 6h `swarmTimeout` the submission is due to void the appeal (step), yet an ask still succeeds if it lands before the next poke, so whether a late appeal is voided or asked depends on transaction ordering rather than on the clock. Money is unaffected either way (void = full refund, asked = fee spent as intended), so informational; aligning it with the other entry points would be one `advanced(subId)` call before the ask.","line":235,"path":"contracts/src/desks/DisputeDesk.sol","reproduction":"appeal at T; warp to T + 6h + 1; call `askAppealSwarm` with the correct texts. Expected (stepDeadline says the step was due at T): AppealVoided. Actual: SwarmAsked, phase Asked.","severity":"info","snippet":"        ICaseDesk(caseDesk).askAppeal(subId, funds, rt);","title":"askAppealSwarm does not play the Appealed timeout first"}],"hash":"379328e842a2d6162e99e1c8f5c760ceab9bf86dbedc38d9f95e84d212422c0c","nodeId":"49329a6c-9ef4-48a4-8c8b-624f033bd9f0","outcome":"completed","summary":"Audit complete. The findings file holds six entries, all low or info, written from the tree at d9c24e3. Report in the mandated format follows.\n\n**0. VERDICT: deployable.** No critical, high or medium defect found in `contracts/src/`. Every pull of user tokens is keyed to the caller, the liabilities invariant holds on every path I traced, every payout is a pull or a credit that falls back to a credit, cancel refunds every party once, and the privileged roles cannot touch deposits. Blockers: none. Open items are comment drift and two hardening notes (a free-form external call in `BuybackBurner.pull`, and `askAppealSwarm` not playing the appeal timeout first). Build and all 664 non-fork tests pass.\n\n**1. User approvals.** info, no issue. Every `transferFrom` uses the caller: `_collect`/`_collectImd` get `msg.sender` from `createTask`/`topUp`; desks pass `_sender()`, which the vault appends and which desks accept only from the vault (`routed`). `_fundCall`'s `a.appellant` is written from `_sender()` in the same call at DisputeDesk.sol:217 and :262 before it is read at :287. Sweetener, Predict, LockHub, DareTrophy and the adapter all pull from `msg.sender`. No admin, upgrade, delegatecall or arbitrary-call path spends an allowance. Permit2 is not used.\n\n**2. Balance invariant.** info, no issue. Books-without-money: ETH bookings at TaskVault.sol:548/691 are only reachable with a matching `ethNeeded` that `_settleValue` checks against `msg.value` on every desk path (submit:136, dispute:123, appeal:221, callSwarm:269). `_splitCurrency` sends dust to the treasury when the winning side has no bond in a currency (:765), so no orphan bucket. Predict, Sweetener and LockHub balance per market or lock; Burner is balance-based.\n\n**3. Stuck money.** info, no issue. Every claim ends in `_pay`, which credits on a failed send (:706-714); credits leave via `withdrawTo`, so a blacklisted USDC or reverting ETH receiver only blocks its own payout. Burn sends use 30,000 gas and fall back to the burner (:671-676). One party's revert cannot block another's claim since each claim is its own transaction.\n\n**4. Reentrancy and routing.** info, no issue. One transient lock covers `createTask`, `topUp`, `withdraw*`, `poke`, `cancelTask`, `sweepTreasury` and every forwarded call (:192-195). Non-whitelisted desk functions (`step`, `unwind`, `startVote`, `settle`, ...) are not routable and are `only(vault|sibling)`. `onOracleResult` is lock-free but makes only a staticcall to the checker and cannot reach a desk.\n\n**5. Access rights.** info, trust assumptions as documented. Wire and `bindSweetener` are once-only. Registry owner: bounded parameters for future tasks after 48 h, pause of creation only. Dev wallet: prices within 10x per call, `cancelTask` (refund-only), and `setBuybackWallet` to any address with no delay. Worst case per dev key: all future burn shares (fee remainders only, never deposits). One drift: `Params.nft`/`delegateRegistry` are ignored by the vote path (finding 2 in the file).\n\n**6. Cancel.** info, no issue. Checked all eight phases: doer bond and appellant bond plus unspent fee are credited in `_settleNeutral`, disputer via `claimDisputer`, queue via `claimVoidSubmission`, pot via `withdrawContribution`, sweetener `withdraw`, predictions 100% via `claim`. Paid swarm fees stay gone. No double refund; no user-controllable revert in `_advance`.\n\n**7. NFT vote by ids.** info, no issue. Bitmap per dispute catches list repeats, transfers after a vote and owner-plus-delegate. `ownerOf` is the identity.md NFT fixed in VotePower. Huge ids fit the word mapping. `payTo` on the other side is the documented design and does not overpay (sum of recs equals the tally).\n\n**8. Appeal and swarm.** info. Replay blocked by `r.answered`; forgery blocked by domain plus attester set; answers after cancel are inert. Gas starvation of `ask` needs the Intake to burn over 9.4M gas. One ordering note: `askAppealSwarm` can ask a stale appeal after the 6 h void deadline i","treeHash":null,"usage":{"cachedInputTokens":3698022,"inputTokens":550,"model":"claude-fable-5-1","outputTokens":62140,"runtime":"claude","turns":50,"wallClockMs":1429124}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c872aaeb2e58b080","findings":[{"citation":"resolved","description":"Item 5/14. MECHANICS and the task brief state the dev wallet sets YOLO/ETH/IMD prices 'within 10x per step'. _check only compares with the current stored price and there is no delay between steps, so the dev wallet can chain calls in one transaction (or one block) and move a price by any factor. The only money effect is CaseDesk._stakeBps (the doer stake bonus): with yoloPriceUsd raised 1000x, yoloValue(currency, base) shrinks 1000x, so any lock power >= base/1000 in YOLO gets the full MAX_STAKE_BONUS_BPS (4%). The bonus is taken from the stakers' 5% (treasury + burn), not from users. Fix: add a minimum interval between price changes (e.g. 1 hour per token) or bound the change relative to a price that is at least N hours old.","line":89,"path":"contracts/src/DevPrices.sol","reproduction":"DevPrices with yoloPriceUsd = 0.01e18. Owner calls setYoloPriceUsd(0.1e18), setYoloPriceUsd(1e18), setYoloPriceUsd(10e18) in one multicall/transaction: all three pass _check, price is now 1000x. Then a doer (dev's second wallet) with 1 YOLO locked 52 weeks (power 1e18) submits to a 1,000 USDC task (base 1000e6 => yoloValue = 100e18 instead of 100,000e18 => power/r = 1%, bps 4); at 100,000 USDC base equivalent it reaches 4%. Expected: at most a 10x move per step with a cooldown; actual: unlimited move in one transaction. Loss: up to 4% of each accepted reward redirected from treasury/burn to the doer (e.g. 40 USDC on a 1,000 USDC reward).","severity":"low","snippet":"if (next > current * MAX_STEP || next * MAX_STEP < current) revert PriceSanity();","title":"Dev price 'max 10x per step' bound has no cooldown: any price is reachable in one transaction"},{"citation":"resolved","description":"Item 3/14. s.addon is a snapshot of YoloSweetener.bonusOf(taskId) taken at submit (CaseDesk.submit, s.addon = t.addon). YoloSweetener.sweeten accepts deposits while a submission is under review (it checks only Open and deadline). If the add-on was 0 at submission and is added during the dispute, bonusTask stays 0 and claimVoterRewards never calls YoloSweetener.claimVoter, contrary to its NatSpec ('An accepted submission with a $YOLO add-on also claims payTo's share'). The money is not lost: payTo (or anyone) can still call YoloSweetener.claimVoter(taskId, payTo) directly. Fix: read the sweetener's bonus/voterVotesLeft at claim time instead of the submission snapshot (e.g. bonusTask = s.taskId whenever outcome is Accepted and a sweetener is bound; the call is already in a try).","line":441,"path":"contracts/src/desks/DisputeDesk.sol","reproduction":"Task 1 (1,000 USDC, no add-on). Doer submits (s.addon = 0). Disputer disputes; during the vote Carol calls YoloSweetener.sweeten(1, 10,000e18). NFT holders vote accept, instant decision, appeal window passes -> Accepted; anyone calls payout(1): voterPool = 500e18 YOLO. Voter V calls vault.claimVoterRewards([subId], V): receives IMD/USDC shares, but YoloSweetener.voterClaimed(1, V) stays false and V receives 0 YOLO from the add-on (expected: V's pro-rata share of 500e18).","severity":"low","snippet":"if (s.addon != 0 && s.outcome == Outcome.Accepted) bonusTask = s.taskId;","title":"claimVoterRewards skips the YOLO add-on voter share when the add-on arrived after submission"},{"citation":"resolved","description":"Item 8. onOracleResult accepts any attestation signed for the vault's EIP-712 domain with a bool answer and enough panel, as long as msg.sender is the Intake stored for requestId. SwarmRequest.questionHash is stored by ask() but never compared with a.questionHash, and a.requestId (the oracle UUID) is not recorded as consumed. The IdentityMD oracle-consumer rules require comparing a.questionHash with the pending request's hash first, because anyone can pay 0.5 IMD for their own question naming the vault as consumer and obtain a valid signature for the vault's domain. Today the pairing of answer to request rests only on the Intake writer; a mis-pairing (or a replayed attestation from another submission of the vault) would decide a swarm appeal, which is final and moves the whole pot. Mitigating: MECHANICS.md:398 records that keccak256(question) may not equal the plane's questionHash formula, so the fix needs the plane's real hash (OracleQuestion.hash with an absolute window). Fix: store the plane-compatible hash at ask and require a.questionHash == r.questionHash; keep a mapping of consumed a.requestId.","line":477,"path":"contracts/src/TaskVault.sol","reproduction":"Mock Intake records request rid1 for subId 7 (questionHash Q7). Attacker pays the Intake for an unrelated question 'Is water wet?' with consumer = vault; the oracle signs attestation A (questionHash Qx != Q7, answer true, panel ok). If the Intake delivers onOracleResult(rid1, A, sig) (writer error or reuse of A from another vault request), TaskVault records answered=true, answer=true for subId 7 (expected: AttestationIgnored because Qx != Q7) and the next step finalizes subId 7 as Accepted, paying the doer the whole pot.","severity":"low","snippet":"uint8 code = attestationChecker.check(address(this), via.minPanelSize, via.minQuorum, a, signature);","title":"Swarm callback does not bind the attestation to the asked question (questionHash) nor consume a.requestId"},{"citation":"resolved","description":"Item 6. cancelTask runs _advance before the status check; a task with no active submission and deadline <= now becomes Expired in that call and cancelTask reverts TaskNotOpen. This matches MECHANICS ('cancelTask of an Open task') and the NatSpec ('still open after its timers are played'), so it is not a rule break, but the dev wallet's cancellation cannot be used to give predictors 100% back once the deadline passed: YoloPredict resolves WontDoWon (5% burn, 5% deferred burned, WILL DO side loses). Contributions, add-ons and bonds still come back through the Expired paths. No fix needed unless the intended rule is otherwise; document it.","line":316,"path":"contracts/src/TaskVault.sol","reproduction":"Task 1 deadline T. At T+1 (nobody poked) the dev wallet calls cancelTask(1): _advance closes it as Expired and the call reverts TaskNotOpen; YoloPredict.claim pays WON'T DO winners and WILL DO positions get 0 (expected per a reading of 'predictions 100% on cancel': not applicable, the task was no longer cancellable).","severity":"info","snippet":"if (t.status != TaskStatus.Open) revert TaskNotOpen();","title":"cancelTask cannot cancel a task whose deadline passed unpoked: it expires first and YoloPredict pays WON'T DO instead of 100% refunds"},{"citation":"resolved","description":"Item 5. Privileged powers verified in code: (a) ParamsRegistry owner: proposeParams/proposeAttester behind a 48 h timelock, validated by ParamsBounds; intake/devPrices/swapAdapter/treasury only checked non-zero. The attester set is global (AttestationChecker reads registry.isAttester at delivery), so after 48 h an owner-added attester also signs answers for tasks created under older versions; combined with a version whose intake is an owner contract (future tasks only), the owner could decide every swarm question of those tasks (final; moves the pot). Old tasks keep their own intake (AskedVia), so for them an owner attester alone is not enough without the Intake delivering. setNewTasksPaused only gates createTask. bindSweetener is once. (b) Dev wallet: setBuybackWallet (BuybackBurner) instantly redirects every future ETH/USDC/IMD burn share (stakers' half and unearned NFT 5%) to any address including itself; cancelTask returns all money to depositors and gives the dev nothing; DevPrices only moves the stake bonus (see the DevPrices finding). (c) Vault has no owner; Desk.wire works only in the deployment transaction (transient flag), cannot be called twice. ProtocolDirectory, HiddenList, TrophyCatalog have no payable/token calls. Worst case per stolen key: registry owner = all reward pots of tasks under a version it configured after 48 h of public notice; dev wallet = burn shares only.","line":55,"path":"contracts/src/helpers/AttestationChecker.sol","reproduction":"Registry owner: proposeAttester(K,true); 48 h later executeAttester(K,true); proposeParams(p with intake = OwnerIntake); 48 h later activateParams(). A 10 ETH task created under that version, owner's second wallet submits a fake proof, an honest disputer wins the NFT vote, the second wallet appeals; OwnerIntake calls onOracleResult with an answer=true attestation signed by K -> recorded; the step finalizes Accepted and the 10 ETH (minus fee) goes to the owner's wallet. Dev wallet: setBuybackWallet(dev) then every _burn of ETH/USDC/IMD pays dev.","severity":"info","snippet":"if (err != ECDSA.RecoverError.NoError || !registry.isAttester(signer)) return 5;","title":"Trust assumption: registry owner (attester set, intake per version) and dev wallet (buyback wallet) powers"}],"hash":"68ec8ed8a0efffa47dd436154f0116ab5f607420f72f0e0fbc803cc404966413","nodeId":"ee05e0d3-0b13-428b-8c0a-49a6ecf256c0","outcome":"completed","summary":"I found no critical or high issues in the economics, so the contracts look deployable from that side. But I couldn't confirm it with tests: `forge build` (via-IR) was still compiling after 20 minutes and was stopped, so nothing was built and no test ran. I wrote no PoCs, since none of the findings is high enough to need one. Five findings (three low, two info) are in `.imd-findings.json`.\n\n**0. Verdict:** Deployable on economics, with no blockers found by reading the code. Before launch:\n- Get a full `forge build` and `forge test` run to complete.\n- Accept two trust assumptions (item 5): the registry owner's attester and intake powers, and the dev wallet's control of the buyback wallet.\n- Consider fixing the low items: the DevPrices step bound has no cooldown, and the swarm callback doesn't check the question hash.\n\n1. **User approvals — no issue.** Every pull takes from the caller:\n   - The vault pulls only from the sender it appends (`fallback`) or from `msg.sender`.\n   - `_fundCall` uses `a.appellant`, which is set from `_sender()` in the same call.\n   - The other contracts' pulls (Sweetener, Predict, LockHub, DareTrophy) all use `msg.sender`.\n   - The swap adapter pulls only from its caller, and the vault approves it for `amountIn` then resets the approval to 0.\n   - No Permit2, delegatecall, arbitrary call or upgrade path exists. The routing table forwards no desk-only function.\n2. **Balance invariant — no issue.** I traced every booking:\n   - ETH: `collectBondPart` and `_collect` count ETH, and `settleValue` checks the sum against `msg.value`.\n   - `ask()` removes the fee from liabilities, and `_charge`/`sideSpent` remove it from the right bucket.\n   - Settlement splits add up to the reward (`r`) and to the penalty.\n   - YoloPredict and the Sweetener balance in every branch.\n3. **Stuck money — no issue.** A failed push becomes a credit, and the 30k-gas send to the buyback wallet falls back to the burner. A blacklisted USDC holder can use `withdrawTo`. One low gap: `claimVoterRewards` skips the YOLO add-on share when the add-on was added after submission (`DisputeDesk.sol:441`). The voter can still claim it directly from the Sweetener.\n4. **Reentrancy and routing — no issue.** One transient lock covers every routed call, and views use STATICCALL. `routed` and `only` guard the desks. `onOracleResult` has no lock but accepts only the stored Intake and makes no external writes.\n5. **Access rights — info (trust assumption).**\n   - The attester set is global. After the 48h timelock, an owner-added attester combined with an owner-chosen intake in a new version can decide the swarm answer on that version's tasks, which moves the pot.\n   - `setBuybackWallet` redirects burn shares instantly.\n   - Low: the \"10x per step\" price bound has no cooldown, so chained calls move a price by any factor. Only the stake bonus is affected (`DevPrices.sol:89`).\n   - `wire` can't be called twice or by anyone else.\n6. **Cancel — no issue in code.** Every party is refunded once and the dev wallet gets nothing. Info: once the deadline passes, a cancel first expires the task and then reverts, so predictions settle as WON'T DO instead of a full refund.\n7. **NFT vote — no issue.** A bitmap stops any id voting twice (a repeat in the list, a transfer after voting, owner plus delegate). Huge ids are fine. `payTo` only collects reward on the votes credited to it.\n8. **Appeal and swarm — low.** The callback doesn't compare the attestation's question hash with the stored one, and doesn't mark the oracle's request id as used (`TaskVault.sol:477`). The docs (`MECHANICS.md:398`) note the plane's hash formula is unverified, which limits the fix. The gas reserve, the 6h timeout and the 3-call cap behave as documented.\n9. **YoloPredict — no issue.** Points can't overflow (`mulDiv`). Settlement is idempotent and the last claimant takes the dust. Positions are blocked during review even without a poke. Zero points happen only for positions under roughly `duration²` ","treeHash":null,"usage":{"cachedInputTokens":1842024,"inputTokens":34,"model":"claude-opus-5-5","outputTokens":32433,"runtime":"claude","turns":19,"wallClockMs":970819}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a11137177fc72d8b","findings":[{"citation":"resolved","description":"ITaskVault.appeal/callSwarm promise 'at most maxImdIn IMD' (ITaskVault.sol:97,107; MECHANICS.md:148,298). DisputeDesk._fundCall only compares bond.imd + pay (fixed IMD bond + wallet-paid fee) with maxImdIn. When the task's reward currency is IMD, the dispute bond's 'rew' part (disputeBondBps = 1% of the author's own reward, also IMD) is pulled in the same collectImd call (_collectBond merges it: `b.imd + extraImd + (rewIsImd ? b.rew : 0)`) but is never counted against the cap. A stranger appellant (or NoDecision caller) who caps the call at the quoted fixed bond + fee has 1% of the author's reward pulled on top, with no revert. Rule broken: USER APPROVALS/ASYMMETRY item: the caller's own stated ceiling is exceeded by a path they did not consent to at that size; a loser forfeits that extra amount with the bond. Loss example: IMD task, author's reward 1000 IMD, fee 0.5 IMD: caller passes maxImdIn = 1e18 and 11e18 IMD leave the wallet (10 IMD unexpected); on a 100,000 IMD reward it is 1,000 IMD. Smallest fix: in _fundCall compute the IMD cost as `bond.imd + pay + (s.currency == address(imd) ? bond.rew : 0)` and revert CostAboveMax when that exceeds maxImdIn (ETH/USDC/YOLO rew parts are not IMD and stay outside the IMD cap by design).","line":303,"path":"contracts/src/desks/DisputeDesk.sol","reproduction":"State: IMD task with the author's own reward 1000 IMD; a submission; one dispute; 20 NFT votes for accept -> Phase.Decided (instantDelta 20). Input: a stranger calls vault.appeal(subId, maxImdIn = 1e18 (= 0.5 IMD fixed bond + 0.5 IMD fee), ImdPay(0,...)). Expected (interface and docs): revert CostAboveMax, since the IMD cost is 0.5 + 10 + 0.5 = 11 IMD > 1 IMD. Actual: the call succeeds and transferFrom pulls 11e18 IMD from the stranger (quoteDisputeBond(subId) = {imd: 0.5e18, rew: 10e18}, quoteSwarmFee = 0.5e18). Run: FOUNDRY_TEST=contracts/test/scratch forge test --match-path contracts/test/scratch/MaxImdInCap.t.sol -> FAIL 'next call did not revert as expected' on the current code; passes once the cap includes the IMD rew part.","severity":"high","snippet":"        if (bond.imd + pay > maxImdIn) revert CostAboveMax();","title":"appeal/callSwarm: maxImdIn does not cap the IMD-denominated bond 'rew' part on IMD tasks (documented 'at most maxImdIn IMD' broken)"},{"citation":"resolved","description":"_fundCall (line 292) treats the disputer as 'a side' only when !sidesDoer; a disputer appealing a Reject decision for the doer's side (sidesDoer = true) posts the full second bond and pays the whole fee from the wallet like a stranger. At settlement the stranger fee carve-out excludes any appellant equal to d.disputer, so this caller gets no 0.5 IMD fee back while an unrelated wallet in the same position does. Asymmetry only; no theft, amount bounded by bondFixedImd. Fix: define 'stranger' in _settleDecided by the same test _fundCall used (the side paid from its bond), e.g. a.fromBond == 0 && a.bond.imd != 0.","line":626,"path":"contracts/src/desks/DisputeDesk.sol","reproduction":"Dispute by D, NFT decides Reject, D appeals with sidesDoer = true (allowed: only the doer is restricted), posts bond 0.5 IMD + 1% and the fee, swarm answers Accept: D is credited a.funds + a.bond + appPart but not strangerFee; an unrelated appellant in the same flow is also credited min(bondFixedImd, penaltyImd).","severity":"info","snippet":"        bool appellantIsStranger = hasCaller && a.appellant != f.doer && a.appellant != d.disputer;","title":"DisputeDesk._settleDecided: a disputer who appeals for the doer's side pays a full stranger bond but is not treated as a stranger for the 0.5 IMD fee refund"},{"citation":"resolved","description":"`vault` is caller-supplied, so the burner (which holds ETH, USDC, IMD and YOLO) performs an external call with msg.sender = burner to any address. No contract in the repository exposes a `withdraw(address)` that would move the burner's funds and the burner grants no allowances, so no loss was found; hardening: pin the vault (constructor argument or ProtocolDirectory lookup) rather than taking it from calldata.","line":122,"path":"contracts/src/BuybackBurner.sol","reproduction":"Any wallet calls burner.pull(X, token) for an arbitrary contract X: X.withdraw(token) runs with msg.sender == burner. Expected: only the project's vault is called. Actual: any target is called. No money moved with the contracts in scope.","severity":"info","snippet":"        IVaultWithdraw(vault).withdraw(token);","title":"BuybackBurner.pull lets anyone make the burner call withdraw(address) on an arbitrary target"},{"citation":"resolved","description":"_collectImd checks the balance delta (ImdShort) but collectBondPart and _collect (line 696) book `amount` after a plain transferFrom. Correct for USDC and for YOLO today (YoloToken taxes only transfers to/from the PoolManager, a wallet -> vault transfer is untaxed), so liabilities stay <= balance; it would break the balance invariant if YOLO ever taxed wallet transfers. Hardening only.","line":555,"path":"contracts/src/TaskVault.sol","reproduction":"No failing input with the in-scope tokens: a wallet->vault YOLO transfer delivers exactly `amount` (YoloToken._update taxes only to/from poolManager).","severity":"info","snippet":"        IERC20(currency).safeTransferFrom(from, address(this), amount);","title":"TaskVault.collectBondPart books `amount` for USDC/YOLO bond parts without a received-amount check (unlike _collectImd)"},{"citation":"resolved","description":"`side` is fixed per voter wallet, but a payTo named by another voter only gets its accept/reject counter incremented. The payTo wallet may later vote itself for the other side (its own side is still 0) and will be paid whichever side wins. Each NFT still counts once on one side and the tallies stay consistent (voterVotesLeft equals the sum of recs on that side), so no money is created; it is a hedging possibility the 'one side per wallet' wording does not cover.","line":164,"path":"contracts/src/desks/DisputeDesk.sol","reproduction":"A votes 5 ids accept with payTo = B; B votes 3 ids reject with payTo = B. votesOf(subId, B) = (5, 3); B claims whichever side is final. No double payment, no loss.","severity":"info","snippet":"            if (side == VoteLib.SIDE_ACCEPT) pr.accept += uint120(power);","title":"DisputeDesk.vote: a payTo record accumulates votes without a side, so one payTo can hold accept and reject counts (hedging across wallets)"},{"citation":"resolved","description":"Verified by reading all of contracts/src: every pull (TaskVault 555/696/769/787 via onlyDesk; sweetener 102, predict 167, lock hub 85, trophy 114, adapter 65) uses msg.sender or the vault-appended _sender() (last 52 bytes, always written by the vault's fallback; routed functions accept only the vault). DisputeDesk._fundCall's a.appellant is set from _sender() in the same call. No delegatecall, selfdestruct, Permit2 or arbitrary-call path in the core. Desk-only entry points (step, unwind, startVote, markAppealed, askEscalation, askAppeal, tryInstantDecision, appealAsked, dropCaller, settle) are guarded by only(vault|caseDesk|disputeDesk); the vault's bank is onlyDesk; wire is one-shot behind a transient flag (note: forge 1.8.3 here does not keep constructor transient storage across a later call from the test contract, so standalone tests must deploy the core through one helper contract, as the launch factory does). Registry owner: bounded params for future versions after 48h, attester set after 48h (global, documented), pause of creation only, bindSweetener once (a reverting bonusOf would block submit/quoteSubmission; documented as a trust assumption, no money moves). Dev wallet: prices within 10x, buyback wallet, cancelTask (refunds only). Not re-checked in depth: payout amounts and role scenarios (separate orders); forge build of contracts/src succeeded; the full project test tree was not run (compiles for over 8 minutes under via_ir in this environment).","line":186,"path":"contracts/src/TaskVault.sol","reproduction":"n/a (no issue). Entry-point inventory and guard check only.","severity":"info","snippet":"        bytes memory data = abi.encodePacked(msg.data, msg.sender, msg.value);","title":"Access matrix verified: no path pulls a user's tokens except from the vault's appended sender; privileged keys move no user money"}],"hash":"b25c8d159d112fe174fcafa75f1c439a10b3f185446db05eb3493e78036e35dd","nodeId":"f5e9cdf9-3f54-48cf-b510-97d9f32eab52","outcome":"completed","summary":"The review is complete and the findings file is written. Here is the recap.\n\n**Verdict.** The permission model holds: no path pulls a user's tokens except from the vault-appended sender, desks accept only the vault or their sibling, the bank is desk-only, and no privileged key can move or redirect user money. One documented rule is broken and is reported with a proof.\n\n**Findings written to `.imd-findings.json`** (6 entries):\n\n- **High, with Foundry proof.** The appeal and swarm-call cap `maxImdIn` ignores the bond's \"rew\" part when the task reward is IMD. The check in `DisputeDesk._fundCall` at line 303 compares only the fixed bond plus the fee, yet the 1% reward bond is pulled in the same IMD transfer. The interface and MECHANICS promise \"at most maxImdIn IMD\". Example state: IMD task with a 1000 IMD reward, a stranger appeals with a 1 IMD cap and 11 IMD leave the wallet. The proof test in `contracts/test/scratch/MaxImdInCap.t.sol` fails on the current code with \"next call did not revert as expected\" and passes once the cap includes the IMD-denominated rew part.\n- **Info.** A disputer who appeals for the doer's side posts a full stranger bond but is excluded from the stranger fee refund at settlement. Asymmetry only.\n- **Info.** `BuybackBurner.pull` calls `withdraw(address)` on a caller-supplied target as the burner. No loss found, hardening only.\n- **Info.** USDC and YOLO bond parts are booked without a received-amount check. Safe with the in-scope tokens.\n- **Info.** A `payTo` record can hold accept and reject counts, since only the voter's side is fixed. No double payment.\n- **Info.** Access-matrix summary and coverage note.\n\n**Coverage.** All of `contracts/src` was read in full. `forge build` of the sources succeeded. The full project test tree was not run because compiling it under via_ir exceeds eight minutes in this environment. One environment note worth passing on: forge 1.8.3 here does not keep constructor transient storage across a later call from the test contract, so standalone tests must deploy the core through one helper contract, which the proof does. Payout amounts and role scenarios were left to their separate orders, as instructed.","treeHash":null,"usage":{"cachedInputTokens":3620282,"inputTokens":642,"model":"claude-fable-5-1","outputTokens":49062,"runtime":"claude","turns":45,"wallClockMs":1270074}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"357c46e3781993d4","findings":[{"citation":"resolved","description":"docs/MECHANICS.md (The swarm, and the state diagram 'Appealed --> Decided: ... not asked within 6 h (appeal void, all refunded)') fixes a boundary: an appeal filed at appealedAt that nobody asked by appealedAt + swarmTimeout is void. CaseDesk.step enforces it only when a timer is played (Phase.Appealed branch, lines 206-212). DisputeDesk.askAppealSwarm (lines 232-236) does not call `advanced`/`advance` before CaseDesk.askAppeal, and askAppeal checks only `phase == Appealed`. So after the deadline two different outcomes are reachable from the same state depending on which transaction lands first: `poke(taskId)` voids the appeal (bond + fee credited back, `asks` += 1, new 24 h appeal window) while `askAppealSwarm` spends the appellant's prepaid fee and asks the swarm, which then decides the case. The protocol's own principle ('the outcome is the same whenever anyone pokes', CaseDesk._swarmStatus) is broken for this step, and the appellant (or anyone) can keep a Decided submission unfinalised past the documented 6 h as long as no one touches the task, then still trigger the swarm. Money at stake per occurrence: the swarm fee (0.5 IMD at the launch price) refunded vs spent, and a different final outcome of the submission (the swarm answer vs the NFT decision), i.e. the whole loser bond split. Smallest fix: play the task's timers first, e.g. in CaseDesk.askAppeal insert `_bank().advance(_subs[subId].taskId);` before the phase check (or call `ICaseDesk(caseDesk).advanced(subId)` in DisputeDesk.askAppealSwarm), so a late ask reverts WrongPhase after step() voided the appeal.","line":274,"path":"contracts/src/desks/CaseDesk.sol","reproduction":"Fixture: Base.t.sol environment (swarmTimeout 6 h). 1) author creates a 100 USDC task, doer submits, dave disputes, voter1 votes REJECT with 20 NFTs, vote end -> NFT Decided (Reject). 2) erin calls vault.appeal(subId, max, noSwap) -> Phase.Appealed, prepaid fee 0.5 IMD. 3) warp 6 h + 1 s with no poke. 4) anyone calls vault.askAppealSwarm(subId, ruleTexts). Expected (docs): the appeal is void, call reverts WrongPhase (or is a no-op) and erin is credited bond + fee. Actual: phase becomes Asked, the mock Intake payee receives 0.5 IMD, s.request is set. Control: same state but step 4 = vault.poke(taskId): phase Decided, credits[imd][erin] == 0.5e18 + 0.5e18, asks == 1. Scratch test contracts/test/scratch/LateAppealAsk.t.sol (both tests pass on this code, demonstrating the two divergent outcomes).","severity":"medium","snippet":"        if (_subs[subId].phase != Phase.Appealed) revert WrongPhase();","title":"askAppealSwarm plays no timers: an appeal past its 6 h ask deadline is still asked (documented rule broken, outcome depends on tx ordering)"},{"citation":"resolved","description":"The payout check requires the current lock to be running and the submission-time point to belong to the same epoch. The epoch only grows in `withdraw`. A lock whose term ended but was not withdrawn can be set again with `lock(0, 1)` (lines 62-87: `old = 0` because the lock ended, the contract comment says 'a lock set again starts afresh'), which creates a new 1-week lock under the SAME epoch. For a submission made under the old 52-week term, stakePower then returns the old full power although the only lock running at payout is a 1-week lock whose own power is 1/52 of it. Numerical example: 1,000 YOLO locked 52 weeks (power 1,000), submit for a task whose author reward is worth 1,000 YOLO -> stakeBps 400; the lock ends before the payout (dispute, appeal, swarm: up to several days); the doer re-sets `lock(0,1)` (power 19.23) one block before the payout and still receives the full 4% of R (and 4% of the YOLO add-on in YoloSweetener.payout, which reads the same Settlement.stakeBps). Without the re-set the same doer gets 0 (`l.end <= block.timestamp`). The rule 'the same lock still running' is thus satisfied by a different, much shorter lock. Bounded: the bonus never exceeds 4% of R and the doer still has the amount locked for a week, so low. Smallest fix: in `lock`, when the previous term ended (`l.end <= block.timestamp`), start a new epoch (`epoch = l.epoch + 1` for the stored Lock and the checkpoint), matching the comment 'starts afresh'; or in stakePower also require `p.end > block.timestamp`.","line":111,"path":"contracts/src/YoloLockHub.sol","reproduction":"Deploy YoloLockHub(yolo). doer: lock(1_000e18, 52) at T; at T+1 powerAt(doer, T+1) == 1_000e18 (= `since`). warp to T+1+52 weeks: stakePower(doer, since) == 0 (ended). doer: lock(0, 1). Now power(doer) == 1_000e18/52 but stakePower(doer, since) == 1_000e18. Expected: 0 (the lock that backed the submission ended; the running lock is a new 1-week term). Scratch test contracts/test/scratch/RelockKeepsPower.t.sol test_EndedLockRelockedOneWeek_KeepsOldStakePower passes on this code.","severity":"low","snippet":"        if (p.epoch != l.epoch || l.end <= block.timestamp || p.end <= since) return 0;","title":"YoloLockHub.stakePower pays the submission-time power of a lock that already ended and was merely re-set with a 1-week term (same epoch)"},{"citation":"resolved","description":"`lock(add, 0)` on a running lock computes `left` as ceil((end - now) / WEEK). With 1 second left the added amount gets `add * 1 / 52` power although it is locked for one second. No payout can be collected inside that second (stakePower needs the lock running at the payout, which is >= 24 h after any submission), so the only way to use it is to re-set the lock for >= 1 week, which is what `lock(add, 1)` would have cost anyway. Hardening only: compute `left` from `max(end - now, WEEK)` or round down with a 1-week floor. No loss.","line":79,"path":"contracts/src/YoloLockHub.sol","reproduction":"lock(1_000e18, 1) at T; warp T + 7 days - 1; lock(1_000e18, 0): the Locked event reports power 2 * 1_000e18 / 52 while the second 1,000 YOLO is withdrawable one second later. Scratch test contracts/test/scratch/RelockKeepsPower.t.sol test_SameSecondAdd_RoundsWeeksUp.","severity":"info","snippet":"        uint256 left = (end - block.timestamp + WEEK - 1) / WEEK; // whole weeks, rounded up","title":"YoloLockHub: weeks left round up, so YOLO added in the last second of a term earns a full week of power"}],"hash":"dc807c046c284bfbd5b60d3a3d0d4c1e666ad8b8861a050a1a01decd03eefe7c","nodeId":"de158b5c-d0e0-4bcd-8c90-eb7935ef6643","outcome":"completed","summary":"Review complete. The findings file holds three substantiated items (one medium, one low, one info); the full non-fork suite passes.\n\n**0. VERDICT.** Deployable for the math I audited. No critical or high. One medium: a filed appeal can still be asked after its documented 6 h deadline because `askAppealSwarm` plays no timers, so the outcome of that step depends on transaction ordering (`contracts/src/desks/CaseDesk.sol:274`). One low: a doer whose lock ended between submission and payout can re-set it for one week and still collect the stake bonus sized on the old term (`contracts/src/YoloLockHub.sol:111`). Every bucket split I traced (fee, bonds, voter shares, prediction pools, sweetener, vestings, tax) sums exactly, with dust assigned to a named last claimant or the treasury. Fix the medium before launch; the low is a one-line epoch bump.\n\n**1. User approvals.** Info, no issue. Every pull reads `from` as the vault-appended sender or `msg.sender`. `_fundCall` uses `a.appellant`, which `appeal` and `callSwarm` set to `_sender()` in the same call. No delegatecall, upgrade or arbitrary call exists.\n\n**2. Balance invariant.** Info, no issue. `liabilities` moves only on entry and exit. Settlement buckets, treasury pot, credits and swarm fees are moves between buckets. YoloPredict positions equal pools plus deferred plus held burn, and Void refunds exactly `paidIn`.\n\n**3. Stuck money.** Info, no issue. Failed pushes become credits, `withdrawTo` lets a blacklisted USDC recipient redirect, the 30,000-gas buyback send falls back to the burner. The last claimant of every remaining/remaining split takes the exact rest.\n\n**4. Reentrancy and routing.** Info, no issue in my area. Views are forwarded by staticcall without the lock, so the sweetener's callbacks into vault views during a locked claim work.\n\n**5. Access rights.** Info, no issue. `DevPrices._check` is inclusive at both bounds and reverts on zero. `ParamsBounds` keeps every division safe: `feeBps` of zero and `nftFeeBps == feeBps` give a zero stakers' part with no underflow.\n\n**6. Cancel.** Info, no issue. Refund buckets are disjoint: contributions, doer bond minus its own spent swarm fee, caller bond plus unspent funds, disputer bond, queued bonds, sweetener, predictions at 100%.\n\n**7. NFT vote by ids.** Info, no issue. Bitmap per id, `uint120` counts, per-payTo accounting sums to `votesAccept`, both sides on one payTo are kept apart.\n\n**8. Appeal and swarm.** Medium. After `appealedAt + 6 h` with no poke, `askAppealSwarm` still asks and spends the fee, while `poke` would void and refund. Reproduction and fix are in the findings file. Loss per case: the 0.5 IMD fee, and a different final outcome of the whole loser-bond split. Scratch test `contracts/test/scratch/LateAppealAsk.t.sol` shows both outcomes.\n\n**9. YoloPredict.** Info, no issue. `_points` uses two `mulDiv` floors and cannot overflow. Zero-point winners get their 90% and are excluded from the split. Empty winning side pays the pool out in `settle`. Double settle is guarded.\n\n**10. YoloLockHub.** Low. An ended lock re-set with `lock(0, 1)` keeps the same epoch, so `stakePower` returns the old 52-week power. Example: 1,000 YOLO locked 52 weeks, submit, lock ends, re-lock one week, payout pays the full 4% of R and of the add-on. Fix: bump the epoch when a lock that ended is set again. Info: weeks left round up, so an add one second before the end gets a week of power; no payout fits inside that second.\n\n**11. Roundings.** Info, no issue. Bonds floor and never exceed the base; `_payReward` gives the odd unit to the treasury as documented; sweetener and vault use the same cap; voter and disputer shares are remaining/remaining; the sell tax formula matches \"70 into the pool, 30 to the treasury\".\n\n**12. DoS and gas.** Info, no issue in my area. `_advance` is bounded to eight steps; `_pointBefore` is a binary search.\n\n**13. Token and side contracts.** Info, no issue. Tax boundaries at minute 30 and 60 are consistent with the d","treeHash":null,"usage":{"cachedInputTokens":3465182,"inputTokens":550,"model":"claude-fable-5-1","outputTokens":50609,"runtime":"claude","turns":52,"wallClockMs":1245082}}],"verification":[]}