# Audit report

> Review exact changed runtime 12da8a671d2b5c33dc34d3f4a634412f6da5d7c2 against 12ddbe0b12380f1260f227318b06a0d03948d386. The preceding review found unbounded identical-input Etherscan retries, stale failure diagnostics after a new accepted GUID, repeated Sourcify uploads on an already-verified 409, and pass responses retained as errors. This candidate fixes those findings. Etherscan has a durable three-submission cap including failed/uncertain POSTs, reserved before sending with row-revision CAS; legacy active attempts conservatively count as one. Original failed GUIDs remain. At exhaustion state stays pending and etherscanState is operator_action_required; read-only exact source checks can still complete an independently verified job. No counter reset or new payment/transaction is possible here. Sourcify 409 suppresses repeated upload only; exact creation/runtime match and Etherscan listing remain mandatory. Twelve focused cases cover both chains, concurrency, cap across HTTP/refused/terminal failures, later exact completion, stale errors, 409, and saved GUID recovery. Version and generated docs are synchronized. Scope is noncustodial readonly Ethereum/Sepolia, finite unchanged sponsor/gas limits. Evaluate the diff and immediate callers for concrete regressions. The prior full runtime gate and real chain acceptance remain applicable to unchanged code. No custody, token launch or public inference.
>  The last specialist also reproduced pre-existing queue starvation. The same candidate now advances the existing per-kind scan cursor after a successful claim, wrapping once with no more than1,000 rows per claim. A waiting early-ID job cannot monopolize the lane; leases and original effect keys are unchanged. 59 focused checks pass and one optional external-Postgres case is skipped. These cover jobs, source verification, approvals, public review/admission, services and intake. Source0.12.7 was never deployed; migration of the legacy verifier sees at most one original submission per row.

| | |
|---|---|
| Repository | https://github.com/contractclaus/claus-network |
| Commit | `12da8a671d2b5c33dc34d3f4a634412f6da5d7c2` |
| Job | `8b53e808-0b20-4f7f-a342-bccb6bd261ae` |
| Judged | 2026-10-12 20:28 UTC |
| Findings | 1 medium · 1 low · 1 info |

Four agents audited the code as it is at `12da8a6`, 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: Explicit Etherscan non-acceptance replies (not-yet-indexed, rate limit, invalid key) consume the durable three-submission cap, so a three-minute explorer hiccup after confirmation permanently strands

`adapters/source-verification.mjs:72`

```
        else value.etherscanError = String(submission.result).slice(0, 500);
```

Merged from audit_permissions #1, audit_economics #1, audit_math #1 and audit_flow #1 (same mechanism, same fix). The slot reserved at line 62 is never released, and line 72 records every status '0' reply the same way, whether Etherscan evaluated the source ('Unable to verify') or explicitly refused to admit the request ('Unable to locate ContractCode at 0x..' while the indexer has not seen the deployment, 'Max rate limit reached', 'Max daily rate limit reached', 'Invalid API Key'). Charging HTTP/transport failures is justified because the submission may have landed; a status '0' body is Etherscan's statement that no GUID was issued and nothing was queued, so charging it buys no protection against duplicate identical submissions. The immediate callers make this routine: chain-executor.mjs:135 and :175 (and wallet-executor.mjs:48, vault-executor.mjs:49) call verification.step as soon as the receipt has two confirmations, and src/chain-deployments/index.mjs:169-170 re-steps a 'verifying' job every 30 s, so with the 60 s cooldown the three POSTs land at t=0, t=90 s and t=180 s after confirmation. If Etherscan is still refusing at t=180 s (indexer lag, a shared-key rate limit across the deployment/execution/wallet/vault lanes, or a misrotated ETHERSCAN_API_KEY), etherscanSubmissionCount is 3, etherscanRetryExhausted is true, etherscanState is 'operator_action_required', and line 59 blocks every future submission of this immutable input: there is no reset and verification_input_changed pins the row to the address. The module stays 'verifying' (chain-executor.mjs:136), prepareRegistration requires module.state === 'verified' (index.mjs:89), the module id digest({projectId, caseId, kind}) (index.mjs:67) prevents redeploying the same reviewed case, and a project-register row stuck in 'verifying' blocks any other registration for that project via 'registration_in_progress' (index.mjs:103, :128). Only a manual Etherscan verification by the operator lets the read-only path complete the job. The previous runtime (12ddbe0, and deployed 0.12.x) simply retried these refusals after the cooldown until the explorer accepted, so this is a regression introduced by the candidate. Foundry's own verifier treats 'Unable to locate ContractCode at' as 'could not detect deployment' and retries it rather than counting it. Note the stranded row keeps being stepped every 30 s with one Sourcify GET and one getsourcecode call (pre-existing cadence), so exhaustion also costs quota without a backoff. Minimal fix that keeps the three-submission design: after the POST, when submission.status === '0' and the result matches a known non-admission (/unable to locate contractcode|rate limit|daily limit|invalid api key/i), decrement etherscanSubmissionCount (keep etherscanAttemptAt so the 60 s cooldown still applies) before the save at line 73; keep the charge for HTTP/transport failures, accepted GUIDs and genuine 'Unable to verify' refusals. Alternatively gate the POST until getsourcecode/eth_getCode shows the address is indexed, and/or make the cooldown exponential so three submissions span an hour rather than three minutes.

