# Audit report

> Review the exact commit 4edc190db79b3ddb04593faf72fecf969616bf24, diff from 582a8cee9dcecd0d6dafd144959bbc0517cb641a, for exploitable or materially incorrect behavior in the new fixed-token ClausTokenDealEscrow and end-to-end optional CLAUS Job payment support. Existing ETH escrow and existing agreements must retain their semantics. CLAUS escrow is not yet deployed or admitted.
>
> Focus on contracts/src/ClausTokenDealEscrow.sol and its tests; runtime/deal-ports.mjs and arbitration-ports.mjs; src/deals/{assets,token-abi,index,task-payments,arbitration,decision}.mjs; src/apps/{index,reviews}.mjs; interfaces, api/deals.mjs and reference/deal-review-native/network-deal-review.ts. Review shared dependencies as needed. Check reentrancy, custody and credits, exact token delta checks, ERC20 approval then zero-ETH funding, claim/delivery/cancellation/timeout/dispute/plan transitions, fixed parties, signature replay/domain/chain separation, asset and escrow pin integrity, stale linked-task state, RPC failures, and whether ETH records can be reinterpreted as CLAUS. Check that arbitration resolves the actual asset's verifying contract and amounts, not the default ETH contract. Explain concrete attack prerequisites and affected lines. Do not mistake advisory Job reviews for authority to transfer funds.
>
> The intended production asset is the existing upgradeable CLAUS token, fixed immutably by address in this new non-upgradeable escrow. Proposed initial limits: 10,000 CLAUS per agreement and 100,000 CLAUS aggregate accounted custody; 18 decimals, minimum subsidized dispute 1 CLAUS. Existing admission controller and independent dispute signer remain. No new fee, token restriction, sweeping authority, or native CLAUS spending permission. The existing native work wallet remains ETH-only. Calls must block unconfigured assets rather than fall back. Each user signs their own transactions; the API only prepares them. A successful independent report will inform the separate bounded mainnet activation decision, and every material finding will be dispositioned first.
>
> Six focused Solidity cases, four new asset-bound API cases, 33 related existing cases and import boundaries passed. Do not treat those checks as proof of security. Report severity and a precise fix for real findings, and explicitly identify any important unverified integration assumption. Avoid unrelated architecture rewrites.

| | |
|---|---|
| Repository | https://github.com/contractclaus/claus-network |
| Commit | `4edc190db79b3ddb04593faf72fecf969616bf24` |
| Job | `d3b991ea-06f1-4b90-8729-066c7023fc89` |
| Judged | 2026-10-14 23:04 UTC |
| Findings | 1 medium · 3 low · 3 info |

Four agents audited the code as it is at `4edc190`, 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: Documented native decision-host copy set omits the new src/deals/assets.mjs, so the vendored decision module fails to load and ETH as well as CLAUS arbitration stops

`reference/deal-review-native/README.md:5`

```
`network-deal-core/` is populated byte-for-byte at build time from the canonical `src/shared/`, `src/deals/{evidence,arbitration,decision}.mjs` and `adapters/deal-oracle-proof.mjs` files in this repository. No alternate decision implementation exists. Native decisions contain no wallet key or signature. The dedicated `runtime/arbitration-ports.mjs` signing boundary checks the real native input/result, current authority, exact current escrow state and existing observation block before signing.
```

This commit adds src/deals/assets.mjs and makes src/deals/decision.mjs (line 3: import {dealAsset,isTokenDeal,amountDescription} from './assets.mjs') and src/deals/arbitration.mjs (line 2) depend on it. The native Claus decision host does not import this repository; per this README line its network-deal-core/ directory is populated byte-for-byte from exactly src/shared/, src/deals/{evidence,arbitration,decision}.mjs and adapters/deal-oracle-proof.mjs. That enumerated set no longer closes the module graph: reference/deal-review-native/network-deal-review.ts imports ./network-deal-core/src/deals/decision.mjs, which now fails at load with ERR_MODULE_NOT_FOUND. validateNativeDealReview and nativeDealReviewRequest are then unavailable for every case, existing ETH cases included, which violates the requirement that existing agreements keep their semantics. Runtime side fails closed: runtime/arbitration-ports.mjs native() sees a non-ok or 400 response, so the case stays claus-pending (native_review_unavailable, retried) or is marked blocked (native_review_rejected, permanent); no signature is issued and no funds move, but the disputed deal then falls through settleExpired after resolutionDue, refunding the buyer in full, so a worker with a legitimate partial claim loses the subsidized review. Prerequisite: the native host is rebuilt from this commit following the documented copy set (the commit does change the host file network-deal-review.ts, so a rebuild is implied). The host build lives outside this repository, so this is demonstrated against the documented copy set and is also the single most important unverified integration assumption of the change. Merged from two specialists (audit_permissions, audit_math). Fix: add src/deals/assets.mjs to the copy set sentence here and to the host build (or inline dealAsset/isTokenDeal/amountDescription into decision.mjs and arbitration.mjs so the documented set stays closed), and add a repository test that copies exactly the documented files into a temporary directory and imports decision.mjs so a future transitive import cannot silently break the host.

