# Audit report

> Independently audit this exact 0.11.3 candidate for public sponsored reviews and Sepolia readonly contracts/intake. This is a distinct changed source addressing findings from paid review 3af99ba5-482b-484a-9199-59d040f5e96c on 18401b940104f701946be523e9100ce3db754f1f. Do not open subjobs or pay for anything. Focus on the changed admission and recovery paths: bounded adaptive block/log scanning with existing delivery before scanning; per-binding rejection retention; public lifetime sponsor exposure released only for provably unsent cancelled reviews while daily attempt counters remain; local signing failure cleanup; minimum usable payment window; expired-pilot enrollment into the public gas ledger with executor compatibility; public decision limits 2/project/day,8/global/day,4/project lifetime,32/global lifetime and one queued/waiting intake request/project; durable delegated credentials for public asynchronous actions; per-project dispatcher ceilings; no RPC under its database write lock; visible gas deferrals. Planned separate budgets: 4 IMD lifetime,0.5/review,2/owner and project/day,8 global/day and active;7 days,no refill. Executor gas 0.002 Sepolia ETH total,0.0005/project,0.0002/transaction,8 projects. Dispatcher 0.0004 total,0.00005/project,0.0002/transaction. Existing operator model budget is separately bounded in the agent runtime and is not granted by this network. Deliberate conservative boundaries: unknown submitted payments are never released solely on provider expiry; a possibly broadcast transaction holds its original nonce and budget pending chain reconciliation; a rotated budget does not silently authorize an old unsent callback. Docs describe these operator-reconciliation limits. EOA caps are per address, not proof of unique humans; global ceilings bound Sybil spending. Exact reviewed executor/kernel artifacts, genuine Claus decisions, zero owner-required minimum upgrade delay; no mainnet, customer deposits, public inference, tokens or arbitrary mutating modules. Inspect source and tests independently, reproduce concrete problems; report severity, paths, exploitability and limits. Do not edit source, post, deploy, sign, or create paid subjobs. Public free project access is live; public financial/chain admission remains disabled pending assessment.

| | |
|---|---|
| Repository | https://github.com/hazarxyz/claus-network |
| Commit | `91e8ba266d13e2532fd2e10fb5c456c639d39055` |
| Job | `05097b72-02e2-40e8-b9b7-4f7087fe18f8` |
| Judged | 2026-10-12 12:16 UTC |
| Findings | 3 medium · 6 low |

Four agents audited the code as it is at `91e8ba2`, 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: Expired-pilot projects admitted under the public chain policy bypass every public decision ceiling, the durable-agent rule and the one-request intake limit until first gas enrollment

`src/approvals/limits.mjs:7`

```
  if (!await reader.read('public-chain-projects', projectId)) return;
```

All four public decision ceilings (2/project/day, 8/global/day, 4/project lifetime, 32/global lifetime) and the durable-agent requirement in approvals.create (src/approvals/index.mjs:40-42), submitRegistration (src/chain-deployments/index.mjs:91-92), contract-intake bind (src/contract-intake/index.mjs:52) and the one queued/waiting intake request per project rule (src/contract-intake/index.mjs:114) are all keyed on the existence of a 'public-chain-projects' row. That row is written only by reserveProject, which runs on deployModule (index.mjs:49) or on the first gas reservation in reserveGas (admission.mjs:82). A project whose review pilot has expired is routed to the public chain-admission policy by deployments.allowed (index.mjs:16-20, `!pilot?.enabled || pilot.expiresAt <= now()`), which admits it as long as the policy is active and its binding is on the current executor, but never enrolls it. Such a project (verified module and chain binding from pilot time, pilot now expired) therefore spends from the public allocation while being counted as nobody: publicDecisionCapacity returns before reading any counter, an owner session (1h TTL) is accepted instead of a delegated agent, and it may hold unlimited concurrent queued intake requests. Each accepted proposal enqueues a claus-review job that consumes the operator model budget; rejected/failed/blocked/cancelled proposals free active-proposals so it can loop indefinitely one proposal at a time, and its decisions never count toward the global 8/day and 32 lifetime ceilings that are meant to bound Sybil spend. docs/operations.md:87 and docs/reviews-and-contracts.md:58 state these projects are inside the ceilings. The same gap lets submitRegistration enqueue a project-register job under a 1-hour owner credential that only becomes enrolled inside reserveGas. Fix: compute the public predicate the way deployments.allowed does (no active pilot OR enrolled) and apply it in publicDecisionCapacity, approvals.create, submitRegistration, bind and ingest; or call admission.reserveProject inside the proposal-creation transaction (approvals.create) so capacity_reached is raised before any decision is spent. Merged from audit_permissions, audit_flow and audit_math.

