← yescrow security review

Published copy of reports/AI pentest, second run (2026-10-31).md from yescrow's private repository, unedited apart from local file paths. File and test references point into that repository; the contracts' source is verified on Arbiscan (see the contracts). Download as Markdown.

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

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

Part 1. Summary

Result

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

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

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

All findings

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

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

Limits


Part 2. Area reports, with each verification

Area A

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

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

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

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

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

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

Findings

A-1. settlementStage is a one-second timestamp: a period that opens and ends in the same second keeps the stage, so an offer signed in that second stays valid in the next period

A-2. Residual of B-3: an offer signed during the vote period is a free option until the period ends, because Kleros publishes each vote as it is cast while the stage doesn't move

A-3. Service: the PAYOUT_PENDING guard on the relayed withdraw (the A-2 mitigation) lapses one day after the deal closed

Checked and found sound

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

Unconfirmed

Verifications for area A

A-1_A-2:

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

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

A-3:

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

Area B

AI penetration test, second run — Area B: Signatures

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

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

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

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

Run everything in area B (from contracts/):

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

Findings

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

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

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

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

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

Proof: test_B2_1_LOW_settlementOfferWithinTheRevealPeriod_stillAFreeOption

forge test --match-path test/pentest/second/B_PhaseStage.t.sol \
  --match-test settlementOfferWithinTheRevealPeriod -vv

(The contract mechanism — the stage is constant within a period, so the offer survives a visible ruling — is proven. That votes are actually visible mid-reveal-period is a Kleros reveal-behaviour fact taken from the first report's court reading, modelled on the mock, not re-proven on a live case; see Unconfirmed.)

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

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


Checked and found sound

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

Unconfirmed

Verifications for area B

B2-1:

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

Area C

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

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

Proofs of concept (all passing):

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

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

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

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


Findings

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


Checked and found sound

Each line says what was examined and why it holds.

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

Unconfirmed

Method

Verifications for area C

C-1:

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

Area D

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

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

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

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

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

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

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

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

Info

Checked and found sound

Unconfirmed

Verifications for area D

D-1_D-2:

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

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

D-3_D-4:

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

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

Area E

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

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

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

Run everything from contracts/:

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

Findings

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

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

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

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

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

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

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

Proof:

forge test --match-path test/pentest/second/E_ForwardCost.t.sol -vv        # the costs, real USDT/USDC/USDT0/LayerZero on Ethereum
forge test --match-path test/pentest/second/E_StrayEdges.t.sol --match-test test_6 -vv   # the attacker's 10 USDT come back
(cd ../scripts/pentest/second && node E_forward_griefing_keeper.ts)        # the real Keeper: 25 boxes -> 25 forwards, no budget, again after a refill; a 1-unit burn completed

E_ForwardCost deploys the router through the real proxy (it lands at Networks.ROUTER), tops the attacker's box up with the real tokens and forwards as the keeper does (value = 1.2 × quoteSend), asserting the relayer is out exactly the quote and the box is empty again. The script's database stand-in throws on any SQL it doesn't know, so a budget read or write (relayer_spend) would fail the run; it passes.

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

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

Info findings (no proof needed; documentation and interface)

Checked and found sound

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

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

Unconfirmed

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

Files

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

Verifications for area E

E-1:

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

E-Info:

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

Area F

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

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

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

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

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


Findings

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


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


Checked and found sound

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

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

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

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

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

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

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

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

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

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

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


Unconfirmed


Files

Verifications for area F

F:

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

Area G

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

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

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

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

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


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

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

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

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

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

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

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


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

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

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

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

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

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

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


Checked and found sound

Unconfirmed (reasoning, no runnable proof)

Verifications for area G

G-1:

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

G-2:

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

Regressions of the first run's proofs

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

What ran

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

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

Finding → fix holds?

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

Failures: none.

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

Logs: scratchpad/pentest2/R-logs/.


Part 3. How the run went