# Audit report

> Audit PaidOraclePilot (src/PaidOraclePilot.sol) and its tally builder (pilot/build-tally.mjs) at the pinned commit. Read pilot/README.md first. Read whatever in src/ they touch (CDPVault.earn, ParameterizedVault.earnLine, Parameters.proposeWorkOracle validation, IWorkOracle) but report on this scope. Tests: forge test (offline) and forge test --match-path test/fork/PaidOraclePilot.t.sol --fork-url <mainnet rpc> (9 tests against the LIVE imdUSD vault 0x0b69F8DA8D8cE521d74Ea69a7A3475B77302EF82, its Parameters and the plane's ERC-8004 adapter; feed freshness is mocked there, nothing else).
>
> What it is: a capped pilot of minting imdUSD for swarm work on the live v1 vault. It becomes the vault's work oracle through the Parameters timelock. Each epoch a poster posts a Merkle root of (epoch, agentId, amount); after a 24 h window in which the protocol governor (read live from Parameters) may veto it, anyone calls pay(), which authorises exactly the batch total as rights for the pilot itself, calls vault.earn(total), and transfers each share to the agent's controller (adapter bindingOf -> ERC-721 ownerOf). Caps: 350 imdUSD per epoch, 3,000 lifetime, one epoch per 6 days. The builder rebuilds a root from Intake Requested/Completed events, the plane's daily receipt (WorkRegistry.latest) and the frozen job documents, re-hashing each.
>
> Answer each with a concrete reproduction or a clear no:
> 1. Can anyone mint through the vault's work channel other than via pay(), at any time, or can pay() mint more than the shares it transfers? Consider _authorised's lifecycle, a revert inside earn, re-entry through earn, the imdUSD transfer and the binding lookups.
> 2. Can the caps be exceeded: more than EPOCH_CAP or LIFETIME_CAP minted, an epoch paying more than its total, a share paid twice (duplicates inside one batch, across batches), a vetoed epoch paying, a window counted twice?
> 3. Is the leaf encoding exactly OpenZeppelin StandardMerkleTree's for (uint256,uint256,uint256), and can an internal node or another epoch's leaf be passed as a leaf?
> 4. Can a hostile or broken binding (unknown agent, ERC-1155/6909, a token contract that reverts, burns gas or returns malformed data) make pay() revert for others, redirect a share, or cost unbounded gas? Is the 60k stipend enough for the plane's real seat collection?
> 5. Worst case with a compromised poster, a compromised governor, and both. Is reading the governor live from Parameters sound? Anything the veto/propose window rules get wrong (the floor after a vetoed epoch, block windows)?
> 6. Installation and exit: does Parameters.proposeWorkOracle accept it while the wage is 0, and does anything in the vault behave differently with wage 1 and this oracle (earnLine, redemption, the old SwarmWorkOracle's claim)? After it has minted, what does replacing it require, and is anything stranded?
> 7. Builder: does it count each paid, attested request exactly once, exclude unpaid, refused and unattested ones, verify every document hash correctly (its RFC 8785 subset against the plane's), keep windows contiguous, and keep rounding at or under the posted total? Can a run include work it did not prove?
>
> Not deployed yet. Report findings with severity and a reproduction.

| | |
|---|---|
| Repository | https://github.com/fa11up/infer-protocol |
| Commit | `5f40fa02644d72350857b8f4d6b1a11f32925fa3` |
| Job | `3d5324e9-f18a-431a-bc12-ea7113b968e6` |
| Judged | 2026-10-21 23:47 UTC |
| Findings | 7 low · 3 info |

Four agents audited the code as it is at `5f40fa0`, 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. Low: propose() does not bound toBlock; toBlock = type(uint64).max (or any far-future block) permanently disables every later propose()

`src/PaidOraclePilot.sol:183`

```
            uint64 floor = last.vetoed ? last.fromBlock : last.toBlock + 1;
```

propose() only checks `toBlock < fromBlock`. It never requires toBlock <= block.number or toBlock < type(uint64).max. If the poster (mistaken or compromised) posts toBlock = type(uint64).max and the governor misses the 24 h veto window, every later propose() evaluates `last.toBlock + 1` in checked uint64 arithmetic and reverts with Panic(0x11) for every caller, with any arguments, forever. setPoster() does not help (the stuck epoch stays `last`), veto() is closed, and because the pilot exposes no predecessor(), once it has minted Parameters._validate accepts no successor that exists in the repository. The weaker form: any toBlock past the chain head (verified: on a chain at block 100, propose(.., 1, 1_000_000) is accepted) forces every later window to start after it, so paid work completed in the intervening blocks can never be counted. No funds are at risk beyond that epoch's capped total; the pilot's remaining lifetime budget is dead. Merged from four specialists (economics, permissions, flow, math). Fix: require toBlock <= block.number (and toBlock < type(uint64).max) in propose(), and/or compute the floor in uint256.

**Reproduction**

1) poster: propose(bytes32(1), 1e18, 1, type(uint64).max, '') -> accepted (epoch 0). 2) warp 7 days (veto window missed). 3) governor: setPoster(newPoster); newPoster: propose(bytes32(1), 1e18, 1, 2, '') -> reverts Panic(0x11) arithmetic overflow at line 183 instead of WindowNotAfterPrevious; every later propose reverts the same way. Expected: step 1 refused, or step 3 answered with the contract's own error. Reproduced: the attached test fails on the current code with `0x4e487b71 != 0x302d5662` (Panic selector vs WindowNotAfterPrevious). Also test/scratch/PilotReview.t.sol::test_futureToBlockAccepted shows propose(.., 1, 1_000_000) accepted at block 100.

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