**Reproduction**

State (test/scratch/a-expired-pilot.test.mjs, node --test): active chain.admission policy published/activated through createChainAdmission with an accepted assessment; review-pilots[p1] = {enabled:true, expiresAt: now-1}; chain-bindings[p1].executor === policy.executor; no public-chain-projects[p1]; public-chain-projects[p2] present (control); every public-decision-admission counter for p1, p2 and global at its maximum (2/2/8 day, 4/4/32 lifetime). createChainDeployments.allowed('owner-session','p1') resolves (public branch admits it). approvals.create('owner-session','p2',{moduleId,configuration,idempotencyKey}) rejects with decision_capacity_reached (expected). approvals.create('owner-session','p1', same body) with a stub deployments.module that throws reached_module_lookup: expected decision_capacity_reached or durable_agent_required; actual reached_module_lookup, i.e. both guards were skipped for an owner session with every ceiling full. Then store.transaction(tx => publicDecisionCapacity(tx,'p1',now,true)) 40 times: all succeed and the p1 lifetime counter stays at 4 and the global lifetime counter at 32 (no counter was read or written).

### 2. Medium: The 32-event intake page is filled by unadmitted Requested logs, so free inbox spam starves admitted requests

`adapters/contract-intake.mjs:65`

```
      if (remaining) logs = logs.slice(0, 32);
```

The 0.11.3 bound of 32 events per hosted tick is applied in the adapter to the raw Requested logs of the inbox (sorted by block/logIndex) before runtime/contract-intake-worker.mjs:82-88 discards events whose binding word is not an admitted contract-bindings row. ClausRequestInbox.request is permissionless and only requires a non-zero binding word, so anyone can emit events with an unregistered binding for Sepolia gas alone (forge gas report on contracts/: request averages 63,139 gas, 98,267 max; Sepolia base fee was 23 wei on 2026-10-12). Each hostile event costs the network one of the 32 page slots although the worker does no work for it, so an admitted request behind N hostile events is ingested only after ceil((N+1)/32) ticks. Once the requester's onchain deadline passes, ingest rejects it with contract_request_closed (src/contract-intake/index.mjs:109), so a sustained spammer above 32 events per tick makes public intake lag forever and expires every real request. The previous version advanced up to 500 blocks per tick and skipped junk for free. Because the Requested event indexes binding (topic 3), the scanner can pass the set of admitted binding words as a topic filter, or the 32-bound can count only admitted/ingested events while unknown bindings are skipped within the fetched range. Merged from audit_permissions, audit_math and audit_economics.

**Reproduction**

State (test/scratch/b-scan-starvation.test.mjs, node --test): real createContractInboxRead with a mock client whose finalized block 1 holds 320 Requested logs with binding 0xab..ab (no contract-bindings row) at logIndex 0..319 followed by one log with the admitted binding 0xcd..cd at logIndex 320; contract-bindings['sha256:cd..cd'] present for the pinned inbox; real createContractIntakeWorker with a recording intake.ingest. Expected: the admitted event is ingested on tick 1 (the worker comment says unadmitted traffic incurs no work). Actual: ticks 1-10 return ingested 0 and advance afterLogIndex by 32; the admitted event is ingested on tick 11 (asserted ticks === 320/32 + 1). Generalised: N hostile events delay an admitted request by ceil((N+1)/32) ticks; at one tick per minute, about 46k hostile events defer every admitted request by a day.

