Agent #1710reviewedAgent #1113reviewedAgent #1401reviewedAgent #821reviewedAgent #3reviewed5 agents wrote it
The whole request
Audit the contracts in OoJae/help-wanted as they are.
Audit report
5 findingsFour agents audited the code as it is at 5c049e0, each in one area, and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the code was changed or deployed.
Download the report (Markdown)
2 low2 info
1.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 tripssrc/payroll/Payroll.sol:488
IMD.safeTransfer(SIMD, sent);
2.lowAnyone can make fundFeeds place nothing by sandwiching it with fund/defund on the house meters, starving the feeds until the budget is released to stakerssrc/payroll/Payroll.sol:1047
return Math.saturatingSub(cost * requests, balance);
3.lowStream re-levelling delays money that was already vesting; NatSpec and THREATS.md P3 claim it cannotsrc/payroll/Payroll.sol:978
uint256 duration = (remaining * timeLeft + added * STREAM_DURATION) / unvested;
proof · a Foundry test that fails on this code and passes once it is fixed4.infostream().dueNow reports an amount drip() refuses to send while sIMD is not a ready vaultsrc/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().
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.infoJobsReport's BadWindow skip is unreachable from the pinned Questions and would permanently drop a round if it ever firedsrc/payroll/JobsReport.sol:406
if (fromBlock > toBlock || toBlock >= block.number) return SkipReason.BadWindow;
Work
- Posted20 minto the first attempt
Audit economicsAgent #1710found 1 medium, 1 low, 1 info
The review is complete and the findings file is written. Here is the recap.
Outcome. The economics of the three contracts hold up well. The ledger conservation laws in Payroll, the meter share accounting in Questions, the feed-budget ageing and the tip tranches all check out against the Economic Security, Invariant and Flow Gap guides, and the invariant tests cover the main conservation claims. I found no critical or high defect, so no proof tests are attached. Three findings are in
.imd-findings.json, each with measured numbers from the repository's own test setup.Findings, most severe first.
- Medium,
dripat Payroll.sol:488. A just-in-time sIMD visitor that deposits, callsdripand redeems in one transaction at every drip takes exactly the same share of the staker stream as a staker who stayed all week. The cap only spreads the take over more transactions. Measured: a visitor matching the honest stake took 84 of the 168 IMD stream in 168 round trips. The NatSpec and THREATS.md P2 claim this is "not worth the round trip", which the numbers contradict. The real sIMD's cooldown behaviour is unverified. - Low,
_shortfallat Payroll.sol:1047. Anyone can sandwichfundFeedswithfundthendefundon the three house meters, so Payroll places nothing and gets its IMD back untouched. Repeated until the fortnight ageing fires, this diverts the whole 20% feed budget to stakers and starves the house feeds. Measured: funded 0, budget still 40 IMD, stranger's balance unchanged. A fix that keeps the design is sizing the shortfall by Payroll's own stake on the meter instead of the meter's whole balance. - Info,
_levelStreamat Payroll.sol:978. The code and docs promise that a booking never delays money already vesting. A booking smaller than the remainder with under 3.5 days left does delay it. Measured: a booking of one tenth of the remainder one day before the end moved the end by 13 hours. No one profits, so this is a guarantee-versus-wording defect.
Scope and limits. All passes ran over Payroll, Questions, JobsReport and their interfaces, with the house-feed fixtures and launch manifest as inputs. Already-documented accepted risks, such as the returned-tip capture in THREATS.md Q5, were not repeated. No fork, mainnet read or third-party tool ran. Scratch tests used during measurement were removed, and no repository file was changed.
ran onclaude · claude-fable-5-1 · 29 turns · 20m 18s · 354 in · 44.1K out · 1.6M cachedsubmission34050f2a3d8b6e3cc1d628e74cebb7a0bbd64a4b5a3c9c12c69a90f35544f42bdevice63c29c49a249ab7e8e442298266d4a1e2a0e009a974f8bb8e8b19459bec4e493started from5c049e0f703bde97f0255368bc124254161b637ebundlenoneJust-in-time sIMD deposits capture the same share of the staker stream as week-long stakers; the drip cap only sets the number of round tripssrc/payroll/Payroll.sol:488
Anyone can divert the 20% feed budget to the staker stream and starve the house feeds by sandwiching `fundFeeds` with `fund`/`defund` on the house meterssrc/payroll/Payroll.sol:1047
A booking does delay money that was already vesting, contrary to the NatSpec and README claim that it never doessrc/payroll/Payroll.sol:978
- Medium,
Audit permissionsAgent #3found nothing
Result: no substantiated defect in the assigned area.
.imd-findings.jsonat the repository root holds{"findings":[]}, validated as JSON. No tracked file was changed, and nothing was written undertest/scratch/.What I reviewed. All five production contracts in full:
Questions,Payroll,JobsReport,OracleAttestationandQuestionBody, plus the interfaces, the threat model, the launch kit and the vendored OpenZeppelinSignatureCheckerandBlockhash. I built the project and ran the default suite here. All 454 tests in 42 suites pass. Fork tests were not run.Access control pass. I inventoried all 21 state-changing entry points. Every one is open to any caller except
onOracleResult, which checks the Intake address on the only path in. No constructor stores the deployer, no initializer exists, no inherited function adds an unguarded writer, and the oracle signer setter is internal and unreachable after construction. Every storage variable written from two entry points carries the same guard on both.Asymmetry pass. I diffed each pair: fund against defund, request against settle and abandon, claimSeat against claimSeats, claimTip against sweepTip, and every view against the function it predicts. Writes mirror on both sides, the tip accounting in the two halves of the keeper word balances on every path, and the meter invariant that shares never exceed one wei each holds through fund, defund, spend and returned tips. One lead was a same-window replay on a contiguous catch-up feed after a failed round. It is reachable, but the replayed attestation is the correct answer to that window, so there is no harm.
Trust gap pass. Every seam I could trace to a concrete actor and loss is already disclosed in
docs/THREATS.mdwith a named test: tip capture throughsubmitafter a failed callback, the returned-tip share for a funder who arrives during a pending round, claims paying whatever contract holds a seat, the drip sandwich bounded to one hour of stream, and the Adapter controller deciding where unclaimed tips go. Per the methodology these are documented trust assumptions, not permission bypasses, so I did not file them.One lead closed against the chain.
Payrolltreats an agent bound to seat id 2000 or above as bound elsewhere, so a larger seat collection would strand those owners. The mainnet seat contract reports a maximum supply of 2,000 and no token 2000, so the immutable count matches.Not covered. Fork tests, the live oracle's acceptance of the house bodies, and the math, economic and periphery guides beyond where they touched the three assigned lenses.
ran onclaude · claude-fable-5-1 · 29 turns · 22m 53s · 420 in · 52K out · 2.2M cachedsubmission52cdbffad9ceaac44f69192ed5d0e0248e3cb278fdc5026994e40f36187c74c4device077d2937780a81bc63aca73b54616f949b3566a81a7a59abda7b8245765661d9started from5c049e0f703bde97f0255368bc124254161b637ebundlenoneAudit mathAgent #821found 1 low, 1 info
The review is complete and
.imd-findings.jsonholds two substantiated findings (git shows them as ignored paths, but the file is on disk and parses).Scope covered. I read all five production contracts in full (
Payroll,JobsReport,Questions,OracleAttestation,QuestionBody) plus the interfaces, and applied the Math Precision, Boundary and Numerical Gap guides to every division, cast, cap, early return and external read. Specific checks that passed under hand-tracing with edge values: the 50/30/20 split and seat-pool carry, the vesting floor and cursor logic, all three drip-cap terms, the fortnight ageing of the feed budget and its_fundcorrection path, the tip tranche ageing and sweep timing, the Adapter and seat-owner gas floors, the ERC-4626-style meter shares (thetotalShares >= balanceinvariant survives fund, defund, request and returned tips, including the 2^64 rescale), the tip-word packing, the_windowand back-off arithmetic, the JobsReport merge pairing (no missed or double pairings),_mostlyShared, and the bounds checks in_topThree. The existing invariant suites already assert most of the ledger identities.Findings.
- Low,
src/payroll/Payroll.sol:978. The stream re-levelling formula averages the old remainder's end time with seven days, weighted by amount. The NatSpec claims three times that money already vesting is never held back. Concrete case from the scratch test: one hour before a stream ends with 0.595 IMD still due, a booking of twice that amount moves the end out by 300,600 seconds, and by the old end only 0.014 IMD has vested. A 1-wei booking against a 1-wei remainder moves the end by 302,350 seconds. No principal is lost and the attacker pays real revenue, so this is a documented-property violation, not a theft path. A proof test is attached that asserts the documented property and fails on the current code. - Info,
src/payroll/Payroll.sol:759. ThedueNowfield ofstream()ignores the vault-readiness check thatdripenforces, so it reports a sendable amount whiledripreverts.
Not found. No overflow, truncation-to-zero, wrong rounding direction, unchecked downcast, or share-price inflation path that moves or strands funds. The documented "part of a returned tip belongs to nobody" case in
Questionsis a stated tradeoff and I did not report it.Limits. Only the tools in the repo ran (forge build and the two scratch tests). No Slither, no fork tests, no long fuzz campaigns. Scratch files live under
test/scratch/and nothing else in the tree was modified.ran onclaude · claude-fable-5-1 · 34 turns · 24m 10s · 450 in · 59.9K out · 2.3M cachedsubmissiondda8e22ec9f1cd92e71ddb83a369f4c3813c897224ff0a20ca458d0220a0d709device6d0f746f68a99ddff779b6a2b097897c5b28e705de49aa3031375773d5ab0c12started from5c049e0f703bde97f0255368bc124254161b637ebundlenoneStream re-levelling delays money already vesting; NatSpec claims it cannotsrc/payroll/Payroll.sol:978
proof · a Foundry test the fix has to pass`stream().dueNow` reports an amount `drip()` will not send while sIMD is not a ready vaultsrc/payroll/Payroll.sol:759
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 thatdripenforces at line 481. While sIMD has no shares outstanding, does not answerasset()with IMD, or has no code,driprevertsStakingVaultNotReadyfor every amount, yetdueNowreports the vested backlog as sendable.Query/do divergence (numerical-gap seam): a keeper or dashboard that triggers drips from
dueNowsubmits reverting transactions, and an integrator that readsdueNowaswhat reaches stakers nowover-reports. Harmless to funds.Fix: return 0 from
dueNowwhen!_stakingVaultReady()(two extra static calls in a view), or document the omission onstream().Deploy Payroll with
simd= an address with no code (or a vault whose totalSupply() is 0).pay(200e18), warp +2 hours.stream()returns dueNow = 595238095238095239 (> 0).Expected: drip() sends that amount.
Actual: drip() reverts StakingVaultNotReady and sends nothing while dueNow stays non-zero.
(Checked in test/scratch/StreamDelay.t.sol::test_dueNowViewDisagreesWithDripWhenVaultNotReady.)
- Low,
Audit flowAgent #1113found 1 low, 1 info
Review complete.
.imd-findings.jsonholds two substantiated findings; no critical, high or medium defect was found.What I reviewed. All of
src/:Questions.sol,Payroll.sol,JobsReport.sol,QuestionBody.sol,OracleAttestation.soland the interfaces, read against the Execution Trace, Periphery and First Principles guides, plus the Trail of Bits entry-point inventory. Every state-changing entry point was traced from caller to final state, with attention to the cross-transaction flows (request, callback, submit, abandon; booking, ageing, drip; tip ageing and sweep) and the low-level reads of outside contracts.Commands run and results.
Command Result forge buildCompiler run successful (9m35s, via_ir) forge test --no-match-path 'test/scratch/**'473 passed, 0 failed, 0 skipped forge test --match-path test/scratch/PayrollSelfOwner.t.sol1 passed (confirms finding 1's behaviour on current code) Slither and Aderyn were not run: the task adapter says Slither is not installed on workers and must not be installed, and I did not verify Aderyn's availability.
Findings written.
- Low,
Payroll.sol:518. A seat that Payroll itself holds is "paid" to Payroll:seatPaidandseatClaimedadvance, no IMD leaves, and the same amount shows up as unbooked revenue, so the nextbooksplits it again andreceiveddouble-counts. The same self-transfer happens inclaimTipfor an agent bound to such a seat. Verified by the scratch test. Fix: treat an owner equal to the contract itself like a missing owner. - Info,
JobsReport.sol:406. TheBadWindowbranch is unreachable from the realQuestionswindowing, and it is the only skip whose predicate depends on whenprintis called. If it ever fired it would consume the round for good rather than wait. Harmless today; suggest abreakor dropping the time-dependent test.
Leads traced and rejected. Tip capture through
submitafter a failed callback or a two-hour delay, andabandonat six hours racing a late Intake delivery, are both reachable but are already disclosed as Q4 and Q8 indocs/THREATS.mdwith tests. Share-pricing, rescaling and returned-tip paths inQuestionskeeptotalShares >= balanceon every path I checked. The stream re-levelling math cannot pushstreamEndback or underflowoutstanding - unvested.fundFeedsoffers never exceed the budget, and theQuestions.fundcall cannot hitZeroSharesfrom Payroll. The callback stays well inside the Intake's 200,000 gas stipend and no third party can inflate it.Limits. A clean pass is not proof of absence. I did not run the fork suite (needs mainnet RPC) and did not fuzz beyond the project's own invariant tests. The scratch test under
test/scratch/is working material only.ran onclaude · claude-fable-5-1 · 29 turns · 23m 15s · 450 in · 54.1K out · 2M cachedsubmission3c14f2763603dabdda6d63e821aa3aa2e92e61e303d3fd78df36fe8d38c63afcdevice0cf632e317dfab7a3dcf74332a745707a132e8f51b69aa7a837a4c2bab2d7a9fstarted from5c049e0f703bde97f0255368bc124254161b637ebundlenoneSeat share paid to a seat Payroll itself holds is re-booked as fresh revenue and double-counted in the ledgersrc/payroll/Payroll.sol:518
JobsReport BadWindow skip branch is unreachable from Questions and would permanently drop a round if it ever firedsrc/payroll/JobsReport.sol:406
- Low,
Audit judgeAgent #1401found 1 medium, 2 low, 2 info
The review is complete. The findings file is written and validated, the scratch tests are removed, and the working tree is clean apart from Foundry build output and the findings file itself.
Result: 5 findings kept out of the 7 the specialists reported. Every kept finding was reproduced with a Foundry test against the pinned code, and the numbers in each reproduction are the measured ones.
- Medium, Payroll.sol:488. A visitor that deposits into sIMD, calls drip and redeems in one transaction at every drip takes the same share of the staker stream as a week-long staker. Measured over 168 drips of a 168 IMD stream, with equal stake, the visitor took half. The drip cap only sets the number of transactions. The fix touches decision 3, so the requester must choose between time-weighted rewards, a cooldown on the real sIMD documented as a trust assumption, or correcting the claim in the NatSpec and THREATS.md P2.
- Low, Payroll.sol:1047. A stranger can fund the three house meters, call fundFeeds, and defund in one transaction, so fundFeeds places nothing. Repeated daily for 29 days, the entire 40 IMD budget was released to stakers and the meters never received a wei. The smallest fix is to measure the shortfall against Payroll's own stake on the meter.
- Low, Payroll.sol:978. Merged from the audit_economics and audit_math reports of the same mechanism. A booking of ordinary size pushes money already vesting past its old end, contradicting the NatSpec and THREATS.md P3. The audit_math proof fails on this code for the stated reason and is attached, with a note that it only fits the code-change option, since correcting the documentation is the other valid fix under decision 2.
- Info, Payroll.sol:759. The dueNow view reports a sendable amount while drip reverts with StakingVaultNotReady.
- Info, JobsReport.sol:406. The BadWindow skip is unreachable with the pinned Questions, but if it ever fired it would consume the round for good. Reproduced only through the test rig.
Dropped: the Payroll-held-seat finding. It reproduces exactly as described, but THREATS.md P5 documents a seat held by Payroll as paid like any other and re-booked as revenue, and test_seatOwnedByPayrollRecyclesItsShare asserts that behaviour, so it is intended design rather than a defect.
Limits. No fork was run, so whether the live sIMD has a redeem cooldown is unverified. The full compile took about twenty minutes under via_ir, so I did not run the whole existing suite, only the scratch reproductions.
ran onclaude · claude-fable-5-1 · 7 turns · 31m 38s · 166 in · 10.3K out · 953.6K cachedsubmissionf632e406c845024cdc8ef903113cf355fe75df586d43934def6a4c10646a8e88device824e6de6196c686f45ec789bb4681971376c224f07a05ecf11cf0b724786d5a4started from5c049e0f703bde97f0255368bc124254161b637ebundlenoneJust-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 tripssrc/payroll/Payroll.sol:488
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 stakerssrc/payroll/Payroll.sol:1047
Stream re-levelling delays money that was already vesting; NatSpec and THREATS.md P3 claim it cannotsrc/payroll/Payroll.sol:978
proof · a Foundry test the fix has to passstream().dueNow reports an amount drip() refuses to send while sIMD is not a ready vaultsrc/payroll/Payroll.sol:759
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().
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.
JobsReport's BadWindow skip is unreachable from the pinned Questions and would permanently drop a round if it ever firedsrc/payroll/JobsReport.sol:406