contract StubCoin {
    function transfer(address, uint256) external pure returns (bool) {
        return true;
    }
}

contract StubParams {
    address public governor = address(0xBEEF);
}

contract StubVault {
    address public stablecoin;
    address public parameters;

    constructor() {
        stablecoin = address(new StubCoin());
        parameters = address(new StubParams());
    }

    function earn(uint256) external {}
}

/// A poster who posts toBlock = type(uint64).max (or any window past the chain head) must either be
/// refused, or must not make every later `propose` revert with a checked-arithmetic panic on
/// `last.toBlock + 1`. On the current code the first post is accepted and the second panics.
contract PilotToBlockOverflowTest is Test {
    PaidOraclePilot pilot;
    address poster = makeAddr("poster");

    function setUp() public {
        pilot = new PaidOraclePilot(address(new StubVault()), poster);
    }

    function test_maxToBlockMustNotBrickLaterEpochs() public {
        vm.prank(poster);
        try pilot.propose(bytes32(uint256(1)), 1e18, 1, type(uint64).max, "") returns (uint256) {}
        catch {
            return; // refused up front: fixed
        }
        vm.warp(block.timestamp + 7 days);
        vm.prank(poster);
        try pilot.propose(bytes32(uint256(1)), 1e18, 1, 2, "") returns (uint256) {}
        catch (bytes memory err) {
            assertEq(
                bytes4(err),
                PaidOraclePilot.WindowNotAfterPrevious.selector,
                "later epochs must be refused with the window error, not a panic"
            );
        }
    }
}
```

### 2. Low: An epoch whose shares can never be paid keeps its total in totalCommitted forever and consumes LIFETIME_CAP; nothing releases it after the 24 h veto window

`src/PaidOraclePilot.sol:197`

```
        totalCommitted += total;
