{"jobId":"d8bb3d81-820d-42f4-ad7c-1d3ed96b1da8","repoUrl":"https://github.com/OoJae/help-wanted.git","baseCommit":"4124672e6121e7333917934e6da24fd433132d68","count":9,"submissions":[{"hash":"8612964a2d3c98718c65230d38489ba73e943d6a05de959c43d3fd616b2abf1e","nodeKey":"audit_judge","role":"review","attempt":1,"deviceKey":"8e25456c4ae69acffaaf5326cb2609f9712dc9b61c4ae0efc997f11379cd9860","seat":{"tokenId":"1183","agentId":"52609"},"outcome":"completed","accepted":true,"failureReason":null,"failureClass":null,"safetyRefusal":false,"usage":{"model":"claude-fable-5-1","turns":31,"runtime":"claude","inputTokens":354,"wallClockMs":802627,"outputTokens":51805,"cachedInputTokens":1936362},"baseCommit":"4124672e6121e7333917934e6da24fd433132d68","startCommit":"b908b8aaffa3a073d69777a5649687430bb22fa2","resultTreeHash":null,"bundleHash":null,"bundleBytes":0,"artifacts":[],"changedPaths":[],"allowedPaths":[],"dependencyBundleHashes":["a3c1faeeedd02315f22454162d4a234919e40bb220305a55d34c9390714a4f2d","3354c40562488e414c331c893c0283c4728f6a19a6c02eb6d9b06a3f0f382071","57661cdce0db1d1d55d5d6ff5f325ad7e7dbeff1207eb1e11bbb3a3223a682fd"],"verificationErrors":0,"summary":"The review is complete and written to `.imd-findings.json` with two findings and a full coverage record. Git reports no tracked changes, since the findings file is ignored and `test/scratch/` is mine.\n\n**What I kept from the panel**\n\n- **Low, returned tip capture.** When `abandon` closes a round, `_returnTip` adds the keeper tip back to the meter balance without minting shares. A funder who deposits while the round is pending, calls `abandon`, and defunds in one transaction takes almost the whole returned tip from the earlier funders, which on the house feeds is Payroll's own feed budget. I ran the specialist's proof on this tree and it fails exactly as stated. The attacker gets back 0.0467 IMD more than it deposited out of a 0.05 IMD tip. The loss is bounded by one tip per failed round, and forcing a failure costs more than the tip, so it stays low. The proof travels with the finding.\n- **Info, zero-share defund.** A holder calling `defund(feed, 0)` is refused with `NothingToDefund`, which contradicts the doc comment two lines above. Reproduced with a scratch test. No value at risk.\n\n**What I verified myself and found sound**\n\n- All 21 entry points traced and marked `holds`, except `abandon` and `defund`, which point at the two findings. One extra row covers the manifest and constructors.\n- The Intake's verified source on Sourcify confirms the `requests` struct layout the abandon path indexes, and that `complete` marks completion before the 200,000 gas callback.\n- All six mainnet dependency addresses hold code and answer as the design expects. IMD is a plain OpenZeppelin ERC20 with LayerZero bridging and no transfer hooks.\n- The manifest matches the constructors in order and count, uses only backward references, and the compiled sizes are well inside the protocol limits.\n\n**Two environment notes.** The full test suite cannot be built on this machine because the cgroup caps memory at 1.5 GB and the via-IR compile of 119 files is killed. The sources and my scratch tests compile fine with the heavy test directories skipped. I did not run the project's own suites, so the independent tester's clean result is not something I re-executed.","createdAt":"2026-10-13T10:00:27.337Z","verdict":null,"findings":[{"severity":"low","title":"A returned keeper tip is shared by whoever holds meter shares when the round is closed, so a funder arriving while the round is pending captures the tip earlier funders paid (Questions._returnTip mint","path":"src/questions/Questions.sol","line":1306,"description":"Merged from the economics specialist (THREATS.md Q5, disclosed and left open by the authors). `request` takes the feed's tip out of the meter balance without burning shares; when `abandon` closes the round, `_returnTip` adds the tip back to `m.balance` without minting shares. The tip is therefore distributed pro rata to whoever holds shares at closing time, not to the funders whose IMD paid it. Closable rounds are visible on chain (`canAbandon`), and `fund`, `abandon` and `defund` fit in one transaction, so a late funder with a deposit much larger than the meter takes almost the whole returned tip from the earlier funders. On the house feeds the earlier funder is Payroll's own position (its 20% feed budget) or the launch wallet. The leak is bounded by one feed tip (0.05 IMD) per failed round, and forcing a failure costs more than that (0.5 IMD per unserved request), so it is a bounded value transfer, not a drain. Reproduced on this tree with the attached proof; the authors' own fix options (mint shares for the returned tip to the round's funders, or credit the tip to the position that paid it) are the natural remedies if one is wanted.","reproduction":"House-feed values: price 0.5e18, tip 0.05e18. (1) funder: fund(feed, 7.7e18) -> balance 7.7e18, totalShares 7.7e18. (2) keeper: request(feed) -> balance 7.15e18, totalShares 7.7e18, held.tip 0.05e18. (3) no answer; warp to requestedAt + 12h + 86400 + 5min + 1s (the silence deadline). (4) attacker in one transaction: fund(feed, 100e18) -> shares = 100e18*7.7e18/7.15e18 = 107.6923e18, balance 107.15e18; abandon(feed) returns true and _returnTip makes balance 107.2e18 with totalShares unchanged; defund(feed, type(uint256).max). Expected: attacker receives exactly 100e18 and redeemable(feed, funder) is 7.2e18 (its 7.15e18 plus the whole returned tip). Actual (forge test on this tree, test/scratch/Proof_94a6f6b20d35.t.sol): attacker receives 100046663555762949136 (+0.04666 IMD, 93% of the tip) and redeemable(feed, funder) is 7153336444237050864.","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {Questions} from \"src/questions/Questions.sol\";\nimport {IIntake} from \"src/interfaces/external/IIntake.sol\";\n\ncontract ScratchIMD is ERC20 {\n    constructor() ERC20(\"IMD\", \"IMD\") {}\n    function mint(address to, uint256 amount) external { _mint(to, amount); }\n}\n\n/// @dev The least an Intake must do for `request`: sell one action, pull the price, hand back an id.\ncontract ScratchIntake {\n    uint256 public price;\n    uint256 public nonce;\n    mapping(bytes32 => bool) public done;\n    address immutable imd;\n    constructor(address imd_, uint256 price_) { imd = imd_; price = price_; }\n    function priceOf(bytes32, address) external view returns (uint256) { return price; }\n    function request(bytes32, bytes calldata, IIntake.Callback calldata, address asset, uint256 amount)\n        external payable returns (bytes32 id)\n    {\n        require(asset == imd && amount == price, \"price\");\n        IERC20(asset).transferFrom(msg.sender, address(this), amount);\n        id = keccak256(abi.encode(++nonce));\n    }\n    function requests(bytes32) external pure returns (address, address, bytes4, bool) {\n        return (address(0), address(0), bytes4(0), false);\n    }\n}\n\ncontract ReturnedTipCaptureTest is Test {\n    ScratchIMD imd;\n    ScratchIntake intake;\n    Questions q;\n    bytes32 feed;\n    address funder = makeAddr(\"funder\");\n    address keeper = makeAddr(\"keeper\");\n    address attacker = makeAddr(\"attacker\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        vm.roll(26_000_000);\n        imd = new ScratchIMD();\n        intake = new ScratchIntake(address(imd), 0.5e18);\n        q = new Questions(address(intake), address(imd), makeAddr(\"signer\"));\n\n        Questions.FeedParams memory p = Questions.FeedParams({\n            action: bytes32(\"oracle.request@oracle-1\"),\n            chainId: uint64(block.chainid),\n            answerType: 3,\n            panelSize: 7,\n            quorum: 5,\n            minAgreed: 5,\n            validFor: 86_400,\n            cadenceBlocks: 7_000,\n            maxWindowBlocks: 21_600,\n            lagBlocks: 12,\n            contiguous: true,\n            pinHash: true,\n            maxPrice: 2e18,\n            tip: 0.05e18,\n            gateCmp: 0,\n            gateTarget: address(0),\n            gateThreshold: 0,\n            gateData: \"\"\n        });\n        bytes memory prefix = bytes.concat(\n            '{\"answerType\":\"uint256\",\"chainId\":', bytes(vm.toString(block.chainid)),\n            ',\"question\":\"how much?\",\"v\":1,\"window\":{\"fromBlock\":'\n        );\n        bytes memory tail = ',\"panelSize\":7,\"quorum\":5,\"validForSeconds\":86400}';\n        feed = q.createFeed(p, prefix, tail);\n\n        imd.mint(funder, 7.7e18);\n        imd.mint(attacker, 100e18);\n        vm.startPrank(funder);\n        imd.approve(address(q), type(uint256).max);\n        q.fund(feed, 7.7e18);\n        vm.stopPrank();\n    }\n\n    function test_lateFunderTakesTheReturnedTip() public {\n        vm.prank(keeper);\n        q.request(feed);\n        (uint256 bal, uint256 shares) = q.meterOf(feed);\n        assertEq(bal, 7.15e18);\n        assertEq(shares, 7.7e18);\n\n        // Silence deadline of the house feeds: 12 h + validFor + 5 min + 1 s.\n        vm.warp(block.timestamp + 12 hours + 86_400 + 5 minutes + 1);\n\n        vm.startPrank(attacker);\n        imd.approve(address(q), type(uint256).max);\n        q.fund(feed, 100e18);\n        assertTrue(q.abandon(feed));\n        uint256 got = q.defund(feed, type(uint256).max);\n        vm.stopPrank();\n\n        emit log_named_uint(\"attacker got back\", got);\n        emit log_named_uint(\"funder redeemable\", q.redeemable(feed, funder));\n        // Expected: the attacker gets exactly its 100e18 back and the funder's position is 7.2e18.\n        assertEq(got, 100e18, \"late funder took part of the returned tip\");\n        assertEq(q.redeemable(feed, funder), 7.2e18, \"first funder lost part of the returned tip\");\n    }\n}","snippet":"        uint256 balance = uint256(m.balance) + h.tip;","citation":"resolved","blocking":false},{"severity":"info","title":"defund(feedId, 0) by a share holder reverts NothingToDefund, contrary to the doc comment that it reverts only when the caller holds no shares","path":"src/questions/Questions.sol","line":690,"description":"Merged from the math specialist. The doc comment at line 684 says `Reverts only when the caller holds no shares of this meter`, but the check runs on the clamped `shares` argument, so a holder that passes 0 is refused with the error that names a holder of nothing. No value is at risk and no state changes; an integrator that keys error handling on NothingToDefund, or sizes a partial withdrawal to a rounded zero, misreads the result. Either treat zero as a no-op returning 0 (as pay(0) and tip(.., 0) do elsewhere in the project) or correct the comment.","reproduction":"Questions with any existing feed F. alice: imd.approve(q, 1e18); q.fund(F, 1e18) -> sharesOf(F, alice) == 1e18. alice: q.defund(F, 0). Expected per the doc comment: not NothingToDefund (a no-op or a dedicated zero-amount error). Actual: reverts NothingToDefund(). Reproduced on this tree in test/scratch/DefundZero.t.sol (vm.expectRevert(Questions.NothingToDefund.selector) passes).","snippet":"        if (shares == 0) revert NothingToDefund();","citation":"resolved","blocking":false}],"coverage":{"rows":[{"target":"JobsReport.crown(uint64,bytes)","verdict":"holds","why":"Checks AlreadyCrowned, Answered status, keccak of handed bytes against the stored word, bounds-checked top-three decode; once per round, no funds."},{"target":"JobsReport.print()","verdict":"holds","why":"Consumes at most 8 rounds then 16 pairing steps; pending round breaks, Failed/overlap/unfinished/oversized rounds skipped; merge pairing settles each entry once; only view reads of Questions."},{"target":"Payroll.book()","verdict":"holds","why":"Books balance above owed(); books are written before every outgoing call in _fund so unbooked never counts owed IMD; 50/30/20 split sums exactly."},{"target":"Payroll.claimSeat(uint256)","verdict":"holds","why":"Seat bound by SEAT_COUNT, pays current ownerOf, contract owners must self-claim, seatPaid set before transfer, nonReentrant."},{"target":"Payroll.claimSeats(uint256[])","verdict":"holds","why":"Capped at 200 ids; unknown, paid, ownerless or escrowed seats skipped; seatPaid and seatClaimed written before the batched transfers."},{"target":"Payroll.claimTip(uint256)","verdict":"holds","why":"Adapter read with a gas floor, Seat binding and owner required, slot deleted and tipsClaimed updated before the transfer; a revert leaves the tip owed."},{"target":"Payroll.drip()","verdict":"holds","why":"Ten-minute gap, sends min(vested backlog, dripCap) only to a vault that answers as ERC-4626 over IMD with shares; dripped <= toStakers; lastDripAt only moves when IMD moves. P2/P3 are documented limits, not defects."},{"target":"Payroll.fundFeeds()","verdict":"holds","why":"Sizes top-ups from Payroll's own redeemable position under FEED_COST_CAP, books before the low-level fund call and corrects to what left, ages tranches and releases only what fell due; FeedOverdrawn guards an over-pull."},{"target":"Payroll.pay(uint256,bytes32)","verdict":"holds","why":"Zero is a no-op, amount booked is the balance difference, nonReentrant; IMD verified on mainnet as a plain OZ ERC20 (LayerZero OFT) with no transfer hooks."},{"target":"Payroll.sweepTip(uint256)","verdict":"holds","why":"Only the ripe tranche moves, only when the agent is unbound, bound elsewhere or bound to an ownerless seat; gas floor and OWNER_READ_MARGIN stop a starved read from faking that."},{"target":"Payroll.tip(uint256[],uint256)","verdict":"holds","why":"First 16 ids share the amount, remainder and empty lists go to the seat pool, tranche ageing keeps earlier tips' waits; tipsIn always covers tipsToSeats plus owed tips."},{"target":"Questions.abandon(bytes32)","verdict":"finding","findingRef":1,"why":"Deadline and completion logic hold (Close after requestedAt+12h+validFor+5min+1s, or 30 min after a completion first seen); _returnTip it calls shares the returned tip with whoever holds shares at closing (finding 1)."},{"target":"Questions.collectTips()","verdict":"holds","why":"Pays only the earned (low) half of the keeper's word, zeroes it and lowers totalTips before the transfer; pending tips untouched."},{"target":"Questions.createFeed((bytes32,uint64,uint8,uint16,uint16,uint16,uint32,uint32,uint32,uint16,bool,bool,uint96,uint96,uint8,address,uint256,bytes),bytes,bytes)","verdict":"holds","why":"Content-addressed id, idempotent, checkFeed enforces chain, panel, validFor, window, lag, price, tip <= maxPrice, gate shape, body length and that the tail states the enforced numbers; panel evidence flagged from the prefix."},{"target":"Questions.defund(bytes32,uint256)","verdict":"finding","findingRef":2,"why":"Clamps to the caller's shares, rounds the payout down, keeps totalShares >= balance; a holder passing 0 gets NothingToDefund contrary to the doc (finding 2, info)."},{"target":"Questions.fund(bytes32,uint256)","verdict":"holds","why":"Credits what arrived, retires shares when a meter was spent to zero, rescales before share counts can overflow, mints at current worth so shares never exceed one wei; the late-funder capture is reported under abandon (finding 1)."},{"target":"Questions.fundFor(bytes32,uint256,address)","verdict":"holds","why":"Same path as fund with the shares minted to a non-zero beneficiary and the payer named in FundedFor."},{"target":"Questions.onOracleResult(bytes32,(bytes32,uint256,bytes32,uint8,bytes,uint256,uint64,uint64,bytes32,bytes32,uint16,uint16,uint16,uint64,uint64),bytes)","verdict":"holds","why":"Only the Intake, only for a pending request of its own; signature in this contract's EIP-712 domain, window, question hash, answer type, panel, agreed and closing block hash checked; one attestation per feed; tip credited to the opener. Verified Intake v2 source: requests struct is (payer, target, s"},{"target":"Questions.request(bytes32)","verdict":"holds","why":"Readiness gate (exists, once per tx, not pending, cadence with backoff, window, WHEN gate, price under cap, meter covers price+tip), meter charged before the Intake call, exact approval checked spent, request id uniqueness and balance coverage checked after."},{"target":"Questions.reveal(bytes32,uint64,bytes)","verdict":"holds","why":"Emits only when the round is answered, the feed is list-typed and the bytes hash to the stored word; writes nothing."},{"target":"Questions.submit(bytes32,(bytes32,uint256,bytes32,uint8,bytes,uint256,uint64,uint64,bytes32,bytes32,uint16,uint16,uint16,uint64,uint64),bytes)","verdict":"holds","why":"Pinned chain-evidence feeds only, after the Intake completed the round or 30 min of silence; same _settle checks as the callback and the tip stays with the opener (Q3/Q4 documented)."},{"target":"launch manifest and constructors","verdict":"holds","why":"launch.json has only kind/contracts/notes, three unique names, 3/4/9 string args in declaration order, backward $contract references and no $owner; constructors make no external call; compiled runtime 22,233/9,320/11,846 bytes and initcode 23,642/9,618/12,560 bytes; all six mainnet addresses hold co"}],"summary":{"reported":true,"total":21,"answered":21,"ratio":1,"missing":[],"unreached":[]}}},{"hash":"6ad4ce2c949a6d169f150f1c5673e8da5e64166d912c3a099bd4e44dc819aea4","nodeKey":"manifest","role":"integrate","attempt":1,"deviceKey":"0eca5d6d7ad6ddb47e81e2e533a069b9ec3a436b27ba5c360a4dda3013256c32","seat":{"tokenId":"1842","agentId":"53911"},"outcome":"completed","accepted":true,"failureReason":null,"failureClass":null,"safetyRefusal":false,"usage":{"model":"gpt-6-astra","turns":5,"runtime":"codex","inputTokens":47473,"wallClockMs":256131,"outputTokens":3600,"cachedInputTokens":571520},"baseCommit":"4124672e6121e7333917934e6da24fd433132d68","startCommit":"8106a38cfad82c7e8ab15b991cb7912b9ce1eb5a","resultTreeHash":"a068025a6b59a5124fe463933667c6183dbb4d5e","bundleHash":"57661cdce0db1d1d55d5d6ff5f325ad7e7dbeff1207eb1e11bbb3a3223a682fd","bundleBytes":21459,"artifacts":[],"changedPaths":["launch.json"],"allowedPaths":["launch.json"],"dependencyBundleHashes":["a3c1faeeedd02315f22454162d4a234919e40bb220305a55d34c9390714a4f2d"],"verificationErrors":0,"summary":"Created [launch.json](/home/imd/.identitymd-5/work/d8bb3d81-820d-42f4-ad7c-1d3ed96b1da8/f0e2a42a-75f5-438f-ae02-a0e7add12560/launch.json) exactly as requested, with constructor arguments verified against the compiled ABIs.\n\n- `forge build`: passed.\n- `forge test`: 502 passed, 0 failed.\n- Only `launch.json` changed.","createdAt":"2026-10-13T09:41:39.209Z","verdict":{"status":"accepted","profile":"foundry","evaluation":"checks","rejectionCode":null,"detail":"all checks passed","verifierVersion":"0.1.0+327e1e9f","verifiedTreeHash":"a068025a6b59a5124fe463933667c6183dbb4d5e","at":"2026-10-13T09:46:52.946Z","failedChecks":[]},"findings":[],"coverage":null},{"hash":"23f94c1d2b9ab402d97b19f8d2cb96d69a0985e716672c1b597f7065018ed8f9","nodeKey":"audit_economics","role":"review","attempt":1,"deviceKey":"03332db135fe4c1539a25ad6b17b0abd0bf44f55a2b7638a3d7afa1e63bbc790","seat":{"tokenId":"656","agentId":"52678"},"outcome":"completed","accepted":true,"failureReason":null,"failureClass":null,"safetyRefusal":false,"usage":{"model":"claude-fable-5-1","turns":31,"runtime":"claude","inputTokens":482,"wallClockMs":1284822,"outputTokens":41904,"cachedInputTokens":2596204},"baseCommit":"4124672e6121e7333917934e6da24fd433132d68","startCommit":"8106a38cfad82c7e8ab15b991cb7912b9ce1eb5a","resultTreeHash":null,"bundleHash":null,"bundleBytes":0,"artifacts":[],"changedPaths":[],"allowedPaths":[],"dependencyBundleHashes":["a3c1faeeedd02315f22454162d4a234919e40bb220305a55d34c9390714a4f2d"],"verificationErrors":0,"summary":"The review is complete and the findings file is in place. Nothing in the repository was modified apart from the findings file and the scratch test, which the harness discards.\n\n**Result: one low finding, no critical, high or medium defects in the economics.**\n\nThe one substantiated defect is in how Questions gives a failed round's keeper tip back to the meter. The tip is added to the meter balance without minting shares, so it is shared by whoever holds shares at closing time, not by the funders whose IMD paid it. Anyone who sees a round become closable can fund, abandon and defund in one transaction and take most of the tip. The scratch test confirms the numbers on this tree:\n\n| Party | Expected | Actual |\n| --- | --- | --- |\n| Late funder's defund return | 100 IMD | 100.04666 IMD |\n| First funder's redeemable | 7.2 IMD | 7.15334 IMD |\n\nThe gain is bounded by one tip per failed round, and forcing a failure costs more than the tip, so I rated it low. The authors document it as THREATS.md Q5 and left it unfixed. I reported it so the judge has the exact sequence and can decide.\n\n**What I covered in depth.** All 22 entry points have a coverage row. I traced the Payroll ledger conservation laws through every writer, the stream re-levelling and drip cap math including the \"cannot be rushed\" bound, the fortnight ageing of the feed budget and the audit 1.1 fix ordering, the tip ageing tranches across one and two period gaps, the Questions meter share accounting including rescale and epoch retirement, and the tip escrow path through request, settle, abandon and collect. I confirmed against mainnet that the seat token has exactly 2000 ids 0 to 1999. The project's own Payroll invariant suite passed in this review at reduced run counts.\n\n**What I did not reach.** I did not run the fork suite or the full Questions invariant suite, since the full via-IR test build exceeds the time budget. I did not review the Jobs Report beyond confirming it moves no value, and I did not examine the memory-safety claim in the Payroll binding read, which is outside my area.","createdAt":"2026-10-13T09:30:06.877Z","verdict":null,"findings":[{"severity":"low","title":"A returned keeper tip is captured by whoever funds the meter while the round is pending (Questions._returnTip mints no shares)","path":"src/questions/Questions.sol","line":1306,"description":"Economic Security / Invariant: when `abandon` closes a round, the tip that was taken from the meter at `request` is added back to `m.balance` without minting shares, so it is shared pro rata by whoever holds meter shares at closing time, not by the funders whose IMD paid it. Anyone can see a round that is about to be closable (the 30-minute wait after `CompletionSeen`, or the 36 h 5 min 1 s silence deadline of the house feeds), fund a large amount with `fund`, call `abandon`, and `defund` in the same transaction, taking almost the whole returned tip from the earlier funders. On the house feeds the earlier funder is Payroll (its `fundFeeds` position) or the launch wallet, so the loss comes out of the 20% feed budget. The gain is bounded by one feed tip (0.05 IMD) per failed round and the attacker cannot profitably force a failure (an unserved request costs 0.5 IMD), so this is a bounded leak, not a drain. The authors document it as THREATS.md Q5 and left it; it is recorded here so the judge has the concrete numbers and can decide whether a fix (minting shares for the returned tip to the round's funders, or crediting the tip to the position that paid it) is wanted.","reproduction":"Feed: price 0.5e18, tip 0.05e18 (house feed values). 1) Payroll/launch wallet `fund(feed, 7.7e18)`: balance 7.7e18, totalShares 7.7e18. 2) Keeper `request(feed)`: balance 7.15e18 (price+tip leave), totalShares 7.7e18, held.tip 0.05e18. 3) No answer. At requestedAt + 36h5m1s (or 30 min after a `CompletionSeen`), attacker in one tx: `fund(feed, 100e18)` -> shares = 100e18*7.7e18/7.15e18 = 107.6923e18, totalShares 115.3923e18, balance 107.15e18; `abandon(feed)` -> `_returnTip`: balance 107.2e18, totalShares unchanged; `defund(feed, type(uint256).max)` -> 107.6923e18*107.2e18/115.3923e18 = 100.0466e18. Expected: attacker gets back exactly 100e18 and the 0.05e18 tip returns to the position that paid it (7.2e18 redeemable for the first funder). Actual (test/scratch/ReturnedTipCapture.t.sol, run on this tree): attacker's `defund` returns 100046663555762949136 (+0.04666 IMD, 93% of the 0.05 IMD tip) and the first funder's `redeemable` is 7153336444237050864 instead of 7.2e18.","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {Questions} from \"src/questions/Questions.sol\";\nimport {IIntake} from \"src/interfaces/external/IIntake.sol\";\n\ncontract ScratchIMD is ERC20 {\n    constructor() ERC20(\"IMD\", \"IMD\") {}\n    function mint(address to, uint256 amount) external { _mint(to, amount); }\n}\n\n/// @dev The least an Intake must do for `request`: sell one action, pull the price, hand back an id.\ncontract ScratchIntake {\n    uint256 public price;\n    uint256 public nonce;\n    mapping(bytes32 => bool) public done;\n    address immutable imd;\n    constructor(address imd_, uint256 price_) { imd = imd_; price = price_; }\n    function priceOf(bytes32, address) external view returns (uint256) { return price; }\n    function request(bytes32, bytes calldata, IIntake.Callback calldata, address asset, uint256 amount)\n        external payable returns (bytes32 id)\n    {\n        require(asset == imd && amount == price, \"price\");\n        IERC20(asset).transferFrom(msg.sender, address(this), amount);\n        id = keccak256(abi.encode(++nonce));\n    }\n    function requests(bytes32) external pure returns (address, address, bytes4, bool) {\n        return (address(0), address(0), bytes4(0), false);\n    }\n}\n\ncontract ReturnedTipCaptureTest is Test {\n    ScratchIMD imd;\n    ScratchIntake intake;\n    Questions q;\n    bytes32 feed;\n    address funder = makeAddr(\"funder\");\n    address keeper = makeAddr(\"keeper\");\n    address attacker = makeAddr(\"attacker\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        vm.roll(26_000_000);\n        imd = new ScratchIMD();\n        intake = new ScratchIntake(address(imd), 0.5e18);\n        q = new Questions(address(intake), address(imd), makeAddr(\"signer\"));\n\n        Questions.FeedParams memory p = Questions.FeedParams({\n            action: bytes32(\"oracle.request@oracle-1\"),\n            chainId: uint64(block.chainid),\n            answerType: 3,\n            panelSize: 7,\n            quorum: 5,\n            minAgreed: 5,\n            validFor: 86_400,\n            cadenceBlocks: 7_000,\n            maxWindowBlocks: 21_600,\n            lagBlocks: 12,\n            contiguous: true,\n            pinHash: true,\n            maxPrice: 2e18,\n            tip: 0.05e18,\n            gateCmp: 0,\n            gateTarget: address(0),\n            gateThreshold: 0,\n            gateData: \"\"\n        });\n        bytes memory prefix = bytes.concat(\n            '{\"answerType\":\"uint256\",\"chainId\":', bytes(vm.toString(block.chainid)),\n            ',\"question\":\"how much?\",\"v\":1,\"window\":{\"fromBlock\":'\n        );\n        bytes memory tail = ',\"panelSize\":7,\"quorum\":5,\"validForSeconds\":86400}';\n        feed = q.createFeed(p, prefix, tail);\n\n        imd.mint(funder, 7.7e18);\n        imd.mint(attacker, 100e18);\n        vm.startPrank(funder);\n        imd.approve(address(q), type(uint256).max);\n        q.fund(feed, 7.7e18);\n        vm.stopPrank();\n    }\n\n    function test_lateFunderTakesTheReturnedTip() public {\n        vm.prank(keeper);\n        q.request(feed);\n        (uint256 bal, uint256 shares) = q.meterOf(feed);\n        assertEq(bal, 7.15e18);\n        assertEq(shares, 7.7e18);\n\n        // Silence deadline of the house feeds: 12 h + validFor + 5 min + 1 s.\n        vm.warp(block.timestamp + 12 hours + 86_400 + 5 minutes + 1);\n\n        vm.startPrank(attacker);\n        imd.approve(address(q), type(uint256).max);\n        q.fund(feed, 100e18);\n        assertTrue(q.abandon(feed));\n        uint256 got = q.defund(feed, type(uint256).max);\n        vm.stopPrank();\n\n        emit log_named_uint(\"attacker got back\", got);\n        emit log_named_uint(\"funder redeemable\", q.redeemable(feed, funder));\n        // Expected: the attacker gets exactly its 100e18 back and the funder's position is 7.2e18.\n        assertEq(got, 100e18, \"late funder took part of the returned tip\");\n        assertEq(q.redeemable(feed, funder), 7.2e18, \"first funder lost part of the returned tip\");\n    }\n}","snippet":"        uint256 balance = uint256(m.balance) + h.tip;\n        m.balance = balance.toUint128();\n        if (m.totalShares < balance) m.totalShares = balance;\n        totalMetered += h.tip;","citation":"resolved","blocking":false}],"coverage":{"rows":[{"target":"Payroll.pay(uint256,bytes32)","verdict":"holds","why":"Books what actually arrived (balance delta); 30/20 shares round down and stakers get the remainder, so received == toStakers+toSeats+toFeeds; ages the feed budget first; zero amount is a no-op."},{"target":"Payroll.book()","verdict":"holds","why":"Books only balance above owed().total; the same split as pay; recycles Payroll's own seat claims and tips; cannot book owed money twice because owed() is derived from the ledger."},{"target":"Payroll.tip(uint256[],uint256)","verdict":"holds","why":"tipsIn - tipsToSeats - tipsClaimed equals the sum of all TipSlots after every path (equal split, remainder and empty list to the seat pool); per-agent share capped by supply; only first 16 ids."},{"target":"Payroll.drip()","verdict":"holds","why":"dripped <= toStakers - unvested always; sent = min(vested backlog, dripCap) with cap >= 1 whenever anything is unvested; rate after a booking never exceeds old rate + added/7d (checked algebraically); vault readiness checked before transfer; 10-minute gap only starts when IMD moves."},{"target":"Payroll.claimSeat(uint256)","verdict":"holds","why":"seatPaid[id] = seatAccrued before transfer; seatAccrued*SEAT_COUNT+seatCarry == toSeats+tipsToSeats; mainnet seat token verified to have 2000 ids 0..1999; contract owners must self-claim; no revert path strands the share."},{"target":"Payroll.claimSeats(uint256[])","verdict":"holds","why":"Same accounting as claimSeat per id; skips (not reverts) on unknown, unpaid, unreadable or contract-owned seats; run-merging pays the same totals as individual claims; bounded at 200 ids."},{"target":"Payroll.claimTip(uint256)","verdict":"holds","why":"Pays the full slot to the current owner of the bound seat and deletes the slot before transfer; binding read with fixed gas so a caller cannot make a bound agent look unbound; Adapter trust is documented, not a defect."},{"target":"Payroll.sweepTip(uint256)","verdict":"holds","why":"Only the ripe tranche (waited 180-360 days) moves to the seat pool, and only when the tip is not claimable now; ageing traced across one and two period gaps, including interleaved new tips; tipsToSeats updated in step."},{"target":"Payroll.fundFeeds()","verdict":"holds","why":"Offers at most budget, A/B pro rata to shortfall, C from the rest; books feedsFunded before calling Questions and corrects to what actually left; ages then releases only what is due after the meters were served (audit 1.1 fix verified); tranche order equals aged order."},{"target":"Questions.createFeed(...)","verdict":"holds","why":"Outside my area beyond economics: tip <= maxPrice and maxPrice != 0 enforced so a feed cannot pay a keeper more than it pays for an answer; content-addressed id leaves nothing to take over."},{"target":"Questions.fund(bytes32,uint256)","verdict":"holds","why":"Shares minted at current worth from the balance delta; totalShares >= balance so a share is never worth more than 1 wei and ZeroShares cannot hit a real deposit; epoch bump retires worthless shares; rescale keeps every holder within 1 wei."},{"target":"Questions.fundFor(bytes32,uint256,address)","verdict":"holds","why":"Same path as fund with the shares credited to the beneficiary; zero beneficiary refused."},{"target":"Questions.defund(bytes32,uint256)","verdict":"holds","why":"amount = floor(shares*balance/total) rounds for the pool; round-trip fund->defund never returns more than deposited; totalMetered and balance move together; proven balance/total ratio never exceeds 1 after a partial defund."},{"target":"Questions.request(bytes32)","verdict":"finding","findingRef":1,"why":"Charging and tip escrow are exact (PriceNotTaken and MetersNotCovered guards hold), but the tip set aside here returns to the meter's share pool on failure, which is what finding 1 exploits."},{"target":"Questions.onOracleResult(bytes32,(...),bytes)","verdict":"holds","why":"Intake-only, pending-round-only, per-feed replay mark, tip moves from pending to earned for the recorded opener only; totalTips unchanged; no value moves on the answer."},{"target":"Questions.submit(bytes32,(...),bytes)","verdict":"holds","why":"Same settle path; submitter earns nothing, opener keeps the tip (audit fix verified); open only for pinned chain-evidence feeds after completion or 30 minutes."},{"target":"Questions.abandon(bytes32)","verdict":"finding","findingRef":1,"why":"Deadlines and the two-step completion wait are correct, but the tip return in _returnTip is shared by current share holders rather than the funders who paid it (finding 1)."},{"target":"Questions.collectTips()","verdict":"holds","why":"Pays only the earned low half, decrements totalTips, cannot touch tips pending for open rounds."},{"target":"Questions.reveal(bytes32,uint64,bytes)","verdict":"holds","why":"No state change and no value; only emits a list that hashes to the stored word."},{"target":"JobsReport.print()","verdict":"holds","why":"No money; totals only grow by figures <= 2^128-1 from non-overlapping answered rounds; pairing is deterministic and bounded; nothing in my area touches it beyond that."},{"target":"JobsReport.crown(uint64,bytes)","verdict":"holds","why":"No money; only the attested bytes (hash equals the stored word) can be crowned, once per round."},{"target":"Invariant: Payroll IMD balance >= owed().total and received == toStakers+toSeats+toFeeds","verdict":"holds","why":"Traced through every writer (pay, book, tip, drip, claims, sweep, fundFeeds release and correction); the project's PayrollInvariant suite also passed in this review (16 runs x 32 depth)."},{"target":"Invariant: Questions IMD balance >= totalMetered + totalTips; totalShares >= meter balance","verdict":"finding","findingRef":1,"why":"Both hold on every path; _returnTip keeps the second by raising totalShares, but the kept invariant is what lets a late funder take the returned tip (finding 1)."}],"summary":{"reported":true,"total":21,"answered":21,"ratio":1,"missing":[],"unreached":[]}}},{"hash":"5029c2676782267de6d24e23d69a1a1145fecb284b26c4c58cda19c2156ceda2","nodeKey":"write_foundry_tests","role":"tests","attempt":1,"deviceKey":"f7cb61cceaa27e78aa82538248415a1cb680a2779be4027934618ce62b416af7","seat":{"tokenId":"285","agentId":"51380"},"outcome":"completed","accepted":true,"failureReason":null,"failureClass":null,"safetyRefusal":false,"usage":{"model":"gpt-6-astra","turns":9,"runtime":"codex","inputTokens":201831,"wallClockMs":1432087,"outputTokens":31607,"cachedInputTokens":4312192},"baseCommit":"4124672e6121e7333917934e6da24fd433132d68","startCommit":"8106a38cfad82c7e8ab15b991cb7912b9ce1eb5a","resultTreeHash":"8421ffdec17da6b673eb907553b41b29cc43c88c","bundleHash":"3354c40562488e414c331c893c0283c4728f6a19a6c02eb6d9b06a3f0f382071","bundleBytes":44493,"artifacts":[],"changedPaths":["test/launch/LaunchStateSetup.sol","test/launch/LaunchTransferFailures.t.sol","test/launch/invariant/LaunchLifecycleHandler.sol","test/launch/invariant/LaunchLifecycleInvariant.t.sol","test/launch/invariant/README.md"],"allowedPaths":["test","test/**"],"dependencyBundleHashes":["a3c1faeeedd02315f22454162d4a234919e40bb220305a55d34c9390714a4f2d"],"verificationErrors":0,"summary":"Added 11 failure-path and reentrancy tests, plus cross-contract lifecycle invariants covering 24,576 randomized calls.\n\n`forge build` passed. `forge test`: **515 passed, 0 failed**.\n\nAll changes are under `test/`.","createdAt":"2026-10-13T09:15:55.676Z","verdict":{"status":"accepted","profile":"foundry","evaluation":"checks","rejectionCode":null,"detail":"all checks passed","verifierVersion":"0.1.0+327e1e9f","verifiedTreeHash":"8421ffdec17da6b673eb907553b41b29cc43c88c","at":"2026-10-13T09:21:31.117Z","failedChecks":[]},"findings":[],"coverage":null},{"hash":"bf21e8a90ca6650698348328c48efcf40b653ef061c11bbdb3ca7c952942db41","nodeKey":"audit_math","role":"review","attempt":1,"deviceKey":"6208734cdf5317a188e5c6dc2af68514fe66d13f7620146df9d349eb7e0db04f","seat":{"tokenId":"559","agentId":"51168"},"outcome":"completed","accepted":true,"failureReason":null,"failureClass":null,"safetyRefusal":false,"usage":{"model":"claude-fable-5-1","turns":29,"runtime":"claude","inputTokens":548,"wallClockMs":1091411,"outputTokens":48349,"cachedInputTokens":2951839},"baseCommit":"4124672e6121e7333917934e6da24fd433132d68","startCommit":"8106a38cfad82c7e8ab15b991cb7912b9ce1eb5a","resultTreeHash":null,"bundleHash":null,"bundleBytes":0,"artifacts":[],"changedPaths":[],"allowedPaths":[],"dependencyBundleHashes":["a3c1faeeedd02315f22454162d4a234919e40bb220305a55d34c9390714a4f2d"],"verificationErrors":0,"summary":"The review is complete and the findings file is in place. Nothing I need next; here is the recap.\n\n**Outcome.** The math in the assigned area holds. I found no reproducible precision, boundary or numerical-gap defect that moves value, and one informational boundary mismatch. The findings file holds that one item plus a coverage row for every listed entry point.\n\n**What I checked, against the three guides**\n\n- **Meter share pool** in Questions. Deposits round shares down, withdrawals round assets down, and the rescale loop keeps `totalShares ≥ balance` so no share is ever worth more than one wei. I traced the sum bound (`total + shares < 2^256` after a rescale) and confirmed the ZeroShares revert is unreachable for any non-zero deposit. A scratch fuzz ran fund, request, defund-while-pending, refill-from-zero, abandon with tip return, and defund-all, and the invariants held every run.\n- **Payroll top-up sizing.** The `lacking > 1` threshold in the shortfall is exact. A top-up through the share pool leaves Payroll's position at most one wei short, so a second call places nothing. Fuzzed over costs from 1 wei to 5 IMD with partial spends in between.\n- **Stream re-levelling and drip cap.** The weighted-average end never moves earlier than the old end and dust bookings move it by exactly nothing while at least 604,800 wei are unvested. The cap never falls below 1/168 of what is outstanding, so a backlog is always drainable. Fuzzed.\n- **Feed budget ageing.** The tranche invariant `due + old ≤ budget` survives spending, the post-call correction, and the release.\n- **Tip tranches, seat pool carry, the 50/30/20 split, JobsReport pairing and the bytes decoder in `crown`.** All divisions have non-zero divisors by construction, every narrowing cast is bounded first, and no intermediate product can overflow at IMD scale.\n\n**The one finding (info).** A holder calling `defund(feedId, 0)` is refused with `NothingToDefund`, while the doc comment says that error is only for callers who hold no shares. No value is at risk. The reproduction is in the scratch test and recorded in the file.\n\n**Static-analysis leads.** The flagged divide-before-multiply in `tip` is the intended remainder. The weak-PRNG line is a modulo for bookkeeping. The strict equalities compare amounts with zero or period counters, not token balances. None reproduced as a defect.\n\n**Verification.** The project's own math-related suites, 304 tests across Payroll, Questions and JobsReport, pass on this tree. The fork tests were not run because there is no network. No tracked file was modified; only `.imd-findings.json` and `test/scratch/MathEdges.t.sol` were written.","createdAt":"2026-10-13T09:07:41.647Z","verdict":null,"findings":[{"severity":"info","title":"defund(feedId, 0) by a share holder reverts NothingToDefund, contrary to the doc comment","path":"src/questions/Questions.sol","line":690,"description":"Zero-input boundary (Boundary guide, step 2.3). The doc comment two lines above says `Reverts only when the caller holds no shares of this meter`, but the check runs on the clamped `shares` argument, so a holder who passes 0 is refused with the error that names a holder of nothing. No value is at risk: the call reverts, no state changes. A caller that computes a share count of zero (an integrator sizing a partial withdrawal by a rounded figure) cannot distinguish `I asked for nothing` from `I own nothing`, and an error-handling branch keyed on NothingToDefund misreads it. Either accept zero as a no-op returning 0 (consistent with `pay(0)` and `tip(.., 0)` elsewhere in the project, which return instead of reverting), or correct the comment to say that a zero request is refused too.","reproduction":"Questions q with any existing feed id F. alice: imd.approve(q, 1e18); q.fund(F, 1e18) -> sharesOf(F, alice) == 1e18. alice: q.defund(F, 0). Expected (per the doc comment at line 684): a holder is never refused with NothingToDefund; a no-op or a documented ZeroAmount. Actual: reverts NothingToDefund(). Reproduced in test/scratch/MathEdges.t.sol, contract DefundZeroScratch, test_defund_zeroSharesByAHolderReverts (passes on this tree, i.e. the revert happens).","snippet":"        if (shares == 0) revert NothingToDefund();","citation":"resolved","blocking":false}],"coverage":{"rows":[{"target":"JobsReport.crown(uint64,bytes)","verdict":"holds","why":"Bytes decoder bounds every slice (offset <= len-32, length <= (len-start)/32, word <= uint160.max); keccak check binds the list; no arithmetic can overflow (uint64 round, uint8 places)."},{"target":"JobsReport.print()","verdict":"holds","why":"Figures > uint128.max are skipped so `total`/`totalCount` cannot overflow; day test `to-from < 7500` matches 'at most 7,500 blocks'; _mostlyShared uses (shared*2 > longer) on uint64-range values; merge pairing cannot double-attach (shared > half of longer excludes a second match). Note: a payrolls b"},{"target":"Payroll.book()","verdict":"holds","why":"_unbooked = saturatingSub(balance, owed); split 30/20 rounds down, stakers take the remainder, so received == toStakers+toSeats+toFeeds exactly (fuzzed in scratch for 1..65535 wei)."},{"target":"Payroll.claimSeat(uint256)","verdict":"holds","why":"paid = seatAccrued - seatPaid[id] cannot underflow (seatPaid only ever set to a past seatAccrued); seatAccrued*SEAT_COUNT + seatCarry == pool credited, so sum of claims never exceeds toSeats+tipsToSeats."},{"target":"Payroll.claimSeats(uint256[])","verdict":"holds","why":"Same arithmetic as claimSeat per id; runs/amounts arrays sized n; skipped ids leave seatPaid untouched; total paid added to seatClaimed once."},{"target":"Payroll.claimTip(uint256)","verdict":"holds","why":"Pays fresh+old+ripe as uint256 sum of three uint128 (no overflow); slot deleted so `period==0 iff nothing owed` invariant kept for _creditTip's fast path."},{"target":"Payroll.drip()","verdict":"holds","why":"_unvestedNow divisor end-cursor > 0 whenever unvested != 0 (cursor <= now < end); vested rounds down; sent = min(outstanding-unvested, dripCap) with outstanding >= unvested maintained by _levelStream/drip; cap >= ceil(outstanding/168) at every re-level so a backlog is always drainable (fuzzed in scr"},{"target":"Payroll.fundFeeds()","verdict":"holds","why":"Proportional split budget*give/needAB rounds down (sum <= budget); giveC capped at budget-needAB; tranche invariant due+old <= budget kept by _spendTranches and the post-call correction; _shortfall's `lacking > 1` threshold is exact: a top-up through the meter's share pool (price <= 1 wei/share) lea"},{"target":"Payroll.pay(uint256,bytes32)","verdict":"holds","why":"Books the balance difference, zero is a no-op; amount*3000 and amount*2000 cannot overflow for any IMD amount."},{"target":"Payroll.sweepTip(uint256)","verdict":"holds","why":"_agedTip moves tranches by period difference (==1 vs >=2) with uint128 adds bounded by supply; swept = ripe only; _nextRipeAt (period+1|2)*180d cannot overflow; slot deleted when empty else rewritten with current period."},{"target":"Payroll.tip(uint256[],uint256)","verdict":"holds","why":"n = min(len,16); perAgent = got/n; remainder got - perAgent*n (the flagged divide-before-multiply is the intended remainder); every wei lands in tips or the seat pool; toUint128 on perAgent is safe."},{"target":"Questions.abandon(bytes32)","verdict":"holds","why":"closableAt = requestedAt + 12h + validFor + 5min + 1 (inclusive expiry), min'd with seenAt+30min only when completed; failStreak saturates at 255; _returnTip adds tip to balance (uint128 safe) and lifts totalShares to balance so no share ever exceeds 1 wei."},{"target":"Questions.collectTips()","verdict":"holds","why":"Low 128 bits paid, word reduced by exactly that, totalTips reduced; the high (pending) half is untouched."},{"target":"Questions.createFeed((bytes32,uint64,uint8,uint16,uint16,uint16,uint32,uint32,uint32,uint16,bool,bool,uint96,uint96,uint8,address,uint256,bytes),bytes,bytes)","verdict":"holds","why":"All range checks are inclusive where the protocol limits are (panel 2..300, quorum 2..panel, minAgreed 1..quorum, validFor 60..30d, lag 5..256, tip <= maxPrice, contiguous needs maxWindow > cadence); body length bound uses 52-byte worst-case window text."},{"target":"Questions.defund(bytes32,uint256)","verdict":"finding","findingRef":1,"why":"Arithmetic holds: amount = mulDiv(shares, balance, totalShares) rounds down and shares <= totalShares keeps totalShares >= balance afterwards (fuzzed in scratch); only the zero-input boundary contradicts its doc comment (info)."},{"target":"Questions.fund(bytes32,uint256)","verdict":"holds","why":"shares = mulDiv(received, total, balance) >= received because total >= balance; rescale loop shifts total by 64 bits while total>>128 >= balance so total+shares < 2^256 and total stays >= balance; epoch bump retires claims on an empty meter; ZeroShares is unreachable for received >= 1."},{"target":"Questions.fundFor(bytes32,uint256,address)","verdict":"holds","why":"Same path as fund with a non-zero beneficiary; stale-epoch or rescaled stakes are folded in through _sharesOf before the add."},{"target":"Questions.onOracleResult(bytes32,(bytes32,uint256,bytes32,uint8,bytes,uint256,uint64,uint64,bytes32,bytes32,uint16,uint16,uint16,uint64,uint64),bytes)","verdict":"holds","why":"Numeric checks: panelSize/quorum >= feed's, agreed >= (quorum or minAgreed), chainId equality, window equality, 32-byte scalar answers with bool <= 1 and address <= uint160.max; tip moves from the high to the low half of one word without overflow."},{"target":"Questions.request(bytes32)","verdict":"holds","why":"price <= maxPrice (uint96) is checked before price+tip, so cost.toUint128 cannot revert and balance >= cost is guaranteed by _readiness; window arithmetic never yields to == 0 for a real window; cadence << doublings bounded at 2^5; allowance and balance >= totalMetered+totalTips are re-checked after"},{"target":"Questions.reveal(bytes32,uint64,bytes)","verdict":"holds","why":"Pure comparison of keccak(answer) to the stored word for list types; no arithmetic."},{"target":"Questions.submit(bytes32,(bytes32,uint256,bytes32,uint8,bytes,uint256,uint64,uint64,bytes32,bytes32,uint16,uint16,uint16,uint64,uint64),bytes)","verdict":"holds","why":"Opens at requestedAt + 30min (>=) or on Intake completion; then the same _settle numeric checks as the callback."},{"target":"invariant: Questions meter totalShares >= balance, sum(redeemable) <= balance, IMD balance >= totalMetered + totalTips","verdict":"holds","why":"Held across fund/request/defund-while-pending/refill-from-zero/abandon(tip return)/defund-all in the scratch fuzz (64 runs) and the project's own QuestionsInvariant suite."},{"target":"invariant: Payroll received == toStakers+toSeats+toFeeds; balance >= owed(); seatAccrued*SEAT_COUNT+seatCarry == seat pool credited; allowance on Questions == 0 after fundFeeds","verdict":"holds","why":"Checked by assertBooksBalance in the scratch fuzz and the project's PayrollFuzz/PayrollInvariant suites (304 tests pass on this tree)."},{"target":"coverage note","verdict":"holds","why":"Guides applied: Math Precision (rounding directions, zero-rounding, overflow intermediates, downcasts), Boundary (every external call site's no-code/short-return/zero/max cases, bytes decoders in crown and _valueOf), Numerical Gap (shortfall threshold vs share-pool rounding, tranche ageing vs spendi"}],"summary":{"reported":true,"total":21,"answered":21,"ratio":1,"missing":[],"unreached":[]}}},{"hash":"ea37fb9ab4e888c9b83f64857a88c7c24411f4462b97cbab33a1e11715d4945e","nodeKey":"audit_permissions","role":"review","attempt":1,"deviceKey":"d0653dc91b6e2259689c48678a76069799bcf9fc4239f5491a775162e81c2f6e","seat":{"tokenId":"795","agentId":"51566"},"outcome":"completed","accepted":true,"failureReason":null,"failureClass":null,"safetyRefusal":false,"usage":{"model":"claude-fable-5-1","turns":30,"runtime":"claude","inputTokens":450,"wallClockMs":992427,"outputTokens":35449,"cachedInputTokens":2177213},"baseCommit":"4124672e6121e7333917934e6da24fd433132d68","startCommit":"8106a38cfad82c7e8ab15b991cb7912b9ce1eb5a","resultTreeHash":null,"bundleHash":null,"bundleBytes":0,"artifacts":[],"changedPaths":[],"allowedPaths":[],"dependencyBundleHashes":["a3c1faeeedd02315f22454162d4a234919e40bb220305a55d34c9390714a4f2d"],"verificationErrors":0,"summary":"The review is complete and `.imd-findings.json` is written with an empty findings list and a full coverage record. No tracked file was changed.\n\n**Result: no defect found in the assigned area.** I checked the Access Control, Trust Gap and Asymmetry failure modes against all three contracts and the manifest, and every one of the 22 verifier-listed entry points holds.\n\nWhat I traced and confirmed:\n\n- **No roles exist after deployment.** No constructor stores `msg.sender` or `tx.origin`. The oracle signer is set once in the constructor and nothing reaches the setter again. Every dependency is an immutable from the manifest, and the argument order and live addresses for the Intake, IMD and the signer match the oracle-consumer reference.\n- **The only guarded entry point is sound.** The callback requires the immutable Intake as sender and a request id this contract itself opened. A stranger who buys an Intake request naming Questions as the callback target is refused as an unknown request.\n- **Paired surfaces are symmetric where it matters.** Callback and `submit` share one settlement path and write identical state. The tip always goes to the recorded opener. Fund and defund both round in the pool's favour, and the returned tip can never lift a share above one wei, which blocks first-depositor inflation.\n- **No approval can be spent by a third party.** Every pull is from `msg.sender`. Questions reverts if the Intake leaves any allowance standing. Payroll approves exactly the offered amount, clears it, and reverts if more left.\n- **Trust-gap seams are either closed or disclosed.** The Adapter controller choosing where unclaimed tips go, sIMD visitors diluting a drip, and unserved-request griefing are documented trust roots in the threat model, not bypasses. The gas floors on the Adapter and seat reads stop a caller from faking an unbound agent or an ownerless seat.\n\nEvidence run locally: the nine launch manifest tests and nine targeted access-control tests all pass, including the callback-sender check, the panel-evidence `submit` refusal, the opener-tip protection, cross-seat isolation, reentrancy on `request`, and the two low-gas sweep fuzz tests.\n\nNot reached: the sIMD, seat token and Adapter addresses could not be verified against the chain without network access, so the coverage row for the manifest notes them as unverified. The fork suite in the repository covers that step.","createdAt":"2026-10-13T09:06:02.807Z","verdict":null,"findings":[],"coverage":{"rows":[{"target":"JobsReport.crown(uint64,bytes)","verdict":"holds","why":"Open to anyone by design; only effect is recording a list whose keccak must equal the word Questions stored for an Answered round, once per round (crownedAt != 0 guard); moves no funds, reads own-launch Questions only."},{"target":"JobsReport.print()","verdict":"holds","why":"Open to anyone; copies Answered/Failed rounds of PAYROLLS/HIRES feeds in order with bounded work; what is recorded is caller-independent; no funds, no roles, no approvals."},{"target":"Payroll.book()","verdict":"holds","why":"Books balance above owed() (saturating), split 50/30/20 by fixed rule; caller chooses nothing; cannot book below owed because every payout writes the ledger before transferring."},{"target":"Payroll.claimSeat(uint256)","verdict":"holds","why":"Pays ownerOf(tokenId) read via bounded staticcall; _claimAllowed requires msg.sender==owner for code-holding owners (except Payroll itself); seatPaid written before transfer; cannot pay another seat's share or a caller-chosen address."},{"target":"Payroll.claimSeats(uint256[])","verdict":"holds","why":"Mirror of claimSeat with skip-instead-of-revert for unknown/duplicate/ownerless/contract-held seats; same _claimAllowed guard; seatPaid and seatClaimed written before transfers; capped at 200 ids."},{"target":"Payroll.claimTip(uint256)","verdict":"holds","why":"Pays all tranches to ownerOf(binding seat) only when Adapter names one of SEATS with BINDING_GAS fixed and a gas floor; same contract-owner-must-claim rule as claimSeat; slot deleted before transfer. Adapter controller choosing the seat is the documented trust root T7, not a bypass."},{"target":"Payroll.drip()","verdict":"holds","why":"Anyone may call; amount is min(vested backlog, dripCap) by fixed rule, destination is immutable SIMD, refused unless sIMD answers asset()==IMD and totalSupply!=0; dripped written before transfer. Visitor dilution (P2) is a property of sIMD, acknowledged by the brief."},{"target":"Payroll.fundFeeds()","verdict":"holds","why":"Anyone may call; funds only the three immutable feed ids, up to runway sized from Payroll's own redeemable position and costOf capped at 2 IMD; approval to QUESTIONS set to exactly give and cleared; FeedOverdrawn if more left; books written before the call. Release of due budget happens only here, a"},{"target":"Payroll.pay(uint256,bytes32)","verdict":"holds","why":"Pulls only from msg.sender (no from parameter, so nobody's approval can be spent by a third party); books what arrived by balance difference."},{"target":"Payroll.sweepTip(uint256)","verdict":"holds","why":"Moves only the ripe tranche to the seat pool and only when the agent is unbound, bound elsewhere, or bound to an ownerless seat; reverts TipStillClaimable for a bound seat with an owner (contract-held included); gas floor and OWNER_READ_MARGIN prevent faking unbound/ownerless by starving the reads."},{"target":"Payroll.tip(uint256[],uint256)","verdict":"holds","why":"Pulls only from msg.sender; equal split to first 16 ids, remainder to seat pool; tipsIn/tipsToSeats keep owed() consistent; nothing here lets a tipper direct funds to an address."},{"target":"Questions.abandon(bytes32)","verdict":"holds","why":"Anyone may call but only after requestedAt+12h+validFor+5min+1s, or 30 min after completion was first observed (two-step); closes the round, returns the tip to the meter via _returnTip without raising a share above one wei; deletes _feedOfRequest so a late callback is refused rather than double-sett"},{"target":"Questions.collectTips()","verdict":"holds","why":"Pays msg.sender only the low (earned) half of its own _tips word; the pending half is untouchable until _settle moves it; totalTips decremented before transfer."},{"target":"Questions.createFeed((bytes32,uint64,uint8,uint16,uint16,uint16,uint32,uint32,uint32,uint16,bool,bool,uint96,uint96,uint8,address,uint256,bytes),bytes,bytes)","verdict":"holds","why":"Content-addressed id, so an existing feed cannot be altered or taken over; creating an existing id is a no-op; checkFeed validates ranges, tip<=maxPrice, prefix/tail shape; gate is a bounded staticcall only. No role is granted to the creator."},{"target":"Questions.defund(bytes32,uint256)","verdict":"holds","why":"Burns only the caller's own shares of the current epoch/rescale, pays floor(shares*balance/totalShares) to msg.sender; never blocked by feed state; cannot reach another funder's share or the held tip."},{"target":"Questions.fund(bytes32,uint256)","verdict":"holds","why":"Pulls only from msg.sender; shares minted at current worth with totalShares>=balance invariant (no first-depositor inflation: _returnTip raises totalShares when needed); epoch retirement only when balance is exactly zero."},{"target":"Questions.fundFor(bytes32,uint256,address)","verdict":"holds","why":"Same as fund with shares to beneficiary; payer is still msg.sender; zero beneficiary refused; gifting shares to Payroll only lowers Payroll's own top-up need and cannot extract value."},{"target":"Questions.onOracleResult(bytes32,(bytes32,uint256,bytes32,uint8,bytes,uint256,uint64,uint64,bytes32,bytes32,uint16,uint16,uint16,uint64,uint64),bytes)","verdict":"holds","why":"msg.sender must be the immutable INTAKE; requestId must map to a pending feed of this contract (a stranger's purchase naming Questions as callback hits UnknownRequest); _settle verifies signer/domain/window, per-feed usedBy replay mark, chain, pinned questionHash, window, type, panel/quorum/agreed, "},{"target":"Questions.request(bytes32)","verdict":"holds","why":"Anyone may call when _readiness allows; caller chooses nothing (price from Intake under maxPrice, body from stored text, callback fixed to this contract); meter charged before the Intake call; PriceNotTaken/RequestIdReused/MetersNotCovered guard the Intake's behaviour; tip only set aside, earned on "},{"target":"Questions.reveal(bytes32,uint64,bytes)","verdict":"holds","why":"Stateless event emission gated on keccak(answer)==stored word for an Answered list round."},{"target":"Questions.submit(bytes32,(bytes32,uint256,bytes32,uint8,bytes,uint256,uint64,uint64,bytes32,bytes32,uint16,uint16,uint16,uint64,uint64),bytes)","verdict":"holds","why":"Requires pinHash, not panelEvidence, a pending round, and Intake completion or 30 min of silence; then the identical _settle checks as the callback, using the round's own requestId; tip always goes to the recorded opener so an observer cannot take it (asymmetry with callback path checked: both branc"},{"target":"invariant: no owner/admin/setter","verdict":"holds","why":"No constructor stores msg.sender or tx.origin; oracleSigner is set once via _setOracleSigner in the constructor and no function reaches it again; every dependency is an immutable; no proxy/initializer/pause/mint paths exist in src/."},{"target":"invariant: approvals held by the contracts","verdict":"holds","why":"Questions approves INTAKE for exactly price and reverts PriceNotTaken if any allowance remains; Payroll approves QUESTIONS for exactly give and clears it after the call; no function pulls from an address other than msg.sender."},{"target":"manifest: constructor arguments vs roles","verdict":"holds","why":"Questions(intake=Intake v2 0xa43e…, imd=0xd34a…, signer=0x5598…) and Payroll(imd, simd, seats, 2000, adapter, $contract:Questions, feedA/B/C) match the constructor parameter order and the oracle-consumer reference's live addresses; no $owner and no argument confers a role. sIMD/seat/Adapter addresse"}],"summary":{"reported":true,"total":21,"answered":21,"ratio":1,"missing":[],"unreached":[]}}},{"hash":"cbebedb957677cd6d51cc0fd3548ab3eeb96773a9d87b5d78fe44e17f8e05255","nodeKey":"audit_flow","role":"review","attempt":1,"deviceKey":"bf4c5b8707945096cdaf154c352676442a5323c449cbf9036bee8a70c599d751","seat":{"tokenId":"1843","agentId":"51368"},"outcome":"completed","accepted":true,"failureReason":null,"failureClass":null,"safetyRefusal":false,"usage":{"model":"claude-opus-5-5","turns":22,"runtime":"claude","inputTokens":38,"wallClockMs":569318,"outputTokens":36385,"cachedInputTokens":2354083},"baseCommit":"4124672e6121e7333917934e6da24fd433132d68","startCommit":"8106a38cfad82c7e8ab15b991cb7912b9ce1eb5a","resultTreeHash":null,"bundleHash":null,"bundleBytes":0,"artifacts":[],"changedPaths":[],"allowedPaths":[],"dependencyBundleHashes":["a3c1faeeedd02315f22454162d4a234919e40bb220305a55d34c9390714a4f2d"],"verificationErrors":0,"summary":"I found no defects I could reproduce in the control-flow area, so `.imd-findings.json` has an empty findings list. Its coverage record has 24 rows: all 21 listed entry points, two balance invariants and the launch manifest's constructor arguments. Every row is marked `holds` with a one-line reason. No other file was changed.\n\n**What I covered**\n- **Execution trace:** I traced each entry point in `Questions`, `Payroll` and `JobsReport` from its checks through state changes to failure paths. That included:\n  - leftover token approvals after the Intake and meter top-up calls (none are left);\n  - partial updates around those external calls;\n  - how `request`, the answer callback, `submit` and `abandon` interleave;\n  - whether a caller can send too little gas to fake a result from the Intake, seat token or seat Adapter reads (they can't);\n  - the fortnight ageing of the feed budget and its release to stakers;\n  - how `JobsReport` pairs payment counts with prints.\n- **Periphery:** I checked the `QuestionBody` text helpers, the small assembly helpers that read one word from another contract, the Adapter read, and the attestation check (window, then signature).\n- **First principles:**\n  - Each feed meter's share value never goes above one wei.\n  - `Questions` always holds at least what its meters and tips add up to.\n  - `Payroll`'s ledger always balances.\n  - The staker stream can always be fully sent out.\n  - The feed budget only goes to stakers after the meters were served.\n\n**Static-analysis leads:** None held up. The strict-equality lines all compare with zero or between counters. The \"weak PRNG\" line is a remainder carried to the next seat payout. The reentrancy lines are covered by each contract's transient lock and by `Payroll` writing its books before calling `Questions`. The ignored return values are timestamps nothing uses. The flagged casts are each bounded on the line before.\n\n**Launch inputs, read live from mainnet:**\n- sIMD's `asset()` is IMD, and it has shares outstanding.\n- The seat token has 2,000 seats numbered 0–1,999; `ownerOf(2000)` reverts. This matches `SEAT_COUNT` and the \"number 2000\" constructor argument.\n- The Intake (version 2) charges 0.5 IMD, under the house feeds' 2 IMD cap.\n- The three feed ids are the same in the manifest, the fixtures, `JobsReport` and `Payroll`, and the arguments are in constructor order.\n\nThe whole non-fork test suite passes. I did not run the fork tests. The callback's 200,000-gas limit and the Intake's `requests()` layout are already tested in `QuestionsCallbackGas.t.sol` and `QuestionsIntake.fork.t.sol`, so I relied on those.\n\n**Known risks, already disclosed in the code's comments:**\n- Someone who buys an Intake request earlier in the same transaction can make a round fail. The employers feed can only be answered through the Intake callback, so it is the most exposed.\n- A returned tip can end up owned by nobody after a meter was refilled from zero.\n- Payouts to sIMD stakers have no time weighting, so a short-term staker can take a share of a drip.","createdAt":"2026-10-13T08:59:02.234Z","verdict":null,"findings":[],"coverage":{"rows":[{"target":"JobsReport.crown(uint64,bytes)","verdict":"holds","why":"Needs the round to be Answered and keccak(answer) to equal the word Questions stored; _topThree bounds-checks offset/length/words, so a malformed answer reverts BadAnswer; a round can be crowned once; nothing else reads crowns."},{"target":"JobsReport.print()","verdict":"holds","why":"Stops at a Pending round, each round is consumed once, skips cannot fire wrongly against this Questions (toBlock < request block), the overlap check keeps each series non-overlapping, and the merge in _pair never drops a pair because both series only move forward; bounded at 8 rounds and 16 steps."},{"target":"Payroll.book()","verdict":"holds","why":"Books balance minus owed() (saturating); _book ages the budget before splitting and releases nothing; 50/30/20 parts add up exactly; stakerShare >= 1 for any amount, so _levelStream never divides by zero."},{"target":"Payroll.claimSeat(uint256)","verdict":"holds","why":"Rejects ids >= SEAT_COUNT (mainnet seats are 0..1999, ownerOf(2000) reverts, checked live); seatPaid is set before the transfer; a contract owner must call for itself; returns 0 when nothing is due."},{"target":"Payroll.claimSeats(uint256[])","verdict":"holds","why":"Duplicates pay once because seatPaid is set before the next id is read; bad ids and ownerless or contract-owned seats are skipped; seatClaimed is added once and transfers come after all writes; at most 200 ids."},{"target":"Payroll.claimTip(uint256)","verdict":"holds","why":"The gas floor (106,587) leaves the Adapter its full 100k after the 63/64 rule; needs a 96-byte answer with token == SEATS and id < SEAT_COUNT; the slot is deleted before the transfer; the ledger tips figure stays conserved."},{"target":"Payroll.drip()","verdict":"holds","why":"_vest moves the cursor; sends min(vested backlog, dripCap), and dripCap >= ceil(outstanding/168) after any booking, so the stream always drains; the vault check (asset == IMD, live sIMD asset verified) runs before the transfer; DripTooSoon is enforced."},{"target":"Payroll.fundFeeds()","verdict":"holds","why":"Shortfalls are read before any funding; books are written before the low-level Questions.fund and corrected by the measured balance change; the allowance is reset to 0; due+old <= budget holds through _spendTranches; release happens only after the meters were served."},{"target":"Payroll.pay(uint256,bytes32)","verdict":"holds","why":"Books only the measured balance increase; returns on zero; the split is exact and the stream re-levels with a non-zero amount."},{"target":"Payroll.sweepTip(uint256)","verdict":"holds","why":"Only the ripe tranche moves; reverts TipStillClaimable when the seat has a readable owner; OWNER_READ_MARGIN stops a caller from faking a failed owner read with low gas; the slot is rewritten or deleted consistently."},{"target":"Payroll.tip(uint256[],uint256)","verdict":"holds","why":"Uses at most 16 ids, splits got/n evenly with the remainder to the seat pool, so tipsIn == credited + tipsToSeats; per-agent tranche ageing keeps earlier tips' waits; share fits uint128 (SafeCast)."},{"target":"Questions.abandon(bytes32)","verdict":"holds","why":"Silence deadline = request + 12h + validFor + 5min + 1, so it is never shorter than an answer's validity; a completion shortens it only after a recorded 30-minute wait; starving the Intake read only makes the step more conservative; the tip goes back to the meter and totalMetered/totalTips stay cons"},{"target":"Questions.collectTips()","verdict":"holds","why":"Pays only the low (earned) half, decreases totalTips first, and transfers after the writes under the lock."},{"target":"Questions.createFeed((bytes32,uint64,uint8,uint16,uint16,uint16,uint32,uint32,uint32,uint16,bool,bool,uint96,uint96,uint8,address,uint256,bytes),bytes,bytes)","verdict":"holds","why":"Content-addressed and idempotent; checkFeed bounds every parameter, prefix and tail; holdsPanelEvidence reads only within the text; a tail-only evidence:panel cannot open submit because the pinned hash would then differ."},{"target":"Questions.defund(bytes32,uint256)","verdict":"holds","why":"Clamps to the caller's shares, rounds down, and keeps totalShares >= sum of stakes through epochs and rescales; never blocked by a pending round."},{"target":"Questions.fund(bytes32,uint256)","verdict":"holds","why":"Credits the measured amount; retires the epoch only at balance 0; the rescale loop keeps total > balance, so shares >= received; the share value stays <= 1 wei."},{"target":"Questions.fundFor(bytes32,uint256,address)","verdict":"holds","why":"Same path as fund, with a non-zero beneficiary; a donor cannot later take back shares given to the beneficiary (for example to Payroll), so the runway it reads is real."},{"target":"Questions.onOracleResult(bytes32,(bytes32,uint256,bytes32,uint8,bytes,uint256,uint64,uint64,bytes32,bytes32,uint16,uint16,uint16,uint64,uint64),bytes)","verdict":"holds","why":"Only the Intake may call, and only for an id mapped while pending; the mapping is cleared on settle or abandon; checks signature, window, per-feed replay, chain, question hash, panel, agreed and closing hash; the tip is credited to the opener; callback gas is covered by QuestionsCallbackGas."},{"target":"Questions.request(bytes32)","verdict":"holds","why":"_readiness is shared with the views; state and meter are charged before the Intake call; exact approval, then allowance==0, id-reuse and balance-coverage checks after the call; the transient one-per-transaction flag (the same-transaction purchase grief is disclosed in the NatSpec)."},{"target":"Questions.reveal(bytes32,uint64,bytes)","verdict":"holds","why":"Only answered list-type rounds, and only bytes that hash to the stored word; it emits an event and writes no storage."},{"target":"Questions.submit(bytes32,(bytes32,uint256,bytes32,uint8,bytes,uint256,uint64,uint64,bytes32,bytes32,uint16,uint16,uint16,uint64,uint64),bytes)","verdict":"holds","why":"Needs pinHash, chain evidence and a pending round, and opens only after 30 minutes or an Intake completion; _settle as for the callback; the tip still goes to the round's opener, so an observer gains nothing."},{"target":"invariant: Questions IMD balance >= totalMetered + totalTips","verdict":"holds","why":"Every meter and tip transition moves both sides by equal amounts; request checks it after paying the Intake."},{"target":"invariant: Payroll IMD balance >= owed().total","verdict":"holds","why":"Each ledger figure moves together with the token movement; fundFeeds counts the offer as spent before the call and corrects it by the measured balance change."},{"target":"launch manifest constructor arguments","verdict":"holds","why":"Order matches each constructor; the feed ids equal the fixture feedIds (payrolls, hires, employers) in both JobsReport and Payroll; sIMD.asset() == IMD, seat ids 0..1999 with totalSupply 2000, and Intake v2 priceOf = 0.5 IMD <= maxPrice 2 IMD, all read from mainnet."}],"summary":{"reported":true,"total":21,"answered":21,"ratio":1,"missing":[],"unreached":[]}}},{"hash":"4792cda19312782eb6de7aa23cae5c41a447559f40bde37e500f64e4d8a3cddf","nodeKey":"adapt_contract_project","role":"implement","attempt":1,"deviceKey":"2d33950b9728fd89b13915cd0439e4a911860c6f3e29e96abea4540d1f7427c8","seat":{"tokenId":"168","agentId":"52372"},"outcome":"completed","accepted":true,"failureReason":null,"failureClass":null,"safetyRefusal":false,"usage":{"model":"gpt-6-astra","turns":13,"runtime":"codex","inputTokens":386056,"wallClockMs":2042862,"outputTokens":44661,"cachedInputTokens":6792064},"baseCommit":"4124672e6121e7333917934e6da24fd433132d68","startCommit":"4124672e6121e7333917934e6da24fd433132d68","resultTreeHash":"1679d62b3a8c02f5c648a9cd70143701a514c540","bundleHash":"a3c1faeeedd02315f22454162d4a234919e40bb220305a55d34c9390714a4f2d","bundleBytes":20398,"artifacts":[],"changedPaths":["ADAPTATION.md","README.md","docs/AUDITS.md","docs/DEVIATIONS.md","docs/LAUNCH.md","docs/THREATS.md","src/interfaces/IPayroll.sol","src/payroll/Payroll.sol","src/questions/Questions.sol","test/launch/fork/LaunchRehearsal.fork.t.sol","test/payroll/PayrollLaunchAudit.t.sol","test/payroll/PayrollSeats.t.sol","test/payroll/integration/LaunchA.t.sol","test/questions/HouseFeeds.t.sol","test/questions/QuestionsAbandon.t.sol","test/questions/QuestionsFixture.sol","test/questions/QuestionsInvariant.t.sol","test/questions/QuestionsLaunchAudit.t.sol","test/questions/QuestionsRequest.t.sol","test/questions/QuestionsTips.t.sol","test/questions/fork/QuestionsIntake.fork.t.sol"],"allowedPaths":[],"dependencyBundleHashes":[],"verificationErrors":0,"summary":"Implemented opener-tip protection, extended oracle timeout grace, and protection against forced escrow payouts.\n\n`forge build` passes. All 503 local tests pass.\n\n[ADAPTATION.md](/home/imd3/.identitymd/work/d8bb3d81-820d-42f4-ad7c-1d3ed96b1da8/8bd93d23-3dd6-48cf-84f4-6d894afff276/ADAPTATION.md) documents changes and remaining signer/timing limitations. Prepared for IdentityMD’s deployment stage; no broadcast performed.","createdAt":"2026-10-13T08:42:19.483Z","verdict":{"status":"accepted","profile":"foundry","evaluation":"checks","rejectionCode":null,"detail":"all checks passed","verifierVersion":"0.1.0+327e1e9f","verifiedTreeHash":"1679d62b3a8c02f5c648a9cd70143701a514c540","at":"2026-10-13T08:49:20.711Z","failedChecks":[]},"findings":[],"coverage":null},{"hash":"e5b61ca2910b906d40562bce8ac8539527db197cc0cc2e686ab4a844ec09462f","nodeKey":"audit_imported_code","role":"review","attempt":1,"deviceKey":"f91c213c19ee44bb77bfee95876463961d20118e8bb3f6555a13110b2401473e","seat":{"tokenId":"1521","agentId":"53895"},"outcome":"completed","accepted":true,"failureReason":null,"failureClass":null,"safetyRefusal":false,"usage":{"model":"claude-fable-5-1","turns":38,"runtime":"claude","inputTokens":482,"wallClockMs":1111266,"outputTokens":51824,"cachedInputTokens":2482615},"baseCommit":"4124672e6121e7333917934e6da24fd433132d68","startCommit":"4124672e6121e7333917934e6da24fd433132d68","resultTreeHash":null,"bundleHash":null,"bundleBytes":0,"artifacts":[],"changedPaths":[],"allowedPaths":[],"dependencyBundleHashes":[],"verificationErrors":0,"summary":"The review is complete and the findings file holds four substantiated items. Here is the recap.\n\n**Outcome.** No critical, high or medium defect was found in the three contracts as they arrived. The accounting of meters, tips, the staker stream, the seat pool and the feed budget held up under every path I traced, and the launch constraints in the brief are met. The four findings recorded in `.imd-findings.json` are two lows and two infos, all economic or liveness weaknesses that the authors already disclose in the code and in docs/LAUNCH.md. I report them so the adapter can make a conscious choice, not because they contradict the brief.\n\n**Findings**\n- **Low. Tip sniping through submit.** Thirty minutes after a request, anyone can hand in the oracle's public attestation and take the tip set aside for the keeper that opened the round. The attached Foundry test shows the bot earning 0.05 IMD and the opener earning nothing. It fails on the current code and passes once the opener is credited.\n- **Low. Twelve-hour abandon races a late writer.** Any address can close a pending round at twelve hours, after which the Intake's late callback reverts with an unknown request. The price is lost and the cadence doubles. The employers feed has no submit fallback, so a griefer paying only gas can keep it from ever producing a crown on days the writer is slow.\n- **Info. Fixed signer.** The pinned OpenZeppelin SignatureChecker takes the ERC-1271 path whenever the signer has code, so an EIP-7702 delegation or a key rotation makes every answer fail until all three contracts are relaunched. This is the brief's own no-owner choice.\n- **Info. Permissionless seat claims pay the current holder.** A seat held in escrow or on a marketplace can have its accrued share claimed into that contract by a third party.\n\n**What I checked and how**\n- Read in full: Questions, Payroll, JobsReport, OracleAttestation, QuestionBody, the three internal interfaces, the two external interfaces, Mainnet.sol, the protected harness, and the first 646 lines of docs/LAUNCH.md.\n- Manifest and constructors: argument order, types and the two backward references match the code. No owner, no setter, no proxy, no initializer, no external call or code-size check in any constructor.\n- Bytecode: compiled the sources and disassembled all three runtimes. No DELEGATECALL, CALLCODE or SELFDESTRUCT. Questions runtime is 22,963 bytes.\n- Live mainnet reads over RPC confirmed the assumptions the code makes: Intake v2 sells the action for 0.5 IMD with a 200,000 gas callback stipend and a four-word `requests` getter whose fourth word is the completed flag; seat ids run 0 to 1999 with total supply 2000; sIMD reports IMD as its asset with shares outstanding; the Adapter reverts for an unbound agent.\n\n**Not reached.** The repository's own test suites, docs/THREATS.md, docs/AUDITS.md and the README were only sampled, not audited. The full `forge test` run could not be completed on this machine because compiling the whole suite with via-IR exhausted memory. The live Intake's verified source was not read; its behaviour was checked only through the RPC calls above. A clean result here is not proof of absence, especially for the oracle plane's off-chain behaviour, which the contracts trust.","createdAt":"2026-10-13T07:05:07.881Z","verdict":null,"findings":[{"severity":"low","title":"submit lets any observer take the opener's keeper tip 30 minutes after a request, using the oracle's public attestation","path":"src/questions/Questions.sol","line":1372,"description":"`request` sets the feed's tip aside for the caller (the keeper that paid gas and opened the round). The tip is credited only when the round is answered, and `_settle` credits it to `earner`, which is `msg.sender` of `submit`. `submit` opens for any pinned, chain-evidence feed when `block.timestamp >= requestedAt + SUBMIT_AFTER_SILENCE` (30 minutes) even though the Intake has not completed the request. The oracle signs within minutes and publishes the attestation and signature through its API, so after 30 minutes anyone can copy that attestation into `submit` and be paid the tip that was set aside for the keeper. docs/LAUNCH.md section 4 records that the Intake's writer has been seen to take more than four hours to deliver, so on such days the opener loses the tip on every round to whoever runs a bot against the oracle API. The keeper incentive the tip is meant to provide is therefore unreliable, and the opener's `request` gas is uncompensated. This is disclosed in the contract notice and in LAUNCH.md as a limit; it is reported so the adapter can decide whether the opener should keep the tip (or a share) when `submit` is used, e.g. by crediting `h.keeper` instead of `earner` when the submitted attestation answers the round's own request, or by paying the opener a fixed part.","reproduction":"State: Questions deployed with a MockIntake selling the action for 0.5 IMD; a pinned, chain-evidence feed with tip = 0.05 IMD is created and funded with 10 IMD. Steps: (1) keeper K calls `request(feedId)` at time t0; `tipsOf(K)` returns (earned 0, pending 0.05e18). (2) The oracle signs an attestation `a` for the round's window and question hash (signature public). (3) Nobody calls the Intake's `complete`. (4) At t0 + 30 minutes bot B calls `submit(feedId, a, sig)`. Actual: the round is Answered, `tipsOf(B)` = (0.05e18, 0), `tipsOf(K)` = (0, 0); K paid the request gas and gets nothing. Expected by a keeper: the tip of the round it opened and whose own request produced the answer.","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {Questions} from \"src/questions/Questions.sol\";\nimport {OracleAttestation} from \"src/questions/OracleAttestation.sol\";\n\ncontract ScratchIMD is ERC20 {\n    constructor() ERC20(\"IMD\", \"IMD\") {}\n    function mint(address to, uint256 a) external { _mint(to, a); }\n}\n\n/// Minimal Intake: sells the action, pulls the price, never completes.\ncontract ScratchIntake {\n    struct Callback { address target; bytes4 selector; }\n    struct Request { address payer; address callbackTarget; bytes4 callbackSelector; bool completed; }\n    mapping(bytes32 => mapping(address => uint256)) public priceOf;\n    mapping(bytes32 => Request) public requests;\n    uint256 public nonce;\n    address public payTo = address(0xBEEF);\n    function setPrice(bytes32 action, address asset, uint256 p) external { priceOf[action][asset] = p; }\n    function request(bytes32 action, bytes calldata, Callback calldata cb, address asset, uint256 amount)\n        external payable returns (bytes32 id)\n    {\n        require(amount >= priceOf[action][asset] && priceOf[action][asset] != 0, \"price\");\n        id = keccak256(abi.encode(address(this), ++nonce));\n        requests[id] = Request(msg.sender, cb.target, cb.selector, false);\n        IERC20(asset).transferFrom(msg.sender, payTo, amount);\n    }\n}\n\ncontract TipSnipeTest is Test {\n    bytes32 constant ACTION = bytes32(\"oracle.request@oracle-1\");\n    ScratchIMD imd;\n    ScratchIntake intake;\n    Questions q;\n    uint256 oracleKey = 0xA11CE;\n    address keeper = makeAddr(\"keeper\");\n    address bot = makeAddr(\"bot\");\n\n    function setUp() public {\n        vm.chainId(1);\n        vm.roll(26_200_000);\n        vm.warp(1_791_600_000);\n        imd = new ScratchIMD();\n        intake = new ScratchIntake();\n        intake.setPrice(ACTION, address(imd), 0.5e18);\n        q = new Questions(address(intake), address(imd), vm.addr(oracleKey));\n    }\n\n    function _params() internal pure returns (Questions.FeedParams memory p) {\n        p.action = ACTION; p.chainId = 1; p.answerType = 3; p.panelSize = 7; p.quorum = 5; p.minAgreed = 5;\n        p.validFor = 86400; p.cadenceBlocks = 7000; p.maxWindowBlocks = 21600; p.lagBlocks = 12;\n        p.contiguous = true; p.pinHash = true; p.maxPrice = 2e18; p.tip = 0.05e18;\n    }\n\n    function test_botTakesOpenersTipAfterThirtyMinutes() public {\n        bytes memory prefix = bytes('{\"answerType\":\"uint256\",\"chainId\":1,\"question\":\"q\",\"v\":1,\"window\":{\"fromBlock\":');\n        bytes memory tail = bytes(',\"panelSize\":7,\"quorum\":5,\"validForSeconds\":86400}');\n        bytes32 feedId = q.createFeed(_params(), prefix, tail);\n        imd.mint(address(this), 10e18);\n        imd.approve(address(q), 10e18);\n        q.fund(feedId, 10e18);\n\n        vm.prank(keeper);\n        q.request(feedId);\n        (uint256 earnedK, uint256 pendingK) = q.tipsOf(keeper);\n        assertEq(earnedK, 0); assertEq(pendingK, 0.05e18);\n\n        (, bytes32 qh, bytes32 bh,) = q.roundRequest(feedId, 1);\n        (,,, uint64 from, uint64 to,) = _round(feedId);\n        OracleAttestation.Attestation memory a = OracleAttestation.Attestation({\n            requestId: bytes32(uint256(1)), chainId: 1, questionHash: qh, answerType: 3,\n            answer: abi.encode(uint256(114e18)), figure: 0, fromBlock: from, toBlock: to, blockHash: bh,\n            panelJobId: bytes32(uint256(2)), panelSize: 7, quorum: 5, agreed: 5,\n            issuedAt: uint64(block.timestamp), expiresAt: uint64(block.timestamp + 86400)\n        });\n        bytes32 digest = q.attestationDigest(a);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(oracleKey, digest);\n        bytes memory sig = abi.encodePacked(r, s, v);\n\n        vm.warp(block.timestamp + 30 minutes);\n        vm.prank(bot);\n        q.submit(feedId, a, sig);\n\n        (uint256 earnedB,) = q.tipsOf(bot);\n        (earnedK, pendingK) = q.tipsOf(keeper);\n        // The opener, whose request produced this answer, is expected to keep the tip it was set aside for.\n        assertEq(earnedK, 0.05e18, \"opener lost its tip\");\n        assertEq(earnedB, 0, \"bot earned the opener's tip\");\n    }\n\n    function _round(bytes32 feedId) internal view returns (uint8, uint256, uint64, uint64, uint64, uint64) {\n        (Questions.RoundStatus st, uint256 value, uint64 from, uint64 to, uint64 upd) = q.roundOf(feedId, 1);\n        return (uint8(st), value, 0, from, to, upd);\n    }\n}","snippet":"            _tips[earner == address(0) ? h.keeper : earner] += h.tip;","citation":"resolved","blocking":false},{"severity":"low","title":"Anyone can close a round exactly 12 hours after its request while the Intake's delivery is still pending; the late answer is then refused, the price is lost and the cadence doubles","path":"src/questions/Questions.sol","line":439,"description":"`abandon` closes any pending round once `block.timestamp >= requestedAt + ABANDON_AFTER_SILENCE`, whatever the Intake says. Closing deletes `_feedOfRequest[requestId]`, so when the Intake's writer finally calls `complete` and the callback `onOracleResult` runs, it reverts `UnknownRequest` and the signed answer is dropped; the Intake records the request as completed and never retries. The round's price (0.5 IMD) is spent for nothing, `failStreak` rises and `_nextRequestBlock` doubles the cadence (up to 32x). For the employers feed (panel evidence) `submit` is refused, so there is no recovery path at all: a griefer who watches for rounds whose writer is late can call `abandon` at the 12-hour mark each time, for gas only, and the feed produces no crown and burns one price per round while backing off. For payrolls and hires the same griefing works whenever nobody hands the answer in through `submit` first. docs/LAUNCH.md section 4 says the writer has been seen to take more than four hours and lists this as limits Q3/Q4/Q8; it is reported so the adapter can weigh e.g. refusing `abandon` while `Intake.requests(id).completed` is false and the attestation would still be valid, or a longer silence for panel-evidence feeds.","reproduction":"State: Questions with a MockIntake; a panel-evidence feed (prefix holds \"evidence\":\"panel\", pinHash true) funded with 10 IMD. Steps: (1) keeper calls `request(feedId)` at t0, meter falls by 0.55 IMD, round 1 pending. (2) The Intake does not complete the request. (3) At t0 + 12 hours any address calls `abandon(feedId)`; it returns true, emits `RoundFailed(feedId, 1, id, false, 1)`, `roundOf(feedId,1).status == Failed`. (4) At t0 + 12 hours + 1 second the writer calls `MockIntake.complete(id, 0, …, abi.encode(id, a, sig))` with a valid attestation for the round's window. Actual: the callback reverts `UnknownRequest(id)` (recorded as not delivered), the 0.5 IMD price is gone, `canRequest` reports TooSoon until `lastRequestBlock + 2*cadence`. Expected: a valid answer that the Intake delivers while it is still inside its validity window is stored, or at least the round cannot be closed by a third party while the Intake still reports it uncompleted and the attestation window has not passed.","snippet":"    uint256 public constant ABANDON_AFTER_SILENCE = 12 hours;","citation":"resolved","blocking":false},{"severity":"info","title":"The oracle signer is fixed for good; an EIP-7702 delegation on the signer EOA or a signer rotation makes every answer fail BadSignature until the contract is relaunched","path":"src/questions/OracleAttestation.sol","line":162,"description":"`oracleSigner` is set once in the constructor and Questions exposes no setter (by design: no owner). The pinned OpenZeppelin `SignatureChecker.isValidSignatureNowCalldata` takes the ECDSA path only when `signer.code.length == 0`; if the signer account ever has code (an EIP-7702 delegation designator is code) every verification goes to `isValidSignatureNow` through ERC-1271 and plain signatures stop verifying. Likewise if IdentityMD rotates the signer key, all attestations fail. Every round then fails after 12 hours, each costing its price, backing off to one request per ~31 days per daily feed; meters stay redeemable. The oracle-consumer reference says the signer should be owner-settable; the brief explicitly chose no owner and docs/DEVIATIONS.md records the trade-off. Not a defect against the brief; recorded as the main liveness assumption the adapter and the panel should carry forward.","reproduction":"State: Questions deployed with signer S (an EOA). Steps: (1) `vm.etch(S, hex\"ef0100\" ++ someAddress)` or any non-empty code, simulating a 7702 delegation, or sign with a rotated key S'. (2) Deliver a valid attestation signed by S's key through the Intake callback. Actual: `_verifyAttestation` reverts `BadSignature`, the callback is recorded as not delivered, the round can only be closed by `abandon` after 12 hours, and this repeats for every round of every feed. Expected under the reference design: an owner re-points the signer; under this design there is no remedy except redeploying all three contracts.","snippet":"        if (!SignatureChecker.isValidSignatureNowCalldata(oracleSigner, attestationDigest(a), signature)) {","citation":"resolved","blocking":false},{"severity":"info","title":"Permissionless claimSeat/claimSeats/claimTip pay whoever holds the seat at the moment of the call, including an escrow or marketplace contract","path":"src/payroll/Payroll.sol","line":564,"description":"`claimSeat` reads `ownerOf(tokenId)` at call time and transfers the seat's whole accrued share to that address, and anyone may call it. While a seat sits in a contract that cannot forward ERC-20 balances (an escrow, an auction, a lending vault, a bridge), a third party can trigger the claim and the accrued IMD is paid to that contract and lost to the seat's economic owner. The same holds for `claimTip`, which pays the owner of the seat the agent is bound to. The contract notice documents this (\"Claim before moving a seat\"). Recorded so the adapter can decide whether to keep it; a pull-only design (owner claims, or a claim-to-owner-at-accrual snapshot) would remove the risk but changes the agreed keeper-friendly behaviour.","reproduction":"State: Payroll with 100 IMD booked (seat pool 30 IMD, 0.015 IMD per seat). Seat 7 is owned by Alice. Steps: (1) Alice lists seat 7 on a marketplace that holds the NFT in escrow (the escrow contract becomes `ownerOf(7)`). (2) Any address calls `claimSeat(7)`. Actual: `seatPaid[7]` is set to `seatAccrued`, `SeatClaimed(7, escrow, 0.015e18)` is emitted and 0.015 IMD is transferred to the escrow contract, which has no function to release it. Expected by Alice: her accrued share, claimable by her after the sale falls through or credited to the buyer.","snippet":"        IMD.safeTransfer(owner, paid);","citation":"resolved","blocking":false}],"coverage":null}]}