# Audit report

> Audit the contracts in OoJae/help-wanted as they are.

| | |
|---|---|
| Repository | https://github.com/OoJae/help-wanted.git |
| Commit | `5c049e0f703bde97f0255368bc124254161b637e` |
| Job | `22624be0-d2da-400e-91c4-51dac0a9a7f5` |
| Judged | 2026-10-13 01:30 UTC |
| Findings | 1 medium · 2 low · 2 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: Just-in-time sIMD deposits around each drip take the same share of the staker stream as week-long stakers; the drip cap only sets the number of round trips

`src/payroll/Payroll.sol:488`

```
        IMD.safeTransfer(SIMD, sent);
```

drip pays the stakers' stream to sIMD by plain transfer, and sIMD prices its shares from its IMD balance (NatSpec lines 431-438; MockSIMD in test/payroll/mocks/MockTokens.sol is an OpenZeppelin ERC4626 with no cooldown). The only protection against a short visit is the per-call cap (one hour of stream, dripCap) and the ten-minute gap, with the stated reasoning that a short visit 'can then collect only its share of one small transfer per round trip, which is not worth the round trip, and the stakers who stay receive the rest'. Nothing ties a drip to who was staked before it and drip is open to anyone, so the visitor chooses the moment: deposit into sIMD, call drip, redeem, all in one transaction, at every drip. Each round trip takes the visitor's pro-rata share of that drip, and the sum over the stream is its pro-rata share of the whole stream, exactly what a staker who stayed the full seven days receives. The cap does not reduce the visitor's take; it only fixes how many transactions it is spread over (168 for one booking, six an hour while a backlog is worked off). Capital that is never locked and never at risk dilutes honest stakers by S/(S+H) of every drip, for gas only. docs/THREATS.md P2 acknowledges a visitor that stays for a backlog, but the mitigation it names (somebody dripping once an hour) does not help, because the visitor can be the dripper. The existing test test_shortVisitCollectsAtMostItsShareOfOneDrip checks one round trip and never repeats it. Whether the real sIMD (0x9efa934d9fad4ae28c998a40195646b965a97247) enforces a deposit cooldown is not verified here (no fork); the repository's own model of it has none and the documentation does not claim one. Fix options all touch decision 3, so the requester must choose: (a) stream to a reward contract that accounts time-weighted (checkpointed reward-per-share) instead of a plain transfer to the vault; (b) keep Payroll as is and require that sIMD enforce a minimum holding period or redeem cooldown, documented as a trust assumption on T5; or (c) at least replace the 'not worth the round trip' claim in the NatSpec (lines 436-438) and THREATS.md P2 with the real figure.

**Reproduction**

Reproduced with test/scratch/JitStake.t.sol on the repository's PayrollSetup (1,000e18 IMD staked in sIMD by one honest staker). pay(336e18) books 168e18 to the stream over 7 days; dripCap == 1e18. Visitor holds 1,000e18 IMD and no sIMD. Loop 168 times: skip(1 hours); visitor simd.deposit(all IMD); visitor calls payroll.drip() (sends 1e18); visitor simd.redeem(all shares), all in the same transaction. Expected (per NatSpec lines 436-438 and THREATS.md P2): the short visit collects only its share of one small transfer and the stakers who stay receive the rest. Actual (measured): stream sent 168e18 in 168 drips; visitor gain 83,999,999,999,999,999,832 wei (half of the whole stream); the honest staker's gain is 84,000,000,000,000,000,167 wei instead of the 168e18 it would receive with no visitor. The visitor's capital was in the vault for zero blocks at a time.

### 2. Low: Anyone can make fundFeeds place nothing by sandwiching it with fund/defund on the house meters, starving the feeds until the budget is released to stakers

`src/payroll/Payroll.sol:1047`

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

_shortfall sizes each top-up from the meter's whole current balance as Questions.meterOf reports it inside the same transaction, and Questions.defund is never blocked. A stranger therefore controls what Payroll sees: in one transaction it funds the three house meters to their runways, calls fundFeeds (every shortfall reads zero, nothing is placed, the budget stays in Payroll), and defunds everything, leaving the meters as empty as before and its own IMD intact (defund rounds down, so it loses at most dust). Budget that no meter takes is released to the staker stream by _ageFeedBudget 14 to 28 days after it was booked. A stranger that front-runs every honest fundFeeds call for that long (each is visible in the public mempool; the sandwich is three funds, one fundFeeds, three defunds, about 1.1 million gas measured) keeps the meters unfunded, so requests on the house feeds are blocked (MeterTooLow), the Jobs Report stops printing, and the whole 20% feed budget for the period goes to stakers instead. A stranger that holds sIMD profits by its share of that 20%; everybody else only loses the feeds. docs/THREATS.md P6 notes that a stranger can fill a meter and empty it again and that the next fundFeeds call tops it up, but not that the same party can be the one making that call and empty it in the same transaction, repeatedly, until the budget has aged away. Mitigations that keep the design: in _shortfall count only Payroll's own stake on the meter (Questions.redeemable(feedId, address(this))) rather than the meter's whole balance, so a stranger's transient funding cannot reduce the top-up (this changes test_fundFeeds_countsWhatOthersAlreadyPutOnAMeter, which asserts the current rule); or have keepers send fundFeeds through a private relay, documented in THREATS.md P6.

