# Audit report

> 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.

| | |
|---|---|
| Repository | https://github.com/OoJae/help-wanted.git |
| Commit | `5c049e0f703bde97f0255368bc124254161b637e` |
| Job | `be91e8ae-9f45-495f-a318-60fab672a92e` |
| Judged | 2026-10-13 00:51 UTC |
| Findings | 1 medium · 2 low · 1 info |

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

## Findings

### 1. Medium: A booking that crosses a fortnight boundary releases the old feed tranche to stakers before the meters are served

`src/payroll/Payroll.sol:838`

```
        uint256 released = _ageFeedBudget();
```

`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.

**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).

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {Payroll} from "src/payroll/Payroll.sol";
import {Questions} from "src/questions/Questions.sol";

contract ProofIMD is ERC20 {
    constructor() ERC20("IMD", "IMD") {}

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

contract ProofVault is ERC4626 {
    constructor(IERC20 asset_) ERC20("sIMD", "sIMD") ERC4626(asset_) {}
}

/// @dev Stands in for the Intake: only `priceOf` is read by `Questions.costOf`.
contract ProofIntake {
    mapping(bytes32 => mapping(address => uint256)) public priceOf;

    function setPrice(bytes32 action, address asset, uint256 amount) external {
        priceOf[action][asset] = amount;
    }
}

contract Nothing {}

/// @dev Finding: `_book` runs `_ageFeedBudget()` and releases the old feed tranche at a fortnight
/// boundary without serving the house meters, although `fundFeeds` (lines 690-694) and
/// docs/THREATS.md P7 say the meters are served first and only what they leave is released.
/// Fails on the current code: a one-wei `pay` ordered before `fundFeeds` in the first block of the
/// fortnight sends the whole old tranche to stakers and leaves the meters empty.
contract ProofBudgetBook is Test {
    bytes32 constant ACTION = bytes32("oracle.request@oracle-1");
    uint256 constant COST = 0.52e18;
    uint256 constant RUNWAY = 14 * COST;
    uint256 constant RUNWAY_C = 4 * COST;
    uint256 constant FULL = 2 * RUNWAY + RUNWAY_C;

    ProofIMD imd;
    ProofVault simd;
    ProofIntake intake;
    Questions questions;
    Payroll payroll;
    bytes32 feedA;
    bytes32 feedB;
    bytes32 feedC;
    address payer = makeAddr("payer");
    address staker = makeAddr("staker");

    function setUp() public {
        vm.warp(1456 * 14 days + 3 days);
        vm.roll(26_000_000);
        imd = new ProofIMD();
        simd = new ProofVault(IERC20(address(imd)));
        intake = new ProofIntake();
        questions = new Questions(address(intake), address(imd), makeAddr("oracle signer"));
        feedA = _createFeed("payrolls");
        feedB = _createFeed("hires");
        feedC = _createFeed("employers");
        Nothing n = new Nothing();
        payroll = new Payroll(
            address(imd), address(simd), address(n), 2000, address(n), address(questions), feedA, feedB, feedC
        );
        imd.mint(staker, 1e18);
        vm.startPrank(staker);
        imd.approve(address(simd), type(uint256).max);
        simd.deposit(1e18, staker);
        vm.stopPrank();
        imd.mint(payer, 1e30);
        vm.prank(payer);
        imd.approve(address(payroll), type(uint256).max);
    }

    function _createFeed(string memory question) internal returns (bytes32) {
        Questions.FeedParams memory p;
        p.action = ACTION;
        p.chainId = uint64(block.chainid);
        p.answerType = 3;
        p.panelSize = 3;
        p.quorum = 2;
        p.minAgreed = 1;
        p.validFor = 3600;
        p.cadenceBlocks = 100;
        p.maxWindowBlocks = 200;
        p.lagBlocks = 12;
        p.pinHash = true;
        p.maxPrice = type(uint96).max;
        bytes memory prefix = bytes.concat(
            '{"answerType":"uint256","chainId":',
            bytes(vm.toString(block.chainid)),
            ',"question":"',
            bytes(question),
            '","v":1,"window":{"fromBlock":'
        );
        bytes memory tail = ',"panelSize":3,"quorum":2,"validForSeconds":3600}';
        return questions.createFeed(p, prefix, tail);
    }

    function _pay(uint256 amount) internal {
        vm.prank(payer);
        payroll.pay(amount, bytes32("x"));
    }

    function _meter(bytes32 feedId) internal view returns (uint256 balance) {
        (balance,) = questions.meterOf(feedId);
    }

    function test_oneWeiBookingAtTheBoundaryMustNotStarveTheMeters() public {
        // The action is not sold yet, so nothing is placeable: 200 IMD of budget can only wait.
        _pay(1_000e18);
        uint256 period = block.timestamp / 14 days;
        vm.warp((period + 1) * 14 days + 1);
        (, uint256 released) = payroll.fundFeeds();
        assertEq(released, 0, "the first boundary only ages the budget");
        (, uint256 old,,) = payroll.feedBudget();
        assertEq(old, 200e18);

        // The feeds become fundable with empty meters.
        intake.setPrice(ACTION, address(imd), COST);
        assertEq(questions.costOf(feedA), COST);
        uint256[3] memory needs = payroll.feedShortfalls();
        assertEq(needs[0] + needs[1] + needs[2], FULL, "the meters lack one runway each");

        // Second boundary. A one-wei booking lands before fundFeeds in the same block.
        vm.warp((period + 2) * 14 days + 1);
        _pay(1);
        (uint256 funded,) = payroll.fundFeeds();

        // Documented (Payroll.sol:690-694, THREATS P7): the meters are served before anything of the
        // old tranche is released, so the three runways are placed and 183.36 IMD goes to stakers.
        assertEq(funded, FULL, "the meters were not served before the old tranche was released");
        assertEq(_meter(feedA), RUNWAY);
        assertEq(_meter(feedB), RUNWAY);
        assertEq(_meter(feedC), RUNWAY_C);
        assertLe(payroll.ledger().feedsReleased, 200e18 - FULL, "budget the meters needed was released to stakers");
    }
}
```