**Reproduction**

Scratch Node test (test/scratch/judge.test.mjs, case A) on this tree with store = openStore(':memory:') and the fixture input from test/source-verification.test.mjs. Fetcher: Sourcify POST -> {verificationId:'source'}; Sourcify GET -> exact_match/exact_match; getsourcecode -> {status:'1',result:[{SourceCode:'',ContractName:'Example'}]}; verifysourcecode -> HTTP 200 {status:'0',message:'NOTOK',result:'Unable to locate ContractCode at 0x1111...1111'} (or 'Max rate limit reached', or 'Invalid API Key' with getsourcecode also returning that status '0' body) while ready=false, then {status:'1',result:'guid-N'} once ready=true; checkverifystatus -> 'Pending in queue'. Drive verifier.step(input) eight times with now() advancing 30 000 ms per call (the worker cadence), then set ready=true and step 20 more times 90 s apart. Expected: once the explorer admits the request the unchanged input is submitted once and etherscanGuid is set. Actual, all three refusal strings: POSTs at offsets 0 s, 90 s, 180 s; after step 8 etherscanSubmissionCount=3, etherscanState='operator_action_required', state='pending', etherscanError=<refusal>; after the explorer is ready posts stays 3, accepted=0, etherscanGuid undefined, etherscanState stays 'operator_action_required' on every later step. The specialist proof at .imd/reads/proofs/Proof_750a37901a22.t.sol is a Node test with the same fetcher (indexed at +181 s); run as test/scratch/proof-750a.test.mjs it fails on this code with 'the explorer accepted a submission once it indexed the contract' (accepted=0). Against the 12ddbe0 adapter the same fetcher yields a fourth POST and a GUID once indexed.

### 2. Low: A step that completes while another step's verifysourcecode POST is in flight makes the accepted GUID fail the revision CAS, so the GUID is dropped, the reserved slot is burned and the same input is r

`adapters/source-verification.mjs:36`

```
          requireThat((current?.revision ?? 0) === (row?.revision ?? 0), 'verification_step_stale');
```

Merged from audit_permissions #2, audit_math #2 and audit_flow #2 (same mechanism, same fix). The reservation is a two-phase write: save() at line 63 bumps the row to revision r+1, the external POST runs for up to 25 s, and the result is persisted by a second save() at line 73 that line 36 only allows if the revision is still r+1. Every step ends with an unconditional save() at line 100, including a purely read-only step. So a second step for the same row that starts after the reservation and finishes before the POST returns (it sees etherscanAttemptAt within the 60 s cooldown and makes no POST) moves the row to r+2, and the first step's post-POST save throws verification_step_stale. The accepted GUID is never persisted: the row keeps etherscanSubmissionCount=1 with no etherscanGuid, no etherscanError and no chain-verification-attempts record. After the cooldown the next step reserves slot 2 and POSTs the identical source again while guid-1 is still live on Etherscan, which is exactly the duplicate the cap exists to prevent; if guid-1 later fails there is no retained evidence for it, contrary to 'original failed GUIDs remain'. Under 12ddbe0 this race only cost a retry (the losing save overwrote with the current revision); under the finite non-resettable cap each occurrence consumes one of three slots and pushes the row toward the operator_action_required state of the previous finding. Reachability: a step can spend five explorer calls of up to 25 s each plus RPC time, exceeding the 120 s lease in src/chain-deployments/index.mjs:149 (vaults/wallets likewise), after which the next per-minute cron tick (worker/chain-tick.mjs, schedule '* * * * *') on another instance re-claims the job and steps the same row; the repository's concurrency test starts all five steps simultaneously so losers fail at their first save and this interleaving is never exercised. Minimal fix: when the save after a successful POST would be stale, re-read the current row inside the transaction and merge the GUID/error into it when etherscanSubmissionCount and etherscanAttemptAt are unchanged (the reservation already guarantees no competing POST used this slot); or record the accepted GUID in a chain-verification-attempts row keyed by digest({id, guid}) before the CAS save; or have a step that observes an in-flight reservation (attemptAt within the cooldown, no GUID, no error) skip its final save.

