# AI penetration test, second run (2026-10-29 to 2026-10-31)

Step 4 of `docs/ai-pentest.md`: the same seven areas and prompt, run again on the fixed commit `e2b52ce` (SPEC v0.31,
which fixed every finding of `reports/AI pentest (2026-10-29).md`), with fresh attack agents and fresh verifiers, plus
a regression run of every first-run proof. Proofs are under `contracts/test/pentest/second/` and
`scripts/pentest/second/`, the verifiers' extra tests under `contracts/test/pentest/verify/V2_*` and
`scripts/pentest/verify/V2_*`, in the working copies listed in Part 3 (not yet copied into the repository).

# Part 1. Summary

## Result

No Critical finding. Every first-run fix still holds: all 187 first-run proofs, 26 fork tests and the service tests
behind the off-chain fixes pass on `e2b52ce` (Part 2, "Regressions"). No agent found a way to steal a party's money,
pay a deal twice, change a final state or pass a proof for a fact both attestors didn't sign.

One High, three Medium and eight Low findings survived verification:

- **High: R2-D-1.** The first run's High (a loser flipping a Kleros ruling by funding an appeal alone) is back under a
  budget attack: sham 1-unit cases make the keeper spend `APPEAL_DAILY_WEI`, so a real case's appeal period opens
  unprotected. About $100 in the AI court (two batches of sham cases, because the budget looks back 24 h and the
  period lasts 36 h). The verifier's recommendation is a contract change, not more budget rules.
- **Medium: R2-D-2, R2-E-1, R2-G-2.** Escalating past the budget pays the loser yescrow's stake (D-2); the keeper's
  forwards from Ethereum and its CCTP completions are outside every relayer budget, so a buyer cycling USDT through its
  own deposit address makes yescrow pay about 10× its own cost (E-1), and anyone can make the keeper pay for USDC
  completions on Ethereum (G-2).
- **Low:** R2-A-2/R2-B-1 (one finding found twice: a settlement offer signed during Kleros's vote period can be held
  while the votes are revealed; minutes in the AI court), R2-C-1 (the dual verifier's separate minimums of expiration
  and fetch time let one compromised attestor remove the 30-day expiry check; present since v0.23), R2-D-3 (Kleros's
  governor can stop disputes from being opened at all), R2-D-4 (re-pointing Kleros's resolver freezes appeal stakes),
  R2-F-1 and R2-F-U1 (the launch gate checks the verifiers' getters and a creation transaction it doesn't tie to the
  escrow, not the deployed code).

The Info list (Part 2) includes a compiler recommendation: move from solc 0.8.30 to 0.8.38 before the audit freezes
the commit. A byte-level comparison shows the published via-IR bug fixed in 0.8.38 doesn't change yescrow's code, and
the router stays at the same address.

## All findings

"Attacker" is the severity the attack agent claimed; "Verifier" is the fresh agent's verdict; "Final" is the
verifier's severity. The rule (the user's): a finding whose proof runs and shows the claimed impact can't be dropped
by argument alone.

| ID | Finding | Attacker | Verifier | Final | Where the fix goes |
|---|---|---|---|---|---|
| R2-D-1 | Sham cases use up the daily appeal budget; a real case's appeal is then unprotected and the loser flips it | High | Confirmed; ~$100, not $50 (two batches); the operator's alert is part of the defence | **High** | Contract (pay on the jurors' ruling when an unanswered appeal overrides it) or service (reactive staking, price floor, per-court budget) |
| R2-D-2 | Escalating past the budget flips a twice-confirmed ruling and pays the loser yescrow's stake | Medium | Confirmed | **Medium** | Same as D-1; SPEC §14.21 wording |
| R2-E-1 | The keeper's forwards from deposit chains (and LayerZero fees) are outside every budget: ~10× amplification for USDT | Medium | Confirmed; real-receipt ratios 10.1×/13.0× (worse than reported) | **Medium** | Service (one budget design with G-2) |
| R2-G-2 | USDC cross-chain withdrawals and the keeper's `receiveMessage` completions draw on no budget | Low | **Upgraded**: capital recycles, ceiling ~1 tx/block, keeper stalls | **Medium** | Service |
| R2-A-2 / R2-B-1 | A settlement offer signed in Kleros's vote period stays exercisable while votes are revealed | Low / Low | Confirmed; minutes in court 34, a race against `passPeriod` in court 1 (not 162 h) | **Low** | Contract (fold revealed votes into the stage, or refuse `settle` in the vote period) or service |
| R2-C-1 | Dual verifier: `min(expiration)` and `min(fetchedAt)` taken separately, so one compromised attestor removes the expiry margin check | Low | Confirmed, challenged as near Info; present since v0.23, not v0.31 | **Low** | Verifier (combine the margin) and prover |
| R2-D-3 | Kleros's governor can price disputes out of existence (no cap), so disputes can't open and nothing reaches the judge | Low | Confirmed; a reverting quote also blocks the answer | **Low** | Contract (cap per court, route to the judge) and docs |
| R2-D-4 | Re-pointing Kleros's resolver strands every appeal stake of open cases | Low | Confirmed; the keeper never recovers it by itself | **Low** | Docs and keeper |
| R2-F-1 | The launch gate trusts the verifier's getters, not its code: a lookalike passes | Low | Confirmed | **Low** | Script (code-hash comparison) |
| R2-F-U1 | The gate's decoded creation transaction isn't tied to the escrow: a backdoored escrow passes | Unconfirmed | **Confirmed** by the verifier's own test | **Low** | Script (rebuild at the escrow's address; parse the creation tx) |
| R2-G-1 | Appeal protection skipped without an operator alert when screening is down or a party listed | Low | **Downgraded**: the operator already gets the louder sanctions alert, the keeper retries | Info | Service (alert wording) |
| R2-A-1 | `settlementStage` is a one-second timestamp: a zero-second period keeps the stage | Info | Confirmed; a commit-period variant too | Info | Contract (stage includes period and round) |
| R2-A-3 | The `PAYOUT_PENDING` guard lapses a day after the deal closed | Info | Confirmed; the day is deliberate but undocumented | Info | SPEC/audit wording, or a keyed check |
| R2-E-I1–I4 | `keys.md` box/router rows, stale router addresses, no interface for `returnStray` on Arbitrum, a stranger's USDT forward shown as "coming" | Info | Confirmed (E-I3 and E-I4 need code, not only docs) | Info | Docs, web, service |
| R2-F-I1–I5 | solc 0.8.30 bug list, EIP-170 headroom, attestor/fee recipient not pinned, the judge only checked for code, the gate's pins not checked before broadcasting | Info | Confirmed; I-5 wider (wrong pins burn the promised addresses) | Info | Compiler, Networks.sol, DeployAll |
| R2-D-I1, I2 | A counter-signed offer can lapse before it lands; staking at the period's open makes every appeal period run its full length | Info | (reported with D) | Info | UI text; reactive staking |

## Limits

- **Less independent than intended.** The user's own fresh session for this run stalled, and at the user's request
  the run was started from the session that wrote the v0.31 fixes. Every agent still had a fresh context and only the
  procedure's prompt; the attack agents used Fable 5.1 (another model than the code's author), the verifiers Opus 5.5
  under the user's rule. No model from another vendor was run.
- **Interrupted.** The weekly usage limit stopped one attack agent (F) and four verifiers mid-work on 2026-10-30; they
  were resumed from their saved state on 2026-10-31.
- **Some proofs are partial.** R2-G-2 had no attack PoC (the verifier wrote one); R2-D-1's correction (two batches) and
  D-2's General Court figures come from reading Kleros's code, not a run; R2-A-3 is code reading.