### 2. Low: 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

`src/payroll/Payroll.sol:1047`

```
        return Math.saturatingSub(cost * requests, balance);
```

`_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...).

**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).

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {Payroll} from "src/payroll/Payroll.sol";
import {Questions} from "src/questions/Questions.sol";

contract ProofIMD is ERC20 {
    constructor() ERC20("IMD", "IMD") {}

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

contract ProofVault is ERC4626 {
    constructor(IERC20 asset_) ERC20("sIMD", "sIMD") ERC4626(asset_) {}
}

/// @dev Stands in for the Intake: only `priceOf` is read by `Questions.costOf`.
contract ProofIntake {
    mapping(bytes32 => mapping(address => uint256)) public priceOf;

    function setPrice(bytes32 action, address asset, uint256 amount) external {
        priceOf[action][asset] = amount;
    }
}

contract Nothing {}

/// @dev Finding: `_shortfall` sizes a meter's need from the pooled meter balance (`meterOf`), not
/// from Payroll's own position, so IMD a stranger parks on the house meters around a boundary
/// `fundFeeds` makes Payroll place nothing and release the whole old tranche to stakers; the
/// stranger then takes every wei back. docs/THREATS.md P6 says such parking is harmless because
/// the meter is topped up again at the next call; across a boundary the budget is gone by then.
contract ProofBudgetPark is Test {
    bytes32 constant ACTION = bytes32("oracle.request@oracle-1");
    uint256 constant COST = 0.52e18;
    uint256 constant RUNWAY = 14 * COST;
    uint256 constant RUNWAY_C = 4 * COST;
    uint256 constant FULL = 2 * RUNWAY + RUNWAY_C;

    ProofIMD imd;
    ProofVault simd;
    ProofIntake intake;
    Questions questions;
    Payroll payroll;
    bytes32 feedA;
    bytes32 feedB;
    bytes32 feedC;
    address payer = makeAddr("payer");
    address staker = makeAddr("staker");
    address stranger = makeAddr("stranger");

    function setUp() public {
        vm.warp(1456 * 14 days + 3 days);
        vm.roll(26_000_000);
        imd = new ProofIMD();
        simd = new ProofVault(IERC20(address(imd)));
        intake = new ProofIntake();
        questions = new Questions(address(intake), address(imd), makeAddr("oracle signer"));
        feedA = _createFeed("payrolls");
        feedB = _createFeed("hires");
        feedC = _createFeed("employers");
        Nothing n = new Nothing();
        payroll = new Payroll(
            address(imd), address(simd), address(n), 2000, address(n), address(questions), feedA, feedB, feedC
        );
        imd.mint(staker, 1e18);
        vm.startPrank(staker);
        imd.approve(address(simd), type(uint256).max);
        simd.deposit(1e18, staker);
        vm.stopPrank();
        imd.mint(payer, 1e30);
        vm.prank(payer);
        imd.approve(address(payroll), type(uint256).max);
        imd.mint(stranger, 100e18);
        vm.prank(stranger);
        imd.approve(address(questions), type(uint256).max);
    }

    function _createFeed(string memory question) internal returns (bytes32) {
        Questions.FeedParams memory p;
        p.action = ACTION;
        p.chainId = uint64(block.chainid);
        p.answerType = 3;
        p.panelSize = 3;
        p.quorum = 2;
        p.minAgreed = 1;
        p.validFor = 3600;
        p.cadenceBlocks = 100;
        p.maxWindowBlocks = 200;
        p.lagBlocks = 12;
        p.pinHash = true;
        p.maxPrice = type(uint96).max;
        bytes memory prefix = bytes.concat(
            '{"answerType":"uint256","chainId":',
            bytes(vm.toString(block.chainid)),
            ',"question":"',
            bytes(question),
            '","v":1,"window":{"fromBlock":'
        );
        bytes memory tail = ',"panelSize":3,"quorum":2,"validForSeconds":3600}';
        return questions.createFeed(p, prefix, tail);
    }

    function _pay(uint256 amount) internal {
        vm.prank(payer);
        payroll.pay(amount, bytes32("x"));
    }

    function _meter(bytes32 feedId) internal view returns (uint256 balance) {
        (balance,) = questions.meterOf(feedId);
    }

    function test_strangersParkedIMDMustNotTurnTheOldTrancheIntoStakerStream() public {
        _pay(1_000e18); // 200 IMD of budget, nothing placeable while the action is not sold
        uint256 period = block.timestamp / 14 days;
        vm.warp((period + 1) * 14 days + 1);
        payroll.fundFeeds(); // old tranche = 200 IMD
        intake.setPrice(ACTION, address(imd), COST);
        vm.warp((period + 2) * 14 days + 1);

        // The stranger parks one runway on each meter in the transaction before fundFeeds.
        vm.startPrank(stranger);
        questions.fund(feedA, RUNWAY);
        questions.fund(feedB, RUNWAY);
        questions.fund(feedC, RUNWAY_C);
        vm.stopPrank();

        (uint256 funded, uint256 released) = payroll.fundFeeds();

        // And takes it all back in the transaction after.
        vm.startPrank(stranger);
        questions.defund(feedA, type(uint256).max);
        questions.defund(feedB, type(uint256).max);
        questions.defund(feedC, type(uint256).max);
        vm.stopPrank();
        assertEq(imd.balanceOf(stranger), 100e18, "the stranger's float came back whole");

        // Documented (THREATS P6, P7): Payroll's own position on each meter is one runway and only
        // what the meters did not need is released. Actual: nothing placed, everything released.
        assertEq(funded, FULL, "Payroll placed nothing because it counted the stranger's IMD");
        assertLe(released, 200e18 - FULL, "budget the meters needed went to stakers");
        assertEq(questions.redeemable(feedA, address(payroll)), RUNWAY, "feed A has no runway of Payroll's own");
        assertEq(_meter(feedA) + _meter(feedB) + _meter(feedC), FULL, "the meters are empty after the defund");
    }
}
```

