# AI penetration test (2026-10-29)

An attack test of yescrow by AI agents, run as `docs/ai-pentest.md` describes, on the code that is to go to mainnet. It
complements an independent human audit and doesn't replace one (see "Limits" at the end of Part 1).

| | |
|---|---|
| Commit under test | `3ca0628` (main, 2026-10-29): escrow v0.30 (`52f98ee`), its service and its docs. Since then `main` changed only the web app (`d2fab6d`) and `docs/ai-pentest.md` (`9d7f12c`); contracts, service, prover and attestor are the same |
| Attack agents | 7, one per area A–G, each with a fresh context: Claude Fable 5.1 (`claude-fable-5-1`), maximum reasoning effort |
| Verifiers | 21, one per finding (one for area F's list of Info notes), each with a fresh context: Claude Opus 5.5 (`claude-opus-5-5`), maximum reasoning effort |
| Orchestration | A Claude Code session (Opus 5.5) that took no part in writing the code. It ran no attacks itself, and checked only D-4 (one line in the docs) and that no agent changed the sources |
| Date | 2026-10-29 |
| Forks | Arbitrum One at block 519,194,000 (517,026,000 for one USDT0 test), Base at 52,978,000, Ethereum at 26,254,000; public RPC reads only |
| Proofs | 18 attack test files and 16 verifier test files in `contracts/test/pentest/`, 4 scripts in `scripts/pentest/`; all pass together (Part 3, D) |

Part 1 is the summary written for this report. Part 2 is each area's report as its attack agent wrote it, with the
verifier's verdict after every finding. Part 3 has the prompts word for word, the proof-of-concept files and the
deviations from the procedure.

# Part 1. Summary

## Result

No Critical finding: no agent found a way to steal a party's money, release a deal on a false proof, pay a deal twice
or change a final state. The proofs held: no proof passed for a fact the registry didn't state, and no signature
worked for another deal, escrow, chain, phase, amount or a second time.

One High, four Medium and seven Low findings survived verification:

- **High: D-1.** A party that lost the Kleros vote can fund an appeal alone, and Kleros then rules for it. The flip
  costs it only gas, because Kleros refunds a one-sided appeal stake. Nothing in yescrow tells the winner or helps it
  counter-fund.
- **Medium: B-1, B-2, E-1, G-1.** Three ways to make yescrow's relayer spend its ETH for nothing (B-1, B-2, G-1). B-2
  can also stall the keeper behind a queue of useless transactions. In E-1 anyone can forward a buyer's deposit from
  Base or Ethereum in a way yescrow never completes.
- **Low: B-3, B-4, C-1, C-2, E-2, F-1, G-2.**

Five findings were downgraded by their verifier (one to Low, four to Info) and one Info note was dropped because it
came from this run's own setup (next sections); per the rule below, no finding was dropped for argument alone.

## All findings

"Attacker" is the severity the attack agent claimed; "Verifier" is the verdict of the fresh agent that reran the proof
and tried to refute it. "Final" is the verifier's severity. The rule (the user's, 2026-10-29): a finding whose proof
of concept runs and shows the claimed impact can't be dropped by the verifier's argument alone; the verifier may
change its severity with a stated reason, and every such change is set side by side in the next section.

