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:
- High: R2-D-1. The first run's High (a loser flipping a Kleros ruling by funding an appeal alone) is back under a
budget attack: sham 1-unit cases make the keeper spend
APPEAL_DAILY_WEI, so a real case's appeal period opens unprotected. About $100 in the AI court (two batches of sham cases, because the budget looks back 24 h and the period lasts 36 h). The verifier's recommendation is a contract change, not more budget rules. - Medium: R2-D-2, R2-E-1, R2-G-2. Escalating past the budget pays the loser yescrow's stake (D-2); the keeper's forwards from Ethereum and its CCTP completions are outside every relayer budget, so a buyer cycling USDT through its own deposit address makes yescrow pay about 10× its own cost (E-1), and anyone can make the keeper pay for USDC completions on Ethereum (G-2).
- Low: R2-A-2/R2-B-1 (one finding found twice: a settlement offer signed during Kleros's vote period can be held while the votes are revealed; minutes in the AI court), R2-C-1 (the dual verifier's separate minimums of expiration and fetch time let one compromised attestor remove the 30-day expiry check; present since v0.23), R2-D-3 (Kleros's governor can stop disputes from being opened at all), R2-D-4 (re-pointing Kleros's resolver freezes appeal stakes), R2-F-1 and R2-F-U1 (the launch gate checks the verifiers' getters and a creation transaction it doesn't tie to the escrow, not the deployed code).
The Info list (Part 2) includes a compiler recommendation: move from solc 0.8.30 to 0.8.38 before the audit freezes the commit. A byte-level comparison shows the published via-IR bug fixed in 0.8.38 doesn't change yescrow's code, and the router stays at the same address.
All findings
"Attacker" is the severity the attack agent claimed; "Verifier" is the fresh agent's verdict; "Final" is the verifier's severity. The rule (the user's): a finding whose proof runs and shows the claimed impact can't be dropped by argument alone.
| ID | Finding | Attacker | Verifier | Final | Where the fix goes |
|---|---|---|---|---|---|
| R2-D-1 | Sham cases use up the daily appeal budget; a real case's appeal is then unprotected and the loser flips it | High | Confirmed; ~$100, not $50 (two batches); the operator's alert is part of the defence | High | Contract (pay on the jurors' ruling when an unanswered appeal overrides it) or service (reactive staking, price floor, per-court budget) |
| R2-D-2 | Escalating past the budget flips a twice-confirmed ruling and pays the loser yescrow's stake | Medium | Confirmed | Medium | Same as D-1; SPEC §14.21 wording |
| R2-E-1 | The keeper's forwards from deposit chains (and LayerZero fees) are outside every budget: ~10× amplification for USDT | Medium | Confirmed; real-receipt ratios 10.1×/13.0× (worse than reported) | Medium | Service (one budget design with G-2) |
| R2-G-2 | USDC cross-chain withdrawals and the keeper's receiveMessage completions draw on no budget |
Low | Upgraded: capital recycles, ceiling ~1 tx/block, keeper stalls | Medium | Service |
| R2-A-2 / R2-B-1 | A settlement offer signed in Kleros's vote period stays exercisable while votes are revealed | Low / Low | Confirmed; minutes in court 34, a race against passPeriod in court 1 (not 162 h) |
Low | Contract (fold revealed votes into the stage, or refuse settle in the vote period) or service |
| R2-C-1 | Dual verifier: min(expiration) and min(fetchedAt) taken separately, so one compromised attestor removes the expiry margin check |
Low | Confirmed, challenged as near Info; present since v0.23, not v0.31 | Low | Verifier (combine the margin) and prover |
| R2-D-3 | Kleros's governor can price disputes out of existence (no cap), so disputes can't open and nothing reaches the judge | Low | Confirmed; a reverting quote also blocks the answer | Low | Contract (cap per court, route to the judge) and docs |
| R2-D-4 | Re-pointing Kleros's resolver strands every appeal stake of open cases | Low | Confirmed; the keeper never recovers it by itself | Low | Docs and keeper |
| R2-F-1 | The launch gate trusts the verifier's getters, not its code: a lookalike passes | Low | Confirmed | Low | Script (code-hash comparison) |
| R2-F-U1 | The gate's decoded creation transaction isn't tied to the escrow: a backdoored escrow passes | Unconfirmed | Confirmed by the verifier's own test | Low | Script (rebuild at the escrow's address; parse the creation tx) |
| R2-G-1 | Appeal protection skipped without an operator alert when screening is down or a party listed | Low | Downgraded: the operator already gets the louder sanctions alert, the keeper retries | Info | Service (alert wording) |
| R2-A-1 | settlementStage is a one-second timestamp: a zero-second period keeps the stage |
Info | Confirmed; a commit-period variant too | Info | Contract (stage includes period and round) |
| R2-A-3 | The PAYOUT_PENDING guard lapses a day after the deal closed |
Info | Confirmed; the day is deliberate but undocumented | Info | SPEC/audit wording, or a keyed check |
| R2-E-I1–I4 | keys.md box/router rows, stale router addresses, no interface for returnStray on Arbitrum, a stranger's USDT forward shown as "coming" |
Info | Confirmed (E-I3 and E-I4 need code, not only docs) | Info | Docs, web, service |
| R2-F-I1–I5 | solc 0.8.30 bug list, EIP-170 headroom, attestor/fee recipient not pinned, the judge only checked for code, the gate's pins not checked before broadcasting | Info | Confirmed; I-5 wider (wrong pins burn the promised addresses) | Info | Compiler, Networks.sol, DeployAll |
| R2-D-I1, I2 | A counter-signed offer can lapse before it lands; staking at the period's open makes every appeal period run its full length | Info | (reported with D) | Info | UI text; reactive staking |
Limits
- Less independent than intended. The user's own fresh session for this run stalled, and at the user's request the run was started from the session that wrote the v0.31 fixes. Every agent still had a fresh context and only the procedure's prompt; the attack agents used Fable 5.1 (another model than the code's author), the verifiers Opus 5.5 under the user's rule. No model from another vendor was run.
- Interrupted. The weekly usage limit stopped one attack agent (F) and four verifiers mid-work on 2026-10-30; they were resumed from their saved state on 2026-10-31.
- Some proofs are partial. R2-G-2 had no attack PoC (the verifier wrote one); R2-D-1's correction (two batches) and D-2's General Court figures come from reading Kleros's code, not a run; R2-A-3 is code reading.
- No findings is not proof of no bugs (each area's "Checked and found sound" lists what was tried).
- Not fixed yet. The fixes follow the user's decisions.
Part 2. Area reports, with each verification
Area A
AI pentest, second run, area A: state machine and money
Commit under test: e2b52ce (SPEC v0.31). Working copy: an export of the commit; contracts/src/ unchanged. Scope: every SPEC §5 transition and deadline, balances and withdraw/withdrawTo, the fee, promo vouchers, the referrer share and fee payers, rounding, USDC/USDT0 quirks, donations, re-entrancy, the 63/64 gas rule; new in v0.31: judgeTimeout without a fee (D-2), the actionPhase and settlementStage views (B-4, B-3), the money paths around Judging.
Proofs, all passing (19 tests), in contracts/test/pentest/second/:
| File | What | Run from contracts/ |
|---|---|---|
A_Money.t.sol |
SPEC §8 unit by unit for every exit from Judging (refused and stalled), the D-2 fix, sponsored fees on judged cases, the rows around a Kleros case, edges (zero Kleros cost, one-unit price, a voucher before a stall), finality, payouts |
forge test --match-path test/pentest/second/A_Money.t.sol -vv |
A_PhaseStage.t.sol |
the B-4 and B-3 fixes attacked on mocks: stale signatures across phases, same-phase reuse, an offer at every move of the case, gas starvation of the stage read, stage collisions, two residuals | forge test --match-path test/pentest/second/A_PhaseStage.t.sol -vv |
A_RealKlerosStage.t.sol |
the B-3 fix on a fork of Arbitrum One (block 519,194,000): a real case (id 328) in Kleros's AI court through its real resolver, driven through evidence, commit, vote, appeal and a new appeal round on the real KlerosCoreNeo; A-1 and A-2 |
forge test --match-path test/pentest/second/A_RealKlerosStage.t.sol -vv |
A_Replay.t.sol |
signatures across the version's two deployments (which share deal IDs), replay, malleation; withdrawTo toward unrouted chains |
forge test --match-path test/pentest/second/A_Replay.t.sol -vv |
Every forge call ran under the shared lock and a 2,500 MB cap. No file outside contracts/test/pentest/second/ was changed; no transaction was sent to any live network.
Bottom line. No Critical, High or Medium finding. The v0.31 fixes in this area hold: judgeTimeout charges no fee on refused and stalled cases and yescrow never earns more by waiting; every action signature is refused in any later phase; a settlement offer lapses at every move of the case, on the mock core and on the real one. Every exit from Judging credits exactly SPEC §8's row and nothing else. What the fixes leave: the stage is a one-second timestamp (A-1, Info), an offer signed inside the vote period is still a free option until that period ends (A-2, Low), and a service note on the A-2 mitigation (A-3, Info).
Findings
A-1. settlementStage is a one-second timestamp: a period that opens and ends in the same second keeps the stage, so an offer signed in that second stays valid in the next period
- Severity: Info (needs a period shorter than a second and an offer signed inside it).
- Attacker and preconditions: the offeree. Kleros lets the commit and vote periods end as soon as every juror has acted, and starts a new round the moment both appeal sides are funded, so on Arbitrum (several blocks per second) a period can last zero seconds; the offeror would have to sign inside it.
- Steps (real
KlerosCoreNeo, case 328, court 34): the vote period opens at secondt2; the seller signs an offer of P/4 (staget2); the three jurors vote andpassPeriodis called in the same second;lastPeriodChangeist2again,settlementStageunchanged, and the buyer, who now sees the ruling, submits the vote-period offer in the appeal period:Settled. One second later the same move changes the stage and the offer is refused. - Impact: the stage binding has a hole one second wide per period. No loss in practice; recorded because the fix's promise ("an offer lapses when the stage changes") is not exactly what the escrow checks.
- PoC:
forge test --match-path test/pentest/second/A_RealKlerosStage.t.sol -vv(block "a move in the same second: the same stamp" intest_fork_B3_stageTracksTheRealCore_offersLapseEachPeriod). - Root cause:
contracts/src/YescrowEscrow.sol:814-820,settlementStagereturns the fifth word of the core'sdisputes(id)(lastPeriodChange) alone; KlerosCore stamps every period change withblock.timestamp. - Suggested fix: include the period in the stage, e.g.
(lastPeriodChange << 3) | period(always ≥ 8, no collision with 0, 1, 2); the service builds typed data from the escrow's view, so nothing else changes.
A-2. Residual of B-3: an offer signed during the vote period is a free option until the period ends, because Kleros publishes each vote as it is cast while the stage doesn't move
- Severity: Low.
- Attacker and preconditions: the offeree of a settlement signed after the vote period opened and before every vote is in. Window: the vote period, 30 minutes in court 34 (ending early once all three AI jurors voted), 162 hours in court 1 (one juror for yescrow's deals, so the first vote decides), from the real core's
getTimesPerPeriod.getVoteInfo/getRoundInfoshow every cast vote. - Steps (real core): the vote period opens at
t2; the seller signs an offer of P/4 for staget2; two of three jurors vote "seller delivered" (public:voted,choice,totalVoted= 2); the stage is stillt2; the buyer signs and submits the standing offer:Settled, the buyer keeps 750 of the 1,000 USDC the jurors were giving the seller. - Impact: as B-3, narrowed to "inside the vote period". SPEC §7.2 covers offers signed before voting starts, not offers signed once voting has started.
- PoC:
forge test --match-path test/pentest/second/A_RealKlerosStage.t.sol -vvandforge test --match-path test/pentest/second/A_PhaseStage.t.sol --match-test test_residual_B3_offerSignedInTheVotePeriod -vv. - Root cause: the stage changes only at period boundaries (
YescrowEscrow.sol:818);service/src/api/app.ts:805-819hands out settlement typed data in every stage with a caller-chosen deadline. - Suggested fix: in the service, refuse to build or relay settlement typed data while the case is in its vote period, or cap settlement deadlines to minutes there and say that votes are public from the start of the vote period. A contract option for a future version: fold the dispute kit's
totalVotedintosettlementStage, so an offer lapses with each vote.
A-3. Service: the PAYOUT_PENDING guard on the relayed withdraw (the A-2 mitigation) lapses one day after the deal closed
- Severity: Info.
- Attacker and preconditions: anyone calling
POST /v1/relaywithaction: "withdraw"for an account whose automatic payout has waited more than a day (keeper down, a payout the keeper couldn't send). - Steps:
service/src/api/app.ts:1146-1153refuses the relayed push only while the payout row iswaitingand the deal'supdated_atis within the last day; afterwards the relayer pushes the whole balance to the party's Arbitrum One address ahead of the payout it signed for another chain. - Impact: the documented mitigation holds for a day; on-chain the open
withdrawis accepted behaviour, so no loss. - PoC: none (reading).
- Root cause: the
d.updated_at > now() - interval '1 day'term atservice/src/api/app.ts:1149-1150. - Suggested fix: drop the one-day term, or key the condition on the payout row's own status and age.
Checked and found sound
- 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). - Sponsored fees on a judged case: yescrow gets only its own back (
test_spec8_refused_sponsoredAnswer_yescrowGetsOnlyItsOwnBack). - Rows around a Kleros case; ETH held = owed (
test_spec8_rowsAroundTheKlerosCase). - Edges: zero Kleros fee, one-unit price, a voucher before a stall (
test_edge_*). - Finality after each exit (
_assertFinal). - Payouts leave once and exactly (
test_sound_payoutsLeaveOnceAndExactly). - B-4: signatures refused in every later phase; same phase across states pays the same (
test_fix_B4_*). - 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). - The stage can't be starved to 2 (
test_fix_B3_gasStarvation_cannotForgeStage2); stage values can't collide (test_sound_stageValuesCannotCollide). - Real
KlerosCoreNeogetter layout: word 4 islastPeriodChange, word 2 the period;_readchecksreturndatasize. - Signatures across the two deployments refused;
WithdrawToonce; malleated copy refused (test_sound_signaturesBoundToTheirDeployment_andUsedOnce). withdrawTotoward unrouted chains revertsNoRoute(test_sound_withdrawTo_unroutedChain_revertsAndKeepsTheBalance).- USDT0 has no fee switch on Arbitrum One; funding measures deltas anyway.
- Re-entrancy: every money-moving function
nonReentrant; no token hooks; first-run ETH re-entry tests pass. - Deposits vs funding windows: no second where both or neither of
acceptandreturnDepositwork. - 63/64 rule unchanged;
applyRuling's 1M floor stands. - Deployment money settings as SPEC (fee 100, share 2500, cap 2,000, floors, courts 1 and 34, judge ≠ fee wallet, promo key pinned).
- 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
arbitrationCostis called plainly, not through_read(530, 976): a core upgrade that made it revert would blockdispute,challengeandpayArbitrationFee, so an opener would win atfeeDeadlineinstead of the deal going to the judge. Only Kleros's governor; the current implementation can't revert. Worth a_readwith a refusal fallback in a future version.- Frequency of same-second period moves on Arbitrum (A-1): not measurable here.
judgeTimeout/judgeRulingsetpromo = trueon deals with no code:getDealshowspromo: true, promoFeeBps: 0; cosmetic in the service's referrer page and fee lines.- A-3 was read, not run.
Verifications for area A
A-1_A-2:
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:
- B-1 / B-2 (relayer ETH drain on micro-withdrawals / zero-value deposit authorizations): the service now enforces a minimum amount, a sender-balance check, issued-nonce-once, and global/per-chain daily ETH budgets, with the keeper and API on separate queues. Verified by reading the exact lines (below); the on-chain legs are unchanged and the first run's fork proofs still pass.
- B-3 (settlement offer a free option once votes are visible):
settlenow binds to the Kleros case's period (settlementStage). The offer lapses at a period change. The fix holds across period boundaries. One residual remains inside a single period — reported as B2-1 (Low) below. - B-4 (stale
Confirm/Cancelcloses a dispute opened after signing): every action digest now includes the liveactionPhase. I confirm the contract-level binding is the real backstop — it refuses a far-future-deadline signature that bypasses the API's 1 h cap and is submitted directly (not only through the relayer). - B-5 (EIP-1271 permissive delegate): unchanged; still the party's own choice, ECDSA tried first. Not re-raised.
New proofs: contracts/test/pentest/second/B_PhaseStage.t.sol (5 tests, all pass). The first run's
contracts/test/pentest/B_Signatures.t.sol (13 tests) was re-run on this commit: all pass.
Run everything in area B (from contracts/):
flock <scratchpad>/heavy.lock \
systemd-run --user --scope -q -p MemoryMax=2500M ~/.foundry/bin/forge test \
--match-path 'test/pentest/second/B_*.t.sol' -vv
Findings
B2-1. A settlement offer is still a free option within a single Kleros reveal period (residual of B-3)
Severity: Low (a free option that shifts expected value to the offeree; the offerer consented to the amount, so
nothing is stolen and no party is forced — the same character B-3 was rated Low for, now narrowed to one period). A
reader may reasonably treat it as Info/accepted, since the team's own regression test
(test_B3_offerSignedAfterTheVote_worksInTheSamePeriod) shows same-period offers are intended to work.
Attacker and preconditions: the party that receives a settlement offer during a dispute (the offeree). The web
app (web/components/deal/steps.ts) has the offerer sign a Settle link valid for 24 h. The dispute's Kleros court
uses hidden votes and reveals them during the vote period (court 34 and court 1, per the first report's reading of
mainnet: reveal happens in the vote period, 30 min in the AI court, 162 h in the General Court). settlementStage is
the case's lastPeriodChange, which is constant across a whole period, so an offer signed at the start of the reveal
period stays valid until passPeriod moves the case on.
Steps:
1. The dispute reaches the vote/reveal period. The seller, hoping to settle instead of waiting for Kleros, signs a
50/50 offer (stage = the reveal period's start) and shares the link.
2. Jurors reveal during the same period: currentRuling becomes public (say, seller delivered), but
lastPeriodChange — and so the settlement stage — does not move.
3. The buyer, seeing it will get 0 under the visible ruling, counter-signs the still-live 50/50 offer and calls
settle before anyone calls passPeriod/applyRuling. Had the vote gone the buyer's way, it lets the ruling
apply instead.
Impact (mock, price 5,000 USDC): the buyer salvages PRICE/2 (2,500 USDC) after losing the vote, instead of the
0 the ruling would give it — 2,500 USDC shifts by the exercised option. For the General Court the exercise window is
the whole reveal period (≈162 h on mainnet), because the single juror's reveal is final and lastPeriodChange does
not move until passPeriod.
Proof: test_B2_1_LOW_settlementOfferWithinTheRevealPeriod_stillAFreeOption
forge test --match-path test/pentest/second/B_PhaseStage.t.sol \
--match-test settlementOfferWithinTheRevealPeriod -vv
(The contract mechanism — the stage is constant within a period, so the offer survives a visible ruling — is proven. That votes are actually visible mid-reveal-period is a Kleros reveal-behaviour fact taken from the first report's court reading, modelled on the mock, not re-proven on a live case; see Unconfirmed.)
Root cause: contracts/src/YescrowEscrow.sol settlementStage (lines 814–820) returns lastPeriodChange, which
only changes on passPeriod; settle (lines 575–593) reads it once and has no view of how far the reveal has
progressed within the period. B-3's fix binds the period boundary, not the reveal visibility inside it.
Suggested fix: this is a design trade-off the team already weighed (same-period offers are intended to be usable,
for informed appeal-period settlements). If the residual is to be closed, the sharpest option is off-chain: the web
app should set a short offer validity (minutes) for offers signed before the appeal period, and its signing summary
should say plainly that an offer can't be withdrawn and the other side may wait for the jurors to reveal before
accepting. A contract change (e.g. refusing settle during the vote period, so settlements are only possible before
the reveal starts or in the appeal period once both sides see the result) would close it fully but narrows the
settlement window; not recommended for this immutable release.
Checked and found sound
Each item names the evidence. Tests without a path are in contracts/test/pentest/second/B_PhaseStage.t.sol; the
first run's suite is contracts/test/pentest/B_Signatures.t.sol (re-run on e2b52ce, 13/13 pass).
- B-4 fix — the phase binding is the contract-level backstop, not just the API cap. A
Confirmthe buyer signs inProvenwith a 365-day deadline (beyondMAX_ACTION_DEADLINE, which the API refuses) is rejected once the buyer challenges and the case opens: the digest named phase 0, butactionPhaseis 1 then 2, so_authorizerevertsInvalidSignature. The mirror holds for a seller'sCancel. So even a client that builds its own typed data and submits directly — bypassing the API deadline cap and the relayer's state re-check — cannot land a stale concession in a dispute (test_sound_phaseBindingIsTheBackstop_longDeadlineConfirmRefusedInDispute,..._longDeadlineCancelRefusedInDispute). The off-chain caps (app.ts822, 1014–1015;relayer.callcheckat 1090) are defence in depth. - B-4 fix — the one phase that groups two states is benign. Phase 0 covers both
ActiveandProven(the only grouping). AConfirmsigned inActivestill releases to the seller after a proof lands inProven, and aCancelrefunds the buyer either way: the money goes to the same party, and no arbitration fee is in play in phase 0, so the grouping can't surprise a signer (test_sound_phase0GroupsActiveAndProven_sameOutcome). The remaining action types are each valid in a single state (accept→Proposed,dispute→Active,challenge→Proven,payArbitrationFee→AwaitingFee), so no cross-phase reuse is possible — confirmed also by the regressiontest_B4_staleConfirmRefusedOnceADisputeOpensandD_KlerosReads/Hardeningtest_actionPhase_everyState,test_concessions_boundToTheDisputePhase,test_otherActions_boundToTheDisputePhase. - No pre-signing a future phase or stage.
actionPhase/settlementStageare computed live inside_authorizeandsettle, so a signer cannot sign for a phase/stage the deal will reach later. A settlement offer for period T0 is dead once the case moves on, and a party cannot revive it by itself signing for a guessed next stage — both signatures must cover the one current stage (test_sound_cannotPreSignAFutureStage; regressiontest_B3_settlementOfferLapsesWhenTheCaseChangesPeriod,test_B_settleSignedInDisputed_lapsesAtTheStall_resignedSameMoney). settlementStagespecial values can't collide with a real period. 0 (AwaitingFee) and 1 (Judging) are returned before anydisputeIdis read; inDisputedthe value islastPeriodChange, a block timestamp (~1.8e9), which can never equal 0 or 1 on-chain.2is the "unreadable getters" fallback. Regressiontest_settlementStage_everyStage.- Typehash / encoding consistency with the new fields. The contract typehashes for
Accept/Confirm/Cancel/Challenge/Dispute/PayArbitrationFeenow carryuint256 phasebetweenpriceanddeadline, andSettlecarriesuint256 stagebetweensellerAmountanddeadline(YescrowEscrow.sol 171–191). Theabi.encodeorder in_authorize(926–939) and_settleDigest(943–963) matches the strings, and the service builds the identical typed data:typed-data.tsactionTypedDatainsertsphaseatACTION_FIELDS.slice(0,5) + phase + deadline, andsettleTypedDatainsertsstageat…sellerAmount + stage + deadline.Base.t.sol's mirror constants match byte-for-byte (the 13-test regression signs with them and passes). A mismatch couldn't reach the chain. - No forced-miss via phase invalidation. For every time-critical signed action, no third party can change the
phase before the deadline:
payArbitrationFee(AwaitingFee) leaves only via the answerer paying, a both-sides settle, orfeeTimeoutafter the deadline;challenge(Proven) leaves only via the buyer orreleaseafter the window;accept(Proposed) leaves only via the proposer orexpireafteracceptBy. So a victim's phase-correct signature stays valid until its own deadline. (Reasoned from the state machine; the transitions are in_openDealand the state guards.) - Fee accounting across phase 2 (Disputed/Judging) is consistent. A concession signed in phase 2 can only be used
on the deal's actual path: a stalled deal (
Disputed→Judging,bothFeesHeld=false) credits the held fee to the winner's side; a refused deal (AwaitingFee→Judging,bothFeesHeld=true, which is phase 1→2, so no phase-2 signature fromDisputedexists for it) credits both fees back._close(1052–1092) branches onbothFeesHeldbefore the Disputed/Judging branch, so each path credits exactly once. Regressiontest_B_settleSignedInDisputed_lapsesAtTheStall_resignedSameMoneyasserts identical splits. - B-1 fix (service, read).
app.tscase 'withdrawTo'(1204–1227): a cross-chainwithdrawToneedsamount ≥ cfg.relayBridgeMin(same-chain:≥ relayWithdrawMinor the whole balance), and the LayerZero fee is sent throughsendWithinwith the global daily budgetcfg.lzFeeDailyWei(budgets.tsreserveSpend/sendWithin, reserved before sending). The contract still allows dust (a party paying its own fee may bridge a small amount) — correct; only the relayer's blind spend is now bounded. - B-2 fix (service, read).
POST /v1/deposit-authorizations(843–888) issues a server nonce and requiresvalue ≥ max(missing, FORWARD_MIN[chain]); thedepositTransferrelay case (1165–1200) accepts only a nonce the service issued, exactly once (UPDATE … used_at … WHERE used_at IS NULL, atomic), checksbalanceOf(from) ≥ value, and sends the gas throughsendWithinwithdeposit-gas:<chainId>daily budget. The keeper and API use separate queues on the shared relayer (relayer.tsforKeeper/Queue,maxQueuedon the API lane only), so a flood can neither stall the keeper nor grow unbounded. A griefer must now hold and actually transfer ≥ the forward minimum of real USDC into the victim's own deposit box — no longer free. - EIP-3009, malleability, cross-escrow/chain, EIP-1271/7702, ERC-6492, WithdrawTo, FeeVoucher, ReferrerShare —
unchanged in v0.31; the first run's checks still hold (re-ran
B_Signatures.t.sol:sameDealId_signaturesDoNotCross Escrows,signaturesDoNotCrossChains,malleableSignatureRejected,compactSignatureRejected,precompileCannotBeMadeToApproveAWithdrawal,frontRunningAFundingSignature_createsTheSameDeal,counterpartySubmitsSignedChallenge_noLossToSigner,openerSubmitsSignedAnswer_noLossToSigner— all pass). The fork proofs (B_Eip3009Fork.t.sol) were not re-run here (public-RPC/compute budget), but their targets (YescrowEscrowfunding/withdraw, the real token EIP-3009 behaviour) are byte-for-byte unchanged from the first run. - ERC-6492 deploy path (
erc6492.ts) and automatic payout (payouts.ts,keeper.sendPayouts) — unchanged; the payout still fixes recipient = signer, amount, chain and a one-use digest, submitted only after a close in the party's favour, with sanctions screening (keeper.ts205–209). No new surface in v0.31.
Unconfirmed
- The information-leak half of B2-1. The contract mechanism (stage constant within a period, so an offer survives a ruling becoming visible) is proven on the mock. That Kleros actually reveals votes during the vote period (so the offeree can learn the direction before the period ends) is taken from the first report's reading of mainnet courts 34 and 1 (hidden votes, reveal in the vote period), not re-proven here: a real commit-reveal round on an Arbitrum One fork is heavy and the harness discourages weakening a proof to fit the budget. If votes are not observable until the appeal period on these courts, B2-1 collapses into the intended "informed settlement" and should be dropped.
- The B-1 / B-2 fixes are verified by reading the service, not by a running local stack. The local stack needs the
Docker Postgres the harness says this run must not touch (same constraint the first run reported for area B). I
traced the exact lines in
app.ts,budgets.ts,relayer.tsandschemas.ts; I did not execute them. A logic error in the SQL reservation (e.g. a race inreserveSpendunder true concurrency) would not show in a read. - Rate-limit keying (unchanged from the first run). Both former Medium vectors are bounded per IP only if
trustProxy(app.ts~105) matches the real proxy chain; the new daily budgets now cap the total relayer ETH spend regardless, which is the stronger bound, but a misconfiguredtrustProxystill lets 20 relays/min share one bucket and crowd honest gasless steps. Not checked against a live stack. - Settlement links carry the signature in the URL (browser history, referrers). A third party still can't use one (the other party's signature is needed too), so no loss; hygiene note, unchanged.
Verifications for area B
B2-1:
Verdict: CONFIRMED (Low), with a correction: the claimed 162 h General Court window is wrong; the real exposure is minutes, in the AI court.
Proof of concept: `forge test --match-path test/pentest/second/B_PhaseStage.t.sol` (B copy, contracts/): 5/5 pass, incl. test_B2_1_LOW_settlementOfferWithinTheRevealPeriod_stillAFreeOption (buyer gets 2,500 of 5,000 after losing the vote) — on the mock arbitrator. Re-run on the real KlerosCoreNeo + DisputeKitClassicNeo (Arbitrum One fork, block 519,194,000), contracts/test/pentest/verify/V2_B2-1_RealKlerosReveal.t.sol, 3/3 pass: (1) AI court (34, 3 jurors), 1,000 USDC: the seller signs 50/50 at the start of the vote period; two jurors reveal "seller delivered"; currentRuling becomes 1 while lastPeriodChange stays; passPeriod refused; ten minutes later the buyer settles: buyer 500, seller 495 (ruling would give buyer 0, seller 990). (2) AI court, all 3 revealed: passPeriod succeeds in the same block, 25 min early; the offer then fails InvalidSignature. (3) General Court (1 juror): periods 78/162/162/108 h; the single reveal makes passPeriod callable in the same block; the offer works only if settle lands before someone passes the period.
Sources unchanged: yes (diff only contracts/test/pentest/second/ and REPORT-B.md).
What I checked: settle/settlementStage (YescrowEscrow.sol 575–593, 814–820), the mock, keeper.ts 126–140 (passPeriod every tick, 30 s); steps.ts/Dispute.tsx (24 h validity, "sooner once every juror has voted", no warning that the other side can watch votes); SPEC §7.1/§7.2, audit §3/§5, dependencies §1.5/§1.8, run 1's B-3.
Reason: not accepted on purpose (SPEC §7.2 and the natspec assume votes visible "once the vote period starts"; with hidden votes the vote period is when jurors reveal one at a time); not a known issue; the team's test signs in the appeal period, not the vote period; Low is right (offerer agreed, nothing forced, narrow exposure).
Corrections: General Court: passPeriod is callable as soon as every vote is in (keeper within 30 s, Kleros's bots, or the offerer), so the offeree needs a bot to beat it. AI court: window from the second reveal to the third or the 30-min end (mainnet case #309 spent 11 min in its vote period), at most ~1,000 USDC at 50/50. A General Court round after an appeal (≥3 jurors) reopens a multi-reveal window; a keeper outage widens windows.
Fix keeping informed settlements: precise: while in the vote period, fold the number of revealed votes (dispute kit getRoundInfo totalVoted, read _read-style, falling back to 2) into settlementStage so every reveal lapses earlier offers; simpler: refuse settle during Kleros's vote period; off-chain only: hide/warn on "Sign an offer" during the vote period.
Area C
AI pentest, second run, area C: proofs (RdapVerifier, DualRdapVerifier, Domains, RdapStatus, the template, the attestor patch)
Commit under test: e2b52ce (main, 2026-10-29, SPEC v0.31). Working copy:
<scratchpad>/pentest2/C.
Model: Claude (Fable 5.1), 2026-10-29.
Proofs of concept (all passing):
cd contracts
forge test --match-path 'test/pentest/second/C_*.t.sol' -vv # 12 tests, all pass (C-1, its controls, the fix sketch)
cd .. # the copy's root
node scripts/pentest/second/C_engines.ts # the template under RE2 "sgiu" (Reclaim's attestor) and RegExp "sgi" (yescrow's)
node scripts/pentest/second/C_engines.ts --live # the same over one live HTTP/1.1 response per registry host
Files added: contracts/test/pentest/second/C_DualCrossField.t.sol, scripts/pentest/second/C_engines.ts. Nothing under
contracts/src/, contracts/script/, prover/src/ or attestor/ was changed; the first run's proofs in
contracts/test/pentest/C_InterProofBlocking.t.sol (8 tests) were run unchanged as regression tests and pass.
Summary: the v0.31 fixes in this area hold. A 17th nameserver no longer blocks a proof, and the two attestations need
agree only on the domain and registrar. No way was found to pass a proof for a domain, registrar or status the registry
didn't state, to pass with one attestor alone, to reuse a stale attestation, or to make the signed parameters name
anything but the domain's own registry URL under yescrow's exact template. One finding, Low: the new strictest-first
combination is strictest for every check that reads one fact but not for the one check that reads two
(expiration ≥ fetchedAt + 30 days), so one attestor, or a stale registry cache, can remove that rejection, which
SPEC §14.18 says cannot happen.
Findings
C-1. DualRdapVerifier combines expiration and fetchedAt separately, so one attestor can remove the 30-day expiration check (SPEC §14.18 says one attestor "can only add rejections")
- Severity: Low. The domain really is at the stated registrar (that fact still needs both attestors), but a proof
passes for a record the registry stated as failing SPEC §6.3 check 4, and the specification's and
docs/keys.md's statement about a single attestor is false. Nobody is paid who isn't owed under the other checks; the buyer receives, or pays for, a domain that expires inside the margin the specification promises, up to one whose record says it has already expired. - Attacker and preconditions: one compromised attestor key, either Reclaim's or yescrow's (
docs/keys.md§1 lists both as "none alone"), used by the seller (an "after" proof) or by whoever submits the acceptance (a "before" proof). A weaker, keyless form: a registry cache (Google's serves data up to about an hour old,docs/security.md) gives the pair the larger of its two margins instead of the smaller (test_C1_twoHonestSessions_staleCache_pairMarginIsTheLargerOne). - Steps ("after" proof): the deal is Active (
activatedAt). Late in the transfer window the domain arrives at the buyer's registrar with, say, a day of registration left: the honest attestation statesregistrarId = buyer's,fetchedAt = T,expiration = T + 1 day, which the escrow rejects (ProofRejected(4)). The compromised attestor signs the same domain and registrar withfetchedAt = activatedAt + 1and a far expiration.DualRdapVerifier.verifyreturnsmin(expiration)= the honest one andmin(fetchedAt)= the forged one (DualRdapVerifier.sol:40-41), so_checkCommon(YescrowEscrow.sol:868) seesexpiration ≥ fetchedAt + 30 daysfor a pair neither attestation stated. The deal is Proven; after the challenge windowreleasepays the seller. The "before" proof has the same hole withproposedAtas the floor (test_C1_beforeProof_sameWeakening). - How far it goes:
fetchedAtcan be pulled back tonotBefore + 1, i.e. by up to the transfer window (60 days, SPEC §9) for an "after" proof and up toacceptBy(30 days) for a "before" proof. The honest record then only needsexpiration ≥ notBefore + 30 days + 1; at the end of a 60-day window that expiration lies 30 days before the honest fetch (test_C1_edge_honestRecordAlreadyExpiredForThirtyDays_passes: the registry's record says the domain expired 2,588,338 s, about 29.96 days, before it was fetched, and the pair passes). - Impact (from the tests):
test_C1_oneCompromisedAttestor_backdatedFetchedAt_passesCheck4_sellerPaid: the buyer's 5,000 USDC are released, the seller is credited 4,950 USDC, for a domain the registry says expires in one day; the same record from two honest attestors is rejected (test_C1_honestRecordExpiringInADay_rejectedFromBothAttestors). Either key works (test_C1_otherAttestorCompromised_sameResult). On a genuine Reclaim attestation of example.com (fixture of 2026-10-29, registrar 376) paired with a forged yescrow-slot half, the dual verifier returns the real expiration with afetchedAt200 days earlier (test_C1_realReclaimAttestation_pairedWithForgedHalf_factsNeverStated). - Proof of concept:
forge test --match-path 'test/pentest/second/C_DualCrossField.t.sol' -vvfrom the copy'scontracts/directory (12 tests). - Root cause:
contracts/src/DualRdapVerifier.sol:40-41(if (g.expiration < f.expiration) f.expiration = g.expiration;if (g.fetchedAt < f.fetchedAt) f.fetchedAt = g.fetchedAt;), mirrored inprover/src/facts.ts:63-64(combineFacts). Each minimum is the strictest choice for the checks that read that fact alone, butYescrowEscrow._checkCommon(line 868) compares the two facts with each other, andmin(a) ≥ min(b) + 30 daysdoes not implya_i ≥ b_i + 30 daysfor either attestation. The first run's verifier proposed this combination (report, C-2 corrections); the cross-field check was missed then and in the fix. - Suggested fix: combine the margin, not the dates:
fetchedAt = min(fA, fB)as now, andexpiration = fetchedAt + min(expA − fA, expB − fB)(0 where an expiration precedes its own fetch). Then check 4 on the pair implies check 4 on each attestation, the result is at most either expiration, andfetchedAtkeeps its properties. The sketchMarginDualin the test file does this: it rejects the attack (test_C1_fixSketch_marginCombination_rejectsTheAttack) and gives every real dual fixture the same facts except anexpirationlower by exactly the seconds between the two halves' fetches, 0–1 s on 24 fixtures and 12–14 s at Nominet (test_C1_fixSketch_everyRealDualFixture_sameFactsWithinTheSessionGap). Mirror it incombineFacts, and reword SPEC §14.18, the contract's NatSpec anddocs/keys.md("none alone"). A verifier is a new deployment anyway.
Checked and found sound
Each line says what was examined and why it holds.
- The v0.31 fix for the first run's C-1 (17 nameservers).
_nameserversHashreturnsbytes32(0)at the 17th"ldhName"(RdapVerifier.sol:321) instead of reverting, and_factsalready skips a capture over 8,192 escaped bytes (line 148), so an InterRegistrar proof passes and an IntraRegistrar proof fails (_termsOkneeds a non-zero hash for Intra, so zero can't match). The first run's regression tests (8), the repository'stest_synthetic_seventeenNameservers_noHashand the engine harness (case 10: both attestors sign a 17-name list and the prover'sfactsFromContextgives 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. - The v0.31 fix for the first run's C-2 (facts combined strictest-first). Only
domainHashandregistrarIdmust be equal; every single-fact check still needs the honest attestation: aclient holdseen by one attestor survives the OR (test_C1_control_blockedStatusStillNeedsBoth), a different registrar isFactsDiffer(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 andlastChangedis the earlier one (repositorytest_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'stest_C2_…regression tests pass), and the combination is order-independent. combineFactsmirrors the verifier.prover/src/facts.ts:55-67applies the same four rules (equality on domain and registrar, zero-unless-equal hash, minimums, OR);factsFromContextapplies the same byte and count caps (lines 96, 73).node --test prover/test/*.test.ts: 13 pass.- Signature binding and parameters rebuilt from the domain.
identifier = keccak256("http\n" ‖ parametersFor(domain) ‖ "\n" ‖ context), signed asidentifier\nowner\ntimestampS\nepochunder personal-sign, equals attestor-core 5.1.5'sgetIdentifierFromClaimInfo/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), soPARAMS_PREFIX‖ base ‖domain/‖ domain ‖"}(or the zk-write suffix for.now/.botonly) is byte-for-byte the canonicalclaimParams(domain).Domains.tldOfvalidates the domain before any string is built. - The request side at the attestor (
assertValidProviderReceipt,lib/index.js:7391-7440):https:only;assertNoSmuggleanchors^GET <path> HTTP/1.1\r\nHost: <host>\r\nConnection: close\r\non the client transcript; the path is compared withmatchRedactedStrings(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, whichFULLY_REVEALEDrefuses. - 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 withHTTP/1.1 200andFULLY_REVEALEDfails; 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)..cois 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. - 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 andmakeRegexfalls back tonew RegExp(str, "sgi")withoutu(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 byRegExp(both are needed); U+00A0 after"ldhName"is skipped byRegExp's Unicode\sand not by RE2's ASCII\s, so RE2 captures the nextldhName(a nameserver's) and the pair failsDomainMismatch. AVary: *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 nestedlinksare all refused by both engines. - 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,.bzare absent, as SPEC §6.1 says, and each of their hosts answered a live lookup. The_rdapBaseindex ranges (3, 4, 11–15, 19–23 Identity Digital; 24.fm; 25.inc) matchDomains.tldOf, and the prover'sRDAP_BASESis the same table (test_rdapBases). - 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);nameserversis followed bysecureDNSorrdapConformance; the domain'sstatusbyentities,events,port43ornotices, except at Nominet (handle, sostatusFlagsis 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 (registrationonly); no body contains*, a control byte or a non-ASCII byte. Status values are RFC 8056 spellings at every host. - Context parsing.
_factsaccepts only{"extractedParameters":{…},"providerHash":"0x<64 hex>"}with the seven known keys in canonical order, none repeated, nothing trailing;_entryskips escape pairs as JSON does;_unescapereverts on\u,\b,\f(a control byte in a capture fails closed); the attestor builds and canonicalizes the context itself (assertValidProviderReceiptreturnsextractedParameters; a non-participating optional group is skipped, lines 7503-7510, matching the optional keys). The repository'stest_synthetic_parserRejectionspasses. - Timestamps and freshness.
_eventDateacceptsYYYY-MM-DDTHH:MM:SS[.frac]Zonly;fetchedAtis the registry's ownlast 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'snotBefore < fetchedAt ≤ nowon 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). - 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
DomainMismatchthenProofRejected(1). Two deals on one domain at the same registrar remain the documented account gap (SPEC §6.5). - Unicode and IDNs. Terms carry A-labels (
xn--passestldOf);_loweris ASCII andldhNameis LDH; a U-label or trailing dot inldhNamefailsDomainMismatch; non-ASCII appears only inunicodeName, and none of today's responses contains any. - Deployment of the verifiers.
DeployAlldeploys Reclaim's verifier withNetworks.RECLAIM_ATTESTOR(0x2448…9072, the key the 26 real fixtures verify against), refuses aYESCROW_ATTESTORthat is zero or equal to it (DeployAll.s.sol:118), and the dual verifier's constructor refuses two verifiers with one attestor.CheckDeploymentreads backdual.reclaim().attestor() == RECLAIM_ATTESTOR,dual.yescrow().attestor() == YESCROW_ATTESTOR, a TLD-table spot check,isVerifieron each escrow and, where the creation transaction is known, an allow-list of exactly that verifier (CheckDeployment.s.sol:149-154, 194, 244). - Gas. Nothing but the nameserver list is holder-inflatable (event objects are brace-bounded, the status array is registry-set, the registrar object is one publicIds entry), and that list is capped twice. The largest real response (Nominet) verifies within the repository's measured 0.72M–3.72M per attestation.
Unconfirmed
- U1. Nominet host-object events (first run U2). Embedded host objects carry
registrationonly today, as in the first run; a standalone host object (/now/nameserver/ns1.markmonitor.com, live) also carrieslast update of RDAP database. If Nominet ever embedded that event, or alast changedfor a modified host, in the domain response, the "twice" regexes would refuse every.now/.botproof of a domain with such a host (harness case 11), and a buyer with an in-bailiwick host could block the seller's proof (the seller disputes, SPEC §6.5). Not observed. - U2. Pipelined second response (first run U7). With a server that ignored
Connection: close, the captures come from whichever response first carries each field (harness case 8): a field-less first response followed by the real record yields the real record; two records are refused. The hardened attestor can't hide the first response, and every registry'sAccess-Control-Allow-Origin: *header in a second response failsFULLY_REVEALEDanyway (case 8c). No registry was tested for pipelining. - U3. Status spelling. The blocked-status regex and
_statusBitmatch RFC 8056 spellings exactly; an EPP-stylependingTransferwould be signed by both engines and mapped to no bit (harness case 5). All 8 live hosts and 26 fixtures use RFC 8056 spellings, and a non-compliant registry would be lying to the whole RDAP ecosystem, so this is noted, not claimed. - U4.
auto renew periodisn't blocked. A domain past its expiration in its registrar's auto-renew grace shows a bumped expiration at Verisign and theauto renew periodstatus, which SPEC §6.3 does not block; an IntraRegistrar push of such a domain passes the proof although the registration hasn't been paid for. This is within SPEC §13.4 (IntraRegistrar rests on the buyer's account check) and a transfer in that state is normal for InterRegistrar deals, so it is a design note, not a finding. - U5. Certificate verification by the attestor. The server-side validation isn't in the npm bundle (yescrow's image
builds it from source).
@reclaimprotocol/tlsexposesverifyCertificateChain(chain, host, …)and the first run reports the attestor verifying the chain against the params host inprocessHandshake; I could not re-read that code here. If it didn't, an on-path attacker between an attestor and a registry could serve a forged record, but both attestors would have to be attacked. - U6. Arbitrum One gas accounting for the worst-case proof. The 16-name worst case (16.5M L2 gas, about 20 KB of calldata) is measured locally; the repository's mainnet fork test submits a real dual proof but not that case, so the L1 calldata component in gas units at mainnet prices is unmeasured. Real responses are far smaller.
Method
- Read SPEC §5, §6, §12, §13, §14.18;
docs/audit.md,docs/keys.md,docs/security.md; the four proof contracts, the escrow'saccept,submitProof,_termsOkand_checkCommon;prover/src/{template,facts,prove,server}.ts; the attestor patch; and, for what the attestors actually sign, attestor-core 5.1.5'sassertValidProviderReceipt,assertNoSmuggle,matchRedactedStrings,makeRegex,createSignDataForClaimandgetIdentifierFromClaimInfoin the prover'snode_modules(the server-side files aren't shipped there). - Ran the repository's verifier suites (46 tests), the prover's tests (13) and the first run's C proofs (8) on this commit.
- Live, read-only: IANA's bootstrap; one HTTP/1.1 GET per registry host (8) plus
google.nowand a Nominet host object, analysed for layout anchors, embedded events, framing and character set. - Wrote the Foundry proof for C-1 with the test-only attestor keys in the attestor's exact context layout, as the repository's own tests do, plus one test on a genuine Reclaim attestation; and the engine harness.
- No transaction was sent to any network; no file under
contracts/src/was changed.
Verifications for area C
C-1:
Verdict: CONFIRMED, Low (kept; challenged as close to Info). Corrections: the hole is not new in v0.31 (present since v0.23), and the fix sketch overclaims one property.
Proof of concept: `forge test --match-path 'test/pentest/second/C_DualCrossField.t.sol' -vv` (C copy): 12/12 pass. The honest record (expiry 1 day after its fetch) is rejected ProofRejected(4); paired with a forged half whose fetchedAt is activatedAt+1, the deal is Proven, released, seller credited 4,950 USDC; edge case: the honest record expired 2,588,338 s before its own fetch and the pair still passes; the margin sketch gives the same facts on all real dual fixtures (expiration lower by 0–15 s). Own tests: `forge test --match-path 'test/pentest/verify/V2_C-1_marginAndHistory.t.sol' --match-test 'test_V2|testFuzz_V2' -vv`: 6/6 (fuzz 256 runs).
Sources unchanged: yes (28 files under contracts/src, contracts/script, prover/src, attestor byte-identical to e2b52ce).
What I checked: test keys stand in for one compromised attestor, exactly the claim; the verifier checks only the signature (timestampS unused), so a leaked key can sign any fetchedAt; one test uses a genuine Reclaim fixture; no vm.store/etch. In scope: SPEC §14.18 "one attestor can only add rejections", keys.md "None alone", audit §4. Not in audit §5. v0.31 is not the cause: the v0.23–v0.30 combination (all facts but fetchedAt equal, min(fetchedAt)) has the same hole — a forged half copying the honest record and backdating only fetchedAt passes there too. Real root cause: fetchedAt feeds check 3's lower bound (min strictest) and check 4 (later fetch strictest); no single combination of that one fact serves both. The keyless "stale cache" form is not a weakening. Effect bound: expiration must still be ≥ notBefore + 30 days + 1; what breaks is "≥30 days from when the domain reached the buyer"; a forged half can't clear a blocked status.
Reason: not a false release (every delivery fact still signed by both); small gain (gTLD transfers add a year; ~one renewal fee vs compromising an attestor key); a "before"-proof abuse ends in delay, not loss. Not Info because the proof falsifies a written spec property and the fix is cheap before mainnet.
Corrections: root cause "since v0.23". The sketch's "at most either expiration" is false when a half is already expired (margin clamps to 0) — harmless since check 4 rejects such a pair. Fix (replace DualRdapVerifier.sol:40-41):
if (g.lastChanged < f.lastChanged) f.lastChanged = g.lastChanged;
// check 4 reads two facts: the smaller margin on the earlier fetch, so the pair passes only if each half does
uint64 mf = f.expiration > f.fetchedAt ? f.expiration - f.fetchedAt : 0;
uint64 mg = g.expiration > g.fetchedAt ? g.expiration - g.fetchedAt : 0;
if (g.fetchedAt < f.fetchedAt) f.fetchedAt = g.fetchedAt;
f.expiration = f.fetchedAt + (mg < mf ? mg : mf);
Mirror in prover/src/facts.ts:63-64 (fetchedAt = min; expiration = fetchedAt + min(margin(a), margin(b))); reword SPEC §14.18 and the NatSpec. Needs a new verifier and new escrows.
Area D
AI pentest, second run, area D: disputes (commit e2b52ce, SPEC v0.31)
Proofs in contracts/test/pentest/second/ (D_AppealBudget.t.sol, D_GovernorFee.t.sol, D_FixesHold.t.sol) and scripts/pentest/second/D_appeal_budget.ts. Run from contracts/: forge test --match-path 'test/pentest/second/D_*.t.sol' -vv (9 tests pass: 5 on the Arbitrum One fork at block 519,194,000, 4 unit); the script from the copy's root. Nothing outside those directories changed; no transaction went to a live network.
Setup. Fork of Arbitrum One at block 519,194,000: real KlerosCoreNeo 0.10.0, DisputeKitClassicNeo 0.12.0, the public DisputeResolver 0xb552…4323, real USDC; the escrow deployed through DeployAll's own config (courts 34 and 1, mainnet template). The keeper's decide (service/src/appeals.ts:106-117) is ported line by line into the fork tests with SPEC §14.21's suggested budget APPEAL_DAILY_WEI = 0.1 ETH; the node script runs the service's decide unmodified. Only stubs: Kleros's RNG (to draw real stakers) and the drawn jurors' votes. On-chain constants: court 34 fee 0.00027 ETH/juror (case fee A = 0.00081 ETH; first appeal 7 jurors → appealCost 0.00189, jurors' side 2× = 0.00378, other side 3× = 0.00567); second appeal from court 34 goes to court 33 at 0.018/juror × 15 = 0.27 (sides 0.54 / 0.81); court 1 fee 0.005/juror.
The v0.31 fixes hold (D_FixesHold.t.sol, plus Judge 19/19, Hardening 14/14, SponsoredFees 23/23, Transitions 88/88): judgeTimeout charges no fee on refused and stalled cases; a settlement lapses when the case changes period; actions name their phase, and the one state pair sharing phase 2 (Disputed vs Judging-after-stall) pays identically; the keeper stakes the jurors' side, including choice 0 on a tie at the winner's 2× (verified on the real kit).
D-1 (High): cheap sham cases reserve the whole daily appeal budget; a real case then opens unprotected and the loser flips it
- Severity: High: the winner of a Kleros vote is forced to find 0.00378 ETH (AI court) or 0.03 ETH (General Court) itself within the appeal period, after the attacker spent ≈ 0.021 ETH (≈ $50; nothing if
SPONSOR_DAILY_WEIis on). In the AI court the winner is typically a party whose fee yescrow sponsored because it holds no ETH (§14.17), so the documented fallback "the winner must fund its own side in time" is exactly what it can't do. The v0.31 alerts reach it, which keeps this out of Critical; if every winner is assumed able to source ETH in 18–36 h, read it as Medium. - Attacker and preconditions: the buyer or seller of any case it expects to lose, with a second wallet and 26 × A of ETH;
APPEAL_DAILY_WEIat its suggested 0.1 ETH (cost scales linearly: ≈ $500 per ETH of budget). It creates the shams before answering the real case's fee (or keeps the budget drained through the 72 h fee window if it is the opener). - Steps (
test_D1_…): (1) real deal 1,900 USDC, seller disputes, buyer answers → real case in court 34. (2) One sham deal of 1 base unit between the buyer's two wallets, disputed and answered; nobody votes; it reaches its appeal period with ruling 0; the keeper (SPEC §14.21: ruling 0 is staked too) stakes 0.00378 ETH. (3) The real case: three jurors vote "seller delivered".decidewith 26 shams' stakes inspentToday(26 × 0.00378 + 0.00378 > 0.1) returnsBUDGET, all period. (4) The buyer funds its own side alone (0.00567); the seller does nothing; period ends;currentRuling= (2, overridden);applyRuling→ Refunded: buyer credited 1,900 USDC + its fee, seller 0 and its fee gone; the buyer withdraws its stake whole. (5) The sham closes by itself; yescrow's stake comes back whole; the sham cost its maker one fee A = 0.00081 ETH. Script: the service's owndecidefunds 26 shams (spentToday 0.09828) then answers{"fund":false,"reason":"BUDGET"}for the real case. - Impact: any ruling, in either court, can be flipped for ≈ $50 plus a refundable 0.00567 ETH, unless the winner stakes itself. yescrow loses nothing but the budget window, so nothing in the budget accounting flags it.
- PoC:
forge test --match-path test/pentest/second/D_AppealBudget.t.sol --match-test test_D1 -vv;node scripts/pentest/second/D_appeal_budget.ts. - Root cause:
service/src/appeals.ts:143protects every Disputed deal of a v0.30+ escrow with no price floor;:114one global daily pool;:120-123spentTodaycounts a stake for 24 h whatever came back;:162-211stakes at the period's open, before any appeal exists, so a sham needs no appeal to consume budget. A sham costs its maker A while costing the budget 4.7 A. - Fix: stake reactively: only after
loserEnd(the loser's half) and only if a choice other than the jurors' is fully funded; the jurors' side can be funded untilend, and Kleros runs the period to its full length once a side is funded, so ≥ 18 h (AI) / 54 h (General) remain. A sham then costs its maker 3 × appealCost per stake it triggers, and unappealed cases still end at the half. Add a price floor (no stake under, say, 50 USDC) and a budget per court so AI-court shams can't drain General Court cases. Also count protected cases per party pair per day.
D-2 (Medium): escalating past the budget flips a twice-confirmed ruling and pays the loser yescrow's stake
- Severity: Medium: loss of yescrow's funds (bounded by the budget per day) plus a profit motive for a flip that SPEC §13.5 documents as a known limit; reported because it is worse than described (SPEC §14.21: the stake is lost only when "the new round reverses the ruling"; in fact it is lost whenever the final appeal is one-sided for the other party, even after new jurors confirmed, and it goes to that party).
- Attacker and preconditions: the loser of the vote, with 0.81 ETH of liquidity for 36 h (AI court) or 0.225 ETH for up to 108 h (General Court), all refunded.
- Steps (
test_D2_…): (1) 1,000 USDC case, 3 jurors: seller delivered; keeper stakes 0.00378; buyer funds 3A' → round 1. (2) Seven jurors confirm. Second appeal's jurors' side 0.54 ETH > budget →BUDGET. (3) Buyer funds its side alone (0.81), period ends,currentRuling= (2, overridden),applyRuling→ Refunded (buyer 1,000 USDC + fee, seller 0). (4) yescrow's round-0 stake withdraws 0; the buyer gets 0.00756 for round 0 and its 0.81 back whole. Net: buyer +0.00189 ETH on top of the flip; yescrow −0.00378. General Court (court 1): yescrow's stakes per round 0.03 / 0.07 / 0.15; the third exceeds the budget, so the loser's unanswered third stake (0.225) flips a deal of any size and it collects 0.2 ETH from rounds 0 and 1, on a ruling confirmed by 1, 3 and 7 jurors. - Impact: every stake yescrow places is a bounty for the party that can afford the first unanswered level; in sham cases the same mechanic drains the budget at a profit of ≈ 0.00108 ETH per sham, up to 0.1 ETH/day.
- PoC:
forge test --match-path test/pentest/second/D_AppealBudget.t.sol --match-test test_D2 -vv. - Root cause: Kleros's
DisputeKitClassicBase.withdrawFeesAndRewardspays every earlier round's pot to the choice equal to the finalcurrentRuling, which a one-sided last appeal sets;appeals.ts:114stops at the budget; SPEC §14.21 describes only the merits case. - Fix: contract, next version: when
currentRulingreturnsoverriddenand the ruling differs from the last round'swinningChoice, hand the deal to the judge instead of paying (a defended case is unaffected). Service: the reactive staking of D-1's fix limits exposure to cases someone actually appeals; cap total exposure per case; word §14.21 and the alerts so a winner knows that above the budget its own stake is the only thing between it and a refunded loser.
D-3 (Low): Kleros's governor can block disputes from being opened, not just refuse them
- Severity: Low: a listed key with an unlisted effect on open, undisputed deals; the same EOA can already upgrade the core, so this adds reach, not a new trust party.
- Steps (
test_D3_…): the governor'schangeCourtParameterssets court 34'sfeeForJurorto 1e30.escrow.arbitrationCost(1,000e6)= 3e30 wei;dispute{value: 1,000,000 ETH}revertsWrongFee;challengeshares_openDispute. At the transfer deadlinerefundpays the buyer 1,000 USDC, who keeps the domain the seller moved; the seller has no judge to go to. Same for court 1 on a 50,000 USDC deal. - Impact: for the duration of the change, no buyer can challenge and no seller can dispute; deals close by their timers alone.
- PoC:
forge test --match-path test/pentest/second/D_GovernorFee.t.sol -vv. - Root cause:
contracts/src/YescrowEscrow.sol:975-985(_openDispute:msg.value != costwith no upper bound), and the docs' "can't touch undisputed deals" (docs/security.md:91). - Fix: next version: a deployment cap on the quoted fee; above it (or if
arbitrationCostreverts) open the dispute at the cap and treat the answer as refused → judge. Now: correctkeys.md/security.md.
D-4 (Low): the resolver key strands appeal stakes of open cases
- Steps (
test_D4_…): after an unopposed stake and the period's end, the resolver's governor callschangeArbitrator(other).KlerosCore.executeRulingreverts (the resolver'srulerequires its current arbitrator),withdrawFeesAndRewardsrevertsDisputeNotResolved, so yescrow's 0.00378 ETH and every party's contribution stay in the kit; the escrow'sapplyRulingstill pays correctly; a new dispute goes to the judge as documented. Only the same key pointing back frees the stakes. - Impact: frozen, not lost; contradicts SPEC §12 / keys.md "can't affect a case once opened". The keeper's
withdraw()(appeals.ts:226-254) would retry every tick forever. - PoC:
forge test --match-path test/pentest/second/D_AppealBudget.t.sol --match-test test_D4 -vv. - Fix: docs only (a Kleros design property); make the keeper stop retrying.
Info
- I-1: the settlement stage also lets a party out of an offer the other side already counter-signed: once the period's time is up (or all votes are in) anyone calls
passPeriod, and a signedsettlenot yet on-chain revertsInvalidSignature(D_FixesHold,test_info_…). Worth a line in the UI. - I-2: with the keeper staking at the period's open, every appeal period now runs its full length (36 h / 108 h) instead of ending at the half when nobody appeals, so AI-court payouts move from ≈ 19 h to ≈ 37 h; reactive staking removes this too.
Checked and found sound
- Refusal detection and gas,
_readofresolver.arbitrator(): unchanged since the first run's proofs;Hardening.t.sol14/14. _readon malformed, short or reverting returns; a case ID that isn't the deal's: unchanged, first-run proofs stand.settlementStage: 0 and 1 can't collide with a timestamp; 2 only when Kleros's getter fails; lapses on every period change including a new round.actionPhase: the only same-phase pair pays identically; a phase-1Confirmcan't close a Kleros case.judgeRuling/judgeTimeout: rulings 0–2, 14 days, no fee either way, loser's fee only on refused cases; ETH owed = held on every path.- Appeals: the keeper reads the jurors' choice during the appeal period;
fundAppealon an already-funded choice reverts (no double stake); a partial pre-funding only lowers the keeper's contribution; a Kleros pause blocks only stake withdrawals; the relayer simulatesfundAppealfirst;withdraw()uses the round's recorded ruling. - Tie → ruling 0 → 50/50, choice 0 funded at 2× by the keeper (proven on the real kit).
Unconfirmed
- U-1:
protectreturns early oncestake_status = 'staked'(appeals.ts:173) and never re-checksfunded.includes(ruling); if Kleros's governor raisesfeeForJurorbetween the read and the kit's pricing, the side stays unfunded while the alerts say it is funded. - U-2:
lastPeriodChangeshared when vote→appeal happens in the same block as commit→vote; theoretical. - U-3: the keeper still calls
applyRulingat day 90 even when the case sits in its appeal period (first run's U-1); unchanged. - U-4: D-1 with sponsorship on costs the attacker nothing; G-1's limits slow it to about a day of lead time.
Verifications for area D
D-1_D-2:
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)
- E-I1.
docs/keys.mddescribes the box wrongly. §2: "The box's only function,sweep, checks that the caller is the escrow that deployed it, which is fixed in its bytecode" — the box'sowneris the router that deployed it (DepositBox.sol:21–30); the escrow is tied to the box only through the router's salt andpull'smsg.sender. §3's row "DepositRouter.returnStray(any chain but Arbitrum) | anyone | Send any other token … to the buyer" is pre-v0.31: on Arbitrum One the buyer in the salt may now sweep a box whose escrow has no code, and off Arbitrum a bridgeable token is returnable only by the buyer. - E-I2. Stale router addresses in the docs.
docs/dependencies.md:275anddocs/runbook.md:82present the v0.12 router0xCCb4…29Ffas "DepositRouter… on every chain";script/DeployRouter.s.sol:22–23describes v0.27 as the current router. The pinned constant (Networks.ROUTER) anddocs/mainnet.mdare right; a reader of the other two could paste the wrong address into a tool (money sent to a v0.12-router box of a v0.31 escrow is locked:E_StrayEdgestest_5). - E-I3. The E-2 fix has no interface or API path.
returnStrayappears nowhere inweb/orservice/srcexcept the ABI; SPEC §4.1 and §13.7 promise that a buyer who paid a testnet deal's address on a mainnet "takes the box back" on Arbitrum One, but only a buyer who can craft the call (Arbiscan's write tab,cast) can. Add a support page or a relay action for it. - E-I4. The USDT inbound row ignores the buyer in the salt.
bridges.ts:82matches aForwarded(escrow, dealId)log to a watched box by escrow and deal ID only (the event carries no buyer), so a stranger's USDT forward for (escrow, realDealId, itself) is recorded as 10 USDT "coming" to the real deal until LayerZero Scan reports delivery, while the USDT lands at the stranger's own box, from which no terms can ever pull it (locked for good). Display only:accept/proposeread the real box on-chain. The stranger pays the full amount, so nobody would.
Checked and found sound
Executable claims are in contracts/test/pentest/second/E_StrayEdges.t.sol and E_RouterPinned.t.sol, and in the
first run's E_*.t.sol re-run on this commit; the rest by reading.
- 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,forwardandpullmove nothing, a stranger'sreturnStraywith its own address as buyer finds an empty (different) box, and the buyer takes all three assets back (E_StrayEdgestest_1; first run'sE_StrandedBoxtests 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'sdepositAddressis that box,returnDepositisTooEarlywhile valid terms can still fund the deal and returns the money to the same buyer afteracceptBy(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 ownpropose. - 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
pullto 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 fromYescrowEscrow.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 openreturnStray. - 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, andDeployRouter.predicted()all land atNetworks.ROUTERthrough the keyless proxy, on Ethereum and Arbitrum One forks too (E_RouterPinned,E_ForwardCost/E_StrayEdgessetups, first run'sE_Soundtest_1). The router deployed on Arbitrum Sepolia (cast code) is byte-identical to the artifact's runtime code with itsboxCodeHashimmutable (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. - Boxes.
boxCodeHashand every box address depend only on (router, escrow, dealId, buyer); two routers' boxes differ, fields aren't interchangeable, and the salt isabi.encodeof fixed-size fields (E_RouterPinnedtest 2). The box'sowneris the router;sweeptakes any token or ETH to atoonly 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 malicioustokengains nothing, and the escrow isnonReentrant. - Forwarding.
forwardsends toboxAddress(escrow, dealId, buyer)from the salt it swept, withdestinationCaller0 (anyone completes),maxFee0 andminFinalityThreshold2000 (standard, free; CCTP V2 requiresmaxFee < amount, so any non-zero amount passes) for USDC, andminAmountLD = amountwith the realquoteSendfor USDT (the relayer paid exactly the quote and got the 20% margin back:E_ForwardCost). TheForwardedevent 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), andbridgefrom the escrow'swithdrawTorefuses the same chain, unknown chains and USDT0 to Base (first run'sE_Soundtest_3 re-run). - Every route constant still matches the real bridge (first run's
E_Soundtest_2 re-run on this commit: CCTP domains, remote messengers, token mappings, the 10M cap, USDT0's adapter/OFT, endpoint IDs, peers, enforcedlzReceivegas). 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. - The service's E-1 fix. Inbound rows are found from CCTP's
DepositForBurn(depositor = the router) by the message'smintRecipient, and from the router'sForwardedfor USDT, keyed by (transaction, log index); a batched forward gives one row per burn;completeCctpcompletes 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 actioncompleteTransferrecords 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. - The escrow side (unchanged since the first run, re-read): the deal ID commits to the buyer,
_pullDeposittrusts the balance difference,_fundrequires the price and credits the excess toterms.buyer,returnDepositpays onlyterms.buyer, the deal token only once_depositCanFundis false, other tokens and ETH straight to the buyer at any time; a buyer whose draft deposit isTooEarlycan leave at once withpropose(the deposit funds it) thenwithdrawProposal(the funding comes back as a balance).withdrawTobridges only what the account signed for, and a bridge revert burns no signature. - Deploying elsewhere. On a chain
routeOfdoesn't know,pull,forwardandbridgeare refused andreturnStrayreturns 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), andDeployAll/CheckDeploymentrequire the router on the settlement chain before an escrow names it. - Keys. No key in
docs/keys.mdgains anything from the router: the relayer'sforward/completeTransferonly 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
- "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 perdocs/dependencies.md). - 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). - Buyers that exist only on one chain. The new
returnStraypath on Arbitrum One needsmsg.sender == buyerthere; 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. - Reorged forwards.
findForwardsreads 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). - 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 (
forwardFromDepositChainslogs "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
- Severity: Low. A gap in the launch gate, not an exploit path against a correct deployment: it needs a deployment
other than the one
DeployAll.s.solmakes (an edited script, a manual deployment, or a deploying machine or checkout that was tampered with), the same precondition class as the first run's F-1, which was rated Low and fixed. If such a deployment ever reached mainnet the impact would be Critical: a false release on any deal, as the impact test shows. - Attacker and preconditions: whoever controls the deployment (or the checkout it runs from). Nothing on-chain;
no key of
docs/keys.mdis needed beyond the deployer's. - Steps (
F_GateTrustsGetters.t.sol, fork of Arbitrum One at block 519,194,000): 1. Deploy the router atNetworks.ROUTERthrough the real keyless proxy, the two honestRdapVerifiers (Reclaim's attestor, yescrow's attestor0xaac9…8975), the honestDualRdapVerifier, and aLookalikeVerifierwhosereclaim()andyescrow()return the two honestRdapVerifiers, whosesupportsDomaindelegates to them, and whoseverify(proof)returnsabi.decode(proof, (RdapFacts)), checking no attestation at all. 2. Run both escrow constructors at the promised addresses0xe13E…aBaand0x3A73…fbBwithDeployAll.config()'s exact mainnet configuration (the pinned promo key0xE4e9…b8dE,promoEnds92 days out, the committed juror templates, SPEC §9 windows, Kleros's real resolver, a judge with code) andverifiers = [lookalike]. The constructor arguments are what the real creation transactions would carry. 3. RunCheckDeployment.checkas launch day runs it (REQUIRE_KLEROS=true,VERIFIER= the lookalike, since the record's.verifieris whatever the broadcast'sDualRdapVerifierentry names). Output:verifier ok,escrow oktwice,creation arguments ok: one verifiertwice, real Kleros cases simulated in courts 34 and 1 from both escrows,ALL CHECKS PASSED(test_F1_lookalikeVerifier_passesTheGate_creationArgumentsIncluded). The control test shows the honest deployment passes identically and that the lookalike answers every gate question exactly as the honest verifier does. 4. Impact (test_F1_impact_unattestedFacts_pay990USDC_forADomainThatNeverMoved): on that deployment a buyer funds a 1,000 USDC same-registrar deal naming the version's only verifier (the one every deal names,deployments/<network>.jsonverifier); the seller submitsabi.encode(RdapFacts)with nobody's signature; the deal isProven; after the 72-hour windowreleasecredits the seller 990 USDC and the fee wallet 10 USDC. The buyer's 1,000 USDC is gone for a domain that never moved. - Impact: a deployment whose verifier accepts anything passes the one check that exists to catch it. The first
run's F-1 impact (2) was exactly this rogue-verifier release; v0.31 catches a second verifier on the list (the
decoded list must have length 1 and name
VERIFIER) but not a replaced one, which is the simpler attack and needs no second entry. - Command:
forge test --match-path test/pentest/second/F_GateTrustsGetters.t.sol -vv(4 tests). - Root cause:
contracts/script/CheckDeployment.s.sol:checklines 149–152 readdual.reclaim().attestor(),dual.yescrow().attestor()andsupportsDomainthrough the verifier's own getters;checkEscrowline 194 checkse.isVerifier(x.verifier);checkCreationline 244 checksc.verifiers.length == 1 && c.verifiers[0] == x.verifier.x.verifierisVERIFIERfrom the environment (expected, line 70), whichdocs/mainnet.mdstep 3 takes fromdeployments/arbitrum-one.json.verifier, whichscripts/deploy-all.shlines 82 and 130 take from the broadcast'sDualRdapVerifierentry. Nothing compares the verifier's code, nor the twoRdapVerifiers' code, with this build. - Suggested fix: in
check, deploy fresh copies in the simulation (the gate sends nothing) and compare runtime code hashes, which include the immutables:address(new DualRdapVerifier(dual.reclaim(), dual.yescrow())).codehash == address(dual).codehash,address(new RdapVerifier(Networks.RECLAIM_ATTESTOR)).codehash == address(dual.reclaim()).codehash, and the same for yescrow'sRdapVerifier(test_F1_fix_codeHashOfAFreshBuild_tellsThemApartshows this tells the honest verifier and the lookalike apart and passes for the honest one). Pin yescrow's attestor address inNetworks.solas the promo key is (Info I-3), sox.yescrowAttestordoesn't come from the same environment either. The rehearsal (scripts/rehearse-mainnet.sh) deploys this build's verifiers, so it still passes.
Info (no proof needed, or covered by the confirmations below)
- I-1. solc 0.8.30 is affected by eight published compiler bugs (
docs/bugs.jsonof the Solidity repository, read 2026-10-29; the first run could not fetch it). Seven have a specific trigger that yescrow's sources don't contain:requirewith a custom error with named arguments (SOL-2026-6, via-IR): none insrc/; mutual recursion with stack-to-memory spilling (SOL-2026-4, SOL-2026-2, via-IR): no recursion insrc/; clearing transient and persistent storage in one contract (SOL-2026-1, via-IR, cancun): no transient storage (OpenZeppelin 5.4'sReentrancyGuardis the storage one); a storage-end warning (SOL-2026-3): no custom storage layout; arrays at the end of storage (SOL-2025-1): none;deleteon one element of a memorybytes(SOL-2026-5, legacy pipeline): none in the router or the box. The eighth, SOL-2026-7 "YulOptimizerKnowledgeBaseStaleBookkeeping" (0.8.19 through 0.8.37, fixed in 0.8.38, severity low), is a Yul optimizer bug whose trigger ("a variable used to compute a memory or storage address is reassigned, and addresses derived from it before and after coincide at run time") can't be ruled out from source under via-IR, where any code may be affected; the compiler team's recommended check is to build with the LoadResolver (L) and EqualStoreEliminator (E) steps removed from the optimizer sequence and run the same tests on both builds (differences indicate the bug; matching results don't rule it out). That check is part (b) ofscripts/pentest/second/F_build-checks.sh(profilenoleadded to this copy'sfoundry.toml, same solc, same settings otherwise, separateout-nole/): see "Checked and found sound" for the result. Because the contracts are immutable, the user and the auditor should decide before launch whether to compile with solc 0.8.38 or later: a compiler change movesNetworks.ROUTER(the router's CREATE2 address depends on its exact creation code, so it would be redeployed on every chain and re-pinned inNetworks.solandDeployRouter.s.sol), may change the escrow's size (I-2), and is itself a change the audit must cover. - I-2. EIP-170 headroom is down to 1,155 bytes: the v0.31 escrow runtime is 23,421 bytes (
forge build --sizes, the artifact, and the deployed code; 22,948 in v0.30).docs/audit.md§1 anddocs/security.mdalready say 23,421, so no drift; audit fixes must stay small or the next version needsoptimizer_runsor a split. - I-3. Only the promo key and the template are pinned; the other expected values still come from the deploying
environment or the record it wrote.
expected()readsVERIFIER,YESCROW_ATTESTOR,FEE_RECIPIENTandJUDGEfrom the environment (.env.mainneton launch day, the record forVERIFIER). A wrongYESCROW_ATTESTORin.env.mainnetdeploys verifiers that trust a key nobody holds (no proof ever passes on mainnet; every deal ends in a dispute or a refund, no loss) and passes the gate, sinceDeployAlland the gate read the same variable. The attestor (0xaac9…8975) and the fee recipient (0x6E91…2449) are decided and listed indocs/keys.md: pin both inNetworks.solnext toMAINNET_PROMO_SIGNER, so the gate compares the deployment with the repository, not with the environment. - I-4. The judge is only checked to have code.
DeployAll.check(line 136) andcheckEscrow(line 198) requirejudge.code.length != 0; any contract passes, including one that can never send a transaction (a token, a stray proxy), in which case every refused or stalled case splits 50/50 after 14 days (the lost-key outcome ofdocs/audit.md§5, not worse). The rehearsal's stand-in judge is0x…1d6ewith code0x00, so the rehearsal can't catch it either. Suggest the gate read Safe'sgetThreshold()andgetOwners()(at least 1 and 2 on mainnet, orVERSION()), with a minimal Safe-shaped stand-in inrehearse-mainnet.sh. - I-5. A mainnet deployment without a promo key fails the gate after deploying.
DeployAllallowsPROMO_SIGNERunset (line 128/140), but on Arbitrum Oneexpected()pinspromoSignertoNetworks.MAINNET_PROMO_SIGNER(line 77) andcheckEscrowrequires equality (line 208). With the NAMEPROS decision this is right; if the decision changes,Networks.solmust change first, or the deployer's nonces are spent on a version the gate refuses to record (RESUME=1re-runs the same gate). Also still open from the first run: a partially sentDeployAll(a CREATE dropped after the verifiers) has no runbook step;forge script script/DeployAll.s.sol:DeployAll --rpc-url … --resumere-sends the unsent transactions with their recorded input (the templates were rendered for the right addresses in the simulation), and the promised addresses still hold as long as the deployer sent nothing else.
Checked and found sound
The v0.31 fixes of the first run's F-1 and F-2 hold (REPORT-F-regression.log): all 24 first-run F_* tests and
the 3 V_F-1_controls tests pass from this checkout. In F_CheckDeploymentGaps: the committed template hashes in
Networks.sol equal what DeployAll.config() renders for each promised address (1,428 bytes, mappings empty); the
right deployment passes; a wrong promo key, a late promoEnds (+365 days on a 92-day promo, 2^64-1, PROMO_DAYS
over 366), a one-letter template change, the USDC template at the USDT address, non-empty mappings, a second
verifier, a lone wrong verifier, a missing creation transaction and an escrow off the promised addresses each fail with
their own message, and arguments of another deployment fail "creation arguments differ". scripts/deploy-escrow.sh
line 87 and docs/mainnet.md line 83 now verify the router with --compilation-profile default (F-2).
The gate's new mechanism on a real chain (F_Sound.t.sol part 1, fork of Arbitrum Sepolia at the latest block).
The creation transactions deployments/arbitrum-sepolia.json records for the live v0.31 escrows 0xEE55…BB4D (USDC)
and 0xEaFD…6933 (USDT), decoded through the gate's own constructorArgs (vm.rpc eth_getTransactionByHash, the
scan for this build's creation code; commit 9a24d75 that deployed them has the same contract sources as e2b52ce), give
exactly one verifier, the record's DualRdapVerifier 0x7Fa1…7691, the record's promo key 0x9281…8240 and end
1808839159, the 1,430-byte template that storage slot 6 holds and empty mappings in slot 7; creationArgs with no
ESCROW_TXS finds the same transactions in the record file; and the live deployment passes check from this
checkout, with Kleros's Sepolia resolver creating a case from each escrow in both courts.
Storage slots. forge inspect YescrowEscrow storage-layout: OpenZeppelin's _nameFallback (0), _versionFallback
(1), _status (2), then arbitratorExtraData (3), smallDealArbitratorExtraData (4), isVerifier (5),
templateData (6), templateDataMappings (7): TEMPLATE_SLOT = 6 is right, and checkCreation compares the slot's
content with the decoded arguments, so a layout change fails the gate instead of passing it. storedString handles
short and long strings (the 1,428-byte template is long; the empty mappings are short).
Networks.sol against Arbitrum One today (F_Sound.t.sol part 2 at the latest block, and cast at block
519,496,325 on 2026-10-29): USDC 0xaf88…5831 and USDT0 0xFd08…Cbb9 with 6 decimals, USDT0's name USD₮0;
KlerosCoreNeo 0x991d…22ea; Kleros's resolver 0xb552…4323 on the allow-list, pointing at the core and at the
template registry 0x0cFB…a5A2; courts 1 (parent 0, 0.005 ETH per juror) and 34 (parent 33, 0.00027 ETH), both
enabled and supporting dispute kit 1; arbitrationCost(abi.encode(1,1,1)) = 5e15 and (34,3,1) = 8.1e14 wei, what
the escrows' extraData encodes; the evidence module has code; floors 24 h / 72 h / 72 h are SPEC §9's minimums. The
mainnet deployer 0xA8C2…D14D has nonce 0 and balance 0; CREATE(deployer, 3) and (deployer, 4) are the pinned
escrow addresses and both are still empty; the keyless CREATE2 proxy is present; the router is not yet on Arbitrum
One (nor the v0.12 router 0xCCb4…29Ff). USDT0 has no version() (it reverts): deploy-all.sh's record then says
"1", which is USDT0's real EIP-712 domain version (ArbitrumOneUsdt0Fork.t.sol rebuilds its DOMAIN_SEPARATOR from
"USD₮0", "1"), so USDT funding signatures built from the record will verify.
The new router address. CREATE2(proxy, keccak256("yescrow deposit router v1"), keccak256(init)) of the legacy
artifact out/DepositRouter.sol/DepositRouter.json (6,842 init bytes, hash 0x0ca2…7017) is
0x8f5d140d1ab4158Bf259D8C909C803A651Fb1a52 = Networks.ROUTER = DeployRouter.ROUTER; the init code in the
recorded Sepolia broadcast has the same hash; the code live at that address on Arbitrum Sepolia and Base Sepolia
(5,957 bytes) equals this build's runtime after masking its two immutables, and its boxCodeHash() is the legacy
DepositBox init hash 0x1e06…f2a0. The via-IR router build would land elsewhere with another box hash, and
DeployRouter.run refuses it (first-run test re-run). Whatever lands at ROUTER through the proxy has exactly this
code, so the gate's code.length != 0 check is enough there.
What the tests run is what deploys. type(YescrowEscrow).creationCode in a via-IR-compiled test equals
vm.getCode of the artifact (also for the two verifiers, first-run test re-run); the artifacts' settings are
viaIR true for the escrow and the verifiers, legacy for the router and the box, 200 runs, cancun, no metadata hash,
no CBOR; DeployAll.s.sol and CheckDeployment.s.sol compile in the via-IR job (they import the escrow), so the
creation code the gate scans for is the one DeployAll deploys. The v0.31 source changes (git diff 3ca0628 e2b52ce
-- contracts/src) add no assembly, unchecked, increments or other multi-effect expressions, recursion, transient
storage or require with custom errors.
DeployAll.s.sol (first-run guard test re-run on the fork): the launch settings pass; each of the thirteen wrong
settings is refused with its own message; mainnet defaults 24 h / 72 h / 72 h, 1%, 25%, 2,000e6, no promo key unless
set; the deploy order with computeCreateAddress(deployer, getNonce) before each escrow and the post-deploy
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
- U-1. The creation transaction the gate decodes is not tied to the escrow it is checked against.
constructorArgs(CheckDeployment.s.sollines 114–144) takes any transaction and returns whatever follows this build's creation code in its input, wherever it finds it word-aligned behind a length word: a CALL to any address, a factory call, a transaction from any sender all qualify, and neitherto,from,noncenor the receipt'scontractAddressis looked at.checkCreation(lines 237–257) then compares only the template, the mappings, the promo key and end, the fee recipient and the judge with what the escrow reads back; the verifier list is taken from the decoded arguments alone, because a mapping can't be enumerated. So a transaction that merely carries the creation code followed by the honest arguments for that escrow address, named as the escrow'shashincontracts/broadcast/DeployAll.s.sol/42161/run-latest.json(RESUME=1,txhline 81) or as.escrows[].transactionindeployments/arbitrum-one.json(step 3,creationArgslines 92–105), would make the gate print "creation arguments ok: one verifier" for an escrow actually deployed with more verifiers. Reasoning only: an end-to-end proof needs a wrong deployment and an edited broadcast staged on a local fork, which I did not build in this run; the mechanism itself is exercised against the real chain inF_Sound.t.sol. Preconditions are the same as F-1's (a deployment that isn'tDeployAll's, plus an edited broadcast or record), and F-1 already achieves more with less, so this is at most Low. Fix: require, from the sameeth_getTransactionByHashresult,to == nullandvm.computeCreateAddress(from, nonce) == escrow(on Arbitrum One alsofrom == Networks.MAINNET_DEPLOYERand nonce 3 or 4), or readeth_getTransactionReceipt'scontractAddress. - U-2. Whether SOL-2026-7 is in yescrow's exact bytecode can only be settled the way the compiler team describes: build solc 0.8.30 with PR 17116 applied and compare the output, which was out of reach on this machine. The differential test above is the next best check and is not a proof of absence.
- U-3. Court 34's name and policy ("Agentic Commerce Court") and the IPFS policy's content behind the pinned
template hash are off-chain; on-chain only the court's parameters and the
policyURIstring are checked.
Files
contracts/test/pentest/second/F_GateTrustsGetters.t.sol: F-1 (control, the finding, the impact, the fix).contracts/test/pentest/second/F_Sound.t.sol: the gate's mechanism against the live testnet deployment; Arbitrum One today; the build.scripts/pentest/second/F_build-checks.sh:FOUNDRY_BROADCAST; the no-L/E differential build (profilenole, added at the end of this copy'scontracts/foundry.toml;out-nole/andcache-nole/are its build output).- Logs:
REPORT-F-run.log,REPORT-F-regression.log,REPORT-F-build-checks.log. - Nothing under
contracts/src/,contracts/script/,scripts/*.shordocs/was changed.
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
- The v0.31 budget fixes hold (B-1, B-2).
budgets.tsreserveSpendinserts the worst-case cost (gas×1.2×maxFee + value) before sending and deletes it only if the 24-hour sum is still within the limit, so concurrent requests fail closed;sendWithinbooks the real cost on success, keeps the reservation on a mined revert (gas was spent) and frees it on a pre-send failure. Thelz-fee(USDT cross-chain) anddeposit-gas:<chainId>(gasless deposits) budgets are each their own pool.relayer.tskeeps the keeper's lane uncapped and ahead of the API's, and caps the API lane atRELAY_QUEUE_MAX(RELAYER_BUSY), so an API flood can neither stall the keeper nor grow without bound. Re-ranservice/test/relayer-caps.test.ts: 10/10 pass. - Sponsored fees can't be drained past the budget (G-1, run 1).
sponsor.tscounts a closed deal's fees net of what the escrow refunded (refundOfmatches_close's held-fee split exactly), caps one deal atSPONSOR_DEAL_SHARE_BPS(25%) of the day's budget, requiresSPONSOR_MIN_DEAL_AGE_HOURS(6 h) of activity, reserves each fee before sending (reservewithON CONFLICT DO NOTHING+ a post-insert budget re-check), andops.tsalerts when deals younger than 3 days take half the budget. The residual (shams that reach Kleros cost ≈ the forwarded cost each, within the budget) is the documented, bounded behaviour. The budget is 0 in.env.mainnet.example, so sponsoring is off until the operator turns it on. - The keeper screens its deposit and transfer steps, failing closed (G-2, run 1).
keeper.clear()treats both a listed address and a thrown screen (SCREENING_UNAVAILABLE) as "skip", and gatespropose/accept(deposits()),forward(forwardFromDepositChains()) and the outboundreceiveMessagerecipient (completeTransfers()). No funds reach a listed party on these paths; the inbound/self-complete read-only follow-up moves nothing. Deadline calls stay unscreened, which only protects the other party. - Forwards by anyone are completed, per message, without redirection (E-1, run 1).
bridges.forwardsInrecords one row per burn keyed by(source_tx, source_index)for watched deal boxes only;completeCctpcompletes only CCTP messages whosemintRecipientequals the row's box and that aren't spent yet, so a stranger'sforwardis finished to the deal, never elsewhere. The relaycompleteTransferresolves boxes from the router's ownForwardedevents, so it can't mint to an arbitrary recipient. - Typed data is bound to the dispute phase and stage, and action deadlines are capped (B-3, B-4).
typed-data.tsaddsphaseto every action andstageto settlements for v0.31 escrows, hashed exactly as the escrow hashes them (verified in the regression test);actionPhaseOf/settlementStageOfread the contract's ownactionPhase/settlementStage. The API refuses an action deadline more thanMAX_ACTION_DEADLINE(3600 s) ahead at both the typed-data GET (app.ts:822) and the relay POST (app.ts:1014), andconfirm/cancelre-check the on-chain state when the relayer's turn comes (DEAL_STATE_CHANGED). A stale action or a lapsed offer only forces a re-sign, never a loss (the fee period is 72 h, offers are re-signable). - Automatic payouts still can't over-pay, double-pay or be redirected (SPEC §14.14).
payouts.ts/keeper.sendPayoutssubmit the party's ownWithdrawTo(recipient = account = signer, one-time by digest, deadline a year out), skip when the deal credited less than the signed amount or closed asSettled/split, screen the recipient, and let the contract re-check signature/deadline/one-time/balance. Thewithdrawrelay refuses while a matching payout iswaiting(PAYOUT_PENDING, A-2). - Deposit authorizations are issued, balance-checked and one-shot (B-2).
POST /v1/deposit-authorizationsissues a random nonce for (deal, sender, value, validBefore) only while the deal can be funded and for at least what it still needs and the chain'sFORWARD_MIN; the relay path sends only an issued, unused nonce whose signer still holds the value, marking it used before sending and freeing it only if nothing was sent. - ERC-6492 deploys stay on the allow-list (B-5 / §14.16).
erc6492.tsdeploys only through the two Coinbase factories and only when the factory call creates the signer's own address;dryRunskips the deploy; a deploy is bundled with a step that also needs the party's valid signature, so it can't be triggered for free. - The prover's parameter check and the attestor patch are unchanged and sound.
prover.tspinsparametersFor(verifier, domain)so a deal stays provable on its own verifier version; the on-chain verifier rebuilds and signature-checks the parameters, so a craftedresponseMatchescan't forge an on-chain-valid proof.attestor/yescrow-hardening.diffstill refuses pre-3.2.0 clients, partial server-record reveals and incomplete responses, before body parsing and signing. - The web signing dialogs show exactly what is signed, including the new fields.
SignReview.tsxrenders every EIP-712 field with the raw payload in a details pane and labels the v0.31phase/stage("Works only while the dispute is at …");Warnings.tsxgates the confirm button on acknowledgement; the relayer-can't-change-this note is accurate. - Admin / promo / referrer paths touch only yescrow's own fee, and the judge service holds no key (builds the judge
wallet's tx only) — re-verified against
docs/keys.md; unchanged from run 1. - Deposit-chain config and
MAX_AUTO_*windows match the contract caps, so a deposit-paying buyer is never auto- accepted into a window longer than SPEC §9 promises.
Unconfirmed (reasoning, no runnable proof)
- Keeper USDT forwards and inbound
receiveMessageare unbudgeted too (keeper.forwardFromDepositChains:relayer!.call('forward', …, value)with the USDT0 LayerZero fee, nosendWithin; andcompleteTransfers's inboundreceiveMessage). Same class as G-2, but self-limiting: both are triggered by real money (≥FORWARD_MIN) arriving at a watched deal box, which then funds a deal or is returned, so the attacker's funds don't come back freely. Not pursued as a separate finding; the spend-rate ops alert covers it. reserveSpendcan overshoot a budget by one in-flight request. The INSERT and theSELECT SUMare separate statements (read-committed, auto-commit), so if a second request's INSERT lands after the first's SELECT, the first can pass counting only itself. The overshoot is one request's worst-case cost and the money is yescrow's own ETH; the pattern is the intended one and matchessponsor.reserve. Not a finding.- Appeal-budget accounting counts gross stakes for 24 h (
appeals.spentTodaysumsstake_weiregardless ofwithdrawn_wei). An attacker who reaches many appeal periods could holdAPPEAL_DAILY_WEIat its cap, denying protection to other disputes. But reaching an appeal period requires a real, voted Kleros case (slow and costly), the stake is genuinely locked in Kleros for the window, and the shortfall is operator-alerted (BUDGET). Within the documented limit of §14.21.
Verifications for area G
G-1:
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
- Who ran it. The user first started a fresh session for the run (prompt in
docs/ai-pentest.md); it showed no progress in the repository for an hour, and the user chose "launch it from here", from the session that made the v0.31 fixes. That session (the orchestrator) only exported the commit, launched the agents, saved their reports and verdicts (the harness refuses report files from subagents, so several reports were returned as text and saved by the orchestrator unchanged) and assembled this report. - Copies.
git archive e2b52ceintopentest2/base(never modified) and one copy per area (A–G) plusRfor regressions, each with the contract build warm and node_modules symlinked; the other sessions' uncommitted edits were not included. Every heavy job ran one at a time under a shared lock and a memory cap (2 cores, 4 GB). - Agents. Seven attack agents on Fable 5.1 at maximum effort, each given the procedure's prompt word for word with
its area brief and the second-run additions (check the fixes hold; look for what they broke). One regression agent
(Opus 5.5). One verifier per finding or group of related findings (Opus 5.5), with the first run's verifier prompt
and the user's rule, writing extra tests only as
V2_*files. - Interruption. The account's weekly usage limit stopped area F and four verifiers on 2026-10-30; they resumed from their saved state on 2026-10-31.
- Files. The proofs and the verifiers' tests are copied into the repository with this report:
contracts/test/pentest/second/*,contracts/test/pentest/verify/V2_*,scripts/pentest/second/*,scripts/pentest/verify/V2_*. Run each with the command its finding names, fromcontracts/(forge) or the repository root /scripts/pentest/second/(node). Area F's compiler differential needs thenoleprofile its script describes (not added to the repository'sfoundry.toml).