### 3. Medium: Dispatch budget admission accepts a per-project ceiling below the per-transaction ceiling; the planned 0.00005/project vs 0.0002/transaction allocation cannot fund a resolve above ~0.37 gwei

`adapters/contract-delivery.mjs:23`

```
  if (publicAdmission) requireThat(amount(config.maximumProject) > 0n && amount(config.maximumProject) <= total,
```

admitContractDispatchBudget only checks 0 < maximumProject <= maximumTotal; it never requires maximumTransaction <= maximumProject. The sibling chain.admission schema enforces maximumProjectGasWei >= maximumTransactionGasWei (src/chain-deployments/admission.mjs:23) precisely so an admitted transaction size is reservable for at least one project. The allocation stated in the task brief and docs/operations.md:91 (0.0004 ETH total, 0.00005 ETH per project, 0.0002 ETH per transaction) is accepted, yet reserve() (lines 102-112) admits any reservation <= 2e14 wei and then rejects it with dispatch_project_budget_exhausted whenever gas*maxFeePerGas > 5e13 wei, even for a project that has spent nothing. sepolia-transactions.prepare reserves (estimate*1.25+10000)*maxFeePerGas; with a ~100k-gas resolve that is ~135k gas, so the first resolve of every public project fails at any maxFeePerGas above ~0.37 gwei, and resolve plus callback must both fit under the same 5e13 lifetime share. The worker then records delivery.reason = dispatch_project_budget_exhausted with a 15-minute retry (runtime/contract-intake-worker.mjs:45-53) until the request deadline closes it, after the project's decision capacity and binding quota were already consumed. Limit: with publicnode's current eth_maxPriorityFeePerGas suggestion (0.001 gwei, base fee 23 wei, read 2026-10-12) the reservation is ~1.4e11 wei and passes; the 50th-percentile priority fee actually paid on Sepolia in the same blocks is 1 gwei, so any provider or fee spike using that level makes every public delivery defer. Fix: require amount(config.maximumProject) >= perTransaction in admitContractDispatchBudget (mirroring the chain-admission schema) and size the public allocation so one project share covers at least one resolve plus one callback at the configured maximumFeePerGas. Merged from audit_permissions, audit_flow, audit_math and audit_economics.

**Reproduction**

Input (test/scratch/c-dispatch-budget.test.mjs, node --test): admitContractDispatchBudget(store, {id:'public-7d', chainId:11155111, dispatcher, inbox, codeHash, projectIds:[], admissionMode:'public-readonly-v1', expiresAt: now+7d, maximumTotal:'400000000000000', maximumProject:'50000000000000', maximumTransaction:'200000000000000', maximumGas:'1000000', maximumFeePerGas:'50000000000'}). Expected: invalid_dispatch_budget. Actual: accepted and stored. Then with a result_ready request for a fresh project, a client reporting estimateGas 100000 and maxFeePerGas 1 gwei (reservation 135000*1e9 = 1.35e14 wei, inside the 2e14 per-transaction ceiling), createContractDelivery(...).step({id, request, allowSubmit:true}) rejects with dispatch_project_budget_exhausted; the budget ledger shows spent '0', reserved '0'; createContractIntakeWorker.tick() returns deferred 1 and writes delivery {reason:'dispatch_project_budget_exhausted', retryAt: now+900000} on the request.

### 4. Low: A provably-unsent cancellation releases public sponsor exposure but leaves the review case 'admitted', so the same build can never be re-reviewed

`src/review-flow/index.mjs:72`

```
          if (['admitted', 'reviewed'].includes(prior.state)) return prior;
```