**Reproduction**

Reproduced with test/scratch/FeedSandwich.t.sol on PayrollSetup with each house feed costing 0.52e18 per request. pay(200e18) books a feed budget of 40e18; the three meters are empty; stranger holds 100e18 IMD. In one transaction the stranger calls questions.fund(FEED_A, 7.28e18), questions.fund(FEED_B, 7.28e18), questions.fund(FEED_C, 2.08e18), payroll.fundFeeds(), then questions.defund on each of the three with type(uint256).max. Expected: fundFeeds places 16.64e18 on the meters (the unsandwiched call in the same file does place 16.64e18). Actual (measured): fundFeeds returned funded = 0, owed().feeds is still 40e18, meter A's balance after the defund is 0, and the stranger's balance is back to exactly 100e18. Repeating the sandwich once a day for 29 days: every call returns funded = 0, the ageing step releases the full 40e18 to the staker stream, owed().feeds becomes 0 and the meters never receive a wei.

### 3. Low: Stream re-levelling delays money that was already vesting; NatSpec and THREATS.md P3 claim it cannot

`src/payroll/Payroll.sol:978`

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

_levelStream joins the unvested remainder R (with timeLeft T) and a new amount A into one stream whose length is the amount-weighted average (R*T + A*7d)/(R+A). The contract states the opposite property three times: 'no sequence of bookings can hold back money that was already on its way. Stakers are paid on that schedule, never less' (lines 63-64), 'money already vesting never waits longer because more arrived' (lines 452-453), and docs/THREATS.md P3 'Stakers are paid in full on that schedule'. The merged stream vests (R+A)*T/E by the old end, which is below R whenever (2R + A)*T < 7d*R, i.e. for every booking of A < R*(7d - 2T)/T when less than 3.5 days remain, and the end moves by a lot whenever A is comparable to R or R is tiny (below about 604,800 wei). Ordinary fee traffic (a payment of a tenth of what is still vesting, one day before the end) pushes part of the old money about 13 hours later; a booking equal to the remainder one hour before the end pushes it 3.5 days later. dripCap is also re-sized to the slower rate (598,781,179,138,321,996 wei in the one-hour case), so the delayed money leaves more slowly too. Anyone can trigger it with pay or book at any time, at the cost of the booked amount; no money is lost and no actor profits, so this is a specification and accounting defect rather than a theft path. The existing tests check only that the end never passes seven days and that a 1-wei booking moves nothing (test_booking_ofDustDoesNotMoveTheEnd, test_booking_hourlyDustDoesNotStretchTheStream, testFuzz_booking_weightedEndIsBetweenTheTwoTimes); none checks a booking of ordinary size. Fix, one of: (1) correct the NatSpec at lines 60-64 and 450-455 and THREATS.md P3 to the guarantee the code gives (the end is a weighted average and may move later, never past seven days; only the total, not its timing, is guaranteed), or (2) if the stated guarantee is wanted, keep the old remainder on its own schedule (track two tranches, or set the new end to max(old end, ...) with rate = old rate + A/7d so that unvested(t) = R*(T-t)/T + A*(7d-t)/7d), which changes decision 2 and is the requester's call. The attached proof asserts the documented property and fails on the current code; it is the right test only if option (2) is chosen. Merged from the audit_economics and audit_math reports of the same mechanism.

**Reproduction**

Reproduced with test/scratch/Proof_b6341558822d.t.sol (the audit_math proof, fails on this code) and test/scratch/Misc.t.sol. Deploy Payroll with any ERC-20; pay(200e18) at t0 (staker share 100e18, end = t0+7d). (a) Warp to t0+7d-1h: stream() reports unvested R = 595238095238095239, endsAt = t0+7d. Call pay(2*R). Expected per NatSpec: by t0+7d at least R has vested since the booking. Actual: at t0+7d only 14088475626937165 wei has vested since the booking (unvested went from 1190476190476190479 to 1176387714849253314), endsAt moved to t0+7d+300600. (b) Warp to t0+6d instead: unvested = 14285714285714285715, endsAt = 1800604800. Call pay(2 * (unvested / 10)), adding 1.43e18. Actual: endsAt = 1800651927 (+47,127 s); at the old end stream() still reports 5546197719241373335 unvested, of which only 1.43e18 is new money; dripCap after the booking is 603741496598639456. (c) Dust: pay(2000), warp to end-100s (1 wei unvested), pay(1): endsAt moves from 1800604800 to 1800907150.

**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 {Payroll} from "src/payroll/Payroll.sol";

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

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

