# Audit report

> Review only the changed verifier and its immediate callers at db3c4f22f67c4a6baab8c14657afb4c95a1f3d98 relative to 12da8a671d2b5c33dc34d3f4a634412f6da5d7c2. All four preceding specialists completed and found two substantive issues: explicit explorer refusals exhausted the permanent cap, and a concurrent read could discard an accepted GUID. This candidate fixes those issues. A valid source-listing entry is required before submitting. Known definitive key/rate-limit/indexer/action refusals release only their matching reservation, with exponentially increasing retry intervals capped at one hour. HTTP/transport uncertainty and actual verification failures retain their charged slots. Each accepted GUID is durably recorded in bounded attempt history and merged only into the reservation that sent it, preserving concurrent read fields. The in-flight cooldown suppresses duplicate steps. Exhausted rows and unrecognized GUID statuses retain their original identity and are read at most hourly; later exact external verification can complete the same job. Legacy rows have only a forward-bound migration slot, not a fabricated reconstructed history. Etherscan listing plus exact Sourcify creation/runtime evidence remains mandatory.
>
> Eighteen verifier tests include explicit indexing/key/rate refusals, request backoff, a gated POST with a concurrent persisted row update, cap retention for HTTP failures, and eventual exact completion. The focused verification/caller run passed 51 checks with one optional external PostgreSQL skip. The previously reviewed round-robin jobs code is unchanged; the earlier full runtime and real-chain evidence is reused. Only this adapter, its tests, version metadata and generated/documentation files changed. No Solidity, signer, payment, mandate, financial limits, custody, tokens or inference changes. Assess concrete regressions in this diff; do not require unrelated future products or restate already documented profile limits as newly introduced defects. Source is published; runtime has not been deployed.

| | |
|---|---|
| Repository | https://github.com/contractclaus/claus-network |
| Commit | `db3c4f22f67c4a6baab8c14657afb4c95a1f3d98` |
| Job | `d135fe68-67bc-4576-99c0-b488ce28334d` |
| Judged | 2026-10-12 20:41 UTC |
| Findings | 3 low · 1 info |

Four agents audited the code as it is at `db3c4f2`, 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: A known rate-limit reply on checkverifystatus freezes the whole row for one hour, unlike the 60 s exponential backoff the same message gets on listing and submission

`adapters/source-verification.mjs:126`

```
          } else value.nextCheckAt = now() + 3600000;
```

The diff classifies 'invalid api key', 'rate limit', 'daily limit' and 'invalid action' as transient explorer refusals on the verifysourcecode path (line 99) and schedules them on the etherscanRefusals backoff that starts at 60 s. The identical text returned by checkverifystatus does not match the pending regex (line 112) or the terminal regex (line 117) and falls into the new else branch, which sets nextCheckAt one hour ahead. Because the early return at line 32 gates the entire step, that hour also suppresses the Sourcify match read and the Etherscan listing read, so a single rate-limit reply on the GUID poll (issued in the same tick as the listing read and the POST, on a key shared by the chain-worker and both collaboration-worker verifiers) delays completion of the module-deploy / project-register job by up to an hour. At 12da8a6 such a row was re-polled on the next 30 s tick. docs/testnet.md reserves the hourly cadence for 'unrecognized GUID statuses'; a rate-limit message is one the same file explicitly recognizes. Merged from audit_permissions 44c8432a, audit_flow 33a381e6, audit_economics d6a868a2. Fix: apply the line 99 refusal regex to status.result before the else branch and schedule those replies with the etherscanRefusals exponential backoff; keep the fixed hourly interval for genuinely unrecognized statuses such as 'Unknown UID'.

**Reproduction**

In-memory store (openStore(':memory:')), now=100000, stub fetcher: Sourcify POST -> {verificationId:'source'}, Sourcify GET -> {runtimeMatch:'exact_match',creationMatch:'exact_match'}; getsourcecode -> {status:'1',result:[{SourceCode:'',ContractName:'Example'}]}; verifysourcecode -> {status:'1',result:'guid-1'}; checkverifystatus -> {status:'0',result:'Max rate limit reached'}. step(input) once: result.etherscanGuid === 'guid-1' and result.nextCheckAt - now === 3600000. Then switch checkverifystatus to 'Pass - Verified' and getsourcecode to SourceCode '{}' and call step every 30 s for 59.5 minutes: zero Sourcify, listing, POST or status requests are made and state stays 'pending'; only the step at now+3600000 returns 'verified'. Control: same stub but verifysourcecode -> {status:'0',result:'Max rate limit reached'}: result.nextCheckAt - now === 60000. Expected: the recognized rate-limit message on the status poll schedules the next poll on the same 60 s exponential backoff and does not block the Sourcify/listing reads. Actual: fixed 3,600,000 ms hold on the entire row. Reproduced with test/scratch/judge-probe.test.mjs (test C) at db3c4f2.