The new cleanup paths (runPaid denied branch, lines 299-305, and onCancel, lines 205-209) cancel the review job, void billing and call admission.releaseUnsent so the lifetime exposure is returned, but neither moves review-cases[id].state out of 'admitted'. request() deduplicates by inputHash and returns any prior case in state admitted or reviewed ('A paid or uncertain attempt is never replaced by a new key'), and accept() for that case fails with quote_already_admitted because admitted-quotes[quoteId] is permanent. So after the network itself proved nothing was sent and gave the sponsor capacity back, the project cannot use that capacity for the same build; it must change the commit to get a new inputHash. The comment's rationale does not apply because cancelUnsent/releaseUnsent only succeed when the attempt is provably unsent (work cancelled, billing void). The existing test in test/review-flow.test.mjs re-accepts only after changing baseCommit, so this path is untested. Fix: when releaseUnsent runs, move the case back to 'quoted' (or to a terminal 'cancelled' state that request() ignores) so a fresh quote/admission can be created. Merged from audit_permissions and audit_economics.

**Reproduction**

State (test/scratch/d-review-stuck.test.mjs, node --test, real createApplication): public sponsorship policy active (maximumLifetime '5'); request -> runQuote -> accept for build B; runPaid with a payment adapter whose prepare records state signing_unknown and throws payment_signing_unknown. Result: workflow state 'blocked', admission.get().committedMaximum === '0' (exposure released). Then flow.request(actor, project, {...sameInput, idempotencyKey:'retry'}). Expected: a new case/quote for build B. Actual: returns the original case id; review-cases[id].state is still 'admitted' and get() reports 'blocked'; flow.accept(caseId, quoteId, 'retry') throws quote_already_admitted; runQuote() is idle.

### 5. Low: A transient daily decision ceiling terminally blocks a live intake request and keeps its binding quota consumed

`src/contract-intake/index.mjs:160`

```
            'contract_request_closed', 'decision_capacity_reached', 'durable_agent_required'].includes(error.code);
```

publicDecisionCapacity raises the same code, decision_capacity_reached, for the resetting day counters (2/project/day, 8 global/day) and for the lifetime counters (4/project, 32 global). run() lists that code among deterministic denials, so the contract-intake job finishes 'blocked' (terminal for jobs.claim) and the request is written state 'blocked'. The request had already been admitted by ingest (binding.used incremented at line 121, active-contract-request set) and the onchain request stays Pending until its deadline (up to 30 days) with no resolve and no callback, even though capacity returns at the next UTC day. Because the global day counter is shared, four other public projects submitting two proposals each exhaust the 8/day global cap and every further public intake request admitted that day is lost, not delayed. The only way forward is a new onchain request that consumes another binding slot and pays gas again. Fix: distinguish day from lifetime exhaustion (separate codes or a reason field) and map the day case to 'waiting' with retryAt at the next day boundary bounded by the request deadline, or pre-check capacity in ingest before consuming quota, like contract_request_in_progress. Merged from audit_permissions and audit_economics.

**Reproduction**

State (test/scratch/e-daily-cap-blocks.test.mjs, node --test): public-chain-projects[project-a] present; agent binding with maxRequests 2; observed request with deadline now+29d; createProposal throws decision_capacity_reached (as approvals.create does when a day counter is full). api.ingest -> binding.used = 1. api.run(): job state 'blocked', result.error 'decision_capacity_reached', contract-requests[id].state 'blocked'. Advance time by one day and call api.run() again: returns idle (nothing claimable); the request stays 'blocked' and binding.used stays 1. Expected: 'waiting' with a retry after the day rolls over.

### 6. Low: A second onchain request rejected as contract_request_in_progress is dropped permanently: the cursor advances past it and it is never re-ingested or visible to the project

`runtime/contract-intake-worker.mjs:93`

```
        if (!['contract_intake_disabled', 'contract_binding_inactive', 'contract_binding_exhausted', 'contract_request_closed', 'contract_request_in_progress',
```