```

totalCommitted rises by `total` at posting and is lowered only by veto(), which is refused once 24 h have passed. The posted total is not tied to the leaves, and no function retires an epoch's unpaid remainder (`total - paid`) later. Concrete ways an epoch becomes partly or wholly unpayable after the window: the builder hashes leaves with the operator-supplied --epoch (pilot/build-tally.mjs line 35) and never reads epochCount(), so a wrong --epoch produces a root for which every pay() reverts BadProof; a mistyped root; agents whose binding is ERC-1155/6909 or absent resolve to Unpayable (and if the agent never binds an ERC-721, the share is committed forever); a tree whose leaves sum above `total` makes the last shares revert OverEpochTotal. Each such epoch burns up to 350 of the 3,000 imdUSD lifetime, one 6-day slot and its block window (the floor moves past its toBlock, so the paid work in it cannot be recounted). Merged from four specialists. Fix: let the governor (or anyone after a long grace) release `total - paid` of an epoch back from totalCommitted and let the floor fall back to it; and make build-tally.mjs read epochCount() and refuse a mismatching --epoch.

**Reproduction**

State: epochCount()==0. Poster posts root = keccak256(bytes.concat(keccak256(abi.encode(uint256(1), 7, 350e18)))) (a tree built with --epoch 1) with total 350e18, window 100..200. Warp 24 h: pay(0, [7], [350e18], [[]]) -> BadProof. Governor: veto(0) -> VetoWindowClosed. totalCommitted()==350e18 with totalPaid()==0 and stays so: after seven further 350e18 epochs totalCommitted==2_800e18 and propose(.., 200e18+1, ..) reverts OverLifetimeCap, so 350 imdUSD of lifetime capacity is gone with nothing minted. Reproduced in test/scratch/PilotReview.t.sol::test_unpayableEpochKeepsLifetimeCapForever (passes on the current code = defect present).

### 3. Low: Window contiguity is not enforced: propose() accepts fromBlock > floor and the builder never checks --from/--epoch against the chain, so skipped blocks are lost forever

`src/PaidOraclePilot.sol:184`

```
            if (fromBlock < floor) revert WindowNotAfterPrevious();
```

The README says --from must be the previous epoch's toBlock + 1, but propose() only refuses fromBlock < floor, and pilot/build-tally.mjs takes --epoch, --from and --to as bare operator arguments (line 35) without reading epochCount() or epochs(n-1).toBlock (it does not know the pilot's address). A typo or off-by-one in --from posts a window with a hole; because every later epoch must start after this one's toBlock, Intake Completed events in the hole can never be counted and that paid work is never bonused. Merged from two specialists (permissions, flow). Fix: require fromBlock == floor in propose() (the vetoed-recount case keeps floor = last.fromBlock), and have the builder derive --from and --epoch from the contract.

**Reproduction**

1) poster: propose(root, 1e18, 100, 200, '') -> epoch 0. 2) warp 6 days; propose(root, 1e18, 300, 400, '') -> accepted although blocks 201..299 were never counted. 3) warp 6 days; propose(root, 1e18, 201, 299, '') -> reverts WindowNotAfterPrevious. Expected: step 2 refused (non-contiguous) or step 3 accepted. Reproduced in test/scratch/PilotReview.t.sol::test_gapBetweenWindowsAcceptedAndUnrecoverable.

### 4. Low: Floor after two consecutive vetoes is the last vetoed epoch's fromBlock, so a narrowed recount that is vetoed again loses blocks no live epoch ever counted

`src/PaidOraclePilot.sol:183`

```
            uint64 floor = last.vetoed ? last.fromBlock : last.toBlock + 1;
```

The floor for the next window looks only at the LAST epoch. One veto is handled (floor = last.fromBlock). But if a vetoed window is recounted with a NARROWER window and that recount is also vetoed, the floor becomes the narrower fromBlock, and the blocks between the first vetoed window's start and the narrower start can never be counted by any epoch, although no live epoch ever covered them. Paid work completed there is permanently unpayable. (Non-overlap itself holds: every fromBlock is >= every earlier live epoch's toBlock + 1.) Reported by the permissions specialist. Fix: compute the floor as max(toBlock + 1) over all non-vetoed epochs, i.e. the earliest fromBlock of the trailing run of vetoed epochs.

**Reproduction**

1) propose(root, 1e18, 100, 200) -> epoch 0 live. 2) warp 6 d; propose(root, 1e18, 201, 300) -> epoch 1; governor veto(1). 3) warp 6 d; propose(root, 1e18, 250, 300) -> epoch 2 (floor 201, accepted); governor veto(2). 4) warp 6 d; propose(root, 1e18, 201, 300). Expected: accepted (201..300 never covered live). Actual: reverts WindowNotAfterPrevious because floor = epochs[2].fromBlock = 250; blocks 201..249 are lost for good. Reproduced in test/scratch/PilotReview.t.sol::test_floorAfterNarrowedRecountLosesBlocks.

### 5. Low: controllerOf decodes the adapter's answer with abi.decode, so a malformed Binding word reverts the whole pay() batch instead of skipping the share; the hard-coded ADAPTER is an upgradeable proxy outsi

`src/PaidOraclePilot.sol:250`

```
        IPilotAdapter.Binding memory b = abi.decode(data, (IPilotAdapter.Binding));