### 2. Low: A refused source listing overwrites etherscanVerified/etherscanMatch before the refusal check and returns early, persisting etherscanVerified=false and etherscanMatch=null beside a stale etherscanStat

`adapters/source-verification.mjs:58`

```
      value.etherscanVerified = !!entry?.SourceCode && entry.ContractName === artifact.contract;
      value.etherscanMatch = value.etherscanVerified ? entry.SimilarMatch ? 'similar' : 'exact' : null;
      if (!entry) {
```

Lines 58-59 derive etherscanVerified and etherscanMatch from `entry` before line 60 decides whether the reply was a listing at all. When getsourcecode is refused (status '0', e.g. 'Max rate limit reached' or 'Invalid API Key') `entry` is null, so a previously observed verified listing is overwritten with etherscanVerified=false and etherscanMatch=null, and the new early-return branch at lines 60-65 saves and returns without running the derived-field block at lines 129-134. Before this diff the same overwrite happened but the step always continued to lines 129-134, so etherscanState was recomputed together with the listing fields and the row stayed internally consistent. After this diff the persisted row, the value returned to chain-executor / vault-executor / wallet-executor (stored as `verification: proof` on chain-modules, chain-registrations, project-vaults, wallet-actions) and every early return at line 32 during the backoff window (60 s up to one hour) carry etherscanState:'verified', etherscanVerified:false, etherscanMatch:null and an etherscanError at the same time, and a previously recorded 'exact' match is erased by a reply that contained no listing. A brand-new row whose first listing is refused is likewise persisted and returned with no etherscanState, etherscanRetryExhausted, explorerUrl or sourcifyUrl, fields the pre-change code always populated. The same stale-derived-field pattern exists on the line 71 save before the POST (after a third transport-failed POST the row carries etherscanSubmissionCount 3 with the previous step's etherscanRetryExhausted false / etherscanState 'pending' for 60 s). Merged from audit_permissions 10bc9919, audit_math 0cf11592, audit_economics 76e54f14. Fix: move lines 58-59 below the `if (!entry)` block so a refused read does not rewrite listing facts, and recompute etherscanState and the url fields before every save/return (for example by extracting the lines 129-133 tail into a helper used by all exits).

**Reproduction**

In-memory store, now=100000, stub fetcher: Sourcify POST -> {verificationId:'source'}, Sourcify GET -> {runtimeMatch:'exact_match',creationMatch:'match'} (not exact, so state stays 'pending'); getsourcecode -> {status:'1',result:[{SourceCode:'{}',ContractName:'Example',SimilarMatch:''}]} on the first step and {status:'0',result:'Max rate limit reached'} afterwards. Step 1: result has etherscanState 'verified', etherscanVerified true, etherscanMatch 'exact', state 'pending'. Step 2 at now+30000: expected either the listing fields unchanged (no listing was received) or etherscanState recomputed to 'pending' with them. Actual, both in the returned value and in store.read('chain-verifications', id).value: {etherscanState:'verified', etherscanVerified:false, etherscanMatch:null, etherscanError:'Max rate limit reached', nextCheckAt:now+60000}; a step at now+60000 returns the same contradictory object from line 32. First-contact variant: new row, getsourcecode -> {status:'0',result:'Invalid API Key'}: persisted keys are address,chainId,checkedAt,creationMatch,etherscanError,etherscanMatch,etherscanRefusals,etherscanSubmissionCount,etherscanVerified,id,inputHash,nextCheckAt,runtimeMatch,sourcifyAlreadyVerified,sourcifyId,state with no etherscanState, explorerUrl, sourcifyUrl or etherscanRetryExhausted. Reproduced with test/scratch/judge-probe.test.mjs (tests B and B2) at db3c4f2.

### 3. Low: etherscanRefusals is only reset by an accepted POST, so isolated refusals over a row's lifetime ratchet the backoff to the one-hour cap instead of restarting at one minute

`adapters/source-verification.mjs:62`

```
        value.etherscanRefusals = Math.min((value.etherscanRefusals ?? 0) + 1, 7);
        value.nextCheckAt = now() + Math.min(3600000, 60000 * 2 ** (value.etherscanRefusals - 1));
```

The new etherscanRefusals counter drives the backoff min(1h, 60s * 2^(refusals-1)) on both the getsourcecode refusal path (lines 62-63) and the POST refusal path (lines 101-102). The only reset is line 93, when a verifysourcecode POST is accepted. A successful listing (entry present at line 60), a successful or pending status poll (line 112) and the final save never reset it, so the counter counts refusals over the row's whole lifetime rather than the current streak. Any row that will not POST again (a row holding a pending GUID, a row whose Etherscan listing is already verified but is still waiting for an exact Sourcify creation match, an exhausted row) accumulates every transient explorer blip; after seven, each further single refusal parks the row for a full hour and, because line 32 returns before the Sourcify and Etherscan reads, both providers are skipped for that hour. A row that collected seven listing refusals during a key outage and then recovers has its first POST refusal ('Unable to locate ContractCode' is normal for a freshly deployed address) scheduled one hour out instead of one minute. docs/testnet.md describes retries that 'back off from one minute to one hour', which implies a fresh streak starts at one minute. Impact is delay of module state 'verified' (and so prepareRegistration, which requires it) by up to one hour per transient refusal; no cap slot or GUID is lost. Merged from audit_permissions 762ad851, audit_flow 9997ac5d, audit_math bd51fdea, audit_economics 30612c27. Fix: reset etherscanRefusals to 0 whenever an Etherscan reply is not a refusal (entry present at line 60; recognized pending/pass status at line 112), or derive the backoff from refusals since the last success.

**Reproduction**

In-memory store, now=100000, stub fetcher: Sourcify exact on both matches; verifysourcecode -> {status:'1',result:'guid-1'}; checkverifystatus -> {status:'0',result:'Pending in queue'}; getsourcecode -> {status:'1',result:[{SourceCode:'',ContractName:'Example'}]} unless a refuse flag is set, then {status:'0',result:'Max rate limit reached'}. Step once (GUID accepted, etherscanRefusals 0). Then for day 1..8: five healthy steps 30 s apart (etherscanError undefined each time), advance 86,400,000 ms, one refused step, record nextCheckAt - now, advance to nextCheckAt+1. Expected: each isolated refusal, preceded by a day of healthy polls, backs off 60,000 ms. Actual: 60000,120000,240000,480000,960000,1920000,3600000,3600000 with stored etherscanRefusals 1,2,3,4,5,6,7,7. Variant: seven consecutive listing refusals (each step at nextCheckAt+1) -> etherscanRefusals 7; advance 24 h; listing now healthy and verifysourcecode -> {status:'0',result:'Unable to locate ContractCode at 0x1111'}: etherscanSubmissionCount is correctly released to 0 but nextCheckAt - now === 3600000 (expected 60000). Reproduced with test/scratch/judge-probe.test.mjs (test A) at db3c4f2.

### 4. Info: An exhausted row whose hourly listing read is refused is re-read after 1, 2, 4 ... minutes instead of hourly as documented

`adapters/source-verification.mjs:63`

```
        value.nextCheckAt = now() + Math.min(3600000, 60000 * 2 ** (value.etherscanRefusals - 1));
```

Line 131 gives an exhausted row (etherscanRetryExhausted true, etherscanState 'operator_action_required') an hourly cadence, and docs/testnet.md in this diff states that exhausted rows are checked at most hourly. The refused-listing branch at lines 60-65 runs first and returns early with its own schedule computed from etherscanRefusals, which for a row without earlier refusals is 60 s. So when the hourly getsourcecode read of an exhausted row is rate-limited or key-refused, the row is read again after 1, 2, 4, 8, 16 and 32 minutes (each read costing a Sourcify GET and an Etherscan call against an already refusing key) before settling back to hourly. No cap slot is consumed, no POST is made and no state is corrupted; the effect is extra traffic and a documented cadence that is not kept. Merged from audit_flow 8c7593d3, audit_math fddd448b, audit_economics d7bd7fa8. Fix: in the `!entry` branch use Math.max(backoff, 3600000) when value.etherscanRetryExhausted is already true (and arguably when a GUID is in an unrecognized state).

**Reproduction**

In-memory store, now=100000, stub fetcher: Sourcify exact on both matches; getsourcecode -> {status:'1',result:[{SourceCode:'',ContractName:'Example'}]}; verifysourcecode -> {status:'1',result:'guid-N'}; checkverifystatus -> {status:'0',result:'Fail - Unable to verify'}. Three steps 90 s apart reach etherscanState 'operator_action_required', etherscanRetryExhausted true, nextCheckAt now+3600000. Advance to nextCheckAt+1, switch getsourcecode to {status:'0',result:'Max rate limit reached'}, step once: result.etherscanRetryExhausted === true and result.nextCheckAt - now === 60000 (expected 3600000). Stepping every 30 s for the next 10 minutes issues 3 further getsourcecode requests (at +1, +3 and +7 minutes). Reproduced with test/scratch/judge-probe.test.mjs (test D) at db3c4f2.

---

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