**Reproduction**

From the repository root at 4edc190: mkdir -p /tmp/ndc/src/deals /tmp/ndc/adapters; cp -r src/shared /tmp/ndc/src/; cp src/deals/evidence.mjs src/deals/arbitration.mjs src/deals/decision.mjs /tmp/ndc/src/deals/; cp adapters/deal-oracle-proof.mjs /tmp/ndc/adapters/; ln -s $PWD/node_modules /tmp/ndc/node_modules; node -e "import('/tmp/ndc/src/deals/decision.mjs').then(()=>console.log('loaded')).catch(e=>console.log(e.code,e.message))". Actual: ERR_MODULE_NOT_FOUND Cannot find module '/tmp/ndc/src/deals/assets.mjs' imported from /tmp/ndc/src/deals/decision.mjs. Expected (and verified at the parent 582a8ce with git archive of the same documented file set): the module loads and exports validateDealDecision.

### 2. Low: Native snapshot manifest source.json still pins the pre-change network-deal-review.ts hash; a host built from the pinned snapshot keeps the 2-pin cap and rejects a 3-pin CLAUS configuration

`reference/deal-review-native/source.json:2`

```
  "network-deal-review.ts": "90cddc0adbb4c7ea923a6ad99d0b285d538deb449ed42c6c2c9679c093558ff7",
```