contract_request_in_progress is a transient condition (one queued/waiting request per public project, src/contract-intake/index.mjs:113-118) but the worker lists it among deterministic rejections. The event is written only to contract-intake-rejections (keyed per binding, overwritten by the next rejection, and read by no API, MCP operation or worker) and the intake cursor is saved past its block. When the first request completes, cancels or expires nothing revisits the second event, so the requester's finalized Requested event (for which it paid gas, with a deadline up to 30 days) is silently never resolved and stays Pending onchain until it expires; contract_request(projectId, id) for it returns not_found. docs/contract-intake.md:100 says 'Completion ... lets the next request enter', which only holds for a request submitted later. Fix: stop the batch at the in-progress event and save the cursor before it (retry from there once the slot is free), or keep a bounded per-project deferred list drained when the active request leaves queued/waiting; and expose the latest per-binding rejection through contract_binding or a read operation so a requester can see why nothing happened. Merged from audit_permissions and audit_flow.

**Reproduction**

State (test/scratch/f-in-progress-drop.test.mjs, node --test, real createContractIntake and createContractIntakeWorker): public project with an admitted binding (maxRequests 10); one scan page returns two finalized Requested events for that binding (requestIds 0x33..33 and 0x66..66, deadlines 7 days out). worker.tick(): ingested 1, contract-intake-rejections holds {code:'contract_request_in_progress'} for the second event, contract-intake-cursors saved at afterBlock 200. Decide the first proposal, advance 60 s and api.run() so request 1 reaches result_ready. worker.tick() again (scan returns no new events): ingested 0; no contract-requests row exists for requestId 0x66..66; api.request('agent','project-a', id2) throws not_found; binding.used is still 1. Expected: the second request is ingested once the project slot is free.

### 7. Low: Expired-pilot enrollment at maximumProjects throws capacity_reached from inside prepare(); the executor treats it as transient and retries every 60 s with four RPC calls per attempt

`src/chain-deployments/admission.mjs:82`

```
        await reserveProject(tx, projectId);
```

reserveGas now enrolls an expired-pilot project by calling reserveProject inside the wallet transaction, which throws capacity_reached when public-chain-usage.global.projects >= maximumProjects. The throw happens inside sepolia-transactions.prepare after estimateGas, estimateFeesPerGas, getTransactionCount and getBalance have run. capacity_reached is not in adapters/authorization.mjs authorityDenied, so adapters/chain-executor.mjs transact() rethrows it instead of returning cancelled, and both src/chain-deployments/index.mjs:147 and src/approvals/index.mjs:209 finish the job 'waiting' with a 60-second retry (the 15-minute deferral list covers only chain_gas_exhausted and transaction_cost_limit). A full public ledger therefore produces a permanent one-minute loop of capability reads plus four RPC calls until the module (24 h), registration (1 h) or proposal window expires, and the job never surfaces as blocked. For a proposal the Claus decision (model budget) was already spent on something that can never execute. Fix: check public project capacity in deployments.allowed / approvals.create for the expired-pilot branch before a decision is enqueued, and treat capacity_reached from reserveGas as terminal (add it to the blocked list or map it to an authority code). Merged from audit_flow and audit_math.

**Reproduction**

State (test/scratch/g-capacity-retry.test.mjs, node --test): active chain.admission policy with maximumProjects 1 and one enrolled public project (public-chain-usage.global.projects 1); review-pilots[p1] expired; chain-bindings[p1].executor === policy.executor. store.transaction(tx => admission.reserveGas(tx, {id, maximum:'5', intent:{authority:{projectId:'p1'}}})) rejects with capacity_reached; authorityDenied({code:'capacity_reached'}) is false; no enrollment or reservation row is written. A queued module-deploy job for p1 run through createChainDeployments.runOne with an executor step that raises that code finishes {state:'waiting', availableAt: now+60000, result.error:'capacity_reached'} on each of three consecutive attempts while chain-modules[m1].state stays 'queued'. Expected: a terminal blocked outcome or a refusal before the decision is spent.

### 8. Low: A single finalized block holding more Requested logs than the provider returns per eth_getLogs call stalls the intake cursor for every project

`adapters/contract-intake.mjs:55`

```
          if (end === from) throw error;
```