```

The NatSpec promises that a lookup that fails leaves only that share unpaid. The staticcall and the `data.length < 96` check cover reverts and short answers, but abi.decode of a struct with uint8 and address fields reverts when a word carries bits above the type's width, and that revert is outside any try/staticcall guard, so pay() reverts for every agent in the batch (and for every batch containing that agent). Verified on mainnet with cast: ADAPTER 0xde152AfB...dD336 has 329 bytes of code, its ERC-1967 implementation slot holds 0xa6d23f27d3b1780b12488482a008cb3c3787135f and owner() is 0x03302Df40186D9B85faEA4fbb6cC5da028B23149 (not the protocol Safe). Its upgrader therefore decides every recipient (bindingOf is the only payee source, with no delay or sanity check) and can stall pay() entirely with a malformed word; a reverting or gas-burning bindingOf or ownerOf, by contrast, is correctly skipped (verified), and return-bombing is bounded by the 60k stipend. For the live code every real agent resolves (fork test: 64 real agents, ~68k gas each, 60k stipend sufficient for the seat collection). Merged from flow and math specialists. Fix: decode the three words manually (abi.decode(data, (uint256, uint256, uint256)) then range-check standard <= 255 and tokenContract < 2^160, else return address(0)); document the adapter admin as a trusted party in the README.

**Reproduction**

Epoch with leaves (0, 1, 1e18) and (0, 2, 2e18); agent 2 bound to an ERC-721 seat. vm.mockCall(ADAPTER, abi.encodeCall(IPilotAdapter.bindingOf, (1)), abi.encode(uint256(256), uint256(uint160(seat)), uint256(11))) -> pay(0, [1,2], [1e18,2e18], proofs) reverts (abi.decode panic) instead of emitting Unpayable(0, 1) and paying agent 2. Same with the tokenContract word (1<<160 | seat). With vm.mockCallRevert on bindingOf(1) instead, pay() pays 2e18 and skips agent 1 as documented. Reproduced in test/scratch/PilotReview.t.sol::test_malformedBindingWordRevertsWholeBatch, ::test_malformedAddressWordRevertsWholeBatch, ::test_revertingBindingIsSkippedNotFatal.

### 6. Low: Builder ignores the on-chain Completed status; refused/failed requests (status 1/2) pass stage 1 and are excluded only by the live API's status string

`pilot/build-tally.mjs:147`

```
      status: c.args.status, delivered: c.args.delivered,
```

Stage 1 records `status` but never filters on it. Intake v2 completes a request with status 0 (answered), 1 (refused) or 2 (ended without a result), and the fee is not refunded in the failure cases. A status-2 Completed whose uri names an oracle request passes the uri regex and the IMD-payment test and becomes a candidate; it is then dropped only because the live API answers a status other than 'attested' (line 165) and the frozen document carries no matching acceptance. The README states the live API is used for one thing only (job lookup); in fact the 'exclude refused' guarantee rests on it. If the plane closes an Intake request with status 2 at its deadline and later attests the job (API 'attested', frozen document naming the request), the builder pays the 1.5 imdUSD bonus for a request whose payer received no answer, which the brief says must be excluded. Merged from four specialists. Fix: `if (c.args.status !== 0) { skip(`completed with status ${c.args.status}`); continue; }` before the API lookup.

**Reproduction**

Verified live (Tenderly RPC) in the dry-run window 26194146..26215746 on Intake 0xa43e6F75ee006411F79Ac1C84120606C2330DE82: 117 Completed events, statuses {0:113, 1:1, 2:3}; two status-2 completions name oracle requests 39cda5f9-453c-4bfa-8f84-d592fb707994 (tx 0x58ae424d15e8887cea1e59324c0687932a3e6bd034f9049b04d5d19b8c3c4197) and 0d6f8aa9-4738-447d-90cf-7cd7dcfbeac9 (tx 0xa65daf69f3570764f1654225f3caf8c8010bb781d0d10aae1503220b4888917b). Stage 1 (lines 136-149) classifies both as candidates; GET /oracle/requests/39cda5f9-... currently answers status 'disagreed', which is the only reason they are excluded. Input that pays for failed work: a status-2 Completed whose oracle request the API reports 'attested' and whose frozen document's attestation.requestId matches: its agents are credited 1.5 imdUSD. Expected: excluded from the on-chain status alone.

### 7. Low: Builder silently skips, rather than refusing to post, a paid request the live API cannot map (404 or non-attested status) or whose Requested event is older than the 50,000-block lookback; the posted w

`pilot/build-tally.mjs:164`

```
  if (!live?.jobId) { q.skipped = "plane names no job"; continue; }
