# Audit report

> Audit the imd-flop folder only, at exactly commit 8b5a068 (head of PR #4, which stacks on PR #3 and PR #1; none of them is on main). Review everything these three PRs change from main (df10e3d) to this commit. PR #1: a paid run (--provider flop) must refuse before any quote or child-key mint unless eth_chainId is 1 and the faucet and devnet RPC genesis both equal the pinned hash, with a recheck before the mint (src/gateway.ts, src/networks.ts, src/providers.ts, src/run.ts, src/cli.ts). PR #3: run --json must print only the text the response file stored, so text the secret scan withholds never reaches stdout (src/run.ts). PR #4: while spend is unresolved the ledger keeps the padded quote as cap_charge, reported usage never lowers it, and an explicit settled amount, including zero, is the charge (src/run.ts, src/ledger.ts). What worries us: a paid call or child-key mint that still happens when a check fails, times out or gets a malformed answer; the daily or per-call cap being bypassed or under-counted across repeated, failed or timed-out calls; ledger or receipt rows whose spend or cap_charge are wrong, lowered or erased; and any path where an API key, child key or withheld completion text reaches stdout, stderr, logs or the ledger. A later PR builds on this commit and was audited separately; this audit is for these three PRs as they would merge on their own, so report what is wrong here even if later work changes it. Every other folder is out of scope. Work offline and read-only, with no live FLOP or IMD calls, no keys and no spending. Give each finding a file and line, its impact, and steps to reproduce it.

| | |
|---|---|
| Repository | https://github.com/uxp-labs/flop-devnet-lab.git |
| Commit | `8b5a0680a5567df2ebd4866ad61e7f66c38eedc3` |
| Job | `ba164ffa-1c3e-4183-93bd-4a11d24b76bd` |
| Judged | 2026-11-10 19:00 UTC |
| Findings | 3 medium · 4 low |

Four agents audited the code as it is at `8b5a068`, 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: PR #4: a negative settled_spend from the receipt becomes a negative cap_charge, lowering today's total and bypassing the daily (and requester job) cap

`imd-flop/src/run.ts:238`

```
  const capCharge = classified.settled ?? unresolvedCharge;
```

Merged from audit_economics, audit_math, audit_permissions and audit_flow. classifyReceipt (src/receipts.ts:57) accepts any typeof-number settled_spend. run.ts:238 then makes it the charge with no check that it is finite or >= 0. spentToday/jobSpent (src/ledger.ts:54-68) sum cap_charge across the latest rows with no lower bound. So one malformed or hostile gateway receipt with settled_spend < 0 subtracts from the day's total (or the requester job total), and run.ts:178-183 then admits calls well past the configured cap. That ledger row also stores spend = cap_charge = a negative number, which is not a real charge. The daily cap is the local defence against a misbehaving gateway, so the gateway's own answer must not be able to widen it. Fix: count settled as resolved only when Number.isFinite(s) && s >= 0; otherwise use unresolvedCharge with spend null and flag the receipt as malformed. Apply the same check in classifyReceipt.

**Reproduction**

Reproduced in imd-flop/test/scratch/audit.test.ts (offline vitest; scratch, not kept), test F1. Setup: readySeat(h,'42','0.5') (0.5 FLOP/day, per call 0.5). Provider: a provider named 'mock' that wraps MockProvider; its readReceipt returns {state:'settled', settled_spend:-10}. Run `run --job neg-1 --prompt 'Say hi' --max-tokens 8 --json`, then 10 more runs with normal unresolved receipts (padded quote 0.18 each). Expected: at most 2 of the 0.18 calls fit under the 0.5 cap (or the -10 receipt is rejected and the padded 0.18 is charged). Actual: the first run prints cap_charge -10 and its ledger row has spend/cap_charge -10. All 10 later calls are admitted, and `status --json` shows today_utc.mock = -8.2.

### 2. Medium: PR #4: settled_spend replaces the padded reservation even when the receipt's state is still reserved/estimated, or when the receipt is for another request_id

`imd-flop/src/run.ts:229`

```
  const classified = classifyReceipt(rr.receipt, { job, provider: provider.name, http: rr.http, error: rr.error, requestId, mock: provider.name === "mock" });
```

Merged from audit_economics, audit_math, audit_permissions and audit_flow. Whether spend counts as resolved is decided only by settled_spend being a number (run.ts:238). receipt.state is never consulted. requestId reaches classifyReceipt only as a fallback for a missing receipt.request_id; nothing compares the two. (a) If the receipt says state 'estimated' or 'reserved' (spend unresolved by definition) but carries settled_spend 0, it is charged 0. The ledger row reads state 'estimated', spend 0, cap_charge 0. This breaks PR #4's rule that the padded quote stays the cap_charge while spend is unresolved. (b) A receipt naming a different request_id has its settled amount applied to this attempt. Either way an unresolved paid call is charged 0 against the daily or job cap, and every such call adds to the under-count. Fix: use classified.settled only when rr.http === 200, receipt.request_id === requestId, and state is 'settled' (or 'released'). Otherwise use unresolvedCharge with spend null.

**Reproduction**

Reproduced in imd-flop/test/scratch/audit.test.ts (offline vitest; scratch, not kept), tests F2a/F2b. Setup: readySeat(h,'42','0.5'), mock-named provider wrapping MockProvider, `run --job e0 --prompt 'Say hi' --max-tokens 8 --json`. (a) readReceipt returns {state:'estimated', settled_spend:0}. Expected: latest ledger row cap_charge = quote_padded 0.18. Actual: row state 'estimated', spend 0, cap_charge 0, quote_padded 0.18, and printed cap_charge 0. (b) readReceipt returns {request_id:'some-other-request', state:'settled', settled_spend:0}. Expected: the receipt is not applied and cap_charge is 0.18. Actual: printed request_id 'mock-42-…', receipt.request_id 'some-other-request', cap_charge 0.

### 3. Medium: PR #1: the pre-mint recheck and the child-key mint ignore the run's abort signal, so a child key is minted after --timeout-ms has expired and the attempt is recorded as timeout_unknown with the full c

`imd-flop/src/providers.ts:146`

```
    const child = await mintChildKey(this.net, this.parent, { model: req.model, maxSpend: req.maxCostFlop }, this.f);
```

Merged from audit_economics, audit_math, audit_permissions and audit_flow. runJob starts the --timeout-ms timer (run.ts:191-192) before provider.complete(). FlopProvider.complete then runs assertPaidNetwork (providers.ts:140; eth_chainId, faucet and WebSocket reads with their own 15 s limits, about 45 s worst case) and mintChildKey (own 15 s limit). Neither receives req.signal, and nothing checks req.signal.aborted. When the deadline passes during the recheck, POST /keys/ephemeral is still sent with the parent bearer, creating a live single-use credential scoped to the padded quote. Only then does createChatCompletion see the already-aborted signal and throw. runJob classes that as timeout_unknown: the padded quote stays charged to the daily cap, exit is 6 'Spend is UNKNOWN', and status lists the attempt as pending indefinitely, although no inference was sent. The CLI does not validate --timeout-ms (cli.ts:322, Number(...)): a value like '60s' becomes NaN and Node clamps it to 1 ms, so every paid run with such a typo hits this path. That parsing is older than these PRs and is noted here only because it makes this finding worse. Fix: call req.signal.throwIfAborted() before assertPaidNetwork and again before mintChildKey, throw a PreSendError (refused_local, charge 0), and pass the signal into both reads.

**Reproduction**

Reproduced in imd-flop/test/scratch/audit.test.ts (offline vitest; scratch, not kept), tests F3/F3b. Real FlopProvider through cli(). Seat 42 is ready and wallet-signed, gateway.key is present, ETH_RPC_URL=https://eth.example/rpc. Scripted fetch: eth_chainId 0x1, eth_call returns the signer as owner, estimate 0.012 (padded 0.132), faucet /chain returns the pin but delays its 2nd call (the pre-mint recheck) by 200 ms, /keys/ephemeral returns 201 {token:'flop_ek_'+'A'*24}. A fake WebSocket returns the pin. Command: `run --job job-abc --prompt 'Say hi' --max-tokens 8 --provider flop --live --timeout-ms 50`. Expected: no mint after the 50 ms deadline; latest row refused_local with cap_charge 0. Actual: POST /keys/ephemeral at t≈229 ms, exit 6 'no answer within 50 ms. Spend is UNKNOWN', ledger [['sent',0.132],['timeout_unknown',0.132]]. With --timeout-ms 60s: Node prints TimeoutNaNWarning, the mint is still sent, and exit is 6.

### 4. Low: PR #1: a pre-mint refusal whose upstream error text holds a bare (non-0x) 64-hex run cannot be written to the ledger, so the pre-call 'sent' charge stays in effect

`imd-flop/src/run.ts:204`

```
    const reason = timedOut ? `${err.name}: no answer; spend unknown, not retried` : preSend ? `nothing sent: ${err.message.replace(/0x[0-9a-fA-F]{64}/g, "[hash]")}` : `HTTP ${http}`;
```

Merged from audit_economics, audit_permissions and audit_flow. The PreSendError reason embeds upstream text: 'eth_chainId error: <rpc message>' (gateway.ts:215), 'rpc error: <ws message>' (gateway.ts:169) and fetch error text. Only lowercase-0x-prefixed 64-hex runs are stripped, but the secret scan (secrets.ts:52 HEX64, BEARER, FLOP_TOKEN) also rejects bare 64-hex runs, '0X' runs, and bearer- or flop-token-shaped text. assertNoSecrets then throws inside the catch, so the refused_local line with cap_charge 0 is never appended. The 'sent' row with cap_charge = padded quote remains the latest row. It counts toward today's cap and shows as a pending paid attempt, although nothing was minted or sent. The user sees exit 5 'secret scan blocked writing ledger.jsonl' instead of the refusal. Fix: write a fixed reason (e.g. 'pre-mint network check failed') and keep the details on stderr only, or scrub with the scanner's own patterns and fall back to a fixed reason if the append still fails.

**Reproduction**

Reproduced in imd-flop/test/scratch/audit.test.ts (offline vitest; scratch, not kept), test F4. Same paid setup as the mint-after-deadline finding, except the 3rd eth_chainId call (the recheck inside complete()) returns {error:{message:'header not found '+'ab'.repeat(32)}}. Command: `run --job job-abc --prompt 'Say hi' --max-tokens 8 --provider flop --live`. Expected: no mint, latest row refused_local with cap_charge 0, status today_utc.flop 0. Actual: no mint (correct), but exit 5 'secret scan blocked writing ledger.jsonl: reason: 64-hex (key-shaped) value', ledger [['sent',0.132]] only, status today_utc.flop 0.132, and 1 pending_receipts entry.

### 5. Low: PR #3: if the secret scan hits a field outside text (e.g. usage), the withheld-text fallback write throws after a completed paid call: no receipt, no final ledger row, exit 5

`imd-flop/src/run.ts:225`

```
    writeJsonSafe(responseFile, { ...responseRecord, text: null, withheld }, "response");
```

Merged from audit_math and audit_permissions. The fallback assumes the scan failed because of text. It re-spreads responseRecord, including the gateway-supplied usage object. If the hit was in usage (e.g. a 64-hex cache or request-hash field), the second writeJsonSafe throws the same CliError. That escapes runJob after the paid completion: readReceipt is never called, no receipt file is written, and the final ledger row is never appended. The ledger stays at 'sent' with only the padded quote, so a higher reported cost_flop is never recorded. stdout is empty and the exit code is REFUSED_RULE (5), the code scripts read as 'refused, nothing sent', for a call that was paid for. Withheld text does not leak (good), but the paid attempt goes unrecorded. Fix: on a scan failure, store a minimal record (request_id, output_sha256, numeric usage fields only, withheld). Always continue to readReceipt and the final ledger row (try/finally), and do not return a refusal code for a sent call.

**Reproduction**

Reproduced in imd-flop/test/scratch/audit.test.ts (offline vitest; scratch, not kept), test F5. Setup: readySeat(h,'42','5'). A mock-named provider returns normal text but usage {...normal, cache_key:'cd'.repeat(32)}. Command: `run --job u0 --prompt 'Say hi' --max-tokens 8 --json`. Expected: exit 0, response stored with the offending part withheld, receipt file and final ledger row written. Actual: provider.calls 1 (paid), exit 5 'secret scan blocked writing response: usage.cache_key: 64-hex (key-shaped) value', readReceipt called 0 times, no receipts/ dir, ledger states ['sent'].

### 6. Low: PR #1: a pre-mint chain/genesis refusal exits 1 (ERROR, the same code as 'sent, spend unknown') instead of REFUSED_RULE 5

`imd-flop/src/run.ts:209`

```
    if (preSend) throw new CliError(`nothing sent: ${err.message}`, EXIT.ERROR);
```

From audit_flow. errors.ts:8 documents REFUSED_RULE (5) as covering 'a paid-run chain/genesis refusal', and the up-front gate (run.ts:118) exits 5. The same refusal raised by the pre-mint recheck in FlopProvider.complete exits EXIT.ERROR (1). That is also the code for an HTTP error after a real send (run.ts:210, 'spend recorded as unknown'). A script branching on the documented codes cannot tell 'nothing sent, charge 0' from 'sent, spend unknown'. test/paid-gate.test.ts:471 asserts code 1, so the suite currently enforces this behaviour. Fix: use EXIT.REFUSED_RULE for preSend.

**Reproduction**

Reproduced in imd-flop/test/scratch/audit.test.ts (offline vitest; scratch, not kept), test F6. Paid setup as above, where the 3rd eth_chainId call (the recheck) returns 0x5. Expected: exit 5, as the up-front gate gives for the same chain-id mismatch. Actual: exit 1, 'nothing sent: Ethereum chain id 5 is not 1 …'.

### 7. Low: PR #1: credentials or API keys embedded in ETH_RPC_URL are echoed to stderr when the paid-run eth_chainId gate fails on URL construction

`imd-flop/src/gateway.ts:205`

```
    throw new Error(`eth_chainId unavailable: ${(e as Error).message}`);
```

Merged from audit_economics and audit_flow. readEthereumChainId wraps the fetch error message verbatim. Node's fetch includes the whole URL in two errors: 'Request cannot be constructed from a URL that includes credentials: <url>' (basic-auth RPC URLs) and 'Failed to parse URL from <url>' (e.g. a missing scheme on a …/v3/<key> URL). assertPaidNetwork propagates the message, run.ts:118 builds 'refused locally, nothing sent: …', and cli.ts:422 prints it to stderr. PR #1 runs this gate on every paid run and on the recheck (where it also lands in the ledger reason). ETH_RPC_URL is never registered as a secret. Fix: validate with new URL() first and report a fixed message without the value, and never interpolate the transport error or URL.

**Reproduction**

Node v24.21.0, offline (the URL fails before any connection): `node -e 'fetch("https://user:s3cretpass@eth.example/rpc",{method:"POST"}).catch(e=>console.log(e.message))'` prints 'Request cannot be constructed from a URL that includes credentials: https://user:s3cretpass@eth.example/rpc'. `fetch("mainnet.example/v3/SECRETKEY123",…)` prints 'Failed to parse URL from mainnet.example/v3/SECRETKEY123'. readEthereumChainId (gateway.ts:204-205) rethrows that as 'eth_chainId unavailable: <message>', and runJob (run.ts:118) → main (cli.ts:422) writes 'imd-flop: refused locally, nothing sent: eth_chainId unavailable: Request cannot be constructed from a URL that includes credentials: https://user:s3cretpass@eth.example/rpc' to stderr for `run --provider flop --live` with that ETH_RPC_URL. The full-CLI step was traced by reading the code: the test setup stubs global fetch, so it was not run end to end. Expected: no URL or credential on stderr.

---

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