The commit edits reference/deal-review-native/network-deal-review.ts (pins() now allows p.length<=4 instead of <=2) but leaves source.json, the review snapshot manifest, pinning the previous file hash (identical to the parent commit). Anyone verifying or building the native host against the manifest obtains the old pins() with the 2-entry cap. Once CLAUS_DEAL_PINS carries a third entry (mainnet ETH, sepolia ETH, CLAUS), that host throws network_review_disabled on every review, existing ETH cases included, and all cases stall claus-pending. Fail-closed, no fund movement. Fix: regenerate source.json for the changed snapshot in the same commit as any reference/*.ts change, and add a test asserting each listed file's sha256 equals the manifest value.

**Reproduction**

sha256sum reference/deal-review-native/network-deal-review.ts -> 4a56fdc8c9fc182e222e83821d10ac0f8442bde6e4bb6e3a34a68136e8bc263f. grep network-deal-review reference/deal-review-native/source.json -> 90cddc0adbb4c7ea923a6ad99d0b285d538deb449ed42c6c2c9679c093558ff7, unchanged from git show 582a8ce:reference/deal-review-native/source.json. Expected: the manifest hash equals the snapshot file hash. Actual: mismatch; the pinned snapshot is the old validator whose pins() throws Error('network_review_disabled') for any CLAUS_DEAL_PINS array longer than 2.

### 3. Low: An ETH-labelled (asset omitted) pin pointing at a deployed ClausTokenDealEscrow passes pin verification, so a token escrow is served and labelled as the ETH escrow

`runtime/deal-ports.mjs:32`

```
   if(isTokenDeal(entry.asset)){
```

verified() reads arbiter, admissionController, maximumDeal, maximumPrincipal and fundingEnabled, which exist with identical signatures on both ClausDealEscrow and ClausTokenDealEscrow. The token()/decimals() binding is only checked when the pin says asset:'CLAUS'. A pin without an asset field (documented default ETH) whose contract is a ClausTokenDealEscrow therefore verifies cleanly with the ETH ABI. Consequences: deal_terms publishes the token escrow as the ETH escrow with rules.asset 'native ETH' and no token field; deal_get/deal_plan_get/deal_balance return CLAUS base units labelled as ETH wei; app_task_agreement without asset would record an ETH-shaped agreement against the token escrow and deal_review_request without asset would open a case whose oracle question says 'native ETH wei' for a CLAUS deal. Funding itself fails safely (the ETH-ABI createDeal selector does not exist on the token escrow and value>0, so simulateContract rejects it), so no deposit is misdirected. The stated rule that calls must block unconfigured assets rather than fall back is enforced only in one direction. Prerequisite: an operator sets CLAUS_DEAL_PINS with a missing or wrong asset label for the token escrow address; reviewed:true is a self-assertion, not a code check. Fix: in verified(), for non-token pins assert the contract is not a token escrow (call token() and require it to revert, or compare the pinned codeHash against the known ClausDealEscrow runtime hash), and make the pin asset field mandatory for pins instead of defaulting through dealAsset().

**Reproduction**

Local anvil, chain id 11155111: forge create test/ClausTokenDealEscrow.t.sol:JobToken, then forge create src/ClausTokenDealEscrow.sol:ClausTokenDealEscrow with (token, controller, arbiter, 1e18, 5e18); setFundingEnabled(true); mint and approve; createDeal(salt 1, projectHash, termsHash, worker, 1e18, now+1d). Then dealPorts({CLAUS_DEAL_PINS:'[{"chainId":11155111,"contract":<tokenEscrow>,"codeHash":keccak256(code),"arbiter":<arbiter>,"admissionController":<controller>,"maximumDealWei":"1000000000000000000","reviewed":true}]'}) with no asset field (clientFactory pointed at anvil). Expected: context(11155111) rejects with deal_pin_mismatch because the pinned contract is not an ETH escrow. Actual: context resolved; c.public had no token/asset fields; c.abi createDeal inputs were salt,projectId,termsHash,worker,deliveryDue (ETH ABI); createDeals().terms(11155111) returned rules.asset 'native ETH' with contract = the token escrow and token undefined; createDeals().read(11155111, dealId, 'deal') returned the CLAUS deal with amount 1000000000000000000 and no asset field.

### 4. Low: app_task_agreement idempotency treats the documented default asset ETH and an omitted asset as different requests

`src/apps/index.mjs:110`

```
      return effect(tx,projectId,actor,'task.agreement',idempotencyKey,{taskId,payerProjectId,chainId,priceWei,deliveryDue,expectedRevision,...(asset?{asset}:{})},async()=>{
```

The idempotency payload includes the raw asset string only when supplied. The API schema and docs define ETH as the default, and taskPayments.create produces an identical agreement for asset undefined and asset 'ETH' (assetFields('ETH') is {}), yet a retry of the same agreement with the same idempotencyKey that spells the default explicitly (or vice versa) is rejected with idempotency_conflict instead of returning the recorded result. Clients following the documented recovery rule (retry the identical operation with the same key) are locked out of their own recorded agreement if an SDK or MCP layer later starts sending the default explicitly. No funds are affected. The CLAUS-vs-ETH conflict asserted by test/token-deals.test.mjs is correct and unaffected. Fix: normalize before hashing, e.g. ...(asset&&asset!=='ETH'?{asset}:{}), so the default never changes the recorded request hash.

**Reproduction**

With test/apps-fixture.mjs and the token-deals dealPorts mock: owner posts a task, bob claims it, owner calls app_task_agreement {projectId,taskId,payerProjectId,chainId:1,priceWei:'100',deliveryDue:now+3600,expectedRevision,idempotencyKey:'agreement'} -> ok (agreement has no asset field); the same call again -> same agreement id; the same call with asset:'ETH' added -> error code idempotency_conflict. Expected: the recorded result, since ETH is the documented default and the created agreement is byte-identical. Actual: idempotency_conflict (HTTP 409).

### 5. Info: CLAUS funding preparation while deposits are paused is reported as the caller input error invalid_deal_input (400) instead of a paused-funding state

`src/deals/index.mjs:49`

```
    requireThat(c.public.enabled&&amount(input.priceWei)<=BigInt(c.public.maximumDealWei)&&due>stamp&&due<=stamp+2592000&&!/^0x0+$/.test(input.termsHash)&&![sender,c.public.arbiter].some(a=>a.toLowerCase()===input.worker.toLowerCase())&&sender.toLowerCase()!==c.public.arbiter.toLowerCase(),'invalid_deal_input');
```

The token funding guard folds c.public.enabled (the admission controller fundingEnabled flag) into one requireThat with the caller input validation and maps everything to invalid_deal_input (HTTP 400). A buyer whose inputs are valid receives an invalid-input error when the escrow is merely paused, and an agent retry loop treating 4xx-input as permanent will not retry after admission reopens. The ETH path never reaches this line and surfaces the paused state through simulation (deal_simulation_failed, 409). Classification only, no security impact. Fix: check enabled separately first, e.g. requireThat(c.public.enabled,'deals_not_enabled') (already mapped to 409), then validate inputs.

**Reproduction**

Using the token-deals dealPorts mock with fundingEnabled:false and a valid request deal_prepare {chainId:1,asset:'CLAUS',action:'fund',input:{salt,termsHash,worker,priceWei:'100',deliveryDue:now+3600}} -> error code invalid_deal_input, HTTP 400. Expected: a state code such as deals_not_enabled (409). Actual: 400 invalid_deal_input although every input field is valid.

### 6. Info: Trust assumption for activation: the pinned CLAUS token is an upgradeable proxy; any later fee, pause, blacklist or decimals change freezes every escrow credit with no exit, and only the constructor c

`contracts/src/ClausTokenDealEscrow.sol:227`

```
        if (token.balanceOf(address(this)) != held - amount || token.balanceOf(recipient) != received + amount) revert TransferFailed();
```

Not a code defect; the fail-closed exact-delta checks in _fund (line 240) and withdraw (line 227) are the correct defence against short payment, and credits are preserved. But the escrow is non-upgradeable and bound immutably to the upgradeable CLAUS proxy, whose admin, not the escrow, decides whether settled credits can ever leave: a fee-on-transfer, rebase, hook, pause or blacklist upgrade makes every withdraw revert TransferFailed and every _fund reject, so all totalCredits and totalLocked are frozen for as long as the upgrade stands, with no escrow-side recovery, grace path or admin action. decimals()==18 is checked once at construction (line 90) and never re-read, and runtime/deal-ports.mjs caches decimals per process (line 34), so a later decimals change is invisible until restart. The ETH escrow has no such dependency. The CLAUS implementation and its upgrade authority are not in this repository and were not verified. Merged from four specialists (audit_flow, audit_permissions, audit_math, audit_economics). Disposition for the activation decision: verify the live CLAUS implementation (no hooks, fee path, pause or blacklist; decimals hard-coded), record who controls the proxy admin and any timelock as a named trust assumption independent of the admission controller and arbiter, document it in deal_terms rules and DEPLOYMENTS.json for the token pin, and consider recording the token implementation code hash alongside the escrow codeHash in verified() so a change is at least observable. No escrow code change is required if the dependency is accepted.

**Reproduction**

Existing test contracts/test/ClausTokenDealEscrow.t.sol testDepositsAndPayoutsRejectTransferTaxWithoutLosingCredit (run: cd contracts && forge test --match-test testDepositsAndPayoutsRejectTransferTaxWithoutLosingCredit, passes): after a settled deal credits the worker 1e18, token.configure(escrow,true,false) enables a 1 wei transfer burn; worker.withdraw(1e18, worker) reverts TransferFailed while credits(worker) stays 1e18 and token.balanceOf(escrow) stays 1e18; withdrawal succeeds only after the token behaviour is reverted. With the real upgradeable CLAUS token the equivalent switch is an implementation upgrade by its proxy admin, outside this contract's control.

### 7. Info: Unverified integration assumption: the native decision host must carry the identical CLAUS pin (asset, token, decimals:18) and the rebuilt decision core, or every CLAUS case is permanently blocked aft

`src/deals/decision.mjs:33`

```
 requireThat(dealAsset(pin?.asset)===dealAsset(o.asset)&&(!isTokenDeal(o.asset)||(pin.token?.toLowerCase()===o.token?.toLowerCase()&&pin.decimals===18&&o.decimals===18)),'network_review_invalid_input');
```

validateDealDecisionShape now selects the pin by chainId and contract (line 32) and requires pin.asset, pin.token and pin.decimals to match the observation (line 33). This correctly fails closed, but the same function runs inside the separate native host (reference/deal-review-native/network-deal-review.ts line 8) against that host's own CLAUS_DEAL_PINS, configured separately from the network API and worker. Nothing in this commit verifies the native pin list contains the CLAUS entry with asset/token/decimals. If it does not, the native side answers 400 network_review_invalid_input; runtime/arbitration-ports.mjs decodeNativeReviewResponse (line 22) maps that to native_review_rejected, which is in the permanent list of src/deals/arbitration.mjs tick (line 47), so the case is saved as blocked after the IMD oracle fee was already paid, the global case counter (eight lifetime subsidized cases) is not decremented, and the single case ID per escrow/chain/agreement cannot be reopened. ETH cases are unaffected. Merged from two specialists (audit_flow, audit_economics). Fix: add the CLAUS pin to the native host's pin list in the same activation step as the API/worker pins, and add a startup or health check comparing a digest of the native pin list with the network pin list (for example via the existing GET /internal/network/reviews route) so api/deals.mjs refuses to publish a healthy CLAUS target when they differ.

**Reproduction**

node script against src/deals/decision.mjs at HEAD with a CLAUS observation {asset:'CLAUS',token,decimals:18,contract:<tokenEscrow>,...} and proposal.contract=<tokenEscrow>: pins=[ETH pin only] -> throws network_review_invalid_input before the observation's codeHash is re-read on line 35 (that is, at line 33); pins=[ETH pin, CLAUS pin without asset/token/decimals] -> same; pins=[ETH pin, full CLAUS pin] -> passes line 33 (codeHash re-read on line 35 observed) and only later checks fail because the stub input is incomplete. In the deployed flow the 400 is converted to native_review_rejected and the case is saved with state 'blocked' after the oracle allocation was settled.

---

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