```

The README promises that a paid request which cannot be proven makes the run write nothing and exit 2. That holds only for the `unproven` branch (receipt missing / job absent, lines 174, 192-197). Three other branches mark a PAID, Intake-completed request `skipped` and still write and post the tally: getJson() returns null on an HTTP 404 from api.imd.fun/oracle/requests/<id> (line 77 -> line 164 'plane names no job'); any live status other than 'attested', including a transient 'pending' or an API behind the frozen record (line 165); and a Requested event more than LOOKBACK = 50,000 blocks before --from (lines 131, 140, 'no Requested event in lookback'). A skipped request appears only in tally.json's `skipped` list, and because the next epoch must start after this one's toBlock, it can never be picked up later: the payer's IMD was taken, the work attested, and the bonus lost. The live API is a mutable service (an unknown id does answer 404: verified with curl -> 404), while the frozen receipts and documents already contain attestation.requestId, so the mapping could be rebuilt from frozen data. Merged from economics, permissions, flow and math specialists. Fix: route the 404, non-attested and lookback-miss branches for paid status-0 requests to `unproven` (exit 2), or at least require an explicit --allow-skips flag; on a lookback miss, fetch Requested by indexed requestId from the Intake's deployment block.

**Reproduction**

Inputs: Intake Requested(R, payer, 'oracle.request@oracle-1', ..., asset IMD, amount > 0) and Completed(R, status 0, hash, 'https://api.imd.fun/oracle/requests/<U>', true) inside [--from, --to]; the day's receipt is anchored and its leaf's frozen document has attestation.requestId == U. Case A: the API answers 404 (or status 'pending') for U during the run. Actual: q.skipped = 'plane names no job' / 'not attested (pending)', stage 2 logs it, tally.json is written without U and can be posted; the next window must start after toBlock, so U is never paid. Case B: Requested(R) at block B < from - 50_000: skip('no Requested event in lookback'), same outcome. Expected per README: 'writes nothing and exits 2 ... nothing is lost'.

### 8. Info: Exit path: after the wage returns to 0 or a successor is applied, every posted-but-unpaid share is stranded; the pilot has no predecessor(), so after its first mint neither address(0) nor another Paid

`src/PaidOraclePilot.sol:238`

```
        IPilotVault(vault).earn(sum);