### 3. Low: Stream re-levelling delays money already vesting, contrary to the contract notice, the drip NatSpec and docs/THREATS.md P3

`src/payroll/Payroll.sol:978`

```
        uint256 duration = (remaining * timeLeft + added * STREAM_DURATION) / unvested;
```

`_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.

**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.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {Payroll} from "src/payroll/Payroll.sol";

contract ScratchIMD is ERC20 {
    constructor() ERC20("IMD", "IMD") {}

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

contract ScratchVault is ERC4626 {
    constructor(IERC20 asset_) ERC20("sIMD", "sIMD") ERC4626(asset_) {}
}

/// @dev Minimal stubs: nothing in these tests touches seats, the Adapter or Questions.
contract Nothing {}

contract StreamTailScratch is Test {
    ScratchIMD imd;
    ScratchVault simd;
    Payroll payroll;
    address payer = makeAddr("payer");
    address staker = makeAddr("staker");

    function setUp() public {
        vm.warp(1_791_600_000);
        vm.roll(26_000_000);
        imd = new ScratchIMD();
        simd = new ScratchVault(IERC20(address(imd)));
        Nothing n = new Nothing();
        payroll = new Payroll(
            address(imd), address(simd), address(n), 2000, address(n), address(n), bytes32("a"), bytes32("b"), bytes32("c")
        );
        imd.mint(payer, 1e30);
        vm.prank(payer);
        imd.approve(address(payroll), type(uint256).max);
    }

    function _stake() internal {
        imd.mint(staker, 1e18);
        vm.startPrank(staker);
        imd.approve(address(simd), type(uint256).max);
        simd.deposit(1e18, staker);
        vm.stopPrank();
    }

    function _pay(uint256 amount) internal {
        vm.prank(payer);
        payroll.pay(amount, bytes32("x"));
    }

    /// NatSpec on `Payroll` (lines 62-64, 451-454) and docs/THREATS.md P3 say money already vesting
    /// "never waits longer because more arrived" and bookings "neither slow nor rush the stream".
    /// Actual: a booking large next to what is left pushes the stream's end back by days, and the
    /// old money vests on the new, longer schedule.
    function test_largeBookingDelaysMoneyAlreadyVesting() public {
        _stake();
        // 200 IMD paid -> 100 IMD to the stakers' stream over seven days.
        _pay(200e18);
        (, uint256 unvested0,,,,) = payroll.stream();
        assertEq(unvested0, 100e18);

        // Drip hourly for 6 days 23 hours so that the backlog is sent as it vests.
        for (uint256 i = 0; i < 167; ++i) {
            vm.warp(vm.getBlockTimestamp() + 1 hours);
            payroll.drip();
        }
        (uint256 outstanding1, uint256 unvested1, uint256 endsAt1,,,) = payroll.stream();
        // About one hour and 100/168 IMD of the first stream are left.
        assertApproxEqAbs(unvested1, uint256(100e18) / 168, uint256(1e15));
        assertEq(endsAt1 - vm.getBlockTimestamp(), 1 hours);
        uint256 oldEnd = endsAt1;
        uint256 oldRemaining = unvested1;

        // A second payer books 20 IMD now (10 IMD more for the stream).
        _pay(20e18);
        (uint256 outstanding2, uint256 unvested2, uint256 endsAt2,,,) = payroll.stream();
        assertEq(unvested2, oldRemaining + 10e18);
        uint256 backlogAtBooking = outstanding2 - unvested2;

        // Expected by the NatSpec: the old remaining (about 0.595 IMD) is still fully vested by
        // `oldEnd`, one hour from now. Actual: the end moved back by more than six days and by
        // `oldEnd` only about 0.067 IMD has vested.
        assertGt(endsAt2, oldEnd + 6 days, "end moved back by more than six days");
        vm.warp(oldEnd);
        (uint256 outstanding3, uint256 unvested3,,,,) = payroll.stream();
        uint256 vestedSinceBooking = (outstanding3 - unvested3) - backlogAtBooking;
        emit log_named_uint("old remaining at booking", oldRemaining);
        emit log_named_uint("vested by the old end   ", vestedSinceBooking);
        emit log_named_uint("end moved back by (s)   ", endsAt2 - oldEnd);
        assertGe(vestedSinceBooking, oldRemaining, "money already vesting waited longer because more arrived");
        outstanding1;
    }

    /// `stream().dueNow` is documented as "what `drip` would send if called in this block"; it ignores
    /// the vault check that makes `drip` revert, so a keeper that trusts the view sends a reverting call.
    function test_viewSaysDueButDripReverts() public {
        // No staker in sIMD.
        _pay(200e18);
        vm.warp(vm.getBlockTimestamp() + 1 hours);
        (,,,,, uint256 dueNow) = payroll.stream();
        assertGt(dueNow, 0, "view reports something due");
        vm.expectRevert(Payroll.StakingVaultNotReady.selector);
        payroll.drip();
    }
}
```

### 4. Info: stream().dueNow reports an amount that drip() refuses to send while sIMD is not ready

`src/payroll/Payroll.sol:759`

```
        dueNow = block.timestamp < nextDripAt ? 0 : Math.min(outstanding - unvested, perDrip);
```

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.

**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.

---

Judge's submission `302b1914914dde8bc9e94aff6ef0c38a63a99eee5b242a2427483e6b0fe956b8`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
