Published copy of reports/AI pentest, third run (2026-11-01).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, third run (2026-11-01)
A targeted step 4 of docs/ai-pentest.md on commit 71ad5a6 (SPEC v0.32, which fixed every finding of reports/AI
pentest, second run (2026-10-31).md): fresh attack agents on what v0.32 changed, grouped into five areas (D disputes
and appeals; AB settlements, signatures and money; C proofs; EG the deposit router, keeper and off-chain parts; F
deployment), a regression run of every first- and second-run proof, and a fresh verifier for every finding. Proofs are
under contracts/test/pentest/third/ and scripts/pentest/third/, the verifiers' extra tests under
contracts/test/pentest/verify/V3_* and scripts/pentest/verify/V3_*.
Part 1. Summary
Result
No Critical or High finding. Every earlier fix still holds: all 291 earlier pentest proofs, 36 fork tests and the
service (139), prover (16) and web (81) tests pass on 71ad5a6. The v0.32 changes held where they were attacked:
applyRuling ignores a one-sided appeal and pays the last round's jurors in every direction tried on the real Kleros
of an Arbitrum One fork; settlements are refused in the vote period and lapse at every move of the case; the dual
verifier accepts a pair only if it would accept each half on its own (fuzzed through the escrow); the keeper's budgets
cap what strangers can make it spend; the launch gate rebuilds and matches a real v0.32 deployment. 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.
Two Medium and seven Low findings survived verification:
- Medium: R3-D-2. Kleros still pays its appeal pots to the ruling a one-sided appeal sets, which the escrow now ignores. A party that funds one side of an appeal, as the deal page's "Fund it" invites, can lose that stake to whoever funds the last appeal alone; the deal itself is paid correctly. Fix in the web app and docs.
- Medium: R3-C-1 (raised from Info by its verifier). For a same-registrar deal the buyer picks the nameservers; at 22 of the 25 TLDs a list of long names makes the registry's answer too large for the verifier to hash, so the deal can't be proven and the seller has to dispute. The seller never sees the list. Fix in the service and web app.
- Low: R3-D-1/R3-AB-1 (one finding found twice: a Kleros quote between the cap and twice the cap goes to Kleros instead of yescrow's judge as the SPEC promises; the only contract fix recommended, 40 bytes), R3-D-3 (a one-sided appeal holds a decided case to the end of its appeal period, 18 h longer in the AI court, for free), R3-EG-1 (the keeper's automatic USDT payouts to another chain pay LayerZero's fee outside every budget), R3-EG-2 (inbound transfers into a sanctioned buyer's deposit box are completed, and both paths fail open without a list), R3-F-1 (the gate takes the fee, windows, promo length and judge from the env file, not from pins), R3-F-2 (the gate doesn't tie each promised escrow to its token).
R3-AB-2 (the stage falls back to 2 when one Kleros read fails) was lowered to Info: the change to Kleros it needs would break Kleros's own appeals, and the second run already rated the same residual Info. Its 1-byte contract fix is optional.
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 |
|---|---|---|---|---|---|
| R3-D-2 | Kleros's appeal pots follow the override the escrow ignores: a party that funds one side (the deal page's "Fund it") loses its stake to whoever funds the last appeal alone | Medium | Confirmed; anyone who funded one side is exposed, not only the jurors' side | Medium | Web and docs: one "fund both sides" action on v0.32 escrows, no single-side stake, corrected texts |
| R3-C-1 | A buyer-chosen same-registrar nameserver list can be too large to hash at 22 of 25 TLDs' layouts: no automatic release, the seller must dispute | Info | Upgraded: a free trap the buyer sets; the seller never sees the list; 22 TLDs, not 4 | Medium | Service and web: size check from the live layout, show the list to the seller, prove before pushing |
| R3-D-1 / R3-AB-1 | A quote between the cap and twice the cap goes to Kleros, not the judge: the winner loses up to its whole fee, a winning seller also pays F | Low / Low | Confirmed, one finding; D's fix tested on the fork (+40 bytes) | Low | Contract (refuse above the cap at the answer) and docs |
| R3-D-3 | A one-sided appeal holds a decided case to the end of its appeal period (+18 h AI court, +54 h General Court) for free | Low | Confirmed; anyone can do it, a winner pressing "Fund it" too | Low | Docs and UI |
| R3-EG-1 | The keeper's automatic cross-chain USDT payouts pay USDT0's LayerZero fee outside every budget, with no minimum | Low | Confirmed | Low | Service (own budget, minimum at signing) |
| R3-EG-2 | Inbound transfers to a sanctioned buyer's deposit box are completed by the keeper and the relay; both fail open with no list | Low | Confirmed, wider (relay path, no list, returnDeposit) |
Low | Service |
| R3-F-1 | The gate takes the fee, referrer share, small-deal limit, windows, promo length and judge from the env file | Low | Confirmed; one-keystroke typos pass | Low | Scripts (pins in Networks.sol, judge Safe pinned) |
| R3-F-2 | The gate doesn't bind each promised escrow to its token or require two distinct escrows | Low | Confirmed; swapped lines pass the whole procedure | Low | Scripts and a service startup check |
| R3-AB-2 | When one Kleros read fails the stage is 2, which drops the vote-period refusal and the period binding | Low | Downgraded: needs a Kleros change that breaks Kleros's own appeals; known residual | Info | Contract (optional, 1 byte), service text, docs |
| R3-D-I1–I3 | docs/security.md states two thresholds; v0.32 cases are outside the stranded-ruling handling (proven on Postgres); the deal page doesn't say which ruling the escrow pays |
Info | Confirmed | Info | Docs, keeper, web |
| R3-AB-I1–I3 | Appeal-period offer text; settlement typed data for an undisputed deal; court periods | Info | Confirmed | Info | Service text, API check |
| R3-C-2, C-3 | Stale fetchedAt comment; a documented test command fails under forge's dynamic test linking |
Info | Confirmed (C-3's cause corrected) | Info | Comment, docs |
| R3-F-I1–I4 | SPEC §9's promo "6 months" vs 92 days; SKIP_VERIFY/REQUIRE_KLEROS honoured on mainnet; deployer funding margin; one RPC for the gate |
Info | Confirmed | Info | Docs, scripts |
Limits
- Targeted, not a full run. The user chose a smaller run: attackers only on what v0.32 changed, with the areas merged into five (A and B together, E and G together); the rest was covered by the regression run of every earlier proof, not attacked afresh.
- Not independent of the author. At the user's request the run was launched from the session that wrote the v0.32 fixes. Every agent had a fresh context and the procedure's prompt word for word; the attack agents used Fable 5.1, the verifiers Opus 5.5 under the user's rule. No model from another vendor was run.
- Some points are read, not run. R3-C-1's registrar panels (whether they accept 6–13 such names), R3-F-I3's funding arithmetic and several "checked and found sound" items come from reading code and live chain reads.
- 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 D: disputes and appeals
AI pentest, third run, area D: disputes as changed in SPEC v0.32 (commit 71ad5a6)
Saved by the orchestrator from the attack agent's final reply (the harness refused the agent's report file).
Findings (one line each)
- R3-D-1 — SPEC §7.1's fee-cap promise is off by two: a Kleros quote between the cap and twice the cap goes to Kleros, paid out of both capped fees, and the winner gets back as little as nothing — Low —
cd contracts && forge test --match-path test/pentest/third/D3_CapBand.t.sol -vv - R3-D-2 — Kleros's appeal pots still follow the overridden ruling the escrow ignores: a winner who funds its side (as the deal page's "Fund it" and its text invite) loses that stake to the loser, who ends up ahead after losing on the merits twice — Medium —
cd contracts && forge test --match-path test/pentest/third/D3_WinnersStake.t.sol -vv - R3-D-3 — A one-sided appeal is now a free 18 h (AI court) / 54 h (General Court) delay of a decided case — Low —
cd contracts && forge test --match-path test/pentest/third/D3_Delay.t.sol -vv - Info I-1 (docs), I-2 (service: v0.32 cases are outside the R2-D-4 stranded-ruling handling), I-3 (UI) below; no proof file (I-2 needs Postgres, which wasn't running for the agent).
Proofs (new, nothing else changed; no transaction to a live network): contracts/test/pentest/third/ — D3_Base.t.sol (shared harness), D3_CapBand.t.sol (R3-D-1, 3 tests + 1 control), D3_WinnersStake.t.sol (R3-D-2, 3 tests + 1 control), D3_Delay.t.sol (R3-D-3, 2 tests), D3_Sound.t.sol (5 attacks that held). Run from contracts/: forge test --match-path 'test/pentest/third/D3_*.t.sol' -vv → 15 passed, 0 failed (log: scratchpad/pentest3/D3-run1.log).
Setup. Fork of Arbitrum One at block 519,194,000 (the repository's own fork block): real KlerosCoreNeo 0.10.0 (0x991d…22ea, implementation 0xC121…E54c), DisputeKitClassicNeo 0.12.0 (0x70B4…5421, implementation 0x371A…19BC, whose verified source the agent read for every claim below), the public DisputeResolver 0xb552…4323, real USDC; the escrow built through DeployAll's own mainnet config (courts 34 and 1, Classic kit, caps 0.01 / 0.05 ETH, mainnet template). Only Kleros's RNG is stubbed (real stakers are drawn) and the drawn jurors' votes are pranked. On-chain values read at that block: court 34 fee 0.00027 ETH/juror, jump at 7, periods 10 min / 45 min / 30 min / 36 h; court 33 fee 0.018, jump at 31; court 1 fee 0.005, jump at 511; courts 1 and 33 periods 78 h / 162 h / 162 h / 108 h; kit multipliers 10000 / 20000, loser window 5000.
Regressions re-run on v0.32 (log scratchpad/pentest3/D3-regress.log): 121 tests, 0 failed, in Judge.t.sol (19), SecondRunFixes.t.sol (13), Hardening.t.sol (24), fork/SecondRunFixesFork.t.sol (10), fork/AppealFunding.t.sol (4), pentest/D_* (21), pentest/second/D_* (9), pentest/verify/V_D-*, V2_D-* (21).
R3-D-1 (Low). SPEC §7.1's cap promise is off by a factor of two
- Severity: Low. Not party-exploitable (Kleros's governor sets the fee); bounded loss (up to the whole capped fee: 0.01 ETH AI court, 0.05 ETH General Court); the parties get Kleros jurors rather than yescrow's judge. Reported because every document promises the opposite and a winning party pays for it.
- Attacker and preconditions: Kleros's governor (a listed trust party) raises a court's
feeForJurorso the quote lands between the deployment's cap and twice the cap (General Court: 0.05 < quote ≤ 0.1 ETH; AI court: 0.01 < quote ≤ 0.02). No change between the two payments is needed. - Steps (
test_R3D1_generalCourt_quoteAtTwiceTheCap_…): the governor sets court 1's fee to 0.1 ETH (quote 0.1, 1 juror).arbitrationCost(50,000e6)= 0.05 (the cap). The seller disputes at 0.05, the buyer answers at 0.05.payArbitrationFee: quoted 0.1 ≤ pot 0.1 → the case is created at Kleros for 0.1 ETH; stateDisputed(notJudging),bothFeesHeldfalse,heldFee0, escrow ETH balance 0. The juror rules for the seller;applyRuling→ Released;ethBalance[seller]= 0: the winner's 0.05 ETH is gone. At 0.075/juror the winner gets 0.025 back; in the AI court at 0.005/juror (quote 0.015 > cap 0.01) the dispute opens at 0.01 a side and the case still goes to Kleros for 0.015, the winner getting 0.005 back. Control: at 0.1 ETH + 1 wei the case is refused, the judge rules, the winner gets its whole fee back and the loser's fee goes to yescrow, as the SPEC says. - Impact: for any quote in (cap, 2·cap] a party that wins its dispute loses 0 to 100% of its capped fee to Kleros, and a sponsored party's fee (§14.17) costs yescrow up to the cap; the documented outcome (refused → judge, winner's fee back whole, loser's fee to yescrow) doesn't happen.
docs/security.mdeven contradicts itself (line 91 "a fee above the cap goes to the judge" vs lines 108–110 "if the cost rose above 2A … the deal goes to yescrow's judge"). - PoC:
cd contracts && forge test --match-path test/pentest/third/D3_CapBand.t.sol -vv - Root cause:
contracts/src/YescrowEscrow.sol:551if (quoted && cost <= pot)refuses only above both fees, whilearbitrationCost(:852-856) caps only what is paid at opening; the natspec ofmaxArbitrationCost(:228-231, "more than both fees") describes the code, butSPEC.md:256(§7.1 "If the quote is above the cap … at the answer the case counts as refused"),SPEC.md:359(§9),docs/keys.md:49,docs/security.md:91,docs/audit.md:98,194promise the cap. - Fix: either keep the promise — refuse at the answer when the quote is above the court's cap as well as above the pot (
quoted && cost <= pot && cost <= cap), so the fee-change band of §8 stays below the cap and a quote above the cap always reaches the judge — or change SPEC §7.1/§9, keys.md, security.md:91 and audit.md to "above twice the cap" and state that between the cap and twice the cap the winner gets back 2·cap − quote.
R3-D-2 (Medium). Kleros's appeal pots still follow the override the escrow ignores; a winner who funds its side loses it to the loser
- Severity: Medium. A party's real loss (its appeal stake: 0.00378 ETH per AI-court round 0, 0.03 / 0.07 / 0.15 ETH per General Court round) and the loser's profit (the new jurors' fee it fronted, back with interest) under plausible conditions: the deal page shows "Fund it" next to the jurors' ruling throughout the appeal period and tells the funder "you get this ETH back with a reward if they rule this way" (
web/components/deal/steps.ts:249-251); the disputes guide says "Whoever funds the side that wins a new round gets their ETH back with a reward" (web/app/(site)/guides/disputes/page.tsx:88-89); the alerts say "Unless the jurors' side is funded too by …, nothing changes" (service/src/alerts.ts:181,205). Not High: the loss is bounded by a stake the winner chose to place and is small against the price, which the escrow pays correctly. Not Low: the loser profits, the trigger is free, and the interface leads a winner into it. - Attacker and preconditions: the losing party, with 3× the next round's
appealCostof liquidity for the rest of the appeal period (0.81 ETH for ≤ 36 h in the AI court, 0.105 ETH for ≤ 108 h in the General Court), all returned; a winner that funded the jurors' side in an earlier round. - Steps (AI court,
test_R3D2_aiCourt_…): (1) 1,000 USDC deal, a real case in court 34; 3 jurors: seller delivered. (2) The seller presses "Fund it" on the jurors' ruling (2 × 0.00189 = 0.00378 ETH); the buyer funds "seller didn't" (3 × = 0.00567): a real appeal, round 1, seven jurors confirm the seller. (3) Round 1's appeal:appealCostis court 33's 15 × 0.018 = 0.27 ETH; the buyer funds "seller didn't" alone (0.81 ETH); the seller, told nothing changes, does nothing; the period ends;currentRuling= (2, overridden). (4)applyRuling→ Released, the seller gets 990 USDC and the held fee: correct. (5)executeRuling, then Kleros'swithdrawFeesAndRewards: round 0 → seller 0, buyer 0.00756 (both stakes less the seven jurors' fee); round 1 → buyer 0.81 back whole. Net: seller −0.00378 ETH, buyer +0.00189 ETH. General Court (50,000 USDC,test_R3D2_generalCourt_…): seller −0.03, buyer +0.015; with two defended rounds (…_twoDefendedRounds): seller −0.1 ETH, buyer +0.05 while fronting 0.225 ETH; the whole thing runs in ~37 days, far from the stall. Control: a winner that never funds loses nothing and the loser's stake comes back whole. - Impact: on v0.32 escrows funding the jurors' side can never help (the escrow ignores the override) and can only lose: it creates a round of new jurors if the loser answers, and whatever the new jurors say, one more unanswered appeal by the loser, which costs it nothing (R3-D-3), hands the loser every earlier round's pot. The interface and alerts promise the opposite.
- PoC:
cd contracts && forge test --match-path test/pentest/third/D3_WinnersStake.t.sol -vv - Root cause:
contracts/src/YescrowEscrow.sol:1079-1098(_finalRuling) pays the last round'swinningChoice, while the deployed kit'swithdrawFeesAndRewardspays every round's pot to the choice equal tocore.currentRuling, which the one-sided last appeal sets (DisputeKitClassicNeo 0.12.0, impl0x371A…19BC:currentRulingoverrides in the execution period when exactly one choice is funded). The UI/alerts/guide lines above present a Kleros rule that the escrow no longer follows. - Fix: interface and docs now: on
oneSidedIgnoredescrows don't offer "Fund it" for the jurors' ruling (or label it as a stake that can only be lost); reword the step intro, the guide andalerts.ts:181,205to say a stake on the jurors' side is never needed and is lost to any later unanswered appeal, and that a real appeal is the loser's to fund both sides of; add the rule to SPEC §7.2/§13.5. No contract change serves the price; making the escrow follow Kleros whenever the jurors' side was ever funded would bring back R2-D-2, so leave the contract.
R3-D-3 (Low). A one-sided appeal is a free 18 h / 54 h delay of a decided case
- Severity: Low (griefing). No money moves; the SPEC/guide "about a day" / "usually within a day" (SPEC §7.1 line 260,
guides/disputes/page.tsx:74) becomes about 37 h in the AI court, and 54 h more in the General Court, at the loser's choice. - Attacker and preconditions: the loser, with 3×
appealCostfor the rest of the period (0.00567 ETH AI, 0.045 ETH General), returned whole. - Steps (
test_R3D3_aiCourt_…): after the vote the appeal period is 36 h; unfunded,passPeriodworks at 18 h and the escrow pays. The buyer funds its side alone a minute before its deadline:passPeriodreverts at 18 h and at 36 h − 1 s,applyRulingrevertsTooEarly(no execution, no stall), at 36 h the same outcome applies;withdrawFeesAndRewardsreturns the stake whole. Delay 64,800 s; General Court 194,400 s. - PoC:
cd contracts && forge test --match-path test/pentest/third/D3_Delay.t.sol -vv - Root cause: Kleros's
passPeriodends an appeal period at its half only while nothing is funded (isAppealFunded); the escrow has nothing to apply before the execution period. Under v0.31 the keeper's own stake caused the same (run 2, I-2); v0.32 leaves it to the loser. - Fix: none on-chain; say in SPEC §7.1/§13.5, the guide and the deal page that a loser can hold the payout to the period's end, and show the real end.
Info
- I-1 (docs):
docs/security.md:91and:108-110state two different refusal thresholds (the cap vs. 2A); see R3-D-1. - I-2 (service): v0.32 cases are outside the R2-D-4 stranded-ruling handling:
service/src/appeals.ts:249selectsstake_reason IS DISTINCT FROM 'NOT_NEEDED', soexecuted()(:284: executes a period-4 ruling, marksstranded_at, feedsstrandedAppealsand the deal page's "stakes are frozen" notice) never runs for them;keeper.ts:174-180triesexecuteRulingonly while the deal isDisputed, andapplyRulingcloses it the same tick. If Kleros re-points its resolver while such a case sits at execution, the deal is paid (fine) but the parties' appeal stakes stay in the kit with no retry, no ops alert and no page notice until someone callsexecuteRulingafter the point-back. yescrow has no stake there. Fix: runexecuted()for NOT_NEEDED rows too (skipping the withdrawal). Not proven: Postgres wasn't running for the agent. - I-3 (UI): in Kleros's execution period its own case page shows the overridden ruling; the deal page says only "The deal pays out once Kleros makes its ruling final".
confirm/cancelstay open, so a party conceding on Kleros's word gives up the price. Show "the escrow will pay: <the jurors' vote>" in that window.
Checked and found sound
- The reads behind
_finalRulingmatch the deployed sources:disputeswords 2/4,currentRulingwords 0/2 (three words returned),getNumberOfRounds, coregetRoundInfo(offset word first,disputeKitIDsecond),disputeKits, the kit'sgetRoundInfo(id, round, 0)words 0/1. The kit maps core round IDs throughcoreRoundIDToLocal, so the escrow asks for the right round even after a court jump;currentRulingoverrides only in the execution period with exactly one funded choice. - Wrong kit / kit jumps: impossible for yescrow's cases:
enableDisputeKitsrevertsCannotDisableClassicDK,appeal()falls back to Classic,isDisputeKitJumpingis false for Classic in every court; the last round's kit is always kit 1. - Override in every direction (D3_Sound + SecondRunFixesFork): loser alone, winner alone (overridden with the same choice, stake back), option 0 alone (the escrow pays the jurors, not 50/50), tie then one-sided (0), both funded → the new round's jurors, a tied new round (0 both ways), the loser funding both sides pays exactly the new jurors' fee.
- Reads can't be starved: at the 1M floor the overridden path with seven drawn jurors uses 234,347 gas; the only read that grows is the core's
getRoundInfo(~2.3k gas per drawn juror), so the floor would matter only with ≈ 350 jurors in the last round (seven answered appeals, ≈ 5.6 ETH of jurors' fees). After day 90 a case at execution is paid on its ruling, never handed to the judge (D3_Sound). - Court jump 34 → 33 at the second appeal:
appealCost0.27 ETH (15 jurors at 0.018) read on the fork; the General Court's own jump (511) makes further appeals NON_PAYABLE. - The cap and the quote: cap returned when the quote is above it or unreadable, the 1M floor in
_quote, above twice the cap → refused → judge with SPEC §7.5's fees (D3_CapBand control, SecondRunFixesFork D3). - Judge paths and fees: Judge.t.sol, D_FeePaths and the refused-at-the-cap control re-run;
judgeRuling/judgeTimeoutboundaries unchanged. - Settlements:
SettleDuringVotesand the stage on the real case (SecondRunFixesFork A1/A2) unchanged. - Keeper:
decidereturns NOT_NEEDED first forpaysJurorsVoteescrows (service/test/appeals.test.ts:146-148, 307-322); detection bymaxArbitrationCost()(escrow.ts:188) is unambiguous;passPeriod/executeRulingare simulated first, so sham cases cost it no gas. - The resolver returns the core's dispute ID and forwards
msg.valuewhole (first-run proof, source).
Unconfirmed
- U-1, reaching the judge by appeals: a loser who funds both sides of every appeal keeps a General Court case alive past day 90 after ≈ 5–7 rounds (0.28–2.5 ETH of jurors' fees, depending on juror speed), moving a $50k deal from 31–255 jurors to yescrow's judge; it pays only if the judge misrules or times out (50/50). AI court: ≈ 3 ETH for a ≤ $2k deal. By design (§7.5); not profitable unless the judge fails.
- U-2, a party as juror: court 34 had 93,059 PNK staked (dependencies.md §1.8); a party holding Kleros's juror NFT staking ≈ 100k PNK would hold most of the three draws in its own case, at 187 PNK per incoherent vote. Kleros's design; needs the NFT.
- U-3: the keeper's own day-90
applyRulinghands a case mid-round to the judge (run 2's U-3); unchanged.
Verifications for area D
R3-D-1 / R3-AB-1 (one verifier for both):
Verdict: CONFIRMED Low. R3-D-1 and R3-AB-1 are one finding with one root cause, YescrowEscrow.sol:551. Both proofs pass on the real Kleros fork and show the claimed amounts.
Notation: A is what each side paid, C is Kleros's quote at the answer, F is yescrow's 1% fee.
Evidence (file:line)
- The code: contracts/src/YescrowEscrow.sol:546-551 sets pot = 2 * d.arbitrationFee and creates the case when quoted && cost <= pot, so it refuses only above both fees. :852-856 (arbitrationCost) holds A at the cap and :1001-1011 stores it; :567 holds 2A − C.
- The contract's comments describe the code, not the SPEC: :228-230 (the judge gets a case only when Kleros "wants more than both fees"), :533-538 the same.
- Places promising "above the cap, the judge": SPEC.md:3 (v0.32 status line), SPEC.md:256 (§7.1), SPEC.md:359 (§9), docs/keys.md:49, docs/security.md:91, docs/audit.md:98 and :194, contracts/script/Networks.sol:19-20, contracts/script/DeployAll.s.sol:129.
- Texts that match the code ("more than both fees", "at most 2A"): SPEC.md:255, :258, :339 (the §8 note, which assumes the fee changed between the payments), docs/security.md:108-110, docs/audit.md:115-116, web/app/(site)/guides/disputes/page.tsx:109-110.
- No document says "twice the cap"; §14.17 and §14.20 say nothing about routing.
- Origin: the R2-D-3 fix recipe (reports/AI pentest, second run (2026-10-31).md:721) relied on "the existing cost > pot refusal path", missing that pot is 2 × cap. The v0.32 cap tests only use a fee of 1e30 (test/SecondRunFixes.t.sol:150, test/fork/SecondRunFixesFork.t.sol:432,445), so nothing tested the band.
Proofs re-run (Arbitrum One fork, block 519,194,000)
- D3_CapBand.t.sol: 4/4 pass. Quote 0.1 ETH (2 × cap): Kleros, the winner gets back 0 of its 0.05. 0.075 ETH: 0.025 back. AI court at 0.015 ETH: 0.005 back. Control at 0.1 ETH + 1 wei: the judge, the winner's whole fee back, the loser's to yescrow.
- AB3_CapBand.t.sol --match-test test_R3AB1: 3/3 pass. AI court band: the seller gets 990 USDC + 0.005 ETH vs 1,000 USDC + 0.01 ETH on the judge path. At ~2 × cap: 2 wei held. General Court at 0.075: 0.025 ETH held.
Own tests (contracts/test/pentest/verify/)
- V3_D-1_CapBand.t.sol: 5/5 pass on 71ad5a6 (who can trigger the band, its edges, payouts in each outcome, fee changes between the payments, a sponsored answer).
- V3_D-1_FixCheck.t.sol: 5/5 pass on an escrow patched with D's fix; on 71ad5a6, 4 of 5 fail at exactly the above-the-cap assertions (the control).
- V3_D-1_FixCheckAlt.t.sol: pins AB's alternative fix; passes only on that variant.
- Full suite on D's fix: 676 tests, 666 pass; the 10 failures all pin the old band behaviour (the 6 attack proofs and 4 of the CapBand tests). Everything else passes, including the invariants and the fork suites.
- Patched copies: scratchpad/pentest3/vD1-fix (D's form), scratchpad/pentest3/vD1-fixAB (AB's form); diffs and logs in scratchpad/pentest3/vD1-logs/.
What I checked
- Who can trigger it: only Kleros's governor, 0xf1C7c037891525E360C59f708739Ac09A7670c59, an EOA. changeCourtParameters reverts for the buyer, seller, judge, fee recipient and relayer. The quote is fee per juror × juror count; court and jurors are fixed at deployment (court 34 with 3, court 1 with 1). A party picks the court only through the price when proposing; no one can push an open deal into the band. The band is 12.3–24.7× today's AI-court quote (0.00081 ETH) and 10–20× today's General Court quote (0.005 ETH).
- Edges, both courts: at the cap a normal Kleros case; 1 wei above: Kleros, cap − 1 wei held; at 2 × cap: Kleros, 0 held; 1 wei above 2 × cap: refused, the judge.
- What each side gets (50,000 USDC General Court deal, C = 0.075, band vs the promised judge path): seller wins 49,500 USDC + 0.025 ETH vs 50,000 + 0.05; buyer wins 0.025 ETH back vs 0.05; split: seller 24,750 USDC and 0.0125 ETH each vs 25,000 and 0.025 each; settled at 40,000: same USDC, 0.0125 ETH each vs 0.05 each; yescrow earns F (500, 250, 400 USDC) instead of the loser's 0.05 ETH. §8's rows assume the fee didn't change; in the band it didn't, and they are still wrong.
- Sub-bands: C ≤ 2A and ≤ cap: Kleros, winner gets 2A − C back as §8 says. C > cap but ≤ 2A: Kleros (the constant-quote band, and a rise between the payments crossing the cap, shown 0.04 → 0.06); winner 2A − C, loser 0, F charged; every document above promises the judge. C > 2A or unreadable: the judge, as documented.
- Sponsored answer (§14.17): when the relayer's party wins it gets 0.005 back instead of 0.01; when its party loses it gets 0 and yescrow's fee wallet gets 0 (the judge path would put that 0.01 in yescrow's fee wallet). yescrow loses up to one cap per sponsored side, within the sponsor budget.
Reason (Low unchanged): only a listed trust party (Kleros's governor, who can already decide disputed deals by upgrading the core); needs a ≥ 10× fee rise; per deal at most one cap of ETH against the promise; a winning seller also pays F (a lost waiver, not money taken); money goes only to documented payees; Kleros's jurors still decide; nothing stolen, locked or falsely released; matches the §8 fee-change limit accepted in audit.md §5.
Corrections to the attack reports
- D, the bound: not limited to the capped fee; a winning seller also pays F, and on a split F is charged on P/2 (AB is right).
- D, security.md "contradicts itself": lines 108–110 cover a rise between the payments and match the code there; they conflict with line 91 only when that rise crosses the cap.
- D, missing places: SPEC.md:3, Networks.sol:19-20, DeployAll.s.sol:129.
- Both, §7.1 wording: read literally, §7.1 also promises a refusal when the quote was above the cap at opening and fell under it by the answer; the code and both fixes send that to Kleros (shown: 0.075 at opening, 0.03 at the answer, 0.07 held). Harmless, but §7.1 should say "the quote at the answer".
- AB, its fix (cost <= d.arbitrationFee, +4 bytes) is a policy change, not the same fix: it sends every rise between the payments to the judge, even 1 wei; breaks 5 existing tests of the documented §8 band (V_D-3_feeChangeRange.t.sol 2, D_FeePaths.t.sol:471, A_StateMoney.t.sol:480, Transitions.t.sol:656); moves those cases from Kleros's jurors to yescrow's judge, against SPEC §2 rule 5.
Fix: D's form, tested on the fork
if (quoted && cost <= pot && cost <= _capFor(d.terms.price)) { // payArbitrationFee
function _capFor(uint256 price) internal view returns (uint256) { // also used by arbitrationCost
return price <= smallDealMaxPrice ? smallDealMaxArbitrationCost : maxArbitrationCost;
}
- Above the cap both courts go to the judge, paying as §8's refused-case rows say. The §8 band under the cap is unchanged; a rise past the cap goes to the judge; a quote back under the cap, or a cut, stays with Kleros. The sponsoring relayer gets its whole fee back when its party wins. Today's fees still give normal Kleros cases. No new gas-starvation route (
_quotestill requires 1M gas). Escrow 24,133 bytes (+40, 443 under EIP-170). - Docs: SPEC §7.1 ("the quote at the answer"), the §8 note ("at most 2A and at most the cap"), security.md:108-110, the comments at
:228-230and:533-538, the disputes guide page. The attack's band proofs become regression tests. - Documenting "above twice the cap" instead would mean changing ~9 places and adding a §8 row; the cap would no longer limit what a winner loses. Since mainnet isn't deployed and the fix costs 40 bytes, the code fix is better.
R3-D-2:
Verdict: CONFIRMED Medium
Evidence (file:line)
- The kit pays every round by the final ruling. In the real kit implementation 0x371A…19BC (published source DisputeKitClassic "0.12.0", deployed as DisputeKitClassicNeo), DisputeKitClassicBase.sol:449 reads finalRuling = core.currentRuling(id) for whichever round is being withdrawn. At :456 that choice's contributors share the round's pot (both stakes less the next jurors' fee, :402, :410). The other funded side gets 0, and both are reimbursed only if the final choice wasn't funded in that round (:461).
- The override sets that ruling: currentRuling overrides in the execution period when exactly one choice is funded (:518-521); the core forwards it unchanged (KlerosCoreNeo 0.10.0, KlerosCoreBase.sol:967, executeRuling at :894). So a one-sided last appeal sets the payee of every earlier round's pot.
- The escrow pays the jurors instead, via the last round's winningChoice: contracts/src/YescrowEscrow.sol:1079-1098.
- The deal page offers "Fund it" on v0.32 escrows: web/lib/appeal.ts:56-71 (canFund doesn't check oneSidedIgnored; the jurors' ruling stays fundable until periodEnd); web/components/deal/Dispute.tsx:176-196 (shown to any connected party, :108); service/src/escrow.ts:188 sets paysJurorsVote from maxArbitrationCost(); service/src/appeals.ts:360,367 turns that into oneSidedIgnored with the 2× / 3× amounts.
- Misleading texts: web/components/deal/steps.ts:250 (v0.32 branch) "you get this ETH back with a reward if they rule this way"; web/app/(site)/guides/disputes/page.tsx:89 "Whoever funds the side that wins a new round gets their ETH back with a reward"; service/src/alerts.ts:181 "nothing changes"; web/lib/appeal.ts:78-82 "the outcome doesn't change".
- The service stakes nothing on v0.32 escrows: appeals.ts:121 returns NOT_NEEDED first, and withdraw skips those rows (:249).
- Not documented as a limit: SPEC §7.2 (SPEC.md:277), §13.5 (:463), docs/audit.md:123 are silent on stakes.
Own test and its result
- The PoC re-run passes 4/4 with the claimed amounts: AI court seller −0.00378 ETH, buyer +0.00189 (the buyer's 0.81 ETH back whole); General Court −0.03 / +0.015; two defended rounds −0.1 / +0.05.
- contracts/test/pentest/verify/V3_D-2_PotsFollowOverride.t.sol passes 4/4:
1. The fork runs the published code: the proxies' EIP-1967 slots point at 0x371A…19BC and 0xC121…E54c, versions 0.12.0 and 0.10.0; each implementation's code hash equals Kleros's published deployedBytecode once the three __self immutable words are masked.
2. Symmetric: round 1's new jurors reverse the ruling and the escrow refunds the buyer; the seller then funds round 1 alone and takes the buyer's round-0 stake: buyer −0.00567, seller +0.00378.
3. Answering the one-sided appeal keeps the stake (General Court): another round, and in this run the seller nets +0.1 ETH.
4. Funding both sides yourself is safe: a later one-sided appeal by the other side leaves the funder at −a0, the new jurors' fee, as SPEC §7.2 claims.
- A Node check (scratchpad/pentest3/verify/V3_D-2_appealChoices.mjs) runs appealChoices on a v0.32 round-1 appeal: canFund: true for the jurors' ruling before and after the losers' deadline.
- Logs: scratchpad/pentest3/verify/V3_D-2_poc.log, V3_D-2_own.log.
What I checked
- The key files in the working copy are byte-identical to 71ad5a6.
- Kleros sources from the verified metadata (scratchpad/kleros/DisputeKitClassicNeo.json, KlerosCoreNeo.json); the older scratchpad/kleros/dk.sol is a newer upstream version, not what's deployed; its withdrawal logic is the same.
- The harness is faithful: DeployAll's mainnet config, real Kleros contracts, only the RNG and the votes stubbed.
- The loser is ahead in ETH: +a0, still ahead after its own arbitration fee (+0.00108 AI court, +0.01 General Court), while losing the price. Its trigger costs only gas. It must fund in the first half, so it locks 3× the next appeal cost for 18–36 h (AI court, 0.81 ETH) or 54–108 h (General Court, 0.105 ETH).
- Not new as a mechanism, new as a trap: under v0.31 and plain Kleros the same pots rule applies (the root cause the second run gave for R2-D-2, for yescrow's stake), but there the deal's payout flipped too, so the alerts (alerts.ts:205) and the keeper pushed the winner to answer. New in v0.32: the payout no longer moves with Kleros's ruling while the stakes still do, the keeper places nothing, and the interface says a one-sided appeal "changes nothing". The audit.md R2-D-4 entry is unrelated.
Reason for the severity: Medium kept. The loss is real, the trigger is free, and yescrow's own button and text lead a party into it; the loser's final step is the same transaction as a sincere "appeal again", and the alert then tells the other party not to answer. Not High: the stake is optional and never needed for the payout; the loss is capped at what the party chose to stake (≈ $9 AI court, $75–250 General Court); the price is always paid correctly; the stake can still be protected by answering. Not Low: the counterparty profits from the victim's ETH; matches R2-D-2 (Medium), the same mechanism hitting yescrow's stake.
Corrections to the attack report
1. alerts.ts:205 is the branch for older escrows and is accurate there. The v0.32 texts are :175 and :181, plus appeal.ts:78-82 and the Dispute.tsx:146 docstring ("A party can fund any ruling here").
2. "Can never help, only lose" goes too far: the promised reward does come if nobody funds the last round alone (e.g. the loser lacks 0.81 ETH), and the stake can be kept by answering (test 3). Accurate: it can be taken for free by whoever funds the last round alone, unless answered.
3. Not limited to the winner or the jurors' side: anyone who funded one side of a real appeal is exposed, including the party that wins the new round and gets the price (test 2). Removing the jurors'-side button alone doesn't close it.
4. The loser's profit is exactly a0, half the winner's stake, not "the new jurors' fee back with interest". The lock-up is at least half the appeal period.
Fix (no contract change; following Kleros would bring back R2-D-2)
- Deal page on v0.32 escrows (oneSidedIgnored): replace the per-side buttons with one "Ask for new jurors: fund both sides" action, priced at 3× + 2× the appeal cost, offered only while nobody has contributed and before the losers' deadline, funding the losing side first; say the funder gets back everything except the new jurors' fee (test 4). Never offer the jurors' side alone. If a single other-side button stays, warn: alone it changes nothing for the deal and only delays the payout; if the other side gets funded, the stake goes to whoever Kleros finally rules for, which a later one-sided appeal can set. Rewrite steps.ts:250 without the reward promise; appeal.ts:80 "the deal's payout" rather than "the outcome". From round 1 on, when one side is funded alone, tell anyone holding earlier-round stakes (Contribution events) to fund the jurors' side by the period's end to keep them; that starts another round, which also re-decides the deal.
- Alerts: alerts.ts:175/181 "the deal's payout doesn't change", plus the warning above from round 1 on.
- Guide: page.tsx:86-90 the real rule; keep "fund both" as the safe way.
- Docs: a known limit in SPEC §7.2 (:277), §13.5 (:463), docs/audit.md:123.
- Optional: the web has no way for parties to withdraw their stakes and the keeper skips v0.32 rows; show what each party can claim.
R3-D-3 and the Info items:
R3-D-3: a one-sided appeal is a free 18 h / 54 h delay
Verdict: CONFIRMED Low
Evidence
- Kleros, verified source (copies in scratchpad/pentest3/verify/V3D2_core, V3D2_src): KlerosCoreBase.passPeriod (:585-592) ends the appeal period early only if isAppealFunded says so; isAppealFunded (DisputeKitClassicBase :596-604) allows that only while no choice is funded and half the period has passed; the period's end is fixed (appealPeriod :947-956); in fundAppeal (:362-424) other choices can be funded only in the first half, the jurors' own choice until the end, and a choice counts only once fully paid.
- Escrow: YescrowEscrow.sol:623-635 (applyRuling reverts TooEarly before the execution period), :1079-1098 (pays the last round's vote).
- v0.31 too: git show e2b52ce:contracts/src/YescrowEscrow.sol :599-618 also waits for the execution period.
- Docs promise: SPEC.md:260 ("rule in about a day"), :261 ("5.5 to 19 days"), :306 ("a General Court round takes at most about 19 days"), :463; guides/disputes/page.tsx:73-74; service/src/quotes.ts:42 (the warning shown before signing).
- Already documented: SPEC.md:707 (§14.21) gives the full-length rule; docs/dependencies.md:168-173 gives the half-way end only "if nobody funds".
Tests: attack PoC re-run on the fork 2/2 (64,800 s and 194,400 s). Own contracts/test/pentest/verify/V3_D-3_DelayScope.t.sol 4/4, measured from the case's creation to the payout:
| Court | Nobody funds | One side funds |
|---|---|---|
| AI court | 18 h 10 min – 19 h 25 min | 36 h 10 min – 37 h 25 min |
| General Court | 5.5 – 19.0 days | 7.75 – 21.25 days |
The difference is exactly 64,800 s (AI court) and 194,400 s (General Court). Mainnet's observed AI-court range, 18.4–36.7 h, matches both ends.
What I checked
- Promised deadlines: none broken. The contract's deadlines (transfer, challenge, fee, 90-day stall, judge's 14 days) are untouched; payout signatures last a year past acceptBy (payouts.ts:35). The deal page's "Appeal period ends" countdown (Dispute.tsx:163) and the alerts (alerts.ts:175) already show the full period end. What breaks are descriptive figures: §7.1, §7.5 (the first round becomes 21.25 days, though four rounds still fit in 90 days), §13.5, the guide and quotes.ts:42. The 90-day stall is affected only at the edge of the attack's U-1.
- Before v0.32 the same delay was possible: in v0.31 the keeper's own stake made every case run its full period (run 2's I-2). Kleros's rule applies to every case it hears.
- Loser's cost: gas only (236,642 for fundAppeal + 112,885 for withdrawFeesAndRewards, ~7.9e12 wei at 0.02 gwei, ~$0.02); the 0.00567 ETH (AI) / 0.045 ETH (General) stake comes back whole.
- Limits: a stake 1 wei short holds nothing; the end never moves; a funded choice can't be funded twice; so once per round; a further delay needs a real appeal (the new jurors' fee).
Reason Low stands: griefing, bounded, inside every deadline yescrow shows or enforces, Kleros's own documented rule. Run 2 rated the full-length period on every v0.31 case Info. Not Info here because it is free, one-sided and roughly doubles the AI court's time to payout, while the SPEC states the short figures without the condition.
Corrections
1. Anyone can impose it, not only the loser, and more cheaply: funding the jurors' own side costs 2× appealCost (0.00378 ETH in the AI court) and stays fundable after the half until someone calls passPeriod (tested at half + 10 s). This includes a winner pressing "Fund it" on the deal page, which delays its own payout.
2. "Decided case" holds only when the jurors' side is the one funded; when the loser funds its side, anyone can still answer by funding the jurors' side before the end and force a real appeal (why no on-chain fix exists).
3. The deal page already shows the real end; what it lacks is that a case normally ends at the half.
Fix (docs and UI only): reword SPEC §7.1, §7.2, §7.5, §13.5, the guide, quotes.ts:42 and dependencies.md ("about 19 h if nobody funds an appeal, up to about 37 h if anyone does"; General Court about 21 days for the first round); show both ends on the deal page; warn on the jurors' "Fund it" button that funding delays the payout to the period's end.
I-1: two refusal thresholds in docs/security.md
Verdict: CONFIRMED Info. docs/security.md:91 ("a fee above the cap goes to the judge") contradicts :108-110 ("above 2A … the judge"); the code refuses only above both fees (YescrowEscrow.sol:546-551), the cap applies only at opening (:852-856). The same split in SPEC.md:256 vs :258, and :359, keys.md:49, audit.md:98,194; the guide (:109-110) and security.md:309 match the code. Info: the documentation half of R3-D-1 (rating it separately would count R3-D-1 twice). SPEC §7.1 contradicts itself the same way. Fix: one rule everywhere, matching R3-D-1's fix.
I-2: v0.32 cases outside the stranded-ruling handling
Verdict: CONFIRMED Info, now proven.
- Every round of a v0.32 case is recorded with reason NOT_NEEDED (appeals.ts:121) and the withdrawal step skips those rows (:246-251), so executed() (:284-299), the stranded mark, the ops alert (ops.ts:188) and the deal-page notice (Dispute.tsx:203-208) never run for them. The keeper tries executeRuling only while the deal is Disputed, and applyRuling closes the deal in the same tick (keeper.ts:174-185). audit.md:195 ("Keeper executes stranded rulings") and the appeals.ts:9-11 header overstate this for v0.32; keys.md:49 is accurate.
- Own script scripts/pentest/verify/V3_D-I2_stranded_v032.ts 5/5 (real Keeper and Appeals on Postgres 17, container yescrow-check-pg-vd3 removed afterwards; control: an older escrow with no yescrow stake). Resolver re-pointed: the older case is marked stranded, alerted, and executed once the resolver points back; the v0.32 case gets no mark, no alert, no notice, and is never executed in 12 hourly ticks, so the loser's 0.00567 ETH can't be withdrawn. No re-pointing, one failed executeRuling send: retried for the older escrow, never for the v0.32 one.
- Info: no deal or yescrow money; only stakes the parties chose to place, recoverable by anyone with a permissionless executeRuling once the resolver points back. But R2-D-4's keeper fix covers none of the mainnet (v0.32) escrows.
- Corrections: the trigger is wider — a re-pointing at any time before the case reaches execution, or a single failed send with no re-pointing.
- Fix: run executed() for NOT_NEEDED rows too (skip the relayer's withdrawal); correct audit.md:195 and the appeals.ts header; tell funders how to withdraw their stake.
I-3: the deal page doesn't say which ruling the escrow pays
Verdict: CONFIRMED Info.
- The deal page shows "Kleros's jurors ruled: …" in bold (Dispute.tsx:156), recorded before any override; the "escrow pays on the jurors' vote" notice appears only while the appeal period is open (:176); afterwards only "pays out once Kleros makes its ruling final" (:201). confirm and cancel stay open up to Disputed (YescrowEscrow.sol:500-511). Kleros's court UI (kleros-v2 master, FinalDecision.tsx) displays currentRuling[0], i.e. the override.
- Own test contracts/test/pentest/verify/V3_D-3_I3_RulingRecords.t.sol 2/2: at execution Kleros's currentRuling returns 2 (overridden); Ruling events: 2 from KlerosCore, 2 from the resolver, 1 from the escrow, which credits the seller 990 USDC; Kleros's record stays 2 for good; a seller who cancels in the execution period hands the buyer 1,000 USDC.
- With the keeper running, the window before payout is one or two 30-second ticks. Info: needs a winner to act against the deal page's own text within about a minute, or during a keeper outage.
- Corrections: the deal page does name the jurors' ruling in that window; the mismatch is permanent (Kleros's court page and events keep the override after the deal closes).
- Fix: "The escrow pays on the jurors' vote: X; Kleros's page shows Y because only one side funded an appeal" from the moment one side alone is funded, kept on the closed deal; drop "once Kleros makes its ruling final".
Area AB: settlements, signatures and money
AI pentest, third run, area AB: settlements, signatures and money (commit 71ad5a6, SPEC v0.32)
Saved by the orchestrator from the attack agent's final reply (the harness refused the agent's report file).
Findings (one line each)
- R3-AB-1 — A Kleros quote between the cap and twice the cap sends the case to Kleros, not to yescrow's judge as SPEC §7.1/§9,
docs/audit.mdanddocs/keys.mdpromise; the winner gets back2·cap − quoteinstead of its whole fee and the seller pays F — Low —cd contracts && forge test --match-path test/pentest/third/AB3_CapBand.t.sol --match-test test_R3AB1 -vv - R3-AB-2 —
settlementStagefalls back to the constant 2 when either core read fails andsettlelets stage 2 past its vote-period check, so a core that stops answeringgetNumberOfRoundsmakes every offer stage-less again (exercisable in the vote period and across rounds) — Low —cd contracts && forge test --match-path test/pentest/third/AB3_StageFallback.t.sol -vv
Proof files (new, nothing existing edited): contracts/test/pentest/third/AB3_CapBand.t.sol (4/4 on the Arbitrum One fork) and AB3_StageFallback.t.sol (3/3 on the unit harness). Run logs: scratchpad/pentest3/AB-logs/capband2.log, AB-logs/stage.log.
Working copy: an export of 71ad5a6; contracts/src/ untouched. Proofs: a fork of Arbitrum One at block 519,194,000 with the real KlerosCoreNeo, DisputeKitClassicNeo, the public DisputeResolver and real USDC, the escrow built exactly as DeployAll configures it for mainnet (only Kleros's RNG stubbed, drawn jurors' votes pranked, as in test/fork/SecondRunFixesFork.t.sol); and the unit harness with the mock core. No transaction was sent to a live network; the live numbers below are eth_calls (2026-11-01).
Result
Two Low findings, both reachable only through Kleros's governor, plus Info notes. No party can lose, steal, lock or misdirect money through settlements, signatures or the SPEC §8 money paths by its own action; the v0.32 changes hold on the real Kleros of a mainnet fork.
R3-AB-1. Quote in (cap, 2 × cap]: the case goes to Kleros, every document says the judge
(Same finding as R3-D-1, found independently.)
- Severity: Low. Only Kleros's governor sets the fee (the band is 12–24× today's AI-court fee, 10–20× the General Court's); the loss is bounded by one cap of ETH per deal (0.01 / 0.05 ETH) plus yescrow's 1% fee.
- Attacker and preconditions: Kleros's governor (or simply Kleros raising a court fee into the band). Any dispute opened while the quote is between the deployment's cap and twice the cap.
- What the documents say: SPEC §7.1 (line 256): "If the quote is above the cap, or can't be read, the dispute still opens, at the cap, and at the answer the case counts as refused, so yescrow's judge decides it." SPEC §9 (line 359): "above it the case goes to yescrow's judge".
docs/audit.md§4 and §6b: "above the deployment's cap, cases go to the judge".docs/keys.md: "since v0.32 a fee above the deployment's cap sends the case to yescrow's judge". - What the code does:
contracts/src/YescrowEscrow.sol:545-576:payArbitrationFeequotes again and creates the case wheneverquoted && cost <= potwithpot = 2 * d.arbitrationFee, whered.arbitrationFeeis the cap when the quote was above it (arbitrationCost, lines 852-856). For a quote C in (cap, 2·cap] the case is created for the whole C,heldFee = 2·cap − C, the deal is paid by Kleros's ruling with fee F, and the winner gets2·cap − Cback (2 wei at C ≈ 2·cap). The NatSpec ofmaxArbitrationCost(lines 228-230) describes this "more than both fees" rule, so the code agrees with its comment and not with the SPEC, the audit notes or the key sheet. - Steps (real Kleros, fork): the governor sets court 34's fee to 0.005 ETH a juror (a case 0.015 ETH; cap 0.01). The seller disputes a 1,000 USDC deal at 0.01 ETH, the buyer answers at 0.01 ETH:
DisputeCreated, stateDisputed, 0.005 ETH held, 0.015 ETH to Kleros. Three jurors rule for the seller;applyRulingreleases 990 USDC to the seller, 10 USDC to yescrow, 0.005 ETH back to the seller. On the path the SPEC promises (same deal, quote far above both fees →Judging→ judge rules for the seller): 1,000 USDC and the whole 0.01 ETH back. Logged:winner's ETH back, actual 5000000000000000 / SPEC path 10000000000000000; seller's USDC actual 990000000 / SPEC path 1000000000. Also at 2·cap (held 2 wei) and in the General Court (0.075 ETH fee: 0.025 ETH held of 0.05 paid). - Impact: against what every document promises, the winner loses C − cap of ETH (up to a whole cap) and, if it is the seller, F; yescrow loses the loser's fee it would keep on a refused case and earns F instead. Not the known issue in
docs/audit.md§5 (fee changing between the two payments): the quote never changed, it was above the cap from the start, and the documents name a different outcome for exactly that. - PoC:
cd contracts && forge test --match-path test/pentest/third/AB3_CapBand.t.sol --match-test test_R3AB1 -vv(3 tests, pass). - Root cause:
contracts/src/YescrowEscrow.sol:551(cost <= pot) versus SPEC §7.1's "above the cap". - Suggested fix: decide which is meant and align the other. If the judge is meant above the cap:
cost <= d.arbitrationFee(also closes the §5 known issue, at the price of sending a fee rise between the two payments to the judge). If the contract's rule is meant: correct SPEC §7.1/§9,docs/audit.md§4/§6b anddocs/keys.mdto "above twice the cap" and show parties that in the band the winner gets less than its fee back.
R3-AB-2. The stage-2 fallback drops the vote-period refusal and the period/round binding together
- Severity: Low. Needs Kleros's governor to upgrade the core so
getNumberOfRounds(ordisputes) no longer answers as before, which is within its trust; but the escrow holds the period word when it drops it, and what it drops is exactly the v0.31 (B-3) and v0.32 (R2-A-2) protection. - Attacker and preconditions: a party holding the counterparty's settlement signature, after a core upgrade changing
getNumberOfRounds's selector or return shape. The service reads the same view and would also build every offer at stage 2. - Mechanism:
settlementStage(YescrowEscrow.sol:834-842) returns 2 unless both reads succeed;settle(line 609) refuses onlystage > 7 && stage & 7 == PERIOD_VOTE. Stage 2 is then a constant for the life of every case: an offer signed at stage 2 in the evidence period stays exercisable through commit, vote, appeal and execution and across rounds, submittable in the vote period while the reveals are public (R2-A-2's free option), for as long as its owndeadlineallows (the API caps action deadlines at an hour, not settlement offers). - Steps (mock core): a real case;
getNumberOfRoundsreturns nothing whiledisputesstill reports period 0 → stage 2. Both sign a 50/50 split at stage 2 with a week's deadline. The case moves to its vote period (disputesreports 2).settlesucceeds:Settled, buyer 2,500 USDC, seller 2,475 USDC. Control: with the core readable the same offer is refused (SettleDuringVotesin the vote period,InvalidSignatureonce the period moved). A third test holds one offer across three rounds and every period. - Impact: a settlement offer is a free option again and outlives every period change, under a Kleros upgrade the escrow otherwise survives (
applyRulingreadsgetNumberOfRoundsonly for overridden rulings). - PoC:
cd contracts && forge test --match-path test/pentest/third/AB3_StageFallback.t.sol -vv(3 tests, pass). - Root cause:
contracts/src/YescrowEscrow.sol:841(ok && ok2 ? … : 2) and line 609. - Suggested fix: keep what was read:
ok ? (changed << 16) | (ok2 ? rounds << 3 : 0) | period : 2keeps the period binding and the vote refusal whenever the period is readable (still > 7 sincechangedis a timestamp); havesettlerefuse when the period word alone says vote; optionally refusesettleoutright at stage 2 for a Kleros case (concessions remain, the judge takes the case at 90 days).
Info (no proof needed)
- I-1 The settle summary (
service/src/typed-data.ts) says an appeal-period offer lapses "when the case moves to its next period (due …; a commit or vote period can end sooner…)"; a both-funded appeal ends the appeal period at once, which the text doesn't say. The offer then just lapses; no loss. - I-2
settlementStageof a deal with no dispute (Active, Proven, closed) returns Kleros dispute 0's stage, and the API will build settle typed data for it. Harmless:settleneeds a dispute state and the stage can never match the deal's own case; a view quirk worth aWrongState. - I-3 Live numbers for the "trap" question: General Court (1) hidden votes, 1 juror, periods 78 h / 162 h / 162 h / 108 h; AI court (34) hidden votes, 3 jurors, 600 s / 2,700 s / 1,800 s / 36 h, court jump at 7 jurors to court 33 (0.018 ETH a juror, General-Court periods). A General Court case has no settlement for up to 6.75 days per round once its vote period opens (until the one juror reveals); only concessions remain then. Nothing promised is broken.
Checked and found sound
- The vote refusal on the real court: refused after two of three reveals show the ruling, works in evidence and commit before and in appeal after; the stage moves at every period and round including two moves in one second (
SecondRunFixesForktest_A2_*/test_A1_*re-read; the agent's harness drives the same real case). - Encoding:
period≤ 4 fits 3 bits;roundswould need 2^13 funded appeals to reach the timestamp bits; a case always has a round so a Kleros stage is ≥ 8, never 0/1/2; a case's stages can't repeat (periods only advance within a round, rounds only grow). - Predictable but not pre-signable: both parties must sign the one current stage; the API reads it from the escrow (
escrow.ts:363-375); the web fetches typed data from the API and compares the link's stage before countersigning (Dispute.tsx:379-404); the review decodes period, round and start (appeal.ts,SignReview.tsx:50). A crafted link only shows "lapsed". - Gas: a starved read inside
settlewould need stage-2 signatures nobody has; with real-stage signatures a failed read mismatches the digest.applyRuling's 1M floor leaves each of its six reads ≥ 0.9M gas, so none can be failed selectively to turn a ruled case into a stalled one. - No trap past a promised deadline: in
Disputedthe only deadline is the 90-day stall, after whichJudging(stage 1) settles again; concessions work throughout. A Kleros pause can't extend the refusal: on the deployed corecastVoteandpassPeriodwork while paused, the vote period ends on time and the deal settles in the appeal period (test_sound_klerosPauseDoesNotHoldTheVotePeriodRefusal, pass on the fork). - Commit-period edges: both mainnet courts hide votes; a commit is a hash, counts say nothing about direction; reveals happen only in the vote period.
- Voiding an appeal-period settlement by funding both appeal sides costs the new jurors' fee (7 × 0.00027 ETH in court 34) and is the SPEC's documented "buy new jurors"; the parties re-sign in the new round; not new in v0.32.
- Execution period: a settlement races
applyRuling, which the keeper calls at once afterpassPeriod; both closes were agreed in advance; the real post-vote window is the appeal period. - SPEC §8, every exit re-read against
_close(lines 1128-1168) andjudgeRuling(648-653): fee timeout, settlement before/after the case, rulings 1/2/0, refused case judged (loser's fee to the fee recipient, winner's back, no F), refused case conceded/settled/timed out (both fees back whole), stalled case judged/timed out (no F, held fee as a ruling). ETH held = ETH owed in every branch. The fork tests confirm the Kleros-ruled rows with real fees. - Jurors'-vote rule money: the held fee follows the escrow's own winner, the one-sided funder's stake returns from Kleros whole, Kleros's juror rewards are Kleros's money; a tie pays 0 with or without an override; all-zero votes stay a split; the real ABI reads hold (offset word then
disputeKitID,disputeKits(1), the kit'swinningChoice/tied); court 34 → 33 → 1 all support kit 1 andappealfalls back to Classic anyway. - Fee caps: quote up to the cap else the cap, also when the quote reverts or returns nothing; the answerer pays the same stored fee; a quote above twice the cap or unreadable at the answer is a refusal with both fees held;
_quoteneeds 1M gas; today's fees (0.00081 / 0.005 ETH) are underNetworks's 0.01 / 0.05 ETH, whichDeployAllpasses through. The band (R3-AB-1) is the one gap. - Promo and referrer: vouchers only lower, only until
promoEnds, never inJudging; judge rulings/timeouts zero the fee; the referrer's share is a share of the rounded fee; invariants 7–9 hold. - Fee payers: refunds always go to the recorded payer including a sponsor; a third party front-running a sponsor with the same signature only changes who gets the refund.
- Rounding:
held/2andprice/2floor to the seller's side, remainder to the buyer's; dust fees floor to 0; nothing lost or double-credited. - Withdrawals:
withdraw/withdrawEthpay onlyaccount;withdrawToonly by the account or its one-use digest; same chain needs no ETH, cross-chain refunds excess ETH to the sender; noreceive(). - Typed data: typehashes and
abi.encodeorder match for all seven actions andSettle; the service builds the same field order; the web shows every field with phase/stage decoded. actionPhase: phase 2 groupsDisputed/Judging, where a concession only ever costs its signer and_closebranches onbothFeesHeld, so the same party is paid either way.- Across the two mainnet deployments: identical terms give the same
dealIdon both escrows, but every digest is underverifyingContract = address(this)andblock.chainid, EIP-3009 names the token andto = escrow, box salts include the escrow, the service keys its domain by escrow; testnet/mainnet differ by chainId even where addresses repeat. - No second use: actions and settlements close or move the deal,
WithdrawTodigests are marked, a voucher can't lower twice to the same fee,ReferrerSharehas a nonce; ECDSA first then EIP-1271; malleable/compact signatures rejected (OpenZeppelin). - Service: action deadlines ≤ 1 h from the chain clock, phase read from the escrow, settlement typed data refused in the vote period (409) and re-checked at relay time; the relayer simulates first, so a lapsed offer costs it nothing.
Unconfirmed
- Fee rises into the band in practice: whether a 10–20× court-fee rise is a realistic Kleros governance outcome can't be judged from the chain; R3-AB-1's Low assumes it is unlikely.
- An appeal-period offer and the appeal clock (I-1): the offer simply lapses when both sides fund; no way found to turn that into a loss.
Verifications for area AB
R3-AB-1 is verified with R3-D-1 above.
R3-AB-2 and the Info items:
Verdict: DOWNGRADED to Info
The proof of concept passes and shows the claimed impact, so the finding stands. Its precondition is a Kleros upgrade that would also break Kleros itself, and it is the same residual the second run already rated Info.
Evidence (file:line)
- The fallback: contracts/src/YescrowEscrow.sol:839-841 (settlementStage returns 2 unless both reads succeed); :609 (the vote check stage > 7 && lets stage 2 through); :1031-1052 (_read leaves a failed read's words at 0); :1087 (applyRuling reads getNumberOfRounds only for overridden rulings); :574-580 (a reverting case creation counts as a refusal and goes to the judge).
- Already known in the tests: contracts/test/Hardening.t.sol:312-317 asserts stage 2 when either disputes or getNumberOfRounds fails; Hardening.t.sol:362-370 (test_settle_courtGettersBroken_stage2): "a settlement signed for it works" — a deliberate, tested liveness choice; contracts/test/pentest/second/A_PhaseStage.t.sol:301-315: "Residual of B-3 (Info, Kleros's governor only)".
- Not documented elsewhere: absent from docs/audit.md §5; SPEC.md:276 states the vote-period refusal with no exception; docs/audit.md:87 (§3) doesn't carve out stage 2.
- Kleros depends on the same getter: the deployed DisputeKitClassicNeo implementation 0x371aa4b1…19bc contains the getNumberOfRounds selector (0xfc6f8f16) three times; kleros-v2 master DisputeKitClassicBase.sol:212 (createDispute) and :383 (fundAppeal) call it; Kleros 2.0.0 (dev branch, fetched 2026-11-01) keeps the getter (KlerosCore.sol:1285), the Dispute field order (:52-60) and the Round layout (:62-77), and its kit still calls the getter (DisputeKitClassic.sol:366, 431, 690).
- Service and web: service/src/escrow.ts:363-374 passes on the escrow's 2; api/app.ts:375-380 runs its own vote-period check by reading disputes (escrow.ts:382-391); app.ts:929-943 hands out settle typed data in any other period, with no deadline cap (the 1-hour cap at :946 is for actions only); :1242-1248 re-checks only the vote period at relay time. service/src/typed-data.ts:119-125, web/components/deal/steps.ts:220, 297-304 and web/components/deal/Dispute.tsx:414-421 all tell the signer a stage-2 offer lapses when the case moves on — false; only the review row (web/lib/appeal.ts:36-40) says "whose period Kleros didn't report".
Own tests and results
- The attack's PoC on 71ad5a6: 3/3 PASS.
- contracts/test/pentest/verify/V3_AB-2_StageFallback.t.sol: 8/8 PASS (mock core): a commit-period offer with the web's 24-hour deadline settles in the vote period while the jurors favour the seller (seller 1,237.5 USDC instead of 4,950); offers signed before the core broke stay dead, stage-2 offers die once it is repaired; with rounds unreadable a plain ruling still applies, an overridden ruling waits for the 90-day stall then the judge rules without a fee; with disputes unreadable no ruling is applied and the stage stays 2 until the stall; fuzz and corner cases of the suggested formula; Kleros 2.0.0's layout keeps every word the escrow reads.
- contracts/test/pentest/verify/V3_AB-2_RealKlerosFork.t.sol (--match-test test_V3AB2_fork): PASS on an Arbitrum One fork at block 519,194,000 with the real KlerosCoreNeo: the core is made to stop answering getNumberOfRounds; the stage falls to 2 and an evidence-period offer settles in the vote period after two public reveals (seller 247.5 USDC, not 990); the same break makes Kleros's own fundAppeal revert, and a new dispute is refused and goes to the judge; with the core answering again, the same appeal funding goes through.
- A patched scratch copy (scratchpad/pentest3/vAB2-fix/contracts) with test/pentest/verify/V3_AB-2_FixRegression.t.sol (results under Fix).
What I checked
- A party can't cause the fallback: gas starvation can't produce stage 2 (the second run's test still passes under every variant).
- The service would hand out stage-2 typed data outside the vote period; if disputes itself is unreadable, its settle routes throw and hand out nothing.
- What else breaks when core getters change: disputes or currentRuling unreadable → no ruling applied → 90-day stall → judge 14 days → judgeTimeout 50/50; the overridden-ruling reads unreadable → those cases stall the same way; the fee quote unreadable → opens at the cap and counts as refused. Nothing locks money.
- I-3's live court settings, read by eth_call.
Reason for the downgrade
1. The precondition isn't realistic as an accident: a core that stops answering getNumberOfRounds while disputes still answers would also stop Kleros's own case creation and appeal funding (the fork test). Kleros 2.0.0 keeps every getter and word the escrow reads.
2. Only Kleros's governor could cause it, deliberately; that governor can already upgrade the core and decide disputed deals outright (docs/keys.md:49, docs/security.md:91).
3. Only offers signed while the core is broken are exposed; each is a split the victim signed itself.
4. The second run rated the same residual Info (consistent with its A-1, Low → Info for negligible likelihood). The new parts (the v0.32 vote-period refusal also skipped at stage 2; the period thrown away when only the round count fails) don't change the impact.
Corrections to the report
- "The escrow otherwise survives": true, but Kleros itself wouldn't: new cases refused, appeals unfundable, so no override could happen; exposure is limited to cases already open.
- "The service would build every offer at stage 2": only outside the vote period; it still refuses to build or relay a settlement in the vote period (anyone can submit on-chain). What is wrong is its text, which promises the offer lapses.
- The PoC's week-long deadline isn't what the product signs (the web uses 24 hours), but the API doesn't cap settlement deadlines.
- The stage-2 behaviour is known in three tests but not documented in docs/audit.md §5 or SPEC §7.2.
- "Refuse when the period alone says vote" needs no extra code: with the formula, the existing check at :609 already covers it.
Fix
- The attack's formula is correct: the stage stays above 7 whenever lastPeriodChange isn't 0; a stage with rounds unreadable never equals one with rounds readable; the only repeat is a whole round inside one second while rounds are unreadable (A-1's hole, negligible). Size 24,092 bytes (1 smaller). Closes the PoC.
- A tidier equivalent (recommended), since _read already zeroes a failed word: (, uint256 rounds,) = _read(...); return ok && changed != 0 ? (changed << 16) | (rounds << 3) | period : 2;. The changed != 0 guard stops a changed core that reports 0 from producing a stage of 0, 1 or 2. Size 24,094 bytes (482 under). The fix-regression test for the rounds-unreadable case passes under both.
- Optionally refuse settle at stage 2: drop stage > 7 && at :609 (2 & 7 equals the vote period; stages 0 and 1 unaffected). Size 24,078 bytes (498 under). Doesn't trap anyone (concessions still work; the 90-day stall moves the deal to the judge, where settling works again; fix-regression 2/2 PASS). But it reverses the tested liveness choice (Hardening.t.sol:362-370) and SPEC.md:274's "at any point before the ruling… both can sign a settlement" — the user's call.
- Tests to update: any variant Hardening.t.sol:315-316; with the stage-2 refusal also Hardening.t.sol:362-370 and A_PhaseStage.t.sol:301-315; the attack's PoC becomes a regression test with inverted expectations.
- Off-chain, whatever the contract does: the API refuses settlements at stage 2 or labels them "doesn't lapse"; the texts in typed-data.ts, steps.ts, Dispute.tsx stop promising a lapse at stage 2; add the stage-2 behaviour to docs/audit.md §5 and SPEC §7.2.
Info items
- I-1 — CONFIRMED Info: typed-data.ts:119 adds "a commit or vote period can end sooner" in every period; in the appeal period it doesn't say that both sides funding an appeal ends the period at once (steps.ts:302 does). Text only.
- I-2 — CONFIRMED Info: for a deal never disputed, disputeId is 0, so settlementStage returns Kleros case 0's stage, and app.ts:929-943 builds settle typed data for it; settle refuses it with WrongState (YescrowEscrow.sol:602). A state check in the API is cheaper than changing the view.
- I-3 — CONFIRMED Info: live eth_call at block 520,270,127. Court 1: 280,800 / 583,200 / 583,200 / 388,800 s, hidden votes, 0.005 ETH a juror. Court 34: 600 / 2,700 / 1,800 / 129,600 s, hidden votes, 0.00027 ETH a juror, jumping at 7 jurors to court 33 (0.018 ETH a juror, court 1's periods). Up to 162 hours a round with no settlement in court 1 is SPEC §7.2's stated rule; concessions remain.
Area C: proofs
AI pentest, third run, area C: proofs as changed in v0.32
Saved by the orchestrator from the attack agent's final reply (the harness refused the agent's report file).
Findings, one line each
- R3-C-1 — IntraRegistrar nameserver lists the API accepts (2–13 names, up to 253 chars) can never be hashed at Google Registry's and Nominet's layouts once 13 names pass about 73 characters (over the 8 KB capture cap), so such a deal can't be proven although it matches the terms — Info —
cd contracts && forge test --match-path 'test/pentest/third/C3_MarginEdges.t.sol' --match-test test_C3_googleLayout -vv - R3-C-2 — Stale interface comment:
RdapFacts.fetchedAtis documented as "TLS session time from the attestation" (contracts/src/interfaces/IRdapVerifier.sol:11); it is the registry's own timestamp since SPEC v0.6 — Info — doc only. - R3-C-3 — The second run's documented verification command for R2-C-1 no longer compiles under solc 0.8.38 in forge's sparse mode ("Variable _514 is 1 too deep in the stack"), while the full build (218 files) and the same tests via
--match-contractcompile and pass 6/6 — Info — fails:forge test --match-path 'test/pentest/verify/V2_C-1_marginAndHistory.t.sol' --match-test 'test_V2|testFuzz_V2'; passes:forge test --match-contract V2_C1_MarginAndHistoryTest --match-test 'test_V2|testFuzz_V2'
No Critical, High, Medium or Low finding. The v0.32 combination is sound against every escrow check (proof below).
Commit 71ad5a6 (SPEC v0.32). Nothing under contracts/src/, contracts/script/, prover/src/ or attestor/ was changed; no transaction was sent to any network; every forge/node job ran under the shared lock in a 2.5 GB scope.
Files added:
- contracts/test/pentest/third/C3_PairImpliesHalves.t.sol — 4 tests (2 fuzz × 256 runs)
- contracts/test/pentest/third/C3_MarginEdges.t.sol — 7 tests (2 fuzz × 256 runs)
- No new script under scripts/pentest/third/: the second run's engine harness was re-run unchanged (the template is unchanged in v0.32). Logs: scratchpad/pentest3/C3-logs/.
Commands (all pass, from contracts/ unless noted):
forge test --match-path 'test/pentest/third/C3_*.t.sol' -vv # 11/11
forge test --match-path 'test/pentest/second/C_*.t.sol' # 12/12 (R2-C-1 regression)
forge test --match-contract V2_C1_MarginAndHistoryTest --match-test 'test_V2|testFuzz_V2' # 6/6
forge test --match-path 'test/pentest/C_InterProofBlocking.t.sol' # 8/8 (first run C-1/C-2)
forge test --match-path 'test/pentest/verify/V_C-2_controlAndFix.t.sol' # 13/13
forge test --match-path 'test/*Rdap*.t.sol' # 48/48 repository verifier suites
forge test --match-path 'test/SecondRunFixes.t.sol' # 13/13
forge test --match-path 'test/pentest/second/E_RouterPinned.t.sol' # 2/2 (router still at Networks.ROUTER)
forge build # all 218 files under 0.8.38
cd ../prover && node --test test/*.test.ts # 16/16
cd .. && node scripts/pentest/second/C_engines.ts && node scripts/pentest/second/C_engines.ts --live
Result
The property SPEC §14.18 promises — "each check still needs both responses and one attestor can only add rejections" — holds exactly for the v0.32 rule, stated and fuzzed as: the escrow accepts the pair iff it would accept each half on its own. Checked through three escrows with identical terms (the dual verifier; the Reclaim-slot verifier alone; the yescrow-slot verifier alone), for InterRegistrar and IntraRegistrar deals, over fetch times before/at/after activation, expirations from already-expired to 100 days out, blocked and clean statuses, missing/matching/other nameserver lists and "last changed" on both sides of activation. So a compromised attestor, a lagging cache or a holder changing the record between the two sessions can only add rejections. The prover's combineFacts mirrors the rule. Template, attestor patch and parsers are unchanged and all earlier proofs pass; solc 0.8.38 has an empty known-bug list and every real fixture verifies under it.
Findings
R3-C-1 (Info). Nameserver lists the API accepts can be unprovable at the registries whose host objects repeat the name
- Attacker/preconditions: none hostile; a buyer picks 13 long nameservers (the API allows 2–13 of ≤253 chars,
service/src/api/schemas.ts:21) for an IntraRegistrar deal on.app/.dev(Google) or.now/.bot(Nominet). The seller sees and accepts the list. - Mechanism:
RdapVerifier._facts(line 148) hashes no capture over 8,192 escaped bytes, and a zero hash fails check 6. The capture is the registry's wholenameserversarray: at Verisign a host is ~78 escaped bytes, but at Google each host object carries a handle, a self link repeating the name and a fixed remark (527 B/host in thegoogle.appfixture, 21-char names) and at Nominet links, events, status, handle and unicodeName (506 B/host, 19-char names). Measured on the Google layout: 13 × 25-char names = 6,956 B (hashed); 13 × 253-char names = 12,884 B (no hash); the cap is crossed at 73-character names. - Impact: the seller who set exactly those names can't get an automatic release and must dispute before the transfer deadline (SPEC §6.5); no money lost. The pre-flight check (
service/src/preflight.ts) doesn't cover it. - PoC:
forge test --match-path 'test/pentest/third/C3_MarginEdges.t.sol' --match-test test_C3_googleLayout -vv - Root cause: the byte cap (
RdapVerifier.sol:54,148) is measured on the registry's layout, which the terms don't see. - Fix: in the pre-flight for IntraRegistrar deals, estimate the escaped capture from the registry's live layout (
service/src/rdap.tsalready fetches it) and refuse or warn above 8 KB; or tighten the schema for those TLDs. No contract change.
R3-C-2 (Info). Stale comment on RdapFacts.fetchedAt
contracts/src/interfaces/IRdapVerifier.sol:11 says "TLS session time from the attestation"; RdapVerifier.sol:168 and SPEC §6.2 define it as the registry's last update of RDAP database. Fix the comment (an auditor reading the interface first gets the wrong semantics).
R3-C-3 (Info). The documented V2_C-1 command fails to compile under 0.8.38 in sparse mode
forge test --match-path 'test/pentest/verify/V2_C-1_marginAndHistory.t.sol' --match-test 'test_V2|testFuzz_V2'(the command inreports/AI pentest, second run (2026-10-31).md) stops withCompiler run failed: Variable _514 is 1 too deep in the stack [ JUNK _514 size_35 … ], whileforge build(all files, 452 s) andforge test --match-contract V2_C1_MarginAndHistoryTest --match-test 'test_V2|testFuzz_V2'compile and pass 6/6. A forge sparse-compilation artifact under the new compiler, no contract impact; update the documented command or split that test file.
Checked and found sound
- The rule (
DualRdapVerifier.sol:40-48).fetchedAt = min;expiration = min(fetchedAt) + min(margin_a, margin_b), margins clamped at 0;lastChanged = min; hash zero unless equal; flags OR'd; domain and registrar equal. Check 4 on the pair is exactlymin(margin) ≥ 30 days, i.e. check 4 on each half. Fuzzed in both orders over 1970–9999: order-independent, every fact obeys its rule, no overflow (testFuzz_C3_combination_rulesAndOrderIndependence,test_C3_year9999_noOverflow). - Pair vs halves through the escrow, both modes, 256 runs each; each half's verdict equals SPEC §6.3/§6.4 computed from its facts (
testFuzz_C3_inter_…,testFuzz_C3_intra_…); a half without "last changed" fails any IntraRegistrar pair (test_C3_intra_missingLastChanged…). - The one asymmetry is harmless: a half fetched after
block.timestampfails alone (check 3's upper bound) but a pair passes on the other half'sfetchedAt; the future half still feeds the minimum margin and the OR, so it can't lengthen anything (test_C3_knownAsymmetry_futureHalf: an honest 20-day record still fails beside a future 40-day half). - One compromised attestor: covered by 1–3 in either slot; the second run's attack and its copy variant are rejected (12/12, 6/6); an expired forged half fails even against a ten-year honest margin (
test_C3_expiredHalf_zeroMargin_eitherOrder). - Lagging cache: cached+fresh halves give
FactsDiffer, a zero hash / older "last changed", or the smaller margin — delays of at most the cache age, never a false fact (test_V2_C1_staleCache…still passes). - Holder changing the record between sessions: registrar →
FactsDiffer(retry); nameservers → zero hash (Inter ignores it; Intra sets them before the push); renewal or grace-period deletion → smaller margin; status → either session's blocked status rejects (the attestor refused anyway); "last changed" → the earlier. All true rejections or retries. combineFacts(prover/src/facts.ts:57-71) applies the same four rules; prover tests 16/16.- v0.31 rules (C-1, C-2): first run 8/8, second run 12/12; the 17th name gives no hash.
- RE2
sgiuvs RegExpsgi: crafted cases 1–11 give the second run's verdicts (differences refuse only:ſfolding, Unicode\s); live, all 8 registry hosts sign with byte-identical captures (2026-11-01). - Full reveal / attestor patch:
FULLY_REVEALEDis in the signed, verifier-rebuilt parameters; the three hunks (client ≥ 3.2.0, direct reveal of every server record, complete HTTP message) are unchanged;assertNoSmugglepins method, exact path (no hidden byte possible without a template placeholder), Host andConnection: close. - JSON parsing: exact context shape, canonical key order, escape handling; nothing holder-controlled reaches a capture except LDH nameserver names (
_stringAfterrejects escapes); holder text can only trigger the inverted regexes (known V5 class). - >16 names and gas (0.8.38 build): 16 × 253-char names as a dual proof: 15,027,205 gas, 10,624 B calldata (inside Arbitrum One's 32M); 17 names: no hash, 15,361,040 gas. Nothing else in a response is holder-inflatable.
- Registry URLs: IANA bootstrap (publication 2026-10-28T22:00:04Z, fetched 2026-11-01) lists exactly the verifier's base for all 18 TLDs it has; the 7 absent ones are as SPEC §6.1 states.
fetchedAtfreshness: on the 26 fixtures it lags the claim time 0–19 s (Verisign stamps whole minutes), never leads it.- Service side:
proveForuses the deal verifier's ownparametersFor; the prover refuses any other URL;service/src/deals.ts:83hashes nameservers exactly as_nameserversHash. - solc 0.8.38:
bugs_by_version.jsonhas no entry for 0.8.38 (eight for 0.8.30); all 218 files build; every real fixture verifies; the router still lands atNetworks.ROUTER. - Second run's U5 resolved (sound): upstream
process-handshake.ts(fetched 2026-11-01) callsverifyCertificateChain(certificates, hostname)with the ClientHello SNI (line 237, refusing a hello without SNI), andassert-valid-claim-request.ts:98requires that hostname to equal the tunnel host the attestor itself connected to.
Unconfirmed
- Arbitrum One's L1 calldata component for the 10,624-byte worst case is a cost outside the 32M computation limit, not measured on a fork (ArbOS pricing isn't modelled).
- The second run's U1–U4 stand unchanged; nothing in v0.32 touches them.
Verifications for area C
Verdicts: R3-C-1 UPGRADED to Medium; R3-C-2 CONFIRMED Info; R3-C-3 CONFIRMED Info (cause misidentified by the attack). The soundness claim holds; it is exact only on submitProof, and on accept it holds in one direction.
R3-C-1 — Verdict: UPGRADED to Medium
Evidence
- RdapVerifier.sol:54 sets the 8,192-byte cap; line 148 skips any capture over it, so line 170 returns no hash.
- YescrowEscrow.sol:475 rejects the proof on check 6. Line 887 requires IntraRegistrar terms to carry a nonzero hash. dispute (518–520) only works before the transfer deadline, and refund (493) only after it.
- service/src/api/schemas.ts:21 and deals.ts:79-90, plus the wizard's identical pattern, accept any 2–13 valid names of up to 253 characters.
- The buyer types the list in BuyFromLink.tsx:189. Drafts (api/app.ts:628, termsJson) and the on-chain terms keep only the hash.
- The seller's to-do (deal-state.ts:198-199) says "set the nameservers, then push… checked automatically".
- The keeper fails silently every 10 minutes (keeper.ts:312-315), and a manual submit shows "nameservers are not the buyer's yet" (errors.ts:123), which is false here.
- SPEC §6.2 says thirteen of the longest names "cost about 6 million gas"; only true for Verisign's layout.
Own test: contracts/test/pentest/verify/V3_C_1_LayoutTrap.t.sol, 8/8 pass. It rebuilds each list from the registry's own signed capture bytes; with the real names, all 26 fixtures come back byte for byte. Name length needed at 13 hosts:
| Registry | Length at 13 hosts | Fewest hosts that suffice |
|---|---|---|
| Verisign (.com/.net/.cc) | never | — |
| Google (.app/.dev) | 73 | 9 hosts of 213 |
| Nominet (.bot / .now) | 45 / 51 | 6 hosts of 228 / 234 |
| PIR (.org) | 121 | 10 hosts |
| CentralNic | 145–147 | 10 hosts |
| .CO | 151 | 10 hosts |
| Identity Digital | 199 | 12 hosts of 225 |
- At each threshold the real dual verifier hashes the list one character shorter and returns zero at the threshold (checked for 9 fixtures).
- Escrow run (google.app, 13 × 73 names, 8,204 escaped bytes): the proof is rejected (check 6) at +5 h and at the last second of the 5-day window; with 72-character names the same record is proven. A seller who misses the deadline: the buyer gets all 5,000 USDC back and keeps the pushed domain; the seller gets 0. A seller who disputes in time, fee paid up front: if the buyer stays silent, the seller gets 4,950 USDC plus the fee back after the 3-day fee period.
scratchpad/v3c/node/api_check.mjsruns these names through the service's ownnameserversHash: accepted, hash matches the test's (0xc72c58c1…a15f). The wizard accepts them too.- The attack's PoC passes again (6,956 B / 12,884 B / 73); the C3 tests pass 11/11.
Reason for the upgrade: the buyer sets the trap at no cost; a seller who sets exactly the hashed names can never get an automatic release; the UI tells them to push before any proof, then gives no explanation when proofs fail. The seller's outcomes: a forced dispute with the fee paid up front and payment delayed; losing the domain and the price if they miss the deadline; or a dead deal, where yescrow also loses its fee. That is "griefing / delays past promised deadlines", V3's (Medium) threat coming back for IntraRegistrar deals, matching the first-run C-1 verifier's rating for a reachable block of this kind. Not High: a seller who disputes in time recovers, and the evidence is clear because the record matches the hashed list.
Corrections - Not 4 TLDs: 22 of 25 (all but Verisign's). - Nominet repeats each name 4 times per host: threshold 45–51 characters, not ~73. - "The seller sees and accepts the list" is wrong for buyer-proposed deals: no seller page shows the list; only the hash survives drafting. - Related, to triage separately: because the seller can't see the list, a buyer could also give them names that don't match the hash.
Fix (no contract change; raising the cap would reopen V3's gas bound)
- In the service and wizard, refuse IntraRegistrar lists whose capture would exceed 8 KB, estimated from the live record's host layout (rdap.ts already fetches it).
- Store the plain list with the draft, show it to the seller, and check it against the terms hash.
- Tell the seller to wait for the proof before pushing.
- Have the keeper alert the seller to dispute when the live names match the terms but the proof has no hash.
- Correct the SPEC §6.2 sentence.
- Unconfirmed: whether common registrars' panels accept 6–13 such names.
R3-C-2 — Verdict: CONFIRMED Info
IRdapVerifier.sol:11 says "TLS session time from the attestation"; RdapVerifier.sol:168, SPEC §6.2 (lines 175, 186) and docs/decisions.md:20 say it is the registry's "last update of RDAP database" timestamp. The comment dates from the first escrow commit, f95be07 (2026-10-17); SPEC v0.6 changed the meaning on 2026-10-18 and the comment was never updated; the only stale copy. Fix: reword the comment.
R3-C-3 — Verdict: CONFIRMED Info, with corrections
- The documented
--match-pathcommand fails, as claimed, in 83 s with "Variable _514 is 1 too deep in the stack … memoryguard was present." - Correction 1: the
--match-contractform also failed in the verifier's copy, with the identical error on the same 64 + 45 files; not a workaround (the attack's 6/6 must have run from an already-compiled cache). - Correction 2: the trigger is not sparse mode (this config has
sparse_mode = false); it is forge 1.8.5's dynamic test linking, on by default, which rewrites tests'new X(...)calls. WithFOUNDRY_DYNAMIC_TEST_LINKING=falsethe documented command passes 6/6 in 22 s. - Shipped contracts — no hint of a problem: the rewrite never touches
src/; the deployed bytecode of the escrow (24,093 B), RdapVerifier, DualRdapVerifier and DepositRouter (both profiles) is byte-identical across three compile jobs (the full build's 142-file via-IR job and two 64-file test jobs); every test job that deploys them compiles. - Fix: document the env prefix, or set
dynamic_test_linking = falsefor the pentest runs.
Soundness spot check
The claim holds. The rule (DualRdapVerifier.sol:40-48) against _checkCommon (YescrowEscrow.sol:891-896), accept (441-446) and submitProof (472-477): domain and registrar must be equal (else FactsDiffer); check 3's lower bound on the earlier fetchedAt holds only if both halves pass it, its upper bound is weaker (the known future-half asymmetry); check 4 reads exactly the smaller margin (clamped at 0, no overflow); check 5 combines both statuses; check 6 is equality-or-zero against a nonzero terms hash; check 7 takes the earlier date.
Counterexample by reasoning: on accept, check 2 is an inequality. If the domain moves between two non-buyer registrars between the two sessions, each half alone is accepted but the pair reverts. So the "iff" is exact only for submitProof; on accept it holds as "pair ⇒ each half (except a future fetch)", the direction that matters; the other direction only adds a rejection and a retry. V3_C_2_PairAcceptPath.t.sol shows this case and fuzzes the accept path (2/2, 256 runs): each half's verdict matches SPEC §6.3; if the pair passes, the earlier-fetched half passes on its own; two passing halves with the same registrar always make a passing pair. The attack's C3_PairImpliesHalves passes 4/4 again. SPEC §14.18's promise holds.
Tests: cd contracts && forge test --match-path 'test/pentest/verify/V3_C_*.t.sol' -vv (10/10). Logs: scratchpad/v3c/logs/.
Area EG: deposit router, keeper and off-chain parts
AI pentest, third run — Area EG (deposit router, keeper, off-chain), commit 71ad5a6 (SPEC v0.32)
Saved by the orchestrator from the attack agent's final reply (the agent did not write report files).
Summary line per finding
- R3-EG-1 — Keeper's automatic cross-chain USDT payout pays USDT0's LayerZero fee outside every budget — Low —
cd scripts/pentest/third && node EG3_payout_lzfee_unbudgeted.ts - R3-EG-2 — Keeper completes an inbound CCTP transfer to a sanctioned buyer's deal box (inbound side unscreened) — Low —
cd scripts/pentest/third && node EG3_inbound_completion_unscreened.ts
No Critical/High/Medium. The v0.32 budget machinery, the anti-starvation ordering, the USDT Forwarded↔Transfer match, LayerZero delivery matching, and the returnStray transaction/relay all held up (details below).
Scope as assigned: the keeper's budgets for every stranger-triggerable send (service/src/budgets.ts, keeper.ts, the separate tickTransfers loop, KEEPER_*_DAILY_WEI, KEEPER_SHARE_BPS, KEEPER_SENDS_PER_TICK, migration 015); held rows/starvation; USDT Forwarded↔box Transfer; LayerZero matching; returnStray tx/relay (E-2 rule); dispute.kleros; the relayer's other caps and sponsorship; sanctions screening; the web "wrong network" and signing flows; the router (unchanged, 0x8f5d…1a52). Proofs drive the real Keeper class over stand-ins, as the accepted first/second-run scripts do (no chain, no DB). contracts/src unchanged; no live transactions.
R3-EG-1 (Low) — The keeper's automatic cross-chain USDT payout pays USDT0's LayerZero fee outside every budget
Severity: Low. Loss of yescrow's ETH, ~1.9e-5 ETH (one LayerZero fee) per automatic cross-chain USDT payout, uncapped by any daily budget, but bounded by real deals closing in a party's favour (one payout per closing deal), so not amplifiable. Only the hourly RELAYER_SPEND_ALERT_WEI ops alert would notice, and only at an implausible rate (~530 payouts/hour).
Attacker and preconditions: any party (buyer or seller) of a USDT-escrow deal who pre-signs an automatic WithdrawTo (SPEC §14.14) to another chain and lets the deal close in its favour (seller: Released; buyer: Refunded/Cancelled). Mainnet has a USDT escrow with DEPOSIT_CHAINS set.
Steps:
1. Sign an automatic payout (POST /v1/deals/{id}/payout) for your own address on Ethereum (chainId=1).
2. The deal closes in your favour. Keeper.sendPayouts submits your WithdrawTo with value = 1.2 × USDT0's quoteSend and this.relayer.call('withdrawTo', …) directly — no sendWithin, no relayer_spend row, no lz-fee/keeper budget.
3. Repeat across your own deals; yescrow pays a LayerZero fee each time with no daily ceiling.
Impact: yescrow's relayer ETH, uncapped. Contradicts SPEC §12 ("Since v0.31 everything it pays for has a cap … relayed withdrawals to another chain … draw on global daily budgets") and docs/keys.md §1/§4 ("USDT0's LayerZero fee when it … relays a USDT withdrawTo"; "everything it pays for has a cap"). The relay withdrawTo (API) is metered by the lz-fee budget; the keeper's automatic one is the same kind of relayed USDT withdrawal but is not.
PoC: cd scripts/pentest/third && node EG3_payout_lzfee_unbudgeted.ts
Output: one withdrawTo carrying 0.00001885 ETH of LayerZero fee, with no relayer_spend SQL read or written (the DB stand-in throws on any budget SQL, and the run passes).
Root cause: service/src/keeper.ts:280-285 — sendPayouts computes value from usdtFee(...) and calls this.relayer.call('withdrawTo', …, { to: e.address, value }) with no budget. Contrast service/src/api/app.ts:1374-1375, where the relay path wraps the identical call in sendWithin({ …, budget: 'lz-fee', limitWei: cfg.lzFeeDailyWei }).
Suggested fix (service only): route sendPayouts' cross-chain withdrawTo through sendWithin with the lz-fee budget (and/or a complete:-style keeper budget), keyed to the payout's account as its share; when the budget is used up, leave the payout waiting (the party withdraws by hand) rather than paying unmetered. Or state in SPEC §12/keys.md that automatic payouts' LayerZero fees are outside the budget and are governed only by the spend alert.
R3-EG-2 (Low) — The keeper completes an inbound CCTP transfer that funds a sanctioned buyer's deal box (inbound side unscreened)
Severity: Low. A sanctions-screening gap, not a fund loss: the money lands in the deal's deposit box on Arbitrum; propose/accept stay screened (SPEC §12), so the listed party cannot get a deal done, and only returnDeposit → the listed buyer's own balance recovers it. The concern is that yescrow's own keeper takes an on-chain action (pays gas for receiveMessage) for a listed party, which SPEC §12 and the v0.31 G-2 fix say it will not.
Attacker and preconditions: a deal whose buyer is on OFAC's SDN list (or the list is unloaded — treated as listed), funding from Base/Ethereum. Sanctions screening on. The buyer, or anyone, forwards the deposit (the router is permissionless), so the keeper never has to forward it itself.
Steps:
1. A listed buyer creates a draft and sends USDC (≥ FORWARD_MIN) to the deal box on Base/Ethereum.
2. The keeper's forwardFromDepositChains refuses to forward (it screens buyer+seller at keeper.ts:447). But the listed buyer (or anyone) calls router.forward themselves → CCTP burn to the box on Arbitrum.
3. findForwards records the inbound row (no screening in watched/forwardsIn). completeTransfers then sends receiveMessage on Arbitrum, unscreened for inbound, minting the USDC to the listed buyer's deal box.
Impact: the keeper performs a cross-chain completion benefiting a sanctioned party's deal box. SPEC §12: the keeper "[doesn't] … complete a cross-chain transfer to a listed recipient." The v0.31 "checked and sound" note even claimed "the inbound/self-complete read-only follow-up moves nothing" — but inbound completeTransfers does send receiveMessage.
PoC: cd scripts/pentest/third && node EG3_inbound_completion_unscreened.ts
Output: inbound 2 USDC to a SANCTIONED buyer's box → completed (receiveMessage, marked delivered); outbound 20 USDC to the same SANCTIONED address → refused and held:SANCTIONED_ADDRESS. The asymmetry is the finding.
Root cause: service/src/keeper.ts:537-543 screens only t.direction === 'out'; the inbound branch (:544 onward, send at :552-557) never screens the deal's buyer. Same gap via the /v1/relay completeTransfer handler (api/app.ts:1378-1415), whose body carries no dealId/address, so the preHandler screen (sanctions.ts:registerScreening) doesn't screen the deal parties either.
Suggested fix (service only): before completing an inbound transfer, screen the deal's buyer (the box beneficiary) via screenedOut('completeTransfer', dealId, buyer), using the terms behind t.deal_id; hold it (held_reason) if listed, as the outbound path does. In the /v1/relay completeTransfer handler, resolve the deal and screen its parties.
Checked and found sound
- Keeper budgets cap the E-1/G-2 drains.
budgets.tsreserveSpendinserts the worst-case cost (gas×1.2×maxFee + value) before sending and deletes it only if the 24h sum is still within the limit, so concurrent requests fail closed;sendWithinbooks the real cost, keeps the reservation on a mined revert, frees it on a pre-send failure. Forwards (keeper.ts:452), completions (:555) and returns (:371,app.ts:1313) all go through it, keyedforward|complete|return:<chainId>with a per-party share (KEEPER_SHARE_BPS) andKEEPER_SENDS_PER_TICKper chain per round. The second-run attack scriptE_forward_griefing_keeper.tspasses here (25 under-funded drafts → 0 forwards; sybil case bounded to the daily budget). Sybil buyers can still reach the whole per-chain budget — documented (report case C), ops-alerted (ops.ts:keeperBudgets), money-safe. - No keeper stall.
tickTransfersis a separatemain.tsloop (:524), so Ethereum receipts can't delay Arbitrum deadline calls;KEEPER_SENDS_PER_TICKcaps per chain per round;sentThisRoundis cleared once per round inforwardFromDepositChains. - Held rows don't starve others.
updated_atdefaults tonow()onbridge_transfersandpayouts(migrations 003/005), sopendingTransfers/duePayouts(ORDER BY updated_at … LIMIT 50/20, held rows only after an hour) rotate fairly; new rows are not sorted behind touched ones. A reverting/listed recipient is held for an hour and ops-alerted (bridges.ts:166,ops.tsheld sections). - USDT
Forwardedmatched to the box'sTransfer(R2-E-I4).forwardsIn(bridges.ts:89-98) counts a USDT forward only if the same receipt shows the USDT leaving the deal's own box to the router, so a stranger's forward of another buyer's box isn't credited to the deal; USDC matched bymintRecipient == boxderived from the real buyer. Sound. - LayerZero delivery matching (R2-E-I4).
layerZeroDelivery(bridges.ts:276-283) matches by the OFT payload's recipient word, not the first message in the tx. returnStraytx/relay (E-2).strayDepositsand the relay action build args withbuyerfrom on-chain/draft terms (app.ts:1297-1314,strayDeposits), never attacker input, so no redirection; the relay only sends where anyone may call (ETH/non-bridgeable off the settlement chain) and within thereturn:<chainId>budget; stablecoins and the settlement chain are buyer-only. Matches the router (DepositRouter.returnStray:106-119).dispute.kleros/ settlement-during-votes.klerosPeriodOf/votesEndOf(app.ts:382-392) feed the deal JSON andnoSettlementDuringVotes; the service refuses settle typed-data/relay in the vote period for every escrow, v0.32 escrows refuse on-chain (SettleDuringVotes). Sound.- Web flows.
StrayMoney.tsxreturns go only to the buyer (buyer-only self-send; relay only foranyoneassets);SignReview.tsxrendersstage/phaseviadecodeStage;DepositPanel.CompleteTransfercompletes only to the transfer's own recipient. No harmful payload or redirect. - Sponsorship (
sponsor.ts), deposit authorizations, automatic-payout one-time semantics, ERC-6492 allow-list unchanged from v0.31 and re-read: sound (payout signatures fix recipient/amount, one-time by contract digest; deposit auths issued/balance-checked/one-shot). - Router unchanged. The only v0.32 change to
DepositRouter.sol/DepositBox.solispragma 0.8.30 → 0.8.38(logic byte-for-byte identical in source); CREATE2 address pinning and byte-identity are the launch gate's job (area F), carried over from the second run'sE_RouterPinned/E_Sound. - No key exceeds
docs/keys.md. The relayer's keeper sends only move a deposit to its own deal address, complete a burn to its named recipient, or return to the salt's buyer; none redirects funds.
Unconfirmed (reasoning, no runnable attack)
- Budget-exhaustion DoS near
acceptBy. An attacker exhaustingcomplete:42161(orforward:1) with sybil deals forces honest cross-chain USDC depositors to self-complete/self-forward (the documented tradeoff). In a narrow timing window — a buyer paying from Base/Ethereum close toacceptBywho doesn't notice and self-complete — the deal could expire before funding. Money is always safe (the web surfaces "Complete it on …"/"Forward it"), so no loss; a missed deal only, within documented behaviour. Not pursued as a finding. propose/accept/expirefrom deposits on the settlement chain are unbudgeted (keeper.ts:366-392,tick). A stranger can make the keeper pay Arbitrum gas by cycling self-funded sybil drafts, recovering their deposit viaexpire→returnDeposit. Arbitrum gas is ~cents, amplification ~1–2× on the cheapest chain; not worth a budget. Sound by design.reserveSpendone-request overshoot (INSERT then SELECT SUM): carried from the second run's accepted list; yescrow's own ETH, one request's worth.
PoC files (in the working copy)
scripts/pentest/third/EG3_payout_lzfee_unbudgeted.tsscripts/pentest/third/EG3_inbound_completion_unscreened.ts
Both exit 0.
Verifications for area EG
R3-EG-1:
Verdict: CONFIRMED Low
Evidence (at 71ad5a6; the verifier diffed service/src, contracts/src, SPEC.md, docs and .env.mainnet.example against git archive 71ad5a6 and they match)
- service/src/keeper.ts:279-285: a USDT payout to another chain gets value = usdtFee(...), which is 1.2 × quoteSend (keeper.ts:497-505). It is sent by a direct this.relayer.call('withdrawTo', …). Forwards and completions do go through budgeted/sendWithin (keeper.ts:118-120, 452, 555); this send does not.
- service/src/api/app.ts:1363-1375: the relayed withdrawTo enforces RELAY_BRIDGE_MIN and uses sendWithin with 'lz-fee'/cfg.lzFeeDailyWei. Neither applies to the keeper's payout.
- service/src/relayer.ts:73-110: Relayer.call has no cap of its own. Only the API lane has a queue limit (49-52).
- It runs on mainnet settings: .env.mainnet.example:14 sets DEPOSIT_CHAINS=8453,1; DepositRouter.routeOf gives Ethereum a USDT0 route (lzEid 30101) and Base none; Networks.sol:39 names the mainnet USDT escrow; app.ts:1032-1043 lets a party pick any withdraw network for its payout. So the case is USDT payouts to Ethereum.
- No amount floor reaches this path: payouts.ts:38 (amount is the price or price − fee), app.ts:1051-1062 (no amount check when a payout is saved), and the contract only requires price != 0 (YescrowEscrow.sol:882).
- Net cost: YescrowEscrow.sol:734 passes msg.sender as refundTo, and DepositRouter.sol:240-245 refunds msg.value − fee. The relayer ends up paying 1× the quote plus gas.
- Spend alert: ops.ts:100-115 and 259ff measure hourly balance drops and subtract only sponsored fees and appeal stakes. This spend counts toward RELAYER_SPEND_ALERT_WEI (0.01 ETH/h) and triggers an alert, but nothing caps it.
- Docs: SPEC.md:453 (§12) says "since v0.31 everything it pays for has a cap", and its v0.32 list leaves payouts out. SPEC.md:653 (§14.14) says the fee is paid "as for any relayed withdrawal", and relayed withdrawals have a minimum and a budget.
PoC re-run: cd scripts/pentest/third && node EG3_payout_lzfee_unbudgeted.ts passes (exit 0). One withdrawTo with 0.00001885 ETH as value; the only SQL is the duePayouts SELECT and UPDATE payouts; no relayer_spend row is read or written.
Own script: scripts/pentest/verify/V3_EG-1_payout_paths.ts (real Keeper.sendPayouts over stubs, plain node) passes:
- A. A cross-chain payout of 1 base unit is sent with value 20,662,782,471,676 wei (1.2 × run 1's measured Arbitrum→Ethereum quote). No budget SQL runs and no minimum applies.
- B. A payout on the settlement chain has value 0 (gas only).
- C. A send refused with BUDGET_USED_UP puts the payout on hold: status stays waiting, held_reason is set, retried hourly. Never marked failed or skipped.
What I checked
- Real code under the stubs: nothing in relayer.ts limits the send, and the DB stand-in throws on any other SQL, so no budget step can hide.
- Bounds: one payout row per (escrow, deal, role) (payouts.ts:53-61); only the role the close credits is paid, Settled skipped (payouts.ts:116-126), so at most one fee per closed deal; RELAY_BRIDGE_MIN/RELAY_WITHDRAW_MIN are API-only; a deal can close in the buyer's favour without the counterparty or a proof (YescrowEscrow.sol:417-430, 1114-1117), so the real bound is "deals someone opens and closes", each costing them their own transactions. No amplification run was built or measured.
- Promo deals change yescrow's fee income, not this cost. Refunds and cancels pay no fee anyway.
- USDC payouts carry value 0; their destination completion is already budgeted (keeper.ts:523 leaves rows below RELAY_BRIDGE_MIN to the recipient, keeper.ts:555 uses sendWithin('complete:<chain>')). Their gas on Arbitrum is unbudgeted, like every keeper payout and deadline call.
Reason for keeping Low: no party's funds at risk; yescrow's ETH, ~0.000017 ETH fee plus gas per payout; each payout needs its own closed deal; the hourly alert sees the spend. Same class as R2-G-2 (Low). No stranger-triggered multiplication shown (unlike R2-E-1, Medium).
Corrections to the attack report
1. 0.00001885 ETH is the gross msg.value; the 20% margin is refunded, so net is 1× the quote plus gas.
2. 1.57e-5 ETH is the Ethereum→Arbitrum forward quote (second run); run 1 measured Arbitrum→Ethereum at 1.72e-5 ETH (B-1). Same order.
3. The withdrawTo's own gas (~571k gas in run 1) is also outside every budget, comparable to the fee.
4. docs/keys.md at 71ad5a6 has no "everything it pays for has a cap" sentence; the contradiction is with SPEC §12 and §14.14.
5. "Bounded only by real closing deals" is weaker than stated (see Bounds).
6. The spend alert does count this spend.
Fix
- Send cross-chain USDT payouts through sendWithin with their own budget and a per-party share (e.g. a payout purpose in keeperBudget, KEEPER_PAYOUT_DAILY_WEI). Sharing 'lz-fee' would let API relays that use it up delay honest payouts.
- Apply RELAY_BRIDGE_MIN when the payout is signed: payoutOf offers another chain only when the amount reaches it; saved rows below it are skipped with "withdraw by hand".
- Payouts won't get stuck: a budget refusal holds the payout for an hourly retry, and held payouts alert ops. The signature's deadline is a year after acceptBy (payouts.ts:35).
- Side effect: while a payout is held, PAYOUT_PENDING (app.ts:1275-1286) blocks that party's relayed withdraw for a day after the close; the party can still withdraw from its own wallet. The deal page should say so.
- Documenting instead is acceptable only together with the minimum at signing.
- Add both scripts as regression tests.
R3-EG-2:
Verdict: CONFIRMED Low
The PoC passes on the real Keeper code, and so does the verifier's own script, which goes further. Low stays: nobody loses money (only the listed buyer's own deposit moves, into its own deal box; gas is a few cents within existing budgets), but it breaks screening promises yescrow makes, the same class as run 1's G-2.
Evidence (file:line)
- Keeper, inbound side unscreened: service/src/keeper.ts:536-543 screens only direction === 'out'; inbound rows go straight to the budgeted receiveMessage at :552-557; the small-deposit branch at :544-551 checks only funds(). Rows come from findForwards over the unscreened watched() list (:410, :467, :475-494); bridges.ts:106 sets account to the box's buyer.
- API relay path unscreened: the completeTransfer handler (service/src/api/app.ts:1378-1419) takes only chainId and txHash, looks up the deal (proposalOf) but never screens it, and pays from the keeper's complete budget in the buyer's share (:1409). The screening hook checks only address fields and a dealId it finds in deals (sanctions.ts:120, :141-151). check([]) never calls isListed (sanctions.ts:74-77), so the request is served even when no list is loaded.
- Self-completion possible: the router burns with destinationCaller = 0 (contracts/src/DepositRouter.sol:230).
- Promised: SPEC.md:444 (keeper doesn't complete "a cross-chain transfer to a listed recipient", missing list treated as listed; the API refuses any request "that acts on a deal one of whose parties is on it"; every write refused until a first copy of the list exists); keeper.ts:10-12 ("takes no part in a deposit-funded deal whose buyer or seller is on the sanctions list"); sanctions.ts:2-4, :8-9; docs/audit.md:175 (G-2 "Screened, failing closed"); docs/security.md:225 out of date (payouts only); docs/keys.md promises nothing about screening.
Own script: scripts/pentest/verify/V3_EG-2_inbound_screen.ts (from the copy root) PASS; the attack's PoC also passes; sources match 71ad5a6 byte for byte.
- A. Keeper completions, list loaded: inbound to the listed buyer's box delivered (gas booked complete:42161 in the listed buyer's share); inbound to a clean buyer's box on a deal whose seller is listed delivered; outbound to the listed address held SANCTIONED_ADDRESS. No list loaded: both inbound rows still completed (fails open); outbound rows only touched, as designed.
- B. Keeper.tick on the listed buyer's deals: expire sent (deadline call, unscreened by design); returnDeposit sent from yescrow's relayer, booked return:42161 in the listed buyer's share; accept from the deposit refused, as designed.
- C. Real buildApp with the screening hook, list loaded: completeTransfer with a dealId → 451; returnDeposit(dealId) → 451; completeTransfer with only chainId/txHash → 200 and yescrow's relayer sends receiveMessage in the listed buyer's share. No list: returnDeposit → 503, completeTransfer still 200 and sends.
What I checked
- Skipping the completion strands nothing: anyone can complete later with Circle's attestation; attestations expire after ~24 h but can be re-attested (POST /v2/reattest/{nonce}). The service has no re-attest call, so a row waiting longer than a day needs that by hand; held and late-transfer alerts would show it.
- Keeper for a listed buyer's deal — screened, failing closed: propose/accept from a deposit (keeper.ts:365), forward (:447), payouts (:272). Unscreened: returnDeposit (:370-371), submitProof with yescrow's prover (:171), applyFeeVoucher (:219-246), deadline calls (by design).
- Contract: accept (YescrowEscrow.sol:433) and returnDeposit (:678) are callable by anyone, so a listed party only loses yescrow's help, never access to the money.
- Existing tests: the G-2 test (service/test/relayer-caps.test.ts:351) covers only outbound completions.
Reason for severity (unchanged): nothing reaches a listed address; propose, accept and payouts stay screened; the listed party could send the same transaction for cents; spend capped by the complete budget (0.004 ETH/day default, config.ts:139) with a 25% share (:227); no deadlines slip, no fees lost. Not Info: the API relays and pays for a request acting on a listed party's deal and doesn't fail closed without a list, both against SPEC.md:444.
Corrections to the attack report
1. The keeper half doesn't break SPEC §12's literal wording (an inbound transfer's recipient is the deal box, not a listed address); it breaks keeper.ts:10-12 and docs/audit.md:175. The literal SPEC §12 breaches are the relay path and the missing fail-closed behaviour.
2. The report missed that the path fails open with no list loaded, in both the keeper and the relay.
3. returnDeposit is itself an unscreened keeper transaction for the listed buyer, while the API refuses the same relay with 451.
4. Side note (reading only): the hook resolves a dealId only from deals, so relayed returnDeposit and returnStray on a draft aren't checked against its parties; matters only for a party listed after its screened draft was made.
5. The relay handler spans app.ts:1378-1419.
Fix
- Keeper inbound completions: screen t.account (the box's buyer; the box address is derived from it) with the same branch as outbound: SANCTIONED_ADDRESS holds the row, SCREENING_UNAVAILABLE touches it so it retries. Don't screen the seller (an honest buyer's deposit would sit half-bridged). Completing only puts the money in the box.
- "Treated as listed" strands nobody: a list is missing only on a fresh database before its first OFAC download (main.ts:25-26 loads the last good copy at start); honest transfers wait and complete on the first round after the list loads; a wait over a day needs re-attestation.
- Relay handler: once the boxes are resolved, deps.screen.check([terms.buyer, terms.seller]) for each deal; this also fails closed without a list.
- returnDeposit (keeper.ts:370-371): screen the buyer too, or name it in SPEC §12's unscreened list with the reason.
- Docs and tests: SPEC §12, keeper.ts:10-12, docs/audit.md:175, docs/security.md:225; add an inbound case to the G-2 test.
Area F: deployment
AI penetration test, third run (SPEC v0.32, commit 71ad5a6) — Area F: deployment
Saved by the orchestrator from the attack agent's final reply (the harness refused the agent's report file).
Findings (one line each)
- R3-F-1 — The gate compares
feeBps,referrerShareBps,smallDealMaxPrice, the windows (above their floors), the promo duration and the judge with the deploying environment, not with pinned values: a wrong.env.mainnetpassesDeployAlland the gate at the promised addresses — Low —cd contracts && forge test --match-path test/pentest/third/F3_UnpinnedSettings.t.sol -vv(6/6 pass) - R3-F-2 — The gate binds neither deal token to its promised address nor one distinct escrow per position;
deploy-all.sh RESUME=1on an edited broadcast records the USDC escrow as the USDT escrow — Low —cd contracts && forge test --match-path test/pentest/third/F3_LiveV032Gate.t.sol -vv(3/3) andscripts/pentest/third/F3_resume_edited_broadcast.sh
Nothing under contracts/src/, contracts/script/, scripts/*.sh or docs/ was changed, no .env file was read, nothing was sent to a live network (forks, a local anvil, read-only RPC calls only). New files: contracts/test/pentest/third/F3_UnpinnedSettings.t.sol, F3_LiveV032Gate.t.sol, F3_ArbitrumOneToday.t.sol; scripts/pentest/third/F3_eip170_script_sim.sh, F3_resume_edited_broadcast.sh. Logs: scratchpad/pentest3/F3-regression.log, F3-tests-2.log, F3-tests-4.log, F3-eip170.log, F3-resume.log. All proofs pass: 12/12 third-run tests, 41/41 regression tests, both scripts as reported.
Headline. The v0.32 gate mechanism holds. On a real deployment of this exact commit (the testnet version deploy-all.sh put on Arbitrum Sepolia on 2026-11-01) the gate ties each creation transaction to its escrow by receipt, decodes it with this build's creation code, rebuilds each escrow at its own address byte for byte, matches the verifiers with fresh builds and passes with Kleros's resolver opening a case from each escrow. The 41 first/second-run F proofs pass on the 0.8.38 build. A build over EIP-170 is refused by forge's own simulation before anything is sent. The receipt/transaction field order the parser relies on is what Arbitrum One's nodes return today. No way was found to make a wrong or malicious deployment pass the pinned checks, to burn the promised addresses through a DeployAll run, or to fool RESUME=1 with a stale broadcast. What remains are two Low gaps in what the gate compares, not in what it does.
Findings
R3-F-1. Settings the gate still takes from the deploying environment (Low)
- Attacker and preconditions: whoever writes
.env.mainnet(operator error or a tampered env file). No outsider; same class as the second run's F-I3, which v0.32 fixed for the attestor and fee recipient. Nothing on-chain. - Steps (
F3_UnpinnedSettings.t.sol, Arbitrum One fork at block 519,194,000; real USDC, USDT0, Kleros core and resolver; router atNetworks.ROUTERvia the real keyless proxy; Safe-shaped judge with threshold 2; every pinned value right): 1.scripts/deploy-all.shsources.env.mainnetwithset -aand runsDeployAllandCheckDeploymentfrom it. The test sets that environment (vm.setEnv) withFEE_BPS=10000,REFERRER_SHARE_BPS=0,SMALL_DEAL_MAX_PRICE=0,MIN_CHALLENGE_WINDOW_INTER=2592001(one second over the contract's 30-day cap),FEE_PERIOD=315360000(ten years),PROMO_DAYS=366, and readsDeployAll.settings()from it. 2.DeployAll.check(the pre-broadcast pins) passes: windows are only checked>=SPEC §9's floors,promoEndsonly<= 366 days; fee, share, threshold andfeePeriodaren't looked at beyond the constructor'sfeeBps <= 10000. 3. Both escrows are built at0xe13E…aBaand0x3A73…fbBwithDeployAll.config()'s exact arguments. 4.CheckDeployment.expected()reads the same environment;check()printsALL CHECKS PASSEDwith real Kleros cases simulated (test_F3_F1_wrongSettingsFromTheSameEnv_passDeployAllAndTheGate). 5. Impact:test_F3_F1_impact_fee100pct_sellerPaidNothing_feeWalletGets1000USDC— a buyer funds a 1,000 USDC same-registrar deal from its deposit address, the seller accepts, the buyer confirms: seller credited 0, fee wallet 1,000 USDC.test_F3_F1_impact_interMinimumOverTheCap_noInterRegistrarDealEver—proposerevertsInvalidTermsfor a 30-day window and for 30 days + 1 s: no InterRegistrar deal can ever exist on that escrow. The control (test_F3_control_documentedSettings_pass) passes with the documented values. - Impact: a deployment whose fee, referrer share, AI-court threshold, windows or promo duration differ from SPEC §9/§14.20 and
docs/mainnet.md(1%, 25%, 2,000, 24 h/72 h/72 h, 92 days) passes the check that exists to catch wrong deployments, at the addresses Kleros was told about, immutably. The judge is in the same position (JUDGEfrom the env; the gate only needsgetThreshold() >= 2): a stranger's Safe would pass and decide every refused or stalled case. The launch-day re-run indocs/mainnet.md§3 step 3 sets neitherPROMO_DAYSnorDEPLOY_TIME, so there the promo end is bounded only by 366 days. - Command:
cd contracts && forge test --match-path test/pentest/third/F3_UnpinnedSettings.t.sol -vv - Root cause:
contracts/script/CheckDeployment.s.solexpected()lines 80–86 (FEE_BPS,REFERRER_SHARE_BPS,SMALL_DEAL_MAX_PRICE,PROMO_DAYS,JUDGEviavm.env*, defaults equal to the promised values but no pin),checkEscrowlines 275–279 (windows>=floors only), 266/273–274 (equality with the env's values);DeployAll.s.solsettings()100–114,check()140–166;Networks.solpins no fee, share, threshold, exact windows, promo days or judge;scripts/deploy-all.shline 25 exports the whole env file to both scripts. - Suggested fix: pin the decided values in
Networks.Netfor Arbitrum One (feeBps100,referrerShareBps2500,smallDealMaxPrice2_000e6, exact windows,promoDays92) and the judge Safe once it exists; compare with==in bothDeployAll.checkandcheckEscrow; have the gate's mainnet expectations come from the pins instead ofvm.envOr; makedocs/mainnet.mdstep 3 passPROMO_DAYSandDEPLOY_TIME(or read them from the pins).
R3-F-2. No token-to-address and no one-distinct-escrow-per-position binding (Low)
- Attacker and preconditions: whoever can edit
contracts/broadcast/DeployAll.s.sol/42161/run-latest.jsonbeforeRESUME=1,deployments/arbitrum-one.jsonbefore the launch-day re-run, or the deploy script (the R2-F-1/R2-F-U1 precondition class). - Steps:
1.
F3_UnpinnedSettings.t.sol:test_F3_F2_usdt0AtTheUsdcAddress_withTheUsdcTemplate_passes— an escrow over USDT0 at0xe13E…aBawith the "locked USDC" template (the hash pinned for that address) passes;test_F3_F2_theSameEscrowTwice_passes—ESCROWS/ESCROW_TXSwith the USDC escrow in both positions pass. 2.F3_LiveV032Gate.t.sol:test_F3_F2_live_theSameEscrowTwice_passes— the same with the real v0.32 USDC escrow0xe46b…686dand its real creation transaction twice, on the Sepolia fork. 3. End to end,scripts/pentest/third/F3_resume_edited_broadcast.sh(F3-resume.log): on a local anvil fork of Arbitrum Sepolia the unchangedscripts/deploy-all.sh arbitrum-sepoliadeploys a full version with anvil's keys (run 1:DeployAll, gate, record — "ALL CHECKS PASSED", USDC0x0590…eDEc, USDT0x58f3…1517); the broadcast's secondYescrowEscrowentry is replaced by a copy of the first;RESUME=1 scripts/deploy-all.sh arbitrum-sepoliaprintsescrow ok 0x0590…eDEc USDCtwice,ALL CHECKS PASSED, and records the USDC escrow as both entries —token.symbol"USDT" over the USDC token address0x75fa…AA4d. The copy's record file is restored afterwards; broadcasts go to a tempFOUNDRY_BROADCAST. - Impact: no funds at risk. The service picks escrows by the record's
token.symbol(service/src/escrow.ts:304) and trusts itstoken.address, so "USDT" deals would be routed to the USDC escrow with USDC's signing domain; a USDT deposit to such a deal's address can't fund it and is returned to the buyer as another token. Jurors of a USDT0 escrow at the USDC address would read "locked USDC". On mainnet both escrows must still be this build at the promised addresses with the pinned templates, so a token swap and a duplicate are the only such deployments. - Commands:
cd contracts && forge test --match-path test/pentest/third/F3_UnpinnedSettings.t.sol --match-test F2 -vv;forge test --match-path test/pentest/third/F3_LiveV032Gate.t.sol -vv;scripts/pentest/third/F3_resume_edited_broadcast.sh - Root cause:
CheckDeployment.checkEscrowline 253 (token == n.usdc || token == n.usdtfor any escrow);checklines 238–243 (no distinctness, no token per position);scripts/deploy-all.shlines 81–84 label entries by broadcast position and 137–138 record them by that label whiletoken_jsonreads the token from the chain. - Suggested fix: on Arbitrum One require
token == n.usdcforMAINNET_USDC_ESCROWandtoken == n.usdtforMAINNET_USDT_ESCROW(next tomainnetTemplateHash); require distinct escrows; indeploy-all.shtake each entry's symbol from the chain and refuse two entries with the same escrow or token.
Info
- I-1.
.env.mainnet.examplecarries the right values (PROMO_DAYS=92,FEE_BPS=100,REFERRER_SHARE_BPS=2500, exact windows,SMALL_DEAL_MAX_PRICE=2000000000,YESCROW_ATTESTOR=0xaac9…8975); R3-F-1 is about what would catch a divergence. SPEC §9 still says "6 months suggested for mainnet" forpromoEndswhile §14.20,docs/keys.mdand the example say 92 days. - I-2.
SKIP_VERIFY=1andREQUIRE_KLEROS=falseare honoured on arbitrum-one too (env file or shell), so blocking source verification and the Kleros requirement are defaults, not rules. Suggest refusing both on arbitrum-one outside rehearsal mode. - I-3. Deployer funding: today's node quote for the mainnet USDC escrow creation is 6,670,565 gas → forge's limit 8,671,734; at the 0.02 gwei base fee the upfront requirement at 2× base fee is ≈ 0.00035 ETH per escrow tx against the 0.002 ETH
docs/mainnet.mdfunds. A ~5× base-fee spike during the five sequential sends would make the node refuse a later transaction for insufficient funds — a stall (nonce unused;forge script … --resumeafter topping up), not a burn. 0.01 ETH removes the case. - I-4. The RPC is a trust assumption of the gate (all code and receipts come through
${ARBITRUM_ONE_RPC_URL%%,*}); the launch-day re-run could use a second, independent provider at no cost.
Checked and found sound
- v0.31/v0.32 fixes hold on 0.8.38 (
F3-regression.log): 41/41 —F_CheckDeploymentGaps(11; committed template hashes still equal whatDeployAll.config()renders),F_DeployAllGuards(3),F_DeploymentConstants(6),F_RealKlerosGasFloor(4; the 5M budget against the real resolver unchanged),second/F_GateTrustsGetters(4; lookalike refused),second/F_Sound(6),verify/V2_F-1(2),verify/V2_F-U1(2),verify/V_F-1(3). - The gate's positive path on a real deployment of this commit (
F3_LiveV032Gate.t.sol, Sepolia latest; v0.32 USDC0xe46bAAE15BE3e77bFe69AD4Ec65E6f292F0E686d, USDT0x9141cE86809d04c6CBAfec65CcE74e00478F0de7, verifier0xB95db0236E67f88CE076294f4611b9E6B5F66E2a):constructorArgsdecodes both live creation transactions withtype(YescrowEscrow).creationCodeof the test build, which equals the deploy artifact — what the tests compile is what deployed; arguments name one verifier; each escrow's 24,093-byte runtime equals the rebuild at its own address; the verifiers equal fresh builds over the pinned attestors;check()passes withrequireKlerostrue (Sepolia resolver creates a case per escrow per court). Refused with their own messages: the USDT escrow's tx named for the USDC escrow ("didn't create"), the v0.31 USDC tx ("doesn't create this build's escrow"), the v0.31DualRdapVerifierwith the same attestors but solc 0.8.30 ("Reclaim's RdapVerifier isn't this build's"), the USDC arguments named for the USDT escrow. - Receipt/transaction parsing on Arbitrum's nodes today. A creation of 2026-11-01 on Arbitrum One (block 520,262,477,
arb1.arbitrum.io/rpc): receipt fields alphabeticallyblockHash, blockNumber, contractAddress, cumulativeGasUsed, effectiveGasPrice, from, gasUsed, gasUsedForL1, l1BlockNumber, logs, logsBloom, status, timeboosted, to, transactionHash, transactionIndex, type; tx fieldsaccessList, blockHash, blockNumber, blockTimestamp, chainId, from, gas, gasPrice, hash, input, ….timeboosted(bool) sorts beforeto, the Arbitrum-only quantities andblockTimestampsort away fromhash/input, so both anchors (tojust beforetransactionHash,inputjust afterhash) hold; Sepolia returns the same shapes._fieldscan only be misled by a static word that is a small multiple of 32 below the tuple length — a bool, null, address or hash can't be; quantities,logs,logsBloomare dynamic.createdcould also be set byfrom == escrow, but an escrow is a CREATE address with no key. - A build over EIP-170 can't burn a nonce through
DeployAll(scripts/pentest/third/F3_eip170_script_sim.sh,F3-eip170.log):DeployAll.checkhas no size guard (escrow 24,093 bytes; the coverage profile's legacy build would be 25,953), butforge script1.8.5 enforces 0x6000 in its simulation by default: a 24,764-byte creation stops with "Bigis above the contract size limit (24764 > 24576)", nonce unchanged. Only--disable-code-size-limit(passed by neitherdeploy-all.shnor the runbook) sends it; then the chain reverts it and the nonce is consumed. - Out of gas can't burn one either. On Arbitrum forge takes 130% of the node's
eth_estimateGas(L1 component included) right before each send — the Sepolia broadcasts show 8,671,986 limit vs 6,617,075 used;F3_ArbitrumOneTodaygets today's mainnet quote 6,670,565 → 8,671,734 for the USDC escrow creation asDeployAllwould send it (creation input 30,865 bytes, under EIP-3860). The constructor has no chain-state-dependent check; insufficient balance or a moved nonce makes forge stop before sending. Networks.solagainst Arbitrum One today (castandF3_ArbitrumOneToday.t.sol, block ≈ 520,265,880): deployer0xA8C2…D14Dnonce 0, balance 0;CREATE(deployer, 3/4)= the pinned escrow addresses, both empty; keyless proxy present; router0x8f5d…1a52not yet on Arbitrum One (code 0; runbook step 1) and the legacy artifact still lands there with 0.8.38; resolver0xb552…4323allow-listed, points at core0x991d…22eaand registry0x0cFB…a5A2; court 1 (feeForJuror 0.005 ETH, enabled) and court 34 (parent 33, hidden votes, 11,000 PNK min stake, alpha 170, feeForJuror 0.00027 ETH, 7 to jump, enabled) support dispute kit 1;arbitrationCost(1,1,1)= 5e15,(34,3,1)= 8.1e14 wei — 10× and 12.3× under the 0.05/0.01 ETH caps thatDeployAll.checkrequires; USDC/USDT0 6 decimals, USDT0 nameUSD₮0; evidence module has code;gasUsedForL1is 0 today.- The pins themselves: template hashes render from
script/dispute-template.jsonwith the pinned policy URI for each promised address andDeployAllrefuses anotherPOLICY_URI/template before sending;YESCROW_ATTESTORis the key behind the live testnetDualRdapVerifier(live two-attestor proofs pass on it);MAINNET_FEE_RECIPIENT,MAINNET_PROMO_SIGNER,MAINNET_DEPLOYERmatchdocs/keys.mdand.env.mainnet.example(the keys can't be checked without.env.mainnet; the rehearsal does, sinceDeployAllrefuses any other promo key or attestor);ROUTER=DeployRouter.predicted()of the legacy artifact. DeployAll.s.solpre-broadcast checks: the thirteen refusals plus the v0.32 pins all run incheck()beforevm.startBroadcast, and the addressrequires are in the same simulatedrun(), so a failing one sends nothing; forge refuses to send if the on-chain pending nonce differs.DeployRouter.runandDeployTestUSDTrefuse the mainnet deployer and mainnet respectively.scripts/deploy-all.shv0.32: gate (line 95) before blocking verification (97–118;optionalfalse on arbitrum-one,set -eexits the|| optionallists) before the record (146–161).RESUME=1fails closed on hashes not on chain, a partially sent run (cast to-check-sum-address null), another build ("doesn't create this build's escrow") and another deployer's version (the pins); R3-F-2's duplicate is the only edit that passes.FOUNDRY_BROADCASTkeeps rehearsal records out ofcontracts/broadcast/; the stalerun-latest.recovery.lockfiles from October didn't stop the 2026-11-01 testnet broadcast.- solc 0.8.38 and the via-IR/legacy split: artifacts record
0.8.38+commit.1835d247; escrow/verifiers via-IR (24,093 / 14,575 / 1,950 runtime bytes), router/box legacy (5,957 / 6,842 creation), via-IR copies written as*.via-ir.jsonand refused byDeployRouter.run;DeployAllandCheckDeploymentcompile in the via-IR job, so the gate scans for the codeDeployAlldeploys. scripts/rehearse-mainnet.sh: local fork only, keys used only to derive addresses and sign on the fork, Safe-shaped stand-in answering 2, pinned fee recipient and policy URI as placeholders — the rehearsal exercises the same pins launch day requires.
Unconfirmed
- U-1. Court 34's identity ("Agentic Commerce Court", AI jurors) is off-chain; only its parameters and the policy URI string are checked (the second run's U-3 stands).
- U-2. A lying RPC (I-4) could make the gate pass anything; not provable without a rogue provider, and outside the attacker list.
Verifications for area F
R3-F-1 and Info items I-1, I-3, I-4:
Verdict: CONFIRMED Low
Evidence (file:line, relative to the working copy)
- contracts/script/CheckDeployment.s.sol:80–86: JUDGE, FEE_BPS, REFERRER_SHARE_BPS and SMALL_DEAL_MAX_PRICE come from the env, as do PROMO_DAYS (default 366) and DEPLOY_TIME (default now). :266 and :273–274 compare with those env values using ==; :275–279 checks the windows only against floors (>=); :283–288 checks that the judge equals the env value and getThreshold() >= 2; :303–304 promo end ≤ DEPLOY_TIME + min(PROMO_DAYS, 366) days + 1 h. The header (:32–35) says only the promo key, attestor, fee recipient, fee caps and template are pinned against a wrong .env.mainnet.
- contracts/script/DeployAll.s.sol:105–112 reads the same variables. check() tests windows only against floors (:140–144), the judge only for Safe shape (:147–151), the promo end only <= now + 366 days (:157). Fee, share and small-deal maximum are never checked.
- contracts/src/YescrowEscrow.sol:344–352: the constructor only bounds fee and share to ≤ 10000 and feePeriod ≠ 0. :881–886: a deal's challenge window must be ≥ the minimum and ≤ 30 days, so a minimum above 30 days kills that mode. :584–589: feeTimeout waits the whole feePeriod.
- contracts/script/Networks.sol:93–95: the mainnet floors already equal the decided 24 h / 72 h / 72 h. Fee, share, small-deal maximum, promo days and judge are not pinned anywhere.
- scripts/deploy-all.sh:25, :94–95 feed one sourced env file to both scripts; scripts/rehearse-mainnet.sh:42–46 reads the same .env.mainnet.
- Promised: docs/ai-pentest.md:92 ("CheckDeployment.s.sol covering every immutable"); docs/ai-pentest.md:66 counts "a deployment setting differs from what the specification … promise[s]"; docs/mainnet.md:89–91 step 3 checks "every setting (the promo key and end … against values pinned in Networks.sol …)" — the end isn't pinned. SPEC.md:351–357, :569, :704 give the decided values (1%, 25%, 2,000, 24/72/72 h, 92 days); SPEC §14.20 accurately lists only the attestor, fee recipient and promo key as pinned. Not in docs/audit.md §5.
Own test: contracts/test/pentest/verify/V3_F-1_UnpinnedSettings.t.sol (forge test --match-path test/pentest/verify/V3_F-1_UnpinnedSettings.t.sol -j 1 -vv, Arbitrum One fork, block 519,194,000): 8/8 pass. The attack's PoC re-ran 6/6 ("seller credited 0 fee wallet credited 1000000000", "ALL CHECKS PASSED", InterRegistrar propose reverts).
1. One-keystroke typos pass DeployAll.check and the gate: FEE_BPS=1000, SMALL_DEAL_MAX_PRICE=2000 (decimals left out) and FEE_PERIOD=2592000 all get "ALL CHECKS PASSED", and each costs parties: the seller gets 900 USDC instead of 990 on 1,000; a 100 USDC deal's dispute costs 0.005 ETH per side (General Court) instead of 0.00081 ETH, 6.2×; a seller who disputes and gets no answer waits 30 days (feeTimeout reverts TooEarly at 72 h + 1 s and at 30 d − 1 s).
2. An independent re-run with the gate's own defaults (no .env.mainnet overrides) catches a fee typo ("escrow: feeBps") but still passes INTER=864000, FEE_PERIOD=2592000, a 366-day promo and a stranger's Safe-shaped judge: the repo holds no decided value for those, only floors.
3. The referrer share can be changed after deployment: an escrow deployed with share 0 is set to 2500 by the fee recipient's signed setReferrerShare.
4. Fix check: a pinned comparison (fee 100, the share's creation argument 2500, small-deal max 2,000e6, Networks' floors as exact windows, promoEnds within ±1 h of deploy + 92 days, judge equal to the pin) passes the documented deployment and refuses all 14 wrong variants; today's DeployAll.check lets every one through.
What I checked
- The PoC is faithful: DeployAll's own settings(), check() and config() through a harness, escrows at the promised addresses, the gate's expected() and check() unmodified; the only shortcut is passing constructor arguments directly instead of decoding them from the creation transaction (same bytes).
- Who writes .env.mainnet: the user, on the VPS, in the same file as DEPLOYER_PRIVATE_KEY (docs/mainnet.md:16, :32, :73). Anyone who can write it can edit Networks.sol, so the gate can't protect against a deliberate operator; its stated aim is catching honest mistakes in .env.mainnet (CheckDeployment.s.sol:32–35) and letting a third party re-check the deployment from the audited commit. Both fail for these values.
- Nothing else catches it before funds arrive: the rehearsal uses the same env and gate, prints no fee or window values, writes its record to a temp directory; the service reads values from the chain and publishes them in /v1/config (service/src/escrow.ts:166–217, api/app.ts:496) without comparing; the web shows the chain's fee before signing ("yescrow's fee is X%" Wizard.tsx:495; "Seller receives" Steps.tsx:127, DraftSigning.tsx:111), so a gross fee error is visible to a seller who reads; the landing page hard-codes "1% fee"; feePeriod is never shown. Arbiscan shows constructor arguments and deployments/arbitrum-one.json records fees and windows (deploy-all.sh:139–144): visible, unchecked. The smoke test (docs/mainnet.md:167–168) would expose a wrong fee but exercises no dispute, no InterRegistrar deal, no promo length and no judge.
- The second run's F-I3 fix named the attestor, fee recipient, VERIFIER and JUDGE; the attestor and fee recipient were pinned, the judge deferred "once the Safe exists". The first run's F-1 verifier had noted "windows and feePeriod are checked only against floors … compare against independent values". This is the unfixed remainder of both.
Reason (Low stands): not Info — subtle typos pass every automated check including the rehearsal and harm parties for the escrow's life (10× fee; General Court fees on small deals; a 30-day fee period no screen shows). Not Medium — every loss needs yescrow's own mistake first; run 1 graded this precondition class Low. The 100% fee overstates the realistic impact (the web shows "Seller receives 0"; the smoke test would show it; the fee lands in yescrow's own wallet, refundable).
Corrections
- referrerShareBps isn't immutable: a storage variable the pinned fee recipient changes with setReferrerShare (YescrowEscrow.sol:265, :769–778); docs/keys.md:68–69 still lists it as immutable (stale since v0.29).
- Step 3 of the runbook does get PROMO_DAYS=92 (step 1 runs set -a; . ./.env.mainnet in the same shell, docs/mainnet.md:77); only DEPLOY_TIME is missing, so the bound is now + 92 days + 1 h, not 366 days.
- The attack's PoC never tests a stranger's Safe (always 0x1d6e); test 2 shows one passes. An address with no code fails DeployAll.
- The promo length only puts yescrow's fees at risk (the promo key can only lower yescrow's own fee, docs/keys.md:19).
- Aside: the smoke test as written can't run — a same-registrar push (IntraRegistrar) needs a challenge window of at least 72 h, not 24 h.
Fix (the suggested fix, refined)
- Networks.sol (Arbitrum One): add fee 100, share 2500, small-deal max 2_000e6, promoDays 92; use the existing floors at :93–95 as exact values on mainnet.
- DeployAll on chain 42161: take these values from Networks.sol, not the env; compute promoEnds as block.timestamp + 92 days; refuse an env that sets a different value.
- The gate on mainnet: == against the pins; compare the share through the decoded creation argument (c.referrerShareBps in checkCreation), not the live getter; bound promoEnds on both sides (±1 h of deploy + 92 days) with the deploy time from the escrow's creation receipt.
- Judge: create the Safe before deploying (docs/mainnet.md §1 step 3) and commit MAINNET_JUDGE in Networks.sol in the release commit (script-only; doesn't move the escrow addresses or the template hashes); require equality in both scripts and fail closed while it is address(0); the rehearsal can anvil_setCode its stand-in at the pinned address; optionally check getOwners().
- Docs: fix step 3's wording; DeployAll, the gate and SPEC §9 the same 92-day promo (I-1).
Info items
- I-1 — true, Info. The example file is right (.env.mainnet.example:34, :38, :48–54); SPEC.md:357 says "6 months suggested for mainnet" while :569, docs/keys.md:38, docs/mainnet.md:43 say 92 days. Also deploy-all.sh:63 defaults to 180 days and CheckDeployment.s.sol:85 to 366: a missing PROMO_DAYS line gives a 180-day promo that passes both; the R3-F-1 pin fixes it.
- I-3 — true, Info. The arithmetic holds against the Sepolia broadcast (escrow gas limit 8,479,045; ~19.8M gas used at ~0.02 gwei). 0.002 ETH covers the last escrow transaction up to ~0.1 gwei base fee. An under-funded transaction is refused without using the nonce (a stall, not a burn). The recovery (forge script … --resume) has no runbook step. Fund 0.01 ETH and document the resume.
- I-4 — true, Info. Every read the gate makes goes through one RPC (deploy-all.sh:48, docs/mainnet.md:95); the blocking Arbiscan/Sourcify verification only independently reads the code. Re-run step 3 against a second provider already in .env.mainnet's RPC_URL list.
R3-F-2 and Info item I-2:
Verdict: CONFIRMED Low
Evidence (file:line)
- contracts/script/CheckDeployment.s.sol:252-253: any escrow passes with either token (token == n.usdc || token == n.usdt), despite the header at :25 saying it checks "its token".
- CheckDeployment.s.sol:238-243: nothing requires distinct escrows, both promised escrows, or a token per position; the template, by contrast, is pinned per address (:310-313, Networks.sol:70-74).
- scripts/deploy-all.sh:81-84, :137-138 label the escrows by broadcast position; token_json (:121-128) reads each escrow's token address and EIP-712 domain from the chain, so only the symbol can be wrong.
- On mainnet the token for each address is fixed before anything is sent: on chain 42161 the token addresses are constants (Networks.sol:77-96; TEST_USDT ignored there); DeployAll.s.sol:79-82 builds nonce 3 over n.usdc and nonce 4 over n.usdt; :85-91 require the promised addresses and :152 nonce 0, in the same simulated run. A swap needs an edited DeployAll or Networks, or a deployment outside DeployAll by whoever holds the deployer key; a duplicate needs the operator's own broadcast file edited before RESUME=1.
- The service trusts the record: service/src/config.ts:149-157 (escrowsOf) does no checks; escrow.ts:193 copies token from the record and never reads usdc(); escrow.ts:304 (escrowFor) picks by symbol; app.ts:222-227, :559 use it for quotes and drafts; afterwards escrowAt (escrow.ts:297, first entry wins; app.ts:646); the keeper watches only the record's token address (keeper.ts:361, :371).
- Nothing is lost: YescrowEscrow.sol:686-687 lets anyone return any other token to the buyer at any time.
Own tests and scripts
- contracts/test/pentest/verify/V3_F-2_GateTokenBinding.t.sol (Arbitrum One fork, block 519,194,000): 7/7 pass. Today's gate passes "swapped lines" (USDT0 at 0xe13E… behind "locked USDC", USDC at 0x3A73…; the only sign in its log is "escrow ok 0xe13E… USD₮0"), the attack's shape, a duplicate, and a list with one escrow. USDT0's on-chain symbol() is "USD₮0". The proposed binding refuses all four shapes and passes the honest version.
- scripts/pentest/verify/V3_F-2_mainnet_resume_step3.sh with contracts/test/pentest/verify/V3_F-2_MainnetUnlocked.t.sol: a local anvil fork of Arbitrum One, the deployer impersonated (no key) sending the transactions DeployAll's own settings, check and config would, at nonces 0-4; then the unchanged RESUME=1 deploy-all.sh arbitrum-one and docs/mainnet.md step 3 word for word.
- A (honest): gate passes, record right, step 3 passes.
- B (edited broadcast, duplicate): the gate passes on the mainnet path and records the USDC escrow twice ("USDT" over the USDC address, "USD Coin" domain). Step 3 then fails: "escrow: no creation transaction to read its verifier list from (ESCROW_TXS)".
- C (record's two token blocks swapped by hand): step 3 passes, because the gate never reads them.
- D (swapped lines): the gate passes and records "USDC" over USDT0 and "USDT" over USDC; step 3 passes too.
- scripts/pentest/verify/V3_F-2_service_routing.ts (the real escrowsOf, escrowFor, escrowAt on those records): duplicate record → a USDT draft goes to the USDC escrow and is shown as USDC from then on; the real USDT escrow isn't served at all (UNKNOWN_ESCROW). Swapped record → USDC drafts go to the USDT0 escrow. The service accepts every record without complaint.
- Logs and records: scratchpad/pentest3/vF2-logs/. The copy is back to its original state.
What I checked
- The attack's PoCs pass again: F2 2/2, LiveV032Gate 3/3, the resume script as reported.
- Only the operator can edit the broadcast and record, in their own checkout. The gate is meant to catch wrong env or template values and deployments that aren't DeployAll's (SPEC §14.20 line 704, docs/audit.md:174/196, R2-F-1/U1); a token swap is inside that mandate.
- With a wrong record no deal gets funded by surprise (funding uses the escrow's own immutable token). Wrong-token deposits sit at the deal address, don't show in the UI and aren't returned automatically, but anyone can return them.
- Nothing fails loudly: duplicate escrow events are absorbed by ON CONFLICT DO NOTHING (watcher.ts:71); the service starts normally.
Why Low: no funds or fees can be lost, so not higher. Not lower: the swapped-lines deployment passes the whole documented procedure (gate and step 3) on the mainnet path; every deposit-address deal on both escrows would be served under the wrong label until the record was fixed by hand, and the "locked USDC" juror text would stay wrong for good.
Corrections to the attack's report
1. "Or an edited record on the launch-day re-run" is wrong for the duplicate: step 3 looks up each promised escrow's creation transaction by address (CheckDeployment.s.sol:99-112) and fails on mainnet when the USDT escrow has none (:330-332). Only RESUME=1 records a duplicate, and step 3 then fails before the stack starts.
2. "Returned to the buyer as another token" is wrong: USDT0 sent to such a deal isn't returned at all; anyone can send it back as USDT0 itself (returnDeposit with asset set to the token, through the API or the contract).
3. The realistic swap shape is the two escrow lines swapped (the attack's PoC put USDT0 in both escrows); it passes too.
4. "Take each entry's symbol from the chain" would record "USD₮0"; the service and the web look for "USDT" (escrow.ts:304, MakeLink.tsx:79), so USDT would vanish from the site.
Fix: right, but not complete
- On mainnet bind the token to each promised address next to mainnetTemplateHash, and require distinct escrows (the test shows it works).
- On mainnet also require exactly [MAINNET_USDC_ESCROW, MAINNET_USDT_ESCROW] (today a one-escrow list passes).
- In deploy-all.sh, compare each escrow's usdc() with the network's USDC or USDT (TEST_USDT on Sepolia) and refuse duplicates; keep the labels "USDC"/"USDT", not the token's symbol().
- Add a check at service startup: refuse duplicate addresses, any entry whose token.address isn't the escrow's usdc(), and on 42161 a symbol that doesn't match its address. The record is edited by hand by design (docs/mainnet.md:189) and the gate never reads its token blocks (run C).
- Apply distinctness on testnet too.
I-2: Confirmed, Info. On arbitrum-one, deploy-all.sh:98-99, :114 skip both Arbiscan and Sourcify verification whenever SKIP_VERIFY is set, including from the env file (run D printed "skipped source verification" then "recorded"). REQUIRE_KLEROS=false (CheckDeployment.s.sol:87, :244-247) turns the mainnet Kleros requirement into a warning with exit 0. Both are the operator's own settings. Refuse SKIP_VERIFY on arbitrum-one unless REHEARSAL_RPC_URL is set, read before the env file is sourced; keep REQUIRE_KLEROS=false only as an explicit command-line override that the script prints.
Regressions of the earlier runs' proofs
AI pentest, third run: regressions of the earlier proofs (commit 71ad5a6, SPEC v0.32)
Saved by the orchestrator from the regression agent's final reply (subagents can't write report files).
Every fix holds, for the second run and the first: all earlier proofs and the relevant service, prover and web tests pass on 71ad5a6, with no failures.
What ran
All runs used the working copy scratchpad/pentest3/R. Every heavy job ran under the shared lock and a 2,500 MB memory cap. Forks used public RPCs, read only. No .env file was read, nothing was sent to a live network, and the docker stack wasn't touched. Logs are in scratchpad/pentest3/R-logs/.
| Run | Result |
|---|---|
forge test --match-path 'test/pentest/**' |
291/291 passed, 59 suites. First run 187 (116 attack + 71 verifier, the same 187 as on v0.31); second run 104 (65 attack + 39 V2_*) |
forge test --match-path 'test/fork/*.t.sol' |
36/36: AppealFunding 4, ArbitrumSepolia 9, ArbitrumOneMainnet 4, ArbitrumOneUsdt0 4, MultiChain 5, SecondRunFixesFork 10 |
Extra evidence: SecondRunFixes.t.sol, DualRdapVerifier.t.sol, Hardening.t.sol |
49/49 (13, 12, 24) |
First-run scripts: C_template_re2, E_foreign_forward_keeper, F_verify-profiles, G_sponsor_drain |
all exit 0; E and G print "Regression … OK" |
Second-run scripts: C_engines, D_appeal_budget, E_forward_griefing_keeper, G_appeal_screen_gap |
all exit 0, each with its regression line |
second/F_build-checks.sh |
(a) the broadcast record lands under FOUNDRY_BROADCAST, none under contracts/broadcast, and the router deploys at 0x8f5d…1a52. (b) the no-L/E build passes 644/644 and the default build passes 644/644 (escrow 24,325 vs 24,093 bytes). |
Verifier scripts: V2_E-1_real_tx_costs, V2_G-1_screen_heals, V2_G-2_unbudgeted_cctp |
all exit 0 with "OK" / "PASS" |
| Service tests without Postgres (7 files) | 52/52 |
Service tests on Postgres (yescrow-check-pg-r3 on 127.0.0.1:55447, removed afterwards) |
appeals 7/7, alerts-api 7/7, api 64/64, deposit-chains 9/9. Total with the above: 139/139, matching the commit message |
| Prover tests (dual, template, server) | 16/16 |
Web node --test lib/*.test.ts |
81/81 |
The inverted proofs are genuine. v0.32 renamed 31 attack tests to test_fixed_* regression tests: 31 test functions out, 31 in, and none dropped. Each keeps the same attack path with the outcome assertion flipped. For example, in D-1's real-Kleros run Kleros still reports overridden, and the escrow now ends Released with the seller credited 1,881 USDC and the buyer 0. Tests that document properties left in place on purpose (R2-D-4, the on-chain half of R2-E-1) still assert them.
Second-run findings: does the fix hold?
| ID | Holds? | Evidence |
|---|---|---|
| R2-D-1 | yes (contract) | D_AppealBudget test_fixed_D1_cheapShamCases_drainTheBudget_butTheLoserCantFlipARealRuling (seller 1,881 USDC, buyer 0). SecondRunFixesFork test_D1_loserFundsAlone…, test_D1_tieThenOneSidedAppeal_paysZero, test_realAbi_theReadsTheEscrowMakes. D_KlerosReads reads on the real core. 7 D1/D2 units in SecondRunFixes (if any read fails, the case counts as not ruled). Service: decide returns NOT_NEEDED for v0.32 escrows (D_appeal_budget.ts, appeals.test.ts on Postgres). Web appeal.test.ts |
| R2-D-2 | yes (contract) | test_fixed_D2_escalatingPastTheBudget_cantFlipATwiceConfirmedRuling (seller 990 USDC, loser 0). SecondRunFixesFork test_D2_nextAppealFundedAlone…, test_D2_bothSidesFund…. Kleros's own pot still goes to the funder of its overridden ruling, but the keeper stakes nothing on v0.32 escrows, so nothing of yescrow's is at risk |
| R2-E-1 | yes (service) | E_forward_griefing_keeper.ts: 0 forwards where there were 25 a round; one buyer gets 8 forwards for 0.000453 ETH, within its 0.001 share; sybils get 61, then the 0.004 ETH budget stops them. V2_E-1 on an anvil Ethereum fork: one buyer stops after 1 forward, sybils after 4 more (0.00336 of 0.004 ETH). relayer-caps R2-E-1 tests. The per-cycle ratios (10.1× and 13.0×, E_ForwardCost, E_StrayEdges test_6) are unchanged, as expected for an off-chain fix |
| R2-G-2 | yes (service) | V2_G-2: one account gets 16 mints for 0.00096 ETH and 44 are left to the recipient; 100 sybils get 66 for 0.00396 of 0.004 ETH; at most 10 per round. relayer-caps "completions…", the deposit-chains held-rows test, the ops budget alert |
| R2-A-2 / R2-B-1 | yes (contract) | SettleDuringVotes is asserted in A_PhaseStage test_fixed_R2A2…, A_RealKlerosStage test_fork_fixed_A1_A2… (real core), B_PhaseStage test_fixed_B2_1…, V2_A-2 3/3, V2_B2-1 3/3, SecondRunFixesFork test_A2_settle_refusedInTheVotePeriod_worksBeforeAndAfter, Hardening test_settle_refusedInTheVotePeriod. Service: relayer-caps "a settlement is refused while the jurors vote" |
| R2-A-1 | yes (contract) | The stage is now (lpc<<16)\|(rounds<<3)\|period. V2_A-1 2/2 (commit → vote in the same second still lapses the offer). SecondRunFixesFork test_A1_stageMovesWithinOneSecond. Hardening test_settlementStage_everyStage. Web stage decoding. The known stage-2 fallback when Kleros's getters break (test_residual_B3_brokenCoreGetters…) is unchanged |
| R2-C-1 | yes (verifier, prover) | C_DualCrossField 12/12: all six test_fixed_C1_* revert with ProofRejected(4). V2_C-1 18/18, including the fuzz pairCheck4ImpliesEachHalf run on the shipped verifier. V_C-2 13/13. DualRdapVerifier.t.sol 12/12, including real dual proofs for every registry. Prover combineFacts margin test |
| R2-D-3 | yes (contract) | D_GovernorFee test_fixed_D3…: the dispute opens at the cap (0.01 / 0.05 ETH) and the judge rules. V2_D-3 4/4: a reverting quote opens at the cap and the answer goes to Judging. SecondRunFixesFork 3 D3 tests (today's fees are under the caps). 6 D3 units in SecondRunFixes. F_Sound shows the caps are ≥10× today's fees; F_DeployAllGuards refuses a fee above the cap |
| R2-D-4 | yes (docs, keeper) | The Kleros property still shows, by design (D_AppealBudget test_D4…, V2_D-4 5/5). The keeper marks rounds stranded_at and raises the appeal-stranded:* alert (appeals.test.ts, ops). On v0.32 nothing is staked, so nothing can strand |
| R2-F-1 | yes (script) | F_GateTrustsGetters test_fixed_F1_lookalikeVerifier_refused. V2_F-1 2/2. F_Sound …isNotThisBuild |
| R2-F-U1 | yes (script) | V2_F-U1 2/2 (the creation transaction is tied to its escrow by the receipt; a backdoored escrow is refused and the honest one passes). F_Sound creationTx_tiedToItsEscrowOnly and aCall_isNoCreation, on live Arbitrum Sepolia receipts |
| R2-G-1 | yes (service) | G_appeal_screen_gap.ts, V2_G-1_screen_heals.ts, ops "with no sanctions list, the alert says open appeal periods are unprotected" |
| R2-A-3 | yes (deliberate, documented) | Comment in app.ts, docs/audit.md §5 "(deliberate, R2-A-3)", api PAYOUT_PENDING |
| R2-E-I1 | yes | docs/keys.md rows for the box owner (the router), pull and returnStray |
| R2-E-I2 | yes | dependencies.md:275, DeployRouter.s.sol and runbook.md:555 name 0x8f5d…1a52. See notes for runbook.md:82 |
| R2-E-I3 | yes | returnStray transaction and relay, StrayMoney.tsx; deposit-chains R2-E-I3 test |
| R2-E-I4 | yes | bridges.ts forwardsIn now requires the USDT Transfer from the deal's own box to the router in the same transaction; relayer-caps tests |
| R2-F-I1 | yes | solc 0.8.38 everywhere. The router is byte-identical (E_RouterPinned 2/2; "6842 bytes lands at 0x8f5d…1a52"; F_build-checks (a)) |
| R2-F-I2 | yes (still Info) | The escrow is 24,093 bytes: 483 bytes under EIP-170, down from 1,155 |
| R2-F-I3 / I4 / I5 | yes | F_DeployAllGuards 3/3: attestor, fee recipient and promo pins; templates; fee caps; a judge Safe with threshold ≥2 (threshold 1 and non-Safes refused); all checked in checkFor before anything is sent |
| R2-D-I1 | yes | Dispute.tsx says "the offer lapses, even when both of you have signed it". D_FixesHold test_info_aSignerEscapes… still documents the property |
| R2-D-I2 | yes (v0.32) | No keeper stake on v0.32 escrows; the v0.30–v0.31 testnet escrows are unchanged |
First-run fixes on v0.32: all hold
| ID | Holds? | Evidence |
|---|---|---|
| D-1 | yes, now in the contract | D_AppealOverride test_fixed_D1…; V_D-1 6/6 including fixed_reverse (buyer refunded); AppealFunding 4/4 and appeals.test.ts for older escrows |
| B-1 | yes | relayer-caps budget test, AMOUNT_TOO_SMALL, V_B-1 12/12, B_Eip3009Fork 10/10 |
| B-2 | yes | queue cap and budget tests; deposit-chains AMOUNT_TOO_SMALL / INSUFFICIENT_BALANCE; V_B-2 |
| E-1 | yes | the E keeper script (own forward 1/1; a stranger's batch 2/2); E_ForeignForward 3/3, V_E-1 5/5 |
| G-1 | yes | G_sponsor_drain.ts; G_SponsoredFeeDrain 1/1, V_G-1 2/2 |
| C-1 | yes | C_InterProofBlocking 8/8; C_template_re2; C_engines case 10 |
| C-2 | yes | V_C-2 13/13 |
| B-3 | yes, now stricter | B_Signatures 13/13, V_B-3 3/3 |
| B-4 | yes | V_B-4 7/7; Hardening phase-binding tests; DEADLINE_TOO_FAR |
| E-2 | yes | E_StrandedBox 4/4 |
| F-1 | yes | F_CheckDeploymentGaps 11/11, V_F-1 3/3 |
| G-2 | yes | relayer-caps listed-party test |
| A-1 | yes | V_A-1 4/4 |
| A-2 | yes | PAYOUT_PENDING, V_A-2 2/2 |
| F-2 | yes | mainnet.md:83 uses --compilation-profile default |
| E-3, D-3, B-5, D-4 | yes | E_RouterStray 1/1, V_E-3 2/2, V_D-3 3/3, V_B-5 3/3; the documentation fixes are in place |
| D-2 | yes | D_FeePaths 17/17, V_D-2 3/3 |
| G-3 | yes | the deal's own fee is asserted in relayer-caps and api; V_G-3 2/2 |
| F I-1–I-5 | yes | F_DeploymentConstants 6/6, F_RealKlerosGasFloor 4/4 |
One first-run "sound" check changed on purpose: D_FeePaths test_sound_read_oneWordRuling_stillApplies is now …_notRuledYet. v0.32 needs currentRuling's third word (overridden), so a one-word answer means the case goes to the judge at the 90-day stall. That is the safe direction, not a regression.
Failures
None.
Notes (not regressions)
docs/security.md:281still gives the escrow as 23,421 bytes; v0.32's is 24,093 (docs/audit.md:12is right).docs/runbook.md:82still shows the v0.12 router0xCCb4…29Ffas "the same address on every chain". It sits in the dated 2026-10-23 (v0.10–v0.12) history section, and the current router is at line 555.F_verify-profiles.shstill labels the bare router command "as docs/mainnet.md runs it", which has been stale since F-2 (also noted in the second run).C_template_re2case 2 andC_enginescase 11 still show the known nameserver-event refusal (the first run's unconfirmed U2). No finding.C_engines.ts --live(one GET per registry host) wasn't run; the crafted cases ran.- To run
F_build-checks.shpart (b), the agent added the second run'snoleprofile to this copy'scontracts/foundry.toml, ran it, then restored the file. The build outputscontracts/out-noleandcontracts/cache-noleare still in the copy.
Part 3. How the run went
- Who ran it. At the user's choice ("smaller targeted run"), from the session that wrote the v0.32 fixes. That session (the orchestrator) exported the commit, launched the agents, saved their reports and verdicts (the harness refuses report files from subagents, so every report and verdict came back as text and was saved by the orchestrator unchanged, apart from shortening absolute scratch paths) and assembled this report.
- Copies.
git archive 71ad5a6intopentest3/base(never modified), one copy per attack area (D,AB,C,EG,F),Rfor regressions and one per verifier, 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 2,500 MB memory cap (2 cores, 4 GB). - Agents. Five attack agents on Fable 5.1 at maximum effort, each given the procedure's prompt word for word with a brief of what v0.32 changed in its area. One regression agent (Opus 5.5). Seven verifiers on Opus 5.5 at maximum effort, one per finding or group, with the user's rule; R3-D-1 and R3-AB-1 shared one verifier once they turned out to be the same finding.
- Timeline. 2026-11-01, about 11:00 to 15:00 (server time). Most of the wall time was forge jobs waiting for the shared lock.
- Files. The attack proofs (
contracts/test/pentest/third/*,scripts/pentest/third/*) and the verifiers' tests (contracts/test/pentest/verify/V3_*,scripts/pentest/verify/V3_*) passed in their own agents' runs. A first combined run in one copy was stopped by the session's low-memory guard during compilation (not a test failure). With the v0.33 fixes, a fresh agent (Opus 5.5) turned every proof that asserted a fixed weakness into a regression test (test_fixed_*, the same attack path with the outcome flipped) and adapted three earlier-run proofs to the new deployment pins (F_DeployAllGuards,F_CheckDeploymentGaps,V_F-1_controls); all of them are in the repository with v0.33. On the v0.33 code: 399 of the 402 pentest proofs passed, the five node scripts and the two anvil scripts print "Regression … OK"; the three others were the twoF3_LiveV032Gatetests, which pin the v0.32 testnet deployment and were moved to v0.33's, and one test that read another suite's environment, made order-independent. It also found thatdeploy-all.shcould record a wrong router address after a test build left a via-IR copy ofDeployRouter.s.sol(the escrows were unaffected); v0.33 takes the address from the escrow on-chain. - Notes from the regression agent (not findings).
docs/security.md:281still gives the escrow as 23,421 bytes (24,093 in v0.32);docs/runbook.md:82shows the v0.12 router in a dated history section;F_verify-profiles.shkeeps a stale label.