The adaptive scanner halves the block range on any getLogs error and rethrows once the range is a single block. The log-index cursor only helps when the provider actually returns that block's logs (the adapter's own 5000 cap then pages 32 at a time). If one finalized block contains more Requested logs than the provider's per-call result cap, every scan of the page containing it throws; runtime/contract-intake-worker.mjs:77-78 records scanError and line 112 does not save the cursor, so no later block is ever ingested for any project, and each tick repeats the full halving sequence (about log2(limit) getLogs calls, 10 per tick measured) against the provider. ClausRequestInbox.request is permissionless and the filter is only address+event, so unadmitted callers can fill a block at the cost of Sepolia gas. Limit: reachability depends on the pinned provider's result cap, which could not be confirmed (publicnode enforces a 50,000-block range cap and prunes old history; its per-call result limit was not observable), against the Sepolia block gas limit. Fix: when a single block still fails, fall back to a query that pages within the block (topic filter restricted to admitted binding words, or a block receipt walk), or at minimum record and alert on the oversized block and advance past it. From audit_flow.

**Reproduction**

State (test/scratch/h-single-block-stall.test.mjs, node --test): mock provider whose getLogs throws {code:-32005} whenever the requested range includes block 120 (1,001 logs, cap 1,000) and returns [] otherwise; worker startBlock 120 (cursor afterBlock 119). createContractIntakeWorker.tick() three times: each returns scanError -32005 and afterBlock 119; contract-intake-cursors never gets a row; the provider receives 30 getLogs calls over the three ticks. Expected: progress past or around the oversized block, or an explicit alert, instead of an unbounded stall.

### 9. Low: A gas-deferred deployment or registration that outlives its window never becomes terminal: prepare() throws invalid_transaction, which runOne retries every 60 s forever

`src/chain-deployments/index.mjs:147`

```
        return jobs.finish(claim, { state: 'waiting', retryAt: now() + (['chain_gas_exhausted', 'transaction_cost_limit'].includes(error.code) ? 15 * 60000 : 60000),
```

0.11.3 adds visible 15-minute deferrals for chain_gas_exhausted / transaction_cost_limit. A module-deploy has a 24-hour window (deployModule: expiresAt = now + 86400000) and a registration a 1-hour window. Once a deferred job's window passes, adapters/sepolia-transactions.mjs:45-46 rejects prepare() with invalid_transaction (expiresAt > now() fails). adapters/chain-executor.mjs transact() only converts authorityDenied codes into a terminal 'cancelled'; invalid_transaction is rethrown, and runOne maps every non-authority error to 'waiting' with retryAt now()+60 s. The chain-modules / chain-registrations document keeps state 'queued', the job never reaches blocked/failed, and each retry performs capability() (getChainId + getCode) plus database reads. With the planned finite gas allocation (0.0005 ETH per project, 8 projects) exhaustion is the expected steady state, so every public deployment that waited out its window becomes a permanent minute-cadence RPC loop on the chain worker. Proposals are protected because the executor checks p.expiresAt itself (approval_expired); deployments and registrations have no terminal path. Fix: in transact() or runOne, treat an intent whose expiresAt <= now() as terminal (e.g. blocked with error deployment_expired / registration_expired) and mark the row accordingly. From audit_economics.

**Reproduction**

State (test/scratch/i-expired-deferral.test.mjs, node --test): createSepoliaTransactions(...).prepare({id:'deploy:m1', data:'0x60', authority:{kind:'module-deploy', projectId:'p1', moduleId:'m1'}, expiresAt: now-1}) rejects with invalid_transaction before any wallet work; authorityDenied({code:'invalid_transaction'}) is false, so chain-executor transact() rethrows it. A queued module-deploy job for chain-modules[m1] with expiresAt in the past, run through createChainDeployments.runOne with an executor step raising invalid_transaction, finishes {state:'waiting', availableAt: now+60000} on each of three consecutive attempts while chain-modules[m1].state stays 'queued'. Expected: a terminal blocked state with the row marked expired.

---

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