← yescrow security review

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:

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


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)

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

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

R3-D-3 (Low). A one-sided appeal is a free 18 h / 54 h delay of a decided case

Info

Checked and found sound

Unconfirmed

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;
}

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)

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.)

R3-AB-2. The stage-2 fallback drops the vote-period refusal and the period/round binding together

Info (no proof needed)

Checked and found sound

  1. 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 (SecondRunFixesFork test_A2_*/test_A1_* re-read; the agent's harness drives the same real case).
  2. Encoding: period ≤ 4 fits 3 bits; rounds would 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).
  3. 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".
  4. Gas: a starved read inside settle would 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.
  5. No trap past a promised deadline: in Disputed the only deadline is the 90-day stall, after which Judging (stage 1) settles again; concessions work throughout. A Kleros pause can't extend the refusal: on the deployed core castVote and passPeriod work while paused, the vote period ends on time and the deal settles in the appeal period (test_sound_klerosPauseDoesNotHoldTheVotePeriodRefusal, pass on the fork).
  6. 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.
  7. 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.
  8. Execution period: a settlement races applyRuling, which the keeper calls at once after passPeriod; both closes were agreed in advance; the real post-vote window is the appeal period.
  9. SPEC §8, every exit re-read against _close (lines 1128-1168) and judgeRuling (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.
  10. 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's winningChoice/tied); court 34 → 33 → 1 all support kit 1 and appeal falls back to Classic anyway.
  11. 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; _quote needs 1M gas; today's fees (0.00081 / 0.005 ETH) are under Networks's 0.01 / 0.05 ETH, which DeployAll passes through. The band (R3-AB-1) is the one gap.
  12. Promo and referrer: vouchers only lower, only until promoEnds, never in Judging; judge rulings/timeouts zero the fee; the referrer's share is a share of the rounded fee; invariants 7–9 hold.
  13. 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.
  14. Rounding: held/2 and price/2 floor to the seller's side, remainder to the buyer's; dust fees floor to 0; nothing lost or double-credited.
  15. Withdrawals: withdraw/withdrawEth pay only account; withdrawTo only by the account or its one-use digest; same chain needs no ETH, cross-chain refunds excess ETH to the sender; no receive().
  16. Typed data: typehashes and abi.encode order match for all seven actions and Settle; the service builds the same field order; the web shows every field with phase/stage decoded.
  17. actionPhase: phase 2 groups Disputed/Judging, where a concession only ever costs its signer and _close branches on bothFeesHeld, so the same party is paid either way.
  18. Across the two mainnet deployments: identical terms give the same dealId on both escrows, but every digest is under verifyingContract = address(this) and block.chainid, EIP-3009 names the token and to = escrow, box salts include the escrow, the service keys its domain by escrow; testnet/mainnet differ by chainId even where addresses repeat.
  19. No second use: actions and settlements close or move the deal, WithdrawTo digests are marked, a voucher can't lower twice to the same fee, ReferrerShare has a nonce; ECDSA first then EIP-1271; malleable/compact signatures rejected (OpenZeppelin).
  20. 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

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

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
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

Checked and found sound

  1. 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 exactly min(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).
  2. 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…).
  3. The one asymmetry is harmless: a half fetched after block.timestamp fails alone (check 3's upper bound) but a pair passes on the other half's fetchedAt; 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).
  4. 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).
  5. 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).
  6. 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.
  7. combineFacts (prover/src/facts.ts:57-71) applies the same four rules; prover tests 16/16.
  8. v0.31 rules (C-1, C-2): first run 8/8, second run 12/12; the 17th name gives no hash.
  9. RE2 sgiu vs RegExp sgi: 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).
  10. Full reveal / attestor patch: FULLY_REVEALED is in the signed, verifier-rebuilt parameters; the three hunks (client ≥ 3.2.0, direct reveal of every server record, complete HTTP message) are unchanged; assertNoSmuggle pins method, exact path (no hidden byte possible without a template placeholder), Host and Connection: close.
  11. JSON parsing: exact context shape, canonical key order, escape handling; nothing holder-controlled reaches a capture except LDH nameserver names (_stringAfter rejects escapes); holder text can only trigger the inverted regexes (known V5 class).
  12. >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.
  13. 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.
  14. fetchedAt freshness: on the 26 fixtures it lags the claim time 0–19 s (Verisign stamps whole minutes), never leads it.
  15. Service side: proveFor uses the deal verifier's own parametersFor; the prover refuses any other URL; service/src/deals.ts:83 hashes nameservers exactly as _nameserversHash.
  16. solc 0.8.38: bugs_by_version.json has no entry for 0.8.38 (eight for 0.8.30); all 218 files build; every real fixture verifies; the router still lands at Networks.ROUTER.
  17. Second run's U5 resolved (sound): upstream process-handshake.ts (fetched 2026-11-01) calls verifyCertificateChain(certificates, hostname) with the ClientHello SNI (line 237, refusing a hello without SNI), and assert-valid-claim-request.ts:98 requires that hostname to equal the tunnel host the attestor itself connected to.

Unconfirmed

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

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
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

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

Unconfirmed (reasoning, no runnable attack)

PoC files (in the working copy)

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)

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)
R3-F-2. No token-to-address and no one-distinct-escrow-per-position binding (Low)
Info

Checked and found sound

Unconfirmed

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)


Part 3. How the run went