# Audit report

> FLOP Observatory M0, third audit: recheck the 15 findings of job c0ebfa5e and try to break what changed since (TypeScript and Python, no Solidity in scope)
>
> SUBJECT: observatory/ and interfaces/ in uxp-labs/flop-devnet-lab at exactly commit 97821ac24511281b44d1aa063526761782572fd0 (branch claude/observatory-m0, PR #5; not on main yet). Node 22 TypeScript package with an independent Python 3 recount. Inspect the TypeScript, Python, tests and scripts. If the audit tool cannot cover this scope, say so plainly.
>
> OUT OF SCOPE: tclk-rehearsal/ (including HashLock.sol, the only Solidity in the repo), imd-flop/, viewer/, spend-guard/, cost-checker/. Do not report contract findings.
>
> HARD RULES: read only. No calls to FLOP endpoints (rpc.devnet.flop.network, faucet.flop.network, api.flop.network), no faucet requests, no keys, no signing, no spending, no source changes, no PRs or comments on the repo.
>
> READ FIRST: observatory/RULES.md (the spec; every rule is binding), observatory/README.md, interfaces/README.md, and observatory/REVIEW-LOG.md. Its "Round 6" section maps each of the 15 findings of job c0ebfa5e (https://explorer.imd.fun/jobs/c0ebfa5e-cf39-4631-9e44-cee039176a70) to its fix and proof; later rounds answer the reviews since.
>
> RUN: cd observatory && npm run check. Offline except npm ci. On Node v22.23.4 (Python 3.13.18) expect all 33 steps to exit 0, 761 tests to pass, 419 of 419 mutants to be killed, and this digest over every output file:
> cd outputs && find . -type f | LC_ALL=C sort | xargs sha256sum | sha256sum
> f4b810f408c61d04f2036da126d76eab6bafa89d188653255bb56a783013b0c5
> On any other Node version the digest differs, because each snapshot pin records process.version. Report any other difference.
>
> FIRST: for each of the 15 findings of c0ebfa5e, say fixed, partly fixed or not fixed, with a reproduction.
>
> THEN LOOK HARDEST AT, in order:
> 1. Read only (section 4, scripts/source-scan.mjs, src/own.ts, test/setup.ts, the scans in test/guard.test.ts and tools/test_recount.py): any src path to the network, a process, a signature, a spend or a key, a function reached by a name the scan cannot read included, and any way a test leaves an attempt behind that no check sees.
> 2. Escrow evidence (section 2, src/escrow.ts): any input that yields funded, claimed or refunded without finalized evidence on the pinned genesis.
> 3. Network status (sections 1 and 1.1, src/status.ts, src/cli.ts): health ok when a check failed or could not run; reset_detected false without a usable faucet /chain reading and a usable, uncontradicted RPC head; a restart, rollback or backwards log that the snapshot log lets through.
> 4. Census (sections 3 and 3.1, src/census.ts against tools/recount.py): any count or refusal where the two differ, command line arguments included, or a partial census read as complete.
> 5. Outputs agree (section 5, scripts/consistency.mjs): any tamper of a real outputs folder that passes, with or without --semantic-only.
> 6. Evidence honesty (scripts/mutate.mjs, scripts/test-report-check.mjs, scripts/compare-recount.mjs, the canaries): anything that could report a pass that is not one.
>
> DELIVER: every finding with severity (critical, high, medium, low, info), file and line, and a reproduction (input files, command, output). Say whether it can cause a wrong escrow state, a wrong health or reset value, a partial count shown as complete, or a network, sign or spend path. Mark each finding as reproduced by running or found by reading. If nothing at medium or above turns up, say so explicitly for each area.

| | |
|---|---|
| Repository | https://github.com/uxp-labs/flop-devnet-lab.git |
| Commit | `97821ac24511281b44d1aa063526761782572fd0` |
| Job | `9d67f583-a550-48a7-90eb-ca76b60cb479` |
| Judged | 2026-11-10 17:28 UTC |
| Findings | 1 medium · 7 low · 3 info |

Four agents audited the code as it is at `97821ac`, 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: Test runtime block (RULES.md 4) does not reach the module hooks thread: module.register() runs net.connect and spawnSync unblocked and no check records it

`observatory/test/setup.ts:90`

```
threads.Worker = class {
```

RULES.md section 4 says every network and process path is blocked at runtime in tests and recorded, worker threads included, and that a test leaving an attempt behind fails. test/setup.ts replaces the exported worker_threads.Worker and patches builtin module objects in the test's own realm, but node:module's register() starts Node's internal loader-hooks worker, which has its own unpatched copies of node:net and node:child_process. Top-level code in a hooks module therefore opens sockets and starts processes. Nothing is journaled: the afterAll sees an empty log, scripts/attempts-check.mjs reports 0 attempts and no problems, and the file passes. Test-side hole only: src cannot import node:module (source-scan allow-list), so this is not a src network/process path, and it cannot change an escrow, health, reset or census value. It breaks the evidence guarantee that no test leaves an unseen attempt. Reproduced by running (Node v24.21.0 here; the specialist saw the same on v22.23.2). Fix: replace module.register (and registerHooks) with blocked() stubs before syncBuiltinESMExports(), and add a canary for it.

**Reproduction**

Copy the tracked tree (git ls-files | tar) to a scratch folder, symlink node_modules, add test/zz-register-probe.test.ts: `import { register } from "node:module"; ... register("data:text/javascript," + encodeURIComponent(hook))` where hook's top level does `connect(9,"127.0.0.1")` from node:net and `spawnSync("true")` from node:child_process and writes the outcomes to a marker file; the test asserts the marker says 'not blocked' and 'spawnSync ran, status 0'. Run `OBS_ATTEMPTS_FILE=$J npx --no-install vitest run test/zz-register-probe.test.ts` then `node scripts/attempts-check.mjs $J`. Expected: both calls throw 'disabled in tests', attempts journaled, file fails. Actual: marker = 'net.connect returned Socket (not blocked)\nspawnSync ran, status 0'; Test Files 1 passed, exit 0; journal holds only {"event":"checked","left":0}; attempts-check: attempts 0, problems [].

### 2. Low: Test runtime block misses net.Server.prototype._listen2 and a dgram socket's native _handle: a test opens a TCP listener and binds UDP with zero attempts recorded

`observatory/test/setup.ts:76`

```
net.Server.prototype.listen = blocked("network", "net.Server.listen");
```

setup.ts patches net.Server.prototype.listen, net.createServer, dgram.createSocket and dgram.Socket.prototype.send/bind/connect. listen() is a wrapper over Server.prototype._listen2, which creates the TCP handle itself, and `new dgram.Socket('udp4')` already holds a native UDP handle whose bind() is not the patched prototype method (and process.binding's udp_wrap block is never consulted). Both open real sockets in a test with nothing in the journal, so the afterAll, attempts-check and the canary do not see them. Test-side only; src cannot reach these (source scan bans underscore names and these modules). Same root cause family as the module.register hole but a distinct mechanism and fix. Reproduced by running inside vitest.

**Reproduction**

In a scratch copy of the tracked tree add test/zz-listen-probe.test.ts: `const srv = new net.Server(); (srv as any)._listen2("127.0.0.1", 0, 4, -1, false); expect((srv.address() as any).port).toBeGreaterThan(0); srv.close(); const s = new (dgram as any).Socket("udp4"); expect(s._handle.bind("127.0.0.1", 0, 0)).toBe(0);` Run `OBS_ATTEMPTS_FILE=$J npx --no-install vitest run test/zz-listen-probe.test.ts; node scripts/attempts-check.mjs $J`. Expected (RULES.md 4): blocked and recorded. Actual: Tests 1 passed; journal only {"event":"checked","left":0}; attempts 0, problems []. Fix: also replace net.Server.prototype._listen2 and wrap the dgram.Socket constructor (or its handle creation) in setup.ts.

### 3. Low: escrow and census commands print meta.now unchecked: observed_at missing when absent, and non-time text accepted

`observatory/src/cli.ts:120`

```
observedAt: one(a, "--now") ?? src.data.meta.now,
```

RULES.md 1.1 requires times to be ISO 8601 with a zone and the escrow schema requires observed_at. status refuses a bad meta.now (exit 2), but escrow (cli.ts:120) and census (cli.ts:159) pass `one(a, "--now") ?? meta.now` straight through (--now is validated, meta.now is not). With no now in meta.json, observed_at is undefined and JSON.stringify drops it; with now:"yesterday" it is printed as the reading time. No escrow state or count is wrong; the CLI output violates the interface. Reproduced by running.

**Reproduction**

mkdir -p $T/nonow/event-pages; meta.json = {"scenario":"nonow","description":"","synthetic":true,"escrow_ids":["0x6fca0e2333da351600475a298ff0bd15acc42933b1ea6873ced2c15910e2cac5"],"census_request":{"from_block":0,"to_block":5}}; cp fixtures/escrows-verification/event-pages/000.json $T/nonow/event-pages/. `node dist/cli.js escrow --fixtures $T/nonow` -> exit 0, "state": "claimed", no observed_at key; `node dist/cli.js census --fixtures $T/nonow | grep -c observed_at` -> 0. Add "now":"yesterday": escrow prints "observed_at": "yesterday", while `status --fixtures` on the same folder exits 2 'now must be an ISO 8601 time with a zone'. Expected: the same refusal in escrow and census.

### 4. Low: A saved reading or the served snapshot that is a FIFO hangs status, snapshot and the MCP binary instead of counting as unreadable

`observatory/src/source.ts:153`

```
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(readFileSync(path));
```

read() checks existsSync then readFileSync with no regular-file check, unlike the page loader (clause 15). A named pipe at faucet-health.json (or any scenario file) blocks the status/snapshot commands forever; mcp-bin.ts:26 reads the snapshot path the same way. No wrong value, but a reading that RULES.md 1.1 says counts as nothing read (with the reason in the detail) yields no answer at all. Reproduced by running.

**Reproduction**

mkdir $T/fifo; cp fixtures/status-healthy/*.json $T/fifo/; rm $T/fifo/faucet-health.json; mkfifo $T/fifo/faucet-health.json; `timeout 5 node dist/cli.js status --fixtures $T/fifo; echo $?` -> 124, no output. mkfifo $T/snap.fifo; `timeout 5 node dist/mcp-bin.js $T/snap.fifo </dev/null; echo $?` -> 124. Expected: faucet health unknown naming faucet-health.json (exit 0), and an MCP refusal (exit 2). Fix: lstat and require isFile() before reading.

### 5. Low: An rpc-head.json with missing keys (e.g. a saved failed read {"error":...}) reads as degraded with a fabricated 'rpc genesis hash is malformed' conflict

`observatory/src/status.ts:437`

```
if (head.genesis_hash !== null && rpcGenesis === null) {
```

The 'nothing read' test and the malformed-genesis check compare head fields with null by identity, so absent keys (undefined) are treated as present values. Health becomes degraded instead of down, and a conflict about a genesis that was never reported is added. reset_detected stays null and health is not ok, so no wrong reset value or escrow state; wrong health word and a false conflict. Reproduced by running.

**Reproduction**

mkdir $T/st; meta.json = {"scenario":"p","description":"p","now":"2026-11-09T23:47:53Z","synthetic":true}; rpc-head.json = {"error":"connection refused"}; `node dist/cli.js status --fixtures $T/st` -> rpc {"health": "degraded", "detail": "read error: connection refused; no valid genesis hash; no valid finalized head; ..."}, conflicts ['rpc genesis hash is malformed'], reset_detected null. Expected (RULES 1.1): down with detail 'connection refused' and no conflict, as the same file with the keys written as explicit nulls gives. Fix: normalise each head field through the type gate (missing -> null) first.

### 6. Low: escrowState() takes NaN/Infinity/1.5/-1 as a finalized height: NaN or Infinity yields claimed with no 'not bounded' note and a null height

`observatory/src/escrow.ts:205`

```
const height = typeof opts.finalizedHeight === "number" ? opts.finalizedHeight : null;
```

escrowState, which interfaces/README.md offers to library callers, gates the height with typeof === 'number' only. With NaN every `block_number > height` is false, so a release at any block counts, no 'not bounded' note is added and finalized_height serialises as null: a claimed state that looks like an unbounded one but omits the note the rules require. Not reachable from the CLI or snapshot command (they pass null or a status height validated by nonNegativeInt). Reproduced by running the compiled module.

**Reproduction**

node --input-type=module -e with escrowState(ID, [{genesis:G,from_block:0,to_block:99,page:0,next_page:null,events:[open@5, EscrowReleased@90]}], {pinnedGenesis:G, finalizedHeight:h, observedAt:'2026-11-10T00:00:00Z', trust:'fixture'}), G = devnet genesis. Output: h=null -> claimed, notes ['evidence is not bounded by a finalized height: ...'], height null; h=50 -> unverified; h=NaN -> claimed, notes [], height null; h=Infinity -> claimed, notes [], null; h=1.5 -> unverified, finalized_height 1.5; h=-1 -> finalized_height -1. Expected: a non-safe-integer height treated as null (claimed with the not-bounded note). Fix: use isSafeInt && >= 0 as status.ts does.

### 7. Low: consistency --semantic-only passes a labeled status row replaced by a bare marked <span> in the table (value loses its label); only the byte comparison catches it

`observatory/scripts/consistency.mjs:460`

```
if (c.tag !== "td") continue;
```

RULES.md section 5 says every value sits in a labeled row and the consistency canary claims each tamper is caught by the semantic rules alone, section 10's byte comparison only backing them up. The labeled-row rule is applied only to marked td cells, so replacing the whole status.reset_detected row with `<span data-k="status.reset_detected" data-v="false">false</span>` passes every semantic rule. A browser foster-parents the span out of the table: a bare 'false' with no 'reset detected' label. Full mode still fails, so a real outputs folder is not accepted overall; the --semantic-only mode (and the canary's claim) is what is wrong. Reproduced by running.

**Reproduction**

out=$T/sem; node dist/cli.js snapshot --status fixtures/status-healthy --escrows fixtures/escrows-verification --census fixtures/census-complete --drift fixtures/drift-seeded --out $out --log $out/snapshots.jsonl --source-sha x; node scripts/mcp-transcript.mjs $out > $out/mcp-transcript.jsonl. In $out/index.html replace the one occurrence of `<tr><th scope="row" data-for="status.reset_detected">reset detected</th><td data-k="status.reset_detected" data-v="false">false</td></tr>` with `<span data-k="status.reset_detected" data-v="false">false</span>`. `node scripts/consistency.mjs --semantic-only $out` -> "failures": [], exit 0. Without --semantic-only: one failure, 'index.html is exactly the page src/page.ts renders for this snapshot', exit 1. Expected: a semantic failure (value not in a labeled row). Fix: apply the labeled-row rule to every marked element inside a section table (or allow span only for the three snapshot.* intro values) and add this tamper to consistency-canary.mjs.

### 8. Low: Census refusal kind differs between TypeScript (input) and recount.py (page-file) when event-pages cannot be listed or the folder cannot be searched

`observatory/src/source.ts:205`

```
const entries = readdirSync(pagesDir, { withFileTypes: true, encoding: "buffer" }).sort((x, y) => Buffer.compare(x.name, y.name));
```

RULES.md 3.1 clause 16: a refusal names its kind and the Python recount names the same one; compare-recount.mjs counts refusals as agreeing only with the same kind. readdirSync(event-pages) is not caught in FixtureSource.fromDir, so EACCES surfaces as 'census refused (input)', while recount.py:146 reports 'page-file' ('event-pages: cannot list'). With the scenario folder unsearchable, TypeScript refuses input (no readable meta.json) and recount.py page-file ('cannot stat', line 137). Both refuse, so no count is wrong or partial-as-complete; the clause-16 agreement fails and the cross-check has no permission case to see it. Reproduced by running as a non-root user.

**Reproduction**

cp -r fixtures/census-complete $T/perm; G=0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631; chmod 000 $T/perm/event-pages; `node dist/cli.js census --fixtures $T/perm` -> 'flop-observatory: census refused (input): EACCES: permission denied, scandir .../perm/event-pages' exit 2; `python3 -I tools/recount.py --dir $T/perm --genesis $G --from 0 --to 199` -> 'census refused (page-file): event-pages: cannot list: [Errno 13] Permission denied'. chmod 755 event-pages; chmod 444 $T/perm: TS -> 'census refused (input): fixture .../perm has no readable meta.json'; Python -> 'census refused (page-file): event-pages: cannot stat: [Errno 13]'. Expected: the same kind on both sides.

### 9. Info: A /chain reading whose http is "200" (string) is reported as 'does not count: http 200'

`observatory/src/status.ts:411`

```
: chain.http !== 200
```

The gate compares the raw field and prints it, so a type-invalid status is described as an HTTP 200 that was rejected. reset_detected is correctly null; only the detail text is misleading. Reproduced by running.

**Reproduction**

cp -r fixtures/status-healthy $T/hs; sed -i 's/"http": *200/"http": "200"/' $T/hs/faucet-chain.json; `node dist/cli.js status --fixtures $T/hs` -> faucet.detail 'the /chain reading does not count: http 200', reset_detected null. Expected: a detail saying the status is not a whole number.

### 10. Info: get_escrow_state echoes an unseen escrow id in the caller's case instead of lower case

`observatory/src/mcp.ts:51`

```
escrow_id: id,
```

escrowFor matches case-insensitively but the fallback returns `escrow_id: id` as typed, while every other output and the MCP binary's own serve check use 0x + 64 lower-case hex. A client keying by escrow_id can hold two spellings of one escrow. State is unverified either way. Reproduced by running.

**Reproduction**

printf '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_escrow_state","arguments":{"escrow_id":"0xABAB...AB" (64 hex)}}}\n' | node dist/mcp-bin.js <healthy outputs>/snapshot.json -> answer text holds "escrow_id": "0xABABABAB..." upper case. Expected lower case. Fix: escrow_id: id.toLowerCase().

### 11. Info: reset_detected stays false while the RPC is degraded for a stuck finalized height, or unknown because the stuck check cannot run

`observatory/src/status.ts:455`

```
const rpcUsable = fresh && !flags.contradicted && finalizedHeight !== null && bestHeight !== null;
```

RULES.md 1.1's usable-head list (fresh, valid heights, uncontradicted) does not include the stuck-height check, and the code follows it, so a head stale because finality is stuck, or one whose stuck check could not run, still lets reset_detected be false. A head stale by age makes it null. Matches the letter of the rules; reported for the rule author's decision under 'unknown is never good news'. No wrong genesis comparison: the genesis itself was read fresh. Reproduced by running.

**Reproduction**

Four copies of fixtures/status-healthy with meta.now / fetched_at / rpc observed_at at 23:50:00, 23:51:00, 23:52:00, 23:53:00Z, finalized.number 2000 in all, best 2002..2005; `node dist/cli.js snapshot --status $T/b$i --out $T/ob$i --log $T/b.jsonl --source-sha x` for each in order. ob3/snapshot.json: rpc degraded 'finalized height stuck at 2000 for 180s', reset_detected false. Variant: first snapshot's rpc observed_at 'not-a-time', second at the same height: rpc unknown 'an earlier snapshot at this finalized height has a head time that does not parse...', reset_detected false.

---

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