```

pay() mints only through vault.earn, which reads oracle().mintingRights(msg.sender). Installation is sound: Parameters._validate accepts the pilot at wage 0 (vault() matches, mintingRights(vault) answers 0, totalEarned == 0 and the live SwarmWorkOracle's totalCredited == 0), wage 1 only opens earn, earnLine and redemption are unchanged, and SwarmWorkOracle.claim then reverts NotTheVaultsOracle (fork test 9/9 reproduced live via Tenderly). But the exit is harsher than the README says: at wage 0 earn reverts WorkMintingOff; once a successor is applied the vault reads the successor's mintingRights(pilot) == 0 and earn reverts InsufficientRights. So shares posted but not yet paid (feeds stale through the window, Unpayable agents who bind later, batches nobody submitted) can never be paid, contradicting setPoster's 'epochs already past their window stay payable' and the README's 'stays payable'. Replacement needs a successor answering predecessor() == pilot (Parameters.sol line 431), which no contract in the repository does, and proposeWorkOracle(address(0)) is refused once totalEarned != 0, so the vault's own SwarmWorkOracle is unreachable after the first paid batch. The pilot exposes no view a successor could use to carry remaining shares. Merged from four specialists. Not a bypass: document the exit order (setPoster(0), wait out the last window, pay every batch, then propose) and give the successor type a way to import epochs()/paidOut.

**Reproduction**

1) Pilot installed at wage 1; epoch 0 posted (total 3e18) with a leaf for agent U who has no binding; after 24 h pay() emits Unpayable(0, U) and pays the rest. 2) Governor: proposeWage(0), 48 h, applyPending(). 3) U binds an ERC-721 seat; pay(0, [U], [amt], [proof]) -> vault.earn reverts WorkMintingOff. 4) Governor: proposeWorkOracle(address(0)) -> InvalidWorkOracle (totalEarned != 0); proposeWorkOracle(new PaidOraclePilot(vault, poster)) -> InvalidWorkOracle (predecessor() probe returns 0). 5) With any successor S applied and wage 1: pay(0, [U], ...) -> S.mintingRights(pilot) == 0 -> InsufficientRights. epochs(0).total - epochs(0).paid stays in totalCommitted forever.

### 9. Info: Trust assumptions to record: governor can appoint itself poster and is the only vetoer; a compromised poster is bounded by 350/epoch, 3,000 lifetime and one missed 24 h window; reading the governor li

`src/PaidOraclePilot.sol:139`

```
        return IPilotGoverned(IPilotVault(vault).parameters()).governor();
```

Parameters.governor() is a pure function (Governed.sol line 48) and ParameterizedVault.parameters is immutable, so there is no stored authority to drift and the live read is sound. Powers worth stating in the README: the governor (Safe) can setPoster(self) and post a 350 imdUSD root paying addresses it chooses every 6 days up to 3,000, with no veto because it is the vetoer; it can also veto every honest epoch (denial of service, no theft). A compromised poster alone is bounded per epoch to 350 the governor fails to veto within 24 h, 3,000 lifetime, and the vault's earnLine; plus the permanent DoS and window-foreclosure in the toBlock finding. Both compromised: 3,000 imdUSD maximum, bounded by earnLine. The adapter's upgrader is a third trusted party (see the controllerOf finding). Merged from the permissions and math specialists.

**Reproduction**

State: pilot installed, wage 1. Governor calls setPoster(SAFE), then propose(root paying agent X 350e18, 350e18, from, to, ''); no veto; after 24 h anyone calls pay() and 350 imdUSD go to X's controller; repeat every 6 days until totalCommitted == 3_000e18 (OverLifetimeCap thereafter). Expected: this is the stated trust surface, not a bypass; it should be written down.

### 10. Info: Builder checks attestation.requestId only on oracleAcceptance[0] while crediting every entry's agentId, and does not deduplicate two Intake requests naming the same oracle request

`pilot/build-tally.mjs:176`

```
  const att = (doc.oracleAcceptance ?? [])[0]?.document?.oracle?.attestation;
```

Line 176 reads the attestation from the first acceptance entry only, and line 184 credits every entry's agentId. A frozen document carrying acceptance entries for more than one attestation (a re-run or merged record) would have all its agents paid for a request only the first entry answers. Separately, nothing in stage 1-3 deduplicates by oracleId: two Intake Completed events (two intake requestIds) whose uris name the same oracle request <U> both pass the frozen-document check (it compares the ORACLE request id, not the Intake request id) and pay the 1.5 imdUSD bonus twice for one job. Today each document is one job and in the dry-run window the 100 oracle ids are distinct (verified live), so both are robustness notes about plane-written data. Reported in part by the flow specialist. Fix: require every entry's attestation.requestId to match (or credit only matching entries), and skip a second Completed naming an oracleId already counted.

**Reproduction**

Frozen document with oracleAcceptance = [{agentId 1, document.oracle.attestation.requestId A}, {agentId 2, ... requestId B}] listed in the day receipt for job J, live API naming J for request A: agents 1 and 2 are both credited for A. Second case: Completed(R1, 0, .., '/oracle/requests/U') and Completed(R2, 0, .., '/oracle/requests/U') both IMD-paid in the window: `paid` contains both, each agent of U's document gets 2 x (1.5e18 / n). Expected: one bonus per oracle request.

---

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