{"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":"be91e8ae-9f45-495f-a318-60fab672a92e","kind":"audit","nodes":[{"acceptedSubmissionHash":"56df78262546d4e4e0bbace7aee6f33edc4f16be681607b30590ae10cc23cf6e","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":"1efd6b4570b06ba760f7b26b16af569df4afa0a291a29cbffabfbc8f73990080","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":"302b1914914dde8bc9e94aff6ef0c38a63a99eee5b242a2427483e6b0fe956b8","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":"db68729e4d7065e1ec1b578a80e3ce803e6e24a2983c25ce121c0e66f28a44c9","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":"3dd643c595bc0d109ea2c96b49f11d168ea8920accee9c094226d2f483e123b9","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":"Questions: the oracle callback and submit paths, the question hash rebuilt on chain, meter share accounting, and the keeper tip that is set aside at request and earned only by an answer. Payroll: the 50/30/20 split, the weighted staker stream and its drip cap, seat and tip claims through the Adapter. docs/THREATS.md lists the limits we already know.","parentJobId":null,"planHash":"c8a5dba832f826875f6d4f1f187ea14b7b7a725199d5fdd458115fa01b79f1a2","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"be91e8ae-9f45-495f-a318-60fab672a92e","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":"52152","feedbackHash":"bc6c530b50ac4459253d83bf17fed73bf5c3b6ba11256dc6402f83a375bc7bc5","nodeKey":"audit_economics","submissionHash":"56df78262546d4e4e0bbace7aee6f33edc4f16be681607b30590ae10cc23cf6e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52160","feedbackHash":"c0cd85abc215371a72e791bf48f380026579d0ef17d853c275783e5af5e04f0c","nodeKey":"audit_flow","submissionHash":"1efd6b4570b06ba760f7b26b16af569df4afa0a291a29cbffabfbc8f73990080","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51316","feedbackHash":"b43772f5be6428960bcaa6e1ff7bf04fbe094dffeda6fd2a8e738c1c7b7268d7","nodeKey":"audit_judge","submissionHash":"302b1914914dde8bc9e94aff6ef0c38a63a99eee5b242a2427483e6b0fe956b8","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52167","feedbackHash":"4d7f6dbecc85934b3a394c88a8a92c0030bfe5defa3a7171b7c96b2e6ffd119d","nodeKey":"audit_math","submissionHash":"db68729e4d7065e1ec1b578a80e3ce803e6e24a2983c25ce121c0e66f28a44c9","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52157","feedbackHash":"c35f5fce80937f8abc4f2bd4915f46507f8a7c305c58d7866aa4f46795d93b6e","nodeKey":"audit_permissions","submissionHash":"3dd643c595bc0d109ea2c96b49f11d168ea8920accee9c094226d2f483e123b9","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"f4fbb2b732d898984814293a6ba5b584811b29eeca1a843f07893317e5228bf0","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"cd8076510aad8b8e","findings":[{"citation":"resolved","description":"The contract notice (Payroll.sol lines 60-64: \"no sequence of bookings can hold back money that was already on its way. Stakers are paid on that schedule, never less\"), the `drip` natspec (lines 452-454: \"money already vesting never waits longer because more arrived\") and docs/THREATS.md P3 (\"Bookings neither slow nor rush the stream ... Stakers are paid in full on that schedule\") promise that what is still vesting keeps its own schedule when new revenue is booked. `_levelStream` does not keep two schedules. It merges the unvested remainder `remaining` (with `timeLeft` to go) and the new amount `added` into ONE stream whose length is the amount-weighted average of `timeLeft` and seven days, and the merged pool then vests linearly at the rate `unvested / duration`. Whenever `timeLeft` is short, the old money's share of that single rate is lower than the rate it had, so less of it vests by its old end than it would have with no booking at all. Algebra: by the old end the merged stream has vested `(R+a)^2 * T / (R*T + a*7d)`, which is below `R` whenever `T*(2R + a) < 7d*R`; for a booking equal to the remainder (`a = R`) that is every booking made with under 2.33 days left. The test suite's own `test_booking_neverPushesTheEndPastSevenDaysOrBeforeTheOldEnd` (test/payroll/PayrollStream.t.sol:132-143) is titled as if the old money is never delayed but asserts `endsAt >= oldEnd`, i.e. that the end moves later, and never checks what has vested at the old end. Impact: anyone can delay payouts to sIMD stakers by paying revenue in (a griefing that costs the caller the full booking, 50% of which goes to the very stakers it delays, so it is not profitable), and the written guarantee a staker relies on is false. This is the behaviour chosen by the brief's decision 2, so the fix is a scope decision: either (a) reword the three documents to say that a booking lengthens the vesting of money already on its way, by at most the weighted average, or (b) keep the design's spirit by vesting the old remainder at its own rate: rate = remaining / timeLeft + added / STREAM_DURATION, end = now + unvested / rate (the money already vesting is then never slower than before and the new money still takes at most seven days).","line":978,"path":"src/payroll/Payroll.sol","reproduction":"Deploy Payroll with any IMD ERC-20 and nonzero placeholder addresses. Day 0: `pay(200e18, memo)` -> 100 IMD to stakers, `stream()` reports unvested 100e18, endsAt = now + 7 days. Warp 6 days: `stream()` reports unvested 14285714285714285715 wei, endsAt unchanged (1 day left). Now `pay(28571428571428571430, memo)`, which adds exactly 14285714285714285715 wei for stakers. Expected per the documentation: the 14.2857 IMD already vesting still finishes by the old end, so at least 14285714285714285715 wei vests in the next day (plus 1/7 of the new money). Actual: `stream()` now reports endsAt = old end + 259200 s (three days later) and, one day later, unvested has fallen from 28571428571428571430 to 21428571428571428573: only 7142857142857142857 wei vested in the day in which the old stream alone owed 14285714285714285715. Stakers received half of what they were owed by the old end despite more money arriving. A self-contained Foundry test (ScratchIMD ERC-20 + Payroll, `assertGe(vestedSinceBooking, unvestedBefore)`) fails on the current code with `7142857142857142857 < 14285714285714285715`.","severity":"low","snippet":"        uint256 duration = (remaining * timeLeft + added * STREAM_DURATION) / unvested;","title":"A booking can hold back staker money already vesting, contrary to the documented stream guarantee"}],"hash":"1efd6b4570b06ba760f7b26b16af569df4afa0a291a29cbffabfbc8f73990080","nodeId":"749dfecb-767e-4480-865d-bb6c49f08a91","outcome":"completed","summary":"The findings file validates (path, line and snippet match the tree) and the scratch directory is removed. The review is complete.\n\n## Outcome\n\nThe findings are in `.imd-findings.json`. One substantiated finding, severity low. I found no exploitable defect in the assigned control flow after tracing every path listed in the task.\n\n**Finding: a booking holds back staker money already vesting.** The Payroll notice, the `drip` natspec and THREATS P3 all promise that money already on its way keeps its schedule when new revenue is booked. The stream re-levelling at `src/payroll/Payroll.sol:978` merges the unvested remainder and the new amount into one pool with an amount-weighted end, so the old money vests at the merged rate. Reproduction with exact numbers: 100 IMD to stakers on day 0, warp six days so 14.2857 IMD has one day left, then a `pay` that adds the same amount. The end moves three days later and only 7.14 IMD vests in the next day, half of what the old stream alone owed. The suite's own test on this (`PayrollStream.t.sol:132`) asserts the end moves later, the opposite of its title. It is a griefing that costs the caller more than it delays, and it is decision 2 of the brief, so the fix is a scope call: reword the guarantee, or vest the old remainder at its own rate.\n\n**What I covered and found sound**, with the concrete counterexamples I tried:\n- Oracle callback and submit: Intake-only guard, per-feed use mark, request-id map deleted on settle and abandon so stale callbacks are refused, submit gating on pin, panel evidence and completion or two hours, tip earner selection, the 30-minute and six-hour abandon rules.\n- Question hash: prefix and tail checks, the shared bytes and the closing brace, panel-evidence detection; a creator can only hurt their own feed, which THREATS Q11 already states.\n- Meter shares: one-wei-per-share bound, epoch retirement, rescale loop termination and overflow room, returned-tip share top-up, defund rounding. Every share-inflation or rounding-to-zero attempt I modelled is blocked.\n- Keeper tip: pending and earned halves, totals, return on failure, set-aside at request only.\n- Payroll: 50/30/20 remainder to stakers, drip cap never stuck below one 168th of the backlog, vault readiness check, seat pool carry, tip tranche ageing, Adapter gas floor and owner-read margin, feed budget ageing and the top-up correction.\n\n**Out of scope but skimmed:** JobsReport's print and pairing logic showed nothing wrong on a read-through. Slither was not run, and no fork tests were run.","treeHash":null,"usage":{"cachedInputTokens":3011330,"inputTokens":708,"model":"claude-fable-5-1","outputTokens":51412,"runtime":"claude","turns":36,"wallClockMs":1382888}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"6b0a28df3d585600","findings":[{"citation":"resolved","description":"`fundFeeds` is written so that at a fortnight boundary the house meters are topped up first and only what they did not take is released (`Payroll.sol:690-694`; docs/THREATS.md P7; `test_budget_metersAreServedBeforeAnythingIsReleased`). `_book` runs the same `_ageFeedBudget()` first (line 838) and never looks at the meters, and `pay` and `book` are open to anyone and are called far more often than `fundFeeds`. So whichever transaction first crosses the boundary decides where the old tranche goes: if it is a `pay` (an ordinary settlement, or `pay(1, memo)` / a one-wei plain transfer plus `book()` from anyone) the whole old tranche is added to the staker stream inside `_book`, and the meters are left with their shortfall until fresh revenue is booked. The amount redirected per boundary is bounded by what the meters lacked at that instant (at most one runway each: 7.28 + 7.28 + 2.08 IMD at the fixture cost of 0.52, up to 28 + 28 + 8 IMD at the 2 IMD cap), it is not recoverable (feedsReleased is already inside toStakers), and the stated ordering guarantee is false in ordinary operation, not only under attack. Minimal fix that keeps the design: in `_book`, do not release at a boundary while any house meter has a shortfall (call `_shortfall` for the three feeds before `_ageFeedBudget` and carry the old tranche forward when any is non-zero), or move the release out of `_book` entirely and let `fundFeeds`, which serves the meters first, be the only place that releases. Merged from audit_economics (id 4ac5b68f...); the second economics finding (stranger parking) is a distinct mechanism with the same bound and is reported separately.","line":838,"path":"src/payroll/Payroll.sol","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 {ERC4626} from \"@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol\";\nimport {Payroll} from \"src/payroll/Payroll.sol\";\nimport {Questions} from \"src/questions/Questions.sol\";\n\ncontract ProofIMD is ERC20 {\n    constructor() ERC20(\"IMD\", \"IMD\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\ncontract ProofVault is ERC4626 {\n    constructor(IERC20 asset_) ERC20(\"sIMD\", \"sIMD\") ERC4626(asset_) {}\n}\n\n/// @dev Stands in for the Intake: only `priceOf` is read by `Questions.costOf`.\ncontract ProofIntake {\n    mapping(bytes32 => mapping(address => uint256)) public priceOf;\n\n    function setPrice(bytes32 action, address asset, uint256 amount) external {\n        priceOf[action][asset] = amount;\n    }\n}\n\ncontract Nothing {}\n\n/// @dev Finding: `_book` runs `_ageFeedBudget()` and releases the old feed tranche at a fortnight\n/// boundary without serving the house meters, although `fundFeeds` (lines 690-694) and\n/// docs/THREATS.md P7 say the meters are served first and only what they leave is released.\n/// Fails on the current code: a one-wei `pay` ordered before `fundFeeds` in the first block of the\n/// fortnight sends the whole old tranche to stakers and leaves the meters empty.\ncontract ProofBudgetBook is Test {\n    bytes32 constant ACTION = bytes32(\"oracle.request@oracle-1\");\n    uint256 constant COST = 0.52e18;\n    uint256 constant RUNWAY = 14 * COST;\n    uint256 constant RUNWAY_C = 4 * COST;\n    uint256 constant FULL = 2 * RUNWAY + RUNWAY_C;\n\n    ProofIMD imd;\n    ProofVault simd;\n    ProofIntake intake;\n    Questions questions;\n    Payroll payroll;\n    bytes32 feedA;\n    bytes32 feedB;\n    bytes32 feedC;\n    address payer = makeAddr(\"payer\");\n    address staker = makeAddr(\"staker\");\n\n    function setUp() public {\n        vm.warp(1456 * 14 days + 3 days);\n        vm.roll(26_000_000);\n        imd = new ProofIMD();\n        simd = new ProofVault(IERC20(address(imd)));\n        intake = new ProofIntake();\n        questions = new Questions(address(intake), address(imd), makeAddr(\"oracle signer\"));\n        feedA = _createFeed(\"payrolls\");\n        feedB = _createFeed(\"hires\");\n        feedC = _createFeed(\"employers\");\n        Nothing n = new Nothing();\n        payroll = new Payroll(\n            address(imd), address(simd), address(n), 2000, address(n), address(questions), feedA, feedB, feedC\n        );\n        imd.mint(staker, 1e18);\n        vm.startPrank(staker);\n        imd.approve(address(simd), type(uint256).max);\n        simd.deposit(1e18, staker);\n        vm.stopPrank();\n        imd.mint(payer, 1e30);\n        vm.prank(payer);\n        imd.approve(address(payroll), type(uint256).max);\n    }\n\n    function _createFeed(string memory question) internal returns (bytes32) {\n        Questions.FeedParams memory p;\n        p.action = ACTION;\n        p.chainId = uint64(block.chainid);\n        p.answerType = 3;\n        p.panelSize = 3;\n        p.quorum = 2;\n        p.minAgreed = 1;\n        p.validFor = 3600;\n        p.cadenceBlocks = 100;\n        p.maxWindowBlocks = 200;\n        p.lagBlocks = 12;\n        p.pinHash = true;\n        p.maxPrice = type(uint96).max;\n        bytes memory prefix = bytes.concat(\n            '{\"answerType\":\"uint256\",\"chainId\":',\n            bytes(vm.toString(block.chainid)),\n            ',\"question\":\"',\n            bytes(question),\n            '\",\"v\":1,\"window\":{\"fromBlock\":'\n        );\n        bytes memory tail = ',\"panelSize\":3,\"quorum\":2,\"validForSeconds\":3600}';\n        return questions.createFeed(p, prefix, tail);\n    }\n\n    function _pay(uint256 amount) internal {\n        vm.prank(payer);\n        payroll.pay(amount, bytes32(\"x\"));\n    }\n\n    function _meter(bytes32 feedId) internal view returns (uint256 balance) {\n        (balance,) = questions.meterOf(feedId);\n    }\n\n    function test_oneWeiBookingAtTheBoundaryMustNotStarveTheMeters() public {\n        // The action is not sold yet, so nothing is placeable: 200 IMD of budget can only wait.\n        _pay(1_000e18);\n        uint256 period = block.timestamp / 14 days;\n        vm.warp((period + 1) * 14 days + 1);\n        (, uint256 released) = payroll.fundFeeds();\n        assertEq(released, 0, \"the first boundary only ages the budget\");\n        (, uint256 old,,) = payroll.feedBudget();\n        assertEq(old, 200e18);\n\n        // The feeds become fundable with empty meters.\n        intake.setPrice(ACTION, address(imd), COST);\n        assertEq(questions.costOf(feedA), COST);\n        uint256[3] memory needs = payroll.feedShortfalls();\n        assertEq(needs[0] + needs[1] + needs[2], FULL, \"the meters lack one runway each\");\n\n        // Second boundary. A one-wei booking lands before fundFeeds in the same block.\n        vm.warp((period + 2) * 14 days + 1);\n        _pay(1);\n        (uint256 funded,) = payroll.fundFeeds();\n\n        // Documented (Payroll.sol:690-694, THREATS P7): the meters are served before anything of the\n        // old tranche is released, so the three runways are placed and 183.36 IMD goes to stakers.\n        assertEq(funded, FULL, \"the meters were not served before the old tranche was released\");\n        assertEq(_meter(feedA), RUNWAY);\n        assertEq(_meter(feedB), RUNWAY);\n        assertEq(_meter(feedC), RUNWAY_C);\n        assertLe(payroll.ledger().feedsReleased, 200e18 - FULL, \"budget the meters needed was released to stakers\");\n    }\n}","reproduction":"Fixture: test/payroll/PayrollSetup.sol (feeds planted, costs unset so costOf()==0). 1) pay(1000e18): 200e18 of feed budget. 2) warp to the next fortnight boundary + 1 s, fundFeeds(): released 0, old tranche = 200e18. 3) setFeedCosts(0.52e18): the three meters now lack 7.28e18, 7.28e18 and 2.08e18. 4) warp to the following boundary + 1 s. Expected (what fundFeeds alone does, confirmed as a baseline in the same scratch file): funded = 16.64e18 placed on the meters, released = 183.36e18. Actual when pay(1) lands first in that block: ledger().feedsReleased == 200e18, then fundFeeds() returns (0, 0), feedBudget().total == 0 and all three meters hold 0. Run: forge test --match-path 'test/scratch/BudgetOrder.t.sol' --skip test/payroll/ test/questions/ test/launch/ test/scaffold/ (test_E1_oneWeiPayBeforeFundFeeds_releasesWholeTrancheAndStarvesMeters passes, i.e. shows the behaviour; the attached proof asserts the documented behaviour and fails).","severity":"medium","snippet":"        uint256 released = _ageFeedBudget();","title":"A booking that crosses a fortnight boundary releases the old feed tranche to stakers before the meters are served"},{"citation":"resolved","description":"`_shortfall` reads `meterOf(feedId)` (the meter's whole unspent balance) and treats everything there as runway, whoever put it there. docs/THREATS.md P6 says that is harmless because Payroll 'tops the meter up again at the next fundFeeds call'. Across a fortnight boundary it is not: `fundFeeds` releases, in the same call, every wei of the old tranche the meters did not take (`_ageFeedBudget`, lines 690-694). A stranger who funds the three meters to their runway in the transaction before `fundFeeds` and defunds in the transaction after gets 100% of the deposit back (no request runs in between, so no rounding loss either), and Payroll has meanwhile moved the whole old tranche to stakers. The amount is bounded exactly as in the one-wei booking finding (what the meters lacked at that instant), but it is a different root cause with a different fix, needs about 16.64 IMD of float (at 0.52 per request) and two transactions, and would survive a fix that only reorders `_book`. Fix: size the shortfall from Payroll's own redeemable part of the meter (`sharesOf(feedId, address(this)) * balance / totalShares`, both views exist in IQuestions) instead of the raw balance; or never release in `_ageFeedBudget` while any house meter is below its runway measured that way. Merged from audit_economics (id 511211fa...).","line":1047,"path":"src/payroll/Payroll.sol","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 {ERC4626} from \"@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol\";\nimport {Payroll} from \"src/payroll/Payroll.sol\";\nimport {Questions} from \"src/questions/Questions.sol\";\n\ncontract ProofIMD is ERC20 {\n    constructor() ERC20(\"IMD\", \"IMD\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\ncontract ProofVault is ERC4626 {\n    constructor(IERC20 asset_) ERC20(\"sIMD\", \"sIMD\") ERC4626(asset_) {}\n}\n\n/// @dev Stands in for the Intake: only `priceOf` is read by `Questions.costOf`.\ncontract ProofIntake {\n    mapping(bytes32 => mapping(address => uint256)) public priceOf;\n\n    function setPrice(bytes32 action, address asset, uint256 amount) external {\n        priceOf[action][asset] = amount;\n    }\n}\n\ncontract Nothing {}\n\n/// @dev Finding: `_shortfall` sizes a meter's need from the pooled meter balance (`meterOf`), not\n/// from Payroll's own position, so IMD a stranger parks on the house meters around a boundary\n/// `fundFeeds` makes Payroll place nothing and release the whole old tranche to stakers; the\n/// stranger then takes every wei back. docs/THREATS.md P6 says such parking is harmless because\n/// the meter is topped up again at the next call; across a boundary the budget is gone by then.\ncontract ProofBudgetPark is Test {\n    bytes32 constant ACTION = bytes32(\"oracle.request@oracle-1\");\n    uint256 constant COST = 0.52e18;\n    uint256 constant RUNWAY = 14 * COST;\n    uint256 constant RUNWAY_C = 4 * COST;\n    uint256 constant FULL = 2 * RUNWAY + RUNWAY_C;\n\n    ProofIMD imd;\n    ProofVault simd;\n    ProofIntake intake;\n    Questions questions;\n    Payroll payroll;\n    bytes32 feedA;\n    bytes32 feedB;\n    bytes32 feedC;\n    address payer = makeAddr(\"payer\");\n    address staker = makeAddr(\"staker\");\n    address stranger = makeAddr(\"stranger\");\n\n    function setUp() public {\n        vm.warp(1456 * 14 days + 3 days);\n        vm.roll(26_000_000);\n        imd = new ProofIMD();\n        simd = new ProofVault(IERC20(address(imd)));\n        intake = new ProofIntake();\n        questions = new Questions(address(intake), address(imd), makeAddr(\"oracle signer\"));\n        feedA = _createFeed(\"payrolls\");\n        feedB = _createFeed(\"hires\");\n        feedC = _createFeed(\"employers\");\n        Nothing n = new Nothing();\n        payroll = new Payroll(\n            address(imd), address(simd), address(n), 2000, address(n), address(questions), feedA, feedB, feedC\n        );\n        imd.mint(staker, 1e18);\n        vm.startPrank(staker);\n        imd.approve(address(simd), type(uint256).max);\n        simd.deposit(1e18, staker);\n        vm.stopPrank();\n        imd.mint(payer, 1e30);\n        vm.prank(payer);\n        imd.approve(address(payroll), type(uint256).max);\n        imd.mint(stranger, 100e18);\n        vm.prank(stranger);\n        imd.approve(address(questions), type(uint256).max);\n    }\n\n    function _createFeed(string memory question) internal returns (bytes32) {\n        Questions.FeedParams memory p;\n        p.action = ACTION;\n        p.chainId = uint64(block.chainid);\n        p.answerType = 3;\n        p.panelSize = 3;\n        p.quorum = 2;\n        p.minAgreed = 1;\n        p.validFor = 3600;\n        p.cadenceBlocks = 100;\n        p.maxWindowBlocks = 200;\n        p.lagBlocks = 12;\n        p.pinHash = true;\n        p.maxPrice = type(uint96).max;\n        bytes memory prefix = bytes.concat(\n            '{\"answerType\":\"uint256\",\"chainId\":',\n            bytes(vm.toString(block.chainid)),\n            ',\"question\":\"',\n            bytes(question),\n            '\",\"v\":1,\"window\":{\"fromBlock\":'\n        );\n        bytes memory tail = ',\"panelSize\":3,\"quorum\":2,\"validForSeconds\":3600}';\n        return questions.createFeed(p, prefix, tail);\n    }\n\n    function _pay(uint256 amount) internal {\n        vm.prank(payer);\n        payroll.pay(amount, bytes32(\"x\"));\n    }\n\n    function _meter(bytes32 feedId) internal view returns (uint256 balance) {\n        (balance,) = questions.meterOf(feedId);\n    }\n\n    function test_strangersParkedIMDMustNotTurnTheOldTrancheIntoStakerStream() public {\n        _pay(1_000e18); // 200 IMD of budget, nothing placeable while the action is not sold\n        uint256 period = block.timestamp / 14 days;\n        vm.warp((period + 1) * 14 days + 1);\n        payroll.fundFeeds(); // old tranche = 200 IMD\n        intake.setPrice(ACTION, address(imd), COST);\n        vm.warp((period + 2) * 14 days + 1);\n\n        // The stranger parks one runway on each meter in the transaction before fundFeeds.\n        vm.startPrank(stranger);\n        questions.fund(feedA, RUNWAY);\n        questions.fund(feedB, RUNWAY);\n        questions.fund(feedC, RUNWAY_C);\n        vm.stopPrank();\n\n        (uint256 funded, uint256 released) = payroll.fundFeeds();\n\n        // And takes it all back in the transaction after.\n        vm.startPrank(stranger);\n        questions.defund(feedA, type(uint256).max);\n        questions.defund(feedB, type(uint256).max);\n        questions.defund(feedC, type(uint256).max);\n        vm.stopPrank();\n        assertEq(imd.balanceOf(stranger), 100e18, \"the stranger's float came back whole\");\n\n        // Documented (THREATS P6, P7): Payroll's own position on each meter is one runway and only\n        // what the meters did not need is released. Actual: nothing placed, everything released.\n        assertEq(funded, FULL, \"Payroll placed nothing because it counted the stranger's IMD\");\n        assertLe(released, 200e18 - FULL, \"budget the meters needed went to stakers\");\n        assertEq(questions.redeemable(feedA, address(payroll)), RUNWAY, \"feed A has no runway of Payroll's own\");\n        assertEq(_meter(feedA) + _meter(feedB) + _meter(feedC), FULL, \"the meters are empty after the defund\");\n    }\n}","reproduction":"Fixture: test/payroll/PayrollSetup.sol. 1) pay(1000e18) with feed costs unset. 2) warp to the next boundary + 1 s, fundFeeds(): old tranche = 200e18. 3) setFeedCosts(0.52e18); warp to the following boundary + 1 s. 4) stranger (100e18 IMD, approved to Questions): questions.fund(FEED_A, 7.28e18); fund(FEED_B, 7.28e18); fund(FEED_C, 2.08e18). 5) payroll.fundFeeds(). 6) stranger: defund(FEED_A/B/C, type(uint256).max). Expected per THREATS P6/P7: 16.64e18 placed on the meters, 183.36e18 released. Actual: fundFeeds returns (funded 0, released 200e18); after the defunds the stranger's balance is exactly 100e18 again, all three meters hold 0, feedBudget().total == 0 and a further fundFeeds() places 0. Run: forge test --match-path 'test/scratch/BudgetOrder.t.sol' --skip test/payroll/ test/questions/ test/launch/ test/scaffold/ (test_E2_strangerParksRunway_fundFeedsReleasesWholeTranche shows the behaviour; the attached proof asserts the documented behaviour and fails).","severity":"low","snippet":"        return Math.saturatingSub(cost * requests, balance);","title":"Payroll sizes a meter's shortfall from the pooled meter balance, so IMD parked by a stranger around a boundary fundFeeds converts the old feed tranche into staker stream"},{"citation":"resolved","description":"`_levelStream` merges the unvested remainder (`remaining`, with `timeLeft` to go) and the new amount into one pool that vests linearly to a single amount-weighted end. The old money therefore vests at (remaining + added) / duration, which is below remaining / timeLeft whenever timeLeft * (2 * remaining + added) < 7 days * remaining; for a booking equal to the remainder that is every booking made with under 2.33 days left. The contract notice (lines 60-64: 'no sequence of bookings can hold back money that was already on its way. Stakers are paid on that schedule, never less'), the `drip` NatSpec (lines 451-454: 'a booking cannot be used to slow the stream ... money already vesting never waits longer because more arrived'), docs/THREATS.md P3 ('Bookings neither slow nor rush the stream ... Stakers are paid in full on that schedule') and the comment on test_booking_neverPushesTheEndPastSevenDaysOrBeforeTheOldEnd all state the opposite, while that very test asserts endsAt >= oldEnd, i.e. that the end does move later, and never checks what has vested at the old end. Only the dust case and the seven-day ceiling actually hold. Anyone can trigger it through `pay` or `book`, and an ordinary large settlement does it by accident; stakers lose no principal, but income the documents promise within the hour is deferred for up to days, and sIMD's share price rises later than a reader of those guarantees expects. This is decision 2's design, so the fix is a scope decision for the requester: (a) reword the four statements to what holds (no money waits past seven days from a booking; dust moves nothing; money already vesting can be delayed up to that ceiling), or (b) honour the guarantee by keeping the old remainder on its own schedule (rate = remaining / timeLeft + added / 7 days, end = now + unvested / rate, or two tranches summed in `_unvestedNow`). Merged from audit_math (10ed929f...), audit_permissions (334c7976...) and audit_flow (d97cb0b3...), which are the same defect at the same line; the audit_permissions proof was run and fails as stated, so it is attached.","line":978,"path":"src/payroll/Payroll.sol","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 {ERC4626} from \"@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol\";\nimport {Payroll} from \"src/payroll/Payroll.sol\";\n\ncontract ScratchIMD is ERC20 {\n    constructor() ERC20(\"IMD\", \"IMD\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\ncontract ScratchVault is ERC4626 {\n    constructor(IERC20 asset_) ERC20(\"sIMD\", \"sIMD\") ERC4626(asset_) {}\n}\n\n/// @dev Minimal stubs: nothing in these tests touches seats, the Adapter or Questions.\ncontract Nothing {}\n\ncontract StreamTailScratch is Test {\n    ScratchIMD imd;\n    ScratchVault simd;\n    Payroll payroll;\n    address payer = makeAddr(\"payer\");\n    address staker = makeAddr(\"staker\");\n\n    function setUp() public {\n        vm.warp(1_791_600_000);\n        vm.roll(26_000_000);\n        imd = new ScratchIMD();\n        simd = new ScratchVault(IERC20(address(imd)));\n        Nothing n = new Nothing();\n        payroll = new Payroll(\n            address(imd), address(simd), address(n), 2000, address(n), address(n), bytes32(\"a\"), bytes32(\"b\"), bytes32(\"c\")\n        );\n        imd.mint(payer, 1e30);\n        vm.prank(payer);\n        imd.approve(address(payroll), type(uint256).max);\n    }\n\n    function _stake() internal {\n        imd.mint(staker, 1e18);\n        vm.startPrank(staker);\n        imd.approve(address(simd), type(uint256).max);\n        simd.deposit(1e18, staker);\n        vm.stopPrank();\n    }\n\n    function _pay(uint256 amount) internal {\n        vm.prank(payer);\n        payroll.pay(amount, bytes32(\"x\"));\n    }\n\n    /// NatSpec on `Payroll` (lines 62-64, 451-454) and docs/THREATS.md P3 say money already vesting\n    /// \"never waits longer because more arrived\" and bookings \"neither slow nor rush the stream\".\n    /// Actual: a booking large next to what is left pushes the stream's end back by days, and the\n    /// old money vests on the new, longer schedule.\n    function test_largeBookingDelaysMoneyAlreadyVesting() public {\n        _stake();\n        // 200 IMD paid -> 100 IMD to the stakers' stream over seven days.\n        _pay(200e18);\n        (, uint256 unvested0,,,,) = payroll.stream();\n        assertEq(unvested0, 100e18);\n\n        // Drip hourly for 6 days 23 hours so that the backlog is sent as it vests.\n        for (uint256 i = 0; i < 167; ++i) {\n            vm.warp(vm.getBlockTimestamp() + 1 hours);\n            payroll.drip();\n        }\n        (uint256 outstanding1, uint256 unvested1, uint256 endsAt1,,,) = payroll.stream();\n        // About one hour and 100/168 IMD of the first stream are left.\n        assertApproxEqAbs(unvested1, uint256(100e18) / 168, uint256(1e15));\n        assertEq(endsAt1 - vm.getBlockTimestamp(), 1 hours);\n        uint256 oldEnd = endsAt1;\n        uint256 oldRemaining = unvested1;\n\n        // A second payer books 20 IMD now (10 IMD more for the stream).\n        _pay(20e18);\n        (uint256 outstanding2, uint256 unvested2, uint256 endsAt2,,,) = payroll.stream();\n        assertEq(unvested2, oldRemaining + 10e18);\n        uint256 backlogAtBooking = outstanding2 - unvested2;\n\n        // Expected by the NatSpec: the old remaining (about 0.595 IMD) is still fully vested by\n        // `oldEnd`, one hour from now. Actual: the end moved back by more than six days and by\n        // `oldEnd` only about 0.067 IMD has vested.\n        assertGt(endsAt2, oldEnd + 6 days, \"end moved back by more than six days\");\n        vm.warp(oldEnd);\n        (uint256 outstanding3, uint256 unvested3,,,,) = payroll.stream();\n        uint256 vestedSinceBooking = (outstanding3 - unvested3) - backlogAtBooking;\n        emit log_named_uint(\"old remaining at booking\", oldRemaining);\n        emit log_named_uint(\"vested by the old end   \", vestedSinceBooking);\n        emit log_named_uint(\"end moved back by (s)   \", endsAt2 - oldEnd);\n        assertGe(vestedSinceBooking, oldRemaining, \"money already vesting waited longer because more arrived\");\n        outstanding1;\n    }\n\n    /// `stream().dueNow` is documented as \"what `drip` would send if called in this block\"; it ignores\n    /// the vault check that makes `drip` revert, so a keeper that trusts the view sends a reverting call.\n    function test_viewSaysDueButDripReverts() public {\n        // No staker in sIMD.\n        _pay(200e18);\n        vm.warp(vm.getBlockTimestamp() + 1 hours);\n        (,,,,, uint256 dueNow) = payroll.stream();\n        assertGt(dueNow, 0, \"view reports something due\");\n        vm.expectRevert(Payroll.StakingVaultNotReady.selector);\n        payroll.drip();\n    }\n}","reproduction":"State: sIMD has at least one share. 1) pay(200e18): 100e18 enters the stream, end = now + 7 days. 2) drip() every hour 167 times: 595,238,095,238,095,239 wei remain with exactly 1 hour left (oldEnd). 3) pay(20e18): added = 10e18; _levelStream sets duration = (0.595e18*3600 + 10e18*604800) / 10.595e18 = 571,024 s, so the end moves 567,424 s past oldEnd. 4) warp to oldEnd and read stream(): vested since the booking = 66,797,292,483,078,019 wei. Expected per the NatSpec: at least the old remainder, 595,238,095,238,095,239 wei. Run: forge test --match-path test/scratch/Proof334.t.sol --match-test test_largeBookingDelaysMoneyAlreadyVesting --skip test/payroll/ test/questions/ test/launch/ test/scaffold/ -vv; it fails with 'money already vesting waited longer because more arrived: 66797292483078019 < 595238095238095239'. A second shape (audit_flow): day 0 pay(200e18); day 6 pay(28571428571428571430) adds exactly the 14285714285714285715 wei still unvested; only 7142857142857142857 wei vest in the following day where the old stream alone owed 14285714285714285715.","severity":"low","snippet":"        uint256 duration = (remaining * timeLeft + added * STREAM_DURATION) / unvested;","title":"Stream re-levelling delays money already vesting, contrary to the contract notice, the drip NatSpec and docs/THREATS.md P3"},{"citation":"resolved","description":"The `stream` view documents `dueNow` as 'what drip would send if called in this block (zero inside the ten-minute gap)'. `drip` has a second refusal the view does not mirror: it reverts `StakingVaultNotReady` unless sIMD answers asset() == IMD and totalSupply() != 0 (line 481). While the vault has no shares (the launch state until the first staker, and again if every staker leaves) the view reports a positive dueNow and the transaction reverts. A keeper that schedules its calls by the view sends a reverting transaction each time; no funds are at risk. Fix: return 0 for dueNow when !_stakingVaultReady() (a view costing two static calls), or document the exception on stream(). From audit_permissions (00f51f35...), reproduced.","line":759,"path":"src/payroll/Payroll.sol","reproduction":"State: Payroll over an ERC-4626 vault with zero shares outstanding. 1) pay(200e18). 2) warp one hour. 3) stream() returns dueNow = 595,238,095,238,095,239 wei (> 0). 4) drip() reverts StakingVaultNotReady. Expected: dueNow == 0 whenever drip would send nothing. Run: forge test --match-path test/scratch/Proof334.t.sol --match-test test_viewSaysDueButDripReverts --skip test/payroll/ test/questions/ test/launch/ test/scaffold/; the test asserts both facts and passes.","severity":"info","snippet":"        dueNow = block.timestamp < nextDripAt ? 0 : Math.min(outstanding - unvested, perDrip);","title":"stream().dueNow reports an amount that drip() refuses to send while sIMD is not ready"}],"hash":"302b1914914dde8bc9e94aff6ef0c38a63a99eee5b242a2427483e6b0fe956b8","nodeId":"20ded5f7-1c79-4533-b128-55a0a744a22b","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds the final result.\n\n**Outcome.** Six specialist findings were judged. All reproduced against the code, and three of them were the same defect, so the file holds four findings:\n\n| Severity | Finding | Proof attached |\n|---|---|---|\n| medium | `_book` releases the old feed tranche at a fortnight boundary before the meters are served (`src/payroll/Payroll.sol:838`) | yes, fails now |\n| low | `_shortfall` reads the pooled meter balance, so a stranger's parked IMD around a boundary `fundFeeds` turns the old tranche into staker stream (`Payroll.sol:1047`) | yes, fails now |\n| low | Stream re-levelling delays money already vesting, contrary to the notice, the `drip` NatSpec and THREATS P3 (`Payroll.sol:978`); merged from audit_math, audit_permissions and audit_flow | yes, the specialist's proof, run and failing as stated |\n| info | `stream().dueNow` reports an amount `drip` refuses while sIMD has no shares (`Payroll.sol:759`) | no |\n\n**How each was reproduced.** The two budget findings were run on the real `Questions` through the project's own `PayrollSetup` fixture, with a fundFeeds-first baseline confirming the documented behaviour. The proofs are self-contained: they deploy the real `Questions`, create three feeds through `createFeed`, and stub only the Intake's price. Both fail with the meters holding nothing where the documentation promises one runway each. I loosened the proofs' final assertion to \"nothing the meters needed was released\" so they pass under any fix that serves the meters first, whether the rest is released in the same call or at the next boundary.\n\n**Severity calls.** The first budget finding is medium because it happens in ordinary operation: `pay` is called far more often than `fundFeeds`, so the first transaction after a boundary is usually a booking and the ordering guarantee almost never holds. Its amount is bounded by what the meters lacked at that instant. The parking variant has the same bound, needs float and two deliberate transactions, and survives a fix that only reorders `_book`, so it stays a separate low finding with its own fix.\n\n**Limits.** The full via-IR build of the whole test tree exceeds the ten-minute foreground limit, so I compiled only `src` and the scratch tests with `--skip` on the other test folders. My own pass over the oracle callback, `submit`, the question-hash rebuild, meter share accounting and the keeper tip found nothing further I could substantiate. Nothing outside `test/scratch/` and the findings file was written.","treeHash":null,"usage":{"cachedInputTokens":1950189,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":42970,"runtime":"claude","turns":33,"wallClockMs":1311291}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"37eed9f56188ea8b","findings":[{"citation":"resolved","description":"Payroll's NatSpec (lines 62-64 and 452-454) and docs/THREATS.md P3 state that a booking never slows the stream and that 'money already vesting never waits longer because more arrived'. The re-levelling formula in _levelStream makes the whole unvested pool, old and new, vest linearly until a single weighted end. Whenever added * 7 days exceeds remaining * timeLeft the new end is later than the old one, and the old remainder is spread over the new, longer duration. The amount that vests by the old end is (remaining + added)^2 * timeLeft / (remaining * timeLeft + added * 7 days), which is below `remaining` for any mid-sized booking. Anyone may trigger it through the open `pay` or `book` entry points, and an ordinary large settlement does it unintentionally. Stakers lose no principal, but income that the documentation promises within the hour is deferred for days, and sIMD's share price (which sIMD derives from its IMD balance) rises later than a staker relying on the stated rule expects. Expected: by the old end, at least the old remaining has vested. Actual: a fraction of it has. Fix options, which are a design decision for the requester: (a) keep decision 2 and correct the NatSpec and THREATS P3 to say that a booking can push the end back by up to seven days and spread the old remainder over it; or (b) honour the stated guarantee by vesting the old remainder on its own schedule (two tranches: `remaining` keeps `streamEnd`, `added` gets now + 7 days, and `_unvestedNow` sums both), which keeps the cap logic intact. The proof test passes only under option (b).","line":978,"path":"src/payroll/Payroll.sol","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 {ERC4626} from \"@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol\";\nimport {Payroll} from \"src/payroll/Payroll.sol\";\n\ncontract ScratchIMD is ERC20 {\n    constructor() ERC20(\"IMD\", \"IMD\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\ncontract ScratchVault is ERC4626 {\n    constructor(IERC20 asset_) ERC20(\"sIMD\", \"sIMD\") ERC4626(asset_) {}\n}\n\n/// @dev Minimal stubs: nothing in these tests touches seats, the Adapter or Questions.\ncontract Nothing {}\n\ncontract StreamTailScratch is Test {\n    ScratchIMD imd;\n    ScratchVault simd;\n    Payroll payroll;\n    address payer = makeAddr(\"payer\");\n    address staker = makeAddr(\"staker\");\n\n    function setUp() public {\n        vm.warp(1_791_600_000);\n        vm.roll(26_000_000);\n        imd = new ScratchIMD();\n        simd = new ScratchVault(IERC20(address(imd)));\n        Nothing n = new Nothing();\n        payroll = new Payroll(\n            address(imd), address(simd), address(n), 2000, address(n), address(n), bytes32(\"a\"), bytes32(\"b\"), bytes32(\"c\")\n        );\n        imd.mint(payer, 1e30);\n        vm.prank(payer);\n        imd.approve(address(payroll), type(uint256).max);\n    }\n\n    function _stake() internal {\n        imd.mint(staker, 1e18);\n        vm.startPrank(staker);\n        imd.approve(address(simd), type(uint256).max);\n        simd.deposit(1e18, staker);\n        vm.stopPrank();\n    }\n\n    function _pay(uint256 amount) internal {\n        vm.prank(payer);\n        payroll.pay(amount, bytes32(\"x\"));\n    }\n\n    /// NatSpec on `Payroll` (lines 62-64, 451-454) and docs/THREATS.md P3 say money already vesting\n    /// \"never waits longer because more arrived\" and bookings \"neither slow nor rush the stream\".\n    /// Actual: a booking large next to what is left pushes the stream's end back by days, and the\n    /// old money vests on the new, longer schedule.\n    function test_largeBookingDelaysMoneyAlreadyVesting() public {\n        _stake();\n        // 200 IMD paid -> 100 IMD to the stakers' stream over seven days.\n        _pay(200e18);\n        (, uint256 unvested0,,,,) = payroll.stream();\n        assertEq(unvested0, 100e18);\n\n        // Drip hourly for 6 days 23 hours so that the backlog is sent as it vests.\n        for (uint256 i = 0; i < 167; ++i) {\n            vm.warp(vm.getBlockTimestamp() + 1 hours);\n            payroll.drip();\n        }\n        (uint256 outstanding1, uint256 unvested1, uint256 endsAt1,,,) = payroll.stream();\n        // About one hour and 100/168 IMD of the first stream are left.\n        assertApproxEqAbs(unvested1, uint256(100e18) / 168, uint256(1e15));\n        assertEq(endsAt1 - vm.getBlockTimestamp(), 1 hours);\n        uint256 oldEnd = endsAt1;\n        uint256 oldRemaining = unvested1;\n\n        // A second payer books 20 IMD now (10 IMD more for the stream).\n        _pay(20e18);\n        (uint256 outstanding2, uint256 unvested2, uint256 endsAt2,,,) = payroll.stream();\n        assertEq(unvested2, oldRemaining + 10e18);\n        uint256 backlogAtBooking = outstanding2 - unvested2;\n\n        // Expected by the NatSpec: the old remaining (about 0.595 IMD) is still fully vested by\n        // `oldEnd`, one hour from now. Actual: the end moved back by more than six days and by\n        // `oldEnd` only about 0.067 IMD has vested.\n        assertGt(endsAt2, oldEnd + 6 days, \"end moved back by more than six days\");\n        vm.warp(oldEnd);\n        (uint256 outstanding3, uint256 unvested3,,,,) = payroll.stream();\n        uint256 vestedSinceBooking = (outstanding3 - unvested3) - backlogAtBooking;\n        emit log_named_uint(\"old remaining at booking\", oldRemaining);\n        emit log_named_uint(\"vested by the old end   \", vestedSinceBooking);\n        emit log_named_uint(\"end moved back by (s)   \", endsAt2 - oldEnd);\n        assertGe(vestedSinceBooking, oldRemaining, \"money already vesting waited longer because more arrived\");\n        outstanding1;\n    }\n\n    /// `stream().dueNow` is documented as \"what `drip` would send if called in this block\"; it ignores\n    /// the vault check that makes `drip` revert, so a keeper that trusts the view sends a reverting call.\n    function test_viewSaysDueButDripReverts() public {\n        // No staker in sIMD.\n        _pay(200e18);\n        vm.warp(vm.getBlockTimestamp() + 1 hours);\n        (,,,,, uint256 dueNow) = payroll.stream();\n        assertGt(dueNow, 0, \"view reports something due\");\n        vm.expectRevert(Payroll.StakingVaultNotReady.selector);\n        payroll.drip();\n    }\n}","reproduction":"State: sIMD has at least one share. 1) pay(200e18): 100e18 enters the stream, end = now + 7 days. 2) drip() every hour 167 times: about 0.595e18 remains with exactly 1 hour left (oldEnd). 3) pay(20e18): added = 10e18. _levelStream computes duration = (0.595e18*3600 + 10e18*604800) / 10.595e18 = 571,024 s, so streamEnd = now + 6.6 days, 567,424 s later than oldEnd. 4) warp to oldEnd and read stream(): vested since the booking = 66,797,292,483,078,019 wei (0.067 IMD). Expected per the NatSpec: at least 595,238,095,238,095,239 wei (the old remainder). Run `forge test --match-path test/scratch/StreamTail.t.sol --match-test test_largeBookingDelaysMoneyAlreadyVesting -vv`; it fails with 'money already vesting waited longer because more arrived: 66797292483078019 < 595238095238095239'.","severity":"low","snippet":"        uint256 duration = (remaining * timeLeft + added * STREAM_DURATION) / unvested;","title":"Payroll stream re-levelling delays money already vesting, contrary to the documented guarantee"},{"citation":"resolved","description":"The `stream` view documents `dueNow` as 'what drip would send if called in this block (zero inside the ten-minute gap)'. `drip` has a second refusal that the view does not mirror: it reverts `StakingVaultNotReady` unless sIMD answers `asset() == IMD` and `totalSupply() != 0`. While the vault has no shares (the launch state until the first staker, and again if every staker leaves), the view reports a positive `dueNow` and the transaction reverts. A keeper that sizes or schedules its calls by the view sends a reverting transaction each time; no funds are at risk. Expected: `dueNow == 0` whenever `drip` would send nothing. Actual: `dueNow > 0` and `drip` reverts. Fix: return 0 from the view when `!_stakingVaultReady()`, or document the exception on `stream()`; `_stakingVaultReady` is already a view and costs two static calls.","line":759,"path":"src/payroll/Payroll.sol","reproduction":"State: sIMD deployed with zero shares outstanding. 1) pay(200e18). 2) warp one hour. 3) stream() returns dueNow = 595,238,095,238,095,239 wei (> 0). 4) drip() reverts StakingVaultNotReady. Run `forge test --match-path test/scratch/StreamTail.t.sol --match-test test_viewSaysDueButDripReverts`; the test asserts both and passes, showing the mismatch.","severity":"info","snippet":"        dueNow = block.timestamp < nextDripAt ? 0 : Math.min(outstanding - unvested, perDrip);","title":"stream().dueNow reports an amount that drip() refuses to send while sIMD is not ready"}],"hash":"3dd643c595bc0d109ea2c96b49f11d168ea8920accee9c094226d2f483e123b9","nodeId":"f5c8cd25-2e7e-4a70-a0b9-825a36a08be6","outcome":"completed","summary":"The review is complete and the findings file is written and validated.\n\n**Outcome.** The assigned area holds up: I found no critical, high or medium defect in Questions or Payroll. Two low-tier gaps between documented and actual behaviour are recorded in `.imd-findings.json`, each with a concrete reproduction and a scratch test under `test/scratch/StreamTail.t.sol`.\n\n**What I checked in depth**, following the Access Control, Trust Gap and Asymmetry guides:\n\n- **Oracle callback and submit paths.** Only the Intake reaches `onOracleResult`; `submit` requires a pinned hash, no panel evidence, a pending round, and either Intake completion or the two-hour wait. Both share `_settle`, which consumes the attestation per feed, checks chain, hash, window, type, panel, agreement and closing hash before any write. Replay across rounds and feeds behaves as THREATS.md Q3, Q4 and Q10 state.\n- **Question hash rebuilt on chain.** One stored text yields both the request body and the hash. A creator can only mis-hash their own feed, which Q11 covers. Tail fields outside the hash cannot redirect another feed.\n- **Meter share accounting.** The invariant that total shares never fall below the balance holds under fund, defund, request, rescaling and the tip return. Epoch retirement and the \"nobody's shares\" cases match Q5 and Q6 exactly.\n- **Keeper tip.** Set aside once per round in the pending half, moved exactly once to the earner or back to the meter. No path double-credits or strands it.\n- **Payroll split, stream, claims.** The 50/30/20 split sums exactly, the drip cap can never fall below the vesting rate, seat and tip claims pay the holder named at call time, and the gas floors keep a starved call from making a bound agent look unbound. Fortnight ageing never underflows.\n\n**Findings recorded:**\n\n- **Low, Payroll.sol:978.** The weighted re-levelling spreads money already vesting over the new, longer end whenever a booking is large next to what is left, while the NatSpec and THREATS P3 promise it never waits longer. A 20 IMD payment one hour before a stream ends pushes its 0.595 IMD remainder out by 6.6 days. Stakers lose nothing, only time. The fix is a design choice: correct the documentation, or vest the old remainder on its own schedule.\n- **Info, Payroll.sol:759.** `stream().dueNow` ignores the sIMD readiness check, so it reports a due amount while `drip()` reverts `StakingVaultNotReady`.\n\n**Not verified.** Nothing ran against the live oracle or the real Intake, seat token or Adapter; those remain the open items THREATS.md section 9 already lists. Slither was not run, per the task rules. JobsReport was skimmed only, as it holds no funds and is outside the assigned area.","treeHash":null,"usage":{"cachedInputTokens":3557104,"inputTokens":678,"model":"claude-fable-5-1","outputTokens":59344,"runtime":"claude","turns":36,"wallClockMs":1514958}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"7c748c02cd2ee98f","findings":[{"citation":"resolved","description":"`fundFeeds` is written so that at a fortnight boundary the meters are served first and only what they did not take is released (`Payroll.sol:690-694`, and docs/THREATS.md P7, `test_budget_metersAreServedBeforeAnythingIsReleased`). But `_book` runs the same `_ageFeedBudget()` first (line 838) and never looks at the meters. Ageing is therefore not an 'at most 28 days unused' rule: whichever transaction crosses the boundary first decides where the old tranche goes, and `pay(1, memo)` or `book()` after a 1-wei plain transfer is enough. Anyone holding sIMD shares has a motive and the cost is gas plus one wei. The meters are then left to starve until fresh revenue is booked; the Jobs Report feeds go unfunded in the meantime, and Payroll has no way to recover the released amount (feedsReleased is already inside toStakers). What is redirectable per boundary is bounded by what the meters lacked at that instant (one runway each: about 7.7 + 7.7 + 2.2 IMD at the live cost, or the entire tranche right after launch while the feeds are being created, B3), but it defeats the stated ordering guarantee and the attacker needs no capital. Fix (minimal): in `_book`, only release the tranche when no house meter has a shortfall, i.e. call `_shortfall` for the three feeds before `_ageFeedBudget` and, if any is non-zero, carry the old tranche forward (or simply skip the release inside `_book` and leave releasing to `fundFeeds`, which serves first). The seat/staker split itself is unaffected.","line":838,"path":"src/payroll/Payroll.sol","reproduction":"PayrollSetup state (START = period P + 3 days, feed costs unset so costOf()==0). 1) payer: pay(1000e18) -> 200e18 fresh feed budget. 2) warp to (P+1)*14d+1, call fundFeeds(): nothing placed (feeds cost 0), old tranche = 200e18. 3) The feeds become fundable: setFeedCosts(0.52e18). 4) warp to (P+2)*14d+1. Expected (fundFeeds first): funded = 0.52e18*(14+14+4) = 16.64e18 placed on the meters, released = 183.36e18 (confirmed by test). Actual when `pay(1)` lands first in that block: ledger.feedsReleased = 200e18, then fundFeeds() returns funded = 0, feedBudget().total = 0, meters hold 0. The only difference between the two outcomes is the order of two transactions in one block. Scratch test test/scratch/BudgetOrder.t.sol (uses PayrollSetup) shows both runs.","severity":"medium","snippet":"        uint256 released = _ageFeedBudget();","title":"A one-wei booking ordered before fundFeeds at a fortnight boundary sends the whole aged feed budget to stakers while the house meters stay empty"},{"citation":"resolved","description":"`_shortfall` reads `meterOf(feedId)` (the meter's whole unspent balance, line 1045) and treats any IMD there as runway, whoever put it there. THREATS P6 says this is harmless because Payroll 'tops the meter up again at the next fundFeeds call'. That is not true across a fortnight boundary: `fundFeeds` releases, in the same call, every old-tranche wei the meters did not take (lines 690-694, `_ageFeedBudget`). A stranger who funds the three meters to their runway in the transaction before `fundFeeds` and defunds in the transaction after gets 100% of the deposit back (no request runs in between, so there is not even rounding loss), and Payroll has meanwhile booked the whole old tranche to stakers. Payroll's own position on the meter is what it should be measuring; by reading the pooled balance it lets anyone with ~17.6 IMD of float (at the live cost, less at a lower price) and two transactions decide, once per fortnight, that the feeds 'did not need' the budget. The stakers (including the stranger, if staked) gain what the meters lacked; the feeds and the Jobs Report lose their funding until new revenue arrives. Fix: size the shortfall from Payroll's own redeemable part of the meter, `sharesOf(feedId, address(this)) * balance / totalShares` (both are in IQuestions), instead of the raw balance; or, in `_ageFeedBudget`, never release while any house meter is below its runway measured that way.","line":1047,"path":"src/payroll/Payroll.sol","reproduction":"PayrollSetup state. 1) payer: pay(1000e18) in period P (feeds cost 0 -> nothing placeable). 2) warp (P+1)*14d+1, fundFeeds(): old tranche = 200e18. 3) setFeedCosts(0.52e18); warp (P+2)*14d+1. 4) stranger (100e18 IMD, approved): questions.fund(FEED_A, 7.28e18); fund(FEED_B, 7.28e18); fund(FEED_C, 2.08e18). 5) payroll.fundFeeds(). 6) stranger: defund(FEED_A/B/C, type(uint256).max). Expected per THREATS P6/P7: the budget the meters needed (16.64e18) is placed, 183.36e18 released. Actual: fundFeeds returns (funded 0, released 200e18); after the defunds meterOf(FEED_A).balance == 0, stranger's balance is exactly 100e18 again, feedBudget().total == 0 and a further fundFeeds() places 0. Scratch test test/scratch/BudgetPark.t.sol.","severity":"medium","snippet":"        return Math.saturatingSub(cost * requests, balance);","title":"Payroll sizes a meter's shortfall by the whole meter balance, so a stranger's same-block fund/defund around fundFeeds at a boundary converts the feed budget into staker stream (contradicts THREATS P6)"}],"hash":"56df78262546d4e4e0bbace7aee6f33edc4f16be681607b30590ae10cc23cf6e","nodeId":"4b973e63-d19b-4221-a346-605727e40023","outcome":"completed","summary":"Both findings are recorded in `.imd-findings.json` with verified reproductions, and the file validates as JSON. Final recap:\n\n**What I reviewed**\n\n- Questions: `request`, `onOracleResult`, `submit`, `abandon`, `_settle`, `_returnTip`, the meter share accounting (`_fund`, `defund`, epoch retirement, rescaling), the keeper-tip ledger (`_tips`, `totalTips`), and the question hash rebuilt in `QuestionBody`.\n- Payroll: `_book` and the 50/30/20 split, `_levelStream` and `drip` with its cap, `claimSeat`/`claimSeats`, `tip`/`claimTip`/`sweepTip` through the Adapter, `fundFeeds`/`_shortfall`/`_fund`/`_ageFeedBudget`.\n- Checked each against the Economic Security, Invariant and Flow Gap guides and against the limits already listed in docs/THREATS.md. The conservation invariants in both contracts hold on every path I traced, and the Q1 to Q15 and P1 to P9 limits are accurately disclosed.\n\n**Findings (both medium, both in Payroll's feed budget, both confirmed by scratch tests)**\n\n1. **A booking ordered before `fundFeeds` at a fortnight boundary releases the whole aged budget to stakers.** `_book` ages the budget without serving the meters, unlike `fundFeeds`. A one-wei `pay` or `book` landing first in the boundary block sends the old tranche to the staker stream and the house meters get nothing. Verified: same state, `fundFeeds` first places 16.64 IMD and releases the rest; `pay(1)` first releases all 200 IMD and the meters stay empty.\n\n2. **`_shortfall` measures the whole meter balance, not Payroll's own share.** A stranger can fill the three meters in the transaction before `fundFeeds` at a boundary and defund in the one after, getting 100% back. Payroll reads no shortfall, releases the entire tranche to stakers, and can no longer refill the meters. This contradicts THREATS P6, which says Payroll tops the meter up again at the next call.\n\nSuggested fixes are in the file: skip the release inside `_book` while a house meter is short, and size the shortfall from Payroll's own redeemable position on the meter.\n\n**Not found / not run**\n\n- No defect in the Questions economics beyond what THREATS already discloses. The tip set-aside, the per-feed `usedBy` mark, the share-worth bound and the returned-tip handling all behaved as documented.\n- I did not run the full suite, the fork tests or Slither. Proof-test files were not required since no finding is critical or high. Scratch tests are under test/scratch/ and nothing else in the tree was changed.","treeHash":null,"usage":{"cachedInputTokens":3032414,"inputTokens":580,"model":"claude-fable-5-1","outputTokens":59008,"runtime":"claude","turns":33,"wallClockMs":1556231}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1ca477e8d9b58040","findings":[{"citation":"resolved","description":"The stream's new end after a booking is the amount-weighted average of the time the unvested money had left and seven days. Because the unvested money is pooled and vests linearly to that later end, the rate at which the OLD money vests drops whenever the new amount is large relative to what was left: the vesting rate after the booking is (remaining + added) / duration, which is below remaining / timeLeft whenever timeLeft * (2 * remaining + added) < 7 days * remaining. The contract notice (Payroll.sol lines 62-64: 'no sequence of bookings can hold back money that was already on its way'), the drip doc (lines 452-453: 'a booking cannot be used to slow the stream ... money already vesting never waits longer because more arrived'), docs/THREATS.md P3 ('Bookings neither slow nor rush the stream') and the comment on test_booking_neverPushesTheEndPastSevenDaysOrBeforeTheOldEnd ('The old money is never made to wait longer by a booking') all state the opposite, while that same test asserts endsAt >= oldEnd, i.e. that the old money's end does move later. Only the dust case (one wei moves the end by nothing) and the seven-day ceiling are actually guaranteed. The delay is bounded (never past now + 7 days) and costs the payer 50% of the booking to stakers, so it is not an economic exploit; it is a false invariant in the written guarantees that a reader or a later launch could rely on. Fixing it means either rewording the four statements to what holds (a booking never pushes any money past seven days from the booking; a booking of dust moves nothing; a booking can delay money already vesting by up to that ceiling) or changing decision 2 so the old tranche keeps its own schedule, which is a design decision for the requester.","line":978,"path":"src/payroll/Payroll.sol","reproduction":"State: Payroll with a staker in sIMD. (1) pay(336e18): 168e18 goes to the stakers' stream, end = t0 + 7 days. (2) warp to t0 + 6 days: stream() reports unvested = 24e18, endsAt = now + 1 day. (3) pay(48e18): 24e18 more to stakers; stream() now reports unvested = 48e18 and endsAt = now + 4 days ((24e18*1d + 24e18*7d)/48e18 = 4 days), 3 days later than before. (4) warp to the old end (now + 1 day): expected under the documented guarantee that the original 24e18 has fully vested; actual stream() unvested = 36e18, so only 12e18 of the pooled 48e18 vested over that day where the old stream alone would have vested 24e18. Verified with a Foundry test under test/scratch (test_bookingDelaysMoneyAlreadyVesting), which logged 'new end minus old end, seconds: 259200' and 'unvested at the old end (wei): 36000000000000000000'.","severity":"low","snippet":"        uint256 duration = (remaining * timeLeft + added * STREAM_DURATION) / unvested;","title":"Weighted re-levelling delays money already vesting; the code, docs/THREATS.md P3 and a test comment claim it cannot"}],"hash":"db68729e4d7065e1ec1b578a80e3ce803e6e24a2983c25ce121c0e66f28a44c9","nodeId":"aaa31df2-9f99-4e5d-a5b2-50d430ae33a5","outcome":"completed","summary":"Review complete. The findings file at the repository root holds one substantiated finding; the JSON parses and the anchored line and quoted comments match the tree.\n\n**What I covered.** All of `Questions.sol`, `Payroll.sol`, `QuestionBody.sol` and `OracleAttestation.sol`, plus `JobsReport.sol` more lightly, against the Math Precision, Boundary and Numerical Gap guides. For each assigned area I traced the arithmetic with concrete values:\n\n- **Meter shares.** `totalShares >= balance` holds through fund, request, defund and the returned tip, so a share is never worth more than a wei and a later funder cannot be rounded out. The rescale loop terminates, cannot overflow, and the per-holder loss is under one wei. The retire-on-zero epoch and the tip returned to a meter whose shares were retired behave exactly as Q5 and Q6 disclose.\n- **Keeper tip.** The packed pending and earned halves balance in every path. The tip leaves the meter only at request, is credited only in `_settle`, and returns only in `abandon`. Conservation of balance against `totalMetered + totalTips` holds across all three.\n- **Callback and submit.** Window, chain, type, panel, quorum, agreed and block-hash checks are exact. The per-feed replay mark and the request-id mapping prevent the cross-round reuse I tried. The on-chain question hash matches the body construction byte for byte, and the body-length bound uses the correct 52-byte window allowance.\n- **Payroll split, seat pool and tips.** The 50/30/20 split conserves every wei with the remainder to stakers. The seat carry keeps `seatAccrued * SEAT_COUNT + seatCarry` exact. The three-tranche tip ageing gives each tip its own 180 to 360 day wait, and seat ids 0 to 1999 match the mainnet fixture. The Adapter and seat-token reads are gas-floored so starving cannot flip a result.\n- **Stream and drip cap.** Duration is always between one second and seven days, no division can hit zero, and the cap is never below what empties a non-empty stream.\n\n**The one finding (low).** A booking delays money that was already vesting, while the contract notice, the `drip` comment, `docs/THREATS.md` P3 and a test comment all claim it cannot. With 24 IMD and one day left, a booking that adds 24 IMD moves the end to four days out, and at the old end 36 of the pooled 48 IMD is still unvested. I confirmed this with a scratch test. The delay is bounded by seven days and costs the payer half the booking, so it is a false written guarantee rather than an exploit. The fix is a wording change or a design decision on decision 2, so no proof test is attached.\n\n**Not verified.** No fork tests ran, and Slither was not run. The existing unit, fuzz and both invariant suites pass locally. A clean result on the remaining paths is evidence, not proof.","treeHash":null,"usage":{"cachedInputTokens":2479778,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":62285,"runtime":"claude","turns":34,"wallClockMs":1061920}}],"verification":[]}