**Reproduction**

Scratch Node test (test/scratch/judge.test.mjs, case B) on this tree with openStore(':memory:') and the fixture input. Fetcher: Sourcify POST -> {verificationId:'source'}; Sourcify GET -> exact/exact; getsourcecode -> SourceCode ''; verifysourcecode -> increments posts and, for the first call only, resolves a 'postStarted' promise then awaits a gate before returning {status:'1',result:'guid-1'}; checkverifystatus -> 'Fail - Unable to verify'. Start slow = verifier.step(input) without awaiting; await postStarted; quick = await verifier.step(input) (returns etherscanSubmissionCount=1, etherscanGuid undefined); release the gate. Expected: slow persists etherscanGuid='guid-1' and the later poll records guid-1's failure in chain-verification-attempts with posts still 1. Actual: slow rejects with code 'verification_step_stale'; the stored chain-verifications row has etherscanSubmissionCount=1, etherscanGuid undefined, etherscanError undefined; chain-verification-attempts is empty. Advance now() by 90 000 ms and step again: posts=2, etherscanSubmissionCount=2, and chain-verification-attempts contains only ['guid-2']; guid-1 is never retained anywhere.

### 3. Info: Legacy-row migration counts an unbounded history of refused Etherscan POSTs as one submission; the deployed verifier re-POSTed every 60 s whenever no GUID was returned

`adapters/source-verification.mjs:32`

```
      value.etherscanSubmissionCount ??= value.etherscanGuid || value.etherscanAttemptAt ? 1 : 0;
```

Merged from audit_economics #3, audit_math #3 and audit_flow #3. The task premise 'migration of the legacy verifier sees at most one original submission per row' holds only for rows that obtained a GUID (the pre-12ddbe0 code at bce9c41 never deleted etherscanGuid). In bce9c41 the submission guard was `!etherscanVerified && !etherscanGuid && (attemptAt older than 60 s)`, and a status '0' reply only refreshed etherscanAttemptAt and etherscanError, so a legacy row whose POSTs were refused N times (rate limit, daily limit, 'Unable to verify', not-yet-indexed) was re-POSTed after every 60 s cooldown for as long as the job was retried and holds N real submissions with no GUID and no attempts rows. Line 32 migrates such a row as count 1 and admits two more identical submissions. Impact is bounded (at most two extra POSTs per legacy row, no payment or transaction), so informational; the stated migration invariant is inexact rather than unsafe. Fix: document that pre-upgrade refusals are not counted, or treat a legacy row with etherscanAttemptAt and etherscanError but no GUID as already at the cap if a conservative bound is wanted.

**Reproduction**

Scratch Node test (test/scratch/judge.test.mjs, case C). Seed chain-verifications with the row the bce9c41 adapter leaves after repeated refusals: {id: digest({chainId:11155111,address}), address, chainId:11155111, inputHash (same digest formula as the adapter), state:'pending', sourcifyId:'x', etherscanAttemptAt:1700000000000, etherscanError:'Max rate limit reached'} with no etherscanGuid and no etherscanSubmissionCount. Step the current adapter with now()=1700000061000 and verifysourcecode still returning {status:'0',result:'Max rate limit reached'}. Expected under the stated premise that the cap bounds total identical submissions: a row that already exhausted the budget pre-upgrade makes no further POST. Actual: the first step migrates the count to 1, reserves and POSTs (count 2, posts 1); 90 s later a second POST is made (count 3, posts 2) before etherscanState becomes 'operator_action_required'. N legacy refusals plus two new POSTs in total.

---

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