/// @dev Payroll's NatSpec: "no sequence of bookings can hold back money that was already on its way"
/// and "money already vesting never waits longer because more arrived". This asserts exactly that:
/// after a second booking, the money that was due by the old stream end must still have vested by
/// that time. It fails on the current `_levelStream`, which averages the two end times by amount.
contract StreamHoldsBackVestingMoneyProof is Test {
    ProofIMD internal imd;
    Payroll internal payroll;

    function setUp() public {
        vm.warp(1_800_000_000);
        imd = new ProofIMD();
        // No dependency is called by `pay` or `stream`, so placeholder addresses are enough.
        payroll = new Payroll(
            address(imd), address(0x51), address(0x52), 2000, address(0x53), address(0x54), bytes32("a"), bytes32("b"), bytes32("c")
        );
        imd.mint(address(this), 1_000_000e18);
        imd.approve(address(payroll), type(uint256).max);
    }

    function test_moneyAlreadyVestingIsNotHeldBackByALaterBooking() public {
        // Booking 1: a staker share of 100 IMD vests over seven days.
        payroll.pay(200e18, "one");
        (,, uint256 end0,,,) = payroll.stream();

        // One hour before the end, R = 0.595 IMD is still unvested and due within 3,600 s.
        vm.warp(end0 - 1 hours);
        (, uint256 remaining, uint256 endBefore,,,) = payroll.stream();
        assertEq(remaining, 595238095238095239);

        // Booking 2 with a staker share equal to R.
        payroll.pay(remaining * 2, "two");
        (, uint256 unvestedAfterBooking,,,,) = payroll.stream();

        // At the old end, everything that was already on its way must have vested.
        vm.warp(endBefore);
        (, uint256 unvestedAtOldEnd,,,,) = payroll.stream();
        uint256 vestedSinceBooking = unvestedAfterBooking - unvestedAtOldEnd;
        // Current code: 14088475626937165 (0.014 IMD) of the 0.595 IMD that was due has vested;
        // the rest now ends 300,600 s (3.5 days) later than it did before the booking.
        assertGe(vestedSinceBooking, remaining, "money already on its way was held back by a later booking");
    }
}
```

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

`src/payroll/Payroll.sol:759`

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

The view is documented as 'What drip would send if called in this block (zero inside the ten-minute gap)', but it omits the _stakingVaultReady() condition that drip enforces at line 481. While sIMD has no shares outstanding, does not answer asset() with IMD, or has no code, drip reverts StakingVaultNotReady for every amount, yet dueNow reports the vested backlog as sendable. A keeper or dashboard that triggers drips from dueNow submits reverting transactions, and an integrator that reads dueNow as 'what reaches stakers now' over-reports. Harmless to funds. Fix: return 0 from dueNow when !_stakingVaultReady() (two extra static calls in a view), or document the omission on stream().

**Reproduction**

Reproduced with test/scratch/Misc.t.sol (DueNowScratch). Deploy Payroll with simd = an address with no code (0x51). pay(200e18), skip 2 hours. stream() returns dueNow = 595238095238095239. Expected per the view's NatSpec: drip() sends that amount. Actual: drip() reverts StakingVaultNotReady and sends nothing while dueNow stays non-zero.

### 5. Info: JobsReport's BadWindow skip is unreachable from the pinned Questions and would permanently drop a round if it ever fired

`src/payroll/JobsReport.sol:406`

```
        if (fromBlock > toBlock || toBlock >= block.number) return SkipReason.BadWindow;
```

The round counters p.payrollsRead and p.hiresRead are advanced (lines 315 and 371) before _unusable is consulted, so any reason returned here consumes the round for good. Questions._window (src/questions/Questions.sol lines 1198-1218) guarantees fromBlock <= toBlock and toBlock = block.number - lagBlocks with lagBlocks at least 5 at request time, and an answer can only be stored later, so against the real Questions both conditions are always false: the branch is dead and SkipReason.BadWindow can never be emitted. Harmless today, but it is the only skip whose predicate depends on the moment print is called (block.number) rather than on the round itself, which contradicts the contract's own promise that 'what ends up in the report does not depend on who calls or when' (line 286) should the window source ever change. Fix: drop the time-dependent toBlock >= block.number test, or make it a break (come back to the round later) rather than a consuming skip.

**Reproduction**

Only reachable through the test harness: with JobsReportSetup, q.pushAnswered(PAYROLLS, 1e18, fromBlock = block.number - 100, toBlock = block.number + 5) then report.print() (test/scratch/Misc.t.sol, BadWindowScratch). Expected per the determinism claim: the round waits and is printed on a later call once toBlock < block.number. Actual: RoundSkipped(PAYROLLS, 1, BadWindow) is emitted, printed = 0, payrollsRead advances to 1, and after vm.roll(+10) print() still prints nothing; the round can never be printed. Against the pinned Questions the condition cannot be made true.

---

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