| ID | Finding | Attacker | Verifier | Final | Where the fix goes |
|---|---|---|---|---|---|
| D-1 | The loser of a Kleros vote flips the ruling by funding an appeal alone; yescrow neither tells the winner nor helps it counter | High | Confirmed | **High** | Product now (alerts, the relayer funds the winner's side); contract check in `applyRuling` (a design decision) |
| B-1 | A relayed USDT `withdrawTo` of one base unit costs the relayer the whole LayerZero fee | Medium | Confirmed; larger with gas (about 0.83 ETH a day per IP) | **Medium** | Service |
| B-2 | Relayed zero-value USDC authorizations on Ethereum burn the relayer's gas and back up the keeper's queue | Medium | Confirmed; ETH capped by the queue, keeper stall added | **Medium** | Service |
| E-1 | Anyone can `forward` a deposit off Base or Ethereum; yescrow only completes burns it made itself | Medium | Confirmed; two impact claims narrowed, one test proves the wrong thing | **Medium** | Service |
| G-1 | One actor playing both sides drains the sponsored arbitration-fee budget | Medium | Confirmed; a cheaper variant, and the budget can be held at zero for free | **Medium** | Service |
| C-1 | A 17th nameserver makes every InterRegistrar proof revert | Medium | Downgraded | **Low** | Verifier (one line) |
| B-3 | A settlement offer becomes a free option for the other side once Kleros's votes are visible | Low | Confirmed; mainly the AI court | **Low** | Contract or web warning |
| B-4 | `Confirm` and `Cancel` signatures aren't bound to the phase they were signed in | Low | Confirmed; one suggested fix wouldn't work | **Low** | Contract; API deadline cap |
| C-2 | The two attestations must agree on nameservers and "last changed" even for InterRegistrar deals | Low | Confirmed; the fix must also cover status flags | **Low** | Dual verifier, SPEC, prover |
| E-2 | A stranger's `forward` locks for good a mistaken mainnet deposit to a testnet deal address | Low | Confirmed | **Low** | Docs and testnet UI |
| F-1 | `CheckDeployment` doesn't read back the promo key, promo end, juror template or the full verifier list | Low | Confirmed; compare against values pinned elsewhere | **Low** | Script |
| G-2 | The keeper proposes and accepts deposit-funded deals for sanctioned parties | Low | Confirmed by reading (no proof of concept) | **Low** | Service |
| A-1 | A promo voucher is refused while yescrow's judge has the deal | Low | Downgraded | Info | SPEC wording |
| A-2 | Anyone can push a party's balance to its Arbitrum One address ahead of its `withdrawTo` | Low | Downgraded | Info | Docs |
| F-2 | The runbook's router verification command is refused as ambiguous | Low | Downgraded | Info | Runbook |
| E-3 | Tokens sent to the router's own address can't be recovered | Low / Info | Downgraded | Info | Docs |
| D-2 | yescrow earns more when its judge lets the 14 days pass than when it rules | Info | Confirmed; holds at every price on stalled cases | Info | Contract (`judgeTimeout`) and SPEC §8 |
| D-3 | A Kleros fee rise between opening and answering comes out of the winner's refund | Info | Confirmed; in `docs/security.md`, not in SPEC or `docs/audit.md` §5 | Info | Docs |
| B-5 | A party whose own code approves every signature (EIP-1271, EIP-7702) can be acted for by anyone | Info | Confirmed | Info | Docs |
| G-3 | The settlement summary shows the deployment fee, not the deal's promo fee | Info | Confirmed by reading; `confirm` has the same bug | Info | Service |
| D-4 | `docs/keys.md` still lists `rule`, which v0.30 removed | Info | Checked by the orchestrating session | Info | Docs |
| F I-1–I-6 | Deployment notes (size figure, `REQUIRE_KLEROS` default, comment drift, fork-test fallback, gas stipend, a cache entry) | Info | I-1, I-3, I-4, I-5 confirmed; I-2 corrected; I-6 dropped (it came from this run's own setup) | Info | Docs and scripts |

Contract changes are still possible: the v0.30 escrow and verifiers aren't deployed on mainnet yet. The findings
that touch them are D-1 (optional check), D-2, B-3, B-4, C-1 and C-2.

## Findings a verifier challenged or downgraded

Side by side, so readers can judge. Each full text is in Part 2.

| ID | The attack agent's claim | The verifier's reason |
|---|---|---|
| C-1 (Medium → Low) | 17 nameservers block every "after" proof of an InterRegistrar deal, forcing the seller into a dispute; High if a registry accepts 17 or more | Every registry of the 25 TLDs caps a domain at 13 nameservers (Google Registry's code, registrars' TLD documentation), below the verifier's 16: a latent defect until a registry raises its cap. Even if reachable, the seller who delivered can dispute and win, so it would be Medium griefing, not High |
| A-1 (Low → Info) | SPEC §14.12 says a voucher works until the deal closes; the contract refuses it in `Judging`, so the seller pays the fee the code waived | v0.30 refuses vouchers in `Judging` on purpose (`test_judging_noFeeVoucher`, `docs/security.md`), and SPEC §8 charges the fee on those exits; the API and deal page already refuse the code there. What is wrong is one stale sentence in §14.12 |
| A-2 (Low → Info) | `withdraw(account)` lets a stranger choose where a party's balance lands, which loses money for a party whose Arbitrum One address isn't its own or that Tether has blocked | A party that can't control its Arbitrum One address can't use `withdrawTo` either, so the push changes nothing; pushing into a blocked wallet is Tether's freeze working as intended. What remains is that anyone can override a party's chosen chain: undocumented, no loss |
| F-2 (Low → Info) | Without `--compilation-profile default` the router stays unverified on every chain | The command fails loudly and can be rerun any day; Sourcify already matches the identical router on two testnets; the CREATE2 address proves identical code without verification |
| E-3 (Low / Info → Info) | Tokens sent to the router are stuck | The same accepted limit as donations to the escrow (`docs/audit.md` §5, SPEC §2 rule 1); no attacker, nobody gains, only the documentation misses the router |
| F I-6 (Info → dropped) | A stale cache entry makes forge log an error on every run | The entry came from a probe test this run's setup created and deleted; it isn't in the repository, and forge removes it on its first run without an error |
| E-1 (kept, Medium) | The buyer's way back on Base is removed, and with Circle paused the loss is permanent | The keeper's own forward empties the box within 30 s anyway, and the keeper also forwards into a paused route; `test_3` passes for the wrong reason (its reverts happen without a pause), though a separate check shows the pause claim is true |
| B-2 (kept, Medium) | More IPs burn more ETH | The relayer sends one transaction at a time per chain, which caps the burn at about 0.59 ETH a day at 1 gwei; but the same backlog stalls the keeper on every chain, which the attacker didn't claim |
| B-3 (kept, Low) | The offeree waits about a day for the AI court's votes | The proof compresses time: at its 5,000 USDC price the deal goes to the General Court, where a 24-hour offer expires 54 hours before any vote is public; the exposure is real in the AI court (deals up to 2,000) |
| D-1 (kept, High) | yescrow doesn't let the winner respond | The deal page links to the Kleros case, where anyone may counter-fund; High holds because nothing tells the winner, sponsored parties hold almost no ETH, and a second appeal would cost the winner about 0.54 ETH |
| G-1 (kept, Medium) | The escalation: an honest seller with no ETH loses by fee default | Not shown by the proof; needs the budget held at zero for the whole 72-hour fee period and a victim who can't find about $2 of ETH. The loss to yescrow stands |

## What the verifiers added

Several verifiers found more than the attacker claimed, or showed a suggested fix wouldn't work. In short:

- D-1: paying on the jurors' `winningChoice` would let a first-round winner block every appeal; any contract check must
  compare the ruling with the jurors' choice, not test `overridden` alone. For the deployed version, the strongest fix
  is for the relayer to fund the winner's side when the appeal period opens (0.00378 ETH in the AI court, returned
  whole if nobody opposes).
- D-2: on a stalled case, or a refused one where yescrow paid the loser's fee, the timeout pays yescrow more at every
  price; the fix is one line in `judgeTimeout`.
- B-4: binding `provenAt` doesn't help (a dispute leaves it unchanged); bind the dispute phase, as `Settle` does.
- C-2: the suggested fix still compares `statusFlags`, so a buyer could toggle the transfer lock between the two
  sessions; a fuller fix is sketched and tested.
- E-1: completions should be keyed by CCTP message, not by transaction hash, or a batched forward leaves money behind.
- G-1 and B-1: per-account or per-hour limits are easy to get around with more accounts; use global budgets reserved
  before sending, counted net of refunds.
- G-2: the keeper's CCTP completions also mint to recipients nobody screened.
- G-3: the `confirm` summary has the same fee bug.
- F-1: rebuilding the expected values from the same `.env.mainnet` would miss a wrong key in that file; compare with
  values pinned elsewhere (`docs/keys.md`, a committed template hash, the creation transaction).

## Limits

- **Not independent.** The code was written with Claude Opus. The attack agents used Fable 5.1, a different model,
  and the verifiers Opus 5.5, which may share the author's blind spots; that is why a verifier could not drop a
  finding by argument. No model from another vendor was run (step 5 of the procedure).
- **No findings is not proof of no bugs.** Each area's "checked and found sound" list (Part 2) shows what was tried;
  nobody is accountable for what wasn't.
- **Some proofs are partial.** B-1 and B-2 prove the on-chain half on a fork and the service half by reading the code
  (a local stack would have needed the live Docker Postgres). G-2 and G-3 have no proof of concept. E-1's `test_3`
  passes for the wrong reason (see its verification).
- **New ground is where it is weakest.** The proof parsing and the judge logic are new; the human review should spend
  its time there and on D-1.
- **Not fixed yet.** Per the procedure, fixes, regression tests and a second run on the fixed commit (step 4) come
  after the user has read this report.

---

# Part 2. The area reports, with each verification

Each area report is the attack agent's own text (Fable 5.1), unchanged except that paths point into the repository and `forge` replaces the session's memory-capped wrapper. After each finding, the fresh verifier's verdict (Opus 5.5) follows as a quote.

## Pentest report, area A: state machine and money

Commit under test: 3ca0628 (main, 2026-10-29). Working copy: a clean copy of the commit. 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 through token and ETH transfers, the 63/64 gas rule.

Proofs: `contracts/test/pentest/A_StateMoney.t.sol` (21 tests, mocks), `A_ArbitrumOneFork.t.sol` (5 tests on a fork of Arbitrum One at block 519,194,000: real USDC, real CCTP V2, real Kleros core and public resolver) and `A_Usdt0Fork.t.sol` (2 tests on a fork at block 517,026,000: real USDT0). All 28 pass. Run from `contracts`:

```
forge test --match-path test/pentest/A_StateMoney.t.sol -vv
forge test --match-path test/pentest/A_ArbitrumOneFork.t.sol -vv
forge test --match-path test/pentest/A_Usdt0Fork.t.sol -vv
```

**Bottom line.** No Critical, High or Medium finding in this area. Every transition, deadline boundary, payout path and fee refund I could construct credits exactly what was paid in, to the parties SPEC §8 names; no signature, gas amount or re-entrant call changes where money goes. Two Low findings: a promo voucher is refused once a deal is in `Judging` (the spec says a voucher works until the deal closes), and `withdraw(account)` being open to anyone lets a third party decide *where* a party's balance lands (on Arbitrum One, into its own address) ahead of the party's `withdrawTo`, which with Tether's blocklist semantics means a stranger can push a blocked party's movable balance into its frozen wallet.

---

### Findings

#### A-1. A fee voucher is refused while a deal is in `Judging`, so a seller whose promo code lands late pays the fee the code waived

- **Severity:** Low (a seller loses yescrow's fee it was promised; needs a late voucher).
- **Attacker and preconditions:** nobody has to attack. A deal with a promo code whose voucher isn't on-chain yet when the deal reaches `Judging` (Kleros refused the case, or no final ruling in 90 days). The keeper normally submits the voucher seconds after the proposal, so this needs the keeper to have been down or the code entered on the deal page late (SPEC §14.12 allows a code "while the deal is open").
- **Steps:** buyer challenges, seller answers, Kleros refuses → `Judging`. `applyFeeVoucher(dealId, 0, expiry, sig)` reverts with `WrongState` (`Judging` = 10 > `Disputed`). Then `judgeTimeout` (or a settlement in `Judging`) charges the deal's full fee: on a 5,000 USDC deal split 50/50 the seller gets 2,475 USDC instead of 2,500; yescrow and the referrer keep 25 USDC. The same voucher one state earlier (`AwaitingFee`) is honoured and the seller keeps the whole half.
- **Impact:** the seller pays `F` on `judgeTimeout` and on a settlement in `Judging` although the code waived it. Only the judge's own ruling (`judgeRuling`) waives the fee. The service agrees with the contract rather than the spec: the keeper's `applyPromos` treats `state > 5` as closed and gives the code's use back (`service/src/keeper.ts:142`).
- **PoC:** `forge test --match-path test/pentest/A_StateMoney.t.sol --match-test test_finding_voucherRefusedInJudging -vv`
- **Root cause:** `contracts/src/YescrowEscrow.sol:733` `if (d.state == State.None || d.state > State.Disputed) revert WrongState();` predates `Judging` being appended to the enum as value 10 (v0.30); `judgeTimeout` (line 645) and `settle` (line 590) in `Judging` still charge `_feeBpsOf(d)`. `test/Judge.t.sol::test_judging_noFeeVoucher` pins the current behaviour as intended, but SPEC §14.12 ("works only while the deal is open, from its proposal until it closes") and §7.5 ("a deal yescrow judges pays no fee") say otherwise.
- **Suggested fix:** accept vouchers in `Judging` (`if (d.state == State.None || (d.state > State.Disputed && d.state != State.Judging))`) and let the keeper keep a pending voucher alive through `Judging`; or, if the current behaviour is wanted, say in SPEC §14.12 that a code can't be applied once the judge has the deal, and have `judgeTimeout` waive the fee as `judgeRuling` does.

**Verification of A-1:**

> **Verdict:** DOWNGRADED to Info
>
> **Proof of concept:** `forge test --match-path test/pentest/A_StateMoney.t.sol --match-test test_finding_voucherRefusedInJudging -vv` passed, on cached artifacts and after a fresh compile.
> - In `Judging` the voucher reverts with `WrongState`.
> - After `judgeTimeout` the seller has 2,475 USDC; yescrow plus the referrer have 25.
> - The same voucher applied in `AwaitingFee` leaves the seller its whole 2,500.
>
> My extra tests, `contracts/test/pentest/verify/V_A-1_judgingVoucher.t.sol` (4/4 pass):
> - A buyer's concession in `Judging` without the voucher pays the seller 4,950 and yescrow plus the referrer 50.
> - `judgeRuling` charges no fee, voucher or not.
> - A voucher applied during a stall's 90 days in `Disputed` still applies in `Judging`.
>
> **Sources unchanged:** yes. Only `contracts/test/pentest/`, `scripts/pentest/`, `REPORT-A.md` and build output differ from `base`.
>
> **What I checked:**
> - **The test:** parties act as themselves and yescrow's promo key signs the voucher, as in normal use. The refusal is the mock's allow-list revert. The revert comes from the state check. The amounts match `_close`.
> - **The cited lines** (733, 645, 590, `keeper.ts:142`) are correct.
> - **Docs and code:** SPEC §7.5, §8 and §14.12, `docs/security.md`, audit §5 (not listed), `Judge.t.sol`, and the promo endpoint, deal view, keeper and relayer.
>
> **Reason:**
> 1. **Deliberate, not a leftover.** v0.30 added `test_judging_noFeeVoucher` ("A promo code can't change a deal while the judge decides it"). `docs/security.md` gives the voucher window as "Proposed to Disputed" and lists "no fee voucher while judging" and "the 14-day timeout (usual fee on the seller's half…)" as tested.
> 2. **The spec charges the fee on these exits.** SPEC §8 lists "Refused case, judge's 14 days pass: P/2 − F + A" and "conceded or settled: S − F + A", and a stalled timeout pays as ruling 0, with F. §7.5 waives the fee only when the judge rules. The seller gets exactly §8's amounts.
> 3. **The deal page breaks no promise.** Past `Disputed`, the API refuses a code (`DEAL_WRONG_STATE`) and the page hides the field (`canApply` needs state ≤ 5). A submission that loses a race reverts, `relayer.call` throws on the receipt, and the code's use is given back with an error.
> 4. **Only one case is silent:** a draft's code whose voucher yescrow's keeper or relayer failed to submit from the proposal until Kleros refused the case, then a close other than `judgeRuling`. The same outage already loses the voucher on every other closing path by design (`keeper.ts:126`: "a deal that closed first gives the use back"); `Judging` only moves the cutoff earlier. The fee goes to yescrow, which offered the waiver, and the referrer.
>
> What is real is one stale sentence: SPEC §14.12's "from its proposal until it closes" dates from v0.14, when `Disputed` was the last open state. That is a documentation inconsistency, so Info.
>
> **Corrections:**
> - **Root cause:** v0.30 kept line 733 on purpose. The defect is §14.12's wording, plus the v0.30 status line's "a deal yescrow judges pays no fee", which §7.5 and §8 narrow.
> - **Impact:** up to F on the whole price (50 USDC on 5,000) through a buyer's concession, not only 25.
> - **Fix, option 1:** reword §14.12 ("…until it closes or goes to yescrow's judge"), or accept vouchers in `Judging`. Accepting them means also changing that test, `docs/security.md`, and the API, keeper and deal view (all use `state > 5`).
> - **Fix, option 2 (fee-free `judgeTimeout`):** contradicts §8 and doesn't fix this finding. It is a separate design question with some merit: on a stalled case yescrow earns F on the seller's half (25 USDC on 5,000) if its judge lets the 14 days pass, and nothing if it rules (verified).

#### A-2. `withdraw(account)` is open to anyone, so a third party chooses where a party's balance lands, ahead of the party's own `withdrawTo`

- **Severity:** Low (griefing; a loss only for a party whose address on Arbitrum One isn't its own, or which Tether has blocked).
- **Attacker and preconditions:** anyone (the counterparty, a bot, or the relayer through `POST /v1/relay` with `action: "withdraw"`, which relays `withdraw(account)` for any account with no signature, `service/src/api/app.ts:1058`). The victim has a balance it meant to send elsewhere with `withdrawTo`.
- **Steps:**
  1. A deal is released; the seller is owed 4,950 USDC and intends `withdrawTo(Base)` (or signed an automatic payout to Base). A stranger calls `withdraw(seller)` first: the whole balance is transferred to the seller's address on Arbitrum One; the seller's `withdrawTo` then fails with `NothingToWithdraw`. For an EOA that is only an inconvenience. For a party whose address is a smart-contract wallet it controls on Base but not on Arbitrum One (a Base-native account-abstraction wallet, or a Safe that can't be reproduced at the same address), the tokens are now at an address nobody controls.
  2. USDT0: Tether's blocklist stops transfers *from* a blocked address only (proven on the real token). A seller Tether blocks after being paid could still move its 1,485 USDT escrow balance to another wallet with `withdrawTo`; a stranger calls `withdraw(seller)` and the tokens land in the blocked wallet, where they are frozen (`transfer` reverts) and can be destroyed by Tether. (Circle's USDC checks `to` as well, so there `withdraw` to a blacklisted party simply reverts and the balance stays movable; see the sound list.)
- **Impact:** a party loses the choice SPEC §4.1 gives it ("Only the account chooses where") for the whole balance, and in the two cases above loses the money. The spec's own warning ("A smart-contract wallet is offered Arbitrum One only") limits case 1 to parties who ignore the interface; whether case 2 is a loss or the intended compliance outcome is the operator's call, but today it is decided by whoever sends the transaction first.
- **PoC:** `forge test --match-path test/pentest/A_StateMoney.t.sol --match-test test_finding_anyoneCanForceAPayout -vv` and `forge test --match-path test/pentest/A_Usdt0Fork.t.sol --match-test test_fork_usdt0_finding -vv`
- **Root cause:** `contracts/src/YescrowEscrow.sol:674-679` (`withdraw` has no caller check; `withdrawEth`, line 718, is the same for ETH). SPEC §2 rule 3 and §12 ("Anyone may trigger it; nobody can redirect it") consider only the recipient, not the chain or a freeze.
- **Suggested fix:** let only the account trigger `withdraw` (`msg.sender == account`, or a `WithdrawTo` signature, as `withdrawTo` already requires); keep a permissionless path only for `withdrawEth` if the keeper needs it. If the open `withdraw` is kept, drop the unauthenticated `withdraw` relay from the API and say in the spec that a balance can be pushed to the party's own Arbitrum One address by anyone.

**Verification of A-2:**

> **Verdict:** DOWNGRADED to Info
>
> **Proof of concept:**
> - `forge test --match-path test/pentest/A_StateMoney.t.sol --match-test test_finding_anyoneCanForceAPayout -vv`: PASS. After `release`, a stranger's `withdraw(seller)` sends the whole 4,950 USDC to the seller's own address, and the seller's `withdrawTo(Base)` then reverts `NothingToWithdraw`. The seller is an EOA and still holds the 4,950, so no loss is shown.
> - `forge test --match-path test/pentest/A_Usdt0Fork.t.sol --match-test test_fork_usdt0_finding -vv` (real USDT0, block 517,026,000): PASS. A `-vvvv` trace rules out its silent `return`s. Tether's owner (0x4DFF…0bf8) blocks the seller, the stranger moves 1,485 USDT0 into the blocked wallet, the seller's `transfer` reverts with "from is blocked", and `withdrawTo` reverts `NothingToWithdraw`.
> - `test_fork_usdt0_blockedParty`: PASS. Without the push, the blocked seller moves its 1,485 USDT0 to another wallet with `withdrawTo`.
>
> **Sources unchanged:** yes. `diff -r` against base shows only `contracts/test/pentest/`, `scripts/pentest/` and REPORT-A.md differ; `contracts/lib` and `foundry.toml` are identical.
>
> **What I checked:**
> - In YescrowEscrow.sol: `withdraw` (674-679), `withdrawTo` (685-711), `withdrawEth` (718) and `_isValidSignature` (926-932). Signatures are checked by ECDSA or EIP-1271 on Arbitrum One only.
> - The design is deliberate. SPEC §2 rule 2 says "Anyone may trigger a payout". `docs/decisions.md` adds that a relayer can push withdrawals for gasless users, and `docs/security.md` and `docs/keys.md` agree. SPEC §14.14 already covers a balance "already withdrawn" and offers contract wallets Arbitrum One only. The web's gasless withdraw relays `withdraw` unsigned (`Dashboard.tsx:153`). Not in audit.md §5.
> - My test `contracts/test/pentest/verify/V_A-2_pushVsWithdrawTo.t.sol` (2/2 PASS):
>   - A buyer whose address is an undeployed CREATE2 wallet pays through the deposit address and is credited a 5,000 USDC refund.
>   - `withdrawTo` is closed to it: the owner's signature gives `InvalidSignature`, a call from the owner's key gives `NotAuthorized`.
>   - Once the wallet is deployed, the full 5,000 comes back either way: by the signed `withdrawTo`, or after a stranger's push by the owner's `execute(transfer)`.
>
> **Reason:**
> 1. The open `withdraw` doesn't cause the loss in case 1.
>    - `withdrawTo` needs a transaction from `account`, or a signature valid for `account` on Arbitrum One. Either means the party controls that address there, which is where a push lands.
>    - A party without that control can't use `withdrawTo` either. Its balance is stuck whether pushed or not, and recovered the same way in both cases.
> 2. Case 2 is Tether's freeze working as intended.
>    - Without the push, the escrow lets a blocked party move its funds out.
>    - SPEC §12 refuses relays and automatic payouts for sanctioned addresses, and warns that issuers can freeze the contract itself, which would hit every party.
>    - The attack report rates the same exit on USDC "no loss" (sound list 8).
> 3. What remains is minor. Anyone can override a party's chosen chain, including the keeper's signed payout. It works reliably within the keeper's 30-second loop and needs no mempool. The party still gets its whole balance at once, at its own Arbitrum One address. Nothing is held back or delayed and nobody gains, so it is an undocumented interaction, not griefing.
>
> **Corrections:**
> - Root cause: `withdrawEth` (718) has no alternative destination, so it isn't an instance.
> - Suggested fix: don't change the contract. The change would break the unsigned gasless withdraw. It would lock forever the refunds of typed-address buyers who can't sign. It would also give Tether-blocked parties a way out. Instead:
>   - Document in SPEC §4.1/§14.14 that anyone may push a balance to the party's own Arbitrum One address ahead of its payout.
>   - Optionally, have the relayer refuse `withdraw` for an account with a pending automatic payout.

---

### Known issues, quantified (not counted as findings)

- **Kleros raises its fee between opening and answering** (`docs/security.md` "Kleros fee change"): at exactly 2A the case is created with nothing held, so the winner gets no fee back; at 2A + 1 wei the case is refused instead, the winner gets its whole fee back and yescrow keeps the loser's. A one-wei cliff that only Kleros's governor can cause. `test_known_klerosFeeRise_winnerRefundCliff`.

---

### Checked and found sound

Each item names the proof. Line numbers are in `contracts/src/YescrowEscrow.sol`.

1. **Every deadline, at, just before and just after its boundary** (`test_sound_boundary_*`): `acceptBy` (accept at T−1, only expire at T, the funded proposer credited the price), the transfer deadline (proof and dispute at T−1, only refund at T), the challenge deadline (challenge at T−1, release at T), `feeDeadline` (answer at T−1, timeout at T with the opener's fee back), the 90-day stall (TooEarly at T−1, Judging at T, the judge's deadline exactly +14 days) and the judge's deadline (ruling at T−1, timeout at T, both fees back whole). State deadlines are exclusive on both sides; signature deadlines, voucher expiry and `promoEnds` are inclusive on both sides: no second is lost on either side of any boundary.
2. **The 63/64 rule, on mocks and on the real Kleros.** Sweeping the answer's gas in 5k steps across the floor and bisecting to the exact floor: every answer either reverts with `GasTooLow` (nothing changes) or creates the case; none sends a deal to the judge. Numbers: the exact floor is 5,239,819 gas with the mocks and **5,249,700 on the real resolver and core**; at the floor the court (mock) receives 4,915,699 gas, i.e. the 5M budget minus the resolver's own layer and its 1/64; a normal answer on the real Kleros uses 818,864 gas in total; the largest court gas need the fixed budget still serves is about 4.76M with the mock resolver (the real resolver's own writes after the court returns are larger, so the real headroom is somewhat lower, still ~5× what court 34 needs). A court that needs more is refused deterministically, not by a party. `test_sound_gasSweep_cannotFakeARefusal`, `test_sound_courtNeedingTheWholeBudget_stillCreatedAtTheFloor`, `test_sound_courtBudget_largestCourtServed`, `test_fork_realKleros_caseCreatedAtTheExactGasFloor` (a real case in court 34 at the exact floor). `applyRuling`'s 1M floor leaves Kleros's two getters (~50k) ample gas (existing `test_applyRuling_needsAmpleGas`).
3. **Kleros's dispute IDs and getters** (read from kleros-v2's source): `DisputeResolver.createDisputeForTemplate` returns the *arbitrator's* dispute ID (not its local index), so `disputeId` is the right key for the core; `KlerosCoreBase.Dispute` is `(courtID, arbitrated, period, ruled, lastPeriodChange, rounds[])`, so `_read(..., 2)` is the period and `execution` = 4; `currentRuling` returns `(ruling, tied, overridden)`, so index 0 is the ruling `executeRuling` would apply. `_read` (line 963) checks `returndatasize` before copying, so a shorter or reverting getter counts as "not ruled" and can't brick `applyRuling`.
4. **Re-entrancy through ETH transfers.** A fee-payer contract re-entering on its refund (`withdrawEth`, `withdraw`) and a buyer contract re-entering `returnDeposit` from the deposit box's ETH return: every re-entrant call fails on the guard, each payment is made once, and ETH held equals ETH owed. Token transfers (USDC, USDT0, CCTP, the OFT) have no hooks into the escrow; EIP-1271 and verifier calls are static. `test_sound_reentrancy_feeRefund`, `test_sound_reentrancy_returnDepositEth`.
5. **Every closing path credits exactly the price and exactly the ETH held**, including the v0.30 paths: refused → judge rules 0/1/2, judge timeout, concession and settlement in `Judging`; stalled → the same four. Fuzzed over the price, the referrer share (0–100%) and a voucher fee: seller + referrer + fee recipient = price, fee ≤ `feeBps`; fuzzed over the arbitration fee (odd wei included): a refused split credits A to yescrow and A across the two payers. `test_sound_judgingPaths_conserveMoney`, `testFuzz_sound_releaseConservesThePrice`, `testFuzz_sound_refusedSplitConservesEth`. Rounding: `price/2` floors toward the seller, `held/2` toward the seller's side, fee and referrer share floor; no path can overflow (0.8 checked arithmetic, windows capped).
6. **Settlement signatures and phases.** A `Settle` signed while Kleros had the case (`caseCreated = true`) stays valid after the case stalls into `Judging` and pays the same (held fee split 50/50), so waiting for the stall gains nothing; one signed in `AwaitingFee` is unusable after a refusal (E3 fix, existing tests). The service builds `caseCreated` as `Disputed || Judging`, matching line 586. `test_sound_settleSignedInDisputed_validAfterStall_sameMoney`.
7. **Fee payers and refunds** (SPEC §2 rule 2, §14.17): the opener's fee goes back to its payer on a missed answer, a concession or a settlement before the case; once the case exists the held fee goes to the winner's side payer or is split; on a refused case `judgeRuling` moves exactly one A to the fee recipient (lines 628–633) and `_close` pays the rest once (lines 1028–1046). A sponsor only ever gets its own ETH back (existing `SponsoredFees.t.sol` and the conservation tests above).
8. **USDC on the real token** (`A_ArbitrumOneFork.t.sol`): a blacklisted buyer can't fund by signature; blacklisting the seller doesn't trap the buyer's refund; `withdraw` to a blacklisted party reverts (its own problem only) while its balance stays and can leave through `withdrawTo(other)`: the issuer's blacklist binds token holders, not escrow balances (Info, no loss). **CCTP's burn limit** is 10,000,000 USDC per message on Arbitrum One: a larger `withdrawTo` to Base reverts, the balance is untouched, and it can go in parts or on Arbitrum One; nothing is stuck.
9. **USDT0 on the real token** (`A_Usdt0Fork.t.sol`): `receiveWithAuthorization` works unchanged (existing fork test); a blocked buyer can't fund; Tether's blocklist checks `from` only (see A-2 for what that allows).
10. **Fees on transfer.** Neither token takes a fee; `_fund` measures the balance difference anyway (lines 849–851) and `_pullDeposit` likewise (lines 865–873), so a short delivery reverts and a deposit funds only what arrived.
11. **Donations.** Tokens or ETH sent to the escrow outside a deal change no balance and no deal (every amount is a measured delta or a credited balance); they stay there, as documented. ETH can't be sent directly (no `receive`), and no path leaves ETH in the escrow: `withdrawTo` on the same chain requires `msg.value == 0`, the router refuses value on CCTP routes and refunds the OFT's excess to the caller.
12. **Deposits go only to their deal** (existing `Deposits.t.sol` plus the re-entrancy test): the box address commits to escrow, deal ID and buyer; `pull` on Arbitrum One sweeps only the caller's own namespace, so a non-escrow caller reaches only boxes salted with its own address; `returnDeposit` credits `terms.buyer`, and the deal token is returnable only once the deposit can no longer fund the deal (checked at `acceptBy`, state and term validity).
13. **No transition reachable twice, no final state changed**: every closing function requires the exact non-final state, `_openDeal` admits Active…Disputed and Judging only, `_cancel` only from Proposed, and `applyRuling` only from Disputed, so a late Kleros ruling, a second `release`, or a `judgeTimeout` after a ruling all revert (`WrongState`); re-checked by reading every entry point.
14. **Deployment settings** (`script/DeployAll.s.sol`, `Networks.sol`): courts 34 (3 jurors) and 1 (1 juror), Classic kit, `smallDealMaxPrice` 2,000, mainnet minimums 24 h / 72 h / 72 h enforced, the resolver must point at the core, the judge must be a contract and not the fee wallet, the deployer must be the fresh one at nonce 0: all as SPEC §9 and §14.20 promise. `CheckDeployment.s.sol` reads them back. (The resolver and core addresses themselves are checked live by the existing mainnet fork test at block 519,194,000.)
15. **Signature deadlines and replay in this area**: `WithdrawTo` is consumed by digest (lines 697–700) so neither the signature nor a malleated copy works twice; `Auth` actions bind deal, domain, parties, price, action and deadline and each is valid in one state that is left for good; a `ReferrerShare` signature is consumed by nonce; a voucher can't be re-applied because it must lower the fee. Two mainnet escrows can share a deal ID, but EIP-712 domains, EIP-3009 token domains and box salts all include the escrow (existing `Misc.t.sol` and `Deposits.t.sol`).

---

### Unconfirmed, or outside what a Foundry test can prove

- **Kleros's `pause`** (guardian or governor) makes `createDispute` revert, which the escrow treats as a refusal: every answer sent during a pause goes to yescrow's judge, and the answering party chooses whether to answer during the pause or wait. Not a party's lever (Kleros only), and consistent with SPEC §7.1's "Kleros may refuse", but worth a line in §12 next to the resolver's key.
- **A buyer paying from Base with a smart-contract wallet that exists only on Base** (EIP-1271 on Base's USDC through `POST /v1/relay depositTransfer`): `terms.buyer` is then an address the buyer doesn't control on Arbitrum One, where refunds and excess are credited and can be pushed by anyone (A-2). SPEC §14.11 says the interface must warn; the API doesn't check `terms.buyer`'s code on Arbitrum One (`service/src/api/app.ts:1068-1081`). Needs a running stack to prove; the loss would be the buyer's whole refund.
- **One-sided appeal funding at Kleros**: in the Classic dispute kit a party that loses a round can fund an appeal, and if the winner's side isn't funded in time the funded side wins. The escrow applies whatever the core's `currentRuling` says at execution, so yescrow's interface and keeper must tell a winning party to fund its appeal side. Area B/C territory; flagged here because it decides money.
- **Two settlements both parties signed** are both valid until their deadlines and the first one submitted wins (`test_info_twoSignedSettlements_firstSubmittedWins`): keep settlement deadlines short in the interface. Not a contract flaw.
- **A "before" proof fetched in the proposal's second is rejected** (`fetchedAt > proposedAt` is strict), so an acceptance needs a later second (`test_info_acceptNeedsASecondAfterTheProposal`). Liveness only.
- **Sponsored fees** can be burned by self-dealing on 1-USDC deals in the AI court (each costs yescrow the court's fee plus gas); capped by `SPONSOR_DAILY_WEI` and accepted in `docs/security.md`.
- **`returnDeposit` with an attacker's ERC-20** trusts the box's reported amount only for the `DepositReturned` event (the deal token is measured by balance delta): a watcher could display a fake amount. Info.
- **Terms naming the escrow, the router or a box as `referrer` or `buyer`** credit a balance nobody can use (a `withdraw` to the escrow is a self-transfer). Self-harm only. Info.

---

### Files

- `contracts/test/pentest/A_StateMoney.t.sol`: boundaries, gas sweep and bisection, re-entrancy, promo voucher in Judging (A-1), permissionless withdraw (A-2), Kleros fee cliff, settlement phases, fuzzed conservation.
- `contracts/test/pentest/A_ArbitrumOneFork.t.sol`: real USDC blacklist, real CCTP burn limit, real Kleros case at the exact gas floor.
- `contracts/test/pentest/A_Usdt0Fork.t.sol`: real USDT0 blocklist semantics and the A-2 USDT0 scenario.

No file outside `contracts/test/pentest/` was changed, and no transaction was sent to any live network (forks read public RPC state only). `REPORT-A.md` was not written because the harness refuses report files from subagents; the text above is the full report.

---

## Pentest report, area B: signatures

Commit under test: `3ca0628` (main, 2026-10-29). Scope: EIP-712 domains and type hashes; EIP-3009 with the deal ID as
nonce; EIP-1271, ERC-6492 and EIP-7702 wallets; settlements bound to their phase; payout signatures the keeper submits
later; malleability; replay across deals, escrows, tokens, chains and phases. Code read: `contracts/src/YescrowEscrow.sol`,
`contracts/src/DepositRouter.sol`, `contracts/src/DepositBox.sol`, OpenZeppelin 5.4 `ECDSA`, `SignatureChecker`,
`EIP712`; `service/src/typed-data.ts`, `erc6492.ts`, `relayer.ts`, `payouts.ts`, `promo.ts`, `admin.ts`, `sponsor.ts`,
`keeper.ts` (payouts), `api/app.ts` (typed data, relay, payouts, deposit authorizations, withdraw fee); the real USDC
(FiatToken v2.2) and USDT0 on a fork of Arbitrum One, and USDC on a fork of Ethereum.

Proofs: 22 Foundry tests in `contracts/test/pentest/B_Signatures.t.sol` (mock stack, 12 tests) and
`contracts/test/pentest/B_Eip3009Fork.t.sol` (forks: Arbitrum One at block 519,194,000, Ethereum at 26,254,000; 10
tests). All pass. Run everything from `contracts/`:

```
forge test --match-path 'test/pentest/B_*.t.sol' -vv
```

No off-chain script was written: a local stack needs the Docker Postgres the harness expects (`service/test/harness.ts`
line 144), which this run must not touch. The two off-chain findings are proved by their on-chain half on a fork plus
the exact service lines that make the relayer pay.

**Summary.** No Critical or High. The on-chain signature layer holds: nothing signed works for another deal, escrow,
chain, action, amount or a second time, the EIP-3009 authorization can only be spent by its escrow, and both real
tokens enforce that. Two Medium findings are about yescrow's relayer paying real money for signatures anyone can
produce (LayerZero fees on micro-withdrawals of USDT, Ethereum gas on zero-value deposit authorizations). Two Low
findings are about signed offers and actions outliving the moment they were meant for (a settlement offer is a free
option against its signer once Kleros's votes are visible; action signatures aren't bound to the phase they were
signed in). One Info note on the EIP-1271 trust boundary.

---

### Findings

#### B-1. Relayed USDT withdrawals to another chain: each costs the relayer the whole LayerZero fee, for any amount, with no cap but a per-IP rate limit

**Severity:** Medium (loss of yescrow's ETH; a drained relayer stops every gasless step, including sponsored dispute
answers, which `docs/keys.md` §4 names as the relayer's worst case).

**Attacker and preconditions:** anyone with a balance in the USDT escrow of at least a few base units (1 USDT is
plenty; a deposit that can no longer fund a deal gives a balance to anyone, as the proof does). No deal needed.

**Steps:**
1. Get a balance: send 1 USDT0 to the deposit address of any terms whose `acceptBy` is about to pass, then call
   `returnDeposit` (anyone may): `usdcBalance[attacker] = 1e6`.
2. Sign `WithdrawTo(attacker, chainId = 1, attacker, amount = 1, deadline)` once per withdrawal with a different
   `deadline` (each is a new digest, so `withdrawalUsed` never blocks it).
3. `POST /v1/relay {action: 'withdrawTo', escrow: <USDT escrow>, account, chainId: 1, recipient, amount: '1', auth}`,
   20 times a minute per IP (`relayLimit`, `RELAY_RATE_LIMIT_PER_MINUTE` default 20). The API computes `withdrawFee`
   as 1.2 x USDT0's `quoteSend` and the relayer sends it as `msg.value`; the router refunds only the excess.

**Impact (measured on the Arbitrum One fork, block 519,194,000):** LayerZero's fee to carry 1 base unit (0.000001
USDT) to Ethereum is 17,218,985,393,064 wei (0.0000172 ETH) and does not depend on the amount. Five withdrawals of one
unit cost the relayer 86,094,926,965,320 wei. At the API's rate limit one IP burns about 0.496 ETH of the relayer's
balance a day while moving 0.0288 USDT; more IPs, more. A relayer out of ETH stops relaying every signed step and
sponsoring arbitration fees, so a sponsored party could then miss `feeDeadline` and lose by default (SPEC §7.1).

**Proof:** `test_fork_B_FINDING_relayerPaysTheWholeLayerZeroFeePerMicroWithdrawal`
```
forge test --match-path test/pentest/B_Eip3009Fork.t.sol --match-test relayerPaysTheWholeLayerZeroFee -vv
```

**Root cause:** `service/src/api/app.ts` lines 280–291 (`withdrawFee`: the relayer pays 1.2 x the quote for any
`amount`) and lines 1082–1096 (`case 'withdrawTo'`: no minimum amount, no per-account or per-day budget; the schema's
`amount` is any decimal string, `service/src/api/schemas.ts` line 7). On-chain, `YescrowEscrow.withdrawTo` (lines
685–714) accepts `amount >= 1` and `DepositRouter._send` (lines 209–244, `send{value: fee.nativeFee}`) spends the
caller's ETH on the message whatever the amount. The contract is right to allow it (a party paying its own fee may
bridge dust); the service shouldn't pay for it blindly.

**Suggested fix:** in the service, relay a cross-chain USDT `withdrawTo` only above a minimum amount (for example the
fee's value in USDT times ten, or a flat 20 USDT), cap relayed LayerZero fees per account per day the way sponsored
arbitration fees are capped (`SPONSOR_DAILY_WEI`), and refuse to pay more than once per account per hour; a party can
always send the transaction itself with the fee. The keeper's automatic payouts are unaffected (they carry the whole
payout amount).

**Verification of B-1:**

> **Verdict:** CONFIRMED (Medium stands)
>
> **Proof of concept:** in contracts: `forge test --match-path test/pentest/B_Eip3009Fork.t.sol --match-test relayerPaysTheWholeLayerZeroFee -vv` → **PASS**. Fee per 1-unit withdrawal to Ethereum: 17,218,985,393,064 wei. 5 withdrawals cost the relayer 86,094,926,965,320 wei (exactly 5× the fee; the 0.2× margin came back). 495,906,779,320,243,200 wei a day per IP. My tests in `contracts/test/pentest/verify/V_B-1_FeeVsGas.t.sol` (3 PASS):
> - The fee is identical for 1 unit, 1 USDT, 1,500 USDT and 1,000,000 USDT.
> - With gas (≈571k at the fork's 0.020 gwei base fee, L2 only), one relayed micro-withdrawal costs ≈28,664,122,297,064 wei: about 0.83 ETH a day per IP.
> - A same-chain 1-unit `withdrawTo` costs ≈120k gas (≈2.4e12 wei, about 0.069 ETH a day per IP). The LayerZero leg makes this plain gas griefing about 12× worse.
>
> **Sources unchanged:** yes. `diff -r` against base differs only in `contracts/test/pentest/`, an empty `scripts/pentest/` and `REPORT-B.md`.
>
> **What you checked:**
> - **The test.** The balance comes from a real `returnDeposit` of the attacker's own USDT0, signed with the attacker's own key. `deal` only funds the attacker and relayer. The mock arbitrator and verifier are never touched by `withdrawTo`. The router is `new DepositRouter()` (no constructor arguments, so the deployed code), with the real USDT0 OFT and LayerZero. `vm.prank(relayer)` with 1.2× the quote matches the service exactly.
> - **The service.** `amount` is any decimal, with no minimum. The only hooks are sanctions and idempotency. `withdrawFee` pays 1.2× `quoteSend` for any amount, then `relayer.call` simulates and sends. There is no budget. `.env.mainnet.example` has `DEPOSIT_CHAINS=8453,1`, so the route is live. Sponsored fees have a daily budget; LayerZero fees have none.
> - **Known or intended?** It is not in `docs/audit.md` §5. SPEC §14.14 has the relayer pay USDT0's fee "as for any relayed withdrawal", but an unbounded spend is documented nowhere: `docs/security.md` lists only per-IP rate limits.
> - **Root cause lines:** correct. `case 'withdrawTo'` is lines 1082–1091.
>
> **Reason / corrections:**
> 1. **The impact is understated.** The PoC counts only `msg.value`, because forge charges pranked calls no gas. At about 0.00057 ETH a minute, the 0.005 ETH `RELAYER_LOW_WEI` alert fires only about 9 minutes before the relayer is empty. Across IPs, the relayer's serialized queue caps throughput, and these relays hold up honest users' relays in it.
> 2. **The sponsored-party escalation is overstated.** `.env.mainnet.example` has `SPONSOR_DAILY_WEI=0`, so sponsorship is off. When it is on, `sponsor.ts:45` refuses once the relayer is low, and the API's `FEE_NOT_SPONSORED` tells the party to pay from its wallet within the 3-day fee period. So Medium rests on griefing, yescrow's lost ETH and an outage of gasless steps (parties can still act directly, SPEC §12), not on a party's loss.
> 3. **Only the on-chain leg is proven.** The API leg rests on code reading, not the local-stack script `docs/ai-pentest.md` asks for. I traced it and found nothing that bounds it.
> 4. **The suggested fix needs a change.** Per-account or once-an-hour limits are easy to get around with more accounts, each needing only a few base units and one `returnDeposit`. Use instead:
>    - a minimum amount for relayed cross-chain `withdrawTo`;
>    - a global daily budget for relayed LayerZero fees, reserved before sending (`SPONSOR_DAILY_WEI` is global too);
>    - an alert on the relayer's spend rate.
>
>    Consider a minimum for same-chain relays too: the same vector at about 1/12 of the cost.

#### B-2. Relayed zero-value USDC `transferWithAuthorization` on Ethereum: free signatures make the relayer burn mainnet gas

**Severity:** Medium (loss of yescrow's ETH on Ethereum; same knock-on as B-1 for deposits waiting there).

**Attacker and preconditions:** anyone. A draft deal with any terms is enough (`proposalOf` accepts drafts); the
attacker needs no USDC at all, because Circle's token accepts a zero-value authorization.

**Steps:**
1. Create a draft (`POST /v1/drafts`) or pick any existing deal; get its deposit address.
2. Sign `TransferWithAuthorization(from = attacker, to = deposit address, value = 0, validAfter = 0, validBefore,
   nonce = random)` with the Ethereum USDC domain; every nonce is a fresh authorization.
3. `POST /v1/relay {action: 'depositTransfer', dealId, chainId: 1, from, value: '0', validBefore, authorizationNonce,
   signature}` at the rate limit. The Ethereum relayer simulates (it passes), estimates gas and sends.

**Impact (measured on the Ethereum fork, block 26,254,000):** 84,037 gas per zero-value authorization, paid by the
relayer, nothing moved. The relayer's queue waits for each receipt, so one IP gets roughly 3,000–7,000 of them through
a day: at 1 gwei about 0.25–0.6 ETH a day, ten times that at 10 gwei; more IPs, more. Base is also reachable the same
way but its gas is negligible.

**Proof:** `test_fork_B_FINDING_zeroValueTransferAuthorization_anySubmitter`
```
forge test --match-path test/pentest/B_Eip3009Fork.t.sol --match-test zeroValueTransferAuthorization -vv
```

**Root cause:** `service/src/api/app.ts` lines 1068–1081 (`case 'depositTransfer'`): any `from`, any `value`
(including 0, `schemas.ts` line 7), any draft, no check that `from` holds `value`, that `value` is at least what the
deal still needs, or that this deal already got a relayed transfer; `relayer.call` only simulates for reverts.

**Suggested fix:** relay a deposit authorization only when `value` is at least the deal's missing amount (or the price)
and `from`'s token balance covers it (one `balanceOf` call), at most once per deal and chain, and only for drafts
whose terms the API accepted; put Ethereum relays under a daily gas budget like the sponsored fees.

**Verification of B-2:**

> **Verdict:** CONFIRMED (Medium stands), with corrections to the impact numbers and the fix.
>
> **Proof of concept:** run from `B/contracts`: `forge test --match-path test/pentest/B_Eip3009Fork.t.sol --match-test zeroValueTransferAuthorization -vv` → PASS: 84,037 gas per zero-value authorization (average of 3), nothing moved. My check, `forge test --match-path test/pentest/verify/V_B-2_realTxCost.t.sol -vv`, also passes:
> - A griefer holding 0 USDC spends a fresh authorization each time; balances and supply don't change.
> - The node's `eth_estimateGas` for the whole transaction at block 26,254,000 is 82,416 gas (24,176 of it base cost plus calldata), so the PoC's figure is within about 2%.
> - That block's base fee was 0.084 gwei.
>
> **Sources unchanged:** yes. Excluding build output, only `contracts/test/pentest/`, an empty `scripts/pentest/` and `REPORT-B.md` differ.
>
> **What I checked:**
> - **The test:** the attacker's own key, Circle's real USDC on an Ethereum fork, no `deal`, `store` or `etch` on real contracts. `vm.prank` only stands in for the relayer; gas is the same without it (81,078 vs 81,080).
> - **`app.ts` 1068–1081** (identical to pristine) never checks `value`, `from`'s balance, how many relays a deal has had, or that `authorizationNonce` came from `POST /v1/deposit-authorizations` (the schema says it does).
> - **Getting a deal ID:** `schemas.ts:7` accepts `"0"`. `proposalOf` takes drafts. `POST /v1/drafts` has only the global 120/min limit, and any on-chain deal ID also works.
> - **Mainnet:** `.env.mainnet.example` turns on Ethereum (`DEPOSIT_CHAINS=8453,1`). `relayer.ts` simulates, then sends; value 0 passes.
> - **Known issues:** not in `docs/audit.md` §5. SPEC §12 and `docs/keys.md` §4 accept that the relayer may run dry, but name only the per-IP rate limit as protection. The relayer's ETH is budgeted only for sponsored fees (§14.17).
>
> **Reason / corrections:**
> 1. **"More IPs, more" is wrong for the ETH burned.** Each chain's relayer sends one transaction at a time and waits for its receipt (`relayer.ts` 45–59). That caps Ethereum at about one relay per block (≤7,200 a day), and one IP at 20/min already fills it. Most it can burn: about 0.59 ETH a day at 1 gwei, about 0.05 ETH a day (plus tips) at the fork block's 0.084 gwei.
> 2. **A bigger harm, not claimed** (from the code; the service wasn't run). The backlog grows by about 900 an hour per IP, and the keeper shares that queue (`main.ts` 21/27/52).
>    - Honest gasless deposits (authorizations valid one hour) expire while waiting, then go out with their pre-estimated gas and revert.
>    - `Keeper.tick` waits behind the backlog for its Ethereum `forward` and `receiveMessage` calls (`keeper.ts` 119–120, 320) whenever ≥10 USDC sits at a deal address there. The attacker can arrange that with its own draft.
>    - Meanwhile every Arbitrum keeper duty stops: releases, refunds, automatic proofs, deposit-funded accepts before `acceptBy`, Kleros calls and payouts.
>    - `ops.ts` alerts only on loops that throw, so nothing reports the stall.
>
>    Anyone can still call every step, so no party loses funds. The result is delays past promised deadlines plus loss of yescrow's ETH: Medium.
> 3. **Labels disagree:** the PoC's comment says "Low", the report says Medium.
>
> **Root cause:** correct. Also: nothing checks where the nonce came from, and each chain's queue is unbounded and shared with the keeper.
>
> **Fix:**
> - "Value ≥ the missing amount" isn't enough: a price can be 1 base unit (`_termsOk` and `deals.ts:119` only reject 0) and drafts are free. Also require `value ≥ FORWARD_MIN` for that chain (10 USDC on Ethereum, `keeper.ts:30`) and `balanceOf(from) ≥ value`.
> - Relay only nonces the service issued, each once.
> - Add a daily gas budget per deposit chain.
> - Cap the queue, and keep the keeper's sends out of the API's queue.
> - "Only drafts whose terms the API accepted" already holds.

#### B-3. A settlement offer is a free option against the party that signed it once Kleros's votes are visible

**Severity:** Low (no party is forced, the signer consented to the amount; but the design shifts expected value to
the offeree, systematically, through the web app's 24-hour offer links).

**Attacker and preconditions:** the party that receives a settlement offer during a dispute. The web app
(`web/components/deal/steps.ts` lines 211–222) has the offerer sign `Settle(..., sellerAmount, caseCreated = true,
deadline = now + 24 h)` and share a link carrying that signature. Kleros's AI court rules in about a day, with votes
and `currentRuling` public on-chain, and the escrow accepts `settle` in `Disputed` until someone calls `applyRuling`.

**Steps:**
1. Seller offers 50/50 (signs the link). Buyer does nothing.
2. Kleros votes; the case reaches its execution period with ruling 1 (the seller delivered): `currentRuling` is final
   and readable, the escrow could apply it.
3. The buyer, who would get nothing, counter-signs the old offer and calls `settle` before anyone calls `applyRuling`
   (no race needed: the buyer can already read the vote direction during the vote and appeal periods, before
   `applyRuling` is possible at all). Had the vote gone the buyer's way, the buyer lets the ruling apply.

**Impact (mock Kleros, price 5,000 USDC):** the seller gets 2,475 USDC and half the held fee instead of 4,950 USDC
and the whole held fee; 2,475 USDC shifts to the buyer by the exercised option. `applyRuling` then reverts
(`WrongState`), so the Kleros ruling is dead.

**Proof:** `test_B_FINDING_settlementOfferIsAFreeOptionOnceTheRulingIsVisible`
```
forge test --match-path test/pentest/B_Signatures.t.sol --match-test settlementOfferIsAFreeOption -vv
```

**Root cause:** `contracts/src/YescrowEscrow.sol` `settle` (lines 573–591) and `_settleDigest` (902–922): the phase
flag `caseCreated` says only whether the case exists, not how far it is, and a signed offer can't be revoked; the
web's 24-hour validity (`steps.ts` line 212) spans the AI court's whole case.

**Suggested fix:** any of: (a) in `settle`, while `Disputed`, read the case's period as `applyRuling` does
(`_read(arbitrator, IKlerosCore.disputes.selector, id, 2)`) and refuse once it is past the evidence period (or
once `currentRuling` is final), so a settlement is only possible before the jurors speak; (b) a per-party settlement
nonce in the `Settle` digest with `revokeSettlementOffers(dealId)` so an offerer can withdraw; (c) in the web, a
short offer validity (an hour or two) and a sentence in the signing summary that the other side may wait for the
votes before accepting.

**Verification of B-3:**

> **Verdict:** CONFIRMED (Low)
>
> **Proof of concept:** `forge test --match-path test/pentest/B_Signatures.t.sol --match-test settlementOfferIsAFreeOption -vv`, run from `pentest/B/contracts`: PASS.
> - Seller's payout under ruling 1: 4,950 USDC. After the exercised 50/50 offer: 2,475 USDC, so 2,475 shifts to the buyer.
> - The seller's held fee is A/2 instead of A, and `applyRuling` then reverts `WrongState`.
> - My timing checks, `contracts/test/pentest/verify/V_B-3_Timing.t.sol`: 3 tests, all PASS.
>
> **Sources unchanged:** yes. Apart from out/ and cache/, `diff -r` against `base` differs only in `contracts/test/pentest/`, `scripts/pentest/` and `REPORT-B.md`. `src`, `script`, `lib`, `foundry.toml`, `Base.t.sol` and the mocks are identical.
>
> **What I checked:**
> - **The test.** The pranks are the buyer acting for itself and the seller paying its own fee. The seller's key signs only its own voluntary offer. There is no `vm.store`, `vm.etch` or extra `deal`. The assertions are exact.
> - **The mocks.** They match Kleros v2: `rule` reaches the resolver, never the escrow, so `settle` stays open in `Disputed` until `applyRuling`.
> - **The code.** `settle` (573–591) and `_settleDigest` (902–922) have no period check, no nonce and no way to withdraw an offer. `steps.ts` lines 211–212 set the deadline to chain time + 24 h. `Dispute.tsx` tells the offerer only that the link is "valid for 24 hours".
> - **The spec.** SPEC §7.2 allows a settlement "at any point before the ruling" on purpose. Nothing in SPEC or `audit.md` §5 covers an offer that outlives the vote. E3 (`caseCreated`) binds the phase, not the Kleros period.
> - **Mainnet KlerosCore, read today.** Court 34 has hidden votes and periods of 10 min / 45 min / 30 min / 36 h. Court 1 has hidden votes and periods of 78 h / 162 h / 162 h / 108 h. Both match `dependencies.md` §1.8, which gives court 34 a median of 19.1 h to its ruling.
>
> **Reason / corrections:**
> 1. **The PoC compresses time.** At its own price, a 5,000 deal goes to the General Court. There, an offer signed when the case opens expires at least 54 h before any vote can be public (V test 3: `SignatureExpired`). Only offers signed within 24 h before the reveal are exposed (V test 3: 2,475 instead of 4,950). "Rules in about a day" describes the AI court, which takes deals up to 2,000. There a 50/50 offer shifts at most about 1,000 (V test 1: 990 instead of 1,980).
> 2. **In the AI court the exposure is real, and no race is needed.** Offers signed in about the first hour of the case, before the reveal, are exposed. That is exactly when a party would sign to settle "instead of waiting for Kleros". The offeree then has the ~18 h appeal period to decide, while `applyRuling` reverts `TooEarly` (V test 1). If the vote goes its way, it lets the ruling apply (V test 2).
> 3. **Low is right.** The offerer gets exactly what it signed; nobody is forced and nothing is stolen.
> 4. **Fix (a) is too broad.** It contradicts SPEC §7.2 and blocks informed settlements, for example both sides seeing the vote and settling instead of appealing.
>    - **A narrower fix:** bind `Settle` to the period it was signed in, for example `lastPeriodChange`, word 4 of `disputes(id)` through `_read`. The offer then lapses when the case moves on. Fix (b) also works.
>    - **Fix (c):** a shorter validity can't beat a sub-hour AI vote. Its useful part is the warning that an offer can't be withdrawn and the other side may wait for the vote.

#### B-4. Action signatures aren't bound to the phase they were signed in: a stale `Confirm` can close a dispute the buyer opened after signing, taking the fee it paid

**Severity:** Low (needs a signature that outlives its submission; the web app relays at once with a one-hour
deadline, but the API lets a client pick any `deadline`).

**Attacker and preconditions:** whoever ends up holding a party's action signature after the deal moved on: the
relayer (its queue can submit late), the counterparty if the signature reached it, or an integrator that asked for
a long `deadline` (`GET /v1/deals/{id}/typed-data?deadline=` is unbounded, `service/src/api/app.ts` line 765).

**Steps:** the buyer signs `Confirm` in `Proven`; the submission doesn't land; the buyer challenges instead (pays A)
and the seller answers (Disputed); the seller submits the stale `Confirm`.

**Impact:** `Released`: the seller gets the price less the fee and the held fee; the buyer's arbitration fee is
gone. For a seller's stale `Cancel` the mirror image holds. Settlements were given a phase flag for exactly this
reason in v0.27 (E3); the five other action types weren't.

**Proof:** `test_B_INFO_staleConfirmLandsDuringDispute_signerLosesItsFee`
```
forge test --match-path test/pentest/B_Signatures.t.sol --match-test staleConfirmLandsDuringDispute -vv
```

**Root cause:** `_authorize` (lines 887–900): the digest has `dealId, domain, buyer, seller, price, deadline` and
no phase; `confirm`/`cancel` accept every state from `Active` to `Judging` (`_openDeal`, lines 956–959).

**Suggested fix:** cap the API's `deadline` (reject more than an hour ahead) and have the relayer re-check the deal's
state right before sending a `confirm`/`cancel`; for the next contract version, add the state the signer saw (or
`provenAt`) to the action digest, as `Settle` has `caseCreated`.

**Verification of B-4:**

> **Verdict:** CONFIRMED (Low), with corrections to the suggested fix.
>
> **Proof of concept:** `forge test --match-path test/pentest/B_Signatures.t.sol --match-test staleConfirmLandsDuringDispute -vv` (from `pentest/B/contracts`): PASS. The buyer signs a Confirm in Proven (one-hour deadline), then challenges (A = 1e13 wei); the seller answers and then submits the Confirm. Result: `Released`, the seller is credited 4,950 USDC (5,000 less the 1% fee) plus the held fee A, and the buyer gets 0.
>
> My extra tests, all 7 passing (`contracts/test/pentest/verify/V_B-4_staleActions.t.sol`):
> - In net ETH the buyer is down 1e13 and the seller is even. The buyer's fee went to Kleros; the seller only got its own back.
> - The mirror case holds: a stale Cancel ends `Refunded` and the seller's fee is gone.
> - A Confirm with a 365-day deadline still lands in Judging after the 90-day stall.
> - If the Confirm lands in AwaitingFee, or in Judging after a refusal, the buyer's fee comes back whole. Only the dispute is lost.
> - Had the buyer won the case, it would have had 5,000 USDC and A back.
>
> **Sources unchanged:** yes. Only `contracts/test/pentest/`, `scripts/pentest/` and `REPORT-B.md` differ from `base`; `lib`, `broadcast` and `foundry.toml` are identical.
>
> **What you checked:**
> - The test signs only the buyer's own Confirm with the buyer's key and pranks only the two parties as themselves. There's no `vm.store` or `vm.etch`; `confirm`/`_close` don't depend on the mocks.
> - The root cause is right. `_authorize` (lines 887–900) binds no phase, and `_openDeal` (956–959) accepts every state from Active to Judging. Only Confirm and Cancel are exposed: the other four action types each work in one state that the deal leaves for good, so "the five other action types" overstates it.
> - It isn't a known issue in `docs/audit.md` §5. §3 claims phase binding for settlements only. `docs/security.md`'s threat model says actions bind "deal, terms and deadline" and that the relayer may "submit or withhold signed actions". SPEC §5 allows concessions in every open state but says nothing about signatures made before a dispute. E3 fixed the same flaw in `Settle`.
> - The service: the typed-data `deadline` has no maximum (`app.ts` line 765; the schema has only `minimum: 0`). The web app uses the one-hour default for actions; the relayer sends from an in-memory queue and stores nothing; Arbitrum has no public mempool. In the shipped flows only yescrow's relayer holds the signature, for seconds.
>
> **Reason (Low stands):** the signer did sign the release. The extra harm is the signer's own arbitration fee plus the dispute it opened. The fee goes to Kleros; the submitter gains nothing. Today it's about 0.0008 ETH in the AI court, or one juror's fee in the General Court (SPEC §9). A counterparty needs an unexpired signature, and only an API client that asked for a long deadline and leaked it would give it one. This isn't Medium griefing, because no attacker can get the signature on its own.
>
> **Corrections to the fix:**
> - Binding `provenAt` doesn't work. A challenge and an answer leave it unchanged (`test_V_B4_provenAtUnchangedByDispute`), so a Confirm bound to it stays valid in Disputed. Bind the dispute phase instead, as `Settle` does: no dispute (Active/Proven), AwaitingFee, case created (Disputed/Judging).
> - A one-hour cap must leave `settle` alone, because the web app's settlement offers last 24 h (`steps.ts` line 212). Cap the six action types. Also check `auth.deadline` in `POST /v1/relay`, since the contract accepts any deadline a client builds itself.
> - The relayer's re-check should compare the deal's state when the request arrived with its state just before `sendTransaction`. Refusing dispute states outright would break the web app's own concessions (`DealPage.tsx` line 473).

#### B-5 (Info). The EIP-1271 fallback makes a party's own code the judge of its signatures

`_isValidSignature` (lines 926–932) tries ECDSA, then EIP-1271. A party whose address runs permissive code (an
EIP-7702 delegation to a "sweeper" or any contract answering the magic value for every digest) can be acted for by
anyone: `test_B_INFO_permissiveDelegate_letsAnyoneActForTheParty` has a stranger's key release the buyer's price. This
is the party's choice and the standard EIP-1271 trust boundary, not an escrow bug; ECDSA-first is right (the EOA key
is the root of a 7702 account and can always re-delegate). Worth one line in the docs, since 7702 phishing
delegations are common.

**Verification of B-5:**

> **Verdict:** CONFIRMED (Info)
>
> **Proof of concept:** `forge test --match-path test/pentest/B_Signatures.t.sol --match-test permissiveDelegate -vvvv` (from `B/contracts`): PASS. Trace: ecrecover of the stranger's Confirm gives 0x981F… (not the buyer), then the buyer's `isValidSignature` returns `0x1626ba7e` and the deal emits `Released(sellerAmount 4,950,000,000, fee 50,000,000)`. The test asserts only the state.
>
> My checks, in `contracts/test/pentest/verify/V_B-5_permissiveDelegate.t.sol` (3 tests, all PASS), use a real 7702 designator signed by the buyer's own key (`vm.signAndAttachDelegation`), not `vm.etch` of raw code:
> 1. From Active, before any transfer, the seller signs "the buyer's" Confirm with its own key and withdraws 4,950,000,000. The buyer gets 0.
> 2. After a refund, a stranger with no part in the deal signs WithdrawTo(buyer → itself) and takes the buyer's 5,000,000,000.
> 3. On a fork of Arbitrum One (block 519,194,000), real USDC: the same delegate lets the same stranger `transferWithAuthorization` the wallet's 5,000 USDC to itself, no escrow involved (FiatToken v2.2's SignatureChecker makes the same `isValidSignature` call).
>
> Also PASS: the repo's `test_eip7702DelegatedEoa_signsWithItsKey` (ECDSA first) and `test_fork_B_INFO_usdt0_eip1271WalletFunding` (USDT0 also accepts EIP-1271).
>
> **Sources unchanged:** yes. Only `contracts/test/pentest/`, `scripts/pentest/` and `REPORT-B.md` differ from the pristine export; `src/`, `script/`, `lib/`, `foundry.toml` and `broadcast/` are identical.
>
> **What I checked:**
> - `_isValidSignature`, YescrowEscrow.sol lines 926–932 (same in the pristine export), tries ECDSA, then `SignatureChecker.isValidERC1271SignatureNow`. Every party signature goes through it: `_authorize` (accept, confirm, cancel, dispute, challenge, payArbitrationFee), `settle`, `withdrawTo`.
> - The PoC's `vm.etch` writes only into the victim's own account, standing in for a delegation the victim signs; a real designator behaves the same (check 1). No prank as a signer, nothing written into yescrow's or third-party contracts; the stranger signs with its own key.
> - `docs/audit.md` §5 lists only the opposite 7702 limit (USDC funding needs a delegate with 1271). `docs/security.md` "EIP-7702 delegated accounts" justifies ECDSA-first but doesn't say a party's code can approve anything. Not a known issue. SPEC §14.16 supports EIP-1271 wallets on purpose.
>
> **Reason (severity stands):** The PoC undersells the reach. It releases a deal already Proven, which would release after the challenge window anyway: the buyer loses only its right to challenge, and the stranger gains nothing. The real reach is every party action and the party's balance:
> - a release from Active with no transfer (checked: the seller gains 4,950 USDC);
> - any split in `settle` with one side forged (from the code, not tested);
> - `withdrawTo` of the party's whole escrow balance by anyone, to any address and chain (checked).
>
> It is still not an escrow weakness:
> - An EIP-1271 "yes" is by definition the account's own signature, and only the party's own key or choice of wallet installs that code, so the precondition equals a leaked key.
> - A sweeper's controller already acts as the party through the no-signature `msg.sender` path, so the fallback gives it nothing new.
> - A permissive delegate already hands anyone the wallet's USDC at Circle's contract (check 3), and its USDT0 too.
>
> The escrow is never the weakest link, so Info is right.
>
> **Root cause and fix:** The line is right as the place where trust passes to the party's code (line 931), but it is not a defect. The docs line belongs in `docs/security.md`'s 7702 section and `docs/audit.md` §4 or §5, and should name `withdrawTo` (anyone can take the balance), not only "acting for the party". Optional, off-chain: the web app could `eth_call` `isValidSignature(random digest, junk signature)` on a party's address and warn if it answers yes. Don't change the contract (e.g. ECDSA only for 7702 designators): that would lock out 7702 wallets that sign with passkeys or session keys, and protect nothing the tokens and the `msg.sender` path don't already expose.

---

### Checked and found sound

Each item names the evidence (tests in `B_Signatures.t.sol` unless marked fork).

- **EIP-712 domain.** `EIP712("yescrow", "1")` with chain ID and verifying contract (OpenZeppelin 5.4 recomputes the
  separator when `block.chainid` changes). The same deal ID exists at the USDC and USDT escrows (the ID doesn't name the
  escrow), and nothing signed for one works at the other: Accept, Confirm, Settle, WithdrawTo, FeeVoucher,
  ReferrerShare and the EIP-3009 funding all refused (`test_B_sameDealId_signaturesDoNotCrossEscrows`); a signature
  made under one chain ID is refused under another and works again on the right one
  (`test_B_signaturesDoNotCrossChains`).
- **Type hashes and encodings.** Every `*_TYPEHASH` string matches its `abi.encode` order and types (string hashed,
  bool and small integers as words), and the service builds the same typed data (`typed-data.ts` `ACTION_FIELDS`,
  `settleTypedData` with `caseCreated`, `withdrawToTypedData`; `promo.ts` `FeeVoucher` uint16/uint64; `admin.ts`
  `ReferrerShare`), so a mismatch couldn't even reach the chain.
- **Replay within a deal.** Each action type is accepted in exactly one set of states that the action leaves for good
  (Accept: Proposed; Dispute: Active; Challenge: Proven; PayArbitrationFee: AwaitingFee; Confirm/Cancel: close the
  deal), and final states never reopen, so no action signature works twice. Settle closes the deal. Existing tests
  (`Misc.t.sol`, `Transitions.t.sol`, `SponsoredFees.t.sol`) cover wrong signer, other action, other deal, expiry.
- **Settlement phase.** `caseCreated` separates AwaitingFee from everything after (Hardening tests). Disputed and
  Judging share `true`, which is safe: a settlement signed while the case was at Kleros still works after the case
  stalls, with identical token and held-fee splits (`test_B_settleSignedInDisputed_sameMoneyAfterStall`). A refused
  case jumps straight from AwaitingFee (`false`) to Judging (`true`), so no earlier `true` signature can exist for it.
- **Malleability and encodings.** The high-s twin of a valid signature is refused (OpenZeppelin `ECDSA`), 64-byte
  compact signatures are refused, all-zero signatures are refused; `withdrawalUsed` is keyed by digest, so no other
  encoding of the same WithdrawTo could spend it twice (`test_B_malleableSignatureRejected`,
  `test_B_compactSignatureRejected`).
- **EIP-3009 at the real tokens (fork).** Only the payee spends a receive authorization: USDC answers "FiatTokenV2:
  caller must be the payee", USDT0 "TetherToken: to != msg.sender", so nobody can push the buyer's money into the
  escrow outside the deal and strand it (`test_fork_B_usdc/usdt0_receiveAuthorization_onlyThePayeeSpendsIt`). A
  receive signature is no transfer signature on either token, while a real transfer authorization works for any
  submitter (`..._receiveSignatureIsNoTransferSignature`). An authorization names one escrow; a second escrow on the
  same token can't spend it, and once spent the deal-ID nonce is gone (`test_fork_B_authorizationNamesOneEscrow`).
  The buyer can cancel its authorization before use (`test_fork_B_buyerCancelsItsAuthorizationBeforeUse`). Both real
  tokens accept an EIP-1271 wallet as `from` (`test_fork_B_eip1271WalletFundsAndConfirms_realUsdc`,
  `test_fork_B_INFO_usdt0_eip1271WalletFunding`: accepted). The nonce is the deal ID, which commits to every term and
  the proposer; funding measures the balance delta (`InsufficientDeposit`, existing test).
- **Front-running signed steps.** A stranger submitting the buyer's funding first creates exactly the deal the buyer
  signed for; the authorization can't fund other terms (`test_B_frontRunningAFundingSignature_createsTheSameDeal`). A
  counterparty submitting the other side's signed Challenge or PayArbitrationFee with its own ETH becomes that fee's
  payer and gets only its own ETH back; the signer's deal money and ETH are unchanged whoever wins
  (`test_B_counterpartySubmitsSignedChallenge_noLossToSigner`, `test_B_openerSubmitsSignedAnswer_noLossToSigner`).
  Refunds-to-payer (SPEC §2 rule 2) make holding someone's fee signature worthless.
- **EIP-1271 edge cases.** `SignatureChecker.isValidERC1271SignatureNow` (OZ 5.4): staticcall, result >= 32 bytes,
  magic compare. A precompile named as referrer (identity, sha256) can't be made to approve a WithdrawTo: the identity
  precompile echoes selector||hash, never the bare magic (`test_B_precompileCannotBeMadeToApproveAWithdrawal`). The
  escrow itself or a token as referrer has no `isValidSignature` (reverts -> false). `address(0)` never holds a balance.
- **EIP-7702.** ECDSA first, then 1271: a delegated EOA keeps signing with its key (no lockout), and nobody holds a
  key for a real contract address. The known limit (Circle's token needs the delegate to implement 1271 for EIP-3009)
  is in `docs/audit.md` §5; the deposit address is the fallback.
- **ERC-6492.** Nothing on-chain; `service/src/erc6492.ts` deploys only through the two Coinbase factories and only
  if `eth_call` of the carried `data` returns the signer's own address, with `dryRun` never deploying; the signer is
  taken from the deal's role, not the request (`app.ts` lines 945–963). Worst case: a factory view call
  (`getAddress`) that returns the signer makes the relayer send one pointless transaction (Arbitrum gas, cents).
- **WithdrawTo.** Bound to account, chain, recipient, amount, deadline and escrow; one use by digest; existing
  `Deposits.t.sol` tests cover redirection. A chain the router doesn't serve reverts (`NoRoute`), USDC crosses by CCTP
  with no fee, USDT by the OFT with `minAmountLD = amount`, and a front-runner can't make a signed USDT withdrawal
  succeed with too little `msg.value` (`WrongValue`). Keeper payouts (`payouts.ts`): recipient = the signer itself,
  the amount fixed, stored privately, deadline a year past `acceptBy` with a per-deal spread so two identical payouts
  don't collide; the keeper sends only after a close that credited at least the signed amount.
- **FeeVoucher.** Only lowers (`FeeNotLower`, so no replay), only open deals, only until `promoEnds`/`expiry`,
  domain-bound (cross-escrow test above; `Promo.t.sol` for the rest). The promo key can't raise a fee or touch the
  price.
- **ReferrerShare.** Nonce, deadline, domain; applies to deals proposed afterwards (`ReferrerShare.t.sol`,
  cross-escrow test above).
- **The judge** has no signature surface (`msg.sender == judge`), rules only in `Judging`, 0–2, before its deadline.
- **AdminSession (off-chain).** Bound to the admin address, the site URL and the chain ID, 12 hours, checked against
  the allow-list on every request; a bearer replay inside those 12 hours is the design; it only reaches promo codes.
- **Sponsored fees.** A party can only make yescrow lose the AI-court fee it chose to sponsor (one per party per deal,
  daily budget, `sponsor.ts`); refunds come back to the relayer.

### Unconfirmed

- **ERC-6492 factory view trick (Info).** `deployedSignature` accepts a 6492 envelope whose `data` is the Coinbase
  factory's `getAddress(owners, nonce)` (returns the signer's address, deploys nothing): the relayer sends one useless
  transaction, then the main step fails simulation. Not scripted (no local stack); cost is cents of Arbitrum gas.
- **AdminSession across stacks.** If two services share `WEB_URL` and chain ID (a preview and production), one
  session token works on both. Configuration-dependent; not checked against the live stacks.
- **Rate limit keying.** Both Medium findings are bounded per IP only if `trustProxy` matches the real proxy chain
  (`app.ts` line 105). If it doesn't, every relay shares one bucket: the attack is slower, but 20 relays a minute
  then also starve honest gasless steps.
- **Stale-signature window (B-4) in practice.** The relayer simulates before queueing and sends with a 120-second
  receipt wait, so a stale `Confirm` needs the state change to land between simulation and inclusion, or a client that
  asked for a long `deadline`. Not measured against the live relayer.
- **Settlement links carry the signature in the URL** (browser history, referrers). A third party can't use it
  (the other party's signature is needed too), so no loss; noted for hygiene.
- **Two escrows on one token.** The deal-ID nonce is per token, so the same buyer can't fund the same deal ID by
  signature at two escrows that share a token (proved on the fork). A curiosity, not a loss: nobody wants to fund the
  same terms twice, and the deposit address still works.

---

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

Commit under test: `3ca0628` (main, 2026-10-29). Working copy:
`the repository root`.
Model: Claude (Fable 5.1), 2026-10-29.

Proofs of concept (all passing):

```
cd contracts
forge test --match-path test/pentest/C_InterProofBlocking.t.sol -vv     # 6 tests, all pass

cd the repository root
node scripts/pentest/C_template_re2.ts                                         # the template under RE2 "sgiu"
```

Files added: `contracts/test/pentest/C_InterProofBlocking.t.sol`, `scripts/pentest/C_template_re2.ts`. Nothing under
`contracts/src/` was changed.

Summary: no way was found to make a proof pass for a fact the registry didn't state, to pass a proof with one
attestor, to reuse a stale attestation, or to make the signed parameters say anything but the domain's own registry
URL under yescrow's exact template. The two findings are griefing paths through which the party holding the domain
blocks the other party's proof, which SPEC §6.3 says must not be possible through nameservers.

---

### Findings

#### C-1. A 17th nameserver makes every InterRegistrar proof revert (the count cap is a revert, not the V3 "no hash")

- **Severity:** Medium (griefing: the seller who delivered is forced into a dispute, and loses the price if they miss
  the transfer deadline; the same lever lets a seller lock a deposit-paying buyer's money until `acceptBy`). It would
  be High if a registry among the 25 TLDs accepts 17 or more nameservers for a domain: see the precondition.
- **Attacker and preconditions:** the buyer after delivery (InterRegistrar deal: they hold the domain), or the seller
  before acceptance of their own proposal. The registry must accept 17 or more nameservers for the domain. The common
  cap is 13, but it is an operational convention from the 512-byte DNS UDP limit, not a specification (RFC 5731 sets
  none); I could not confirm the current cap of Identity Digital, Google Registry, CentralNic, Nominet (whose
  gTLD platform runs `.now`/`.bot`) or GoDaddy Registry (`.co`) from a primary source (see Unconfirmed U1).
- **Steps (buyer):** the deal is Active with a passing "before" proof; the domain arrives at the buyer's registrar;
  the buyer sets 17 nameservers (about 1.1 KB in the attestation, far below the 8 KB byte cap). The seller's "after"
  proof, from both attestors, now reverts with `RdapVerifier.BadValue()` inside `_nameserversHash`, whatever the
  seller does, until the transfer window ends; `refund` then credits the buyer the whole price while they keep the
  domain. The seller's only recourse is `dispute` before the deadline (SPEC §6.5), paying the arbitration fee first.
- **Steps (seller):** the seller proposes with `acceptBy` 30 days ahead, the buyer pays the deposit address (SPEC
  §4.1, no wallet needed), and the seller's domain lists 17 nameservers: no "before" proof can pass, `accept` reverts,
  and `returnDeposit` refuses the deal token until `acceptBy`. The buyer's 5,000 USDC are locked for 30 days.
- **Impact (from the tests):** `test_C1_seventeenNameservers_blockEveryAfterProof_buyerRefundedWithTheDomain`: the
  buyer ends with 5,000 USDC back and the domain, the seller with 0. `test_C1_sellerWithSeventeenNameservers_locks…`:
  5,000 USDC locked for 30 days. `test_C1_sixteenNameservers_afterProofPasses` and
  `test_C1_overTheByteCap_passes_soOnlyTheCountCapBlocks` show that 16 names pass and that a list over 8 KB passes
  with `nameserversHash = 0`, so only the count cap blocks. `scripts/pentest/C_template_re2.ts` (case 1) shows that the
  template, run as the attestor runs it (RE2, flags `sgiu`), extracts a 13-, 16-, 17- and 20-entry list unchanged, so
  both attestors sign such a record and the revert is the verifier's alone.
- **Proof of concept:** `forge test --match-path test/pentest/C_InterProofBlocking.t.sol -vv` (run from the
  `contracts` directory of the working copy) and `node scripts/pentest/C_template_re2.ts` (from the copy's root).
- **Root cause:** `contracts/src/RdapVerifier.sol:319` (`if (n == MAX_NAMESERVERS) revert BadValue();` in
  `_nameserversHash`), reached from `_facts` at line 170 for every proof whose nameservers capture is at most
  `MAX_NAMESERVERS_BYTES` (line 148). SPEC §6.2/§6.3 and V3 promise that a long list "gives no nameservers hash" so an
  InterRegistrar proof can't be blocked; the count limit predates V3 and still reverts. The escrow (`YescrowEscrow.sol:452`)
  needs only `registrarId`, `domainHash`, `fetchedAt`, `expiration` and `statusFlags` for InterRegistrar deals.
- **Suggested fix:** in `_nameserversHash`, when the 17th `"ldhName"` is found return `bytes32(0)` (the same outcome as
  a list over the byte cap) instead of reverting; an IntraRegistrar proof then fails as intended and an InterRegistrar
  proof passes. Alternatively count the names first and skip the hash above `MAX_NAMESERVERS`. Either is a one-line
  change in the verifier, which is a new deployment anyway (SPEC v0.24: deals keep their verifier).

**Verification of C-1:**

> **Verdict:** DOWNGRADED to Low
>
> **Proof of concept:** `forge test --match-path test/pentest/C_InterProofBlocking.t.sol -vv` (from `C/contracts`):
> PASS, 6/6. With 17 names, the "after" proof reverts `BadValue` in each half and in `submitProof`. After the 10-day
> window, `refund` credits the buyer 5,000 USDC (5_000e6) and the seller 0. The 16-name control passes. A 40-name list
> over 8 KB passes with no hash. A buyer who paid the deposit address gets 5,000 USDC back only at `acceptBy` (30 days).
> 16 × 253-character names cost 16,499,510 gas. `node scripts/pentest/C_template_re2.ts` (from `C/`): PASS. It extracts
> 13, 16, 17 and 20 names unchanged (17 names: 1,148 bytes escaped).
>
> **Sources unchanged:** yes. `diff -rq -x out -x cache -x node_modules base C` differs only in `contracts/test/pentest/`,
> `scripts/pentest/` and `REPORT-C.md`.
>
> **What I checked:**
> - **Root cause:** correct. `RdapVerifier.sol:319` reverts at the 17th `"ldhName"`, and `_facts` (line 170) calls it
>   whenever the capture is 8 KB or less (line 148).
> - **Signatures:** the PoC signs with the repo's test-only keys, on a `DualRdapVerifier` built from two `RdapVerifier`s
>   deployed with those keys. That is production code apart from the attestor addresses. The revert happens in `_facts`,
>   which reads only the context, so who signed doesn't matter. The context has the attestor's exact layout, and the
>   Node script mirrors attestor-core 5.1.5's loop (RE2 `sgiu`, no limits on captures). So both real attestors would sign
>   this record, if a registry served 17 nameservers.
> - **Test honesty:** no `vm.store` or `vm.etch`; each prank is a party acting for itself; the mocks aren't on the
>   path. The 16-name control shows the revert comes from the count, not from another `BadValue`.
> - **Real fixtures (all 28):** each has exactly one `"ldhName"` per nameserver object, and every capture parses as the
>   nameservers array alone. Reaching 17 therefore takes 17 real hosts.
> - **Precondition (U1):** Google Registry's own code (Nomulus `DomainFlowUtils`, `MAX_NAMESERVERS_PER_DOMAIN = 13`,
>   "Only 13 nameservers are allowed per domain") covers `.app` and `.dev`. Registrars' TLD documentation lists 0–13 for
>   .com, .net, .cc, .org, .io, .ai, .me, .live, .xyz, .art, .co and .fm (OpusDNS), and for .bot on Nominet's backend
>   (GoDaddy). The other TLDs run on the same platforms.
> - **Fix safety:** `YescrowEscrow._termsOk` (line 834) requires a non-zero hash for IntraRegistrar deals, so returning 0
>   can't make an Intra proof pass.
>
> **Reason:** the defect is real. SPEC §6.3 and V3 promise that nameservers can't block an InterRegistrar proof, and the
> code breaks that promise. But no registry of the 25 TLDs lets a domain have 17 nameservers today: all cap it at 13,
> below the verifier's 16. The attack works only if a registry raises its cap above 16 (the verifier can't be changed)
> or a later verifier adds a TLD that allows more. That makes it a latent defect: Low.
>
> Even if it were reachable, it wouldn't be High. The seller who delivered disputes before the deadline. They win by
> default if the buyer doesn't pay the fee, or before Kleros on the public RDAP record, and their fee comes back (SPEC
> §7.1, §7.2). That is griefing (Medium), the same class as V3 (Medium) and V5 (Low, "the seller disputes"). A buyer can
> already force that dispute by deleting the domain (the report's U4).
>
> The seller path adds nothing new. Before acceptance the seller holds the domain and can make any "before" proof fail
> without touching nameservers: move the domain to the buyer's registrar, or start a transfer out (`pending transfer`).
> A 30-day lock is the accepted remainder of E2.
>
> **Fix:** correct; also update SPEC §6.2 (over 16 names or 8 KB: no hash) and add a 17-name test.

#### C-2. The two attestations must agree on nameservers and "last changed" even for InterRegistrar deals

- **Severity:** Low (griefing with a timing window of seconds; the keeper retries, and the seller can dispute).
- **Attacker and preconditions:** the buyer after delivery, with a registrar that applies nameserver (or any) changes
  to the registry quickly and often; the seller's prover runs the two attestor sessions concurrently (`prover/src/server.ts`,
  `Promise.all`), 0–23 s apart on today's fixtures.
- **Steps:** the holder keeps changing the nameservers (or anything that moves the domain's "last changed" event).
  Whenever a change lands between the two sessions' fetches, `DualRdapVerifier.verify` reverts with `FactsDiffer`
  although SPEC §6.2/§6.3 say an InterRegistrar proof "doesn't use" the nameservers hash, and the "last changed"
  event matters only for IntraRegistrar deals. Each half alone is a valid attestation of the domain at the buyer's
  registrar (`test_C2_nameserverChangeBetweenTheTwoSessions_blocksTheAfterProof` verifies both halves on their own
  verifiers, then shows the pair rejected; the same with only "last changed" moved).
- **Impact:** the seller's proof fails for as long as the buyer keeps the record moving; the deal ends in `refund` or
  a dispute. The practical window is narrow (the prover notes the retry itself in `prover/src/facts.ts`), so Low.
- **Proof of concept:** same command as C-1 (`test_C2_…`).
- **Root cause:** `contracts/src/DualRdapVerifier.sol:35-38` compares `nameserversHash` and `lastChanged` for every
  deal; the verifier doesn't know the deal's mode.
- **Suggested fix:** compare only `domainHash`, `registrarId`, `expiration` and `statusFlags` in `DualRdapVerifier`,
  and for `nameserversHash` and `lastChanged` return them only when both attestations agree (zero otherwise): an
  IntraRegistrar proof then still needs two agreeing attestations (no single attestor can set the Intra facts), and an
  InterRegistrar proof no longer depends on facts it doesn't use. Or add a second entry point the escrow calls for
  InterRegistrar deals. Service side, run the two sessions back to back from one prover and retry on `FactsDiffer`.

**Verification of C-2:**

> **Verdict:** CONFIRMED (Low)
>
> **Proof of concept:** `cd …/pentest/C/contracts && forge test --match-path test/pentest/C_InterProofBlocking.t.sol -vv`: pass, 6/6, including `test_C2_nameserverChangeBetweenTheTwoSessions_blocksTheAfterProof` (6.12M gas). Each half verifies alone (registrarId 1068, the buyer's). The pair reverts `FactsDiffer` for a nameserver change, and again with only "last changed" moved. No tokens move: the 5,000 USDC stay locked in an Active deal (C-1's test shows the refund path). My checks: `forge test --match-path test/pentest/verify/V_C-2_controlAndFix.t.sol --match-test test_V_C2 -vv`: 3/3 pass.
>
> **Sources unchanged:** yes. The copy only adds `contracts/test/pentest/`, `scripts/pentest/` and `REPORT-C.md`. `src`, `script`, `lib`, `foundry.toml` and `prover/src` match the base.
>
> **What you checked:**
> - The test runs the real `DualRdapVerifier`/`RdapVerifier` code. The repo's test-only attestor keys stand in for two honest attestors, which is fair here: the attacker needs no key, only a registry update. No `vm.store`/`etch`. The only prank is the seller's own proposal. Proofs are built before `vm.expectRevert`, so the revert comes from `submitProof`. Control (V_C-2 test 1): the same pair with only `fetchedAt` 2 s apart passes to Proven, so the revert comes from comparing nameservers and "last changed".
> - Spec: §14.18 requires the two attestations to "agree on every fact except `fetchedAt`". That contradicts §6.3's stated reason for not checking nameservers. Not in audit.md §5; the closest is V5 (buyer-set data fails the proof, accepted at Low). `prover/src/prove.ts:122` already notes mid-change mismatches ("then try again").
> - The window: in the rdap-dual fixtures, the two attestations are 0–1 s apart for 24 TLDs and 9–11 s apart for `.now`/`.bot`. In production, `paced` (`server.ts:23`) starts sessions to the same host 1 s apart, and `oneAtATime` (`server.ts:32`) runs zk TLDs one after the other. The keeper retries every 10 min (`PROOF_RETRY_MS`, `service/src/config.ts:167`) after a dry run (`keeper.ts:217`), so a failed pair costs nothing.
>
> **Reason:** Low stands. The Medium case (the seller forced to dispute) needs nearly every one of roughly 700–1,440 separate attempts in the transfer window to fail. The buyer would have to land a registry update inside each ~1 s window, which they can't observe, continuously for days: 10^5–10^6 updates through registrar and registry rate limits. Caching can widen one attempt's window to minutes (CentralNic `max-age=120`; Google up to 1 h per server, `docs/dependencies.md` §4.4), but each retry is a fresh chance: the buyer only delays the proof. The seller can still dispute (§6.5).
>
> **Corrections:**
> 1. Precondition: "0–23 s" is how far the registry's "last update" timestamp lags behind the attestation (REPORT-C, "Checked" item 9), not the gap between the two sessions. The retry note is in `prove.ts:122–127`, not `facts.ts`.
> 2. Root cause: the line is right, but the contract implements SPEC §14.18 as written. The spec and its off-chain copy, `combineFacts` (`prover/src/facts.ts:53`), must change too, or the prover keeps rejecting pairs a fixed verifier would accept.
> 3. The suggested fix is incomplete. It still compares `statusFlags`, so a buyer can toggle the transfer lock between the two sessions where their registrar allows it. V_C-2 test 2: a pair that differs only in `client transfer prohibited` still reverts `FactsDiffer` (same nameservers, "last changed" and expiration; no blocked status). Better:
>    - require equality only for `domainHash` and `registrarId`;
>    - set `nameserversHash` to zero unless both agree;
>    - take the minimum of `lastChanged` and of `expiration`;
>    - OR the two `statusFlags`.
>
>    Each check then still needs the honest attestation, and a single attestor can only add rejections. V_C-2 test 3 uses a sketch of this fix (`FixedDualForC2`, not yescrow code). A pair where the nameservers, "last changed" and the lock all moved proves an InterRegistrar deal. A `client hold` seen by only one attestor is still rejected (`ProofRejected(5)`). Its service half (back-to-back sessions, retry) already exists.

---

### Checked and found sound

Each line says what was examined and why it holds.

1. **Signature binding.** `identifier = keccak256("http\n" ‖ parametersFor(domain) ‖ "\n" ‖ context)`, signed as
   `identifier\nowner\ntimestampS\nepoch` under Ethereum personal-sign, exactly `createSignDataForClaim` /
   `getIdentifierFromClaimInfo` in attestor-core 5.1.4/5.1.5 (`lib/index.js` 5691-5747; the owner is lowercased on
   both sides, the identifier is lowercase hex on both). `ECDSA.recover` rejects high-s and bad `v`, so a signature
   can't be mauled into another. The attestor is immutable; Reclaim's address in `Networks.RECLAIM_ATTESTOR` is the
   one the 26 real fixtures verify against; `DualRdapVerifier`'s constructor refuses two verifiers with the same attestor.
2. **Parameters rebuilt from the domain.** `PARAMS_PREFIX` + base + `domain/` + domain + `"}` (or the zk-write suffix
   for `.now`/`.bot` only) equals `canonicalize(claimParams(domain))`: every real fixture's identifier recomputes from
   it on-chain (`test_realProofs_everyRegistryAndTld`) and off-chain (`prover/test/template.test.ts`). RFC 8785 key
   order (`body, headers, method, responseMatches, responseRedactions, url, writeRedactionMode`; `User-Agent` before
   `accept`) is what the prefix encodes. `paramValues`, a different URL, query string (`?jscard=1` fixture), another
   host (the registrar-RDAP fixture) or a different regex all change the signed string. The domain is validated by
   `Domains.tldOf` (lowercase LDH, one dot, labels 1–63 without edge hyphens, 25 TLDs) before any string is built, so
   nothing can be injected into the URL or the JSON.
3. **Each TLD's registry URL.** The 25 entries of `_rdapBase` match SPEC §6.1 and `prover/src/template.ts`; the index
   ranges (3,4,11–15,19–23 Identity Digital; 24 `.fm`; 25 `.inc`) match `Domains.tldOf`. One live GET per host today
   (9 hosts) answered `200` `application/rdap+json` with `Content-Length`, no `*` byte and no control byte in any
   body; the attestor checks `https:`, the exact path (no templates, so no `*` allowed in it), the `Host` header, GET,
   `Connection: close`, and in `processHandshake` verifies the server's certificate chain against the host name, so a
   route hijack alone can't substitute a server.
4. **Context parsing.** `_facts` accepts only `{"extractedParameters":{…},"providerHash":"0x<64 lowercase hex>"}` with
   the seven known keys in canonical order, none repeated, nothing trailing (tested: an extra top-level key, an unknown
   key, a repeated key, out-of-order keys, a short hash, trailing bytes all revert). `_entry` skips `\x` pairs exactly
   as JSON string syntax does, so a value can't end early or late; `_unescape` decodes `\" \\ \/ \n \r \t` and reverts on
   `\b \f \u` (a raw control byte in a capture fails closed). The attestor builds `extractedParameters` and
   `providerHash` itself and canonicalizes the context before signing (`assertValidProviderTranscript`), so a prover's
   own context keys can only make the proof fail.
5. **Field parsers.** `_stringAfter` requires `"key" : "plain string"` with no escapes (fails closed); `_uint` takes 1–9
   digits; `_eventDate` requires `YYYY-MM-DDTHH:MM:SS[.frac]Z`, 1970 ≤ year ≤ 9999, month 1–12, day 1–31, second ≤ 60,
   and the repository fuzzes the round trip; fractional seconds as sent by CentralNic (`.0`) and Nominet
   (nanoseconds) parse; `+00:00`, missing seconds, lowercase `z` and a 5-digit year revert. `_daysFromCivil` is
   Hinnant's algorithm. `_statusFlags` lowercases each string and maps the 23 RFC 8056 values; unknown values are ignored.
6. **Template versus Solidity (first match is the only match).** Inverted "appears twice" regexes cover the IANA ID,
   expiration, last changed, last update of RDAP database and nameservers; blocked statuses are refused anywhere in the
   response. JSON string values can't contain an unescaped `"`, so holder-controlled text (vCard fields, remarks)
   can't form `"eventAction"`, `"IANA Registrar ID"`, `"ldhName"` or a status string; nameserver names are LDH. The
   `i` flag and RE2's Unicode case folding only widen what the attestor refuses; Solidity's case-sensitive key lookups
   (`"eventDate"`, `"identifier"`, `"ldhName"`) fail closed on exotic casing. Where yescrow's attestor falls back to
   JavaScript's `RegExp` (no RE2), `\s` is wider and case folding narrower than RE2's: both directions only add refusals.
7. **Layouts of the live responses (9 hosts, today).** The first `"ldhName"` is the domain's in every layout (Verisign
   at byte 63, CentralNic and `.co` at about 180, Google at 1489 after `entities` and `events`, Identity Digital, PIR
   and Nominet at 3.7–4 KB after `notices`); no entity, event, link or notice carries `ldhName`. The top-level status
   array is followed by `entities`/`port43`/`notices` where it is captured (Nominet's isn't: `statusFlags` is 0 there,
   and a blocked status is still refused by the inverted regex). `nameservers` is followed by `secureDNS` or
   `rdapConformance` everywhere. Each unique field appears once; no nested object carries events except Nominet's
   nameserver objects (`registration` only today, see U2).
8. **Full reveal.** `FULLY_REVEALED` needs the `HTTP/1.1 200` line, header lines without `*` except an
   `access-control-…: *`, and a body without `*` ending in `}` plus whitespace; attestor-core ≥ 3.2.0 matches it over
   headers + de-chunked body, and an older client's body-only string (with `*` runs deleted) can't start with the status
   line. The hardened attestor additionally refuses clients below 3.2.0, server records not revealed by their key, and
   incomplete HTTP messages; the patch applies cleanly to attestor-core 5.1.4 (`patch --dry-run`). `assertTranscriptsMatch`
   compares the client's server bytes as a prefix of the tunnel's, so a prover can truncate the tail of a response at
   Reclaim's attestor but can't drop or reorder a record in the middle (byte equality plus per-record AEAD with
   sequence numbers); a truncated response fails the hardened attestor's `Content-Length`/final-chunk check, and both
   attestors are required.
9. **Timestamps and freshness.** `fetchedAt` is the registry's own `last update of RDAP database`, fresh within 0–23 s
   of the attestation time on all 26 fixtures (Verisign and Nominet lag 11–23 s; the rest 0–1 s). The escrow requires
   `notBefore < fetchedAt ≤ now` and `expiration ≥ fetchedAt + 30 days`; `DualRdapVerifier` returns the earlier
   `fetchedAt`, so both attestations post-date the proposal (before) or the activation (after). One compromised
   attestor therefore can't forge a fact (the honest half must state it), can't resurrect an old honest attestation
   (the minimum is the old time), and can't help a stale record pass. Registry clock skew only delays or fails proofs.
10. **Unicode, IDNs and escapes.** Terms carry A-labels (`xn--` passes `tldOf`); Verisign's uppercase `ldhName` and
    nameservers are lowercased before hashing and comparison; non-ASCII appears only in `unicodeName`, outside the
    captures or (inside nameserver objects) ignored by `_nameserversHash`; CentralNic's and `.co`'s `\/` escapes decode;
    a `\u` escape or raw control byte in a capture reverts. A registry answering a different name, a trailing dot or a
    U-label fails `DomainMismatch`.
11. **Gas.** A dual proof with the costliest list the count cap allows (16 names of 253 characters) costs 16.5M gas in
    `submitProof` (`test_C1_gas_sixteenLongestNames_dualProof`), under Arbitrum One's 32M; a list over 8 KB isn't hashed
    (10.8M for 40 long names). Nothing else in the context is holder-controlled, so no other field inflates the cost.
12. **Replay and binding.** A proof is public data and binds no deal, escrow or chain by design; the time checks stop
    stale reuse, a "before" proof can't serve as an "after" proof (registrar inequality) or the reverse, and two deals
    on one domain at the same registrar are the documented account gap (SPEC §6.5), not a verifier issue.
13. **Escrow checks.** `accept` and `submitProof` call the allow-listed verifier through a STATICCALL, compare the
    domain hash with the terms, check the registrar inequality (before) or equality (after), the blocked bits, the
    expiration margin, and for IntraRegistrar the nameservers hash and `lastChanged > activatedAt`; `propose` asks the
    verifier `supportsDomain` after the allow-list check.
14. **Deployment of the verifiers.** `DeployAll` deploys Reclaim's verifier with `Networks.RECLAIM_ATTESTOR`, refuses
    a `YESCROW_ATTESTOR` that is zero or equal to it, and the dual verifier needs both: SPEC §14.18 as promised.

### Unconfirmed

- **U1. Registry nameserver caps (C-1's precondition).** The 13-nameserver cap is a convention from the 512-byte UDP
  limit, not a specification; registries set their own (Norid 8, `.lv` 5, DENIC 13). I found no primary source for the
  current caps at Verisign, PIR, Identity Digital, Google Registry, CentralNic, Nominet's gTLD platform or GoDaddy
  Registry. If any of the 25 TLDs' registries accepts 17 or more, C-1 is reachable today; the fix costs one line either way.
- **U2. Nominet host-object events.** Nominet embeds each nameserver object's `events` in the domain response
  (`registration` today). If a modified host object gets a `"last changed"` event, as the RDAP profile allows, the
  domain response carries two "last changed" events and the inverted regex makes both attestors refuse the whole
  response (`scripts/pentest/C_template_re2.ts`, case 2 shows the refusal on a Nominet-layout response). A buyer with
  an in-bailiwick host at `.now`/`.bot` could then block the seller's proof; a seller could block the "before" proof.
  Whether Nominet emits that event for hosts is unverified.
- **U3. A registrar whose entity repeats "last changed".** `docs/security.md` notes a MarkMonitor response the attestor
  refuses for a repeated "last changed". None of today's nine sampled domain responses embeds entity events, so I
  couldn't tell at which registry that happens. Where it does, a buyer who names that registrar makes every "after"
  proof impossible at that registry (the seller must dispute), which the pre-flight checks don't cover.
- **U4. Deleting the domain after delivery.** A buyer who deletes the just-received domain puts it in
  `pending delete`/`redemption period` (blocked, check 5), so the seller can't prove and must dispute; the buyer restores
  it for the registrar's restore fee. This is the SPEC §6.5 "delivered but can't prove" path, so not reported as new.
- **U5. Truncated transcripts at Reclaim's attestor.** Reclaim's attestor accepts a server transcript that stops short,
  and the holder controls the response length (nameserver names), so in principle a TLS record boundary can be placed
  right after a `}` so that `FULLY_REVEALED` still matches a cut body; only the hardened attestor's completeness check
  stops it. The protection rests on yescrow's patch alone, and nothing hides from a complete response anyway.
- **U6. Empty nameserver list in IntraRegistrar terms.** A deal whose `nameserversHash` is `keccak256("")` is "proven"
  by a domain whose nameservers array is `[]` (the hash of an empty join is non-zero). It needs the buyer to sign such
  terms, so social engineering only (Info).
- **U7. Pipelined second request.** After the first request's blank line the attestor treats the rest of the client
  transcript as a body it doesn't check (the template's body is empty). A registry that ignored `Connection: close`
  would append a second response; first-match semantics and the inverted regexes leave nothing to gain, and no
  registry was tested for it.
- **U8. RE2 versus JavaScript `RegExp`.** `\s` (ASCII in RE2, Unicode in JS) and simple case folding (`K`, `ſ`) differ
  between the two attestors' engines; only refusals can result, and no registry output contains such characters today.

---

## Pentest report, area D: Disputes (Kleros resolver, refusal, `applyRuling`, the 90-day stall, yescrow's judge, fee paths)

Commit under test: `3ca0628` (SPEC v0.30, 2026-10-29). Fork tests run on Arbitrum One at block 519,194,000 (the block
`test/fork/ArbitrumOneMainnetFork.t.sol` pins), against the real KlerosCoreNeo 0.10.0 (`0x991d…22ea`), the real public
`DisputeResolver` (`0xb552…4323`), DisputeKitClassicNeo 0.12.0 (`0x70B4…5421`), SortitionModuleNeo 0.9.0 and the real USDC,
with the escrows configured exactly as `script/DeployAll.s.sol` configures them (mainnet juror template included).

Proof files (all pass, 20 tests): `contracts/test/pentest/D_AppealOverride.t.sol`, `D_KlerosReads.t.sol`,
`D_FeePaths.t.sol`. Run from `…/pentest/D/contracts` with the shared wrapper, e.g.

```
cd contracts
forge test --match-path "test/pentest/D_*.t.sol" -vv
```

No file outside `contracts/test/pentest/` was changed. No transaction was sent to any live network (reads only: `cast call`
at the pinned block, and Foundry's cached fork).

---

### Findings

#### D-1 (High): the losing side of a Kleros vote flips the ruling by funding an appeal alone; yescrow neither tells the winner nor lets it respond, and the flip costs the attacker only gas

**Severity:** High. A party is forced into a loss (the whole price) under plausible conditions: the winner has to notice,
within 36 h (AI court) or 108 h (General Court), an appeal funding that yescrow's deal page, alerts and keeper never
mention, and counter it with ETH from a wallet that, for the deals yescrow sponsors, has none.

**Attacker and preconditions:** the buyer or the seller, after Kleros's jurors voted against it. Needs
`3 × appealCost` in ETH for the length of the appeal period (AI court: 0.00567 ETH ≈ $13; General Court, 1 juror:
0.045 ETH ≈ $106), which it gets back in full. The other party must not counter-fund `2 × appealCost` before the appeal
period ends. Nothing about yescrow tells it to.

**Steps (as proven on the real Kleros, `test_D1_appealFundedByTheLoserAlone_flipsTheRuling_forFree`):**
1. A 1,000 USDC deal, the seller disputes, the buyer answers: a real case in court 34 through Kleros's resolver.
2. Three real stakers of court 34 are drawn (the sortition module's RNG is stubbed on the fork), commit and vote 3–0
   "the seller delivered" (ruling 1). Control branch: nobody funds an appeal → execution → `applyRuling` → Released,
   the seller is credited 990 USDC + its fee.
3. Attack branch: in the first half of the appeal period the buyer calls `DisputeKitClassic.fundAppeal(disputeId, 2)`
   with 0.00567 ETH. The seller does nothing. No new round is created (the other side never funded).
4. The appeal period ends; `passPeriod` → execution. Kleros's `currentRuling(disputeId)` now returns `(2, false, true)`:
   the buyer's choice, `overridden = true`.
5. Anyone, in practice yescrow's keeper (`service/src/keeper.ts:97-103` calls `executeRuling` then `applyRuling` as
   soon as the period is 4), calls `applyRuling`. The escrow reads word 0 and refunds: the buyer is credited the full
   1,000 USDC plus the held arbitration fee; the seller, who delivered per the jurors, gets 0 and loses its fee.
6. After `executeRuling`, the buyer calls `withdrawFeesAndRewards(disputeId, buyer, 0, 2)` and gets its whole
   0.00567 ETH back (Kleros reimburses a one-sided funding). The flip cost gas.

**Impact:** the whole price moves against the jurors' verdict for free. In the AI court, where yescrow pays the fees
of parties with no ETH (SPEC §14.17), the honest winner typically has no ETH to counter-fund even if it noticed. An
attentive, funded winner is still forced to stake `2 × appealCost` (refundable only if it wins the next round) every
round the loser escalates. Note the loser has the first half of the appeal period to fund, so the winner may be left
with 18 h (AI court) to react.

**Proof of concept:**
```
forge test --match-path test/pentest/D_AppealOverride.t.sol -vvv
```
Logs: control `seller credited USDC 990000000`; attack `appeal stake locked 5670000000000000` wei,
`buyer credited 1000000000`, `seller credited 0`, buyer's ETH balance unchanged after withdrawing the stake.

**Root cause:** Kleros's classic dispute kit overrides the final ruling when exactly one side fully funds an appeal
(`DisputeKitClassicBase.currentRuling`, returned `overridden`); `YescrowEscrow.applyRuling` takes word 0 only
(`contracts/src/YescrowEscrow.sol:606-609`) and pays on it, which matches what Kleros's `executeRuling` would do, but
yescrow treats that as "the jurors decide" (SPEC §7.2 line 270: "Kleros handles appeals itself, and the contract only
acts on the final ruling"; §12 "Its jurors decide disputed deals"; `docs/audit.md` §3 "No party can force the other
into a loss by withholding … ETH"). Nothing in the product covers appeals: `grep -ri appeal service/src web/components`
finds no handling; `web/components/deal/Dispute.tsx:71-77` tells the parties "Jurors answer one question … The winner
gets their fee back"; `service/src/alerts.ts:209-210` alerts only on `Ruling` and `Judged`; `watcher.ts` indexes no
dispute-kit event (`Contribution`, `ChoiceFunded`).

**Suggested fix:**
- Contract (next version): read word 2 (`overridden`) of `currentRuling` in `applyRuling`; if set, do not pay on it:
  hand the deal to yescrow's judge (`_toJudge`), who rules on the evidence and the jurors' votes within 14 days, or
  pay on the jurors' `winningChoice` instead. Either stops money alone from deciding a yescrow deal.
- Product (now, for the deployed version): index the dispute kit's `Contribution`/`ChoiceFunded` events for the deal's
  case; alert the other party at once ("the other side funded an appeal: fund yours by <deadline> or the ruling flips
  to them"), show the appeal period and a fund button on the deal page, and consider sponsoring the counter-funding in
  the AI court (the winner's stake comes back with the loser's when it wins the round). Document the mechanic in SPEC
  §7.2/§13.5 and in the dispute panel's text.

**Verification of D-1:**

> **Verdict:** CONFIRMED (High stands)
>
> **Proof of concept:** `forge test --match-path test/pentest/D_AppealOverride.t.sol -vvv`: PASS. Control: seller credited 990 USDC. Attack: the buyer stakes 0.00567 ETH and gets 1,000 USDC plus its fee; the seller gets 0; the stake comes back.
>
> **Sources unchanged:** yes. Only `contracts/test/pentest/`, an empty `scripts/pentest/` and `REPORT-D.md` differ.
>
> **What you checked:**
> - **The test.** The only stubs are the sortition RNG (`vm.etch`, for drawing only) and the drawn real stakers' pranked votes (the precondition). Apart from `deal` funding the parties, nothing is written into Kleros, USDC or the escrow. The attack is the buyer's own `fundAppeal`, then calls anyone can make.
> - **The deployed code at the fork block:** the dispute kit 0.12.0 and core, from Kleros's deployment files; bytecode matches the chain except `__self`.
>   - `currentRuling` returns the only funded choice at execution.
>   - `withdrawFeesAndRewards` repays that side in full (`feeRewards == paidFees`).
>   - `executeRuling` passes the same ruling to the resolver.
>   - `isAppealFunded` ends the period early only when no side funded.
> - **My tests,** `test/pentest/verify/V_D-1_appealWindow.t.sol`, all 5 pass:
>   - **Loser funds in the last second of its half:** the period can't be ended early, so the winner keeps 64,801 s (18 h) to stake 0.00378 ETH.
>   - **Anyone counters in the last second:** a new round starts and `applyRuling` reverts `TooEarly`.
>   - **Reverse direction:** a seller found not to have delivered funds alone and gets 990 USDC; the buyer gets 0.
>   - **Winner's side funded early, unopposed:** Kleros returns `overridden = true` with the jurors' own choice.
> - **Docs and product:** SPEC, audit §3 and §5, `security.md`, the keeper, the alerts and the deal page.
>
> **Reason:**
> - **Not a known issue.** SPEC §7.2 hands appeals to Kleros, so the contract matches its spec. But no document explains the one-sided override (`dependencies.md` gives only stakes and deadlines), and audit §5 is silent. The override also undoes §14.17's purpose (no loss for lack of ETH), because appeals aren't sponsored.
> - **"Forced" is overstated for the first appeal.** The deal page links to the Kleros case, and anyone may counter-fund. High holds as "a loss under plausible conditions":
>   - nothing tells the winner it won the vote or that an appeal was funded;
>   - parties yescrow sponsors hold less than A + 0.0001 ETH;
>   - trying is free for an unopposed loser.
> - **The second appeal comes close to "forced".** It jumps to court 33, and the winner must stake 0.54 ETH (≈ $1,345) against the loser's refundable 0.81 ETH, more than many AI-court deals are worth.
> - **The reverse direction is a false release that depends on the buyer doing nothing.** That keeps it High, not Critical.
>
> **Corrections:**
> - **Line references:** the root cause is at `YescrowEscrow.sol:603–609`. The panel text is at `Dispute.tsx:78–84`. "Nor lets it respond" is wrong.
> - **Contract fix, option "pay on the jurors' `winningChoice`": drop it.** Kleros opens a round only when both sides fund, so the first-round winner could block every appeal and with it the human jurors §7.1 promises.
> - **Contract fix, any check:** it must compare the ruling with the jurors' choice, not test `overridden` alone. Otherwise defended cases go to the judge.
> - **Contract fix, `_toJudge`:** it lets any loser send any case to yescrow's judge for gas only: the user's trust decision.
> - **Deployed version, strongest fix:** the relayer funds the winner's side when the appeal period opens. That costs 0.00378 ETH, which comes back whole if nobody opposes and doubles if the next round confirms the ruling. It removes the race, at 36 h instead of 18 h per case; add alerts and a deal-page notice.

#### D-2 (Info): on a refused case, yescrow is paid more for letting its judge's deadline pass than for ruling

SPEC §7.5 says "a deal yescrow's judge rules on pays no fee, so yescrow gains nothing from either verdict", and §8's
rows agree: a ruling earns yescrow the loser's arbitration fee A (0.005 ETH ≈ $12 General, 0.00081 ETH ≈ $2 AI court)
and no fee on the price. But §8's "Refused case, judge's 14 days pass" row charges F on the seller's half: yescrow
earns 0.5% of P. Above P ≈ $2,400 (General Court) yescrow is paid more by not ruling; on a $50,000 deal $250 against
$12. Not a loss for a party beyond the specified outcomes, and the judge is yescrow's own; an incentive note.
`test_info_judgeTimeoutPaysYescrowMoreThanRulingOnLargeDeals` (ruling: A wei, 0 USDC; timeout: 25 USDC on a 5,000 deal).
Fix: charge no fee on the judge-timeout split of a refused case too (or send it to the parties), so the judge has no
reason to be slow.

**Verification of D-2:**

> **Verdict:** CONFIRMED (Info stands), wider than reported
>
> **Proof of concept:** `cd …/pentest/D/contracts && forge test --match-path test/pentest/D_FeePaths.t.sol --match-test test_info_judgeTimeoutPaysYescrowMoreThanRulingOnLargeDeals -vvv`: **PASS**. On a refused 5,000 USDC case:
> - When the judge rules, the fee address gets 1e13 wei (the mock's A) and 0 USDC.
> - When the judge lets the 14 days pass, the fee address and the referrer together get 25,000,000 (25 USDC). That is the fee F on P/2.
>
> My extra tests, `contracts/test/pentest/verify/V_D-2_scope.t.sol`, all pass (3/3):
> - **Stalled case:** a ruling pays yescrow 0 ETH and 0 USDC. The timeout pays it 18.75 USDC, plus 6.25 USDC to the referrer.
> - **Refused case:** of the PoC's 25 USDC, yescrow's own address gets 18.75 and the referrer gets 6.25.
> - **Refused case where yescrow's relayer paid the loser's fee (§14.17):** a ruling only returns the A that yescrow spent (net 0). The timeout returns it too and adds F on P/2.
>
> **Sources unchanged:** yes. Apart from `out/` and `cache/`, `D` differs from `base` only by `contracts/test/pentest/`, an empty `scripts/pentest/` and `REPORT-D.md`.
>
> **What I checked:**
> - **The code:** `judgeRuling` zeroes the fee (`YescrowEscrow.sol:634-635`). `judgeTimeout` (642-646) closes at P/2 without zeroing it, so `_close` charges the deal's fee (1016).
> - **The PoC:**
>   - The only cheats are the judge's own call (`vm.prank(judge)`), `vm.warp` to `rulingDeadline`, and `arb.setRefuse` standing in for a Kleros refusal.
>   - The fee arithmetic doesn't depend on the mock, and the assertions pass for the reason stated.
> - **Spec, tests and known issues:**
>   - SPEC §8 specifies this on purpose. Line 327 gives the refused timeout as P/2 − F + A. Line 330 says the stalled timeout "pays as ruling 0", which includes F.
>   - `Judge.t.sol` asserts both. Its comment "yescrow didn't judge, so it keeps nothing" is true only for ETH.
>   - `docs/audit.md` §5 doesn't list this.
>   - §4 of the same file and `docs/security.md` say the judge "can't charge a fee". That is true of its ruling, but when it doesn't rule, a fee is charged.
> - **Mainnet A:** 0.005 ETH in the General Court (1 juror) and 8.1e14 wei in the AI court (`docs/dependencies.md` §1.8).
>
> **Reason (severity unchanged):**
> - Info is the right severity:
>   - The timeout split is a specified outcome, and no deadline is missed.
>   - A judge who wants a party to lose can already rule against it, which `docs/audit.md` §5 accepts. This gives the judge a motive, not a new power.
> - It does undercut the reason §7.5 gives for the no-fee rule ("yescrow gains nothing from either verdict"), and a deployed escrow can't change.
>
> **Corrections:**
> - **It isn't limited to "P above ≈ $2,400 in the General Court":**
>   - On a stalled case, or a refused case where yescrow paid the loser's fee, a ruling nets yescrow nothing, so the timeout pays more at every price.
>   - On refused AI-court deals (A ≈ 0.00081 ETH), the timeout pays more from about $390, at the report's ≈ $2,400 per ETH.
> - **The 25 USDC includes the referrer's 25% share.** yescrow keeps 0.375% of P, so with a referrer the General Court threshold is about $3,200.
> - **The fix should cover the stalled-case timeout too:**
>   - In `judgeTimeout`, set `d.promo = true; d.promoFeeBps = 0;` before `_close`, as `judgeRuling` does.
>   - SPEC §8: the refused timeout pays each side P/2 + A, and the stalled timeout pays as ruling 0 without F.
>   - Update the two timeout tests in `Judge.t.sol`.
>   - Make the change before the mainnet deployment. For an escrow already deployed, the only remedy is a commitment that the judge always rules within the 14 days.

#### D-3 (Info, documented): a Kleros fee rise of up to 2× between opening and answering comes out of the winner's refund

`heldFee = pot − cost` (`YescrowEscrow.sol:548`): if Kleros raised the court's fee to at most 2A after the opener
paid, the case opens and the winner gets back `2A − cost` instead of the A that SPEC §8 promises (`security.md` "Kleros
fee change between opening and joining" states this). Only Kleros's governor can cause it. `test_info_klerosFeeRaisedAfterOpening_winnerGetsBackLessThanA`
(fee +50%: the winner gets A/2 back). Fix: none needed on-chain; state it in SPEC §8 as well.

**Verification of D-3:**

> **Verdict:** CONFIRMED (Info stands)
>
> **Proof of concept:** `cd …/pentest/D/contracts && forge test --match-path test/pentest/D_FeePaths.t.sol --match-test test_info_klerosFeeRaisedAfterOpening_winnerGetsBackLessThanA -vvvv`: **PASS**. The trace:
> - The buyer challenges with A = 1e13 wei; the cost is then set to 1.5e13; the seller answers with 1e13.
> - The escrow sends 1.5e13 to Kleros; case 100 opens with `heldFee` = 5e12.
> - Ruling 1: the seller is credited 5e12 wei (A/2) and 4,950 USDC; the buyer gets 0.
> - Kleros holds 1.5e13; the escrow's ETH equals what it owes.
>
> My extra tests, `contracts/test/pentest/verify/V_D-3_feeChangeRange.t.sol`, pass (3/3):
> - Cost exactly 2A: the case still opens, the held fee is 0, the winner gets nothing back.
> - +50% with ruling 0: each payer gets A/4.
> - Cost lowered to A/2: the winner gets 3A/2.
>
> **Sources unchanged:** yes. Apart from `out/` and `cache/`, only `contracts/test/pentest/`, `scripts/pentest/` and `REPORT-D.md` differ between `base` and `D`.
>
> **What I checked:**
> - **The code** (`YescrowEscrow.sol:519-562`):
>   - The answer must equal `d.arbitrationFee` (525).
>   - If `cost <= 2A`, the current `cost` goes to Kleros (533, 543) and `heldFee = pot − cost` (548).
>   - Otherwise: refused, to the judge, both fees held (558-561).
>   - `_close` (1033-1044) pays `heldFee` to the winner's payer, or 50/50.
> - **Who can change `cost`:** only Kleros's governor.
>   - `arbitrator`, `resolver` and `smallDealMaxPrice` are immutable; both extra-data values are set only in the constructor (342-343).
>   - Kleros's cost is `feeForJuror × minJurors`, which only the governor can change (`changeCourtParameters` or an upgrade, `docs/dependencies.md` §1).
>   - The mainnet fee period is 72 h.
> - **Realism:** no `vm.store`/`vm.etch` or forged signatures; `arb.setCost` stands in for the governor's fee change; the pranks are only the two parties' own dispute and answer.
> - **Known issue:**
>   - `docs/security.md` "Kleros fee change between opening and joining" (lines 108-110) describes it exactly as it behaves: the current cost is forwarded, `2A − cost` is kept for the winner, and above 2A the deal goes to the judge.
>   - The project's own `Transitions.t.sol` test `test_payArbitrationFee_costRiseWithinPotShrinksHeldFee` already asserts `heldFee == A/2` at +50%.
>   - `docs/audit.md` §4 says Kleros can "change fees", but §5 doesn't list this.
>   - SPEC doesn't state it: §7.1 (line 252) says the escrow "forwards one fee … the other fee is held to refund whoever wins", and §8's rows promise "+ A".
>
> **Reason (severity unchanged):** Info is right.
> - Only Kleros's governor can cause it (already in the trust table), and it has at most 72 h.
> - The loss is capped at the winner's own A (≈ $1.91 AI court, ≈ $11.79 General Court at the pinned fees).
> - The money goes to Kleros as its published fee, not to an attacker; the loser loses its A either way.
> - No party can trigger it; an answerer can only time its answer around an announced change (worth less than A).
>
> **Corrections:**
> - **Not only the winner bears it:** after ruling 0, a settlement after the case, or a stalled case's 50/50 timeout, each payer gets (2A − cost)/2.
> - **It works both ways:** if Kleros cuts its fee, the winner gets more than A (part of the loser's fee). SPEC §8 doesn't say that either.
> - **Fix, docs only (the contract is immutable):**
>   - SPEC §7.1, line 252: say the escrow "forwards the court's current cost (at most both fees); the rest is held for the winner, or split".
>   - SPEC §8: a note under the table.
>   - `docs/audit.md` §5: list it, so auditors don't report it again.
>   - Optionally, qualify "the winner gets their fee back" in `web/components/deal/Dispute.tsx:81` and `web/app/(site)/guides/disputes/page.tsx` (lines 28, 57, 61).

#### D-4 (Info, docs): `docs/keys.md` still lists `rule` (`msg.sender == address(arbitrator)`) in its §2 and §3 tables

Since v0.30 the escrow implements no `rule` (Kleros rules on the resolver; `applyRuling` reads the core). The rows at
`docs/keys.md:76` and `:110` are stale; the claims ("KlerosCore only; nothing") are still true in spirit. No test.

---

### Checked and found sound

Each item names the proof (`D_…` = this area's files) or the review that supports it.

- **Opening cases through Kleros's public resolver.** The real resolver's `_createDispute` calls the core first and
  returns the core's `arbitratorDisputeID` (verified against the kleros-v2 source and empirically: with the resolver's
  local counter pushed one ahead by `vm.store`, the escrow stored core ID 328 while the resolver's local ID was 329, and
  `kleros.disputes(328).arbitrated == resolver`, `dealOfDispute[328] == dealId`). The existing mainnet fork test could not
  tell the two apart because all 328 mainnet cases came through the resolver, so the IDs coincide.
  `D_KlerosReads.t.sol: test_sound_resolverReturnsTheCoreId_localIdsDiverge`.
- **Evidence group.** The resolver's `DisputeRequest._externalDisputeID` is its local ID (329 in that test), which the
  service records as `evidenceId` from the same transaction (`watcher.ts:evidenceIdOf`) and files evidence under
  (`api/app.ts` `submitEvidence`). Correct, and it would stay correct once the IDs diverge.
- **Refusal detection and gas starvation.** On the real resolver, the least gas with which the answer passes
  `GasTooLow` (5,348,481 for the call, buyer answering, 1,000 USDC deal) still opens the real case in court 34: no gas
  budget sends a case Kleros would take to the judge (`test_sound_leastGasAnswer_stillOpensTheRealCase`). With the real
  1,428-byte mainnet template in storage, at the least passing gas (5,341,101) the resolver receives 5,001,470 gas at
  entry, i.e. the whole 5M budget plus the call stipend (`D_FeePaths: test_sound_gasFloor_realTemplate_resolverGetsTheWholeBudget`),
  so the 100k margin in `ARBITRATOR_GAS_NEEDED` covers the escrow's own work between the check and the call. A real case
  needs about 655k. A resolver or core that burned all forwarded gas still leaves the escrow 1/64 (≈81k after the 5M
  call, ≈15k after `_read` in `applyRuling`), enough for the refusal (≈50k) or stall (≈7k) bookkeeping (reviewed).
- **The resolver pointing at another arbitrator.** `_read(resolver.arbitrator())` must equal the immutable
  `arbitrator` exactly: another address, dirty high bits (`test_sound_resolverArbitratorWord_mustMatchExactly`), no code
  or a short answer (existing `Hardening.t.sol`) all count as a refusal with nothing sent anywhere. The real resolver's
  `arbitrator()` is the core, its governor is Kleros's EOA, no proxy (cast at the pinned block).
- **`_read` on the real core.** Word 2 of `disputes(id)` is the period (4 at executed cases 300 and 310), word 0 of
  `currentRuling(id)` the ruling (2 and 1 there), an unknown ID reverts at the core (panic 0x32) and reads as not ok
  (`test_sound_readWordsMatchTheRealCore`). Malformed answers: one word for `disputes` (no period), period 4 with an
  empty `currentRuling`, a 96+-byte revert reason, all read as "not ruled" and only reach the judge after 90 days; a
  ruling getter cut to one word still applies; a ruling above 2 splits 50/50 (`D_FeePaths: test_sound_read_*`). The
  assembly is memory-safe (scratch past the free pointer, `returndatasize` checked before the copy, `ok` never set
  back to 1 after a revert).
- **A case ID that isn't the deal's.** `d.disputeId` is written only from the resolver's return in the same
  transaction; a refused case never enters `Disputed`, so `applyRuling` can't read ID 0 for it (reviewed).
- **Appeals and the execution period.** `fundAppeal` is refused outside the appeal period; a two-sided funding
  creates a new round and the period returns to evidence, so period 4 is only reached when no appeal can follow;
  `currentRuling` at period 4 is what `executeRuling` would apply (observed on the fork in D-1's control branch).
- **The 90-day stall.** `rulingDeadline` is set when the case is created; `applyRuling` before it reverts `TooEarly`
  unless the case is at execution; a final ruling after 90 days still wins (existing `Judge.t.sol`); once in `Judging`
  a late Kleros ruling is a no-op (`test_sound_judgingRaces…`).
- **`Judging` (value 10, appended).** Every comparison on `State` was reviewed: `applyFeeVoucher` excludes it
  (`> Disputed`), `_openDeal` includes it explicitly, `settle` lists it, `_depositCanFund` only looks at `None`/`Proposed`.
  No path leads back from `Judging` except to a final state.
- **yescrow's judge.** `judgeRuling`: only `judge`, only `Judging`, only before `rulingDeadline`, rulings 0–2, once;
  money only through `_close` to the deal's payees, no fee (`promo = true, promoFeeBps = 0` set first), the loser's A
  (half of each on a split) to `feeRecipient` only on a refused case (`bothFeesHeld`); the judge itself is never
  credited (`invariant_judgeIsNeverPaid`). `judgeTimeout`: anyone, at or after the deadline, 50/50. The judge's
  powers match `docs/keys.md` and `docs/audit.md` §4. Existing `Judge.t.sol` plus `test_sound_spec8_judgeRows`.
- **Races.** At `rulingDeadline − 1` the judge rules and the timeout reverts; at the deadline the reverse; a
  settlement works at any moment in `Judging` until someone splits; a concession that lands first makes the judge's
  ruling and the timeout revert with no money moved (`test_sound_judgingRaces_deadlineBoundary_concession_settlement`).
  All entry points are `nonReentrant` and state-guarded, so only one closing transition can ever succeed.
- **Every fee path of SPEC §7.5 and §8** with exact amounts, including sponsored fees: fee timeouts, rulings 1/2/0,
  concessions after the case, settlement after the case, judge rulings on refused cases (seller: P + A, buyer: P + A,
  split: P/2 + A/2 each, yescrow A), the refused timeout (P/2 − F + A / P/2 + A), a refused case conceded (both fees
  back whole), stalled rulings (yescrow keeps nothing, the held fee to the winner) and the stalled timeout (as ruling
  0). After each sequence the escrow's ETH and token balances equal exactly what is owed; one fee per created case sits
  with Kleros, none for refused cases (`test_sound_spec8_*`; existing `Judge.t.sol`, `SponsoredFees.t.sol`).
- **Signatures.** A `PayArbitrationFee` signature works only for its deal (`InvalidSignature` on another deal of the
  same parties), only in `AwaitingFee` (`WrongState` after a refusal), and not as any other action (typehash); a
  settlement signed before the case can't be used after it and vice versa (existing `Hardening.t.sol`); the same
  `caseCreated = true` signature is valid in `Disputed` and in `Judging`-after-stall, which pay identically. The opener
  may carry the other party's signed answer with its own ETH: it then holds both fee-payer slots and only ever gets its
  own ETH back, the signer loses nothing (`test_sound_openerSubmitsTheOthersSignedAnswer_noLossToTheSigner`).
- **Judged deals pay no fee; a refused case keeps the loser's fee including a sponsored one** (existing
  `test_refused_sponsoredFees`, `test_sound_spec8_judgeRows`): a sponsor's fee that loses goes to `feeRecipient`, one
  that wins goes back to the sponsor, as SPEC §7.5 says.
- **Deployment settings** (`Networks.sol` against the chain at the pinned block, by `cast`): resolver `0xb552…4323` on
  the allow-list and pointing at the core, courts 34 and 1 with dispute kit 1 only, costs 8.1e14 and 5e15 wei, court 34
  periods 600/2700/1800/129600 s with hidden votes, court 1 280800/583200/583200/388800 s; `DeployAll` refuses a judge
  that is the deployer, the fee wallet or not a contract on mainnet, and `CheckDeployment` simulates a case from the
  escrow's own address.
- **Kleros's governor and resolver key** can only do what `docs/keys.md` lists: refuse (allow-list, arbitrator
  change, court change, cost) and decide disputed deals; the resolver's `rule` is never read by the escrow.

### Unconfirmed

- **U-1, stalling to the judge by sustained appeals (design).** A party that keeps funding appeals can push a General
  Court case past 90 days (each round ≤ 19.5 days; loser stakes 0.045, 0.105, 0.225, 0.465, 0.945 ETH for the next
  rounds), after which `applyRuling` hands the deal to yescrow's judge and the Kleros case, with everyone's appeal
  stakes still at risk there, no longer counts for the escrow. The judge's 14-day timeout is a 50/50 split, so a sure
  loser who can afford ≈ 1.8 ETH buys a chance at half the price if the judge is slow or wrong. By design (SPEC §7.5),
  but the keeper calls `applyRuling` at the 90-day mark even when a final ruling is minutes away; consider letting the
  keeper wait while a case is at the appeal or execution period.
- **U-2, sponsorship griefing (service, off by default).** `sponsor.ts` sponsors one fee per party per deal in the AI
  court for any wallet short of ETH, with no minimum price and no way to see that buyer and seller are one person. Two
  empty wallets could create 1-unit deals, dispute and answer with yescrow's ETH, settle with `sellerAmount = 0` (no
  fee), and repeat until `SPONSOR_DAILY_WEI` is spent (≈ A ≈ $2 per deal plus relayed gas). `SPONSOR_DAILY_WEI`
  defaults to 0, so this matters only if it is enabled; then the daily budget bounds it. Not scripted (needs the
  local stack); Low.
- **U-3, Kleros 2.0.0's subgraph and the five-field `DisputeRequest`.** `docs/dependencies.md` §1.9 records an open
  question to Kleros whether cases opened with the resolver's five-field event keep their template under the coming
  subgraph; if not, jurors would see no question or policy for yescrow's cases and the AI court might rule 0 (a 50/50
  split) or refuse. Depends on Kleros's roadmap; unconfirmed.
- **U-4, a core upgrade that changes `disputes()`'s layout.** `_read` takes word 2 as the period; if an upgrade moved
  another field there, a value of 4 (for example a court ID) would make a live, non-final `currentRuling` look final.
  A trust assumption on Kleros's governor already (SPEC §7.4); a cheap hardening is to also require word 3 (`ruled`,
  set only by `executeRuling` at the execution period) or `currentRuling`'s `overridden`/`tied` words to decode as
  booleans (0 or 1).
- **U-5, evidence groups are open.** Kleros's EvidenceModule lets anyone file evidence under any group ID, and its
  subgraph groups by `_externalDisputeID` alone, so a third party can plant text (including instructions aimed at the
  AI jurors) in a yescrow case's evidence, and another allow-listed arbitrable using the same external ID would share
  the group. Kleros's design, known in SPEC §12; nothing in the escrow to change.

### Method

Read SPEC §5–§8, §12, `docs/audit.md`, `docs/keys.md`, `docs/security.md`, `docs/dependencies.md` §1.5–1.9, the
escrow, the vendored Kleros interfaces, the mocks, `Base.t.sol`, `Judge.t.sol`, `Hardening.t.sol`, the invariant
handler, `DeployAll`/`CheckDeployment`/`Networks`, the juror template, and the service's `keeper.ts`, `judge.ts`,
`sponsor.ts`, `relayer.ts`, `watcher.ts`, `api/app.ts` (evidence), `deals.ts`, `alerts.ts` and the web's
`Dispute.tsx`. Verified the real resolver's source (kleros-v2 master) and its on-chain storage, the real core's getters
at executed cases, the dispute kit's `hashVote`, `castCommit`, `castVote`, `fundAppeal`, `appealCost`, `appealPeriod`
and the court parameters by `cast call` at the pinned block, then wrote the three proof files above.

---

## Pentest report, area E: deposit router

Commit under test: 3ca0628 (main, 2026-10-29). Scope: `contracts/src/DepositRouter.sol`, `DepositBox.sol`, the
escrow's deposit and `withdrawTo` paths, `script/DeployRouter.s.sol`, `script/Networks.sol`, and the service's
forwarding, completion and deposit-status code (`service/src/keeper.ts`, `bridges.ts`, `api/app.ts`).

Proofs: 10 Foundry tests in `contracts/test/pentest/E_*.t.sol` on forks of Base (block 52,978,000), Ethereum
(26,254,000) and Arbitrum One (519,194,000) against the real USDC, CCTP V2 (TokenMessengerV2, MessageTransmitterV2,
TokenMinterV2), USDT0 and its LayerZero adapter, and the real keyless CREATE2 proxy; and one Node script in
`scripts/pentest/` that drives the service's own `Keeper` class. All pass. No transaction was sent to any network.

Run everything from `contracts/`:

```bash
forge test --match-path 'test/pentest/E_*.t.sol' -vv
(cd ../scripts/pentest && node E_foreign_forward_keeper.ts)
```

### Findings

#### E-1. Anyone can `forward` a buyer's USDC off Base or Ethereum, and yescrow never completes a burn it didn't make

**Severity:** Medium (griefing: funding delayed past `acceptBy`, the deal dies, the money is invisible until a
manual completion; the buyer's own way back on Base, promised by SPEC §4.1 and audit finding R1, is removed). If
Circle has stopped receiving on Arbitrum One, R1's own scenario, the same action makes the loss permanent.

**Attacker and preconditions:** any third party with a few cents of gas on Base or Ethereum. The victim is a buyer
whose USDC sits at a deal address on Base or Ethereum (an exchange withdrawal, a gasless EIP-3009 transfer, or
money the buyer meant to take back with `returnStray`). Deal addresses of on-chain seller proposals are public
(`depositAddress(dealId, buyer)`), so the attacker can watch them and act the moment USDC lands.

**Steps:**
1. On Base, before the keeper's next tick (or just before the buyer's `returnStray`), call
   `DepositRouter.forward(escrow, dealId, buyer, USDC)`. The router sweeps the box and burns the whole amount
   through CCTP V2 for the same address on Arbitrum One, `destinationCaller = 0`. Cost: 366,580 gas, about
   2.2e-6 ETH at Base's 0.006 gwei.
2. The USDC is now neither on Base nor on Arbitrum One. It appears on Arbitrum One only when someone calls
   `MessageTransmitterV2.receiveMessage` with Circle's attestation for *that* burn transaction.
3. Nothing in yescrow does: `Keeper.completeTransfers()` iterates `pendingTransfers(db)`, the `bridge_transfers`
   rows the keeper inserts only after its *own* `forward` succeeded (`forwardFromDepositChains`, keeper.ts:303–341,
   `recordTransfer` at l.321) or when it relays a `withdrawTo`. No code reads the router's `Forwarded` or CCTP's
   `DepositForBurn` events (`grep -rn Forwarded service/src` finds only the ABI). The keeper's own forward now
   finds the box empty (l.317) and records nothing.
4. The API's `depositView` (app.ts:240–275) sums the box balances on each chain plus pending rows, so the deal shows
   `received 0, coming 0, missing = price`: to the buyer and the seller, nothing was ever paid. A buyer who pays
   again locks twice the price.
5. On Arbitrum One the keeper's `accept` reverts `InsufficientDeposit`, `returnDeposit` reverts `TooEarly`, and at
   `acceptBy` the seller's proposal expires. Once someone finally completes the mint, `returnDeposit` credits the
   buyer of a deal that is gone.

**Impact:** 5,000 USDC (the proof's price) unreachable by anyone in yescrow's stack, the deal cancelled, and the
buyer's self-service exit on Base gone, for half a cent. Repeatable against every Base/Ethereum deposit, so it is
also a cheap denial of service of cross-chain funding. With Circle's receiving side paused on Arbitrum One (the
transmitter's `pauser` role, reproduced on the fork), the burn on Base still succeeds and the money is in a route
that cannot deliver; the buyer could have kept it on Base.

**Proof:**
```bash
forge test --match-path test/pentest/E_ForeignForward.t.sol -vv      # the burn, the dead deal, the pause
(cd ../scripts/pentest && node E_foreign_forward_keeper.ts)                  # the keeper's two cases
```
`test_1` burns the deposit from a stranger's account on the real Base messenger and shows the buyer's `returnStray`
then reverts; `test_2` deploys an escrow on the Arbitrum One fork, shows `accept`/`returnDeposit` failing, the
proposal expiring, and the money reappearing only after a simulated `receiveMessage` (the real TokenMessengerV2's
`handleReceiveFinalizedMessage`, called as its transmitter would); `test_3` pauses the real transmitter with its
pauser and shows no path mints. The script runs the real `Keeper.completeTransfers` with a stub database, stub
relayers and a stub Circle API: with its own row it asks Circle and sends `receiveMessage`; without one (a stranger's
burn) it never asks.

**Root cause:** `contracts/src/DepositRouter.sol:83–100` (`forward` is open to anyone, by design) combined with
`service/src/keeper.ts:303–341` and `355–385` (completion is driven only by rows written after the keeper's own
sends) and `service/src/api/app.ts:257–259` (`coming` counts only those rows). The contract's permissionlessness is
fine; the service assumes it is the only forwarder.

**Suggested fix:** off-chain, cheap. Each tick, for every watched box and deposit chain, read the router's
`Forwarded(escrow, dealId)` logs (and CCTP's `DepositForBurn` with `mintRecipient == box`, for burns made directly)
since the last scanned block, and insert a `bridge_transfers` row keyed by the event's transaction hash; Circle's
API indexes attestations by that hash, so `completeTransfers` then finishes them, and `depositView` shows them as
coming. Add a "complete a transfer" action that takes a Base/Ethereum transaction hash, for buyers and support. Also
state in SPEC §4.1 that a deposit forwarded by someone else still needs a completion on Arbitrum One. No contract
change is needed for this; `destinationCaller` must stay 0 so that the keeper (or anyone) can complete it.

**Verification of E-1:**

> **Verdict:** CONFIRMED (Medium stands), with corrections to the stated impact, to test_3 and to the suggested fix.
>
> **Proof of concept:**
> - `forge test --match-path test/pentest/E_ForeignForward.t.sol -vv` (from `E/contracts`): **3/3 pass**.
>   - test_1 (Base fork): a stranger's `forward` burns 5,000e6 USDC from the box, using 366,580 gas. The box is left with 0, `mintRecipient` is the box, `destinationCaller` is 0. The buyer's `returnStray` then reverts.
>   - test_2 (Arbitrum One fork): `accept` reverts `InsufficientDeposit` and the proposal expires. The buyer is credited 0 until a simulated mint, then 5,000e6.
> - `node E_foreign_forward_keeper.ts`: **pass**. With a row, the real `Keeper.completeTransfers` asks Circle and sends `receiveMessage`. Without one, it does neither.
> - My checks, `forge test --match-path test/pentest/verify/V_E-1_Checks.t.sol --match-test test_V_`: **2/2 pass**.
>
> **Sources unchanged:** yes. Apart from build output, the only differences are the added `test/pentest/`, `scripts/pentest/` and `REPORT-E.md`.
>
> **What I checked:**
> - **The attacker's only action is a permissionless `forward`.** The other cheatcodes set up the scenario; none gives the attacker a power it lacks:
>   - `deal` funds the victim's box;
>   - `vm.etch` places yescrow's own router at its pinned address;
>   - `vm.prank(transmitter)` simulates the rescue mint;
>   - `vm.prank(pauser)` simulates Circle's pause.
>
>   USDC and TokenMessengerV2 are the real contracts.
> - **Service:**
>   - Incoming rows are written only after the keeper's own successful forward (keeper.ts:321).
>   - Outgoing rows come from the watcher's `WithdrawnTo` indexing (watcher.ts:90–102), not from a relay.
>   - Nothing reads `Forwarded` or `DepositForBurn`.
> - **This is a bug, not the design.** SPEC §4.1 requires showing "what is crossing" and completing CCTP transfers. The issue is not in audit §5.
> - **Realistic:**
>   - `Proposed` exposes `dealId` and `buyer`.
>   - The keeper ticks every 30 s and Base makes a block every 2 s, so a bot wins the race.
>   - USDT is unaffected, because USDT0 delivers by itself.
> - **My test 2:** if anyone completes the mint before `acceptBy`, `accept` funds the deal normally. So the money is delayed, not lost.
>
> **Reason:**
> - **Medium is right; High is not.** The deal dies, the money is hidden and delayed with no end date, and yescrow loses its fee, for about a cent per Base deposit. Repeatable against every deposit, though paying on Arbitrum One still works. Nothing is lost, which rules out High.
> - **Two impact claims are not the attacker's doing:**
>   - "The buyer's way back on Base is removed": the keeper's own forward empties the same box within 30 s anyway.
>   - "With Circle paused, the loss becomes permanent": nothing in `service/src` checks Circle's pause state, so the keeper also forwards into a paused route. "For good" also needs Circle to strand burned messages forever, which is R1's accepted risk and unchanged by this finding.
> - **test_3 passes for the wrong reason.** Its two bare `expectRevert()` calls revert with no pause at all:
>   - "Invalid attestation length" (a garbage message);
>   - "Invalid message transmitter" (a stranger calling the handler).
>
>   My test 1 shows a paused transmitter reverts with "Pausable: paused". So the pause claim is true, but test_3 doesn't prove it.
> - **Root cause:** correct, except that `forward` is DepositRouter.sol:83–97, not 83–100.
> - **The fix needs one change: key rows by message, not by transaction hash.** `source_tx` is `UNIQUE` (written with `ON CONFLICT DO NOTHING`), and `cctpAttestation` (bridges.ts:76–83) reads only `messages[0]`. An attacker who batches several forwards into one transaction would leave one row and one completion.
>   - Key rows by (tx hash, CCTP nonce).
>   - Complete the message whose `mintRecipient` is the row's box.
>   - Filter `DepositForBurn` on its indexed `depositor` = the router. That catches every router burn, batched ones included.

#### E-2. A stranger's `forward` locks for good money that the buyer could have taken back: boxes whose escrow has no code on Arbitrum One

**Severity:** Low (needs a buyer's mistake first; then a third party makes it permanent, against SPEC §4.1 "it isn't
lost" and §13.7).

**Attacker and preconditions:** anyone. A buyer has sent real USDC on Base (or Ethereum) to a deal address of an
escrow that exists only elsewhere: the active Arbitrum Sepolia USDC escrow `0x57Af…c836`
(`deployments/arbitrum-sepolia.json`) has no code on Arbitrum One, and a testnet deal's address is a live address on
Base mainnet (audit R3). Picking "Base" instead of "Base Sepolia" at an exchange does it.

**Steps:**
1. On Base the buyer can take the money back (`returnStray`, msg.sender = buyer): proven in `test_1`.
2. Before that, anyone calls `forward(0x57Af…c836, dealId, buyer, USDC)`: the USDC is burned for the same box on
   Arbitrum One (`test_2`).
3. On Arbitrum One `pull` is keyed by `msg.sender`, so only `0x57Af…c836` can sweep that box; `returnStray` and
   `forward` revert `NotHere` on the settlement chain for everyone, including the buyer; the box has no other
   function (`test_3`). The only key is code at that address on Arbitrum One, which only yescrow's testnet deployer's
   CREATE at the matching nonce could produce, and which `docs/keys.md` says must never be used on a mainnet chain.

**Impact:** 1,000 USDC in the proof, locked at the deal address on Arbitrum One. The same holds for any escrow
address without code on the settlement chain (a future escrow version's precomputed address before it is deployed,
an escrow of another settlement chain, a typo in an integrator's `escrow` argument).

**Proof:** `forge test --match-path test/pentest/E_StrandedBox.t.sol -vv`

**Root cause:** `contracts/src/DepositRouter.sol:76–79` (`pull` only by the escrow in the salt) with `:108`
(`returnStray` refuses the settlement chain unconditionally): on Arbitrum One there is no path for a box whose
escrow can never call. `forward` (l.83) cannot know, on Base, whether the salt's escrow exists on Arbitrum One.

**Suggested fix:** in the next router version, let the buyer in the salt reclaim on the settlement chain a box whose
escrow has no code: in `returnStray`, `if (here.settlement == block.chainid && (escrow.code.length != 0 ||
msg.sender != buyer)) revert NotHere();`. An escrow deployed later at that address loses nothing: no deal can exist
before it does, and the money is the buyer's. Until then: the testnet interface should say that money sent on a
mainnet network to a testnet address can be reclaimed on that network only until someone forwards it, and support
should know the rescue (code at the escrow address) is a mainnet use of the testnet deployer key.

**Verification of E-2:**

> **Verdict:** CONFIRMED (Low), with corrections below.
>
> **Proof of concept:** `forge test --match-path test/pentest/E_StrandedBox.t.sol -vv`, run from `pentest/E/contracts`: **pass**, 3 of 3 tests.
> - `test_1`: the buyer's `returnStray` on Base returns 1,000,000,000 base units (1,000 USDC).
> - `test_2`: a stranger's `forward` burns the 1e9 through the real TokenMessengerV2 (`depositForBurn(1e9, domain 3, box 0xeE34…04B7, maxFee 0, 2000)`). The buyer's `returnStray` then reverts `NothingThere`.
> - `test_3`: 1e9 is minted to the same box on Arbitrum One. The buyer and the stranger get `NotHere` from `returnStray` and `forward`, and their `pull`s return 0. The box keeps 1e9 and has no code.
>
> **Sources unchanged:** yes. `diff -r` against `base` differs only in the pentest folders, `REPORT-E.md`, the node_modules symlinks and build output.
>
> **What I checked:**
> - The `-vvvv` trace: the router lands at `Networks.ROUTER` through the real CREATE2 proxy (no `etch`); `deal` only funds the buyer's mistaken deposit; the mint is the real TokenMessengerV2, called as its transmitter calls it after Circle's attestation; the final prank as `0x57Af…` only shows who could sweep.
> - `0x57Af…c836` is the active testnet USDC escrow and names router `0x7073…`, which is `Networks.ROUTER`. So the box is a real testnet deal address.
> - Arbitrum One, block 519,343,733: no code at `0x57Af…`, no router on Arbitrum One or Base yet. The testnet deployer `0x934f…6795` has nonce 0 there; the DeployAll broadcast shows `0x57Af…` is its CREATE at nonce 56.
> - yescrow's keeper never does this forward. It forwards only its own deals (keeper.ts:303), and only on chains that settle to its own escrow's chain (escrow.ts:243). Only a deliberate third party does.
> - The issue isn't in audit §5. R3 (§6) accepts that testnet addresses are live on mainnets, and its fix (never use the testnet key there) is what makes this lock permanent.
> - The root-cause lines (76–79, 108, 83) are right.
>
> **Reason:**
> 1. **Low stands.** The attack needs the victim's own mistake on a testnet labelled "no real money" (banner, FAQ, Sepolia network names), plus a griefer who gains nothing. A direct send on Arbitrum One to a testnet deal address is already locked with no griefer at all. That is R3's root cause, rated Low. One interface line makes the mistake easier: `web/components/deal/DepositPanel.tsx:85` also tells testnet buyers to "buy it … at an exchange and withdraw it straight to this address", and exchanges offer only mainnets.
> 2. **"For good" holds only under the key rule.** The testnet deployer's nonce on Arbitrum One is still 0, so yescrow could rescue the money: send 56 filler transactions, then deploy at nonce 56 a contract with no owner whose only call is `router.pull(dealId, buyer, token, buyer)`. That breaks the rule in keys.md, so it's the user's call. Done at every testnet escrow address on Arbitrum One, it would also close R3's theft risk.
> 3. **The suggested router change doesn't fix the PoC's own scenario.** Existing testnet escrows name `0x7073…` forever, and anyone can deploy that code on Base or Ethereum through the keyless proxy, so their deal addresses keep the old `forward`. The change protects only escrows that name a new router, which is still possible before mainnet. For existing testnet deals, the remedies are the rescue above, a line in SPEC §4.1 "Wrong network" and §13.7 that testnet addresses on mainnets aren't covered (direct sends on Arbitrum One included), and hiding the exchange line on testnet builds.
> 4. **"A future escrow's precomputed address" normally means only a delay.** Once that escrow is deployed, `returnDeposit` can return the money. The loss is permanent only for addresses that will never have code: testnet escrows and typos.

#### E-3. Tokens sent to the router's own address are unrecoverable

**Severity:** Low / Info (user error; no attacker).

**Preconditions:** someone sends USDC or USDT0 to `0x7073…Cb3D` itself, on any chain (the router's address appears
on block explorers next to every deposit transaction and is verified source, so it will be pasted somewhere).

**Steps and impact:** the router "holds nothing between calls" but has no function over its own balance: `forward`
bridges exactly what the box's `sweep` returned, `bridge` exactly what it pulled from `msg.sender`, `returnStray`
and `pull` only touch boxes, and there is no owner or sweep. 100 USDC sent to the router stay there through every
public function (proof). ETH cannot get stuck the same way: there is no `receive`, `forward{value}` with USDC
reverts `WrongValue`, and the USDT path refunds the excess.

**Proof:** `forge test --match-path test/pentest/E_RouterStray.t.sol -vv`

**Root cause:** `contracts/src/DepositRouter.sol:119–129` and `209–241`: every send moves an explicit `amount`
from a box or from the caller, never the router's balance.

**Suggested fix:** none on-chain for this version (immutable, and a sweep would need an owner). Never show the router
address as a destination; say in the explorer's contract notes and `docs/security.md` that it holds nothing and
takes nothing. A future router could let anyone `bridge` the router's own stray balance to the fee recipient on the
settlement chain, which keeps it keyless.

**Verification of E-3:**

> **Verdict:** DOWNGRADED to Info
>
> **Proof of concept:** `forge test --match-path test/pentest/E_RouterStray.t.sol -vv` (from `pentest/E/contracts`): PASS. On a Base fork (block 52,978,000), 100,000,000 base units (100 USDC) stay at `0x7073…Cb3D` after:
> - `forward` bridges the box's 1 USDC through the real CCTP;
> - a stranger `bridge`s its own 2 USDC;
> - `returnStray` reverts `NothingThere`;
> - `pull` reverts `NotHere`.
>
> My extra check, `forge test --match-path "test/pentest/verify/V_E-3_*" -vv` (`contracts/test/pentest/verify/V_E-3_RouterStrayEverywhere.t.sol`): 2 PASS.
> - **Base:** the router lands at `Networks.ROUTER` through the real CREATE2 proxy, so the PoC's `vm.etch` fallback never runs. 100 USDC sent by a real ERC-20 transfer (not `deal`) stay through `forward`, the buyer's `returnStray` and `forward` on an empty box.
> - **Arbitrum One** (block 519,194,000): 100 USDC, 100 USDT0 and 1 ETH forced in by SELFDESTRUCT all stay through `pull` (exactly the box's 7 USDC / 5 USDT0), `bridge` via CCTP and via USDT0's OFT, and `forward`/`returnStray` (`NotHere`). The router's own ETH can't pay USDT0's fee (`WrongValue`).
>
> **Sources unchanged:** yes. Excluding build output, `diff -r` against the pristine export shows only `contracts/test/pentest/`, `scripts/pentest/` and `REPORT-E.md`.
>
> **What I checked:**
> - **The PoC test:** no prank of a party that wouldn't sign (the buyer may call `returnStray`), no mocks, real CCTP. `deal` stands only for the sender's mistake. The assertions read the router's balance after each call.
> - **The router's ABI:** `boxAddress`, `boxCodeHash`, `pull`, `forward`, `returnStray`, `bridge`, `routeOf`; no receive or fallback. Every outgoing transfer moves an amount taken from a box or the caller in that same call.
> - **The docs:** `docs/audit.md` §5, `docs/security.md` "Donations", SPEC §2 rule 1.
> - **Where yescrow shows the address:** only on /about/ (`web/components/site/Contracts.tsx:22–23`), never in the deposit panel.
>
> **Reason:** the behaviour is real, but it is the router's copy of an accepted, by-design limit. §5 says: "Donations to the escrow outside a deal stay there forever (no recovery function, by design)". SPEC §2 rule 1 rules out any recovery function. §5 doesn't name the router, so this is a documentation gap, not something made worse.
> - There is no attacker, and nobody gains.
> - No deal party's money is involved: deposits go to box addresses, and no yescrow flow asks anyone to pay the router.
> - The loss needs someone to paste a contract's address, like sending USDC to the USDC contract.
> - The scale's "Critical: permanently lost" is about the protocol losing a party's money, not a self-inflicted transfer outside any deal. Low overstates it.
>
> **Corrections:**
> - **Root cause:** the cited lines are `bridge` (119–128) and `_send` (209–243, not 241); `forward` (83–97, amount from the sweep at 91) belongs there too. The cause is the deliberate absence of any function over the router's own balance (line 21, SPEC §2 rule 1), not a defect in those lines.
> - **ETH:** "can't get stuck" is too strong. A plain transfer to the deployed router reverts. But ETH forced in by SELFDESTRUCT stays (shown above). So does anything sent to `0x7073…Cb3D` on a chain where the router isn't deployed, which today is every mainnet (`E_Sound` test_1).
> - **Fix:**
>   - Add the router (any token, ETH, every chain) to "Donations" in audit.md §5 and security.md.
>   - /about/ shows the address as "Deposit router (same address on every chain)" with a copy button, which echoes the deposit panel's wording. Relabel it "not a payment address".
>   - The "future router" sweep means a new router address, so every box address changes and the escrows (router immutable) redeploy. It also needs a fee-recipient constant in a contract with no settings, and makes yescrow hold strangers' mistakes. Documentation is enough.

### Checked and found sound

Executable claims are in `contracts/test/pentest/E_Sound.t.sol` (`forge test --match-path
test/pentest/E_Sound.t.sol -vv`); the rest was checked by reading and by `cast` reads of public chain state.

1. **The router's address is pinned and reproducible.** This build's `out/DepositRouter.sol/DepositRouter.json`
   (solc 0.8.30, optimizer 200, legacy pipeline, no metadata) hashes to creation code `0x520ee3…e491` and lands at
   `Networks.ROUTER` = `0x7073…Cb3D` through the real proxy on Arbitrum One, Base and Ethereum (`test_1`). The
   routers already at that address on Arbitrum Sepolia and Base Sepolia are byte-identical to this build once the
   `boxCodeHash` immutable is filled in (5,917 bytes). A file that imports only the router and `Networks`, as
   `DeployRouter.s.sol` does, gets the same creation code as the artifact (`test_1` asserts it), so `predicted()`
   guards the deploy correctly. The address is not deployed on the three mainnet chains yet; anyone may deploy it,
   and only identical code lands there (CREATE2 commits to the init-code hash), so nobody can squat it.
2. **Every `routeOf` constant matches the real bridge on its chain** (`test_2`): CCTP domains 0/3/6 equal each
   transmitter's `localDomain`, V2 (`version()` 1, `messageBodyVersion` 1), the remote TokenMessenger for each
   destination is registered, the burn token on each side maps to the other side's USDC in the minter
   (`getLocalToken`), the per-message cap is 10,000,000 USDC; USDT0's Ethereum adapter wraps legacy USDT as a
   lockbox (`approvalRequired` true), the Arbitrum OFT wraps USDT0 (mint/burn), their endpoints are EIDs 30101 and
   30110 and each is the other's peer. Enforced options on both sides give `lzReceive` 80,000 gas
   (`0x0003010011010000…13880`), so the router's empty `extraOptions` are delivered by LayerZero's executor.
3. **Who may move what** (`test_3`, plus `test/DepositRouter.t.sol`): on Arbitrum One a box is swept only by the
   escrow in its salt (`pull` keyed by `msg.sender`; a stranger sweeps its own empty box), `forward` and
   `returnStray` revert `NotHere`, and `bridge` reverts `NoRoute` for the same chain, a testnet, an unknown chain and
   USDT0 to Base, with the real tokens and nothing leaving the caller. Off the settlement chain a bridgeable token
   can be returned only by the buyer in the salt, anything else by anyone, to the buyer only.
4. **The box.** `sweep` only by the deploying router (immutable `owner`), ETH or any token, to a `to` the router
   chooses: the escrow (funding), the buyer (returns), the router itself (bridging). Its creation code has no
   deployer-dependent part, so `boxCodeHash` and every box address are the same on every chain; the salt is
   `abi.encode` of fixed-size fields (no collision). Malicious `token` arguments to `returnStray`/`returnDeposit`
   run in the token's context only; the router and box keep no state to corrupt, and the escrow is `nonReentrant`.
5. **A burn goes only to its own box and can't be re-pointed.** `forward` sends to `boxAddress(escrow, dealId,
   buyer)` from the same salt it swept, `destinationCaller` 0, `maxFee` 0, finality 2000 (standard, free); USDT0
   with `minAmountLD = amount`. The burn's `messageSender` is the router, and the V2 messenger implementation
   (`0x555E…3ec8`) has no `replaceDepositForBurn` (selector absent), so neither the router nor anyone else can
   change a burn's recipient. Testnet routes can never reach Arbitrum One (`there.settlement == here.settlement`).
6. **The escrow side.** `_pullDeposit` trusts the escrow's balance difference, not the box's count; `_fund` requires
   the price and credits the excess to `terms.buyer`; `returnDeposit` pays only `terms.buyer` and the deal token
   only once `_depositCanFund` is false (a buyer's proposal already funded, a seller's proposal past `acceptBy` or
   closed, invalid terms), other tokens and ETH straight to the buyer; the deal ID commits to the buyer, so nothing
   can be redirected. `withdrawTo` is authorized only by the account (transaction or one-use EIP-712 over chainId,
   recipient, amount, deadline), requires `msg.value` 0 for USDC, quotes and pays USDT0's fee in the same call and
   refunds the excess to the caller; a bridge revert reverts the whole call, so no signature is burned.
7. **Approvals and value.** `forceApprove` handles USDT's approve-from-nonzero restriction; CCTP and the Ethereum
   adapter consume the router's allowance exactly; the Arbitrum OFT (`approvalRequired` false) leaves a dangling
   allowance from the router, but the router holds nothing and `send` burns only `msg.sender`'s own balance, so it
   is unusable. `forward` with USDC refuses any `msg.value`; the USDT path's refund reverts the call if `refundTo`
   rejects ETH, which only hurts the caller.
8. **Deploying elsewhere.** On a chain the router doesn't know, `routeOf` returns settlement 0: `pull`, `forward`
   and `bridge` are refused and `returnStray` returns everything to the buyer in the salt, as SPEC §13.7 says. A
   fake proxy or a chain with different CREATE2 rules puts the router at a different address, where no yescrow
   escrow names it.
9. **Service checks.** `loadDepositChain` uses a chain only if the router is deployed there and its route's
   settlement is this escrow's chain; the API relays EIP-3009 transfers only to a known deal's own box; the keeper
   forwards above `FORWARD_MIN` only, simulates every call, and records withdrawals to other chains for completion.
10. **CCTP denylist state.** The router is not on Circle's denylist on Base (`isDenylisted` false at the fork block);
    see unconfirmed 2 for the systemic case.

### Unconfirmed

1. **`maxFee = 0` is immutable.** Circle's attestation service refuses to attest a burn whose required fee exceeds
   `maxFee`. Standard transfers are free today (checked 2026-10-22 in `docs/dependencies.md`), but if Circle ever
   charges for them, every `forward` and cross-chain `withdrawTo` burn would be made and never minted, with no way
   to change the constant. Not provable on a fork (off-chain policy). Recommendation: before each forward and relay,
   the keeper queries Circle's fee endpoint (`/v2/burn/USDC/fees/{src}/{dest}`) and stops, with an operator alert
   and a UI warning, if the standard-transfer minimum fee is nonzero; buyers then reclaim with `returnStray`.
2. **Circle's and Tether's denylists are checked on `msg.sender` (and `tx.origin`).** TokenMessengerV2 and
   MessageTransmitterV2 are `Denylistable`; a shared router means a denylisting of `0x7073…Cb3D` would stop every
   CCTP forward and USDC withdrawal to another chain at once (money stays at the boxes on Base and Ethereum,
   reclaimable by buyers; on Arbitrum One local withdrawals still work). A sanctioned party routing funds through
   the permissionless router could, in theory, provoke it. The same holds for Tether's blocklist on USDT0 and the
   OFT owner `0x4DFF…0bf8`, who sets peers and enforced options. Trust assumptions, already partly in SPEC §12.
3. **The keeper's own forwards can be orphaned the same way as E-1** if the process dies between the transaction's
   receipt and `recordTransfer` (keeper.ts:320–321). The fix for E-1 (index `Forwarded` logs) covers this.
4. **Relayer gas griefing through minimum deposits.** A gasless 1 USDC EIP-3009 deposit on Base costs the relayer a
   Base transaction, a forward, a mint on Arbitrum One and later a `returnDeposit`; repeated against drafts it
   drains the relayer slowly. SPEC §12 accepts that the relayer can be drained; `FORWARD_MIN` only bounds the
   per-deposit cost. Needs a stack to measure, so not proven.

### Not findings (reasoned out)

- Dust or over-10M griefing of `forward`: a stranger's USDC sent to a box only enlarges the buyer's deposit, and
  the buyer (R1) or `returnDeposit` returns it; pushing a box over the 10M cap costs the attacker that USDC.
- Smart-contract or 7702 buyers: `returnStray` for bridgeable tokens needs `msg.sender == buyer` on that chain,
  which a wallet that exists only on Arbitrum One can't satisfy, but `forward` plus `returnDeposit` plus
  `withdraw(account)` (callable by anyone, paying the account itself) always gets the money to the buyer on
  Arbitrum One. The cross-chain address-ownership caveat for such wallets is already in SPEC §14.11.
- A burn made directly through CCTP with a `destinationCaller` of the sender's choice only lets that sender time
  the arrival of its own money at a box; whatever arrives funds the deal on its fixed terms or returns to the buyer.

---

## Pentest area F: Deployment (commit 3ca0628, v0.30)

Scope: the two compiler pipelines (`contracts/foundry.toml` `compilation_restrictions`: via-IR for the escrow and
the verifiers, legacy for the router), whether what the tests run is what deploys, via-IR codegen surprises,
`DeployAll.s.sol`'s order and guards, `Networks.sol` against Arbitrum One (fork at block 519,194,000, the mainnet fork
test's pinned block), `CheckDeployment.s.sol`'s coverage of every immutable, and the promised escrow addresses.

Proofs: `contracts/test/pentest/F_*.t.sol` (17 tests, all passing on the Arbitrum One fork against the real USDC,
USDT0, KlerosCoreNeo, Kleros's public DisputeResolver, template registry, evidence module and USDT0's OFT) and
`scripts/pentest/F_verify-profiles.sh`. Run everything from
`contracts`:

```bash
forge test --match-path 'test/pentest/F_*.t.sol' -vv          # all 17 (log: ../REPORT-F-run.log)
../scripts/pentest/F_verify-profiles.sh            # the verification profiles (no network)
```

**Summary.** No way to lose, steal, lock or misdirect money through the deployment was found. The build is sound:
every test suite (unit, fuzz, invariant, fork) and the service's Anvil harness exercise byte-for-byte the via-IR
escrow and verifiers that `DeployAll` deploys, the router artifact that `DeployRouter` deploys is the legacy build
whose init code lands at `Networks.ROUTER`, every `Networks.sol` constant matches Arbitrum One, the promised escrow
addresses are the mainnet deployer's nonces 3 and 4 and are still free, and the 5M Kleros gas budget holds at the
exact gas floor of the via-IR build against the real resolver. Two low-severity process findings: the launch gate
`CheckDeployment.s.sol` does not read back four settings (a misdeployed escrow passes it), and the router's source
verification command in the mainnet runbook is refused by forge as ambiguous.

---

### Findings

#### F-1. `CheckDeployment.s.sol` does not read back `promoSigner`, `promoEnds`, the juror template or the whole verifier allow-list: a misdeployed escrow passes the launch gate

- **Severity:** Low (a gap in the safety net, not an exploit path; the impact if a deployment ever carried one of
  these mistakes would be Medium, loss of yescrow's fees, or High for a rogue verifier).
- **Attacker and preconditions:** none on-chain. A wrong value in `.env.mainnet` (`PROMO_SIGNER` derived from a key
  that isn't the service's), an edited `DeployAll.s.sol`/template file, or a manual deployment. `DeployAll` builds the
  config correctly today; the finding is that the check that docs/audit.md §8 and docs/mainnet.md §3 step 3 rely on
  ("every setting read back") would not notice.
- **Steps (PoC `F_CheckDeploymentGaps.t.sol`):** deploy an escrow with DeployAll's exact mainnet config except
  `promoSigner` = a stranger's key, `promoEnds` = +365 days (inside DeployAll's 366-day bound), `templateData` =
  `"{}"`, and a second verifier (a `MockVerifier` that accepts any facts) on the allow-list; run `CheckDeployment.run()`
  with `REQUIRE_KLEROS=true` against the real Kleros on the fork. It prints `ALL CHECKS PASSED` (both simulated
  cases are created). Then: (1) the stranger signs `FeeVoucher(dealId, 0, expiry)`, anyone submits it, the buyer
  confirms: the seller is credited 1,000 USDC instead of 990 and the fee recipient 0 instead of 10 USDC
  (`test_F_impact_promoKeyNotChecked_feeWaivedByAStranger`); (2) a deal whose terms name the rogue verifier is
  proven with `abi.encode(RdapFacts)` nobody attested and released: 990 USDC to the seller for a domain that never
  moved (`test_F_impact_rogueVerifierNotChecked_paysOnUnattestedFacts`; needs terms that name that verifier, which the
  web app and API never produce); (3) the stored template is 2 bytes instead of the 1,428-byte juror template, read
  from storage slot 6 since the contract has no getter.
- **Impact:** a deployment mistake in any of these four settings reaches mainnet unnoticed by the gate. Each escrow's
  settings are immutable, so the only remedy would be a new deployment.
- **Command:** `forge test --match-path test/pentest/F_CheckDeploymentGaps.t.sol -vv`
- **Root cause:** `contracts/script/CheckDeployment.s.sol` `checkEscrow` (lines 52–93) reads token, router,
  arbitrator, both `arbitratorExtraData`, `smallDealMaxPrice`, `feeRecipient`, `feeBps`, `referrerShareBps`, the
  window floors, `isVerifier(verifier)` for the one given verifier (line 79), `resolver`, `judge` and the EIP-712
  domain, but never `promoSigner()` / `promoEnds()` (public immutables, `YescrowEscrow.sol` lines 206–208), nor the
  template (`string internal templateData`, line 231, no getter), and a mapping can't be enumerated for extra
  entries.
- **Suggested fix:** the strongest single check is to decode the escrow's own creation transaction: the `Config`
  struct is the constructor argument in the CREATE input (`cast tx <hash> input` minus the creation code, or
  `vm.rpc("eth_getTransactionByHash")` in the script), so `CheckDeployment` can `abi.decode` it and compare *every*
  field (template, mappings, verifier list, promo key and end, windows) with the config it rebuilds through
  `DeployAll.config(...)` for that escrow address. Cheaper: `require(e.promoSigner() == vm.envOr("PROMO_SIGNER",
  address(0)) && e.promoEnds() == vm.envOr("PROMO_ENDS", 0))` (deploy-all.sh exports both), read `templateData` from
  slot 6 (as the PoC's `_templateData` does) and compare it with the rendered `script/dispute-template.json`, and add
  a `templateData()` getter in the next contract version.

**Verification of F-1:**

> **Verdict:** CONFIRMED (Low)
>
> **Proof of concept:** `forge test --match-path test/pentest/F_CheckDeploymentGaps.t.sol -vv` from `F/contracts`: 4/4 pass, also with `-j 1` (the tests' `vm.setEnv` can't race). On the Arbitrum One fork (block 519,194,000) the misdeployed escrow `0x1d14…f211` gets "ALL CHECKS PASSED" with `REQUIRE_KLEROS=true` (real Kleros cases 328 in the AI court and 329 in the General Court). It stores a 2-byte template instead of 1,428 bytes. A stranger's voucher leaves the seller 1,000 USDC and the fee recipient 0 (instead of 990 and 10). The rogue-verifier deal pays the seller 990 USDC on facts nobody attested.
>
> **Sources unchanged:** yes. `diff -rq` against `base` differs only in `contracts/test/pentest/`, `scripts/pentest/`, `REPORT-F*` and build output.
>
> **What I checked:**
> - `CheckDeployment.s.sol` `checkEscrow` (lines 52–93) never reads `promoSigner()`, `promoEnds()` or the template. It checks `isVerifier` only for the verifier it is given (line 79). The `YescrowEscrow.sol` references (lines 206–208, 231) are right.
> - The test is clean:
>   - Nothing is stored or etched into yescrow's or a third party's contracts. The judge is an etched stand-in at `0x1d6e`, as in `rehearse-mainnet.sh`.
>   - `deal` only funds the buyer's deposit address.
>   - The pranks are normal party actions: the buyer confirms; the seller accepts and submits a proof.
>   - The stranger's key and `MockVerifier` are the stated misconfiguration, not stand-ins for real contracts.
> - My controls, `test/pentest/verify/V_F-1_controls.t.sol` (3/3 pass, `-j 1`):
>   - On a DeployAll-built escrow, slot 6 is the 1,428-byte rendered template and slot 7 the empty mappings, so the PoC reads the right slot.
>   - The gate reverts ("escrow: fee recipient") on a setting it checks, so its pass is meaningful.
>   - The gate also passes `promoEnds = 2^64−1`: DeployAll's 366-day bound isn't checked again at launch.
> - Not a known issue (`docs/audit.md` §5). Area F's brief asks for "CheckDeployment covering every immutable", and `docs/mainnet.md` §0 and §3 step 3 claim "every setting".
>
> **Reason (caveats; the severity stands):**
> - A gap in the safety net: each loss shown needs a separate mistake first.
> - `deploy-all.sh` (lines 53–58) derives `PROMO_SIGNER` from `PROMO_SIGNER_PRIVATE_KEY` in `.env.mainnet`, and the mainnet service signs with that same key (`ENV_FILE=.env.mainnet`, `service/src/config.ts:149`). So "a stranger's key" needs a manual or edited deployment and someone else holding the named key. That is the leaked-promo-key worst case `docs/keys.md` already accepts: fee income only, until `promoEnds`. A wrong address nobody holds only stops promos: `promoOn` (`service/src/promo.ts:38–41`) won't issue vouchers.
> - The rogue-verifier release needs all three:
>   - edited deploy code (`DeployAll` always passes exactly one verifier);
>   - a buyer who funds terms naming that verifier;
>   - no challenge in the 72-hour window.
> - So "Medium/High if deployed" is an upper bound.
>
> **Corrections:**
> - Root cause: `templateDataMappings` (line 232) isn't read back either. The windows and `feePeriod` are checked only against floors.
> - Both suggested fixes are circular for the first precondition named. A config rebuilt through `DeployAll.config`, or a `PROMO_SIGNER` from the same env and checkout, matches a wrong `.env.mainnet` key or an edited template.
> - "deploy-all.sh exports both" holds only inside `deploy-all.sh`. The separate launch gate (`docs/mainnet.md` §3 step 3) sources `.env.mainnet`. Per the example file, that has `PROMO_SIGNER_PRIVATE_KEY` and `PROMO_DAYS`, no `PROMO_SIGNER`, and an empty `PROMO_ENDS`. `envOr` would return 0 and fail a correct NAMEPROS deployment.
> - Compare against independent values instead:
>   - the promo address pinned in `docs/keys.md` (`0xE4e9…b8dE`);
>   - `promoEnds` ≤ deploy time plus `PROMO_DAYS`;
>   - a template hash committed to the repo;
>   - the exact verifier list, decoded from the creation transaction recorded in `deployments/arbitrum-one.json`.

#### F-2. The router's source verification, as the mainnet runbook runs it, is refused: "Ambiguous compilation profiles found in cache: default, via-ir"

- **Severity:** Low (no funds; the public cannot read the verified router source at `0x7073…Cb3D`, which
  docs/keys.md §5 and SPEC §4.1's "identical code" promise rely on).
- **Attacker and preconditions:** none; a launch-day process failure.
- **Steps (PoC `scripts/pentest/F_verify-profiles.sh`, offline via `--show-standard-json-input`):**
  `forge verify-contract 0x7073f01f36773e8A67731d287365191Ab207Cb3D src/DepositRouter.sol:DepositRouter` (docs/mainnet.md
  §3 step 1, for chains 42161, 8453 and 1) exits with the ambiguity error because `DepositRouter.sol` is compiled in
  both jobs (`out/DepositRouter.sol/DepositRouter.json` and `DepositRouter.via-ir.json`). With
  `--compilation-profile default` forge submits `viaIR=false`, which is the deployed build; with `via-ir` it submits a
  build that lands at `0x3460…a8a1` and can never match. The escrow and the verifiers submit correctly (`viaIR=true`,
  `bytecodeHash none`, `appendCBOR false`, cancun, 200 runs).
- **Impact:** without the flag the router stays unverified on Arbiscan and Sourcify on every chain; with the wrong
  profile it is "verified" against nothing (the submission is simply rejected).
- **Command:** `../scripts/pentest/F_verify-profiles.sh`
- **Root cause:** `contracts/foundry.toml` lines 10–15 (two profiles for one source) and docs/mainnet.md §3 step 1's
  `forge verify-contract … src/DepositRouter.sol:DepositRouter` loop, which names no profile. `scripts/deploy-all.sh`
  verifies only the five via-IR contracts (fine).
- **Suggested fix:** add `--compilation-profile default` to the router's verify commands in docs/mainnet.md (Arbiscan
  and Sourcify), or move the router verification into `scripts/deploy-all.sh`/`DeployRouter` docs with the flag, and
  rehearse it once on Arbitrum Sepolia where the same router already exists.

**Verification of F-2:**

> **Verdict:** DOWNGRADED to Info
>
> **Proof of concept:** `../scripts/pentest/F_verify-profiles.sh`, run from `pentest/F/contracts`. It runs, and its results match the attacker's:
> - The escrow, RdapVerifier and DualRdapVerifier submit `viaIR=True`, 200 runs, cancun, `bytecodeHash none`, `appendCBOR False`.
> - The router, as docs/mainnet.md runs it: "Ambiguous compilation profiles found in cache: default, via-ir".
> - With `--compilation-profile default`: `viaIR=False`. With `via-ir`: `viaIR=True`.
>
> **Sources unchanged:** yes. `diff -r` against `base` differs only in `contracts/test/pentest/`, `scripts/pentest/` and the two report files. `lib/` is identical.
>
> **What I checked:**
> - **The failure doesn't depend on a test build.** I used a copy of `base/contracts` without `out/` or `cache/` (`pentest/verify/F-2-sim`). Running only the runbook's `forge script script/DeployRouter.s.sol:DeployRouter` (no RPC, no key, nothing sent) leaves `DepositRouter` in both profiles in the cache, and no test file was compiled. The cause is `src/YescrowEscrow.sol:16`, which imports `./DepositRouter.sol` into the via-IR job.
> - **The exact docs/mainnet.md:83 command fails** with exit 1 offline on 42161, 8453 and 1. On the real submission path, pointed at a closed local verifier URL, it fails before sending any request. `--verifier sourcify` fails the same way. With `--compilation-profile default` it gets past this check and stops only at the connection.
> - **The fix works.** I compiled the standard JSON that forge submits, with solc 0.8.30:
>   - default profile: 6,802 creation bytes, init-code hash `0x520ee3…e491`, which lands at `0x7073f01f…Cb3D` through the CREATE2 proxy;
>   - via-IR profile: 5,903 bytes, which lands at `0x34600Fa5…a8a1`.
> - **Public state (read only):** there is no code at `0x7073…Cb3D` on Arbitrum One yet. On Arbitrum Sepolia and Base Sepolia, Sourcify already shows this router as a creation and runtime "match" (verified 2026-10-29 08:03 UTC).
> - **Known issues:** not among them in docs/audit.md §5. `deploy-all.sh` verifies only sources compiled in one profile, so it is fine.
>
> **Reason:** The bug is real, but it doesn't touch money, deadlines or fees. Its only effect is a command that fails loudly: exit 1 on each chain, with forge's message naming the missing flag.
> - The router is immutable, so it can be verified on any later day. "Stays unverified" assumes the operator ignores three errors.
> - "The public cannot read the router source" is overstated: Sourcify already matches the byte-identical router's source on two chains.
> - SPEC §4.1's "identical code" doesn't depend on verification. The CREATE2 address commits to the init-code hash, so the bytecode alone proves it. Only docs/keys.md (line 8, §5 step 1) promises verified source on Arbiscan.
> - The attacker's own report rates I-2 (the silent `REQUIRE_KLEROS=false` default) as Info, and this failure is louder and milder.
>
> **Root-cause corrections:**
> - In `foundry.toml`, the lines that matter are 13 (the via-IR profile) and 15 (the escrow's restriction); lines 10–12 are comments. Besides tests, `src/YescrowEscrow.sol:16` and `script/Deploy*.s.sol` pull the router into the via-IR job.
> - The same router verification without a profile is in `scripts/deploy-escrow.sh:87` (Arbitrum Sepolia), where `|| true` hides the error.
>
> **Fix:**
> - Add `--compilation-profile default` to docs/mainnet.md:83 and to deploy-escrow.sh:87.
> - Rehearse offline instead of on Arbitrum Sepolia, where a rehearsal would likely just report "already verified": `--show-standard-json-input`, then compile it and compare the CREATE2 address, as above.

---

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

- **I-1. Size drift in the docs.** The via-IR escrow runtime is 22,948 bytes (`out/…/YescrowEscrow.json`, and the deployed
  code in `test_F_escrowAndVerifiers…`), not the 22,906 in docs/audit.md §1 and docs/security.md; 1,628 bytes remain
  under EIP-170, so audit fixes must stay small or the pipeline needs the next trick (`optimizer_runs`, splitting).
- **I-2. `scripts/deploy-all.sh` runs `CheckDeployment` with `REQUIRE_KLEROS=${REQUIRE_KLEROS:-false}`** even on
  arbitrum-one, so the deployment record (`active: true`) is written even if Kleros refuses cases; docs/mainnet.md §3
  step 3 reruns the gate with `true`. Default it to `true` for arbitrum-one so the record can't be written past a
  refusal.
- **I-3. Comment drift:** `DeployAll.s.sol` line 30 still says `FEE_RECIPIENT` is "a multisig on mainnet"; the decision
  (docs/mainnet.md, docs/keys.md) is an EOA, which `CheckDeployment` only notes.
- **I-4. The mainnet fork test's fallback hides a wrong router artifact:** `ArbitrumOneMainnetFork.t.sol` etches the
  deployed code at `Networks.ROUTER` if the artifact lands elsewhere (meant for coverage builds). Should foundry ever
  change which build is written as `DepositRouter.json`, the test would still pass. Assert `landed == Networks.ROUTER`
  unless `FOUNDRY_PROFILE=coverage`.
- **I-5. The Kleros call's budget arrives with the EVM's 2,300 value stipend** (5,002,300 at the callee's entry, 5,001,495
  after its dispatch at the exact floor), so "5M" is a floor, not an exact figure; nothing depends on it.
- **I-6. Stale cache entry:** `contracts/cache/solidity-files-cache.json` (copied from the checkout) still lists
  `test/pentest/Probe.t.sol`, which doesn't exist; forge logs an ERROR on every run and continues. Harmless; a
  `forge clean` removes it.

**Verification of these Info items:**

> **Verdict:**
> - I-1: CONFIRMED.
> - I-2: CORRECTED. The facts hold, but the suggested fix wouldn't stop the record being written.
> - I-3: CONFIRMED.
> - I-4: CONFIRMED.
> - I-5: CONFIRMED.
> - I-6: DROPPED.
>
> None of them deserves more than Info.
>
> **Proof of concept:** I ran this from `F/contracts`:
>
> `forge test --match-path 'test/pentest/F_*.t.sol' --match-test 'test_F_escrowAndVerifiers_testBuildEqualsDeployArtifact_andFitsEip170|test_F_atTheExactFloor_calleeStillGets4_99MOfThe5MBudget|test_F_calleeOverTheBudget_isARefusal_evenWith30MGas' -vv`
>
> All 3 tests pass on the fork:
> - The escrow is 22,948 bytes, both as the artifact and as deployed.
> - At the exact floor of 5,348,481 gas, the callee still had 5,001,495 gas after its dispatch.
>
> **Sources unchanged:** yes. `diff -r` against `base` (checked before and after the run) shows only `test/pentest/`, `scripts/pentest/` and the report files.
>
> **What I checked, and the reasons, item by item:**
>
> - **I-1 (size drift in the docs):**
>   - The two builds produce identical runtime and init code: the base copy's artifact (built in the checkout at 09:38) and F's rebuild from the commit's source.
>   - The old build-info's source differs from the commit's only by a trailing blank line.
>   - `docs/audit.md:12` and `docs/security.md:273` both say 22,906. The headroom is 24,576 − 22,948 = 1,628 bytes.
>   - SPEC:669 and GOAL-3:274 (24,178) are historical notes, not drift.
> - **I-2 (`REQUIRE_KLEROS` defaults to false):**
>   - The facts hold:
>     - `deploy-all.sh:136` forces `REQUIRE_KLEROS=${REQUIRE_KLEROS:-false}` on every network.
>     - That overrides `CheckDeployment`'s own default, `vm.envOr("REQUIRE_KLEROS", n.mainnet)` (line 35, documented as "default true on mainnet").
>     - `docs/mainnet.md:91` reruns the gate with `true`, and `rehearse-mainnet.sh:41` passes `true`.
>   - **What's wrong:** the record (`deployments/arbitrum-one.json`, `active: true`) is written at lines 97–112, before the gate runs at all (line 136). Defaulting to `true` would only make the script exit non-zero after the record already exists.
>   - **The fix:**
>     - Delete the override, so that `n.mainnet` applies: true on arbitrum-one, false on Sepolia.
>     - If no record should exist past a refusal, also move the `CheckDeployment` call before the jq write.
>   - Since v0.30, refused cases go to the judge, and the record only takes effect when the stack starts after step 3. So it stays Info.
> - **I-3 (comment drift):**
>   - `DeployAll.s.sol:30` says "a multisig on mainnet".
>   - `docs/keys.md:39` and `docs/mainnet.md:33` say an EOA (the user's choice of 2026-10-29). SPEC:437 only *recommends* a multisig.
>   - `CheckDeployment.s.sol:90–92` only logs a note.
> - **I-4 (fork test hides a wrong router artifact):**
>   - `ArbitrumOneMainnetFork.t.sol:80–86` behaves as described. Nothing later in the test pins the router or `boxCodeHash`, so a different build written as `DepositRouter.json` would pass.
>   - Production is guarded:
>     - `DeployRouter.s.sol:33` requires `predicted() == ROUTER` on its own legacy `type(DepositRouter).creationCode`.
>     - The escrow asks the router for box addresses (`YescrowEscrow.sol:792`).
>   - So only test fidelity is at stake: Info.
>   - The suggested fix works, because coverage runs as `FOUNDRY_PROFILE=coverage` (`scripts/check.sh:19`, `foundry.toml:47–52`). Alternatively, `vm.isContext(VmSafe.ForgeContext.Coverage)` from the vendored forge-std avoids relying on an environment variable.
> - **I-5 (the 2,300 value stipend):**
>   - The call is `createDisputeForTemplate{value: cost, gas: ARBITRATOR_GAS}` (`YescrowEscrow.sol:543`). The callee had 5,001,495 gas left, more than the 5,000,000 cap, which only the 2,300 value stipend explains.
>   - The 5,002,300 figure at entry is inferred (805 gas of dispatch), not measured.
>   - No document is wrong. `security.md:65` says "a fixed budget of 5M", and the budget is fixed whatever gas the caller sends. A callee needing 5,010,000 is still a refusal at 30M gas.
> - **I-6 (stale cache entry):** the provenance is wrong, and it isn't a property of the code under review.
>   - **Where it came from:**
>     - The entry's `lastModificationDate` is 10:16:25 UTC, five minutes after the export's file times (10:11:35).
>     - It was compiled in build `946a6067…` at 10:17:18, which left `out/Probe.t.sol/ProbeTest.json`.
>     - So it was a probe the pentest setup created in `base` and then deleted.
>   - **It isn't in the checkout:**
>     - The checkout's cache (09:51) has 93 entries, none of them missing on disk.
>     - `contracts/cache/` is gitignored (`.gitignore:8`).
>     - All seven area copies' caches were rewritten between 11:29 and 11:33 without it.
>   - **No ERROR reproduced:**
>     - In a throwaway copy with the entry put back, forge 1.8.5 logged no ERROR on `build`, `test` (including a `test/pentest` glob), `script` or `verify-contract`.
>     - `build`, `test` and `script` silently pruned the entry on their first run.

---

### Checked and found sound

**Build pipeline: the tests run what deploys.**
- `contracts/cache/solidity-files-cache.json` and `out/build-info`: one via-IR job (build `cbd4ea1d…`) compiles
  `YescrowEscrow.sol`, `RdapVerifier.sol`, `DualRdapVerifier.sol`, `DeployAll.s.sol`, `Deploy.s.sol`,
  `CheckDeployment.s.sol`, `Base.t.sol` and every escrow test, the invariant suite and the three escrow fork tests;
  one legacy job (`634abc37…`) compiles `DeployRouter.s.sol`, `DepositRouter.t.sol` and `MultiChainFork.t.sol`. So
  the unit, fuzz, invariant and fork tests deploy the via-IR escrow and verifiers, and the router script and the
  router's own tests deploy the legacy router. (`F_DeploymentConstants.t.sol`
  `test_F_escrowAndVerifiers_testBuildEqualsDeployArtifact_andFitsEip170`: `type(X).creationCode` in a test equals
  `vm.getCode` of the artifact for the escrow and both verifiers; the deployed escrow is 22,948 bytes.)
- Artifacts carry the right settings: `YescrowEscrow.json`, `RdapVerifier.json`, `DualRdapVerifier.json` are `viaIR
  true`; `DepositRouter.json` / `DepositBox.json` are the legacy builds and `*.via-ir.json` the others; all 200 runs,
  cancun, no metadata hash, no CBOR.
- The legacy router artifact's init-code hash `0x520ee3…e491` lands at `0x7073f01f36773e8A67731d287365191Ab207Cb3D`
  through the keyless proxy, and is byte-identical to the init code in the recorded deployments
  `broadcast/DeployRouter.s.sol/421614` and `/84532`: the v0.30 compiler split did not move the router. The via-IR
  router (what a file that also imports the escrow gets, as the escrow unit tests do) would land at
  `0x34600Fa5…a8a1` with another `boxCodeHash`, so every deal would get other deposit addresses; `DeployRouter.run`'s
  `predicted() == ROUTER` refuses it (`test_F_routerArtifact_isLegacy_andLandsAtNetworksROUTER_viaIrBuildWouldNot`).
- `forge verify-contract --show-standard-json-input` submits `viaIR true` for the escrow and the verifiers (F-2 for the
  router).
- The service's Anvil harness (`service/test/harness.ts` line 57) and `scripts/export-abi.sh` read
  `contracts/out/<Name>.sol/<Name>.json`: the via-IR escrow and the legacy router, as deployed.

**via-IR codegen.**
- No expression in `YescrowEscrow.sol`, `DepositRouter.sol`, `DepositBox.sol`, `RdapVerifier.sol`, `Domains.sol`
  evaluates two side-effecting sub-expressions (the one documented semantic difference between the pipelines:
  argument evaluation order). Increments in the verifier are single-effect (`c[++i]`, `out[n++]`, `j = n++`).
- Every assembly block is memory-safe as annotated: `_read` writes only at and after the free memory pointer without
  bumping it (allowed by the Solidity memory-safety rules) and copies one word back; `Domains.tldOf` and
  `RdapVerifier._find` keccak over slices inside allocated arrays; `_unescape` shrinks a length to at most its
  allocation. `_read` returns `ok=false` on short returns (`returndatasize` check) and reads word 2 of KlerosCore's
  5-word `disputes` getter, matching the real core (fork test: period 0 before `applyRuling`).
- `gasleft()` is non-movable in Yul, modifiers have a single `_`, `return` inside the `try` runs `nonReentrant`'s
  tail, immutables and OpenZeppelin's `ShortString` EIP-712 are via-IR safe. The `vm.getBlockTimestamp()` note in
  docs/security.md is a test-only artefact (within one call the real EVM's timestamp is constant).

**The Kleros gas budget on the via-IR build, against the real resolver (`F_RealKlerosGasFloor.t.sol`).**
- A real AI-court case through Kleros's resolver with the real 1,428-byte template costs 727,474 gas (General Court
  710,292): 15% of the 5M budget.
- The least gas with which `payArbitrationFee` doesn't revert `GasTooLow` is 5,348,481 (169,116 of work before the
  check: the cold `arbitrationCost` call, two SSTOREs and the two storage-string copies); at exactly that gas the real
  case is created in court 34 (`Disputed`, `heldFee` = the fee), and a callee that needs 4.96M gas still had
  5,001,495 at entry: the 100,000 margin covers everything between the check and the call. A callee needing more
  than the budget is a refusal at any caller gas (30M), so the caller can't choose between Kleros and the judge.
- The relayer sends `estimateGas × 1.2` (`service/src/relayer.ts` 39–46), which clears the floor.

**`DeployAll.s.sol` (`F_DeployAllGuards.t.sol`).** At the fork block the router isn't on Arbitrum One and `check`
refuses to run ("deposit router not deployed"); with it deployed the launch settings pass, and each of the thirteen
wrong settings is refused with its own message: another deployer, a used deployer (nonce 1), an EOA judge, judge =
fee wallet, fee recipient or judge = deployer, each window below its floor, `PROMO_ENDS` past 366 days or in the
past, yescrow's attestor = Reclaim's, a short policy URI, and a resolver whose `arbitrator()` is another court.
`settings()` on mainnet defaults to 24 h / 72 h / 72 h, 1%, 25%, 2,000e6, no promo key. The deploy order (two
`RdapVerifier`, `DualRdapVerifier`, USDC escrow, USDT escrow) with `computeCreateAddress(deployer, getNonce)` before
each escrow and the post-deploy `require`s binds each template to its own escrow; the `--slow` broadcast and the
rehearsal script exercise the real key on a local fork only.

**Promised addresses.** `CREATE(0xA8C2…D14D, 3)` = `0xe13E539Ac355CDe25eA62e6591Ba2E3870227aBa` and nonce 4 =
`0x3A73eC53e860ad4F41bD9499102A13B6F3011fbB` (cast and `vm.computeCreateAddress`); at block 519,194,000 the deployer
has nonce 0 and balance 0, both addresses are empty, the CREATE2 proxy is present; nobody but the key holder can put
code there (CREATE), and since v0.30 nothing depends on the listing anyway.

**`Networks.sol` against Arbitrum One at block 519,194,000 (`test_F_networks_*`, `test_F_routerRoutes_onArbitrumOne`).**
USDC `0xaf88…5831` (6 decimals, "USDC"), USDT0 `0xFd08…Cbb9` (6 decimals, name "USD₮0", EIP-3009
`authorizationState`); KlerosCoreNeo `0x991d…22ea` version 0.10.0, governor `0xf1C7…0c59` (the resolver's governor is
the same key); the resolver `0xb552…4323` points at the core, is on the allow-list and uses the template registry
`0x0cFB…a5A2` (339 templates); the evidence module `0x48e0…1EB3` accepts evidence; court 1 (parent 0) `feeForJuror`
0.005 ETH, court 34 (parent 33) 0.00027 ETH, `jurorsForCourtJump` 7, periods 600/2,700/1,800/129,600 s, both
hidden-vote, enabled, supporting dispute kit 1; `arbitrationCost(abi.encode(1,1,1))` = 5e15 and `(34,3,1)` = 8.1e14
wei, exactly what the escrow's extraData encodes; the router's route: CCTP V2 TokenMessenger `0x28b5…cf5d` and
USDT0's OFT `0x14E4…8D92` (`token()` = USDT0, a quote to Ethereum = 1.72e13 wei). Floors 24 h / 72 h / 72 h equal
SPEC §9's minimums.

**`CheckDeployment.s.sol` covers** the token and its decimals, router, arbitrator, both court settings, the small-deal
cap, fee recipient, fee, referrer share, window floors, the given verifier (and the dual verifier's two attestors and
TLD table), the resolver and its arbitrator, the judge (and that it has code on mainnet), the EIP-712 domain, and a
simulated case in each court from the escrow's own address, passing on a correct deployment
(`test_F_checkDeployment_passes_theRightDeployment`). Gaps: F-1.

---

### Unconfirmed

- **solc 0.8.30 via-IR known bugs.** The live solc bug list wasn't fetched (offline); from memory no published via-IR
  bug affects 0.8.30, and the code review above found none of the historically affected patterns (argument-order with
  side effects, non-memory-safe assembly, multi-`_` modifiers).
- **Court 34 is the "Agentic Commerce Court".** Court names live only in Kleros's config/policy files; on-chain it is
  consistent with docs/dependencies.md §1.8 (parent 33, fee 2.7e14, 10/45/30 min and 36 h periods, jump 7).
- **Arbiscan/Sourcify acceptance of the via-IR escrow** (`--guess-constructor-args` with the 1.4 KB template in the
  constructor args) wasn't exercised on a network; the standard JSON forge would submit is correct.
- **Browser wallets' gas estimates at the 5.35M floor.** Arbitrum's `eth_estimateGas` bisects to the first
  non-reverting limit, so it clears the `GasTooLow` floor; a wallet that caps or trims the estimate would make the
  answering party's own transaction revert (retry within the 72-hour fee period). Not provable offline; the relayed
  path is fine.
- **A failed CREATE mid-`DeployAll`** (for example the USDC escrow's transaction dropped after the verifiers) moves
  the deployer's nonce, so the `getNonce == 0` guard blocks every rerun and the promised addresses become
  unreachable; since v0.30 no money depends on them, but the runbook has no recovery step.

---

## Pentest report — 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, and the web app's signing dialogs. Commit under test: `3ca0628`.

Headline: 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 hold no key that can move a
deal's money (verified against `docs/keys.md`), and the contract rejects anything the relayer/keeper would submit that
a signature or deadline didn't authorise. The reportable issues are one **Medium** (yescrow's own ETH, griefing, with a
plausible escalation to a seller loss) and two **Low/Info**. The bulk of the review is in "Checked and found sound".

---

### Finding G-1 (Medium) — Sponsored arbitration fees let a zero-ETH attacker drain yescrow's daily ETH budget for free and deny the sponsored-dispute path to honest no-ETH parties

**Attacker:** any user, controlling two wallets that hold no ETH (buyer + seller of the same deal), or just a no-ETH
buyer issuing frivolous challenges. **Preconditions:** the service runs the relayer with `SPONSOR_DAILY_WEI > 0`
(SPEC §14.17, mainnet is meant to sponsor AI-court fees); the deal price is `<= smallDealMaxPrice` (the AI court).

**Root cause:** `service/src/sponsor.ts` `sponsorship()` (lines 26–47). It grants `{ sponsored: true }` to any party
whose wallet holds `< feeWei + 0.0001 ETH`, in the AI court, once per `(escrow, dealId, role)`, within the global
daily budget. There is **no check that the two sponsored sides of one dispute are different actors**, and **no cap on
the number of deals** — only `SPONSOR_DAILY_WEI`. So one actor gets yescrow to pay *both* fees of a dispute it fully
controls, on as many fresh deals as it likes. The arbitration cost forwarded to Kleros (`cost ≈ A`) is never refunded
(only `heldFee = 2A − cost` returns), so each sham dispute that reaches a Kleros case costs yescrow ~A of ETH while
costing the attacker nothing (every step is relayed; the USDC deposit is refunded on a concession).

**Steps:**
1. Attacker owns `buyer` and `seller`, both with 0 ETH. Buyer funds a small-deal-priced deal from the deposit address
   (no gas); the keeper/relayer proposes + accepts (no gas).
2. Seller `dispute` (signed; relayer sends it and pays A — sponsored). → `AwaitingFee`.
3. Buyer `payArbitrationFee` (signed; relayer pays A — sponsored). → Kleros case created; A is forwarded to Kleros for
   good.
4. Seller `cancel` → `Refunded`: the buyer's full deposit is credited back; the `heldFee = A` goes to the relayer.
   Net: relayer paid 2A, recovered A, is **out exactly one fee A**; attacker is whole.
5. Repeat on new deals until `spentToday + feeWei > SPONSOR_DAILY_WEI`. A legitimate no-ETH seller mid-dispute then
   gets `FEE_NOT_SPONSORED` and must pay from an empty wallet or lose the deal by fee-default (SPEC §7.1).

**Impact:** yescrow loses up to ~`SPONSOR_DAILY_WEI`/day of relayer ETH to shams at zero attacker cost (loss of
yescrow's funds + griefing = Medium). **Escalation toward High:** exhausting the budget denies the advertised
sponsored-dispute path to a genuine unbanked no-ETH seller who delivered the domain; if they can't fund the fee before
`feeDeadline`, `feeTimeout` refunds the buyer and the seller loses both the domain and the payment — a party forced
into a loss under plausible conditions.

**PoC commands** (from `.../pentest/G/contracts`):
- Off-chain (the service decision that makes it free and drains the budget — needs only node):
  `node ../scripts/pentest/G_sponsor_drain.ts`
  → both sides of one deal `sponsored: true`; 12 fees over 6 shams consume the whole 0.01 ETH budget; an honest no-ETH
  seller is then refused.
- On-chain money flow (exact ETH loss and full attacker refund):
  `forge test --match-path 'test/pentest/G_SponsoredFeeDrain.t.sol' -vv`
  → `relayer ETH lost per sham dispute (wei): 10000000000000` (= A), `attacker USDC recovered: 1000000000` (full).

**Suggested fix:** cap sponsorship per funder/deal more tightly and tie the two sponsored sides to distinct,
non-colluding actors where possible; at minimum refuse to sponsor *both* sides of one dispute (sponsor only the party
that is answering an opponent who paid its own fee), require a small refundable anti-spam bond or a minimum deal age,
and alert/rate-limit when the daily budget is being consumed by many fresh small deals from correlated addresses. The
budget bound is the current mitigation; the gap is that it is drainable for free.

**Verification of G-1:**

> **Verdict:** CONFIRMED (Medium), with corrections below.
>
> **Proof of concept:**
> - `node ../scripts/pentest/G_sponsor_drain.ts` (from `G/contracts`): PASS. One deal gets both sides `{ sponsored: true }`. 12 fees across 6 shams book 0.00972 ETH of the 0.01 ETH budget. Then deal #7 and an honest seller with no ETH are refused.
> - `forge test --match-path 'test/pentest/G_SponsoredFeeDrain.t.sol' -vv`: PASS. The relayer loses 10000000000000 wei (the mock's A) per sham; the attacker's 1,000 USDC comes back in full.
> - My test `contracts/test/pentest/verify/V_G-1_OneSponsoredSide.t.sol`: 2/2 PASS (see Reason).
>
> **Sources unchanged:** yes. `diff -r` against `base` shows only the PoC files and `REPORT-G.md` added.
>
> **What I checked:**
> - `sponsor.ts` and its only caller, `/v1/relay` (`app.ts:1010–1030`). Sponsorship looks only at (escrow, dealId, role), the party's ETH, the AI court and one global 24-hour sum. Nothing links fees to an actor or caps deals. The Node PoC's stand-in database matches migration 008: primary key (escrow, deal_id, role), no refund tracking.
> - The Foundry PoC. The attacker's own keys sign every step. `vm.prank(seller)` stands in for a signed step the relayer would send, with the same end state. `MockArbitrator` keeps the fee, as KlerosCore does. No `vm.store` or `vm.etch`. The refunds match `_close` (`YescrowEscrow.sol:1027–1044`).
> - Not a known issue (`docs/audit.md` §5). SPEC §14.17 sets exactly these limits, without considering one actor owning both sides.
> - Mainnet AI-court fee: 0.00081 ETH (≈ $2), per the fork test.
>
> **Reason (corrections; the severity stays Medium):**
> 1. **Impact figure.** The PoC books 2A of budget per sham but loses only A, because the held fee comes back. That drains about half of `SPONSOR_DAILY_WEI` a day. The whole budget can be lost another way, which I verified: sponsor only the answer, and have the attacker pay the opener's fee from a third wallet, then concede the other way to win it back. The relayer loses A for each A of budget. The attacker ends whole: the price is 1 base unit (the contract only requires price ≠ 0), so the 1% fee rounds to 0.
> 2. **The denial of service costs yescrow nothing.** A sponsored dispute conceded before the answer refunds the relayer in full (verified). But `spentToday()` (`sponsor.ts:20–23`) still counts the row for 24 hours. One actor can keep the budget at zero for free, and the relayer's balance shows no loss.
> 3. **Root cause.** `sponsor.ts:26–47` is right. Add `sponsor.ts:20–23`: one global pool that counts gross fees and ignores refunds.
> 4. **Fix.** "At minimum, refuse to sponsor both sides" doesn't stop the loss (point 1 sponsors one side). Better:
>    - Count only fees that reached Kleros, net of refunds.
>    - Give each deal or wallet pair a small share of the budget, with a minimum deal age.
>    - Alert the operator when fresh small deals use most of the budget.
>    - Document that self-dealing can take the whole budget daily, and keep it small.
> 5. **Precondition.** `SPONSOR_DAILY_WEI` is 0 in `.env.example` and `.env.mainnet.example`. The attack exists only once the operator turns sponsoring on, as SPEC §14.17 plans.
>
> **Why not High:** the loss is yescrow's own ETH, capped by the budget the operator sets: on this scale, a loss of yescrow's fees. The PoC doesn't show the escalation, an honest seller losing by fee default; it only shows `sponsored: false`. That needs a victim who can't get about $2 of ETH within the 72-hour fee period, and an attacker who keeps the rolling 24-hour budget drained for all 72 hours. SPEC §14.17 says an unsponsored party pays its own fee. Still, note the targeted version: a buyer challenging a delivered domain while draining the budget reopens the gap §14.17 closed.

---

### Finding G-2 (Low) — The keeper proposes/accepts deals for sanctioned parties from deposit addresses, bypassing the API's sanctions screen

**Attacker/condition:** a buyer or seller on OFAC's SDN list, using the deposit-address funding flow. **Root cause:**
screening is a Fastify `preHandler` hook that only runs on API requests (`service/src/sanctions.ts`
`registerScreening` / `screened`). The keeper's deposit path (`service/src/keeper.ts` `deposits()` → `propose` /
`acceptFromDeposit`, lines 260–297) runs in a background loop and calls `this.screen` **nowhere** — only
`sendPayouts()` screens (line 179). So when a listed party funds a deal's deposit address, yescrow's own relayer
submits the `propose`/`accept` that creates and activates the deal, even though `POST /v1/relay`/`/drafts` for the same
party would be refused with `SANCTIONED_ADDRESS`.

**Impact:** a compliance gap, not a direct money loss: no funds reach the sanctioned party through this path (the
escrow just holds the buyer's USDC; the eventual payout *is* screened by `sendPayouts`, and an on-chain `withdraw` is
permissionless anyway). SPEC §12 scopes keeper screening to "automatic payouts", so this is arguably within spec, but
it means the keeper actively advances deals the API declines to touch. No runnable PoC (would require the full
stack); confirmed by code reading at the lines above.

**Suggested fix:** screen `terms.buyer`/`terms.seller` in `keeper.deposits()` before `propose`/`accept` (and in
`forwardFromDepositChains`), skipping (not reverting) a listed deal, mirroring `sendPayouts`.

**Verification of G-2:**

> **Verdict:** CONFIRMED (Low), by code reading only. **This finding has no proof of concept.**
>
> **Proof of concept:** none. The attack agent supplied none (`contracts/test/pentest/` holds only G-1's test), and I ran none: the gap is in TypeScript (`service/`), which a forge test can't exercise, and a live run needs the full stack. Every claim below was checked by reading the code at commit 3ca0628.
>
> **Sources unchanged:** yes. `diff -rq` of the attack copy against the pristine export differs only in `contracts/test/pentest/`, `scripts/pentest/` and `REPORT-G.md`.
>
> **What you checked (each claim):**
> - **Screening runs only in the API: true.** `registerScreening` (sanctions.ts:141–151) is a Fastify `preHandler`, registered only at api/app.ts:131. `relayer.ts` doesn't screen.
> - **The keeper's deposit path never screens; only `sendPayouts` does: true.** `this.screen` appears once, at keeper.ts:179. `deposits()`/`acceptFromDeposit()` (260–297) call `propose`/`accept` unscreened, although main.ts:52 hands the keeper the screen.
> - **The API would refuse the same calls: true.** A `/v1/relay` `propose` carries `terms` and `proposer`; an `accept` carries `dealId`, whose parties the hook loads from `deals`. `POST /v1/drafts` is screened on `terms`.
> - **No funds reach the listed party on this path: true.** `propose`/`accept` with empty funding pull the box into the escrow (`_fund` → `_pullDeposit`). The keeper's `returnDeposit` with the deal's token only credits `usdcBalance[buyer]` (YescrowEscrow.sol:656–671).
> - **Within spec: true.** SPEC §12 and security.md promise keeper screening only for automatic payouts. It isn't a known issue in audit.md §5.
>
> **Reason (severity and preconditions):** Low fits: no party loses money, no false release, no deadline moves. It isn't Info, because it breaks the design's stated intent (sanctions.ts:4: "anyone can still call the contracts directly; yescrow takes no part"). Here yescrow's relayer pays the gas, and its prover makes the "before" proof, for a deal the API would refuse.
>
> The preconditions differ between the two halves:
> - **`accept` (realistic):** a seller proposes directly on-chain, with no API call. The watcher indexes every `Proposed` (watcher.ts:85). The deposit address is in the unscreened `GET /v1/deals/{id}` (app.ts:561) or the on-chain `depositAddress()`. Once the price lands, the keeper accepts. Neither party ever makes a screened request.
> - **`propose` (narrow):** drafts come only from the screened `POST /v1/drafts`, so this needs a party listed after its draft was made (within 30 days).
>
> **Corrections to the fix:**
> 1. **A stronger case the fix misses** (code reading, untested). The watcher records every cross-chain `WithdrawnTo` (watcher.ts:91), including one a listed party sends directly. `completeTransfers()` (keeper.ts:355–384) then has yescrow's remote relayer call CCTP `receiveMessage` (line 377), which mints USDC to the listed recipient on Base or Ethereum. This is the only keeper path that actually delivers tokens to an address. Mitigating: the burn's `destinationCaller` is 0 (DepositRouter.sol:226), so the party could complete the transfer themselves. Fix: screen `account`/`recipient` of outbound transfers there, and leave a listed one pending.
> 2. **The rest of the tick is also unscreened:** `submitProof` (with yescrow's prover), `release`, `refund`, `expire`, `feeTimeout`, `applyRuling`, `judgeTimeout` and `returnDeposit`, though `/v1/relay` refuses each of them for a listed party's deal. Leaving the deadline calls unscreened is defensible: they protect the non-listed party and only credit pull balances. So the reporter's narrow fix is right, and SPEC §12 should say so explicitly.
> 3. **Fail closed:** in `deposits()` and `forwardFromDepositChains()`, treat `SCREENING_UNAVAILABLE` as "skip", as `sendPayouts` does, not as "proceed".

---

### Finding G-3 (Info) — `settle` typed-data summary shows the deployment fee, not the deal's promo fee

`service/src/api/app.ts` (settle branch of `GET /v1/deals/{dealId}/typed-data`) passes `e.params.feeBps` to
`settleTypedData`, not `dealFeeBps(e, d)`. If a promo voucher already lowered the deal's fee, the human-readable
summary overstates the fee and understates the seller's take. The signed `Settle` message carries only `sellerAmount`
(the fee is computed on-chain from the deal's current fee), so there is **no money impact** — the seller merely
receives at least as much as the summary says. Info only.

**Verification of G-3:**

> Verdict: CONFIRMED (Info)
>
> Proof of concept: **none was supplied.** I checked the claims by reading the code, plus two checks of my own:
> - `node --input-type=module -e …` on the real `settleTypedData` (service/src/typed-data.ts): with feeBps 100, as app.ts passes, the summary reads "2475 USDC to the seller (after the 25 USDC fee) and 2500 USDC back to the buyer". With the deal's waived fee it would read 2500 and 0. The signed `Settle` fields contain no fee.
> - `forge test --match-path 'test/{Promo.t.sol,pentest/verify/V_G-3_SettleSummary.t.sol}' -vv` (run from G/contracts) passed 13/13. The new test is `contracts/test/pentest/verify/V_G-3_SettleSummary.t.sol`. Setup: price 5,000 USDC, sellerAmount 2,500, deployment fee 100 bps.
>   - Voucher to 0 bps before signing: seller credited 2,500 (summary: 2,475), fee 0 (summary: 25), buyer 2,500 (summary: 2,500).
>   - Voucher to 50 bps after both parties signed: the signatures still work. Seller 2,487.50, fee 12.50, buyer 2,500.
>   - Existing Promo.t.sol: 11/11, including `test_onlyLowers` (`FeeNotLower`) and `test_settlementInADispute_usesTheLowerFee`.
>
> Sources unchanged: yes. `diff -rq base G` shows only build output, REPORT-G.md, contracts/test/pentest/ and scripts/pentest/.
>
> What I checked:
> - app.ts:775 passes `e.params.feeBps`. The helper `dealFeeBps` (app.ts:317) exists and is used for the deal view (:555) and the payout typed data (:847), but not here.
> - `e.params.feeBps` is the escrow's on-chain immutable `feeBps()` (escrow.ts `loadEscrow`), so it is never below a deal's fee:
>   - `applyFeeVoucher` reverts with `FeeNotLower` unless the new fee is lower.
>   - `promoEnds` only stops new vouchers; a promo already applied stays.
>   - The judge's 0 fee is set only inside `judgeRuling`, which closes the deal.
> - `SETTLE_TYPEHASH` binds dealId, domain, parties, price, sellerAmount, caseCreated and deadline. `_close` charges `sellerAmount × _feeBpsOf(d) / 10000` at submission.
> - Web app:
>   - The Settlement panel (Dispute.tsx) takes the gross "Amount to the seller (before the yescrow fee)". Its offer card shows the gross amount and the buyer's refund, both exact.
>   - SignReview.tsx shows the server's summary word for word as the dialog's headline, then the signed fields exactly.
>   - The deal page's Terms row shows the deal's real promo fee, so the dialog contradicts the page.
> - Not in audit.md §5, and not intended: §12 requires typed data that shows the terms (met); the summary is extra.
>
> Reason: severity stands. No one can be misled into signing something worse than shown:
> - The buyer's refund (price − sellerAmount) is exact and doesn't depend on the fee.
> - The seller's net is understated by sellerAmount × (feeBps − deal fee) / 10,000. At 100 bps that is at most 1% of sellerAmount.
> - Only yescrow's fee recipient and the referrer get less than the summary implies, and neither signs `Settle`.
>
> One theoretical side effect: parties who bargain over the seller's *net* as the summary states it could agree on a sellerAmount up to about 1% higher than needed, at the buyer's expense. Even then, each signer's own figure is exact. Info stands.
>
> Correction to the root cause and the fix: the same bug is at app.ts:786, where every other action's typed data gets `feeBps: e.params.feeBps`. Only the `confirm` summary uses it. On a deal with a waived fee, the buyer is told "Release 4950 USDC to the seller (the 50 USDC fee goes to yescrow)" while the seller actually receives 5,000. It errs the same way and has the same severity, because the buyer releases the full price either way.
>
> Fix: pass `dealFeeBps(e, d)` at both line 775 and line 786. A voucher can still land after signing, so the summary's fee stays a ceiling, which errs the safe way. Both lines are unchanged at HEAD 9d7f12c and in the working tree (checked read-only).

---

### Checked and found sound

- **Automatic payouts can't over-pay or double-pay (SPEC §14.14).** `payouts.ts` `payoutAmount` computes the seller's
  net with the same integer `feeOf(price,feeBps)` the contract uses on `release` (`_close`, `fee = sellerAmount*feeBps/BPS`),
  so `credited == signed` exactly; a promo applied after signing only raises the credit, and `creditOf` sends the
  signed (smaller) amount with the remainder left as balance. `keeper.sendPayouts` skips when `credited < amount`,
  skips `Settled`/split outcomes, and the contract re-checks the signature, deadline, one-time use (`withdrawalUsed`)
  and that the balance covers it — so a replay, a wrong amount, or a send after a manual withdraw all revert. The
  signature is only ever submitted with `recipient = account = signer`, verified at save time.
- **No signature crosses deal/escrow/chain/phase/amount or works twice.** EIP-712 domain binds `yescrow/1/chainId/
  escrow`; actions bind the deal ID and are valid in one state only; `Settle` binds `sellerAmount` and `caseCreated`;
  `WithdrawTo` is one-time by digest; EIP-3009 funding nonce = deal ID. The web requests typed data by `role` and the
  server derives the signer/account, so a mismatched role yields a signature that fails `verifyTypedData` at save
  (checked in `payouts`/`alerts/email` handlers).
- **The relayer can't move a party's money or call arbitrary targets.** `relayer.ts` only ever sends to an
  escrow / router / token / Kleros address derived from config or the resolved deal, or an allow-listed wallet factory;
  it simulates first; every money step still needs the party's signature (verified on-chain). ERC-6492 deploy
  (`erc6492.ts`) is restricted to the two Coinbase factories and only when the call creates the *signer's own* address.
- **Deposit router isolation (`DepositRouter.sol`, `DepositBox.sol`).** A box is swept only by the escrow in its salt
  on the settlement chain (`pull`), or bridged only to the *same* box address on the settlement chain (`forward`,
  CCTP `mintRecipient`/USDT0 `to`), or returned to the salt's buyer (`returnStray`). `minAmountLD = amount` and CCTP
  `maxFee 0` mean nothing is skimmed in transit. A deposit can go nowhere but its deal or back to its buyer.
- **The prover's parameter check is defence-in-depth and can't forge an on-chain-valid proof** (`prover/src/server.ts`).
  Even if a caller supplied crafted `responseMatches`, the on-chain `RdapVerifier` rebuilds `parameters` from the
  domain and checks the attestor's signature over `keccak256("http\n"‖parameters‖"\n"‖context)`, so any deviation from
  the fixed template yields a different identifier and fails the signature check. `server.ts` additionally pins
  `url == rdapUrl(domain)` and `method == GET`.
- **The attestor patch is wired correctly** (`attestor/yescrow-hardening.diff`). `AttestorVersion.ATTESTOR_VERSION_3_2_0`
  is a real enum member (= 7) in attestor-core's proto, so `clientVersion < ATTESTOR_VERSION_3_2_0` genuinely refuses
  pre-3.2.0 clients; the server-record-fully-revealed and response-completeness checks are placed before body parsing
  and before signing. Closing the "old client / hidden-byte" vector (audit §V1/V2) on yescrow's side is sufficient
  because a `DualRdapVerifier` proof needs that attestor too.
- **Sanctions screening of the signing paths.** `screened()` catches every `POST /v1/*` write and every GET whose URL
  contains `typed-data` (verified: `payout-typed-data` and `withdraw-typed-data` match the substring regex and are
  screened); `addressesIn` scans body/terms/params/query address fields and the hook also loads the deal's parties.
  `isListed` fails closed (throws `SCREENING_UNAVAILABLE`), and `keeper.sendPayouts` treats `listed !== false`
  (including the throw → null) as "skip", so an unavailable list never pays a listed address. `MIN_ADDRESSES = 50`
  guards against a truncated list.
- **The web signing dialogs show exactly what is signed** (`SignReview.tsx`, `SignDialog.tsx`, `Warnings.tsx`): every
  EIP-712 field and the domain/verifying-contract/chain are rendered with the raw payload in a details pane, risk
  warnings must be acknowledged first, and amounts use the deal's token symbol. The relayer-can't-change-this note is
  accurate. No field is hidden or relabelled in a way that changes its meaning.
- **Admin / promo / referrer-share paths touch only yescrow's own fee.** `admin.ts`/`promo.ts` vouchers only lower the
  fee (contract enforces `FeeNotLower`, `promoEnds`); `setReferrerShare` needs `feeRecipient`'s signature and splits
  only the fee between `feeRecipient` and `referrer`, never a party's amount; admin sessions are EIP-712, 12 h, and a
  removed wallet loses its session (checked each request). A leaked admin/promo/fee-recipient key matches `docs/keys.md`.
- **Buy links / wizard / referral are untrusted-input safe** (`web/lib/buy-link.ts`, `wizard.ts`, `referral.ts`):
  strict validation, HTML/BBCode escaping, the referrer can only be a validated address (and the API rejects a
  referrer that is a party), and a hostile link's worst case is locking the buyer's funds until the transfer deadline
  (SPEC §4.2), refunded after.
- **Deployment settings match SPEC §14.20 and `Networks.sol`** (`DeployAll.s.sol`): escrows at the deployer's nonces 3
  and 4 (= the allow-list-requested addresses, asserted on mainnet); feeBps 100, referrerShare 2500, windows 24h/72h/
  72h, smallDealMaxPrice 2,000e6, courts 34 (3 AI jurors) and 1 (1 juror), Classic dispute kit, resolver
  `0xb5526D…4323`; the mainnet branch refuses any other deployer, a non-contract judge, judge == fee wallet, a used
  deployer nonce, and promoEnds > 1 year. Confirmed consistent with the mainnet fork test's assertions.
- **Keeper auto-accept window caps match the contract.** `MAX_AUTO_TRANSFER_WINDOW`/`MAX_AUTO_CHALLENGE_WINDOW`
  (60/30 days) equal the contract's `MAX_TRANSFER_WINDOW`/`MAX_CHALLENGE_WINDOW`, so a deposit-paying buyer is never
  locked longer than SPEC §9 promises.
- **Judge service holds no key and only builds the judge wallet's tx** (`service/src/judge.ts`): reasons are hashed,
  the `judgeRuling` tx is for the judge Safe to send, and the on-chain `_close` fee/ETH accounting on refused/stalled/
  timeout paths credits each wei once (traced against SPEC §8 and the mainnet fork test).

### Unconfirmed (reasoning, no proof)

- **RE2 vs JavaScript `RegExp` divergence between the two attestors** (`docs/security.md` §141–142: yescrow's attestor
  runs `sgi` JS RegExp, Reclaim's runs `sgiu` RE2). A crafted registry response that the two engines parse differently
  would make `DualRdapVerifier` revert `FactsDiffer` — a *liveness* risk (a legit seller can't get a proof), not a
  forgery, since a false fact would need both engines to agree on the same wrong capture. A malicious registry can
  already deny proofs by serving garbage, so this adds no capability. Not pursued: proving it needs the real attestor
  image (Docker), which is out of scope here.
- **DNS-rebinding SSRF on webhook and Web-Push endpoints** (`webhooks.ts` `assertPublicUrl`, used by `POST /v1/webhooks`
  and `POST /v1/deals/{id}/alerts`). The private-range check resolves the host once; the later `fetch`/`web-push`
  re-resolves, so a host that returns a public IP at check time and a private IP at send time yields a *blind* POST to
  an internal address (responses aren't returned to the attacker). No deal money is involved; worth pinning the
  resolved IP or re-checking at send time.
- **Idempotency key can be bricked**: if the process dies between the `idempotency` INSERT (status NULL) and `onSend`
  for a non-5xx response, that `(key, route)` returns `IDEMPOTENCY_IN_PROGRESS` (409) forever. Availability only; the
  client picks a new key.

---

# Part 3. Appendices

## A. The attack prompt, word for word

Each attack agent got this prompt with `{AREA BRIEF}` replaced by its row of the table below (as `<letter>. <name>: <brief>`), from `docs/ai-pentest.md` at commit 3ca0628:

> You are attacking yescrow, an escrow on Arbitrum One that holds buyers' USDC or USDT0 until a domain transfer is
> proven or a dispute is decided. Its contracts are immutable: a bug can't be fixed after launch, so assume every
> weakness you miss costs real people their money. Your job is to find ways to lose, steal, lock or misdirect money,
> or to make the contracts do what the specification says they must not, and to prove each one.
>
> **Read first:** `SPEC.md` (the rules; §5 state machine, §6 proofs, §7 disputes and §7.5 yescrow's judge, §8 money,
> §12 trust), `docs/audit.md` (scope, invariants, trust assumptions, known issues), `docs/keys.md` (what each key can
> and can't do), `docs/security.md`. Code: `contracts/src/`, `contracts/script/`, `prover/src/template.ts`,
> `attestor/yescrow-hardening.diff`, and the parts of `service/src/` that submit transactions or sign anything.
>
> **Attackers to play:** the buyer; the seller; any third party; a referrer; a front-runner; a party that withholds
> gas, ETH, a signature or a case; a smart-contract wallet (EIP-1271, ERC-6492, EIP-7702); a registrar or registry
> serving crafted RDAP; one compromised attestor; yescrow's relayer, keeper, prover or API; the holder of a key in
> `docs/keys.md` (only within the powers listed there: show any power beyond them); Kleros's resolver and governor.
>
> **Your area:** {AREA BRIEF}
>
> **What counts as a finding:** any party loses money it shouldn't, or someone gets money they shouldn't; a deal is
> paid twice, or a final state changes; a party can force the other into a loss or past a deadline the specification
> promises; a proof passes for a fact the registry didn't state; a signature works for another deal, escrow, chain,
> phase, amount or a second time; a deposit goes anywhere but its deal; a key does more than `docs/keys.md` says; an
> invariant in `docs/audit.md` §3 breaks; a deployment setting differs from what the specification and
> `contracts/script/Networks.sol` promise.
>
> **Proof required.** For every finding, write a Foundry test in `contracts/test/pentest/` that passes and shows the
> impact in token amounts, on a fork of Arbitrum One where real contracts matter (real USDC, USDT0, Kleros, CCTP) or
> with the real proof fixtures in `contracts/test/fixtures/`. For off-chain parts, a script in `scripts/pentest/`
> against a local stack. Don't change `contracts/src/` to make an attack work. Never send a transaction to a live
> network. Ideas you can't prove go in a separate list, "unconfirmed", with your reasoning.
>
> **Report each finding as:** title; severity (Critical: any party's funds stolen or permanently lost, or a false
> release. High: a loss under plausible conditions, or a party forced into one. Medium: griefing, delays past promised
> deadlines, or loss of yescrow's fees. Low and Info otherwise); attacker and preconditions; steps; impact; the
> command that runs the proof of concept; root cause (file and line); suggested fix.
>
> **Also report what you checked and found sound**, with a sentence on why, so readers can see how deep you went.
> Don't report the known issues in `docs/audit.md` §5 unless you can make them worse than described there.

**Area briefs**

| Area | Brief |
|---|---|
| A. State machine and money | Every transition of SPEC §5, each deadline at, just before and just after its boundary; balances, `withdraw`, `withdrawTo`; the fee, promo vouchers, the referrer share and fee payers; rounding; USDT0 and USDC quirks (blocklists, fees on transfer); donations; reentrancy through token or ETH transfers; the 63/64 gas rule |
| B. Signatures | EIP-712 domains and type hashes; EIP-3009 with the deal ID as nonce; EIP-1271, ERC-6492 and EIP-7702 wallets; settlements bound to their phase; payout signatures the keeper submits later; malleability; replay across deals, escrows, tokens, chains and phases |
| C. Proofs | `RdapVerifier`, `DualRdapVerifier`, `Domains`, `RdapStatus`; the two attestations agreeing; the parameters rebuilt from the domain; the template's regexes as RE2 runs them versus what Solidity checks; full reveal; JSON escapes, Unicode and IDNs, nested objects, long nameserver lists, timestamps and freshness; each TLD's registry URL; gas |
| D. Disputes | Opening cases through Kleros's public `DisputeResolver` (`0xb552…4323` on Arbitrum One); refusal detection with Kleros's fixed 5M-gas budget and gas starvation (the answer reverts below about 5.18M gas); the resolver pointing at another arbitrator; `applyRuling` reading the period and `currentRuling` from Kleros's core through a raw `staticcall` (`_read`): malformed or short return data, a case ID that isn't the deal's, appeals; the 90-day stall; the `Judging` state (value 10, appended) and yescrow's `judge`: `judgeRuling` limited to rulings 0, 1, 2 and its 14 days, `judgeTimeout`, races between the judge, a concession, a settlement and the timeout; judged deals paying no fee; on a refused case yescrow keeping the loser's fee (half each on a split), including sponsored fees; every fee path in SPEC §7.5 and §8 |
| E. Deposit router | CREATE2 deposit addresses and who can deploy them on which chain; forwarding through CCTP V2 (recipient, destination caller, finality) and USDT0 (LayerZero options); `returnStray`; tokens sent by mistake; anyone deploying the router or boxes on another chain |
| F. Deployment | Since v0.30 the escrow and the verifiers compile through via-IR (`contracts/foundry.toml`, `compilation_restrictions`) and the router through the legacy pipeline: check that what the tests run is what deploys, and look for via-IR codegen surprises; `DeployAll.s.sol` order and guards, `Networks.sol` constants against what is on Arbitrum One today (check them on the fork), `CheckDeployment.s.sol` covering every immutable, the promised escrow addresses |
| G. Off-chain parts | Only where they 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 |

## B. Operating notes given to every attack agent with the prompt

These were added after the prompt, under a line saying they are not part of the published prompt. `<copy>` was the agent's own copy of the commit, `<workdir>` the session's scratch folder, `<area>` its letter.

```text
Operating notes from the session running this test (not part of the published prompt):

- Commit under test: 3ca0628 (main, 2026-10-29). Your working copy: <copy>, a clean export of that commit with contracts/lib populated and contracts/ already built. Work only in that copy. The other six areas run at the same time in sibling copies; don't read or write them.
- Never touch <checkout> (the user's checkout, shared with other sessions), any .env file, any key, any Docker container (the yescrow-* containers are live stacks) or any live network. Reading public chain state through a public RPC is fine; sending a transaction is not. node_modules in prover/, service/ and web/ are symlinks into the user's checkout: don't install or change packages.
- The server has 2 cores and 4 GB of memory, shared with other sessions. Run forge only through the wrapper <workdir>/pforge (same arguments as forge, e.g. `cd <copy>/contracts && ../../pforge test --match-path test/pentest/<area>_Example.t.sol -vvv`). It takes a shared lock so only one forge job runs on the machine at a time, under a 3 GB memory cap, so it may wait for other jobs: give each Bash call a 600000 ms timeout. Always filter with --match-path or --match-test; never run the whole suite. A new test file that imports the escrow takes about a minute to compile. Don't run other heavy jobs (anvil, docker, next build, browsers) outside the lock; if you need one, run it as `flock <workdir>/forge.lock systemd-run --user --scope -p MemoryMax=2G --quiet -- <command>`.
- Fork tests: follow contracts/test/fork/ArbitrumOneMainnetFork.t.sol (`vm.envOr("ARBITRUM_ONE_RPC_URL", string("https://arbitrum.gateway.tenderly.co"))` at a pinned block; reuse its FORK_BLOCK where you can so RPC responses come from Foundry's cache). Don't set RPC environment variables. If the public RPC rate-limits you, wait and retry.
- Name proof-of-concept files contracts/test/pentest/<area>_<ShortName>.t.sol (scripts: scripts/pentest/<area>_<name>.*) so they merge with the other areas' without clashes. Don't edit files outside contracts/test/pentest/ and scripts/pentest/; you may read and reuse the existing tests, helpers and mocks.
- When you finish, write your full report to <copy>/REPORT-<area>.md and return the same report as your final message. For each finding, give the exact command that runs its proof of concept from <copy>/contracts, and keep the "checked and found sound" and "unconfirmed" sections.
```

## C. The verifier's prompt

Each verifier got this text, with the finding pasted in where it says so, and the instruction to read it first. `<workdir>` is the session's scratch folder. For the findings without a proof of concept (G-2, G-3) and for area F's Info list, the instruction added that the claims were to be checked by reading the code.

```text
You are a verifier in the AI penetration test of yescrow (docs/ai-pentest.md, "How to run", step 3). yescrow is an escrow on Arbitrum One that holds buyers' USDC or USDT0 until a domain transfer is proven (zkTLS-attested RDAP) or a dispute is decided (Kleros, or yescrow's judge for refused/stalled cases). Its contracts are immutable once deployed. An attack agent working on area <area name> reported the finding below. Your job is to try to refute it, and to run its proof of concept again yourself. You start with a fresh context: read what you need.

**The finding, as the attack agent reported it:**

<the finding, pasted from the area report>

**Where things are:**
- The attack agent's working copy (a clean export of commit 3ca0628 plus its proof-of-concept files): <copy>. Its full area report: <copy>/REPORT-<area>.md. Don't edit its files.
- A pristine export of the same commit, never modified: <workdir>/base. Use it to confirm that the attack copy's `contracts/src/`, `contracts/script/` and every other file the PoC relies on (outside `contracts/test/pentest/` and `scripts/pentest/`) are unchanged (`diff -r`).
- The rules and context: `SPEC.md`, `docs/audit.md` (§3 invariants, §5 known issues), `docs/keys.md`, `docs/security.md`, `docs/ai-pentest.md` (the attackers' prompt and severity scale).

**Do this:**
1. Check the copy's sources against the pristine export (step above).
2. Rerun the proof of concept with the reported command (from <copy>/contracts unless it says otherwise). Then read the test line by line: does it really show the claimed impact in token amounts? Look for things a real attacker couldn't do: `vm.prank` as a party that would never sign, `vm.store`/`vm.etch` into yescrow's or a real third party's contracts, `deal` beyond funding an attacker, mocks standing in for real contracts that behave differently, signatures from keys the attacker doesn't hold, assertions that pass for the wrong reason.
3. Try to refute it: is it one of the known issues in `docs/audit.md` §5 and not made worse? Does the specification allow this behaviour on purpose? Are the preconditions realistic? Is the root cause right (file and line)? Is the severity right on this scale: Critical: any party's funds stolen or permanently lost, or a false release. High: a loss under plausible conditions, or a party forced into one. Medium: griefing, delays past promised deadlines, or loss of yescrow's fees. Low and Info otherwise.
4. If you need to check a claim with code, write extra tests only as `contracts/test/pentest/verify/V_<ID>_<name>.t.sol` in that copy.

**Rules for your verdict (the user's):** a finding whose proof of concept runs and shows the claimed impact can't be dropped by your argument alone. You may downgrade (or upgrade) its severity with a stated reason, or keep it and state your challenge; the report will show the attacker's claim and your reason side by side so readers can judge. Drop a finding only if its proof of concept doesn't run, or doesn't show the claimed impact.

**Return** (under 600 words): `Verdict:` one of CONFIRMED (severity stands), DOWNGRADED to <severity>, UPGRADED to <severity>, CHALLENGED (kept at its severity, with your objection), DROPPED; `Proof of concept:` the command you ran, pass or fail, and the key numbers it showed; `Sources unchanged:` yes/no; `What you checked:` a few lines; `Reason:` your argument for anything other than a plain CONFIRMED; any correction to the root cause or the suggested fix. Also write the same text to <workdir>/verify/<ID>.md.

---

Operating notes (same as the attack agents had):
- Never touch <checkout> (the user's checkout, shared with other sessions), any .env file, any key, any Docker container (the yescrow-* containers are live stacks) or any live network. Reading public chain state through a public RPC is fine; sending a transaction is not. node_modules in prover/, service/ and web/ are symlinks into the user's checkout: don't install or change packages.
- The server has 2 cores and 4 GB of memory, shared with other sessions. Run forge only through the wrapper <workdir>/pforge (same arguments as forge). It takes a shared lock so only one forge job runs on the machine at a time, under a 3 GB memory cap, so it may wait for other jobs: give each Bash call a 600000 ms timeout. Always filter with --match-path or --match-test; never run the whole suite. Don't run other heavy jobs (anvil, docker, next build, browsers) outside the lock; if you need one, run it as `flock <workdir>/forge.lock systemd-run --user --scope -p MemoryMax=2G --quiet -- <command>`.
- Fork tests use `vm.envOr("ARBITRUM_ONE_RPC_URL", string("https://arbitrum.gateway.tenderly.co"))` at a pinned block. Don't set RPC environment variables. If the public RPC rate-limits you, wait and retry.
```

## D. Proof-of-concept files and how to run them

All tests together, from `contracts/` (171 tests in 36 suites, all passing on 2026-10-29 after `forge fmt`):

```bash
forge test --match-path 'test/pentest/**/*.t.sol'
```

The fork tests read public RPCs at pinned blocks (`ARBITRUM_ONE_RPC_URL`, `BASE_RPC_URL`, `ETHEREUM_RPC_URL` if set,
Tenderly's public gateways otherwise). A test that proves a finding asserts the weakness, so it will fail once the
finding is fixed; step 4 of the procedure turns each into a regression test. The tests depend on the via-IR build
(exact gas floors, artifacts, stack depth), so `scripts/check.sh` leaves `test/pentest/` out of its coverage run.

| File (`contracts/test/pentest/`) | Proves |
|---|---|
| `A_StateMoney.t.sol` | A-1, A-2; deadline boundaries, the 63/64 gas sweep, re-entrancy, conservation fuzzing, the Kleros fee cliff |
| `A_ArbitrumOneFork.t.sol` | Real USDC blacklist, CCTP burn limit, a real Kleros case at the exact gas floor |
| `A_Usdt0Fork.t.sol` | A-2 on the real USDT0; Tether's blocklist semantics |
| `B_Signatures.t.sol` | B-3, B-4, B-5; signatures across escrows and chains, malleability, front-running |
| `B_Eip3009Fork.t.sol` | B-1, B-2; EIP-3009 on the real USDC and USDT0 |
| `C_InterProofBlocking.t.sol` | C-1, C-2 |
| `D_AppealOverride.t.sol` | D-1, on the real Kleros core, dispute kit and resolver |
| `D_FeePaths.t.sol` | D-2, D-3; every fee path of SPEC §7.5 and §8; the gas floor with the real template |
| `D_KlerosReads.t.sol` | The resolver's dispute IDs and `_read` against the real core |
| `E_ForeignForward.t.sol` | E-1 (see its verification about `test_3`) |
| `E_StrandedBox.t.sol` | E-2 |
| `E_RouterStray.t.sol` | E-3 |
| `E_Sound.t.sol` | The router's address, routes and permissions on three chains |
| `F_CheckDeploymentGaps.t.sol` | F-1 |
| `F_DeployAllGuards.t.sol` | `DeployAll`'s guards and settings |
| `F_DeploymentConstants.t.sol` | Test build equals the deployed build; `Networks.sol` against Arbitrum One |
| `F_RealKlerosGasFloor.t.sol` | The 5M Kleros gas budget on the via-IR build against the real resolver |
| `G_SponsoredFeeDrain.t.sol` | G-1 (on-chain half) |
| `verify/V_<ID>_*.t.sol` | The verifiers' own checks for that finding (16 files) |

| Script (`scripts/pentest/`) | Proves | Run |
|---|---|---|
| `C_template_re2.ts` | C-1: the template under RE2 extracts 17 or more nameservers unchanged | `node scripts/pentest/C_template_re2.ts` (repository root) |
| `E_foreign_forward_keeper.ts` | E-1: the keeper completes only its own burns | `cd scripts/pentest && node E_foreign_forward_keeper.ts` |
| `F_verify-profiles.sh` | F-2: what `forge verify-contract` submits for each contract | `scripts/pentest/F_verify-profiles.sh` |
| `G_sponsor_drain.ts` | G-1: the service's sponsorship logic | `cd contracts && node ../scripts/pentest/G_sponsor_drain.ts` |

The Node scripts need Node 22 or later (they run TypeScript directly) and the service's and prover's `node_modules`.

## E. How the run went, and deviations from the procedure

- **Commit.** Frozen at `3ca0628`, then the current `main`. Each agent worked on its own clean export of it
  (`git archive`, plus the two library submodules), never on the live stacks or the shared checkout. A check after the
  run found every copy's sources identical to the export, apart from the added test, script and report files.
- **Models.** The user chose Fable 5.1 for the attack agents and Opus 5.5 for the verifiers, both at maximum effort.
- **Verification rule.** The user added it during the run (`9d7f12c`): a finding whose proof passes can't be dropped
  by argument, only re-rated with a reason. It was applied to every verdict.
- **Resources.** The seven attack agents ran at the same time, then the verifiers; every forge job took one shared
  lock under a 3 GB memory cap (2 cores, 4 GB server).
- **Reads of live systems.** Area C made one GET to each of the nine RDAP hosts to compare live layouts with the
  fixtures; agents read public chain state through public RPCs, Kleros's published deployment files and registrars'
  public documentation. No transaction was sent anywhere.
- **Area A's report file.** The tool refused the agent's write of its report file, so the session saved the agent's
  final message, which was the full report, word for word.
- **Not checked by a separate agent.** D-4 (one line in `docs/keys.md`) was checked by the orchestrating session.
- **Formatting.** `forge fmt` was applied to the proof files before they were committed; the full run above was made
  after formatting.
- **Not done yet.** Step 4 (fix, regression tests, a second run on the fixed commit) waits for the user. Step 5 (a
  model from another vendor) was not run.
