# Audit report

> Audit the exact repository commit named in this request. This is the corrected fixed-term ETH agreement escrow plus its actual public-evidence/IMD-oracle/native-Claus arbitration boundary. Review all specified paths, not older readonly contracts or sample Counter modules. The previous original escrow review a272377e-2868-43fc-a142-37351c9d8b7f covered source9db5611861aed8099aba4e5578f8fb8f1bb560ff; its 7low/3info findings are corrected or explicitly disclosed in this candidate. Do not assume that previous report approves this changed code.
>
> Contract subject: contracts/src/ClausDealEscrow.sol and its focused tests. Immutable native ETH agreements, named distinct-address arbiter, max deal/accounted custody limits, 24h buyer acceptance window, 3day dispute timeout to buyer, pull credits, buyer-only finite recurring runs, cancellation and explicit run index. No proxy, arbitrary admin withdrawal, fee or customer key. Check conservation, reentrancy, role separation, replay/time boundaries, plan accounting, forced ETH and live exit availability.
>
> Offchain subject: src/deals/*, runtime/deal-ports.mjs, runtime/arbitration-ports.mjs, adapters/imd-oracle.mjs, adapters/deal-oracle-proof.mjs, unchanged adapters/imd-payment.mjs and imd-cost.mjs, api/deals.mjs, shared identity/billing/storage boundaries, shared interfaces and docs/paid-deals.md. One hash-bound public case per deal, no private evidence, fixed 5-member/4-quorum zero-tolerance signed IMD oracle, original quote/order/payment retained, separate finite subsidy, exact independent result proposed to genuine native Claus, fresh current escrow and observation block checks, dedicated unfunded signer only signs Resolution for this contract/deal/amount/evidence/expiry. API never signs user transactions. Once issued signatures remain valid until expiry; docs disclose that and default timeout behavior. Check state races, identity/revocation, missing proof, unknown effects, duplicate or selective oracle results, changed parties/amount/input, malleable native decisions and ledger accounting.
>
> Native decision integration: exact source snapshots in reference/deal-review-native/ with mapping README/source hashes, shared core copied byte-identically at native build. Separate networkDealReview capability and pins, maximum8 lifetime decisions and unchanged2USDC/request payer ceiling, existing queue/unknown payment preservation, no direct model signing key. Existing readonly/managed contract profiles still reject financial products. Check whether genuine financial decisions and current authorization are actually required, not fabricated or inferred from a code audit.
>
> Report actual findings with path and impact. Explicitly distinguish source review from deployment/source verification/activation, provider evidence from truth/guaranteed fairness, immutable rules from infallibility, and required live acceptance from local fixtures. No deployment or funding of this escrow is requested from IMD.

| | |
|---|---|
| Repository | https://github.com/contractclaus/claus-network |
| Commit | `935c323e5190ce9806e1b844a3644b5519200487` |
| Job | `b9c4399e-2616-411c-86e3-415b1e889f29` |
| Judged | 2026-10-14 06:34 UTC |
| Findings | 2 medium · 5 low |

Four agents audited the code as it is at `935c323`, 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: Arbitration case is bound to the requester's credential; the 1-hour owner session expiry (or self-revocation) permanently blocks the only case for the deal

`src/deals/arbitration.mjs:17`

```
 async function permitted(v,reader=store){try{const p=await admission(reader);return p.generation===v.admissionGeneration&&await identity.allowed(v.actor,'deal.review',{projectId:v.projectId},reader);}catch{return false;}}
```

request() stores the requesting credential id as v.actor (line 28) and every tick() calls permitted(v) (line 36), which requires identity.allowed(v.actor,'deal.review',...). identity.credential() rejects any credential whose expiresAt has passed (src/identity/index.mjs:46), and owner credentials are issued with OWNER_TTL = 60*60*1000 (src/identity/index.mjs:20,146). The case pipeline (quote, x402 payment, 5-member panel, native Claus inference with 60 s re-queues, 3-confirmation chain checks) runs across many cron ticks, so an owner-requested case routinely passes the one-hour mark before signing. permitted() then returns false and tick() writes state 'blocked', error 'authority_closed'. caseId is deterministic per chain/contract/deal and request() returns any existing row before any other check (line 25), so neither party can open a replacement, even with a fresh session. The same binding gives the requester a unilateral veto: revoking the credential or mandate, archiving the project or starting a transfer (identity allowed() lines 66-68) after the oracle is paid kills the case; because the onchain default (settleExpired after resolutionDue) refunds the buyer, a buyer who requests first can block the provider's only arbitration path. identity.allowed() returning false for an expired or revoked requester credential adds nothing over the admission-generation check already performed in permitted(); the party standing check (owner address is buyer or worker) is what should persist. Impact: loss of the subsidised arbitration path (one of 8 lifetime cases plus the paid oracle order) and a buyer-controlled veto over resolution; escrow funds remain safe under the contract's timeout. Fix: after admission, gate tick() on admission generation and current party standing (project not archived, owner address still buyer or worker) rather than liveness of the single requesting credential, or let either party re-attach an authorised actor to the existing case without a new oracle purchase. Disclose in docs/paid-deals.md until fixed.

**Reproduction**

node --test test/scratch/repro.test.mjs test 'A' (uses test/apps-fixture.mjs with arbitrationEnabled, a Disputed deal whose buyer is the fixture owner, resolutionDue far in the future, and a stub advance that returns {state:'oracle-pending'}). Steps: core.request(ownerCredentialId, projectId, 1, dealId) -> 'queued'; core.tick() -> state 'oracle-pending', advance called once. Advance the fixture clock by 3600001 ms with no revocation and unchanged admission; core.tick() -> state 'blocked', error 'authority_closed', advance not called again. A fresh owner login (new credential id) calling core.request for the same deal returns the same case id in state 'blocked'; a further tick makes no progress. Expected: an admitted case keeps progressing while admission generation and the onchain deal are unchanged. Actual: the case is permanently dead one hour after the request. Output: 'A: state after TTL blocked authority_closed advances 1', 'A: re-request same case true blocked'.

### 2. Medium: Buyer alone controls whether arbitration can exist: an unpublished or wrong-kind disputeHash makes request() impossible and guarantees the 3-day full refund

`src/deals/evidence.mjs:20`

```
  async bundle(deal,reader=store){const out={};for(const [kind,field]of [['terms','termsHash'],['result','resultHash'],['dispute','disputeHash']]){const evidence=await this.read(deal[field].toLowerCase(),reader);requireThat(evidence.document.kind===kind,'deal_evidence_mismatch');out[kind]=evidence;}return out;}
```

ClausDealEscrow.dispute() (contracts/src/ClausDealEscrow.sol:148) accepts any non-zero reasonHash with no bond or publication requirement, and settleExpired() (line 171) refunds the full amount to the buyer once resolutionDue passes. The only alternative outcome is a signed Resolution, whose sole issuer is the review case. request() (src/deals/arbitration.mjs:22) calls evidence.bundle(d), which requires a deal-evidence row for termsHash, resultHash and disputeHash of the matching kind and throws not_found / deal_evidence_mismatch otherwise; validateDealDecisionShape (src/deals/decision.mjs:27) requires bundle.dispute too. Only the buyer knows the dispute preimage. A buyer who receives a conforming delivery can call dispute(id, anyNonZeroHash) inside the 24 h window without publishing; then no party can open a case, the arbiter never signs, and the buyer collects the default refund while keeping the delivered work. docs/paid-deals.md:23-25 asks buyers to publish before disputing and says the three documents are required, but does not say that a missing buyer document removes the provider's only recourse. The contract behaves as written; the gap is the offchain admission rule combined with the buyer-favouring default. Fix (product decision): allow request() to open a case when only the dispute document is missing or of the wrong kind, passing an explicit 'dispute text unavailable' marker to the panel question and the native decision input (and adjust validateDealDecisionShape accordingly); or at minimum disclose in DEAL_RULES and docs/paid-deals.md that the provider has no arbitration recourse when the buyer's dispute document is unpublished.

**Reproduction**

node --test test/scratch/repro.test.mjs test 'B': deal in state 4 with published terms and result documents, buyer's disputeHash set to 0x0101...01 (never published). core.request(partyCredential, projectId, 1, dealId) rejects with code 'not_found' and no deal-case-capacity row is written, so no case exists for either party. Onchain (existing contracts/test/ClausDealEscrow.t.sol lines 62-64 show the same path): funded(1 ether) -> claim -> deliver -> dispute(id, keccak256('second dispute')) -> warp 3 days + 1 -> settleExpired(id) credits the buyer 1 ether, workerPaid 0. Expected: the provider can still have the committed terms and result reviewed when the buyer's dispute text is missing, or the asymmetry is disclosed. Actual: a buyer-controlled missing preimage silently disables arbitration and guarantees a full refund. Output: 'B: request rejected not_found'.

### 3. Low: Finite 8-case arbitration subsidy can be exhausted by one principal with 1-wei self-dealt disputes; no minimum amount, per-principal limit or requester cost

`src/deals/arbitration.mjs:25`

```
    const old=await tx.read('deal-cases',id);if(old)return publicCase(old.value);const p=await admission(tx),counter=await tx.read('deal-case-capacity','global'),total=counter?.value.total??0;requireThat(total<ARBITRATION_LIMITS.cases,'deal_arbitration_capacity');
```

request() admits any Disputed deal whose requesting project's owner address is the deal's buyer or worker (line 24), subject only to the global lifetime counter (8), projectDaily 1 and globalDaily 4 (lines 25-27). Nothing requires a minimum amount, distinct principals, or a requester-paid fee. The escrow accepts msg.value >= 1 wei (contracts/src/ClausDealEscrow.sol:221,228) and the decision validator only requires amount >= 1 (src/deals/decision.mjs:26). Each admitted case reserves up to IMD_ORACLE.maximum (0.5e18 of the review asset, adapters/imd-oracle.mjs:6) from the 'deal-arbitration-v1' ledger, and if it reaches the native host uses one of the 8 lifetime native decisions (reference/deal-review-native/network-review.ts:68) at up to 2 USDC. The daily project limit is keyed by projectId and an owner may hold up to 10 projects, so one actor needs only the 4/day global cap: two days of 1-wei deals between two addresses it controls set deal-case-capacity.total to 8, after which discovery() reports enabled:false and genuine disputes fall to the buyer-refund default. Fix: require BigInt(d.amount) >= a configured minimum (for example a minimumArbitrableWei in the pin), bind daily/lifetime counters to the owner principal rather than the project, and/or require a requester-paid deposit before consuming capacity.

**Reproduction**

node --test test/scratch/repro.test.mjs test 'C': Disputed deal with amount '1' (1 wei), buyer = fixture owner address, all three documents published. core.request(ownerCredential, projectId, 1, dealId) returns state 'queued' and deal-case-capacity.global.total becomes 1. Repeating with 4 projects per day over two days (projectDaily 1, globalDaily 4) reaches total 8, after which any request fails 'deal_arbitration_capacity'. Expected: the subsidy is reserved for genuine disputes between distinct parties above a meaningful value. Actual: about 8 wei plus gas consumes it. Output: 'C: total 1'.

### 4. Low: Admission requires only 3600 s remaining, the same margin every later stage needs, so cases admitted near the deadline pay the oracle and consume a lifetime slot with no possible resolution

`src/deals/arbitration.mjs:16`

```
 async function observed(chainId,dealId){const c=await dealPorts.context(chainId,{fresh:true,confirmations:3}),d=await dealPorts.read(c,'deal',dealId);requireThat(d.state===4&&Number(d.resolutionDue)>seconds(now)+ARBITRATION_LIMITS.minRemainingSeconds,'deal_not_reviewable');return {c,d};}
```

observed() admits a case when resolutionDue > now + minRemainingSeconds (3600) and the identical check is repeated on every tick(); its failure code 'deal_not_reviewable' is in the permanent list (line 41) and blocks the case. Downstream, validateDealDecisionShape (src/deals/decision.mjs:26) and the native host (reference/deal-review-native/network-review.ts:43,59,98) require proposal.expiresAt > now + 3600 at their own later times, with expiresAt <= resolutionDue. The admission minimum therefore leaves zero time for the quote, x402 payment, five-member panel, native inference (up to 650 s plus 60 s re-queues) and confirmations. Any case requested with resolutionDue - now in (3600, 3600 + pipeline latency] is admitted, permanently counts against the 8 lifetime cases (deal-case-capacity.total is never decremented) and the daily caps, usually pays the IMD oracle order in the first tick, and then cannot produce a resolution. docs/paid-deals.md:25 tells users 'at least an hour remaining' is enough. Escrow funds are not at risk; the finite subsidy is. Fix: admission should require minRemainingSeconds = 3600 + worst-case oracle + native + signing latency (for example 3 h), or the downstream margins should be relaxed; document the real minimum.

**Reproduction**

node --test test/scratch/repro.test.mjs test 'D': Disputed deal with resolutionDue = now + 3700 s. core.request(...) returns 'queued' and total = 1. First core.tick(): advance runs (the stub stands in for quote+payment) and the case is 'oracle-pending'. Advance the clock 120 s and tick again: observed() sees 3580 s remaining <= 3600, throws deal_not_reviewable, and the case becomes 'blocked' with advance called only once. Expected: admission rejects a case that cannot finish. Actual: admitted, charged, and certain to fail. Output: 'D: blocked deal_not_reviewable advances 1'.

### 5. Low: Oracle subsidy reservation is never released when a case stops before a payment record exists, and an expired unpaid quote is never re-quoted so the case stalls until the deadline

`runtime/arbitration-ports.mjs:55`

```
  async reconcile(){for(const row of await store.list('deal-oracle-allocations',{limit:8})){const a=row.value;if(a.billing!=='reserved')continue;const old=await payment.status(a.offer.id);if(!old)continue;try{const state=await payment.reconcile(a.offer.id);await settleCost(a.offer,state,a.inputHash);}catch{}}}
```

advance() reserves offer.maximum (up to 0.5e18) from the 'deal-arbitration-v1' ledger (line 26) before any imd-payments record exists. The case can then stop before payment.prepare commits its 'signing' row: stillAllowed() fails at line 28 (revocation, admission generation bump, capability disable), authorize() returns false inside prepare, or provider.challenge fails transiently (provider_outcome_unknown) and the fixed quote (quoteId 'deal-oracle:'+caseId, at most 600 s validity, adapters/imd-oracle.mjs:58; provider.quote returns the stored quote forever, lines 76-82, without checking expiresAt) expires, after which every later prepare throws payment_window_expired (adapters/imd-payment.mjs:49). Nothing requests a replacement quote for the same inputHash and abandonUnsent is never called. The only release path, reconcile() (line 55), skips any allocation whose payment.status() is null ('if(!old)continue'), and settleCost requires a chain receipt. So a hold with no signed payment, where no uncertain effect exists to preserve, is kept forever, the allocation row stays billing:'reserved', and later reserve() calls fail funding_insufficient. The stalled case keeps retrying payment_window_expired each tick until observed() blocks it at resolutionDue-3600, consuming a lifetime slot. Fix: in advance(), when payment.status(offer.id) is null (or 'cancelled' via abandonUnsent) and offer.expiresAt <= now()+60000, settleReservation(tx, ACCOUNT, ASSET, offer.maximum, '0') inside a transaction, mark the allocation 'abandoned', and obtain a fresh quote with a suffixed quoteId still bound to the same inputHash; keep the existing hold semantics for anything that reached signing. Let reconcile() release reservations of terminal cases that have no payment record.

**Reproduction**

node --test test/scratch/repro.test.mjs test 'F/G' (in-memory store, real arbitrationPorts + createImdOracleProvider + createImdPayment, stubbed fetch for /requests/quote and /requests/{id}/submit, ledger funded with 1e18, capability enabled at generation 1). Tick 1: quote 5e17 returned and checkpointed, reserve() succeeds, challenge throws a network error -> advance rejects provider_outcome_unknown; balance available 5e17 / reserved 5e17. Clock +660 s, challenge now answers 402 with the same (expired) quote -> advance rejects payment_window_expired, again +60 s -> payment_window_expired; no imd-payments row exists. ports.reconcile() leaves available 5e17 / reserved 5e17 and the allocation billing 'reserved'. Second case: stillAllowed returns true on the first call and false on the second -> advance rejects authority_closed with the ledger now available 0 / reserved 1e18, and reconcile() leaves it unchanged. Expected: a hold with no payment intent is released once the case is terminal or the quote is dead, and a fresh quote is obtained for the same input. Actual: the subsidy is held forever and the case stalls. Output: 'F: after tick1 ... reserved: 500000000000000000', 'G: tick2 error payment_window_expired', 'F: after authority_closed ... available: 0, reserved: 1000000000000000000'.

### 6. Low: Decision validator bounds the amount with the unvalidated pin field maximumDealWei instead of the chain-read maximumDeal; a pin that deal-ports accepts throws TypeError after the oracle is paid

`src/deals/decision.mjs:26`

```
 requireThat(d.state===4&&/^0x[0-9a-fA-F]{40}$/.test(d.buyer)&&/^0x[0-9a-fA-F]{40}$/.test(d.worker)&&d.buyer.toLowerCase()!==d.worker.toLowerCase()&&![d.buyer.toLowerCase(),d.worker.toLowerCase()].includes(pin.arbiter.toLowerCase())&&/^[1-9][0-9]*$/.test(d.amount)&&BigInt(d.amount)<=BigInt(pin.maximumDealWei)&&/^(0|[1-9][0-9]*)$/.test(p.workerAmount)&&BigInt(p.workerAmount)<=BigInt(d.amount)&&Number.isSafeInteger(p.expiresAt)&&p.expiresAt>Math.floor(now/1000)+3600&&p.expiresAt<=Number(d.resolutionDue),'network_review_invalid_input');
```

validateDealDecisionShape computes BigInt(d.amount)<=BigInt(pin.maximumDealWei). The pins come from the same CLAUS_DEAL_PINS that runtime/deal-ports.mjs:11 validates (api/deals.mjs:20 passes JSON.parse(CLAUS_DEAL_PINS) to arbitrationPorts; reference/deal-review-native/network-deal-review.ts:8 reads the same variable). deal-ports requires only chainId, contract, codeHash, arbiter, admissionController and reviewed; it never checks maximumDealWei, and test/deals.test.mjs:28 builds an accepted pin without it. For such a pin BigInt(undefined) throws a TypeError with no code for every valid case. In advance() this is first reached at runtime/arbitration-ports.mjs:36, after the oracle order has been paid and attested; tick() treats a code-less error as transient and retries every tick until the deadline blocks the case, so every case under that configuration spends the oracle fee and a lifetime slot. If the field is present but differs from the onchain immutable maximumDeal, the bound is wrong in one direction or the other. The chain value is already available: verified() in deal-ports reads maximumDeal() into observation.maximumDealWei, which the case stores as o. Fix: have deal-ports require a decimal maximumDealWei equal to the onchain maximumDeal(), or compare against BigInt(o.maximumDealWei) (and require it to equal the pin); run the shape check in request() before any oracle spend.

**Reproduction**

node --test test/scratch/repro.test.mjs test 'E': build a valid decision input exactly as test/deal-arbitration.test.mjs does (attested oracle proof, workerAmount 600 of amount 1000). With pins=[{chainId:1,contract,codeHash,arbiter,admissionController,reviewed:true,maximumDealWei:'1000'}] validateDealDecisionShape passes. With the same pin minus maximumDealWei (which runtime/deal-ports.mjs accepts) it throws 'TypeError: Cannot convert undefined to a BigInt' with e.code undefined. Expected: an invalid pin is rejected at configuration time with a coded error, or the chain-read maximum is used. Actual: an uncoded TypeError after payment, retried forever. Output: 'E: TypeError Cannot convert undefined to a BigInt code= undefined'.

### 7. Low: Committed deployment configuration schedules no worker for /api/deals, so admitted cases are counted but never processed by anything in the tree

`vercel.json:35`

```
  "crons": [
```

api/deals.mjs is the only caller of createArbitration().tick() and arbitrationPorts().reconcile(), and it runs only on a GET carrying the CRON_SECRET bearer. vercel.json schedules /api/compile and /api/workflows only; api/workflows.mjs does not dispatch the deal tick (no reference to deals), .github/workflows is empty and no file in the tree references /api/deals as a scheduled path. Because request() admits and counts cases synchronously (src/deals/arbitration.mjs:25-28) while all progress happens in tick(), the public API would accept and permanently count up to 8 lifetime cases that never leave state 'queued'. This is a source-level observation about the committed deployment configuration; it says nothing about what is scheduled in the operator's live Vercel project, and no deployment or activation is requested here. Fix: add {"path":"/api/deals","schedule":"* * * * *"} and a functions entry for api/deals.mjs with a maxDuration covering the 25 s native and 30 s provider timeouts, or document the external scheduler and have discovery() refuse new cases when worker-health/deals.observedAt is stale.

**Reproduction**

Inspect vercel.json: "crons" (line 35) lists only /api/compile and /api/workflows; "functions" has no api/deals.mjs entry. grep -rn "api/deals" over the tree (excluding node_modules) returns no scheduler, workflow or documentation reference. Deploying this tree as committed with CLAUS_DEAL_ARBITRATION_ENABLED=1 and the deal-arbitration capability enabled, then calling deal_review_request on a Disputed deal, writes deal-case-capacity.global.total += 1 and a 'queued' case; nothing in the repository ever invokes /api/deals, so public_deal_case stays {state:'queued'} until the onchain timeout. Expected: the only deal worker is scheduled or its external scheduler is documented. Actual: neither is in the tree.

---

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