- **No findings is not proof of no bugs** (each area's "Checked and found sound" lists what was tried).
- **Not fixed yet.** The fixes follow the user's decisions.

---

# Part 2. Area reports, with each verification


## Area A

### AI pentest, second run, area A: state machine and money

Commit under test: e2b52ce (SPEC v0.31). Working copy: an export of the commit; `contracts/src/` unchanged. Scope: every SPEC §5 transition and deadline, balances and `withdraw`/`withdrawTo`, the fee, promo vouchers, the referrer share and fee payers, rounding, USDC/USDT0 quirks, donations, re-entrancy, the 63/64 gas rule; new in v0.31: `judgeTimeout` without a fee (D-2), the `actionPhase` and `settlementStage` views (B-4, B-3), the money paths around `Judging`.

Proofs, all passing (19 tests), in `contracts/test/pentest/second/`:

| File | What | Run from `contracts/` |
|---|---|---|
| `A_Money.t.sol` | SPEC §8 unit by unit for every exit from `Judging` (refused and stalled), the D-2 fix, sponsored fees on judged cases, the rows around a Kleros case, edges (zero Kleros cost, one-unit price, a voucher before a stall), finality, payouts | `forge test --match-path test/pentest/second/A_Money.t.sol -vv` |
| `A_PhaseStage.t.sol` | the B-4 and B-3 fixes attacked on mocks: stale signatures across phases, same-phase reuse, an offer at every move of the case, gas starvation of the stage read, stage collisions, two residuals | `forge test --match-path test/pentest/second/A_PhaseStage.t.sol -vv` |
| `A_RealKlerosStage.t.sol` | the B-3 fix on a fork of Arbitrum One (block 519,194,000): a real case (id 328) in Kleros's AI court through its real resolver, driven through evidence, commit, vote, appeal and a new appeal round on the real `KlerosCoreNeo`; A-1 and A-2 | `forge test --match-path test/pentest/second/A_RealKlerosStage.t.sol -vv` |
| `A_Replay.t.sol` | signatures across the version's two deployments (which share deal IDs), replay, malleation; `withdrawTo` toward unrouted chains | `forge test --match-path test/pentest/second/A_Replay.t.sol -vv` |

Every `forge` call ran under the shared lock and a 2,500 MB cap. No file outside `contracts/test/pentest/second/` was changed; no transaction was sent to any live network.

**Bottom line.** No Critical, High or Medium finding. The v0.31 fixes in this area hold: `judgeTimeout` charges no fee on refused and stalled cases and yescrow never earns more by waiting; every action signature is refused in any later phase; a settlement offer lapses at every move of the case, on the mock core and on the real one. Every exit from `Judging` credits exactly SPEC §8's row and nothing else. What the fixes leave: the stage is a one-second timestamp (A-1, Info), an offer signed inside the vote period is still a free option until that period ends (A-2, Low), and a service note on the A-2 mitigation (A-3, Info).

#### Findings

### A-1. `settlementStage` is a one-second timestamp: a period that opens and ends in the same second keeps the stage, so an offer signed in that second stays valid in the next period
- **Severity:** Info (needs a period shorter than a second and an offer signed inside it).
- **Attacker and preconditions:** the offeree. Kleros lets the commit and vote periods end as soon as every juror has acted, and starts a new round the moment both appeal sides are funded, so on Arbitrum (several blocks per second) a period can last zero seconds; the offeror would have to sign inside it.
- **Steps (real `KlerosCoreNeo`, case 328, court 34):** the vote period opens at second `t2`; the seller signs an offer of P/4 (stage `t2`); the three jurors vote and `passPeriod` is called in the same second; `lastPeriodChange` is `t2` again, `settlementStage` unchanged, and the buyer, who now sees the ruling, submits the vote-period offer in the appeal period: `Settled`. One second later the same move changes the stage and the offer is refused.
- **Impact:** the stage binding has a hole one second wide per period. No loss in practice; recorded because the fix's promise ("an offer lapses when the stage changes") is not exactly what the escrow checks.
- **PoC:** `forge test --match-path test/pentest/second/A_RealKlerosStage.t.sol -vv` (block "a move in the same second: the same stamp" in `test_fork_B3_stageTracksTheRealCore_offersLapseEachPeriod`).
- **Root cause:** `contracts/src/YescrowEscrow.sol:814-820`, `settlementStage` returns the fifth word of the core's `disputes(id)` (`lastPeriodChange`) alone; KlerosCore stamps every period change with `block.timestamp`.
- **Suggested fix:** include the period in the stage, e.g. `(lastPeriodChange << 3) | period` (always ≥ 8, no collision with 0, 1, 2); the service builds typed data from the escrow's view, so nothing else changes.

### A-2. Residual of B-3: an offer signed during the vote period is a free option until the period ends, because Kleros publishes each vote as it is cast while the stage doesn't move
- **Severity:** Low.
- **Attacker and preconditions:** the offeree of a settlement signed after the vote period opened and before every vote is in. Window: the vote period, 30 minutes in court 34 (ending early once all three AI jurors voted), 162 hours in court 1 (one juror for yescrow's deals, so the first vote decides), from the real core's `getTimesPerPeriod`. `getVoteInfo`/`getRoundInfo` show every cast vote.
- **Steps (real core):** the vote period opens at `t2`; the seller signs an offer of P/4 for stage `t2`; two of three jurors vote "seller delivered" (public: `voted`, `choice`, `totalVoted` = 2); the stage is still `t2`; the buyer signs and submits the standing offer: `Settled`, the buyer keeps 750 of the 1,000 USDC the jurors were giving the seller.
- **Impact:** as B-3, narrowed to "inside the vote period". SPEC §7.2 covers offers signed before voting starts, not offers signed once voting has started.
- **PoC:** `forge test --match-path test/pentest/second/A_RealKlerosStage.t.sol -vv` and `forge test --match-path test/pentest/second/A_PhaseStage.t.sol --match-test test_residual_B3_offerSignedInTheVotePeriod -vv`.
- **Root cause:** the stage changes only at period boundaries (`YescrowEscrow.sol:818`); `service/src/api/app.ts:805-819` hands out settlement typed data in every stage with a caller-chosen deadline.
- **Suggested fix:** in the service, refuse to build or relay settlement typed data while the case is in its vote period, or cap settlement deadlines to minutes there and say that votes are public from the start of the vote period. A contract option for a future version: fold the dispute kit's `totalVoted` into `settlementStage`, so an offer lapses with each vote.

### A-3. Service: the `PAYOUT_PENDING` guard on the relayed `withdraw` (the A-2 mitigation) lapses one day after the deal closed
- **Severity:** Info.
- **Attacker and preconditions:** anyone calling `POST /v1/relay` with `action: "withdraw"` for an account whose automatic payout has waited more than a day (keeper down, a payout the keeper couldn't send).
- **Steps:** `service/src/api/app.ts:1146-1153` refuses the relayed push only while the payout row is `waiting` and the deal's `updated_at` is within the last day; afterwards the relayer pushes the whole balance to the party's Arbitrum One address ahead of the payout it signed for another chain.
- **Impact:** the documented mitigation holds for a day; on-chain the open `withdraw` is accepted behaviour, so no loss.
- **PoC:** none (reading).
- **Root cause:** the `d.updated_at > now() - interval '1 day'` term at `service/src/api/app.ts:1149-1150`.
- **Suggested fix:** drop the one-day term, or key the condition on the payout row's own status and age.

#### Checked and found sound

1. SPEC §8, every exit from `Judging`, unit by unit: refused and stalled × {judge 1, 2, 0; timeout; buyer concedes; seller concedes; settle S}: all 14 paths credit exactly the row's amounts (`test_fix_D2_spec8_refusedCase_everyExit`, `…_stalledCase_everyExit`). D-2 holds, fuzzed over the price (`testFuzz_fix_D2_waitingNeverPaysYescrowMore`).
2. Sponsored fees on a judged case: yescrow gets only its own back (`test_spec8_refused_sponsoredAnswer_yescrowGetsOnlyItsOwnBack`).
3. Rows around a Kleros case; ETH held = owed (`test_spec8_rowsAroundTheKlerosCase`).
4. Edges: zero Kleros fee, one-unit price, a voucher before a stall (`test_edge_*`).
5. Finality after each exit (`_assertFinal`).
6. Payouts leave once and exactly (`test_sound_payoutsLeaveOnceAndExactly`).
7. B-4: signatures refused in every later phase; same phase across states pays the same (`test_fix_B4_*`).
8. B-3: an offer lapses at every move on the mock and on the real core; appeal-period offers settle (`test_fix_B3_offerLapsesOnEveryMoveOfTheCase`, `test_fork_B3_stageTracksTheRealCore_offersLapseEachPeriod`).
9. The stage can't be starved to 2 (`test_fix_B3_gasStarvation_cannotForgeStage2`); stage values can't collide (`test_sound_stageValuesCannotCollide`).
10. Real `KlerosCoreNeo` getter layout: word 4 is `lastPeriodChange`, word 2 the period; `_read` checks `returndatasize`.
11. Signatures across the two deployments refused; `WithdrawTo` once; malleated copy refused (`test_sound_signaturesBoundToTheirDeployment_andUsedOnce`).
12. `withdrawTo` toward unrouted chains reverts `NoRoute` (`test_sound_withdrawTo_unroutedChain_revertsAndKeepsTheBalance`).
13. USDT0 has no fee switch on Arbitrum One; funding measures deltas anyway.
14. Re-entrancy: every money-moving function `nonReentrant`; no token hooks; first-run ETH re-entry tests pass.
15. Deposits vs funding windows: no second where both or neither of `accept` and `returnDeposit` work.
16. 63/64 rule unchanged; `applyRuling`'s 1M floor stands.
17. Deployment money settings as SPEC (fee 100, share 2500, cap 2,000, floors, courts 1 and 34, judge ≠ fee wallet, promo key pinned).
18. Keeper's deadline calls permissionless and outcome-neutral; the API signs with the phase/stage read from the escrow; action deadlines capped at an hour.

#### Unconfirmed

- `arbitrationCost` is called plainly, not through `_read` (530, 976): a core upgrade that made it revert would block `dispute`, `challenge` and `payArbitrationFee`, so an opener would win at `feeDeadline` instead of the deal going to the judge. Only Kleros's governor; the current implementation can't revert. Worth a `_read` with a refusal fallback in a future version.
- Frequency of same-second period moves on Arbitrum (A-1): not measurable here.
- `judgeTimeout`/`judgeRuling` set `promo = true` on deals with no code: `getDeal` shows `promo: true, promoFeeBps: 0`; cosmetic in the service's referrer page and fee lines.
- A-3 was read, not run.

### Verifications for area A

**A-1_A-2:**
```text
A-1 — Verdict: CONFIRMED (Info)
PoC: `forge test --match-path test/pentest/second/A_RealKlerosStage.t.sol -vv` PASS (real case 328, court 34, 8.1e14 wei per side): vote and appeal in the same second t2 → stage stays t2 and the vote-period offer of 250 settles; one second later refused. Own: verify/V2_A-1_SameSecond.t.sol 2/2 PASS. Sources unchanged: yes.
Checked: the PoC's only etch is the RNG stub; on KlerosCoreNeo 0.10.0 passPeriod is open to anyone, round 0's evidence needs its full 600 s, commit ends when every juror committed (deployed core does this), vote ends when every committed juror revealed — a zero-second vote period needs all three jurors to act within the second it opened. Missed variant (tested): all commits in the commit period's first second carry an offer signed in the commit period into the vote period, where it settles after two public reveals. Plausibility: possible (several blocks per second) but needs every AI juror within a second (mainnet case #309 spent 10/11 min in commit/vote) and the offeror's typed data built in that second. Narrow exception to audit §3; not in SPEC §7.2 or audit §5.
Reason: proof runs; impact as B-3 (Low) but likelihood negligible → Info.
Fix: (lastPeriodChange << 3) | period closes both same-round cases (tested, stages ≥ 8); a whole new round within one second repeats round 0's (stamp, period) — add getNumberOfRounds: (lpc << 16) | (rounds << 3) | period, or (rounds, period) alone, which rises at every move.

A-2 — Verdict: CONFIRMED (Low), with a correction for court 1
PoC: A_RealKlerosStage PASS (buyer keeps 750 of the 1,000 USDC the jurors were giving the seller); A_PhaseStage test_residual_B3_offerSignedInTheVotePeriod PASS (mock); own verify/V2_A-2_VoteWindow.t.sol 3/3 PASS. Sources unchanged: yes.
Checked: commit/reveal hides votes only until the vote period (during commit: choice 0, voted false, currentRuling 0; each reveal writes choice and counts and emits VoteCast; after the first reveal currentRuling already returns 1). Periods: court 34 600/2,700/1,800/129,600 s; court 1 78/162/162/108 h. Court 34: after two matching reveals the offeror can't end the period (passPeriod reverts until the third reveal or 30 min); the buyer settles at t2+1,799 s and keeps 750; window = deciding reveal to last reveal, ≤ 30 min. Court 1 (one juror): the "162 h" overstates it — nothing readable before the reveal, and the single reveal lets anyone pass the period at once (keeper every 30 s, or the offeror); the offeree wins only by acting in the reveal's own block. Spec: SPEC §7.2 doesn't accept vote-period offers; not a known issue. The web app lets a party sign in any period with a 24 h deadline and doesn't warn reveals are public.
Reason: Low like B-3; window minutes (court 34) / seconds (court 1); stays Low because the offeror can't close the window and the app invites signing then.
Fix: service: refuse settlement typed data while the case is in its vote period (period 2) — informed settlements move to the appeal period, which opens as soon as all votes are in; capping deadlines doesn't help. Contract (before mainnet): fold the dispute kit's totalVoted (getRoundInfo(id, rounds−1, 0)) into the stage, or refuse settle during the vote period. Also web/components/deal/steps.ts (settleOffer, offerLapse).
```

**A-3:**
```text
Verdict: CONFIRMED at Info
Evidence: app.ts:1147-1152 refuses only while p.status='waiting', the deal closed, and d.updated_at > now() - interval '1 day' (line 1151); else the relayer sends withdraw(account) (1155). watcher.ts:197-198 sets deals.updated_at when the last event is indexed, so the day runs from the indexed close, not the payout row's age. keeper.ts:190-233: a row stays waiting when the closing event isn't indexed yet, when screening lists the address or is down, or on any non-final send error (relayer out of ETH, failed USDT0 quote, RPC errors, paused bridge), or if the tick throws before sendPayouts. After a push the keeper's withdrawTo reverts NothingToWithdraw (YescrowEscrow.sol:708) and the row is marked failed ("already withdrawn").
What I checked: SPEC §4.1, §2 rule 2, §14.14; audit.md §5; run 1's A-2; sanctions.ts:119-151 screens the relayed withdraw's account (a listed party's row is never pushed); the web's own-wallet withdraw (Dashboard.tsx:153, 251) relays the unsigned withdraw. Read only.
Reason: no loss (the whole balance goes to the party's own Arbitrum One address, which it controls); no new attacker power (withdraw is open on-chain by design; anyone can push during the guarded day too; the lapse only means yescrow's relayer pays the gas after a day); rare (needs a row stuck a day); the one day looks deliberate (without it a stuck row would block the party's own one-click gasless withdraw forever) but isn't documented: SPEC §4.1 and audit §5 say "while the account has an automatic payout pending", broader than the code.
Corrections: the term is on line 1151. Dropping it would block the party's own Dashboard withdraw while a row is stuck. Right fixes: (a) keep it and make SPEC §4.1 / audit §5 say "for a day after the deal closed"; or (b) refuse with no time limit, keyed on the payout row, and have the Dashboard fall back to a signed whole-balance withdrawTo on Arbitrum One on PAYOUT_PENDING (the relay accepts it with no minimum, app.ts:1211-1219).
Unconfirmed side note: duePayouts takes only the 20 oldest waiting rows (payouts.ts:83-84); rows held by screening or a non-final send error are never marked, so twenty such rows would stall every newer payout.
```


## Area B

### AI penetration test, second run — Area B: Signatures

**Commit under test:** `e2b52ce` (SPEC v0.31, the fixes for the first run's findings).
**Scope:** EIP-712 domains and type hashes; EIP-3009 with the deal ID as nonce; EIP-1271 / ERC-6492 / EIP-7702
wallets; settlements bound to their phase; payout signatures the keeper submits later; malleability; replay across
deals, escrows, tokens, chains and phases. New in v0.31 for this area: every action signature carries `uint256 phase`
(`actionPhase`: 0 none, 1 AwaitingFee, 2 Disputed/Judging); `Settle` carries `uint256 stage` (`settlementStage`: 0
AwaitingFee, 1 Judging, else Kleros's `lastPeriodChange`, 2 if unreadable); the API caps action deadlines at 1 h and
the relayer re-checks state before `confirm`/`cancel`.

**Code read:** `contracts/src/YescrowEscrow.sol` (`_authorize`, `_settleDigest`, `actionPhase`, `settlementStage`,
`_isValidSignature`, `_close`, `settle`, `confirm`/`cancel`, `withdrawTo`, `applyFeeVoucher`, `setReferrerShare`);
`DepositRouter.sol`; `service/src/typed-data.ts`, `erc6492.ts`, `relayer.ts`, `budgets.ts`, `sponsor.ts`,
`payouts.ts`, `keeper.ts`, `api/app.ts` (typed-data, relay, deposit-authorizations, withdrawTo, payout), `api/schemas.ts`;
the first run's report and proofs.

**Result.** No Critical, High or Medium. The v0.31 fixes in area B hold:

- **B-1 / B-2** (relayer ETH drain on micro-withdrawals / zero-value deposit authorizations): the service now enforces
  a minimum amount, a sender-balance check, issued-nonce-once, and global/per-chain daily ETH budgets, with the keeper
  and API on separate queues. Verified by reading the exact lines (below); the on-chain legs are unchanged and the
  first run's fork proofs still pass.
- **B-3** (settlement offer a free option once votes are visible): `settle` now binds to the Kleros case's period
  (`settlementStage`). The offer lapses at a period change. The fix holds across period boundaries. **One residual
  remains inside a single period** — reported as **B2-1 (Low)** below.
- **B-4** (stale `Confirm`/`Cancel` closes a dispute opened after signing): every action digest now includes the live
  `actionPhase`. I confirm the **contract-level** binding is the real backstop — it refuses a far-future-deadline
  signature that bypasses the API's 1 h cap and is submitted directly (not only through the relayer).
- **B-5** (EIP-1271 permissive delegate): unchanged; still the party's own choice, ECDSA tried first. Not re-raised.

New proofs: `contracts/test/pentest/second/B_PhaseStage.t.sol` (5 tests, all pass). The first run's
`contracts/test/pentest/B_Signatures.t.sol` (13 tests) was re-run on this commit: all pass.

Run everything in area B (from `contracts/`):

```
flock <scratchpad>/heavy.lock \
  systemd-run --user --scope -q -p MemoryMax=2500M ~/.foundry/bin/forge test \
  --match-path 'test/pentest/second/B_*.t.sol' -vv
```

---

#### Findings

### B2-1. A settlement offer is still a free option *within* a single Kleros reveal period (residual of B-3)

**Severity:** Low (a free option that shifts expected value to the offeree; the offerer consented to the amount, so
nothing is stolen and no party is forced — the same character B-3 was rated Low for, now narrowed to one period). A
reader may reasonably treat it as Info/accepted, since the team's own regression test
(`test_B3_offerSignedAfterTheVote_worksInTheSamePeriod`) shows same-period offers are intended to work.

**Attacker and preconditions:** the party that *receives* a settlement offer during a dispute (the offeree). The web
app (`web/components/deal/steps.ts`) has the offerer sign a `Settle` link valid for 24 h. The dispute's Kleros court
uses hidden votes and reveals them during the **vote period** (court 34 and court 1, per the first report's reading of
mainnet: reveal happens in the vote period, 30 min in the AI court, 162 h in the General Court). `settlementStage` is
the case's `lastPeriodChange`, which is constant across a whole period, so an offer signed at the start of the reveal
period stays valid until `passPeriod` moves the case on.

**Steps:**
1. The dispute reaches the vote/reveal period. The seller, hoping to settle instead of waiting for Kleros, signs a
   50/50 offer (stage = the reveal period's start) and shares the link.
2. Jurors reveal during the same period: `currentRuling` becomes public (say, seller delivered), but
   `lastPeriodChange` — and so the settlement stage — does not move.
3. The buyer, seeing it will get 0 under the visible ruling, counter-signs the still-live 50/50 offer and calls
   `settle` before anyone calls `passPeriod`/`applyRuling`. Had the vote gone the buyer's way, it lets the ruling
   apply instead.

**Impact (mock, price 5,000 USDC):** the buyer salvages `PRICE/2` (2,500 USDC) after losing the vote, instead of the
0 the ruling would give it — 2,500 USDC shifts by the exercised option. For the General Court the exercise window is
the whole reveal period (≈162 h on mainnet), because the single juror's reveal is final and `lastPeriodChange` does
not move until `passPeriod`.

**Proof:** `test_B2_1_LOW_settlementOfferWithinTheRevealPeriod_stillAFreeOption`
```
forge test --match-path test/pentest/second/B_PhaseStage.t.sol \
  --match-test settlementOfferWithinTheRevealPeriod -vv
```
(The contract mechanism — the stage is constant within a period, so the offer survives a visible ruling — is proven.
That votes are actually visible mid-reveal-period is a Kleros reveal-behaviour fact taken from the first report's
court reading, modelled on the mock, not re-proven on a live case; see Unconfirmed.)

**Root cause:** `contracts/src/YescrowEscrow.sol` `settlementStage` (lines 814–820) returns `lastPeriodChange`, which
only changes on `passPeriod`; `settle` (lines 575–593) reads it once and has no view of how far the reveal has
progressed within the period. B-3's fix binds the period boundary, not the reveal visibility inside it.

**Suggested fix:** this is a design trade-off the team already weighed (same-period offers are intended to be usable,
for informed appeal-period settlements). If the residual is to be closed, the sharpest option is off-chain: the web
app should set a short offer validity (minutes) for offers signed before the appeal period, and its signing summary
should say plainly that an offer can't be withdrawn and the other side may wait for the jurors to reveal before
accepting. A contract change (e.g. refusing `settle` during the vote period, so settlements are only possible before
the reveal starts or in the appeal period once both sides see the result) would close it fully but narrows the
settlement window; not recommended for this immutable release.

---

#### Checked and found sound

Each item names the evidence. Tests without a path are in `contracts/test/pentest/second/B_PhaseStage.t.sol`; the
first run's suite is `contracts/test/pentest/B_Signatures.t.sol` (re-run on `e2b52ce`, 13/13 pass).

- **B-4 fix — the phase binding is the contract-level backstop, not just the API cap.** A `Confirm` the buyer signs in
  `Proven` with a 365-day deadline (beyond `MAX_ACTION_DEADLINE`, which the API refuses) is rejected once the buyer
  challenges and the case opens: the digest named phase 0, but `actionPhase` is 1 then 2, so `_authorize` reverts
  `InvalidSignature`. The mirror holds for a seller's `Cancel`. So even a client that builds its own typed data and
  submits directly — bypassing the API deadline cap and the relayer's state re-check — cannot land a stale concession
  in a dispute (`test_sound_phaseBindingIsTheBackstop_longDeadlineConfirmRefusedInDispute`,
  `..._longDeadlineCancelRefusedInDispute`). The off-chain caps (`app.ts` 822, 1014–1015; `relayer.call` `check` at
  1090) are defence in depth.
- **B-4 fix — the one phase that groups two states is benign.** Phase 0 covers both `Active` and `Proven` (the only
  grouping). A `Confirm` signed in `Active` still releases to the seller after a proof lands in `Proven`, and a
  `Cancel` refunds the buyer either way: the money goes to the same party, and no arbitration fee is in play in phase
  0, so the grouping can't surprise a signer (`test_sound_phase0GroupsActiveAndProven_sameOutcome`). The remaining
  action types are each valid in a single state (`accept`→Proposed, `dispute`→Active, `challenge`→Proven,
  `payArbitrationFee`→AwaitingFee), so no cross-phase reuse is possible — confirmed also by the regression
  `test_B4_staleConfirmRefusedOnceADisputeOpens` and `D_KlerosReads`/`Hardening` `test_actionPhase_everyState`,
  `test_concessions_boundToTheDisputePhase`, `test_otherActions_boundToTheDisputePhase`.
- **No pre-signing a future phase or stage.** `actionPhase`/`settlementStage` are computed live inside `_authorize`
  and `settle`, so a signer cannot sign for a phase/stage the deal will reach later. A settlement offer for period T0
  is dead once the case moves on, and a party cannot revive it by itself signing for a guessed next stage — both
  signatures must cover the one current stage (`test_sound_cannotPreSignAFutureStage`; regression
  `test_B3_settlementOfferLapsesWhenTheCaseChangesPeriod`,
  `test_B_settleSignedInDisputed_lapsesAtTheStall_resignedSameMoney`).
- **`settlementStage` special values can't collide with a real period.** 0 (AwaitingFee) and 1 (Judging) are returned
  before any `disputeId` is read; in `Disputed` the value is `lastPeriodChange`, a block timestamp (~1.8e9), which can
  never equal 0 or 1 on-chain. `2` is the "unreadable getters" fallback. Regression `test_settlementStage_everyStage`.
- **Typehash / encoding consistency with the new fields.** The contract typehashes for `Accept`/`Confirm`/`Cancel`/
  `Challenge`/`Dispute`/`PayArbitrationFee` now carry `uint256 phase` between `price` and `deadline`, and `Settle`
  carries `uint256 stage` between `sellerAmount` and `deadline` (YescrowEscrow.sol 171–191). The `abi.encode` order in
  `_authorize` (926–939) and `_settleDigest` (943–963) matches the strings, and the service builds the identical typed
  data: `typed-data.ts` `actionTypedData` inserts `phase` at `ACTION_FIELDS.slice(0,5) + phase + deadline`, and
  `settleTypedData` inserts `stage` at `…sellerAmount + stage + deadline`. `Base.t.sol`'s mirror constants match
  byte-for-byte (the 13-test regression signs with them and passes). A mismatch couldn't reach the chain.
- **No forced-miss via phase invalidation.** For every time-critical signed action, no third party can change the
  phase before the deadline: `payArbitrationFee` (AwaitingFee) leaves only via the answerer paying, a both-sides
  settle, or `feeTimeout` after the deadline; `challenge` (Proven) leaves only via the buyer or `release` after the
  window; `accept` (Proposed) leaves only via the proposer or `expire` after `acceptBy`. So a victim's phase-correct
  signature stays valid until its own deadline. (Reasoned from the state machine; the transitions are in `_openDeal`
  and the state guards.)
- **Fee accounting across phase 2 (Disputed/Judging) is consistent.** A concession signed in phase 2 can only be used
  on the deal's actual path: a *stalled* deal (`Disputed`→`Judging`, `bothFeesHeld=false`) credits the held fee to the
  winner's side; a *refused* deal (`AwaitingFee`→`Judging`, `bothFeesHeld=true`, which is phase 1→2, so no phase-2
  signature from `Disputed` exists for it) credits both fees back. `_close` (1052–1092) branches on `bothFeesHeld`
  before the Disputed/Judging branch, so each path credits exactly once. Regression
  `test_B_settleSignedInDisputed_lapsesAtTheStall_resignedSameMoney` asserts identical splits.
- **B-1 fix (service, read).** `app.ts` `case 'withdrawTo'` (1204–1227): a cross-chain `withdrawTo` needs
  `amount ≥ cfg.relayBridgeMin` (same-chain: `≥ relayWithdrawMin` or the whole balance), and the LayerZero fee is sent
  through `sendWithin` with the global daily budget `cfg.lzFeeDailyWei` (`budgets.ts` `reserveSpend`/`sendWithin`,
  reserved before sending). The contract still allows dust (a party paying its own fee may bridge a small amount) —
  correct; only the relayer's blind spend is now bounded.
- **B-2 fix (service, read).** `POST /v1/deposit-authorizations` (843–888) issues a server nonce and requires
  `value ≥ max(missing, FORWARD_MIN[chain])`; the `depositTransfer` relay case (1165–1200) accepts **only** a nonce
  the service issued, exactly once (`UPDATE … used_at … WHERE used_at IS NULL`, atomic), checks `balanceOf(from) ≥
  value`, and sends the gas through `sendWithin` with `deposit-gas:<chainId>` daily budget. The keeper and API use
  separate queues on the shared relayer (`relayer.ts` `forKeeper`/`Queue`, `maxQueued` on the API lane only), so a
  flood can neither stall the keeper nor grow unbounded. A griefer must now hold and actually transfer ≥ the forward
  minimum of real USDC into the victim's own deposit box — no longer free.
- **EIP-3009, malleability, cross-escrow/chain, EIP-1271/7702, ERC-6492, WithdrawTo, FeeVoucher, ReferrerShare** —
  unchanged in v0.31; the first run's checks still hold (re-ran `B_Signatures.t.sol`: `sameDealId_signaturesDoNotCross
  Escrows`, `signaturesDoNotCrossChains`, `malleableSignatureRejected`, `compactSignatureRejected`,
  `precompileCannotBeMadeToApproveAWithdrawal`, `frontRunningAFundingSignature_createsTheSameDeal`,
  `counterpartySubmitsSignedChallenge_noLossToSigner`, `openerSubmitsSignedAnswer_noLossToSigner` — all pass). The
  fork proofs (`B_Eip3009Fork.t.sol`) were not re-run here (public-RPC/compute budget), but their targets
  (`YescrowEscrow` funding/withdraw, the real token EIP-3009 behaviour) are byte-for-byte unchanged from the first run.
- **ERC-6492 deploy path** (`erc6492.ts`) and **automatic payout** (`payouts.ts`, `keeper.sendPayouts`) — unchanged;
  the payout still fixes recipient = signer, amount, chain and a one-use digest, submitted only after a close in the
  party's favour, with sanctions screening (`keeper.ts` 205–209). No new surface in v0.31.

#### Unconfirmed

- **The information-leak half of B2-1.** The contract mechanism (stage constant within a period, so an offer survives
  a ruling becoming visible) is proven on the mock. That Kleros actually reveals votes *during* the vote period (so the
  offeree can learn the direction before the period ends) is taken from the first report's reading of mainnet courts
  34 and 1 (hidden votes, reveal in the vote period), not re-proven here: a real commit-reveal round on an Arbitrum One
  fork is heavy and the harness discourages weakening a proof to fit the budget. If votes are *not* observable until
  the appeal period on these courts, B2-1 collapses into the intended "informed settlement" and should be dropped.
- **The B-1 / B-2 fixes are verified by reading the service, not by a running local stack.** The local stack needs the
  Docker Postgres the harness says this run must not touch (same constraint the first run reported for area B). I
  traced the exact lines in `app.ts`, `budgets.ts`, `relayer.ts` and `schemas.ts`; I did not execute them. A
  logic error in the SQL reservation (e.g. a race in `reserveSpend` under true concurrency) would not show in a read.
- **Rate-limit keying (unchanged from the first run).** Both former Medium vectors are bounded per IP only if
  `trustProxy` (`app.ts` ~105) matches the real proxy chain; the new daily budgets now cap the *total* relayer ETH
  spend regardless, which is the stronger bound, but a misconfigured `trustProxy` still lets 20 relays/min share one
  bucket and crowd honest gasless steps. Not checked against a live stack.
- **Settlement links carry the signature in the URL** (browser history, referrers). A third party still can't use one
  (the other party's signature is needed too), so no loss; hygiene note, unchanged.

### Verifications for area B

**B2-1:**
```text
Verdict: CONFIRMED (Low), with a correction: the claimed 162 h General Court window is wrong; the real exposure is minutes, in the AI court.
Proof of concept: `forge test --match-path test/pentest/second/B_PhaseStage.t.sol` (B copy, contracts/): 5/5 pass, incl. test_B2_1_LOW_settlementOfferWithinTheRevealPeriod_stillAFreeOption (buyer gets 2,500 of 5,000 after losing the vote) — on the mock arbitrator. Re-run on the real KlerosCoreNeo + DisputeKitClassicNeo (Arbitrum One fork, block 519,194,000), contracts/test/pentest/verify/V2_B2-1_RealKlerosReveal.t.sol, 3/3 pass: (1) AI court (34, 3 jurors), 1,000 USDC: the seller signs 50/50 at the start of the vote period; two jurors reveal "seller delivered"; currentRuling becomes 1 while lastPeriodChange stays; passPeriod refused; ten minutes later the buyer settles: buyer 500, seller 495 (ruling would give buyer 0, seller 990). (2) AI court, all 3 revealed: passPeriod succeeds in the same block, 25 min early; the offer then fails InvalidSignature. (3) General Court (1 juror): periods 78/162/162/108 h; the single reveal makes passPeriod callable in the same block; the offer works only if settle lands before someone passes the period.
Sources unchanged: yes (diff only contracts/test/pentest/second/ and REPORT-B.md).
What I checked: settle/settlementStage (YescrowEscrow.sol 575–593, 814–820), the mock, keeper.ts 126–140 (passPeriod every tick, 30 s); steps.ts/Dispute.tsx (24 h validity, "sooner once every juror has voted", no warning that the other side can watch votes); SPEC §7.1/§7.2, audit §3/§5, dependencies §1.5/§1.8, run 1's B-3.
Reason: not accepted on purpose (SPEC §7.2 and the natspec assume votes visible "once the vote period starts"; with hidden votes the vote period is when jurors reveal one at a time); not a known issue; the team's test signs in the appeal period, not the vote period; Low is right (offerer agreed, nothing forced, narrow exposure).
Corrections: General Court: passPeriod is callable as soon as every vote is in (keeper within 30 s, Kleros's bots, or the offerer), so the offeree needs a bot to beat it. AI court: window from the second reveal to the third or the 30-min end (mainnet case #309 spent 11 min in its vote period), at most ~1,000 USDC at 50/50. A General Court round after an appeal (≥3 jurors) reopens a multi-reveal window; a keeper outage widens windows.
Fix keeping informed settlements: precise: while in the vote period, fold the number of revealed votes (dispute kit getRoundInfo totalVoted, read _read-style, falling back to 2) into settlementStage so every reveal lapses earlier offers; simpler: refuse settle during Kleros's vote period; off-chain only: hide/warn on "Sign an offer" during the vote period.
```


## Area C

#### AI pentest, second run, area C: proofs (`RdapVerifier`, `DualRdapVerifier`, `Domains`, `RdapStatus`, the template, the attestor patch)

Commit under test: `e2b52ce` (main, 2026-10-29, SPEC v0.31). Working copy:
`<scratchpad>/pentest2/C`.
Model: Claude (Fable 5.1), 2026-10-29.

Proofs of concept (all passing):

```
cd contracts
forge test --match-path 'test/pentest/second/C_*.t.sol' -vv        # 12 tests, all pass (C-1, its controls, the fix sketch)

cd ..   # the copy's root
node scripts/pentest/second/C_engines.ts            # the template under RE2 "sgiu" (Reclaim's attestor) and RegExp "sgi" (yescrow's)
node scripts/pentest/second/C_engines.ts --live     # the same over one live HTTP/1.1 response per registry host
```

Files added: `contracts/test/pentest/second/C_DualCrossField.t.sol`, `scripts/pentest/second/C_engines.ts`. Nothing under
`contracts/src/`, `contracts/script/`, `prover/src/` or `attestor/` was changed; the first run's proofs in
`contracts/test/pentest/C_InterProofBlocking.t.sol` (8 tests) were run unchanged as regression tests and pass.

Summary: the v0.31 fixes in this area hold. A 17th nameserver no longer blocks a proof, and the two attestations need
agree only on the domain and registrar. No way was found to pass a proof for a domain, registrar or status the registry
didn't state, to pass with one attestor alone, to reuse a stale attestation, or to make the signed parameters name
anything but the domain's own registry URL under yescrow's exact template. One finding, Low: the new strictest-first
combination is strictest for every check that reads one fact but not for the one check that reads two
(`expiration ≥ fetchedAt + 30 days`), so one attestor, or a stale registry cache, can remove that rejection, which
SPEC §14.18 says cannot happen.

---

### Findings

#### C-1. `DualRdapVerifier` combines `expiration` and `fetchedAt` separately, so one attestor can remove the 30-day expiration check (SPEC §14.18 says one attestor "can only add rejections")

- **Severity:** Low. The domain really is at the stated registrar (that fact still needs both attestors), but a proof
  passes for a record the registry stated as failing SPEC §6.3 check 4, and the specification's and `docs/keys.md`'s
  statement about a single attestor is false. Nobody is paid who isn't owed under the other checks; the buyer receives,
  or pays for, a domain that expires inside the margin the specification promises, up to one whose record says it has
  already expired.
- **Attacker and preconditions:** one compromised attestor key, either Reclaim's or yescrow's (`docs/keys.md` §1 lists
  both as "none alone"), used by the seller (an "after" proof) or by whoever submits the acceptance (a "before" proof).
  A weaker, keyless form: a registry cache (Google's serves data up to about an hour old, `docs/security.md`) gives
  the pair the larger of its two margins instead of the smaller (`test_C1_twoHonestSessions_staleCache_pairMarginIsTheLargerOne`).
- **Steps ("after" proof):** the deal is Active (`activatedAt`). Late in the transfer window the domain arrives at the
  buyer's registrar with, say, a day of registration left: the honest attestation states `registrarId = buyer's`,
  `fetchedAt = T`, `expiration = T + 1 day`, which the escrow rejects (`ProofRejected(4)`). The compromised attestor
  signs the same domain and registrar with `fetchedAt = activatedAt + 1` and a far expiration. `DualRdapVerifier.verify`
  returns `min(expiration)` = the honest one and `min(fetchedAt)` = the forged one (`DualRdapVerifier.sol:40-41`), so
  `_checkCommon` (`YescrowEscrow.sol:868`) sees `expiration ≥ fetchedAt + 30 days` for a pair neither attestation
  stated. The deal is Proven; after the challenge window `release` pays the seller. The "before" proof has the same
  hole with `proposedAt` as the floor (`test_C1_beforeProof_sameWeakening`).
- **How far it goes:** `fetchedAt` can be pulled back to `notBefore + 1`, i.e. by up to the transfer window (60 days,
  SPEC §9) for an "after" proof and up to `acceptBy` (30 days) for a "before" proof. The honest record then only needs
  `expiration ≥ notBefore + 30 days + 1`; at the end of a 60-day window that expiration lies 30 days *before* the
  honest fetch (`test_C1_edge_honestRecordAlreadyExpiredForThirtyDays_passes`: the registry's record says the domain
  expired 2,588,338 s, about 29.96 days, before it was fetched, and the pair passes).
- **Impact (from the tests):** `test_C1_oneCompromisedAttestor_backdatedFetchedAt_passesCheck4_sellerPaid`: the buyer's
  5,000 USDC are released, the seller is credited 4,950 USDC, for a domain the registry says expires in one day; the
  same record from two honest attestors is rejected (`test_C1_honestRecordExpiringInADay_rejectedFromBothAttestors`).
  Either key works (`test_C1_otherAttestorCompromised_sameResult`). On a genuine Reclaim attestation of example.com
  (fixture of 2026-10-29, registrar 376) paired with a forged yescrow-slot half, the dual verifier returns the real
  expiration with a `fetchedAt` 200 days earlier (`test_C1_realReclaimAttestation_pairedWithForgedHalf_factsNeverStated`).
- **Proof of concept:** `forge test --match-path 'test/pentest/second/C_DualCrossField.t.sol' -vv` from the copy's
  `contracts/` directory (12 tests).
- **Root cause:** `contracts/src/DualRdapVerifier.sol:40-41` (`if (g.expiration < f.expiration) f.expiration = g.expiration;`
  `if (g.fetchedAt < f.fetchedAt) f.fetchedAt = g.fetchedAt;`), mirrored in `prover/src/facts.ts:63-64` (`combineFacts`).
  Each minimum is the strictest choice for the checks that read that fact alone, but `YescrowEscrow._checkCommon`
  (line 868) compares the two facts with each other, and `min(a) ≥ min(b) + 30 days` does not imply
  `a_i ≥ b_i + 30 days` for either attestation. The first run's verifier proposed this combination (report, C-2
  corrections); the cross-field check was missed then and in the fix.
- **Suggested fix:** combine the margin, not the dates: `fetchedAt = min(fA, fB)` as now, and
  `expiration = fetchedAt + min(expA − fA, expB − fB)` (0 where an expiration precedes its own fetch). Then check 4 on
  the pair implies check 4 on each attestation, the result is at most either expiration, and `fetchedAt` keeps its
  properties. The sketch `MarginDual` in the test file does this: it rejects the attack
  (`test_C1_fixSketch_marginCombination_rejectsTheAttack`) and gives every real dual fixture the same facts except an
  `expiration` lower by exactly the seconds between the two halves' fetches, 0–1 s on 24 fixtures and 12–14 s at
  Nominet (`test_C1_fixSketch_everyRealDualFixture_sameFactsWithinTheSessionGap`). Mirror it in `combineFacts`, and
  reword SPEC §14.18, the contract's NatSpec and `docs/keys.md` ("none alone"). A verifier is a new deployment anyway.

---

### Checked and found sound

Each line says what was examined and why it holds.

1. **The v0.31 fix for the first run's C-1 (17 nameservers).** `_nameserversHash` returns `bytes32(0)` at the 17th
   `"ldhName"` (`RdapVerifier.sol:321`) instead of reverting, and `_facts` already skips a capture over 8,192 escaped
   bytes (line 148), so an InterRegistrar proof passes and an IntraRegistrar proof fails (`_termsOk` needs a non-zero
   hash for Intra, so zero can't match). The first run's regression tests (8), the repository's
   `test_synthetic_seventeenNameservers_noHash` and the engine harness (case 10: both attestors sign a 17-name list and
   the prover's `factsFromContext` gives no hash) pass on this commit. Gas stays bounded by the 16-name case (16,495,612
   gas for a dual proof with 16 names of 253 characters, re-measured today) because the return happens before the 17th
   name is parsed or sorted.
2. **The v0.31 fix for the first run's C-2 (facts combined strictest-first).** Only `domainHash` and `registrarId` must
   be equal; every single-fact check still needs the honest attestation: a `client hold` seen by one attestor survives
   the OR (`test_C1_control_blockedStatusStillNeedsBoth`), a different registrar is `FactsDiffer`
   (`test_C1_control_registrarStillNeedsBoth`), a record fetched before activation stays stale under the minimum
   (`test_C1_control_staleRecordStillStale_andCleanPairPasses`), the nameservers hash is zero unless both agree and
   `lastChanged` is the earlier one (repository `test_otherFacts_combinedStrictest_eitherOrder`). C-1 above is the one
   check that reads two facts. A holder changing the record between the two sessions no longer blocks an InterRegistrar
   proof (the first run's `test_C2_…` regression tests pass), and the combination is order-independent.
3. **`combineFacts` mirrors the verifier.** `prover/src/facts.ts:55-67` applies the same four rules (equality on domain
   and registrar, zero-unless-equal hash, minimums, OR); `factsFromContext` applies the same byte and count caps (lines
   96, 73). `node --test prover/test/*.test.ts`: 13 pass.
4. **Signature binding and parameters rebuilt from the domain.** `identifier = keccak256("http\n" ‖ parametersFor(domain)
   ‖ "\n" ‖ context)`, signed as `identifier\nowner\ntimestampS\nepoch` under personal-sign, equals attestor-core 5.1.5's
   `getIdentifierFromClaimInfo` / `createSignDataForClaim` (`prover/node_modules/@reclaimprotocol/attestor-core/lib/index.js:5691-5747`:
   the context is re-canonicalized, the owner lowercased, the identifier lowercase hex). The 26 real fixtures of all 25 TLDs
   verify on this commit (`test_realProofs_everyRegistryAndTld`, `test_realDualProofs_everyRegistry`; 46 verifier tests
   pass), so `PARAMS_PREFIX` ‖ base ‖ `domain/` ‖ domain ‖ `"}` (or the zk-write suffix for `.now`/`.bot` only) is
   byte-for-byte the canonical `claimParams(domain)`. `Domains.tldOf` validates the domain before any string is built.
5. **The request side at the attestor** (`assertValidProviderReceipt`, `lib/index.js:7391-7440`): `https:` only;
   `assertNoSmuggle` anchors `^GET <path> HTTP/1.1\r\nHost: <host>\r\nConnection: close\r\n` on the client transcript;
   the path is compared with `matchRedactedStrings` (lines 7140-7190), in which a hidden byte (`*`) is accepted only
   where the template has a `{{param}}` placeholder, and yescrow's URL has none, so no byte of the path can be hidden
   although the code nominally allows 13; the Host header must equal the URL's host exactly; the attestor opens the TCP
   tunnel to the host of the params URL itself (`getHostPort`, line 7609). Request headers other than Host and
   Connection aren't checked, but a header that changes the response (`Range`, `Accept-Encoding`) yields a non-200 or
   non-JSON response, which `FULLY_REVEALED` refuses.
6. **The response side.** The regexes run over status line + headers + CRLFCRLF + de-chunked body (clients ≥ 3.2.0; the
   hardened attestor refuses older ones). Leading server records revealed as all `*` are dropped before matching
   (lines 7429-7431), but then the string no longer starts with `HTTP/1.1 200` and `FULLY_REVEALED` fails; hidden bytes
   anywhere else are `*` and fail it too; yescrow's attestor additionally refuses any server record not revealed whole by
   its key (patch hunk 3) and an HTTP message cut short (hunk 1). `.co` is the one registry that answers chunked over
   HTTP/1.1 (live today); its dual fixture (claim time 2026-10-29 07:14 UTC) post-dates the patch (07:09), so the
   completeness check accepts a de-chunked body that ends in the final chunk.
7. **Template as RE2 runs it versus JavaScript's `RegExp`** (`scripts/pentest/second/C_engines.ts`). Reclaim's attestor
   has RE2 (`sgiu`); yescrow's image lacks it and `makeRegex` falls back to `new RegExp(str, "sgi")` without `u`
   (`lib/index.js:7128-7132`). Over one live HTTP/1.1 response from each of the 8 registry hosts the captures are
   byte-identical and the facts equal. Where the engines differ they only refuse: a status spelt with U+017F
   (`tranſfer`) is refused by RE2's Unicode folding and signed by `RegExp` (both are needed); U+00A0 after `"ldhName"` is
   skipped by `RegExp`'s Unicode `\s` and not by RE2's ASCII `\s`, so RE2 captures the next `ldhName` (a nameserver's)
   and the pair fails `DomainMismatch`. A `Vary: *` header, a blocked status nested in a host object, a holder string
   ending in `"IANA Registrar ID` (the "twice" regex), two pipelined responses, and an event object with nested `links`
   are all refused by both engines.
8. **Each TLD's registry URL.** IANA's bootstrap (`https://data.iana.org/rdap/dns.json`, publication 2026-10-28T22:00:04Z,
   fetched today) lists exactly the verifier's base for `.com`, `.net`, `.ai`, `.org`, `.xyz`, `.app`, `.dev`, `.cc`,
   `.info`, `.bet`, `.pro`, `.art`, `.now`, `.bot`, `.live`, `.finance`, `.fm`, `.inc`; `.io`, `.co`, `.vc`, `.me`, `.sh`,
   `.ac`, `.bz` are absent, as SPEC §6.1 says, and each of their hosts answered a live lookup. The `_rdapBase` index
   ranges (3, 4, 11–15, 19–23 Identity Digital; 24 `.fm`; 25 `.inc`) match `Domains.tldOf`, and the prover's
   `RDAP_BASES` is the same table (`test_rdapBases`).
9. **Today's layouts (8 hosts, live).** The first `"ldhName"` is the domain's everywhere (byte 63 at Verisign, 178–188 at
   CentralNic and `.co`, 1,489 at Google, 3,668–3,940 at PIR, Identity Digital and Nominet); `nameservers` is followed
   by `secureDNS` or `rdapConformance`; the domain's `status` by `entities`, `events`, `port43` or `notices`, except at
   Nominet (`handle`, so `statusFlags` is 0 there and a blocked status is still refused by the inverted regex); no
   entity, nested entity or host object carries events except Nominet's hosts (`registration` only); no body contains
   `*`, a control byte or a non-ASCII byte. Status values are RFC 8056 spellings at every host.
10. **Context parsing.** `_facts` accepts only `{"extractedParameters":{…},"providerHash":"0x<64 hex>"}` with the seven
    known keys in canonical order, none repeated, nothing trailing; `_entry` skips escape pairs as JSON does; `_unescape`
    reverts on `\u`, `\b`, `\f` (a control byte in a capture fails closed); the attestor builds and canonicalizes the
    context itself (`assertValidProviderReceipt` returns `extractedParameters`; a non-participating optional group is
    skipped, lines 7503-7510, matching the optional keys). The repository's `test_synthetic_parserRejections` passes.
11. **Timestamps and freshness.** `_eventDate` accepts `YYYY-MM-DDTHH:MM:SS[.frac]Z` only; `fetchedAt` is the
    registry's own `last update of RDAP database`; on the 26 dual fixtures it lags the claim time by at most 17 s
    (Verisign) and the two halves differ by 0–1 s, 12–14 s at Nominet. The escrow's `notBefore < fetchedAt ≤ now` on the
    minimum means both halves post-date the proposal or activation, so one attestor can't resurrect an old attestation
    or make a stale one fresh (the C-1 control tests).
12. **Replay and binding.** A proof is public data by design: the time checks stop stale reuse, a "before" proof can't
    serve as an "after" proof or the reverse (registrar inequality versus equality), and a proof for another domain fails
    `DomainMismatch` then `ProofRejected(1)`. Two deals on one domain at the same registrar remain the documented
    account gap (SPEC §6.5).
13. **Unicode and IDNs.** Terms carry A-labels (`xn--` passes `tldOf`); `_lower` is ASCII and `ldhName` is LDH; a U-label
    or trailing dot in `ldhName` fails `DomainMismatch`; non-ASCII appears only in `unicodeName`, and none of today's
    responses contains any.
14. **Deployment of the verifiers.** `DeployAll` deploys Reclaim's verifier with `Networks.RECLAIM_ATTESTOR`
    (`0x2448…9072`, the key the 26 real fixtures verify against), refuses a `YESCROW_ATTESTOR` that is zero or equal to
    it (`DeployAll.s.sol:118`), and the dual verifier's constructor refuses two verifiers with one attestor.
    `CheckDeployment` reads back `dual.reclaim().attestor() == RECLAIM_ATTESTOR`, `dual.yescrow().attestor() ==
    YESCROW_ATTESTOR`, a TLD-table spot check, `isVerifier` on each escrow and, where the creation transaction is known,
    an allow-list of exactly that verifier (`CheckDeployment.s.sol:149-154, 194, 244`).
15. **Gas.** Nothing but the nameserver list is holder-inflatable (event objects are brace-bounded, the status array is
    registry-set, the registrar object is one publicIds entry), and that list is capped twice. The largest real response
    (Nominet) verifies within the repository's measured 0.72M–3.72M per attestation.

### Unconfirmed

- **U1. Nominet host-object events (first run U2).** Embedded host objects carry `registration` only today, as in the
  first run; a standalone host object (`/now/nameserver/ns1.markmonitor.com`, live) also carries `last update of RDAP
  database`. If Nominet ever embedded that event, or a `last changed` for a modified host, in the domain response, the
  "twice" regexes would refuse every `.now`/`.bot` proof of a domain with such a host (harness case 11), and a buyer
  with an in-bailiwick host could block the seller's proof (the seller disputes, SPEC §6.5). Not observed.
- **U2. Pipelined second response (first run U7).** With a server that ignored `Connection: close`, the captures come
  from whichever response first carries each field (harness case 8): a field-less first response followed by the real
  record yields the real record; two records are refused. The hardened attestor can't hide the first response, and every
  registry's `Access-Control-Allow-Origin: *` header in a second response fails `FULLY_REVEALED` anyway (case 8c). No
  registry was tested for pipelining.
- **U3. Status spelling.** The blocked-status regex and `_statusBit` match RFC 8056 spellings exactly; an EPP-style
  `pendingTransfer` would be signed by both engines and mapped to no bit (harness case 5). All 8 live hosts and 26
  fixtures use RFC 8056 spellings, and a non-compliant registry would be lying to the whole RDAP ecosystem, so this is
  noted, not claimed.
- **U4. `auto renew period` isn't blocked.** A domain past its expiration in its registrar's auto-renew grace shows a
  bumped expiration at Verisign and the `auto renew period` status, which SPEC §6.3 does not block; an IntraRegistrar
  push of such a domain passes the proof although the registration hasn't been paid for. This is within SPEC §13.4
  (IntraRegistrar rests on the buyer's account check) and a transfer in that state is normal for InterRegistrar deals,
  so it is a design note, not a finding.
- **U5. Certificate verification by the attestor.** The server-side validation isn't in the npm bundle (yescrow's image
  builds it from source). `@reclaimprotocol/tls` exposes `verifyCertificateChain(chain, host, …)` and the first run
  reports the attestor verifying the chain against the params host in `processHandshake`; I could not re-read that code
  here. If it didn't, an on-path attacker between an attestor and a registry could serve a forged record, but both
  attestors would have to be attacked.
- **U6. Arbitrum One gas accounting for the worst-case proof.** The 16-name worst case (16.5M L2 gas, about 20 KB of
  calldata) is measured locally; the repository's mainnet fork test submits a real dual proof but not that case, so the
  L1 calldata component in gas units at mainnet prices is unmeasured. Real responses are far smaller.

### Method

- Read SPEC §5, §6, §12, §13, §14.18; `docs/audit.md`, `docs/keys.md`, `docs/security.md`; the four proof contracts, the
  escrow's `accept`, `submitProof`, `_termsOk` and `_checkCommon`; `prover/src/{template,facts,prove,server}.ts`; the
  attestor patch; and, for what the attestors actually sign, attestor-core 5.1.5's `assertValidProviderReceipt`,
  `assertNoSmuggle`, `matchRedactedStrings`, `makeRegex`, `createSignDataForClaim` and `getIdentifierFromClaimInfo` in the
  prover's `node_modules` (the server-side files aren't shipped there).
- Ran the repository's verifier suites (46 tests), the prover's tests (13) and the first run's C proofs (8) on this commit.
- Live, read-only: IANA's bootstrap; one HTTP/1.1 GET per registry host (8) plus `google.now` and a Nominet host object,
  analysed for layout anchors, embedded events, framing and character set.
- Wrote the Foundry proof for C-1 with the test-only attestor keys in the attestor's exact context layout, as the
  repository's own tests do, plus one test on a genuine Reclaim attestation; and the engine harness.
- No transaction was sent to any network; no file under `contracts/src/` was changed.

### Verifications for area C

**C-1:**
```text
Verdict: CONFIRMED, Low (kept; challenged as close to Info). Corrections: the hole is not new in v0.31 (present since v0.23), and the fix sketch overclaims one property.
Proof of concept: `forge test --match-path 'test/pentest/second/C_DualCrossField.t.sol' -vv` (C copy): 12/12 pass. The honest record (expiry 1 day after its fetch) is rejected ProofRejected(4); paired with a forged half whose fetchedAt is activatedAt+1, the deal is Proven, released, seller credited 4,950 USDC; edge case: the honest record expired 2,588,338 s before its own fetch and the pair still passes; the margin sketch gives the same facts on all real dual fixtures (expiration lower by 0–15 s). Own tests: `forge test --match-path 'test/pentest/verify/V2_C-1_marginAndHistory.t.sol' --match-test 'test_V2|testFuzz_V2' -vv`: 6/6 (fuzz 256 runs).
Sources unchanged: yes (28 files under contracts/src, contracts/script, prover/src, attestor byte-identical to e2b52ce).
What I checked: test keys stand in for one compromised attestor, exactly the claim; the verifier checks only the signature (timestampS unused), so a leaked key can sign any fetchedAt; one test uses a genuine Reclaim fixture; no vm.store/etch. In scope: SPEC §14.18 "one attestor can only add rejections", keys.md "None alone", audit §4. Not in audit §5. v0.31 is not the cause: the v0.23–v0.30 combination (all facts but fetchedAt equal, min(fetchedAt)) has the same hole — a forged half copying the honest record and backdating only fetchedAt passes there too. Real root cause: fetchedAt feeds check 3's lower bound (min strictest) and check 4 (later fetch strictest); no single combination of that one fact serves both. The keyless "stale cache" form is not a weakening. Effect bound: expiration must still be ≥ notBefore + 30 days + 1; what breaks is "≥30 days from when the domain reached the buyer"; a forged half can't clear a blocked status.
Reason: not a false release (every delivery fact still signed by both); small gain (gTLD transfers add a year; ~one renewal fee vs compromising an attestor key); a "before"-proof abuse ends in delay, not loss. Not Info because the proof falsifies a written spec property and the fix is cheap before mainnet.
Corrections: root cause "since v0.23". The sketch's "at most either expiration" is false when a half is already expired (margin clamps to 0) — harmless since check 4 rejects such a pair. Fix (replace DualRdapVerifier.sol:40-41):
  if (g.lastChanged < f.lastChanged) f.lastChanged = g.lastChanged;
  // check 4 reads two facts: the smaller margin on the earlier fetch, so the pair passes only if each half does
  uint64 mf = f.expiration > f.fetchedAt ? f.expiration - f.fetchedAt : 0;
  uint64 mg = g.expiration > g.fetchedAt ? g.expiration - g.fetchedAt : 0;
  if (g.fetchedAt < f.fetchedAt) f.fetchedAt = g.fetchedAt;
  f.expiration = f.fetchedAt + (mg < mf ? mg : mf);
Mirror in prover/src/facts.ts:63-64 (fetchedAt = min; expiration = fetchedAt + min(margin(a), margin(b))); reword SPEC §14.18 and the NatSpec. Needs a new verifier and new escrows.
```


## Area D

### AI pentest, second run, area D: disputes (commit e2b52ce, SPEC v0.31)

Proofs in `contracts/test/pentest/second/` (`D_AppealBudget.t.sol`, `D_GovernorFee.t.sol`, `D_FixesHold.t.sol`) and `scripts/pentest/second/D_appeal_budget.ts`. Run from `contracts/`: `forge test --match-path 'test/pentest/second/D_*.t.sol' -vv` (9 tests pass: 5 on the Arbitrum One fork at block 519,194,000, 4 unit); the script from the copy's root. Nothing outside those directories changed; no transaction went to a live network.

**Setup.** Fork of Arbitrum One at block 519,194,000: real KlerosCoreNeo 0.10.0, DisputeKitClassicNeo 0.12.0, the public DisputeResolver 0xb552…4323, real USDC; the escrow deployed through `DeployAll`'s own config (courts 34 and 1, mainnet template). The keeper's `decide` (`service/src/appeals.ts:106-117`) is ported line by line into the fork tests with SPEC §14.21's suggested budget `APPEAL_DAILY_WEI = 0.1 ETH`; the node script runs the service's `decide` unmodified. Only stubs: Kleros's RNG (to draw real stakers) and the drawn jurors' votes. On-chain constants: court 34 fee 0.00027 ETH/juror (case fee A = 0.00081 ETH; first appeal 7 jurors → appealCost 0.00189, jurors' side 2× = 0.00378, other side 3× = 0.00567); second appeal from court 34 goes to court 33 at 0.018/juror × 15 = 0.27 (sides 0.54 / 0.81); court 1 fee 0.005/juror.

**The v0.31 fixes hold** (`D_FixesHold.t.sol`, plus `Judge` 19/19, `Hardening` 14/14, `SponsoredFees` 23/23, `Transitions` 88/88): `judgeTimeout` charges no fee on refused and stalled cases; a settlement lapses when the case changes period; actions name their phase, and the one state pair sharing phase 2 (Disputed vs Judging-after-stall) pays identically; the keeper stakes the jurors' side, including choice 0 on a tie at the winner's 2× (verified on the real kit).

#### D-1 (High): cheap sham cases reserve the whole daily appeal budget; a real case then opens unprotected and the loser flips it

- **Severity:** High: the winner of a Kleros vote is forced to find 0.00378 ETH (AI court) or 0.03 ETH (General Court) itself within the appeal period, after the attacker spent ≈ 0.021 ETH (≈ $50; nothing if `SPONSOR_DAILY_WEI` is on). In the AI court the winner is typically a party whose fee yescrow sponsored because it holds no ETH (§14.17), so the documented fallback "the winner must fund its own side in time" is exactly what it can't do. The v0.31 alerts reach it, which keeps this out of Critical; if every winner is assumed able to source ETH in 18–36 h, read it as Medium.
- **Attacker and preconditions:** the buyer or seller of any case it expects to lose, with a second wallet and 26 × A of ETH; `APPEAL_DAILY_WEI` at its suggested 0.1 ETH (cost scales linearly: ≈ $500 per ETH of budget). It creates the shams before answering the real case's fee (or keeps the budget drained through the 72 h fee window if it is the opener).
- **Steps** (`test_D1_…`): (1) real deal 1,900 USDC, seller disputes, buyer answers → real case in court 34. (2) One sham deal of 1 base unit between the buyer's two wallets, disputed and answered; nobody votes; it reaches its appeal period with ruling 0; the keeper (SPEC §14.21: ruling 0 is staked too) stakes 0.00378 ETH. (3) The real case: three jurors vote "seller delivered". `decide` with 26 shams' stakes in `spentToday` (26 × 0.00378 + 0.00378 > 0.1) returns `BUDGET`, all period. (4) The buyer funds its own side alone (0.00567); the seller does nothing; period ends; `currentRuling` = (2, overridden); `applyRuling` → Refunded: buyer credited 1,900 USDC + its fee, seller 0 and its fee gone; the buyer withdraws its stake whole. (5) The sham closes by itself; yescrow's stake comes back whole; the sham cost its maker one fee A = 0.00081 ETH. Script: the service's own `decide` funds 26 shams (spentToday 0.09828) then answers `{"fund":false,"reason":"BUDGET"}` for the real case.
- **Impact:** any ruling, in either court, can be flipped for ≈ $50 plus a refundable 0.00567 ETH, unless the winner stakes itself. yescrow loses nothing but the budget window, so nothing in the budget accounting flags it.
- **PoC:** `forge test --match-path test/pentest/second/D_AppealBudget.t.sol --match-test test_D1 -vv`; `node scripts/pentest/second/D_appeal_budget.ts`.
- **Root cause:** `service/src/appeals.ts:143` protects every Disputed deal of a v0.30+ escrow with no price floor; `:114` one global daily pool; `:120-123` `spentToday` counts a stake for 24 h whatever came back; `:162-211` stakes at the period's open, before any appeal exists, so a sham needs no appeal to consume budget. A sham costs its maker A while costing the budget 4.7 A.
- **Fix:** stake reactively: only after `loserEnd` (the loser's half) and only if a choice other than the jurors' is fully funded; the jurors' side can be funded until `end`, and Kleros runs the period to its full length once a side is funded, so ≥ 18 h (AI) / 54 h (General) remain. A sham then costs its maker 3 × appealCost per stake it triggers, and unappealed cases still end at the half. Add a price floor (no stake under, say, 50 USDC) and a budget per court so AI-court shams can't drain General Court cases. Also count protected cases per party pair per day.

#### D-2 (Medium): escalating past the budget flips a twice-confirmed ruling and pays the loser yescrow's stake

- **Severity:** Medium: loss of yescrow's funds (bounded by the budget per day) plus a profit motive for a flip that SPEC §13.5 documents as a known limit; reported because it is worse than described (SPEC §14.21: the stake is lost only when "the new round reverses the ruling"; in fact it is lost whenever the final appeal is one-sided for the other party, even after new jurors confirmed, and it goes to that party).
- **Attacker and preconditions:** the loser of the vote, with 0.81 ETH of liquidity for 36 h (AI court) or 0.225 ETH for up to 108 h (General Court), all refunded.
- **Steps** (`test_D2_…`): (1) 1,000 USDC case, 3 jurors: seller delivered; keeper stakes 0.00378; buyer funds 3A' → round 1. (2) Seven jurors confirm. Second appeal's jurors' side 0.54 ETH > budget → `BUDGET`. (3) Buyer funds its side alone (0.81), period ends, `currentRuling` = (2, overridden), `applyRuling` → Refunded (buyer 1,000 USDC + fee, seller 0). (4) yescrow's round-0 stake withdraws 0; the buyer gets 0.00756 for round 0 and its 0.81 back whole. Net: buyer +0.00189 ETH on top of the flip; yescrow −0.00378. General Court (court 1): yescrow's stakes per round 0.03 / 0.07 / 0.15; the third exceeds the budget, so the loser's unanswered third stake (0.225) flips a deal of any size and it collects 0.2 ETH from rounds 0 and 1, on a ruling confirmed by 1, 3 and 7 jurors.
- **Impact:** every stake yescrow places is a bounty for the party that can afford the first unanswered level; in sham cases the same mechanic drains the budget at a profit of ≈ 0.00108 ETH per sham, up to 0.1 ETH/day.
- **PoC:** `forge test --match-path test/pentest/second/D_AppealBudget.t.sol --match-test test_D2 -vv`.
- **Root cause:** Kleros's `DisputeKitClassicBase.withdrawFeesAndRewards` pays every earlier round's pot to the choice equal to the final `currentRuling`, which a one-sided last appeal sets; `appeals.ts:114` stops at the budget; SPEC §14.21 describes only the merits case.
- **Fix:** contract, next version: when `currentRuling` returns `overridden` and the ruling differs from the last round's `winningChoice`, hand the deal to the judge instead of paying (a defended case is unaffected). Service: the reactive staking of D-1's fix limits exposure to cases someone actually appeals; cap total exposure per case; word §14.21 and the alerts so a winner knows that above the budget its own stake is the only thing between it and a refunded loser.

#### D-3 (Low): Kleros's governor can block disputes from being opened, not just refuse them

- **Severity:** Low: a listed key with an unlisted effect on open, undisputed deals; the same EOA can already upgrade the core, so this adds reach, not a new trust party.
- **Steps** (`test_D3_…`): the governor's `changeCourtParameters` sets court 34's `feeForJuror` to 1e30. `escrow.arbitrationCost(1,000e6)` = 3e30 wei; `dispute{value: 1,000,000 ETH}` reverts `WrongFee`; `challenge` shares `_openDispute`. At the transfer deadline `refund` pays the buyer 1,000 USDC, who keeps the domain the seller moved; the seller has no judge to go to. Same for court 1 on a 50,000 USDC deal.
- **Impact:** for the duration of the change, no buyer can challenge and no seller can dispute; deals close by their timers alone.
- **PoC:** `forge test --match-path test/pentest/second/D_GovernorFee.t.sol -vv`.
- **Root cause:** `contracts/src/YescrowEscrow.sol:975-985` (`_openDispute`: `msg.value != cost` with no upper bound), and the docs' "can't touch undisputed deals" (`docs/security.md:91`).
- **Fix:** next version: a deployment cap on the quoted fee; above it (or if `arbitrationCost` reverts) open the dispute at the cap and treat the answer as refused → judge. Now: correct `keys.md`/`security.md`.

#### D-4 (Low): the resolver key strands appeal stakes of open cases

- **Steps** (`test_D4_…`): after an unopposed stake and the period's end, the resolver's governor calls `changeArbitrator(other)`. `KlerosCore.executeRuling` reverts (the resolver's `rule` requires its current arbitrator), `withdrawFeesAndRewards` reverts `DisputeNotResolved`, so yescrow's 0.00378 ETH and every party's contribution stay in the kit; the escrow's `applyRuling` still pays correctly; a new dispute goes to the judge as documented. Only the same key pointing back frees the stakes.
- **Impact:** frozen, not lost; contradicts SPEC §12 / keys.md "can't affect a case once opened". The keeper's `withdraw()` (`appeals.ts:226-254`) would retry every tick forever.
- **PoC:** `forge test --match-path test/pentest/second/D_AppealBudget.t.sol --match-test test_D4 -vv`.
- **Fix:** docs only (a Kleros design property); make the keeper stop retrying.

#### Info

- I-1: the settlement stage also lets a party out of an offer the other side already counter-signed: once the period's time is up (or all votes are in) anyone calls `passPeriod`, and a signed `settle` not yet on-chain reverts `InvalidSignature` (`D_FixesHold`, `test_info_…`). Worth a line in the UI.
- I-2: with the keeper staking at the period's open, every appeal period now runs its full length (36 h / 108 h) instead of ending at the half when nobody appeals, so AI-court payouts move from ≈ 19 h to ≈ 37 h; reactive staking removes this too.

#### Checked and found sound

- Refusal detection and gas, `_read` of `resolver.arbitrator()`: unchanged since the first run's proofs; `Hardening.t.sol` 14/14.
- `_read` on malformed, short or reverting returns; a case ID that isn't the deal's: unchanged, first-run proofs stand.
- `settlementStage`: 0 and 1 can't collide with a timestamp; 2 only when Kleros's getter fails; lapses on every period change including a new round.
- `actionPhase`: the only same-phase pair pays identically; a phase-1 `Confirm` can't close a Kleros case.
- `judgeRuling`/`judgeTimeout`: rulings 0–2, 14 days, no fee either way, loser's fee only on refused cases; ETH owed = held on every path.
- Appeals: the keeper reads the jurors' choice during the appeal period; `fundAppeal` on an already-funded choice reverts (no double stake); a partial pre-funding only lowers the keeper's contribution; a Kleros pause blocks only stake withdrawals; the relayer simulates `fundAppeal` first; `withdraw()` uses the round's recorded ruling.
- Tie → ruling 0 → 50/50, choice 0 funded at 2× by the keeper (proven on the real kit).

#### Unconfirmed

- U-1: `protect` returns early once `stake_status = 'staked'` (`appeals.ts:173`) and never re-checks `funded.includes(ruling)`; if Kleros's governor raises `feeForJuror` between the read and the kit's pricing, the side stays unfunded while the alerts say it is funded.
- U-2: `lastPeriodChange` shared when vote→appeal happens in the same block as commit→vote; theoretical.
- U-3: the keeper still calls `applyRuling` at day 90 even when the case sits in its appeal period (first run's U-1); unchanged.
- U-4: D-1 with sponsorship on costs the attacker nothing; G-1's limits slow it to about a day of lead time.

### Verifications for area D

**D-1_D-2:**
```text
D-1 — Verdict: CONFIRMED (High, at the lower edge), with two corrections. (Verifier wrote no extra tests; from reruns and reading the Kleros source.)
PoC: D_AppealBudget test_D1 PASS (each sham makes the keeper stake 0.00378 ETH; 26 shams block the budget; the attacker pays 0.00081 ETH per sham, 0.02106 ETH in all; the buyer gets back 1,900 USDC + its fee, the seller 0); D_appeal_budget.ts PASS (the service's decide returns BUDGET after 26 shams). Sources unchanged: yes (export matches e2b52ce).
Checked: the decide port is faithful (same order, reserve before send); spentToday is a rolling 24 h sum that keeps counting a stake after it comes back; stubs only the RNG and jurors' votes; one fee per sham; isAppealFunded lets a period end at its half only while nothing is funded; the period's end is fixed, never extended.
Corrections: (1) the window: the AI court's appeal period lasts 36 h but spentToday looks back 24 h, and the test checks decide once then jumps to the end; really the shams' stakes drop out ~24 h in and the keeper then stakes the real case → the attacker needs a second batch of 26 opened 12–24 h into the real period: ~52 shams ≈ 0.042 ETH (~$100); General Court (108 h): ~5 batches of 19 ≈ $185; timing is doable. (2) The operator is part of the defence: the ops email (appealProblems) fires when the period opens; the runbook says fund by hand (0.00378 ETH); winners' alerts reach only subscribed parties. (3) Sponsorship doesn't make it free (open shams count 2 fees each against SPONSOR_DAILY_WEI).
Reason for High: a party can lose the whole price; needs a sponsored winner not to find ~$9 of ETH in 18–36 h and a solo operator to miss an email that long — both plausible. Medium if the operator commits to answering BUDGET alerts within the period; Medium in the General Court anyway.
On the reactive fix: sound on the real kit (once a side is funded the period can't end early; the loser can fund until a second before its half ends, leaving 18 h / 54 h; doesn't bring back run 1's race) but not sufficient: the attacker then funds appeals on its shams, plays both sides, sways new jurors, collects yescrow's stake, drains the budget at a profit. Stake as soon as the other side is funded.
Recommended: CONTRACT, before mainnet: when Kleros's ruling is `overridden` and differs from the last round's jurors' ruling (0 on a tie), pay on the jurors' ruling; then retire the keeper's stakes. Doesn't block appeals: a loser can still get new jurors by funding both sides itself and gets everything back except the new jurors' fee (from the source, not run). Cost: cheaper appeals let a loser delay more cheaply, but each round costs more. Service until then: react to funded appeals, price floor, per-court budget, operator on call.

D-2 — Verdict: CONFIRMED (Medium)
PoC: test_D2 PASS: AI court: yescrow loses its 0.00378 ETH stake, the loser gains 0.00189 ETH, fronting 0.81 ETH (returned). General Court: yescrow's stakes 0.03/0.07/0.15 per round; the loser's unanswered third stake 0.225 ETH collects 0.2 ETH (arithmetic from chain fees, not run). Sources unchanged: yes.
Checked: withdrawFeesAndRewards pays each round's pot to whoever funded the final ruling's choice (described correctly); an unanswered last appeal makes currentRuling return that choice, overridden; jurors 1 → 3 → 7 → 15 (appealCost = fee × (2n+1)).
Reason: the later-appeal flip is a documented limit (SPEC §13.5); new: SPEC §14.21 says the stake "comes back doubled" if the next round confirms, but it goes to the loser even after new jurors confirmed, and the loser profits; yescrow's loss is capped by the budget → Medium.
Corrections: the sham-profit figure assumes 0.81 ETH capital per sham; swaying jurors in a sham is cheaper. Handing to the judge lets any loser reach yescrow's judge for gas; paying on the jurors' ruling (D-1's recommendation) avoids that and removes yescrow's stake loss (no stake placed). Correct SPEC §14.21's wording either way.
```

**D-3_D-4:**
```text
D-3 — Verdict: CONFIRMED (Low)
PoC: D_GovernorFee.t.sol 1/1 PASS: the governor's real changeCourtParameters sets feeForJuror 1e30; dispute reverts WrongFee even with 1,000,000 ETH; at the transfer deadline refund pays the buyer 1,000 USDC; same in court 1 for 50,000 USDC. Own verify/V2_D-3_GovernorReach.t.sol 3/3: governor 0xf1C7…0c59 (an EOA, as keys.md lists; also the resolver's governor; KlerosCore is an ERC-1967 proxy, impl 0xC121…E54c); control: the same raise after a dispute is opened takes the documented path (refused → Judging), so the gap is only at opening; a reverting quote (an upgraded core could) blocks opening the same way and also payArbitrationFee (calls arbitrationCost with no guard) → the opener wins by feeTimeout; challenge shares _openDispute (YescrowEscrow.sol:511-516, 975-985). Sources unchanged: yes.
Reason: listed trust party whose upgrade power can already produce the same outcomes on contested deals; adds reach, not a new party → Low. But it is a real loss, not just "deals end by their timers": a seller who delivered but can't prove it (§6.5) is refunded away (buyer keeps domain and price); in IntraRegistrar mode the buyer can block the proof by changing nameservers (security.md:81); a buyer facing a passing proof in the §6.5 gap can't challenge. None of it goes to the judge, contrary to keys.md, audit §4, SPEC §7.1/§13.10; security.md:91 "can't touch undisputed deals" is literally true but misleading.
Fix: (a) read the quote with try/catch in both _openDispute and payArbitrationFee; (b) above a fixed cap, or when the quote reverts, open at the cap so the existing cost > pot refusal path hands the deal to the judge (feeTimeout stays the anti-stall rule); (c) cap per court (~10× headroom; fees differ ~20×); (d) docs now (keys.md, security.md:91, audit §4, SPEC §7.1/§13.10); (e) an ops alert when the quote jumps or reverts.

D-4 — Verdict: CONFIRMED (Low), with corrections
PoC: test_D4 1/1 PASS: after the governor's changeArbitrator(0xdead), executeRuling and withdrawFeesAndRewards revert; applyRuling still releases (seller 990 USDC); a new dispute goes to Judging; yescrow's 0.00378 ETH stuck until the resolver points back. Own verify/V2_D-4_StrandedStakes.t.sol 1/1: changeArbitrator is real; resolver, core and dispute kit share the governor EOA (the kit is also an ERC-1967 proxy); the reverts are strings ("Only the arbitrator can execute this.", "Dispute should be resolved."), not the custom errors named; KlerosCore.execute (jurors' fees) still succeeds; pointing back frees nothing until someone calls executeRuling. Sources unchanged: yes.
Reason: frozen, not lost; the same key can already upgrade the kit; contradicts SPEC §12 / security.md:56 "can't affect a case once opened"; parties' own stakes frozen too (0.81 ETH in D-2's case); a non-malicious trigger is plausible (Kleros moving the resolver to a new core) → Low.
Corrections: revert names are strings; "retries every tick forever" is half right — withdraw() (appeals.ts:226-254) re-reads `ruled` each tick (an RPC read, no gas); the keeper tries executeRuling only for deals still Disputed (keeper.ts:93-126), and the same tick's applyRuling (:129) closes the deal, so after a point-back the stake isn't recovered by itself.
Fix: docs (SPEC §12, security.md:56, keys.md: re-pointing freezes the case's appeal stakes until it points back and the ruling is executed); keeper: in withdraw(), at period 4 and not ruled, simulate and send executeRuling itself; mark the round stranded and back off while resolver.arbitrator() isn't the core, with an ops alert; alerts/deal page shouldn't promise a stake comes back without this caveat.
```


## Area E

### Pentest report (second run), area E: deposit router

Commit under test: e2b52ce (SPEC v0.31, 2026-10-29). Scope: `contracts/src/DepositRouter.sol` (the v0.31 router at
`0x8f5d140d1ab4158Bf259D8C909C803A651Fb1a52`), `DepositBox.sol`, the escrow's deposit and `withdrawTo` paths,
`script/DeployRouter.s.sol`, `script/Networks.sol`, and the service's forwarding, indexing and completion code
(`service/src/keeper.ts`, `bridges.ts`, `api/app.ts` relay action `completeTransfer`). The first run's E-1 (strangers'
forwards never completed) and E-2 (a box whose escrow has no code on Arbitrum One was locked for good) were checked
to hold, and the new code they brought was attacked.

Proofs: 10 Foundry tests in `contracts/test/pentest/second/E_*.t.sol` on forks of Ethereum (block 26,254,000) and
Arbitrum One (519,194,000) against the real USDC, USDT, USDT0 and its LayerZero adapter and OFT, CCTP V2, the keyless
CREATE2 proxy and the router at its pinned address; one script in `scripts/pentest/second/` that drives the service's
own `Keeper` class with stand-ins (as the first run's accepted script did). The first run's E proofs (11 tests, one
script) were re-run on this commit as regressions: all pass. No transaction was sent to any network; `contracts/src`
is unchanged.

Run everything from `contracts/`:

```bash
forge test --match-path 'test/pentest/second/E_*.t.sol' -vv
(cd ../scripts/pentest/second && node E_forward_griefing_keeper.ts)
forge test --match-path 'test/pentest/E_*.t.sol' -vv && (cd ../scripts/pentest && node E_foreign_forward_keeper.ts)   # first run, regressions
```

#### Findings

### E-1. The keeper's forwards from Ethereum, and its CCTP completions, are outside every relayer budget: a buyer that is its own counterparty makes yescrow pay an Ethereum forward and a LayerZero fee for every 10 USDT it cycles through its own draft's deposit address

**Severity:** Medium (loss of yescrow's ETH at about 6–10× the attacker's spend; once the relayer's Ethereum balance
is gone, honest buyers' deposits on Ethereum wait at their addresses until a top-up). SPEC §12 (v0.31) says "Since
v0.31 everything it pays for has a cap (AI pentest findings B-1, B-2, G-1)"; the keeper's forward is not capped, and
`docs/keys.md` §1 lists "USDT0's LayerZero fee when it forwards USDT" among what the relayer pays without a cap.

**Attacker and preconditions:** anyone with ~10 USDT or USDC, Ethereum gas, and yescrow's API. Deposits from Ethereum
are a mainnet feature (SPEC §4.1; `docs/keys.md`: the mainnet relayer holds gas on Ethereum). The attacker creates
drafts with `POST /v1/drafts` (no signature, no wallet, 120 per minute per IP) naming itself as buyer and proposer,
with any price above what it will send; every such draft's deposit address is watched by the keeper for 30 days
(`keeper.ts` `watched`, `DRAFT_DAYS`).

**Steps:**
1. Create N drafts (`acceptBy` an hour ahead, price 1,000 USDT, the attacker as buyer and proposer).
2. Send exactly 10 USDT (`FORWARD_MIN` for Ethereum, `bridges.ts:14`) to each draft's deposit address on Ethereum
   with a plain `transfer` (USDT has no signed transfers there, SPEC §4.1). Cost: 84,793 gas each (measured).
3. Within one tick (30 s) the keeper forwards each box: `relayer.call('forward', …, { value: 1.2 × LayerZero's
   quote })` at `keeper.ts:350`, outside `sendWithin` (`budgets.ts`): no `relayer_spend` row, no daily limit, no cap
   per deal or per buyer. The router passes the whole LayerZero fee on and refunds the rest. Cost to the relayer:
   522,659 gas plus 0.0000157 ETH of fee (702,871 gas the first time, when the box is deployed).
4. USDT0 mints the 10 USDT to the attacker's own box on Arbitrum One. The deposit never funds the deal (price 1,000),
   so after `acceptBy` the keeper's own `returnDeposit` credits the attacker and anyone's `withdraw` pays it: the
   attacker's money is never spent (`E_StrayEdges.t.sol` test_6), only its transfer gas.
5. Refill the same boxes and repeat; the same 25 boxes are forwarded again next tick, for ever (script, cases A–B).
   The USDC path has no fee but the same shape (test_2); the gasless USDC deposit the B-2 fix budgets (an ~80k-gas
   `transferWithAuthorization` under `DEPOSIT_RELAY_DAILY_WEI`) triggers an unbudgeted forward of 204k–385k gas, so
   the real spend is 2.5–4.8× that budget, and a plain transfer needs no budget at all.
6. Separately, every CCTP burn to a watched box, by anyone and of any amount, is completed on Arbitrum One by the
   relayer with `receiveMessage` (`completeTransfers`, no minimum, no budget; script case D). That one costs the
   stranger about as much on Base as it costs the relayer on Arbitrum One, so it is uncapped but not amplified.

**Impact, measured on the Ethereum fork (base fee 0.0836 gwei at block 26,254,000):**

| per cycle | attacker (one transfer) | relayer (one forward) | ratio |
|---|---|---|---|
| USDT, steady state | 84,793 gas = 0.0000071 ETH | 522,659 gas + 0.0000157 ETH fee = 0.0000594 ETH | 8.4× |
| USDT, first cycle (box deployed) | 0.0000071 ETH | 702,871 gas + fee = 0.0000745 ETH | 10.5× |
| USDC, steady state | 83,820 gas = 0.0000070 ETH | 204,411 gas = 0.0000171 ETH | 2.4× |
| USDC, first cycle | 0.0000070 ETH | 384,622 gas = 0.0000322 ETH | 4.6× |

At 1 gwei the USDT cycle is 0.000538 ETH for the relayer against 0.000085 ETH for the attacker (6.3×). The relayer
sends forwards one at a time and waits for each receipt, so a few thousand cycles a day are possible across the
attacker's boxes: about 2 ETH a day of yescrow's at 1 gwei for 0.3 ETH of the attacker's, or, more simply, the
relayer's whole Ethereum balance (alerted below `RELAYER_LOW_WEI`, 0.005 ETH by default) within the hour, after which
no deposit from Ethereum is forwarded for anyone until it is topped up (money waits at the deal addresses; buyers can
take it back with `returnStray`). The spend alert (`RELAYER_SPEND_ALERT_WEI`, 0.01 ETH per hour) detects it; nothing
prevents it. The first run listed this as unconfirmed ("Relayer gas griefing through minimum deposits"); the v0.31
budgets covered the relayed signatures around it but not the forward itself.

**Proof:**
```bash
forge test --match-path test/pentest/second/E_ForwardCost.t.sol -vv        # the costs, real USDT/USDC/USDT0/LayerZero on Ethereum
forge test --match-path test/pentest/second/E_StrayEdges.t.sol --match-test test_6 -vv   # the attacker's 10 USDT come back
(cd ../scripts/pentest/second && node E_forward_griefing_keeper.ts)        # the real Keeper: 25 boxes -> 25 forwards, no budget, again after a refill; a 1-unit burn completed
```
`E_ForwardCost` deploys the router through the real proxy (it lands at `Networks.ROUTER`), tops the attacker's box up
with the real tokens and forwards as the keeper does (`value` = 1.2 × `quoteSend`), asserting the relayer is out
exactly the quote and the box is empty again. The script's database stand-in throws on any SQL it doesn't know, so a
budget read or write (`relayer_spend`) would fail the run; it passes.

**Root cause:** `service/src/keeper.ts:333–364` (`forwardFromDepositChains`: `relayer!.call('forward', …, { value })`
at l.350 with no `sendWithin`), `keeper.ts:252–285` (`watched`: every draft whose proposer is its buyer, for 30
days), `keeper.ts:397–425` (`completeTransfers`: every pending inbound row, no minimum), `bridges.ts:14`
(`FORWARD_MIN` binds only the keeper's threshold, not what it spends), `api/app.ts:502–523` (drafts need no
signature). `budgets.ts` (`sendWithin`) is used only by the relay actions.

**Suggested fix (service only):** send the keeper's forwards and completions through `sendWithin` with daily budgets
per deposit chain (`FORWARD_DAILY_WEI_<chainId>`, reserving gas × max fee plus `value`), and refuse to forward a
draft's deposit until it can fund the deal (what is at the address on the deposit chain plus what is already at the box
on Arbitrum One ≥ price), which is the only forward that gains anything; for an on-chain seller proposal the same
test applies. Count the forward a gasless deposit will trigger against `DEPOSIT_RELAY_DAILY_WEI`. Complete inbound burns
below `FORWARD_MIN` only when they complete a fundable deal, as `selfComplete` already leaves small payouts to their
recipients. Add the forwards to the ops alert's per-deal view (`relayer_spend` rows), and say in SPEC §12 and
`docs/keys.md` that the forward and its LayerZero fee are budgeted.

### Info findings (no proof needed; documentation and interface)

- **E-I1. `docs/keys.md` describes the box wrongly.** §2: "The box's only function, `sweep`, checks that the caller is
  the escrow that deployed it, which is fixed in its bytecode" — the box's `owner` is the *router* that deployed it
  (`DepositBox.sol:21–30`); the escrow is tied to the box only through the router's salt and `pull`'s `msg.sender`.
  §3's row "`DepositRouter.returnStray` (any chain but Arbitrum) | anyone | Send any other token … to the buyer" is
  pre-v0.31: on Arbitrum One the buyer in the salt may now sweep a box whose escrow has no code, and off Arbitrum a
  bridgeable token is returnable only by the buyer.
- **E-I2. Stale router addresses in the docs.** `docs/dependencies.md:275` and `docs/runbook.md:82` present the v0.12
  router `0xCCb4…29Ff` as "`DepositRouter` … on every chain"; `script/DeployRouter.s.sol:22–23` describes v0.27 as the
  current router. The pinned constant (`Networks.ROUTER`) and `docs/mainnet.md` are right; a reader of the other two
  could paste the wrong address into a tool (money sent to a v0.12-router box of a v0.31 escrow is locked: `E_StrayEdges`
  test_5).
- **E-I3. The E-2 fix has no interface or API path.** `returnStray` appears nowhere in `web/` or `service/src`
  except the ABI; SPEC §4.1 and §13.7 promise that a buyer who paid a testnet deal's address on a mainnet "takes the
  box back" on Arbitrum One, but only a buyer who can craft the call (Arbiscan's write tab, `cast`) can. Add a
  support page or a relay action for it.
- **E-I4. The USDT inbound row ignores the buyer in the salt.** `bridges.ts:82` matches a `Forwarded(escrow, dealId)`
  log to a watched box by escrow and deal ID only (the event carries no buyer), so a stranger's USDT forward for
  (escrow, realDealId, *itself*) is recorded as 10 USDT "coming" to the real deal until LayerZero Scan reports
  delivery, while the USDT lands at the stranger's own box, from which no terms can ever pull it (locked for good).
  Display only: `accept`/`propose` read the real box on-chain. The stranger pays the full amount, so nobody would.

#### Checked and found sound

Executable claims are in `contracts/test/pentest/second/E_StrayEdges.t.sol` and `E_RouterPinned.t.sol`, and in the
first run's `E_*.t.sol` re-run on this commit; the rest by reading.

1. **E-2's fix holds and is tight.** On Arbitrum One a box whose salt names the precomputed mainnet USDC escrow
   (no code at the fork block) holding USDC, USDT0 and ETH: a stranger's and the relayer's `returnStray`, `forward`
   and `pull` move nothing, a stranger's `returnStray` with its own address as buyer finds an empty (different) box,
   and the buyer takes all three assets back (`E_StrayEdges` test_1; first run's `E_StrandedBox` tests 3–4 re-run).
   The same box once the real escrow's code and storage are at that address (`vm.etch` + `vm.copyStorage`): the
   router refuses everyone (`NotHere`), the escrow's `depositAddress` is that box, `returnDeposit` is `TooEarly` while
   valid terms can still fund the deal and returns the money to the same buyer after `acceptBy` (test_2). So a deposit
   made before the deployment is never lost and never anyone else's, and there is no moment in the sequence where a
   third party can act. The premise "no deal can exist before its escrow does" is exact: deals are created only by the
   escrow's own `propose`.
2. **Code at the salt's escrow address.** EIP-7702 delegation code (23 bytes) counts as code: the buyer is refused and
   that EOA, as the salt's escrow, sweeps the box with `pull` to anyone (test_3), which is consistent (whoever controls
   the salt's escrow controls its boxes) and unreachable from yescrow's flows (every deposit address comes from
   `YescrowEscrow.depositAddress`; the precomputed mainnet escrows are CREATE addresses with no key). Live code that is
   not an escrow (test_4: USDC itself as the salt's escrow) and an escrow that names another router (test_5) stay
   locked for good: the fix covers only addresses without code, by design, and SPEC §4.1 already says so for the
   pre-v0.31 testnet escrows' deal addresses on mainnets. Selfdestruct can't remove code after Cancun except in the
   creation transaction, so an escrow's code can't vanish to open `returnStray`.
3. **The router's address is pinned to this build.** The default-profile artifact (6,842 bytes, init-code hash
   `0x0ca29278…7017`), the creation code a test file gets, and `DeployRouter.predicted()` all land at
   `Networks.ROUTER` through the keyless proxy, on Ethereum and Arbitrum One forks too (`E_RouterPinned`,
   `E_ForwardCost`/`E_StrayEdges` setups, first run's `E_Sound` test_1). The router deployed on Arbitrum Sepolia
   (`cast code`) is byte-identical to the artifact's runtime code with its `boxCodeHash` immutable
   (`0x1e068479…f2a0`) filled in (5,957 bytes). Nobody can put other code at that address on any chain (CREATE2 commits
   to the init code), and a different proxy or CREATE2 rule lands elsewhere, where no escrow names it.
4. **Boxes.** `boxCodeHash` and every box address depend only on (router, escrow, dealId, buyer); two routers' boxes
   differ, fields aren't interchangeable, and the salt is `abi.encode` of fixed-size fields (`E_RouterPinned`
   test 2). The box's `owner` is the router; `sweep` takes any token or ETH to a `to` only the router chooses (the
   escrow in the salt, the buyer in the salt, or the router itself to bridge). The router keeps no state, so
   re-entering it from an ETH sweep or a malicious `token` gains nothing, and the escrow is `nonReentrant`.
5. **Forwarding.** `forward` sends to `boxAddress(escrow, dealId, buyer)` from the salt it swept, with
   `destinationCaller` 0 (anyone completes), `maxFee` 0 and `minFinalityThreshold` 2000 (standard, free; CCTP V2
   requires `maxFee < amount`, so any non-zero amount passes) for USDC, and `minAmountLD = amount` with the real
   `quoteSend` for USDT (the relayer paid exactly the quote and got the 20% margin back: `E_ForwardCost`). The
   `Forwarded` event is emitted before `_send`, so a reverting bridge reverts the whole call and leaves the money at
   the box. Testnet routes can't reach a mainnet (`there.settlement == here.settlement`), and `bridge` from the escrow's
   `withdrawTo` refuses the same chain, unknown chains and USDT0 to Base (first run's `E_Sound` test_3 re-run).
6. **Every route constant still matches the real bridge** (first run's `E_Sound` test_2 re-run on this commit: CCTP
   domains, remote messengers, token mappings, the 10M cap, USDT0's adapter/OFT, endpoint IDs, peers, enforced
   `lzReceive` gas). A USDT0 delivery that ran out of gas could be retried by anyone through LayerZero's endpoint; the
   enforced options are USDT0's own for every user, so a mint to a fresh box is routine.
7. **The service's E-1 fix.** Inbound rows are found from CCTP's `DepositForBurn` (depositor = the router) by the
   message's `mintRecipient`, and from the router's `Forwarded` for USDT, keyed by (transaction, log index); a batched
   forward gives one row per burn; `completeCctp` completes only messages minting to the row's box and skips spent
   nonces, so nothing is completed twice; a re-scan records nothing twice (first run's script re-run; mine, case D).
   The relay action `completeTransfer` records only deals the service knows (`proposalOf`) and only messages minting
   to their real buyer's box, so a stranger's forward for another "buyer" of a real deal ID is not matched for USDC
   (for USDT see E-I4). Direct CCTP burns by someone else's contract (not the router) or from chains the router
   doesn't know aren't indexed, but their recipients can complete them and the money funds the deal or returns.
8. **The escrow side** (unchanged since the first run, re-read): the deal ID commits to the buyer, `_pullDeposit`
   trusts the balance difference, `_fund` requires the price and credits the excess to `terms.buyer`, `returnDeposit`
   pays only `terms.buyer`, the deal token only once `_depositCanFund` is false, other tokens and ETH straight to the
   buyer at any time; a buyer whose draft deposit is `TooEarly` can leave at once with `propose` (the deposit funds
   it) then `withdrawProposal` (the funding comes back as a balance). `withdrawTo` bridges only what the account signed
   for, and a bridge revert burns no signature.
9. **Deploying elsewhere.** On a chain `routeOf` doesn't know, `pull`, `forward` and `bridge` are refused and
   `returnStray` returns everything to the buyer in the salt; on a known deposit chain anyone returns non-bridgeable
   tokens and ETH, and only the buyer the stablecoins. The service uses a deposit chain only if the router is deployed
   there and routes to its settlement chain (`loadDepositChain`), and `DeployAll`/`CheckDeployment` require the router
   on the settlement chain before an escrow names it.
10. **Keys.** No key in `docs/keys.md` gains anything from the router: the relayer's `forward`/`completeTransfer` only
    move a deposit to its own deal address or complete a burn to its named recipient; the deployer keys never touch
    the router (deployed through the proxy by any key, refused for the mainnet deployer).

#### Unconfirmed

1. **"Another EVM chain" is wider than the router can serve.** SPEC §4.1 "Wrong network" says money sent to a deal
   address on another EVM chain "isn't lost: anyone can deploy the router there and return it to the buyer". On ZK-stack
   chains (zkSync Era, Abstract, Lens, Sophon) CREATE2 addresses are derived differently, so neither the router nor a
   box can land at its address, and on a chain where the keyless proxy's pre-EIP-155 transaction can't be replayed
   there is no proxy at `0x4e59…956C`; money sent there is lost like on a non-EVM network. Exchanges offer USDC/USDT
   withdrawals on some of these. Not provable on a fork; the interface's warning should list the networks where the
   rescue works (the proxy exists on Ethereum, Arbitrum One, Base, OP Mainnet and Polygon PoS per `docs/dependencies.md`).
2. **CCTP's fee policy (`maxFee` = 0, immutable)** and **denylists on the shared router address**: carried from the
   first run's unconfirmed 1–2; v0.31 changed nothing there (the keeper still doesn't query Circle's fee endpoint).
3. **Buyers that exist only on one chain.** The new `returnStray` path on Arbitrum One needs `msg.sender == buyer`
   there; a smart-contract buyer deployed only on Base, or an L1 contract calling through a retryable ticket (its
   address arrives aliased), can't satisfy it, as SPEC §14.11's caveat already says for Base and Ethereum. Safe-style
   wallets can usually be redeployed at the same address; CREATE-based ones can't.
4. **Reorged forwards.** `findForwards` reads logs up to the chain's latest block; a forward in a reorged block leaves
   a pending row Circle never attests (an ops "late transfers" alert, no money).
5. **RPC load from drafts.** Every watched draft costs two balance reads per deposit chain per tick; thousands of free
   drafts make the keeper's tick slow and the RPC quota a cost (`forwardFromDepositChains` logs "deposit chain
   unreachable" and skips the chain). Not measured.

#### Files

| File | What |
|---|---|
| `contracts/test/pentest/second/E_ForwardCost.t.sol` | E-1: the costs on the Ethereum fork (USDT with the LayerZero fee, USDC), two cycles each |
| `contracts/test/pentest/second/E_StrayEdges.t.sol` | `returnStray` edges on the Arbitrum One fork (tests 1–5, sound) and E-1's recycle (test 6) |
| `contracts/test/pentest/second/E_RouterPinned.t.sol` | the router's address pinned to this build (no fork) |
| `scripts/pentest/second/E_forward_griefing_keeper.ts` | E-1: the real `Keeper` forwards 25 attacker boxes with no budget, again after a refill, and completes a 1-unit burn |
| `contracts/test/pentest/E_*.t.sol`, `scripts/pentest/E_foreign_forward_keeper.ts` | first run's proofs, re-run as regressions: 11 tests and the script pass |

### Verifications for area E

**E-1:**
```text
Verdict: CONFIRMED (Medium), with corrections to the numbers.
PoC: E_ForwardCost.t.sol 2/2 PASS (USDT 8.38× steady, 10.50× first; USDC 2.43×/4.58×; LZ fee 0.0000157 ETH); E_StrayEdges test_6 PASS (attacker gets 10 USDT back on Arbitrum; back to Ethereum costs 0.0000172 ETH); E_forward_griefing_keeper.ts A–D PASS (25 boxes → 25 forwards, 25 more after a refill, none below FORWARD_MIN, a 1-unit burn completed, no budget read or written). Own scripts/pentest/verify/V2_E-1_real_tx_costs.ts PASS (local anvil fork, real receipts): USDT transfer 63,197 gas vs relayer forward 452,351 steady / 632,563 first (+fee) → 10.1× / 13.0×; USDC 62,248 vs 145,203 / 314,314 → 2.3× / 5.0×; batched top-ups → ~15× for USDT.
Sources unchanged: yes.
Checked: real tokens/bridges, router at Networks.ROUTER via the keyless proxy; the script runs the real Keeper with honest stand-ins. keeper.ts:350 forwards every watched box with ≥ FORWARD_MIN without canFund; watched() covers every buyer-proposed draft for 30 days, no LIMIT; drafts need no signature (app.ts:502); sendWithin used only by API relays; the gasless path is bounded (deposit-authorizations need ≥ the missing amount, app.ts:858–866) so the plain transfer is the path. Bounds: none per buyer/deal/day; one tx at a time → ~1 forward per block (~7,200/day). Attacker per cycle: Ethereum top-up ~0.0000053 ETH; draft free; relayer forward + LZ fee 0.0000535 ETH; return to Ethereum 0.0000172 ETH per send (spread over a batch); capital a few thousand USDT, recovered. SPEC §12 promises a cap; not in audit §5; SPEC §14.11 / keys.md §1 acknowledge the cost without a limit.
Reason: unbounded, ~10× amplified for USDT: ~0.385 ETH/day from the relayer for 0.038 ETH from the attacker at the fork's base fee; ~3.4 ETH/day at 1 gwei; the planned 0.01 ETH Ethereum float lasts ~187 forwards (< 1 h) for ~$2.50; after that Ethereum deposits wait. No party loses money → Medium. Unproven aggravation: forwards run inside keeper.tick without a per-tick cap, so N refilled boxes hold the keeper ≥ N Ethereum blocks, delaying Arbitrum deadline calls (a seller relying on the keeper near transferDeadline could be refunded against; it can relay submitProof itself).
Overlap with G-2: inbound CCTP completions share G-2's root (completeTransfers unbudgeted); E-1 adds the deposit-chain forward and the LZ fee; both drain the same Ethereum key through the same lane, so ceilings don't add; E-1 ~3× more per tx.
Corrections: in-test gas overstates the attacker's transfer (34%) and the forward (15%) → real ratios 10.1×/13.0×; the way back to Ethereum is omitted (~2.4× if per cycle, negligible batched); E_ForwardCost.t.sol cites a nonexistent E_ForwardRecycle.t.sol (use E_StrayEdges test_6).
Fix (one design with G-2): (1) route every stranger-triggerable keeper send through sendWithin: forwards, receiveMessage on any chain, returnDeposit, the LZ value; daily budget per chain and purpose (forward:<id>, complete:<id>), reserve gas × max fee + value, a share per deal/buyer, book every send in relayer_spend; (2) forward a deposit only when it can fund the deal (deposit chain + box + in flight ≥ price, and canFund); else leave it to the buyer (forward is open; returnStray); (3) complete inbound burns below FORWARD_MIN only when they fund a deal; when a budget is used up fall back to selfComplete/completeOn; (4) cap keeper sends per tick per deposit chain and move deposit-chain work into its own loop; (5) update SPEC §12, §14.11, keys.md.
```

**E-Info:**
```text
E-I1 (keys.md describes the box wrongly): CONFIRMED (Info), wider than reported. keys.md:59-60 says only the escrow that deployed the box can sweep; the box's owner is msg.sender = the router (DepositBox.sol:16,22,30; DepositRouter.sol:204-206). keys.md:59 "no owner" and the grep claim at 61-63 are wrong (DepositBox matches). The returnStray row (keys.md:106) is pre-v0.31 (real rules DepositRouter.sol:112-113). §3 lacks DepositRouter.pull and bridge rows. Docs only. Fix: rewrite §2's box bullet (owner = router; escrow from pull's msg.sender + salt), the grep claim, the returnStray row; add pull and bridge rows.
E-I2 (stale router addresses): CONFIRMED (Info) except runbook.md:82 (a dated historical entry). dependencies.md:275 gives 0xCCb4…29Ff "on every chain"; DeployRouter.s.sol:22-23 says "v0.27" while Networks.ROUTER is v0.31 (0x8f5d…1a52). The service reads the router from the escrow (escrow.ts:173,189). Fix: correct dependencies.md:275 and the script comment; mark v0.12/v0.27 routers as older-testnet only.
E-I3 (E-2 fix has no interface/API path): CONFIRMED (Info). returnStray appears only in the service's ABI; SPEC.md:59,65 promise the buyer can take the box back; guides/pay-by-bank/page.tsx:68 says money on another network "can't be recovered" (contradicts SPEC for EVM chains). A relay can't do it for stablecoins (msg.sender must be the buyer). Fix: a "money sent on the wrong network" tool on the buyer's deal/draft page + an API unsigned transaction: read boxAddress balances on Arbitrum One/Base/Ethereum, switch the wallet's chain, call returnStray(escrow, dealId, buyer, token); relay only for ETH/other tokens off Arbitrum; fix the guide.
E-I4 (USDT inbound row ignores the buyer): CONFIRMED (Info), with side effects. bridges.ts:82 matches escrow+dealId only (Forwarded has no buyer); coming/missing count pending rows (app.ts:261-267) → the real buyer may see "Your payment is on its way" and nothing due, and may not pay until delivery is noted. The seller sees nothing (deposit panel buyer-only); the escrow never credits it to the real deal (pulls use t.buyer); costs the stranger the full price, locked for good; USDC unaffected. Extra: the row's account is the real buyer, moving its default payout chain (app.ts:915-916; only a default). Unverified: layerZeroDelivery reads only data[0] (bridges.ts:212-217). Fix: before recording, require a USDT Transfer(from=box, to=router, amount) in the same receipt; match the LayerZero message by destination box; add buyer to Forwarded in a next router version.
```


## Area F

### AI pentest, second run, area F: Deployment (commit e2b52ce, SPEC v0.31)

Scope: the two compiler pipelines (`contracts/foundry.toml`: via-IR for the escrow and the verifiers, legacy for the
router) and whether what the tests run is what deploys; via-IR codegen surprises, including solc 0.8.30's published
bug list; `DeployAll.s.sol`'s order and guards; `Networks.sol` against Arbitrum One today; `CheckDeployment.s.sol`
and everything v0.31 added to it (the promo key and end, the juror template and mappings in storage slots 6/7 against
the hashes pinned in `Networks.sol`, the verifier list decoded from the escrow's creation transaction); the new
`scripts/deploy-all.sh` (gate before record, `RESUME=1`), `scripts/rehearse-mainnet.sh`; the new router address
`0x8f5d…1a52`; the promised escrow addresses. The first run's F-1 and F-2 fixes were re-checked (all their regression
tests pass from this checkout) and the new gate was attacked.

Proofs: `contracts/test/pentest/second/F_*.t.sol` (10 tests, all passing, `forge fmt` clean: 7 on a fork of Arbitrum One at the mainnet
fork tests' pinned block 519,194,000 or at the latest block, against the real USDC, USDT0, KlerosCoreNeo and Kleros's
public resolver; 3 on a fork of Arbitrum Sepolia against the live v0.31 escrows) and
`scripts/pentest/second/F_build-checks.sh` (build-level confirmations). Run from `contracts/`, through the lock
wrapper the run prescribes:

```bash
forge test --match-path 'test/pentest/second/F_*.t.sol' -vv      # the finding and the confirmations (log: ../REPORT-F-run.log)
forge test --match-path 'test/pentest/F_*.t.sol' -vv             # the first run's 24 tests: the v0.31 fixes hold (../REPORT-F-regression.log)
../scripts/pentest/second/F_build-checks.sh                      # FOUNDRY_BROADCAST, and the no-L/E compiler differential (../REPORT-F-build-checks.log)
```

**Summary.** No way to lose, steal, lock or misdirect money through the deployment was found, and the v0.31 fixes
hold: every regression test of the first run's F-1 passes, the pinned template hashes equal what `DeployAll` renders
for the promised addresses, the gate runs before the record is written, and `Networks.sol` matches Arbitrum One at
the latest block. The gate's new verifier-list check, however, identifies the verifier only by what its getters answer
and by its address: a lookalike verifier (right attestors behind `reclaim()` and `yescrow()`, right TLD table, no
attestation checked) passes every check with "ALL CHECKS PASSED", creation arguments included, and on such a
deployment every deal can be released on facts nobody attested (F-1, Low: a gap in the safety net, as the first run's
F-1 was; the fix is a code-hash comparison in the gate's simulation). The creation transaction the gate decodes is not
tied to the escrow it is checked against (unconfirmed, with reasoning and a fix). Five Info items, the most important
being that solc 0.8.30 is affected by eight published compiler bugs, one of them a via-IR optimizer bug that can
affect any via-IR code (SOL-2026-7, fixed in 0.8.38); yescrow's sources contain none of the specific trigger patterns
of the other seven, and the compiler team's recommended differential check (a build without the two optimizer steps
involved, same tests) is in `F_build-checks.sh`.

---

#### Findings

### F-1. The launch gate identifies the verifier by its getters and its address, never by its code: a lookalike verifier passes "ALL CHECKS PASSED", creation arguments included

- **Severity:** Low. A gap in the launch gate, not an exploit path against a correct deployment: it needs a deployment
  other than the one `DeployAll.s.sol` makes (an edited script, a manual deployment, or a deploying machine or checkout
  that was tampered with), the same precondition class as the first run's F-1, which was rated Low and fixed. If such a
  deployment ever reached mainnet the impact would be Critical: a false release on any deal, as the impact test shows.
- **Attacker and preconditions:** whoever controls the deployment (or the checkout it runs from). Nothing on-chain;
  no key of `docs/keys.md` is needed beyond the deployer's.
- **Steps** (`F_GateTrustsGetters.t.sol`, fork of Arbitrum One at block 519,194,000):
  1. Deploy the router at `Networks.ROUTER` through the real keyless proxy, the two honest `RdapVerifier`s (Reclaim's
     attestor, yescrow's attestor `0xaac9…8975`), the honest `DualRdapVerifier`, and a `LookalikeVerifier` whose
     `reclaim()` and `yescrow()` return the two honest `RdapVerifier`s, whose `supportsDomain` delegates to them, and
     whose `verify(proof)` returns `abi.decode(proof, (RdapFacts))`, checking no attestation at all.
  2. Run both escrow constructors at the promised addresses `0xe13E…aBa` and `0x3A73…fbB` with `DeployAll.config()`'s
     exact mainnet configuration (the pinned promo key `0xE4e9…b8dE`, `promoEnds` 92 days out, the committed juror
     templates, SPEC §9 windows, Kleros's real resolver, a judge with code) and `verifiers = [lookalike]`. The
     constructor arguments are what the real creation transactions would carry.
  3. Run `CheckDeployment.check` as launch day runs it (`REQUIRE_KLEROS=true`, `VERIFIER` = the lookalike, since the
     record's `.verifier` is whatever the broadcast's `DualRdapVerifier` entry names). Output: `verifier ok`, `escrow
     ok` twice, `creation arguments ok: one verifier` twice, real Kleros cases simulated in courts 34 and 1 from both
     escrows, `ALL CHECKS PASSED` (`test_F1_lookalikeVerifier_passesTheGate_creationArgumentsIncluded`). The control
     test shows the honest deployment passes identically and that the lookalike answers every gate question exactly as
     the honest verifier does.
  4. Impact (`test_F1_impact_unattestedFacts_pay990USDC_forADomainThatNeverMoved`): on that deployment a buyer funds a
     1,000 USDC same-registrar deal naming the version's only verifier (the one every deal names,
     `deployments/<network>.json` `verifier`); the seller submits `abi.encode(RdapFacts)` with nobody's signature; the
     deal is `Proven`; after the 72-hour window `release` credits the seller 990 USDC and the fee wallet 10 USDC. The
     buyer's 1,000 USDC is gone for a domain that never moved.
- **Impact:** a deployment whose verifier accepts anything passes the one check that exists to catch it. The first
  run's F-1 impact (2) was exactly this rogue-verifier release; v0.31 catches a *second* verifier on the list (the
  decoded list must have length 1 and name `VERIFIER`) but not a *replaced* one, which is the simpler attack and needs
  no second entry.
- **Command:** `forge test --match-path test/pentest/second/F_GateTrustsGetters.t.sol -vv` (4 tests).
- **Root cause:** `contracts/script/CheckDeployment.s.sol`: `check` lines 149–152 read `dual.reclaim().attestor()`,
  `dual.yescrow().attestor()` and `supportsDomain` through the verifier's own getters; `checkEscrow` line 194 checks
  `e.isVerifier(x.verifier)`; `checkCreation` line 244 checks `c.verifiers.length == 1 && c.verifiers[0] ==
  x.verifier`. `x.verifier` is `VERIFIER` from the environment (`expected`, line 70), which `docs/mainnet.md` step 3
  takes from `deployments/arbitrum-one.json` `.verifier`, which `scripts/deploy-all.sh` lines 82 and 130 take from
  the broadcast's `DualRdapVerifier` entry. Nothing compares the verifier's code, nor the two `RdapVerifier`s' code,
  with this build.
- **Suggested fix:** in `check`, deploy fresh copies in the simulation (the gate sends nothing) and compare runtime
  code hashes, which include the immutables: `address(new DualRdapVerifier(dual.reclaim(), dual.yescrow())).codehash
  == address(dual).codehash`, `address(new RdapVerifier(Networks.RECLAIM_ATTESTOR)).codehash ==
  address(dual.reclaim()).codehash`, and the same for yescrow's `RdapVerifier` (`test_F1_fix_codeHashOfAFreshBuild_tellsThemApart`
  shows this tells the honest verifier and the lookalike apart and passes for the honest one). Pin yescrow's attestor
  address in `Networks.sol` as the promo key is (Info I-3), so `x.yescrowAttestor` doesn't come from the same
  environment either. The rehearsal (`scripts/rehearse-mainnet.sh`) deploys this build's verifiers, so it still passes.

---

#### Info (no proof needed, or covered by the confirmations below)

- **I-1. solc 0.8.30 is affected by eight published compiler bugs** (`docs/bugs.json` of the Solidity repository, read
  2026-10-29; the first run could not fetch it). Seven have a specific trigger that yescrow's sources don't contain:
  `require` with a custom error with named arguments (SOL-2026-6, via-IR): none in `src/`; mutual recursion with
  stack-to-memory spilling (SOL-2026-4, SOL-2026-2, via-IR): no recursion in `src/`; clearing transient and persistent
  storage in one contract (SOL-2026-1, via-IR, cancun): no transient storage (OpenZeppelin 5.4's `ReentrancyGuard`
  is the storage one); a storage-end warning (SOL-2026-3): no custom storage layout; arrays at the end of storage
  (SOL-2025-1): none; `delete` on one element of a memory `bytes` (SOL-2026-5, legacy pipeline): none in the router or
  the box. The eighth, **SOL-2026-7 "YulOptimizerKnowledgeBaseStaleBookkeeping"** (0.8.19 through 0.8.37, fixed in
  0.8.38, severity low), is a Yul optimizer bug whose trigger ("a variable used to compute a memory or storage address
  is reassigned, and addresses derived from it before and after coincide at run time") can't be ruled out from source
  under via-IR, where *any* code may be affected; the compiler team's recommended check is to build with the
  LoadResolver (`L`) and EqualStoreEliminator (`E`) steps removed from the optimizer sequence and run the same tests
  on both builds (differences indicate the bug; matching results don't rule it out). That check is part (b) of
  `scripts/pentest/second/F_build-checks.sh` (profile `nole` added to this copy's `foundry.toml`, same solc, same
  settings otherwise, separate `out-nole/`): see "Checked and found sound" for the result. Because the contracts are
  immutable, the user and the auditor should decide before launch whether to compile with solc 0.8.38 or later: a
  compiler change moves `Networks.ROUTER` (the router's CREATE2 address depends on its exact creation code, so it
  would be redeployed on every chain and re-pinned in `Networks.sol` and `DeployRouter.s.sol`), may change the escrow's
  size (I-2), and is itself a change the audit must cover.
- **I-2. EIP-170 headroom is down to 1,155 bytes:** the v0.31 escrow runtime is 23,421 bytes (`forge build --sizes`,
  the artifact, and the deployed code; 22,948 in v0.30). `docs/audit.md` §1 and `docs/security.md` already say 23,421,
  so no drift; audit fixes must stay small or the next version needs `optimizer_runs` or a split.
- **I-3. Only the promo key and the template are pinned; the other expected values still come from the deploying
  environment or the record it wrote.** `expected()` reads `VERIFIER`, `YESCROW_ATTESTOR`, `FEE_RECIPIENT` and `JUDGE`
  from the environment (`.env.mainnet` on launch day, the record for `VERIFIER`). A wrong `YESCROW_ATTESTOR` in
  `.env.mainnet` deploys verifiers that trust a key nobody holds (no proof ever passes on mainnet; every deal ends in a
  dispute or a refund, no loss) and passes the gate, since `DeployAll` and the gate read the same variable. The
  attestor (`0xaac9…8975`) and the fee recipient (`0x6E91…2449`) are decided and listed in `docs/keys.md`: pin both in
  `Networks.sol` next to `MAINNET_PROMO_SIGNER`, so the gate compares the deployment with the repository, not with the
  environment.
- **I-4. The judge is only checked to have code.** `DeployAll.check` (line 136) and `checkEscrow` (line 198) require
  `judge.code.length != 0`; any contract passes, including one that can never send a transaction (a token, a stray
  proxy), in which case every refused or stalled case splits 50/50 after 14 days (the lost-key outcome of
  `docs/audit.md` §5, not worse). The rehearsal's stand-in judge is `0x…1d6e` with code `0x00`, so the rehearsal can't
  catch it either. Suggest the gate read Safe's `getThreshold()` and `getOwners()` (at least 1 and 2 on mainnet, or
  `VERSION()`), with a minimal Safe-shaped stand-in in `rehearse-mainnet.sh`.
- **I-5. A mainnet deployment without a promo key fails the gate after deploying.** `DeployAll` allows
  `PROMO_SIGNER` unset (line 128/140), but on Arbitrum One `expected()` pins `promoSigner` to
  `Networks.MAINNET_PROMO_SIGNER` (line 77) and `checkEscrow` requires equality (line 208). With the NAMEPROS
  decision this is right; if the decision changes, `Networks.sol` must change first, or the deployer's nonces are spent
  on a version the gate refuses to record (`RESUME=1` re-runs the same gate). Also still open from the first run: a
  partially sent `DeployAll` (a CREATE dropped after the verifiers) has no runbook step; `forge script
  script/DeployAll.s.sol:DeployAll --rpc-url … --resume` re-sends the unsent transactions with their recorded input
  (the templates were rendered for the right addresses in the simulation), and the promised addresses still hold as
  long as the deployer sent nothing else.

---

#### Checked and found sound

**The v0.31 fixes of the first run's F-1 and F-2 hold** (`REPORT-F-regression.log`): all 24 first-run `F_*` tests and
the 3 `V_F-1_controls` tests pass from this checkout. In `F_CheckDeploymentGaps`: the committed template hashes in
`Networks.sol` equal what `DeployAll.config()` renders for each promised address (1,428 bytes, mappings empty); the
right deployment passes; a wrong promo key, a late `promoEnds` (+365 days on a 92-day promo, `2^64-1`, `PROMO_DAYS`
over 366), a one-letter template change, the USDC template at the USDT address, non-empty mappings, a second
verifier, a lone wrong verifier, a missing creation transaction and an escrow off the promised addresses each fail with
their own message, and arguments of another deployment fail "creation arguments differ". `scripts/deploy-escrow.sh`
line 87 and `docs/mainnet.md` line 83 now verify the router with `--compilation-profile default` (F-2).

**The gate's new mechanism on a real chain** (`F_Sound.t.sol` part 1, fork of Arbitrum Sepolia at the latest block).
The creation transactions `deployments/arbitrum-sepolia.json` records for the live v0.31 escrows `0xEE55…BB4D` (USDC)
and `0xEaFD…6933` (USDT), decoded through the gate's own `constructorArgs` (`vm.rpc` `eth_getTransactionByHash`, the
scan for this build's creation code; commit 9a24d75 that deployed them has the same contract sources as e2b52ce), give
exactly one verifier, the record's DualRdapVerifier `0x7Fa1…7691`, the record's promo key `0x9281…8240` and end
`1808839159`, the 1,430-byte template that storage slot 6 holds and empty mappings in slot 7; `creationArgs` with no
`ESCROW_TXS` finds the same transactions in the record file; and the live deployment passes `check` from this
checkout, with Kleros's Sepolia resolver creating a case from each escrow in both courts.

**Storage slots.** `forge inspect YescrowEscrow storage-layout`: OpenZeppelin's `_nameFallback` (0), `_versionFallback`
(1), `_status` (2), then `arbitratorExtraData` (3), `smallDealArbitratorExtraData` (4), `isVerifier` (5),
`templateData` (6), `templateDataMappings` (7): `TEMPLATE_SLOT = 6` is right, and `checkCreation` compares the slot's
content with the decoded arguments, so a layout change fails the gate instead of passing it. `storedString` handles
short and long strings (the 1,428-byte template is long; the empty mappings are short).

**`Networks.sol` against Arbitrum One today** (`F_Sound.t.sol` part 2 at the latest block, and `cast` at block
519,496,325 on 2026-10-29): USDC `0xaf88…5831` and USDT0 `0xFd08…Cbb9` with 6 decimals, USDT0's name `USD₮0`;
KlerosCoreNeo `0x991d…22ea`; Kleros's resolver `0xb552…4323` on the allow-list, pointing at the core and at the
template registry `0x0cFB…a5A2`; courts 1 (parent 0, 0.005 ETH per juror) and 34 (parent 33, 0.00027 ETH), both
enabled and supporting dispute kit 1; `arbitrationCost(abi.encode(1,1,1))` = 5e15 and `(34,3,1)` = 8.1e14 wei, what
the escrows' extraData encodes; the evidence module has code; floors 24 h / 72 h / 72 h are SPEC §9's minimums. The
mainnet deployer `0xA8C2…D14D` has nonce 0 and balance 0; `CREATE(deployer, 3)` and `(deployer, 4)` are the pinned
escrow addresses and both are still empty; the keyless CREATE2 proxy is present; the router is not yet on Arbitrum
One (nor the v0.12 router `0xCCb4…29Ff`). USDT0 has no `version()` (it reverts): `deploy-all.sh`'s record then says
`"1"`, which is USDT0's real EIP-712 domain version (`ArbitrumOneUsdt0Fork.t.sol` rebuilds its `DOMAIN_SEPARATOR` from
`"USD₮0"`, `"1"`), so USDT funding signatures built from the record will verify.

**The new router address.** `CREATE2(proxy, keccak256("yescrow deposit router v1"), keccak256(init))` of the legacy
artifact `out/DepositRouter.sol/DepositRouter.json` (6,842 init bytes, hash `0x0ca2…7017`) is
`0x8f5d140d1ab4158Bf259D8C909C803A651Fb1a52` = `Networks.ROUTER` = `DeployRouter.ROUTER`; the init code in the
recorded Sepolia broadcast has the same hash; the code live at that address on Arbitrum Sepolia and Base Sepolia
(5,957 bytes) equals this build's runtime after masking its two immutables, and its `boxCodeHash()` is the legacy
`DepositBox` init hash `0x1e06…f2a0`. The via-IR router build would land elsewhere with another box hash, and
`DeployRouter.run` refuses it (first-run test re-run). Whatever lands at `ROUTER` through the proxy has exactly this
code, so the gate's `code.length != 0` check is enough there.

**What the tests run is what deploys.** `type(YescrowEscrow).creationCode` in a via-IR-compiled test equals
`vm.getCode` of the artifact (also for the two verifiers, first-run test re-run); the artifacts' settings are
`viaIR true` for the escrow and the verifiers, legacy for the router and the box, 200 runs, cancun, no metadata hash,
no CBOR; `DeployAll.s.sol` and `CheckDeployment.s.sol` compile in the via-IR job (they import the escrow), so the
creation code the gate scans for is the one `DeployAll` deploys. The v0.31 source changes (`git diff 3ca0628 e2b52ce
-- contracts/src`) add no assembly, `unchecked`, increments or other multi-effect expressions, recursion, transient
storage or `require` with custom errors.

**`DeployAll.s.sol`** (first-run guard test re-run on the fork): the launch settings pass; each of the thirteen wrong
settings is refused with its own message; mainnet defaults 24 h / 72 h / 72 h, 1%, 25%, 2,000e6, no promo key unless
set; the deploy order with `computeCreateAddress(deployer, getNonce)` before each escrow and the post-deploy
`require`s bind each template to its own escrow and both escrows to the pinned addresses.

**`scripts/deploy-all.sh` v0.31.** The gate runs (line 93) before the record is written (line 122) and
`REQUIRE_KLEROS` is no longer overridden (the gate's own default, true on Arbitrum One, applies; the rehearsal passes
true explicitly). `RESUME=1` fails closed on a stale broadcast whose hashes aren't on the chain (`cast receipt` of the
first verifier's hash, then `vm.rpc` in the gate, error out), on a partially sent run (a missing escrow entry makes
`cast to-check-sum-address null` fail) and on a CREATE that reverted (no code: "escrow: no code"); `promoEnds` is
computed before deploying, so it is always within `PROMO_DAYS` of the deployment time the gate reads from the first
verifier's block; in `RESUME` it is read back from the chain. The mainnet guards (`CONFIRM_MAINNET`, no existing
record, the RPC's chain id) and the rehearsal's local-RPC-only rule are as documented. The record's `promoSigner`,
`promoEnds` and `router` are informational: the service reads the escrow's promo values from the chain
(`service/src/escrow.ts`) and the escrows' `router()` is checked against `Networks.ROUTER`.

**`FOUNDRY_BROADCAST`** (`F_build-checks.sh` part (a)): honoured. The real `DeployRouter` script, run against a throwaway local anvil chain (31337, anvil's own
test key, the keyless proxy's code copied there) with `FOUNDRY_BROADCAST` set, deployed the router at `0x8f5d…1a52`
and wrote its record under that directory only; nothing appeared under `contracts/broadcast/` for chain 31337. So the
rehearsal's records and broadcasts (`deploy-all.sh` line 40, `rehearse-mainnet.sh` line 29) stay out of the repository,
and a `RESUME=1` on launch day can't pick up a rehearsal's broadcast (`REPORT-F-build-checks.log`, part (a)).

**The compiler differential for SOL-2026-7** (`F_build-checks.sh` part (b)): the no-L/E build is a real via-IR build of the same sources with solc 0.8.30 and the same
settings except the optimizer sequence (its artifact metadata records the custom steps; the escrow is 23,643 bytes
instead of 23,421, the verifiers' bytecode differs too, the legacy router is byte-identical). Every test of the project
ran on it (60 suites, 525 tests: unit, fuzz with 64 runs, invariant, the Arbitrum One, Sepolia and multi-chain fork
tests with the real proofs of all TLDs and real Kleros cases, the first run's and this run's pentest tests): 522
passed, 3 failed, and the 3 are `F_Sound.t.sol`'s tests that decode the live testnet escrows' creation transactions,
which the gate refuses with "doesn't create this build's escrow" because the no-L/E creation code isn't the one that
deployed them (a confirmation of that check, not a difference in behaviour). On the default build all 525 pass. By the
compiler team's own caveat this doesn't rule SOL-2026-7 out (U-2), but no behaviour that the test suite covers depends
on the two optimizer steps involved. A first run with a `code_size_limit` configured made 18 suites fail at
construction (forge 1.8.5 enforces EIP-170 and EIP-3860 in tests only once a limit is configured, and the test
contracts are 25–176 KB in both builds); the log shows both runs (`REPORT-F-build-checks.log`, part (b)).

**Kleros gas budget** on the via-IR build against the real resolver (first-run `F_RealKlerosGasFloor` re-run, 4 tests):
unchanged by v0.31's escrow changes.

---

#### Unconfirmed

- **U-1. The creation transaction the gate decodes is not tied to the escrow it is checked against.**
  `constructorArgs` (`CheckDeployment.s.sol` lines 114–144) takes any transaction and returns whatever follows this
  build's creation code in its input, wherever it finds it word-aligned behind a length word: a CALL to any address, a
  factory call, a transaction from any sender all qualify, and neither `to`, `from`, `nonce` nor the receipt's
  `contractAddress` is looked at. `checkCreation` (lines 237–257) then compares only the template, the mappings, the
  promo key and end, the fee recipient and the judge with what the escrow reads back; the verifier list is taken from
  the decoded arguments alone, because a mapping can't be enumerated. So a transaction that merely carries the creation
  code followed by the honest arguments for that escrow address, named as the escrow's `hash` in
  `contracts/broadcast/DeployAll.s.sol/42161/run-latest.json` (`RESUME=1`, `txh` line 81) or as `.escrows[].transaction`
  in `deployments/arbitrum-one.json` (step 3, `creationArgs` lines 92–105), would make the gate print "creation
  arguments ok: one verifier" for an escrow actually deployed with more verifiers. Reasoning only: an end-to-end proof
  needs a wrong deployment and an edited broadcast staged on a local fork, which I did not build in this run; the
  mechanism itself is exercised against the real chain in `F_Sound.t.sol`. Preconditions are the same as F-1's (a
  deployment that isn't `DeployAll`'s, plus an edited broadcast or record), and F-1 already achieves more with less, so
  this is at most Low. Fix: require, from the same `eth_getTransactionByHash` result, `to == null` and
  `vm.computeCreateAddress(from, nonce) == escrow` (on Arbitrum One also `from == Networks.MAINNET_DEPLOYER` and nonce
  3 or 4), or read `eth_getTransactionReceipt`'s `contractAddress`.
- **U-2. Whether SOL-2026-7 is in yescrow's exact bytecode** can only be settled the way the compiler team describes:
  build solc 0.8.30 with PR 17116 applied and compare the output, which was out of reach on this machine. The
  differential test above is the next best check and is not a proof of absence.
- **U-3. Court 34's name and policy** ("Agentic Commerce Court") and the IPFS policy's content behind the pinned
  template hash are off-chain; on-chain only the court's parameters and the `policyURI` string are checked.

---

#### Files

- `contracts/test/pentest/second/F_GateTrustsGetters.t.sol`: F-1 (control, the finding, the impact, the fix).
- `contracts/test/pentest/second/F_Sound.t.sol`: the gate's mechanism against the live testnet deployment; Arbitrum One
  today; the build.
- `scripts/pentest/second/F_build-checks.sh`: `FOUNDRY_BROADCAST`; the no-L/E differential build (profile `nole`,
  added at the end of this copy's `contracts/foundry.toml`; `out-nole/` and `cache-nole/` are its build output).
- Logs: `REPORT-F-run.log`, `REPORT-F-regression.log`, `REPORT-F-build-checks.log`.
- Nothing under `contracts/src/`, `contracts/script/`, `scripts/*.sh` or `docs/` was changed.

### Verifications for area F

**F:**
```text
F-1 — Verdict: CONFIRMED (Low)
PoC: F_GateTrustsGetters.t.sol 4/4 PASS (Arbitrum One fork): the lookalike verifier gets "ALL CHECKS PASSED" with creation arguments checked and real Kleros cases 328–331; the seller is credited 990 USDC on unattested facts; the honest control passes; the fix test tells them apart. Sources unchanged: yes (only test/script pentest dirs, REPORT-F*, verifier V2 tests, an appended [profile.nole]).
Checked: check/checkCreation read the verifier only through getters (reclaim(), yescrow(), attestor(), supportsDomain) and the decoded address; nothing compares code. DeployAll always deploys the real verifiers; honest mistakes are caught (a mock has no reclaim(); a test key fails the attestor check); only a deliberate lookalike passes. Precondition: a deployment not made by DeployAll from the audited commit (edited script, manual deployment, tampered checkout) — same class as run 1's F-1. Own verify/V2_F-1_codeHashFix.t.sol 2/2 (Arbitrum Sepolia fork, live v0.31): fresh builds match the live verifiers' code hashes exactly; a fresh `new` escrow doesn't match (OZ EIP712 immutables: address(this), domain separator); running this build's creation code with the decoded arguments at the escrow's own address (vm.etch + call inside snapshotState/revertToState) reproduces the live runtime byte for byte.
Reason: Critical outcome if shipped, but no outsider can cause it and the honest path can't produce it → Low. The gate runs from the same checkout, so the fix protects when the gate itself is genuine (e.g. the auditor reruns it from the audited commit).
Fix: verifiers: compare codehash of new RdapVerifier(Networks.RECLAIM_ATTESTOR), new RdapVerifier(<pinned yescrow attestor>), new DualRdapVerifier(dual.reclaim(), dual.yescrow()) (equality proves immutables); optionally pin mainnet verifier addresses (deployer nonces 0–2) and code hashes in Networks.sol; escrows: rebuild at the escrow's own address and compare runtime (hash can't be pinned: promoEnds and judge fixed at deploy); make source verification blocking on mainnet (deploy-all.sh uses `|| true` after the record).
I-1 (solc 0.8.30 bug list): true, Info; eight listed bugs; no trigger for seven (no recursion cycles, transient, delete, storage layout specifier, require with custom error); SOL-2026-7 see below.
I-2 (EIP-170 headroom 1,155 B): true, Info.
I-3 (attestor and fee recipient not pinned): true, Info: pin YESCROW_ATTESTOR and FEE_RECIPIENT in Networks.sol, JUDGE once the Safe exists.
I-4 (judge only checked for code): true, Info: read the Safe's getThreshold() ≥ 2 and owners, or pin it; give the rehearsal a Safe-shaped stand-in.
I-5 (no-promo deployment fails the gate): true, Info, wider: DeployAll checks none of the gate's mainnet pins before sending; a wrong promo key or POLICY_URI burns the deployer's nonces and the promised addresses before the gate refuses. Run the pinned comparisons in DeployAll.check before broadcasting.
U-1 (creation tx not tied to the escrow): CONFIRMED, Low. verify/V2_F-U1_untiedCreationTx.t.sol 2/2 (Sepolia fork): naming the live USDC escrow's creation tx for another escrow: an escrow that also allows an accept-anything verifier gets "creation arguments ok: one verifier"; an escrow with a backdoor gets "ALL CHECKS PASSED" and a stranger withdraws its 1,000 USDC. Precondition: F-1's plus an edited record/broadcast file. Fix: (1) the rebuild-at-address check; (2) require the named tx to be the escrow's own creation: `to` null, `from` the mainnet deployer at nonce 3 or 4 (or receipt contractAddress == escrow), and input == creation code + arguments exactly (today's scan accepts the code anywhere in the input); parse fields (cast tx --json, cast receipt) instead of scanning.
Compiler recommendation: move to 0.8.38 before the audit freezes the commit. Compiled this commit's exact inputs with official binaries (0.8.30 reproduces forge's artifacts): 0.8.37 vs 0.8.38: one dispatcher instruction differs, everything else identical apart from shifted addresses → the SOL-2026-7 fix changes nothing in yescrow's code (the Solidity team's "strong indication" check, not a proof for 0.8.30). 0.8.30 vs 0.8.37: verifiers identical; the escrow differs only in a storage-clearing loop (SOL-2025-1 fix, can't trigger here). The router and box are byte-identical on 0.8.30/0.8.37/0.8.38 (router stays 0x8f5d…1a52); the escrow grows to 23,429 B (headroom 1,147); each verifier shrinks 1 B; mainnet escrow addresses and template hashes unchanged. Work: pragma + solc_version, tests, Slither, rehearsal, testnet redeploy, audit scoped to 0.8.38. Reasons: the Solidity team advises upgrading before deploying Yul-optimized contracts; immutability; Arbiscan shows the known-bug list. 0.8.38 is 9 days old: recheck its bug list before launch.
```


## Area G

### AI penetration test, second run (SPEC v0.31, commit `e2b52ce`) — Area G: off-chain parts

Scope (area G): only where an off-chain part could make a party lose money or sign something it didn't mean — the
relayer, the keeper's automatic acceptances and payouts, the prover's parameter checks, the attestor patch, sanctions
screening, the web app's signing dialogs; and, new in v0.31: the global relayer budgets (`budgets.ts`, migration 013),
deposit authorizations (issued nonces + balance check), sponsored fees net of refunds with a per-deal share and a
minimum age (`sponsor.ts`), the keeper's sanctions screening of deposits and transfers, the keeper's appeal stakes
(`appeals.ts`, migration 014), completion of forwards made by anyone and read-only follow-up of self-complete payouts
(`bridges.ts`, `keeper.ts`), and the typed data with phase/stage and the 1-hour action-deadline cap.

**Headline.** The v0.31 fixes in my area **hold**: I re-ran the regression suite for them
(`service/test/relayer-caps.test.ts`, 10/10 pass) and re-derived each fix against SPEC v0.31 and the contracts. I found
**no way for an off-chain part to steal, lose, double-pay or misdirect a buyer's or seller's escrowed funds, and no way
to make a party sign a harmful payload.** The off-chain services still hold no key that can move a deal's money
(`docs/keys.md`), and the contract rejects anything the relayer/keeper would submit that a signature or deadline didn't
authorise.

The two reportable issues are both **Low**, and both are gaps the v0.31 fixes left rather than things they broke:
1. **G-1** — the new appeal-stake protection (the fix for last run's High finding D-1) is silently skipped when the
   sanctions screen is unavailable or a party is listed, and the operator alert does not cover those two cases, so the
   D-1 flip reopens with nobody who would act being told.
2. **G-2** — USDC (CCTP) cross-chain withdrawals and the keeper's `receiveMessage` completions draw on **no** daily ETH
   budget, although SPEC §12 says cross-chain withdrawals "draw on global daily budgets"; the relayer's destination-chain
   gas is bounded only by `RELAY_BRIDGE_MIN` and the per-IP rate limit (the spend-rate ops alert is the backstop).

No Critical, High or Medium. The bulk of the review is in "Checked and found sound".

---

#### Finding G-1 (Low) — The appeal-stake protection (last run's D-1 fix) is skipped, and the operator is not alerted, when the sanctions screen is unavailable or a party is listed

**Attacker and preconditions:** the losing party of a Kleros vote in a deal of a v0.30+ escrow, during that case's
appeal period, **while either party is on OFAC's SDN list, or while the sanctions list is not loaded** (a cold start
before the first fetch; `screenOf.isListed` throws `SCREENING_UNAVAILABLE` only when no list has ever loaded, so in
steady state this variant is a narrow window). `APPEAL_DAILY_WEI > 0` (the protection is on).

**Root cause:** `service/src/appeals.ts`.
- `decide()` (lines 106–117) returns `{ fund: false, reason: 'SCREENING_UNAVAILABLE' }` when `listed === null` and
  `{ fund: false, reason: 'SANCTIONED' }` when `listed === true`, so `protect()` does **not** call `fundAppeal`; the
  jurors' ruling is left unfunded.
- `protect()` (line 184) sets `listed` to `null` if **either** party's `isListed` rejects, and to `true` if either is
  listed (`l.some(Boolean)`).
- `appealProblems()` (lines 331–343) — the operator alert (`ops.ts` reads it) — selects only rows whose
  `stake_reason = ANY(OPS_REASONS)`, and `OPS_REASONS` (lines 324–328) is `{ BUDGET, RELAYER_LOW, FAILED }`. A round left
  unstaked for `SCREENING_UNAVAILABLE` or `SANCTIONED` is therefore **never surfaced to the operator**, although
  SPEC §14.21 says "an ops alert when a stake couldn't be placed."

**Steps / impact:** jurors rule for the honest winner (say ruling 1, seller delivered). In the exposed window the keeper
does not stake the jurors' side. The losing buyer funds a one-sided appeal for the other option (≈ `3 × appealCost`,
about 0.0057 ETH in the AI court) within the period's first half. If the winner does not also fund the jurors' side,
Kleros rules for the only funded option at period end — the exact D-1 flip the v0.31 fix was meant to close — and the
escrow pays the loser. The honest party loses the domain (already transferred) and the payment. The operator is not told
the ruling is unprotected.

**Mitigant (stated honestly):** the per-party alert (`alerts.ts` `appealAlerts`) still fires for a `skipped` status and
tells a **subscribed** party to fund the jurors' side itself ("yescrow couldn't fund the jurors' side … or the ruling
flips"). So the residual exposure is to the **operator** and to a party that did not subscribe to alerts. And the
`SANCTIONED` trigger is not attacker-chosen (OFAC listing), while `SCREENING_UNAVAILABLE` is essentially a cold-start
window. That is why this is **Low**, not the High that D-1 itself was.

**PoC command** (from the copy root, no chain or database):
`node scripts/pentest/second/G_appeal_screen_gap.ts`
→ `decide(screen down) = {"fund":false,"reason":"SCREENING_UNAVAILABLE"}`, `decide(party listed) =
{"fund":false,"reason":"SANCTIONED"}`, and `appealProblems` reports only the `BUDGET` round (`appeal:103:0`), never the
two screening rounds. Drives the real `decide()` and `appealProblems()`.

**Suggested fix:** in `appeals.ts`, add `SCREENING_UNAVAILABLE` to `OPS_REASONS` (a screen outage over an appeal window
is operator-actionable: retry, or fund on Kleros by hand), and consider adding `SANCTIONED` too (so the operator knows a
listed deal's ruling is unprotected and can decide whether to fund the jurors' side, which is legal — it funds Kleros,
not a sanctioned party). The party alert should stay as the first line.

---

#### Finding G-2 (Low) — USDC cross-chain withdrawals and the keeper's `receiveMessage` completions draw on no daily budget, unlike USDT

**Attacker and preconditions:** anyone with a withdrawable USDC balance of at least `RELAY_BRIDGE_MIN` (20 USDC on
mainnet) in a USDC escrow with a router and `DEPOSIT_CHAINS` set (mainnet: Base, Ethereum). No deal needed (a balance can
be had with `returnDeposit`, as last run's B-1 showed).

**Root cause:** `service/src/api/app.ts`.
- `withdrawFee()` (lines 300–311) returns `0n` for any non-USDT token, so a USDC cross-chain `withdrawTo` has
  `value === 0`.
- the `withdrawTo` relay branch (lines 1220–1224): `value > 0n && !dryRun ? sendWithin(lz-fee budget, …) :
  relayer.call(…)`. With `value === 0`, USDC cross-chain withdrawals take the **plain `relayer.call`** path — no budget
  reservation. Only USDT (which carries a LayerZero `msg.value`) is metered by the `lz-fee` budget.
- the destination mint is then completed by the keeper: `keeper.completeTransfers()` → `bridges.completeCctp()` →
  `relayer.call('receiveMessage', …)` (`bridges.ts` line 205), also with **no budget**. The same unbudgeted
  `receiveMessage` path serves inbound deposit completions.

So the ETH the relayer spends on USDC cross-chain traffic — Arbitrum `withdrawTo` gas plus destination `receiveMessage`
gas (notably on Ethereum, ~$1–10/mint) — is bounded only by `RELAY_BRIDGE_MIN` (20 USDC, consumed per withdrawal) and
the 20/min per-IP relay rate limit, with no daily cap. This contradicts SPEC §12 ("relayed withdrawals to another chain
and relayed deposit authorizations … draw on global daily budgets") and the `docs/keys.md` §4 claim that "everything it
pays for has a cap".

**Impact:** griefing of yescrow's own ETH; **no party loses money and the attacker gains nothing** (the withdrawn USDC
goes to the attacker's own address on the destination chain). The attacker must recycle each ≥ 20 USDC balance back to
Arbitrum at their own expense, which is cheap on Base (so low-value griefing there) and about symmetric on Ethereum (so
self-limiting there). **Backstop:** `ops.ts` raises `spend:<chainId>` when the relayer's hourly ETH spend exceeds
`RELAYER_SPEND_ALERT_WEI` (sponsored fees and appeal stakes excluded, plain gas not), so a fast drain is reported — the
operator is alerted, though nothing is capped. This is why it is **Low**.

**PoC:** none runnable (the branch is inline in `buildApp`; a full local stack would be needed). Confirmed by reading:
`withdrawFee` returns `0n` for USDC (checked live: `e.token.symbol !== 'USDT'` → `0n`), so the `value > 0n` budget branch
is never taken for USDC, and `completeCctp`'s `receiveMessage` is a bare `relayer.call`. `.env.mainnet.example` sets
`DEPOSIT_CHAINS=8453,1` and `RELAY_BRIDGE_MIN=20000000`, so the route is live on mainnet.

**Suggested fix:** meter USDC cross-chain `withdrawTo` and the keeper's `receiveMessage` completions on a per-chain daily
gas budget too (e.g. reuse `depositRelayDailyWei(chainId)` or add a `bridge-gas:<chainId>` budget via `sendWithin`), or
state in SPEC §12 that USDC cross-chain traffic is bounded by `RELAY_BRIDGE_MIN` and the spend-rate alert rather than a
hard budget. A per-chain floor higher than 20 USDC on Ethereum would also help.

---

#### Checked and found sound

- **The v0.31 budget fixes hold (B-1, B-2).** `budgets.ts` `reserveSpend` inserts the worst-case cost (`gas×1.2×maxFee +
  value`) **before** sending and deletes it only if the 24-hour sum is still within the limit, so concurrent requests
  fail closed; `sendWithin` books the real cost on success, keeps the reservation on a mined revert (gas was spent) and
  frees it on a pre-send failure. The `lz-fee` (USDT cross-chain) and `deposit-gas:<chainId>` (gasless deposits) budgets
  are each their own pool. `relayer.ts` keeps the keeper's lane uncapped and ahead of the API's, and caps the API lane at
  `RELAY_QUEUE_MAX` (`RELAYER_BUSY`), so an API flood can neither stall the keeper nor grow without bound. Re-ran
  `service/test/relayer-caps.test.ts`: 10/10 pass.
- **Sponsored fees can't be drained past the budget (G-1, run 1).** `sponsor.ts` counts a closed deal's fees net of what
  the escrow refunded (`refundOf` matches `_close`'s held-fee split exactly), caps one deal at `SPONSOR_DEAL_SHARE_BPS`
  (25%) of the day's budget, requires `SPONSOR_MIN_DEAL_AGE_HOURS` (6 h) of activity, reserves each fee before sending
  (`reserve` with `ON CONFLICT DO NOTHING` + a post-insert budget re-check), and `ops.ts` alerts when deals younger than
  3 days take half the budget. The residual (shams that reach Kleros cost ≈ the forwarded cost each, within the budget)
  is the documented, bounded behaviour. The budget is 0 in `.env.mainnet.example`, so sponsoring is off until the
  operator turns it on.
- **The keeper screens its deposit and transfer steps, failing closed (G-2, run 1).** `keeper.clear()` treats both a
  listed address and a thrown screen (`SCREENING_UNAVAILABLE`) as "skip", and gates `propose`/`accept`
  (`deposits()`), `forward` (`forwardFromDepositChains()`) and the outbound `receiveMessage` recipient
  (`completeTransfers()`). No funds reach a listed party on these paths; the inbound/self-complete read-only follow-up
  moves nothing. Deadline calls stay unscreened, which only protects the other party.
- **Forwards by anyone are completed, per message, without redirection (E-1, run 1).** `bridges.forwardsIn` records one
  row per burn keyed by `(source_tx, source_index)` for watched deal boxes only; `completeCctp` completes only CCTP
  messages whose `mintRecipient` equals the row's box and that aren't spent yet, so a stranger's `forward` is finished to
  the deal, never elsewhere. The relay `completeTransfer` resolves boxes from the router's own `Forwarded` events, so it
  can't mint to an arbitrary recipient.
- **Typed data is bound to the dispute phase and stage, and action deadlines are capped (B-3, B-4).** `typed-data.ts`
  adds `phase` to every action and `stage` to settlements for v0.31 escrows, hashed exactly as the escrow hashes them
  (verified in the regression test); `actionPhaseOf`/`settlementStageOf` read the contract's own `actionPhase`/
  `settlementStage`. The API refuses an action deadline more than `MAX_ACTION_DEADLINE` (3600 s) ahead at both the
  typed-data GET (app.ts:822) and the relay POST (app.ts:1014), and `confirm`/`cancel` re-check the on-chain state when
  the relayer's turn comes (`DEAL_STATE_CHANGED`). A stale action or a lapsed offer only forces a re-sign, never a loss
  (the fee period is 72 h, offers are re-signable).
- **Automatic payouts still can't over-pay, double-pay or be redirected (SPEC §14.14).** `payouts.ts`/`keeper.sendPayouts`
  submit the party's own `WithdrawTo` (recipient = account = signer, one-time by digest, deadline a year out), skip when
  the deal credited less than the signed amount or closed as `Settled`/split, screen the recipient, and let the contract
  re-check signature/deadline/one-time/balance. The `withdraw` relay refuses while a matching payout is `waiting`
  (`PAYOUT_PENDING`, A-2).
- **Deposit authorizations are issued, balance-checked and one-shot (B-2).** `POST /v1/deposit-authorizations` issues a
  random nonce for (deal, sender, value, validBefore) only while the deal can be funded and for at least what it still
  needs and the chain's `FORWARD_MIN`; the relay path sends only an issued, unused nonce whose signer still holds the
  value, marking it used before sending and freeing it only if nothing was sent.
- **ERC-6492 deploys stay on the allow-list (B-5 / §14.16).** `erc6492.ts` deploys only through the two Coinbase
  factories and only when the factory call creates the signer's own address; `dryRun` skips the deploy; a deploy is
  bundled with a step that also needs the party's valid signature, so it can't be triggered for free.
- **The prover's parameter check and the attestor patch are unchanged and sound.** `prover.ts` pins
  `parametersFor(verifier, domain)` so a deal stays provable on its own verifier version; the on-chain verifier rebuilds
  and signature-checks the parameters, so a crafted `responseMatches` can't forge an on-chain-valid proof.
  `attestor/yescrow-hardening.diff` still refuses pre-3.2.0 clients, partial server-record reveals and incomplete
  responses, before body parsing and signing.
- **The web signing dialogs show exactly what is signed, including the new fields.** `SignReview.tsx` renders every
  EIP-712 field with the raw payload in a details pane and labels the v0.31 `phase`/`stage` ("Works only while the
  dispute is at …"); `Warnings.tsx` gates the confirm button on acknowledgement; the relayer-can't-change-this note is
  accurate.
- **Admin / promo / referrer paths touch only yescrow's own fee**, and the judge service holds no key (builds the judge
  wallet's tx only) — re-verified against `docs/keys.md`; unchanged from run 1.
- **Deposit-chain config and `MAX_AUTO_*` windows** match the contract caps, so a deposit-paying buyer is never auto-
  accepted into a window longer than SPEC §9 promises.

#### Unconfirmed (reasoning, no runnable proof)

- **Keeper USDT forwards and inbound `receiveMessage` are unbudgeted too** (`keeper.forwardFromDepositChains`:
  `relayer!.call('forward', …, value)` with the USDT0 LayerZero fee, no `sendWithin`; and `completeTransfers`'s inbound
  `receiveMessage`). Same class as G-2, but self-limiting: both are triggered by real money (≥ `FORWARD_MIN`) arriving at
  a watched deal box, which then funds a deal or is returned, so the attacker's funds don't come back freely. Not pursued
  as a separate finding; the spend-rate ops alert covers it.
- **`reserveSpend` can overshoot a budget by one in-flight request.** The INSERT and the `SELECT SUM` are separate
  statements (read-committed, auto-commit), so if a second request's INSERT lands after the first's SELECT, the first can
  pass counting only itself. The overshoot is one request's worst-case cost and the money is yescrow's own ETH; the
  pattern is the intended one and matches `sponsor.reserve`. Not a finding.
- **Appeal-budget accounting counts gross stakes for 24 h** (`appeals.spentToday` sums `stake_wei` regardless of
  `withdrawn_wei`). An attacker who reaches many appeal periods could hold `APPEAL_DAILY_WEI` at its cap, denying
  protection to other disputes. But reaching an appeal period requires a real, voted Kleros case (slow and costly), the
  stake is genuinely locked in Kleros for the window, and the shortfall is operator-alerted (`BUDGET`). Within the
  documented limit of §14.21.

### Verifications for area G

**G-1:**
```text
Verdict: DOWNGRADED to Info
Proof of concept: `node scripts/pentest/second/G_appeal_screen_gap.ts` (attack copy root): PASS, exit 0. decide(screen down) = {fund:false, reason:SCREENING_UNAVAILABLE}; decide(party listed) = {fund:false, reason:SANCTIONED}; appealProblems reported only the BUDGET round. Drives the real decide()/appealProblems(); the db stub matches the real SQL filter. The "flippable" step is only asserted (funded.length === 0, now < end) and ignores the keeper's retry.
Sources unchanged: yes (diff -r vs base: only REPORT-G.md, scripts/pentest/second/, an empty contracts/test/pentest/second/).
What I checked: appeals.ts 106–117, 184, 324–343 (cited lines right); ops.ts 135–141 raises a `sanctions` ops alert when no list is loaded ("every write to the API is refused"), same OPS_EMAIL channel; main.ts:26 loads the last good list from Postgres before any loop; alerts.ts appealAlerts warns both parties whenever yescrow didn't fund the jurors' side and again when the other side is funded; SPEC §14.21 "doesn't fund cases of listed parties"; appeals.test.ts asserts both skips. Own script scripts/pentest/verify/V2_G-1_screen_heals.ts (real Ops.problems() and Appeals.tick() with stubs): A) no list → the sanctions alert; B1) first tick skips; B2) loser funds alone, list arrives later → next tick sends fundAppeal(42,1) with 0.00378 ETH, both sides funded, no flip.
Reason: SCREENING_UNAVAILABLE: the operator is told (louder alert), the keeper retries every interval and can stake until the period ends (36 h / 108 h once one side funds), so a flip needs a database that never downloaded OFAC's list for a whole appeal period — not attacker-controlled. SANCTIONED: the skip is specified (SPEC §14.21) and tested; listing isn't the attacker's choice; both parties are alerted and the deal page offers "Fund it". What remains is a visibility gap with no attacker-controlled loss path: Info; the residual (an unfunded winner must fund itself) is the accepted limit in audit.md §5 / SPEC §13.5.
Corrections: the operator-alert gap is real only for SANCTIONED. Better fix than adding SCREENING_UNAVAILABLE to OPS_REASONS: make the sanctions alert say open appeal periods are unprotected; optionally add SANCTIONED to OPS_REASONS "for information"; optionally list in SPEC §14.21 which skips alert the operator.
```

**G-2:**
```text
Verdict: UPGRADED to Medium
Evidence (pristine e2b52ce): app.ts:301–302 withdrawFee returns 0n for non-USDT; app.ts:1220–1224 `value > 0n ? sendWithin(lz-fee) : relayer.call` → a USDC withdrawTo takes the unbudgeted branch. watcher.ts:91–103 records every cross-chain WithdrawnTo (whoever sent it); keeper.ts:403–418 completes each row ≥ RELAY_BRIDGE_MIN (bridges.ts:109–115, ORDER BY id LIMIT 50); bridges.ts:205 sends receiveMessage via plain dest.relayer.call; nothing touches relayer_spend. Bounds: the 20 USDC floor and one-tx-at-a-time per chain; the keeper lane has no RELAY_QUEUE_MAX or rate limit → Ethereum ceiling ≈ one mint per block (≤ ~7,200/day). At ≈150k gas/mint (estimate): 0.04 gwei ≈ 0.043 ETH/day (~$100); 0.4 gwei ≈ 0.43 ETH/day (~$1,000); 1 gwei ≈ 1.08 ETH/day. A 0.01 ETH Ethereum relayer runs dry in ~0.5–5.5 h. The spend alert (0.01 ETH/h) fires only above ~0.22 gwei and caps nothing.
Own script: scripts/pentest/verify/V2_G-2_unbudgeted_cctp.ts → PASS: real Keeper.completeTransfers + keeper-lane Relayer; 60 withdrawals of 20 USDC → 60 receiveMessage sends (50 in the first tick), zero budget statements; a 19.99 USDC row left pending.
What I checked: SPEC §12 promises relayed cross-chain withdrawals draw on global daily budgets ("nobody more"); only runbook.md:341 describes reality; not in audit.md §5; DepositRouter._send uses CCTP standard transfer, maxFee 0.
Reason for upgrade: the attacker needn't recycle each 20 USDC: bring a batch back to Arbitrum in one transfer, regain an escrow balance via a deposit box + returnDeposit (relayer-paid); ~9k USDC of locked capital sustains the ceiling for a few dollars a day; the relayed withdrawTo is free to it. Same class and ceiling as run 1's B-2 (Medium): burns the Ethereum relayer at ~1 tx/block and stalls the keeper (completeTransfers runs inside the sequential keeper.tick; 50 × ≥12 s ≈ 10+ min per tick, delaying work on every chain; appeals loop unaffected). Not High: no party loses money; windows are days long.
Corrections: the cost is the keeper's receiveMessage on the destination (Ethereum), not the Arbitrum relay; a self-sent withdrawTo (or many in one tx) triggers it too, bypassing rate limits and RELAY_QUEUE_MAX, so budgeting only the app.ts branch wouldn't close it; at current prices a mint costs ~$0.014–0.14, not $1–10. Fix: budget the mints in Keeper.completeTransfers per destination chain (sendWithin with a bridge-gas:<chainId> budget); when used up, treat the row like selfComplete (API shows completeOn) instead of retrying; cap destination sends per tick; a higher Ethereum floor helps but doesn't cap (capital recycles); or correct SPEC §12.
Side note (unverified in depth): pendingTransfers reads only the 50 oldest pending rows; rows whose recipient is OFAC-listed (skipped, keeper.ts:413) or Circle-blacklisted (receiveMessage reverts in simulation) stay pending forever, so 50 such self-sent 20 USDC withdrawals (1,000 USDC sacrificed) would hide every later transfer from the keeper (inbound deposits still completable via completeTransfer; the late-transfer alert would fire).
```


## Regressions of the first run's proofs

### Second run: regressions of the first run's proofs (commit e2b52ce, v0.31)

#### What ran
All under the shared lock and a 2,500 MB cap; forks on public RPCs; no `.env` read; the docker stack untouched.

| Run | Result |
|---|---|
| `forge test --match-path 'test/pentest/**'` (attack and verifier tests) | 187 / 187 passed, 36 suites |
| `forge test --match-path 'test/fork/*.t.sol'` | 26 / 26 passed (`AppealFunding.t.sol` 4/4) |
| `C_template_re2.ts`, `E_foreign_forward_keeper.ts`, `F_verify-profiles.sh`, `G_sponsor_drain.ts` | all exit 0; E and G print "Regression … OK" |
| Service tests without Postgres (`relayer-caps`, `appeals`, `ops`) | 26 passed |
| Service tests on Postgres (container `yescrow-check-pg-r`, removed afterwards) | appeals 6/6, api 64/64, deposit-chains 7/7 |
| Prover `dual.test.ts`, `template.test.ts` (C-1, C-2) | 13/13 |

#### Finding → fix holds?

| ID | Holds? | Evidence |
|---|---|---|
| D-1 | yes (service) | `AppealFunding.t.sol` 4/4; `appeals.test.ts` 6/6 incl. the keeper loop on Postgres. `D_AppealOverride` and `V_D-1` still show the flip when nobody counter-funds (no contract check, by the user's decision) |
| B-1 | yes | relayer-caps budget test; ops spend alert; api/deposit-chains `AMOUNT_TOO_SMALL`; on-chain half still passes. No end-to-end USDT0→Ethereum service test (no USDT0 on the testnet fork) |
| B-2 | yes | queue cap and budget tests; deposit-chains: value 1 → `AMOUNT_TOO_SMALL`, unfunded sender → `INSUFFICIENT_BALANCE`, issued nonce used once |
| E-1 | yes | keeper script: own forward → 1 row/1 `receiveMessage`; stranger's batched forward → 2 rows/2 `receiveMessage`; `E_ForeignForward` 3/3, `V_E-1` 5/5 |
| G-1 | yes | `G_sponsor_drain.ts` (deal age, per-deal share, net-of-refund budget, ops alert); up to `SPONSOR_DAILY_WEI` a day remains by design |
| C-1 | yes | `test_C1_*` 6/6; prover ">16 nameservers give no hash" |
| C-2 | yes | `test_C2_*` 2/2; `V_C-2` 13/13; prover `combineFacts` tests |
| B-3 | yes | `test_B3_*` 2/2; `V_B-3` 3/3; service `stage`/`stageEndsAt` |
| B-4 | yes | `test_B4_staleConfirmRefusedOnceADisputeOpens`; `V_B-4` 7/7; api `DEADLINE_TOO_FAR` |
| E-2 | yes | `E_StrandedBox` 4/4; router lands at `0x8f5d…1a52`. Testnet web warning not exercised |
| F-1 | yes | `F_CheckDeploymentGaps` 11/11; `V_F-1` 3/3 |
| G-2 | yes | relayer-caps G-2 test (listed parties refused; everyone refused without a list) |
| A-1 | yes (SPEC) | SPEC §14.12; `V_A-1` 4/4 |
| A-2 | yes | api `PAYOUT_PENDING`; SPEC §4.1 |
| F-2 | yes | `docs/mainnet.md` uses `--compilation-profile default`; the script shows `viaIR=False` with it |
| E-3, D-3, B-5, D-4 | yes (docs) | SPEC §8/§12, audit.md §5, security.md, keys.md |
| D-2 | yes | `test_D2_*` 2/2; `V_D-2` 3/3 |
| G-3 | yes | relayer-caps (settle) and api (confirm) assert the deal's own fee; `V_G-3` 2/2 |
| F I-1–I-5 | yes | sizes 23,421; gate before record, no override; comment; fork test asserts the router address; stipend note |
| F I-6 | n/a | dropped in the first run |

Failures: none.

Notes, not regressions: `F_verify-profiles.sh` still labels the bare command "as docs/mainnet.md runs it" (stale wording since F-2); the C script's case 2 still shows the attestor refusing a nameserver with its own "last changed" event (the first run's unconfirmed U2, no finding).

Logs: `scratchpad/pentest2/R-logs/`.

---

# Part 3. How the run went

- **Who ran it.** The user first started a fresh session for the run (prompt in `docs/ai-pentest.md`); it showed no
  progress in the repository for an hour, and the user chose "launch it from here", from the session that made the
  v0.31 fixes. That session (the orchestrator) only exported the commit, launched the agents, saved their reports and
  verdicts (the harness refuses report files from subagents, so several reports were returned as text and saved by
  the orchestrator unchanged) and assembled this report.
- **Copies.** `git archive e2b52ce` into `pentest2/base` (never modified) and one copy per area (`A`–`G`) plus `R` for
  regressions, each with the contract build warm and node_modules symlinked; the other sessions' uncommitted edits were
  not included. Every heavy job ran one at a time under a shared lock and a memory cap (2 cores, 4 GB).
- **Agents.** Seven attack agents on Fable 5.1 at maximum effort, each given the procedure's prompt word for word with
  its area brief and the second-run additions (check the fixes hold; look for what they broke). One regression agent
  (Opus 5.5). One verifier per finding or group of related findings (Opus 5.5), with the first run's verifier prompt
  and the user's rule, writing extra tests only as `V2_*` files.
- **Interruption.** The account's weekly usage limit stopped area F and four verifiers on 2026-10-30; they resumed from
  their saved state on 2026-10-31.
- **Files.** The proofs and the verifiers' tests are copied into the repository with this report:
  `contracts/test/pentest/second/*`, `contracts/test/pentest/verify/V2_*`, `scripts/pentest/second/*`,
  `scripts/pentest/verify/V2_*`. Run each with the command its finding names, from `contracts/` (forge) or the
  repository root / `scripts/pentest/second/` (node). Area F's compiler differential needs the `nole` profile its
  script describes (not added to the repository's `foundry.toml`).
