Published copy of reports/AI pentest, fourth run (2026-11-02).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, fourth run (2026-11-01 to 2026-11-02)
A targeted step 4 of docs/ai-pentest.md on commit 1e08fee (SPEC v0.33, which fixed every finding of reports/AI
pentest, third run (2026-11-01).md, deployed on Arbitrum Sepolia): fresh attack agents on what v0.33 changed, in four
areas (D disputes and appeals; AB settlements, signatures and money; F deployment; SV the service, keeper and web
outside disputes), a regression run of every earlier proof, and a fresh verifier for every finding. Proofs are under
contracts/test/pentest/fourth/ and scripts/pentest/fourth/, the verifiers' tests under
contracts/test/pentest/verify/V4_* and scripts/pentest/verify/V4_*.
Part 1. Summary
Result
No Critical or High finding, and no finding needs a contract change: the v0.33 escrow and verifiers stand as deployed. Every earlier fix holds (402/402 pentest proofs, 359 unit/fuzz/invariant/fork tests, service 143, prover 16, web 82, every earlier script). The v0.33 contract changes held where they were attacked: a Kleros quote above the court's cap at the answer reaches yescrow's judge in every band and both courts on the real Kleros of a mainnet fork; the settlement stage can't be shared by two case states or starved into the fallback; every SPEC §8 money row holds with promo, referrer share, sponsor and odd amounts; the judge pin fails closed on Arbitrum One today; the service's startup check refuses every wrong deployment record tried.
Two Medium and five Low findings survived verification, all in the web app, the service, the agent-facing text and the deployment procedure:
- Medium: R4-D-1. The deal page's new "Ask for new jurors" (R3-D-2's fix) sends two transactions, the other ruling's stake first, and promises everything but the new jurors' fee back. The opponent can make it a one-sided stake the funder loses, by racing the second transaction or, with no race at all, by funding the jurors' side with 1 wei less than its full stake beforehand. The deal is paid correctly; the funder's appeal stake is at risk.
- Medium: R4-SV-1. R3-C-1's nameserver size check estimates the registry's answer from the domain's current nameservers. Nameservers under a domain in the same TLD come with glue addresses at Identity Digital, PIR and Nominet (15 of 25 TLDs), which the buyer can add after the check, so a same-registrar deal can still be made unprovable. The seller who follows the page keeps the domain; the deal dies.
- Low: R4-D-2 (
/llms.txttells agents the pre-v0.32 appeal rules), R4-D-3 (the "keep your earlier stakes" warning is shown to everyone, including parties with nothing at risk, and following it can cost them; raised from Info), R4-F-1 (the runbook's--resumeadvice after a first send that never landed leaves a correct deployment the gate's promo bound refuses), R4-F-2 (FOUNDRY_*settings from the env file or the shell change what is compiled, and the gate, rehearsal and source verification all compile in that same environment), R4-SV-2 (the public pre-flight, quote and draft routes feed an unbounded RDAP queue: a free registry probe and backlog, present since an early version through/v1/preflight).
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 |
|---|---|---|---|---|---|
| R4-D-1 | "Ask for new jurors" sends the other ruling's stake first, then the jurors'; a race or a 1-wei-short pre-fund turns it into a one-sided stake the funder loses | Medium | Confirmed; no race needed (partial pre-fund), the half-done flow proven too | Medium | Web and service: jurors' side first, check the first receipt, show what each side has paid, offer it only when nothing is paid, honest texts |
| R4-SV-1 | The nameserver check under-counts glue: nameservers under a same-TLD domain at Identity Digital, PIR, Nominet make the deal unprovable | Medium | Confirmed on-chain with real glue layouts; the practical variant is the buyer's own hosts in the TLD | Medium | Service and web: refuse such names for same-registrar deals, a keeper alert to the seller, the seller's text |
| R4-D-2 | /llms.txt describes the pre-v0.32 appeal rules |
Low | Confirmed; line 56 stale too | Low | Service text, OpenAPI |
| R4-D-3 | The "keep your earlier stakes" warning ignores who staked what and asks a stake that can itself be lost | Info | Upgraded: following it costs a measurable amount to a free trigger | Low | Web and alerts: only for a party with an exposure, with the amounts |
| R4-F-1 | --resume after a first send that never landed deploys a correct version the gate refuses (promo bound) |
Low | Confirmed, at the bottom of Low; forge re-runs run() on resume (correction) |
Low | Docs (run step 2 again while the nonce is 0) and a balance check |
| R4-F-2 | FOUNDRY_* / DAPP_* settings (env file, shell, contracts/.env, global config) change the build; every check compiles in the same environment |
Low | Confirmed; narrower than claimed (exact pragmas, Cancun-only code, the router check catches most) | Low | Scripts: creation-code pins in Networks.sol, settings pinned in foundry.toml, refuse such variables and files |
| R4-SV-2 | /v1/preflight, /v1/quotes, /v1/drafts feed an unbounded per-registry RDAP queue (probe, backlog, memory) |
Low | Confirmed; present since M3 through preflight; the attestor shares the IP | Low | Service: queue cap, rate limits, a bounded cache, IPv6 /64 keys |
| R4-AB-I1–I3 | Settlement texts in the execution period and stages 0/1; USDC's EIP-3009 nonce per token | Info | Confirmed (texts wrong in more places than listed) | Info | Service and web texts |
| R4-F-I1–I5 | The rehearsal's local-URL glob accepts userinfo; CONFIRM_MAINNET/RESUME from the env file; the second-RPC loop runs once; chain ID and decimals not checked at startup; the judge pin checks only the threshold |
Info | Confirmed | Info | Scripts, docs, service |
| R4-SV-I1, I2 | IDN unicodeName may under-count (unconfirmed); the relayed completeTransfer screens the seller, the keeper only the buyer |
Info | Plausible / confirmed by reading | Info | Service |
Limits
- Targeted, not a full run. Attackers only on what v0.33 changed; the rest was covered by the regression run of every earlier proof.
- Not independent of the author. Launched from the session that wrote v0.33, at the user's request; every agent had a fresh context and the procedure's prompt word for word; the attack agents used Fable 5.1, the verifiers Opus 5.5 under the user's rule. No model from another vendor was run.
- Interrupted. The account's 5-hour usage limit stopped five verifiers mid-work; they resumed from their saved state after the reset.
- Some points are read or reasoned, not run. Registrar dashboards (whether they accept 13 nameservers, long names,
many addresses per host), registry throttling of yescrow's IP (R4-SV-2), IDN
unicodeNamesizes. - No findings is not proof of no bugs (each area's "Checked and found sound" lists what was tried).
- Not fixed yet. The fixes follow the user's decisions.
Part 2. Area reports, with each verification
Area D: disputes and appeals
AI pentest, fourth run, area D: disputes and appeals as changed in SPEC v0.33 (commit 1e08fee)
Saved by the orchestrator from the attack agent's final reply (the harness refused the agent's report file).
Findings, one line each
- R4-D-1 — The deal page's "Ask for new jurors" sends two separate transactions, the loser's side first, and promises "you get back everything but their fee, whichever way they rule": the opponent funds the jurors' side between the two, the second reverts, and the party holds a one-sided 3× stake it loses to the opponent (buyer −0.00567 ETH / seller +0.00378 in the AI court; −0.045 / +0.03 ETH in the General Court); the page also offers the option while another ruling is already funded, when its own first transaction completes the appeal — Medium —
cd contracts && forge test --match-path test/pentest/fourth/D4_FundBothSides.t.sol -vvandnode --test scripts/pentest/fourth/D4_new_jurors_options.test.ts - R4-D-2 — The agent-facing
/llms.txtstill describes pre-v0.32 appeals (a one-sided appeal decides the case, yescrow's relayer stakes the jurors' side, anyone may fund a side); an agent following it on a v0.32+ escrow stakes for nothing or to lose (R3-D-2) — Low —node --test scripts/pentest/fourth/D4_llms_text.test.ts - R4-D-3 —
guardEarlierStakesand the round ≥ 1 "against" alert ask whoever is connected to stake 2× the new round's cost (0.54 ETH in an AI-court case) "to keep" earlier stakes, when after the page's own both-sides flow nothing is at risk and a one-sided round-0 stake would be 0.00378 ETH; the new stake is itself lost if the new round reverses — Info —cd contracts && forge test --match-path test/pentest/fourth/D4_FundBothSides.t.sol --match-test R4D3 -vv
Proofs (new files only, nothing else changed, no transaction to a live network):
- contracts/test/pentest/fourth/D4_FundBothSides.t.sol — R4-D-1 (3 tests), the fix check, R4-D-3
- contracts/test/pentest/fourth/D4_Bands.t.sol — 8 tests: every band of v0.33's payArbitrationFee on the real Kleros, all sound
- scripts/pentest/fourth/D4_new_jurors_options.test.ts — R4-D-1, the page's offer
- scripts/pentest/fourth/D4_llms_text.test.ts — R4-D-2
From contracts/: forge test --match-path 'test/pentest/fourth/D4_*.t.sol' -vv → 13 passed, 0 failed (logs scratchpad/pentest4/D4-run1.log, D4-run2.log, D4-run3.log). From the root: node --test scripts/pentest/fourth/*.test.ts → 6 passed.
Setup. Fork of Arbitrum One at block 519,194,000 through the third run's harness (test/pentest/third/D3_Base.t.sol): real KlerosCoreNeo 0x991d…22ea, DisputeKitClassicNeo 0x70B4…5421 (verified source at kleros-v2 e4dce7f, read for fundAppeal, withdrawFeesAndRewards, currentRuling, isAppealFunded), the public DisputeResolver 0xb552…4323, real USDC; the escrow built through DeployAll's mainnet config (courts 34 and 1, Classic kit, caps 0.01 / 0.05 ETH). Only Kleros's RNG is stubbed; real stakers are drawn, the drawn jurors' votes are pranked. On-chain: court 34 0.00027 ETH/juror (3, then 7, then court 33's 15 at 0.018), court 1 0.005 ETH (1, then 3); kit multipliers 10000 / 20000, loser window 5000.
R4-D-1 (Medium). "Ask for new jurors" is two transactions, the loser's side first; the opponent turns it into a one-sided stake the funder loses
- Severity: Medium. A party loses real money (its whole 3 × appealCost stake: 0.00567 ETH per AI-court round 0, 0.045 ETH per General Court round 0, 0.81 ETH in an AI-court round 1) under plausible conditions, the opponent profits (2 × appealCost), and the interface leads the party into it with a promise that is false the moment anyone else funds anything. Not High: the party chose to appeal, the stake is small against the price, and the escrow pays the deal correctly. Same severity as R3-D-2, whose fix this flow is.
- Attacker and preconditions: the party that won the jurors' vote (or any watcher), with 2 × appealCost of ETH and a bot watching the kit's
Contributionevents or the chain; the loser uses the deal page's "Ask for new jurors", as v0.33 intends. A deterministic second path needs no race: any ruling other than the jurors' already funded by anyone while the jurors' isn't. - Steps (
test_R4D1_aiCourt_…): (1) 1,000 USDC deal, real case in court 34; three jurors: the seller delivered. (2) The buyer presses "Ask for new jurors, backing 'the seller didn't deliver': 0.00945 ETH" (steps.ts fundBothSides; "funds both sides at once; all but the new jurors' fee comes back"). Its wallet sendsfundAppeal(id, 2)with 3 × 0.00189 = 0.00567 ETH and waits for the receipt (runtime.ts send). (3) The seller sees it land and sendsfundAppeal(id, 1)with 2 × 0.00189 = 0.00378 ETH: two funded choices, the kit callscore.appeal, round 1 begins. (4) The buyer's second transaction reverts (AppealPeriodIsOver); the dialog shows a generic error. (5) Seven new jurors confirm the seller;applyRuling→ Released (correct). (6)executeRuling,withdrawFeesAndRewards: the buyer's 0.00567 ETH is gone; the seller takes 4 × 0.00189 = 0.00756 ETH. Net: buyer −0.00567 ETH where the page promised −0.00189; seller +0.00378 ETH for front-running. General Court (test_R4D1_generalCourt_…, 50,000 USDC): buyer −0.045 ETH (promised −0.015), seller +0.03 ETH. Deterministic variant (test_R4D1_bothSidesOffered…): the buyer funded its side alone via Kleros's interface;newJurorsOptionsstill offers the seller "Ask for new jurors, backing 'no ruling'" (D4_new_jurors_options.test.ts); the seller's first transaction (3× on choice 0) is the second funded choice and fires the appeal, its second reverts; the new jurors side with the buyer: the seller loses the deal and its 0.00567 ETH stake, the buyer collects 5 × appealCost. - Why plausible: Arbitrum's sequencer has no public mempool, but the page waits for the first receipt before the wallet prompts for the second, so the opponent has block time plus the person's reaction time after a public event; a stale
appeal.funded(theappealsrow is refreshed each keeper tick, the page polls every 5 s) gives the same result with no race; the half-done case (second prompt rejected or failed) leaves a one-sided stake the opponent can turn into this bet at leisure until the period ends, since the jurors' side is fundable to the end. - Impact: a loser following the page's only appeal flow can lose its whole stake to the opponent instead of paying the new jurors' fee; the opponent is paid for racing a yescrow interface; the party whose second transaction failed isn't told it now sits in a one-sided appeal. SPEC §7.2's promise ("a loser who wants new jurors can fund both sides itself and gets back everything but the new jurors' fee"; §13.5) holds only if nobody else funds anything in that round, which nobody can ensure.
- PoC:
cd contracts && forge test --match-path test/pentest/fourth/D4_FundBothSides.t.sol -vv;node --test scripts/pentest/fourth/D4_new_jurors_options.test.ts. - Root cause:
web/components/deal/steps.ts:258-292(fundBothSides):firstfundsother.choice(3×),secondthe jurors' ruling,gosends them one after the other from an EOA, and the intro promises the refund unconditionally.web/lib/appeal.ts:83-91(newJurorsOptions) returns options whenever the jurors' side is unfunded, ignoring that another funded choice makes the first transaction the appeal. DisputeKitClassicBasefundAppealfirescore.appealwhenfundedChoices.length > 1, by whoever funds the second choice;withdrawFeesAndRewardspays each round's pot to the final ruling. - Suggested fix: fund the jurors' ruling first, the other second (
test_fix_jurorsRulingFirst_…: then the only interference is completing the appeal against the jurors, which pays the funder 4× if they confirm and costs it 2× only when it wins the deal; no racer profits); return no option fromnewJurorsOptionswhile any other ruling is funded, and offer instead the jurors' side alone with its true description ("completes the appeal; this stake is lost if the new jurors rule the other way"); make the intro conditional ("if nobody else funds this round") and, when the second transaction fails, say what the party now holds. The only atomic version is a small helper contract funding both in one call and forwarding the kit's withdrawals.
R4-D-2 (Low). /llms.txt tells agents the appeal rules of v0.31
- Severity: Low. Misleading agent-facing instructions about money: an agent following them stakes ETH that at best comes back and at worst is lost (R3-D-2), or believes a one-sided appeal flips a ruling and only delays the payout (R3-D-3). The escrow pays correctly.
- Steps:
service/src/api/llms.ts:59-61: "if only one side is funded when it ends, Kleros rules for that side. yescrow's relayer funds the jurors' ruling within a daily budget (appeal.yescrow); anyone may fund a side with fundAppeal(…) on appeal.kit, paying appeal.choices[].required." On v0.32+ escrows the escrow ignores a one-sided appeal (appeal.oneSidedIgnored), the relayer stakes nothing (reason NOT_NEEDED), and a stake on one side alone can be lost to whoever funds the last appeal alone. The text names neitheroneSidedIgnored, nor the jurors' vote, nor "both sides". - PoC:
node --test scripts/pentest/fourth/D4_llms_text.test.ts. - Root cause: R3-D-2's fix updated the deal page, guide, alerts and SPEC, not
llms.ts. - Fix: describe v0.32+ appeals there, with
appeal.oneSidedIgnoredas the switch, and R4-D-1's caveats.
R4-D-3 (Info). The guard asks for a stake far above what it could protect; after the page's own flow nothing is at risk
web/lib/appeal.ts:97-102,Dispute.tsx:230-243,alerts.ts:181(round ≥ 1): "If you funded an appeal in an earlier round…, fund the jurors' ruling by …, which starts a new round." Shown to whichever party is connected, sent to both.- A party that used "Ask for new jurors" in round 0 funded both sides, and
withdrawFeesAndRewardspays it round 0's whole pot (4 × appealCost) whichever ruling Kleros finally names (proven intest_R4D3_…: 4 × c₀ back after a one-sided round-1 appeal against it). So after the page's own flow no earlier stake is at risk, and the other party staked nothing. - The stake asked in round 1 of an AI-court case is court 33's: 2 × 0.27 = 0.54 ETH, 143× the 0.00378 ETH a one-sided round-0 stake would be (General Court: 0.07 to protect 0.03). The new stake is itself lost if the new jurors reverse; the escrow never needs it. The text states neither amount, nor that it is a bet; a loser who funds round 1 alone (returned whole if unanswered, R3-D-3) can use it as bait. The round ≥ 1 "against" alert says the same.
- Fix: show the guard only to a party with a one-sided earlier stake (from
Contributionevents), with both amounts and the sentence that the new stake is lost if the round reverses; qualify or drop it in the alert.
Checked and found sound
- Every band of v0.33's
payArbitrationFeeon the real Kleros (D4_Bands.t.sol): quote unchanged → Kleros gets A, the escrow holds A, ruling 1 pays P − F and A to the seller's payer; fee raised to ≤ 2A and ≤ cap → Kleros takes 0.0015 out of both fees, the winner gets 0.00012 back (known band, D-3); raised above 2A under the cap → refused, both fees held, and every SPEC §8 refused-case row holds exactly (judge 1, 2, 0, the 14-day timeout, a concession, a stage-1 settlement); opened at the cap with the quote in (cap, 2·cap] → refused in both courts (R3-D-1's case now reaches the judge; General Court 0.1 = 2A refused); opened at the cap and the fee cut back → Kleros at the lower quote, the winner gets 0.0191 of 0.02 back; a quote that reverts, returns 4 bytes, says 0 (Kleros then refuses the underpaid case inside the resolver, caught) oruint256.max→ refused, nothing leaves the escrow; the boundary at exactlysmallDealMaxPriceand one unit above, each court's quote and cap independent of the other's fee. - Gas floors:
disputeandpayArbitrationFeerevertGasTooLowbelow 1M; an answer with 3M gas reverts instead of counting as a refusal; with 6M Kleros takes the case. No starved refusal. - A sponsored answer Kleros refuses (§14.17): the relayer is the payer; the judge's verdict routes each fee by payer; the sponsored party never holds a fee; the signature is dead once the phase changes.
payArbitrationFeesets state and payer before the external call,trywith fixed 5M gas andvalue: cost;_closecredits each wei once on every path. _finalRulingagainst the kit's source:currentRulingoverrides only at execution with exactly one funded choice; nobody can fund after the appeal period, so the read at execution is fixed; courts 1, 33, 34 keep the Classic kit so kit rounds stay in step with the core's.- The two-transaction flow's other orderings: a reverted first transaction stops the second (
sendthrows on a failed receipt); a third choice can't be funded once the appeal fired; partial earlier funding only dilutes the share of that choice's pot. - Keeper stranded-ruling handling for v0.32+ cases (
appeals.ts withdraw/executed): rows only for the service's escrows' Disputed deals that reached an appeal period;executed()once per dispute per tick,executeRulingonly at execution, after simulation, only when the resolver points at the core; NOT_NEEDED rows marked onceruled, nothing withdrawn; a failed send is re-marked next tick; a stranger's sham case costs it two Kleros fees to make the keeper send about six cheap simulated transactions (no amplification);stranded_atdepends only on the resolver'sarbitrator(). - Alerts (
alerts.ts:170-183): the NOT_NEEDED texts say the payout doesn't change, a real appeal needs both sides, don't fund one side alone; the only questionable sentence is the round ≥ 1 guard (R4-D-3). settlementStagewith the round count unreadable keeps the period, soSettleDuringVotesholds (read; the third run's fork tests cover the stage).- Sponsor accounting on a refused case:
refundOfcounts a sponsored loser's fee as refunded while the ETH went to yescrow's fee wallet, not the relayer: the daily budget bounds yescrow's net loss (zero), not the relayer's outflow while Kleros refuses cases. yescrow-to-yescrow; noted, not a finding.
Unconfirmed
- U-1, baiting with the guard: a loser funding round 1 alone (0.81 ETH, back whole if unanswered) shows the winner the 0.54 ETH guard button; if the winner bites, 15 human jurors decide a ≤ 2,000 USDC deal. Kleros's own game, not yescrow's money.
- U-2, the half-done flow as a free option: after the first transaction, if the second never happens, the loser's side stays funded alone and the winner may complete the appeal whenever it expects confirmation; the loser can't withdraw. The mechanics are those of the race test; not separately proven.
- U-3: the keeper's
applyRulingat day 90 for a case mid-round (the second run's U-3) is unchanged.
Verifications for area D
R4-D-1:
Verdict: CONFIRMED Medium
Evidence (file:line)
- web/components/deal/steps.ts:272-290: first funds the other ruling (3c), second the jurors' ruling (2c); go awaits send(first) then send(second). The intro (:284) promises "you get back everything but their fee, whichever way they rule"; the summary (:275) says "Any part already funded by others comes back at once".
- web/components/wallet/runtime.ts:100-107: send waits for the first receipt and throws on a failed one; viem 2.56.9 sends eth_sendTransaction without estimating gas, so the wallet decides whether to warn. On Arbitrum the receipt is polled every 500 ms, so the race window is roughly how long the person takes to confirm the second prompt.
- web/lib/appeal.ts:83-91: newJurorsOptions hides the offer only when the jurors' ruling is fully funded.
- service/src/appeals.ts:42,67,89-104,373: funded comes from getFundedChoices (fully funded choices only) and required is always the full stake; the page can't see a partial contribution.
- Staleness: the keeper refreshes every 30 s (service/src/config.ts:210), the page polls every 5 s (DealPage.tsx:59), the dialog never re-checks after it opens.
- Failure path: SignDialog.tsx:49,66-70 keeps the refund promise on screen; rejecting the second prompt shows "You cancelled the request in your wallet. Nothing was signed or sent." (web/lib/errors.ts:177,213) although the first transaction already staked 3c.
- Deployed kit 0x371A…19BC (verified metadata source, DisputeKitClassicBase.sol): :368 "Appeal period is over."; :386 "Appeal fee is already paid."; :393 partial contributions, :426 refunds the excess; :408-423 starts the appeal on the second funded choice, whoever funds it; :453 refunds a never-fully-funded choice; :456/:461 pay the pot to the final ruling; :596 isAppealFunded ignores partial funding.
- The promise in SPEC.md :277-278 (§7.2) and :464 (§13 item 5).
Own test and its result
- The attack's PoC 5/5 with the claimed amounts (AI court c = 0.00189: buyer −0.00567, seller +0.00378; General Court −0.045 / +0.03); its Node test 4/4.
- contracts/test/pentest/verify/V4_D-1_Interleavings.t.sol 6/6 on the fork:
1. No race needed: the seller puts 2c − 1 wei into the jurors' ruling right after the vote; the page still shows "Not funded"; both of the buyer's page transactions succeed (the kit takes 1 wei of the second) and the appeal starts; jurors confirm: buyer −(3c − 1 wei), seller +(2c − 1 wei); no error shown.
2. The bait is free: if nobody bites, the period still ends at its half, the escrow pays on time and the seller gets its 2c − 1 back.
3. "Jurors' side first" alone loses the same way, but the first receipt shows the buyer contributed only 1 wei, so a page that stops there loses nothing.
4. Fixed order, someone funds the other ruling between the two: the party ends +2c if the jurors are confirmed and −2c if reversed (when it wins the deal); the interloper makes +c on a reversal.
5. Stale page, jurors' side already fully funded: current order starts the appeal at once with the first transaction; fixed order's first transaction reverts, nothing staked.
6. Half-done flow (the attack's U-2), proven: 30 hours in, after the losers' deadline, when the page offers the buyer nothing, the seller completes the appeal: buyer −3c, seller +2c.
- scripts/pentest/verify/V4_D-1_page_offers.test.ts 3/3: the API has no "paid so far" field; the half-done state offers only the third ruling, then nothing; the "Nothing was signed or sent" text.
- Logs: scratchpad/pentest4/vD1-notes/ (V4_D-1_poc.log, V4_D-1_own.log, V4_D-1_node.log), with the extracted kit and core sources.
What I checked - Key files match 1e08fee. - The race on Arbitrum: no public mempool, so nobody front-runs the first transaction; once it lands it is visible within milliseconds, so a bot beats a person confirming a second prompt; a human opponent watching the page usually won't. - Other routes: the partial pre-fund needs no timing; the stale-page case is mostly coincidence; the half-done case stays open until the period ends (36 h AI court, 108 h General Court). - Against the promise: the extra loss is 2c beyond the promised fee; a reversal leaves the party at +c instead of −c, so the opponent turns the hedge into an even 2c bet on the side it picks. - Against v0.32: the same 3c was at risk with the single-side "Fund it" whenever the winner answered; v0.33 doesn't raise the amount at risk, it replaces a warning with a false promise. - Every interleaving of the fixed order: jurors' ruling fully funded before → first tx reverts; partly funded before → caught by the receipt check; other ruling fully funded between → c worse than promised, only on a reversal; other ruling partly funded (y) → y/3 < c worse, only on a reversal; a third choice completed → worst −2c, if it wins; second tx never sent → the jurors' stake comes back whole; second tx after the losers' deadline → reverts, stake back whole.
Reason: Medium stands. The loss is the party's optional appeal stake, the deal is always paid correctly, same mechanism and bound as R3-D-2 (Medium). Not Low: the page promises the opposite, the opponent profits, and the partial pre-fund is free, needs no race and shows nothing. Extra loss: 0.00378 ETH AI-court round 0, 0.03 ETH General Court round 0, 0.54 ETH AI-court round 1.
Corrections to the attack report 1. The race isn't needed: a partial pre-fund (2c − 1 wei) gives the same loss with no revert and no error. "Partial earlier funding only dilutes the share" is wrong for the jurors' ruling. 2. "Jurors' side first" alone doesn't stop that variant (test 3). 3. "No racer profits" is too strong: an interloper betting on a reversal makes +c (only when the party wins the deal); the jurors' winner never gains. 4. The deterministic variant uses an implausible victim (the winner asking for new jurors); if the other funded ruling is the party's own leftover, pressing the button costs it c whatever happens: confusing, not a loss. 5. The second-failure path is worse than a "generic error": rejecting the second prompt says "Nothing was signed or sent", and after the losers' deadline the page offers nothing while the opponent can still complete the appeal.
Fix
1. Order: fund the jurors' ruling (2c) first, then the other ruling (3c).
2. Check after the first transaction: send returns the receipt; continue only if it shows a Contribution from the party to the jurors' ruling of exactly 2c, a ChoiceFunded for it, and the same round count; otherwise stop and say what the party holds.
3. When to offer: the service adds paid per choice, summed from the kit's Contribution events for this round; newJurorsOptions returns options only when every choice has paid == 0 and there is enough time before loserDeadline (e.g. 10 minutes); if the connected party alone fully funded the jurors' ruling (its own half-done flow), offer "Finish: fund '<other>' with 3c"; otherwise no button, and state who funded what.
4. Texts: button "funds the jurors' ruling, then this one; if nobody else funds this round meanwhile, all but the new jurors' fee (c) comes back"; intro "…If someone funds another ruling between your two transactions, the appeal starts with their stake: you get 4c back if the new jurors keep the ruling and lose your 2c if they change it."; drop "Any part already funded by others comes back at once" from the first summary; never show "Nothing was signed or sent" after the first transaction landed — say what the party holds, when it comes back (after the ruling if nobody funds another ruling by the losers' deadline), and how to finish before that deadline; SPEC §7.2 and §13 item 5: "if nobody else funds that round in between".
5. Not recommended: a helper contract funding both sides atomically (adds immutable code that must forward withdrawals, to recover at most c on a reversal).
R4-D-2 and R4-D-3:
R4-D-2: CONFIRMED Low. R4-D-3: UPGRADED to Low (from Info). Both proofs pass with the claimed numbers, and so do the verifier's own tests (8 fork tests, 5 script checks; run 1 had one failure from its own test leaving out a withdrawal, fixed; run 2 8/8).
R4-D-2: /llms.txt still describes the old appeal rules — Verdict: CONFIRMED Low
Evidence
- service/src/api/llms.ts:59-61 says three wrong things for v0.32+ escrows: a side funded alone wins ("Kleros rules for that side"), yescrow's relayer funds the jurors' ruling, "anyone may fund a side". llms.ts:56 is also stale: applyRuling applies "Kleros's final ruling", but on v0.32+ escrows it applies the jurors' vote when Kleros's ruling was set by a one-sided appeal. Added in v0.31 (0e3542c), never updated (v0.32 edited other parts).
- The API itself contradicts it: appeals.ts:121 (the keeper never stakes on these escrows, NOT_NEEDED); appeals.ts:362-367 (the deal JSON carries oneSidedIgnored: true, yescrow.reason: NOT_NEEDED). Every other text says the opposite ("don't fund one side alone"): alerts.ts:170-176, web/lib/appeal.ts:105-111, the disputes guide (guides/disputes/page.tsx:86-98), SPEC.md:277-278, 464.
- llms.txt is the only agent-facing description of appeals: the OpenAPI has no schema for appeal (schemas.ts:110-116), /v1/transactions has no fundAppeal action (schemas.ts:188-200).
Own tests
- contracts/test/pentest/verify/V4_D-23_LlmsAgent.t.sol 2/2 on the real Kleros fork, with exactly the call llms.txt describes. Control: the buyer funds its ruling alone, nobody answers: Kleros names the buyer's ruling, the escrow still releases to the seller, the buyer's 0.00567 ETH comes back whole. Seller's agent follows llms.txt and funds the jurors' ruling (0.00378 ETH), completing a real appeal: if the 7 new jurors reverse, the seller loses the 1,000 USDC deal it had won plus the stake; if they confirm, the buyer funds round 1 alone (0.81 ETH, returned whole) and takes the stake: seller −0.00378, buyer +0.00189 (llms.txt would then have the agent answer with 0.54 ETH). Loser's agent follows llms.txt: the deal doesn't flip, the payout is held 64,800 s longer, and the stake comes back only through executeRuling and withdrawFeesAndRewards, which llms.txt never mentions.
- scripts/pentest/verify/V4_D-23_texts.test.ts, R4-D-2 part 3/3: the JSON contradictions, the contradicting texts, no appeal schema and no fundAppeal action in the OpenAPI.
- The attack's PoC 2/2.
What I checked: dispute.klerosDisputeId is the core's dispute ID, so the call llms.txt gives is otherwise correct. Older testnet escrows (active: false) are still served, and the current text is accurate for them; every mainnet escrow is v0.33.
Severity (Low kept): not Info (an agent following the only agent-facing description loses money, proven); not Medium like R3-D-2 (only agents read it; the agent builds a raw transaction to the dispute kit and a human signs it; that human's own alerts and deal page say the opposite; the loss is bounded by the stake; losing the deal isn't an extra harm, since the loser could always get new jurors by funding both sides).
Corrections: line 56 is stale too; "Kleros rules for that side" is literally true of Kleros — what misleads is that nothing says the escrow ignores it; the fix must keep the old rule for older testnet escrows.
Fix:
1. Line 56: "the case's outcome (Kleros's final ruling, or, on escrows with appeal.oneSidedIgnored, the last round's jurors' vote when Kleros's ruling was set by an appeal only one side funded) is applied by anyone with …".
2. Replace lines 59-61: "After Kleros's jurors vote, the deal's appeal shows the appeal period. On escrows with appeal.oneSidedIgnored (every mainnet escrow), an appeal funded by one side alone changes nothing for the deal. The escrow pays on the last round's jurors' vote once the period ends, although Kleros's own ruling then names the funded side. yescrow stakes nothing (appeal.yescrow.reason NOT_NEEDED). New jurors decide only when two rulings are fully funded, so whoever wants them funds both itself, with fundAppeal(dispute.klerosDisputeId, choice) on appeal.kit, paying appeal.choices[].required, the other ruling before appeal.loserDeadline. In the order R4-D-1's verdict fixes, it gets back everything but the new jurors' fee via the kit's withdrawFeesAndRewards once Kleros has executed the ruling. Never fund one side alone, and never answer the other side's one-sided appeal to "protect" the ruling: Kleros pays every round's stakes to the ruling it finally names, which a later appeal funded by one side alone can set. On older testnet escrows (oneSidedIgnored false), a ruling funded alone wins, and yescrow's relayer funds the jurors' ruling within a daily budget (appeal.yescrow)."
3. Describe appeal in the OpenAPI; a regression test pinning oneSidedIgnored and "never fund one side alone" in llms.txt.
R4-D-3: the "keep your earlier stakes" guard — Verdict: UPGRADED to Low
Evidence: web/lib/appeal.ts:97-102 (computed only from (appeal, now), which holds nothing about who staked what); Dispute.tsx:104, :230-243 (shown to any connected party); alerts.ts:179-181 (the same text to both parties by email and push); the button opens the generic dialog (steps.ts:249-251), which does warn "a stake on one side can be lost"; SPEC.md:278 says the guard is offered "only to someone who staked in an earlier round" — the code doesn't do that; the kit pays each round's pot to whoever funded the ruling Kleros finally names (DisputeKitClassicBase.sol:436-474).
Own tests: contracts/test/pentest/verify/V4_D-23_Guard.t.sol 6/6. Setup for 1–5: the buyer uses "Ask for new jurors" in round 0, the new jurors rule for the buyer in round 1, the seller then funds its own ruling alone in round 1.
| # | Court | Situation | Result |
|---|---|---|---|
| 1 | General | Buyer ignores the guard (its condition is true of the buyer; it asks 0.07 ETH) | Refunded; round 0's 0.06 back; buyer −0.015 (only the new jurors' fee); seller's 0.105 back whole: the trigger was free |
| 2 | General | Buyer follows; round 2 confirms the buyer; seller funds round 2 alone (0.225, back whole); the page asks 0.15 more; buyer stops | Same payout as ignoring; buyer −0.085 instead of −0.015; seller +0.035 |
| 3 | General | Buyer follows; round 2 rules for the seller | Released: buyer loses the 50,000 USDC deal and the 0.07 |
| 4 | AI court | Guard asks 0.54 ETH, 57× the buyer's whole round-0 outlay of 0.00945, none at risk; buyer ignores it | Buyer −0.00189 only; seller's 0.81 back whole |
| 5 | AI court | Buyer follows; round 2 in court 33 (15 jurors) confirms the buyer; seller funds round 2 alone (1.674, back whole) | Buyer −0.54189 instead of −0.00189; seller +0.27 |
| 6 | General | Genuine one-sided earlier stake (what R4-D-1's race leaves the seller holding) | Ignore: −0.03. Follow, round 2 reverses: −0.1 and the deal. Follow, round 2 confirms, buyer stops: +0.1. Follow, round 2 confirms, buyer funds round 2 alone: −0.1, and the page asks 0.15 more |
V4_D-23_texts.test.ts, R4-D-3 part 2/2: the guard is computed from the appeal data alone; the alert body is identical for buyer and seller and names no amount; neither text says the new stake can be lost; in the same round the page also offers the buyer "Ask for new jurors, backing no ruling" (R4-D-1's variant). The attack's PoC 1/1 (0.54 ETH asked against 0.00378 ETH; the page-flow funder's round-0 stakes come back 4×appealCost whatever Kleros names).
What I checked: the guard is the only on-chain way to keep a one-sided earlier stake, no cheaper alternative, but it is a bet, not protection: it re-opens the deal, the new stake is itself one-sided, and it keeps the old stake only if the other side stops (test 6) — a rational opponent decides after seeing the new round's result and can take everything for free unless the staker keeps paying more each round. Bait: a one-sided appeal is free if unanswered and shows the guard to both parties; the baiter gains one appeal cost either way, given ~3× the next appeal cost to lock (0.225 ETH General, 1.674 ETH AI court). The guard also appears between the two transactions of the other side's own "Ask for new jurors", inviting R4-D-1's race innocently (reasoned, not tested).
Reason for upgrade: Info underrates it: the guard addresses exactly the party that used the page's own flow and makes a false claim to it; following it loses a measurable amount to a rational opponent whose triggers are free; for a genuine one-sided stake it raises the loss from 0.03 to 0.1 ETH. Not Medium: needs a completed real appeal first, then a one-sided funding in a later round; the dialog behind the button warns, the alert says the deal is decided again; the price is always paid on the last jurors' vote; the loss is bounded by the stake the party chooses. The General Court amounts (0.07 ETH asked against 0.075 already in) invite following it; the AI court's 0.54 ETH on a ≤ $2,000 deal deters it.
Corrections: the button does show the guard's amount — what's missing is the amount actually at risk (zero after the page's own flow); the dialog does say a one-sided stake can be lost and the alert says the deal is decided again (the guard notice itself says neither); missed: the guard's condition is true of the page-flow funder, the guard's own stake is itself exposed, and the SPEC:278 mismatch.
Fix:
1. Show the guard only to an address with a net exposure, computed from the kit's Contribution and ChoiceFunded events (ABI in appeals.ts:46; no view exists): for each earlier round k, feeRewards = (sum of the funded choices' paid fees) − (the next round's totalFeesForJurors from the core); pay(F) = contrib[F] × feeRewards / paidFees[F] if F was funded in round k, else a pro-rata refund; at risk = pay(jurors' ruling) − pay(ruling funded alone now). A party that funded both sides always gets 0.
2. When shown: "Your earlier stakes: you get back A ETH if this appeal stays one-sided, B ETH if the jurors' ruling is also funded and the new jurors confirm it. Funding it costs Z ETH. The new jurors decide the deal again: if they rule the other way, you lose the deal, Z and the earlier stakes. If they confirm, the other side can fund the next round alone and take Z unless you answer again (about 2× the next appeal cost)." Hide it when Z exceeds the amount at risk.
3. Drop the guard sentence from the shared "against" alert; a per-party alert only to an exposed party.
4. Make SPEC:278 match the code.
Logs: scratchpad/pentest4/vD2-logs/ (R4D2-poc.log, R4D3-poc.log, V4_D-23-run2.log, V4_D-23-texts.log).
Area AB: settlements, signatures and money
AI pentest, fourth run, area AB: settlements, signatures and money (commit 1e08fee, SPEC v0.33)
Saved by the orchestrator from the attack agent's final reply (the harness refused the agent's report file).
Findings (one line each): none. No R4-AB finding: settlements, signatures and the SPEC §8 money paths hold on v0.33, on the mock harness and on the real Kleros of an Arbitrum One fork. Three Info notes (texts, a token property), none moving money.
Proofs: 18/18 pass, all new files (nothing existing edited):
- contracts/test/pentest/fourth/AB4_Money.t.sol (10) and AB4_Stage.t.sol (5): cd contracts && forge test --match-path 'test/pentest/fourth/AB4_[MS]*.t.sol' -vv
- contracts/test/pentest/fourth/AB4_RealKleros.t.sol (3, fork at block 519,194,000): cd contracts && forge test --match-path test/pentest/fourth/AB4_RealKleros.t.sol -vv
- Logs: scratchpad/pentest4/AB-logs/mock2.log, mock4.log, fork1.log.
Working copy: an export of 1e08fee; contracts/src/ untouched. Fork proofs use the real KlerosCoreNeo, DisputeKitClassicNeo, the public DisputeResolver and real USDC, the escrow built exactly as DeployAll configures it for mainnet (courts 34/1, caps 0.01/0.05 ETH, mainnet template and fee recipient); only Kleros's RNG stubbed and drawn jurors' votes pranked. No transaction sent to a live network.
Findings
None.
Checked and found sound
- Stage uniqueness (v0.33 formula)
(changed << 16) | (rounds << 3) | period, rounds 0 on a failed read: distinct case states never share a stage; a Kleros stage is ≥ 2^16 with the period in its low 3 bits, never 0/1/2. "Rounds unreadable" is just another distinct stage: offers don't cross between the two views in either direction, and the vote period is refused in both.testFuzz_stage_distinctStatesDistinctStages,test_roundsUnreadableAndReadable_areDifferentStages_neitherOfferCrosses. - No selective read failure by gas, on the real core. Each
_readgets 63/64 of remaining gas, so a starveddisputesread leaves nothing for signatures and payout. Against KlerosCoreNeo through its proxy, every gas amount 21,000 → floor+200k (step 500) and 30M either runs out of gas (last at 48,500) or is refusedInvalidSignaturebecause the real stage was read; the real-stage offer works from the floor (180,029) up. Mock core: floor 201,905, last OOG 48,250.test_fork_realCore_noGasAmountSettlesAStage2Offer,test_gas_noGasAmountSettlesAStage2Offer_whileTheCoreAnswers. - Return data isn't party-controllable. Reads go to the immutable core by plain staticcall;
changed= 0, period > 7 or a flipping core need the governor's upgrade, already documented as deciding disputed deals (docs/keys.md). Not new in v0.33. - Execution period. Real court: stage =
(changed << 16) | 8 | 4; a settlement there works and only racesapplyRuling; after either, the other isWrongState; no second payout.test_fork_executionPeriod_stageIsPeriod4_settlementRacesApplyRuling. - v0.33 refusal with a sponsor, real Kleros. Governor sets court 34 to 0.005 ETH/juror (case 0.015 > cap 0.01, within both fees); seller disputes (own ETH); the relayer pays the buyer's answer with the buyer's signature →
Judging, both fees held, nothing to Kleros. Judge for seller: seller withdraws 1,000 USDC (no fee) + 0.01 ETH; the relayer's 0.01 ETH goes to the mainnet fee wallet by realwithdrawEth; escrow ends at 0 wei. Judge for buyer: relayer refunded whole, yescrow keeps the seller's. Exactly SPEC §8/§7.5.test_fork_bandRefused_sponsoredAnswer_judgeBothWays_realWithdrawals. - Every refused-case row with promo + referrer + sponsor + odd amounts (price 4,999,999,999; A = 0.07 ETH + 1 wei; voucher 50 bps; referrer 25%): judge for opener (P whole, voucher and referrer share vanish, sponsor's fee to the fee wallet), judge for sponsored answerer (sponsor refunded whole), split (floor halves to the seller's side, odd unit/wei to the buyer's, exactly one A to yescrow), judge timeout (no fee, both fees back whole), concessions (voucher fee applies with referrer share, both fees back), settlement at stage 1 (S − F, P − S, both fees back; judge can't rule afterwards, offer can't settle twice), 256-run fuzz over S and the voucher. Escrow holds exactly what it owes; judge owed nothing (invariants 1, 2, 5, 9).
AB4_Money.t.sol. - Cap boundary. Quote exactly at the cap → Kleros (2 wei held when A = cap/2 + 1); one wei above → judge with both fees held; the stored A is paid either way, so the answer can't be made to fail by the quote.
test_capBoundary_atTheCapKleros_oneWeiAboveTheJudge. - Known Kleros rows. Quote rose under the cap: Kleros takes it, the winner's payer (sponsor) gets 2A − C; quote fell: winner gets more than A. As SPEC §8 /
docs/audit.md§5.test_quoteMovesUnderTheCap_klerosCase_winnerGets2AminusC. - Bindings across deployments. Same terms → same deal ID on two escrows, yet funding can't cross (EIP-3009 signs over the payee = calling escrow),
Accept/Confirm/Cancel/Dispute/PayArbitrationFee(phased),Settle,FeeVoucher,ReferrerShare,WithdrawToare allInvalidSignatureon the other escrow, a usedWithdrawTodigest is refused twice, and each escrow has its own box the other can't sweep. Testnet/mainnet differ by chain ID.test_twoDeployments_sameDealId_noSignatureCrosses. - Stage 0 vs the fee deadline (property). A stage-0 offer keeps working after
feeDeadlineuntil someone callsfeeTimeout(stage follows state, not the clock); the opener signed it and anyone may call the timeout.test_property_stage0Offer_outlivesTheFeeDeadline_untilTheTimeoutIsCalled. - Withdrawals pay only the account itself, including the real mainnet fee wallet on the fork.
- Service handouts by case (code read,
escrow.tssettlementStageOf,app.ts,typed-data.ts): other states → 409DEAL_WRONG_STATE(new, closes R3-AB-I2); AwaitingFee → stage 0 text; Judging → stage 1 text; vote period → 409SETTLEMENT_DURING_VOTES, re-checked before the relayer sends; evidence/commit/appeal → formula stage withstageEndsAtand the period's "sooner" text; execution → formula stage, no end;disputesunreadable → routes throw before handing anything out (the stage-2 text is never actually sent); rounds unreadable alone → normal period text. Relayer simulates first.sponsor.tsrefundOftreats a refused case's fee as not spent whether it returned to the relayer or went to the fee wallet (both yescrow's); a reverted sponsored send releases its reservation. - Keys within their powers: judge only 0/1/2 on
Judgingbefore its deadline, refused-case fee only to the fee recipient; relayer paying a fee is only the refund's recipient; promo and fee-recipient signatures don't cross escrows.
Info (text/property only, nothing moves)
- I-1
settleTypedDatain the execution period says the offer "lapses when the case moves to its next period" and "the other side may wait to see how the jurors vote": no next period exists, the vote is known, the offer ends atapplyRuling. - I-2 Stages 0 and 1 get no
stageEndsAt, and the texts (API and webofferLapse) don't say the offer also ends atfeeTimeout/judgeRuling/judgeTimeout. - I-3 USDC's EIP-3009 nonces are per signer per token and the nonce is the deal ID: the same terms+proposer nonce on two escrows of one token fund by signature only once (second: "authorization is used or canceled"; the deposit address still works). The mainnet escrows hold different tokens and the API draws random nonces; only the buyer can consume or cancel the nonce.
Unconfirmed
Nothing: every idea became a passing soundness proof or needs Kleros's governor, whose power is already documented in docs/keys.md and docs/audit.md §4.
Verification of area AB's Info notes
All three notes hold; none deserves more than Info. No money can move on any of them: every settlement needs both signatures, and the stage check in settle is sound. The texts are wrong in a few more places than the attack listed. Code read; one node script printed settleTypedData's text for each stage (scratchpad/verify-ab4/texts.ts).
I-1: the API's text in Kleros's execution period — Verdict: CONFIRMED Info; the stale clause is wider than the note says.
- service/src/typed-data.ts:136 is the text sent in the execution period: "lapses when the case moves to its next period. Until then it can't be withdrawn, and the other side may wait to see how the jurors vote before signing." service/src/escrow.ts:382 gives no end time from period 4 on. YescrowEscrow.sol:623-628: the case never leaves period 4; the offer ends at applyRuling or its own deadline, and the API puts no upper limit on a settlement's deadline (api/app.ts:939). keeper.ts:184 calls applyRuling as soon as period 4 is reached.
- Wider: "the other side may wait to see how the jurors vote" has been false in every period since v0.32: in evidence and commit the offer lapses before any vote is public (the stage includes the period; settle refused during the vote, YescrowEscrow.sol:608-609); in the appeal period the votes are already public. SPEC.md:276 says settling happens "when both sides know the same". The clause dates from v0.31 (0e3542c).
- The web has the same error: web/components/deal/steps.ts:333-339 offerLapse says ", and before the Kleros case moves to its next period" for period 4, and the same for stage 2 (web/lib/appeal.ts:26), where the offer doesn't lapse when the case moves on (stage 2 only if Kleros's governor changes the court).
- Info: in period 4 the result is fixed and readable by both; the signer can end the offer by calling applyRuling.
- Fix: typed-data.ts:136 last branch: period 4 → " This offer is for the Kleros case's execution period, after its final ruling: there is no next period, so it holds until its deadline or until someone applies the ruling, which anyone may do now and which ends the deal."; otherwise "…it lapses when the case moves to its next period${ends}. Until then it can't be withdrawn" + (v0.31 stage: ", and the other side may wait to see how the jurors vote before signing"; appeal: "; both sides already see the jurors' votes"; else ", but it lapses before any juror's vote is public"). steps.ts offerLapse: stage 2 → ", and before yescrow's judge takes the case"; period 4 → ", and before someone applies the ruling, which anyone may do now".
I-2: stages 0 and 1 have no end time, and the texts leave out how they end — Verdict: CONFIRMED Info.
- service/src/escrow.ts:378 returns no end time unless Disputed. API texts typed-data.ts:131, :133; web steps.ts:334-335 (stage 0: "before the dispute is answered"; stage 1: nothing).
- Stage 0 also ends through feeTimeout (YescrowEscrow.sol:584-589) and when the answer is paid and Kleros refuses (the judge then has it, no case created, :577-580): the API's "lapses once the case is created" is wrong there. Stage 1 ends at judgeRuling, at any moment (:643), or at judgeTimeout (:662). keeper.ts:173, :195 call both timeouts at once.
- Info: an offer ending early moves no money; a stage-0 offer outliving the fee deadline gives only the split the signer offered; a stage-1 offer gives no option on the judge's decision (reasons saved through admin-only routes, api/app.ts:900-909, shown only once Judged exists, judge.ts:86-92).
- Fix: typed-data.ts:131: " This offer is for before the Kleros case exists: it lapses once the other side pays its fee (a Kleros case or yescrow's judge then has the dispute), or once anyone closes the deal for the side that paid after the fee deadline." :133: " This offer is for while yescrow's judge has the case: it lapses once the judge rules, which can happen at any moment, or once anyone splits the deal 50/50 after the judge's deadline." steps.ts:334-335: stage 1 → ", and before yescrow's judge rules or its deadline passes"; stage 0 → ", and before the dispute is answered or closed at its fee deadline". Optional: escrow.ts:378 returns feeDeadline for AwaitingFee and rulingDeadline for Judging as endsAt.
I-3: USDC's EIP-3009 nonce is the deal ID — Verdict: CONFIRMED Info.
- YescrowEscrow.sol:806-808 dealIdOf hashes terms, proposer, nonce (no escrow or chain); :910 funding calls receiveWithAuthorization(buyer, address(this), …, dealId, sig); USDC nonces are per signer per token contract, so a second escrow of the same token can't fund the same ID by signature. Nothing moves; the deposit address still works; only the buyer can use up or cancel the nonce (payee must be the caller; cancellation needs the buyer's signature); the payee is in the signature (typed-data.ts:196). The mainnet escrows hold different tokens (DeployAll.s.sol:82-84); the API draws a random nonce by default (api/app.ts:568).
- Fix: none needed.
Area F: deployment
AI penetration test, fourth run (SPEC v0.33, commit 1e08fee) — 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)
- R4-F-1 — The runbook's recovery after a stalled first send (
forge script … --resume) deploys a correct version at the promised addresses that the v0.33 gate refuses for good ("escrow: promoEnds too early") — Low —cd contracts && forge test --match-path test/pentest/fourth/F4_ResumeTrap.t.sol -vv(4/4) andscripts/pentest/fourth/F4_resume_replays_stale_promo.sh - R4-F-2 —
FOUNDRY_*variables in.env.mainnetor the operator's shell change what is compiled and deployed; the gate, the rehearsal and the blocking source verification all pass because they run in the same environment, so the immutable code need not be the audited commit's build — Low —scripts/pentest/fourth/F4_env_file_changes_the_build.sh
Targeted run on what v0.33 changed in the deployment: the mainnet pins in contracts/script/Networks.sol, DeployAll.s.sol's pre-broadcast checks, CheckDeployment.s.sol (the launch gate), scripts/deploy-all.sh, scripts/rehearse-mainnet.sh, docs/mainnet.md §1–§3 as an operator would follow them, and the service's startup checks of the deployment record. 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 of Arbitrum One, local anvil, read-only RPC calls only); only anvil's test keys were used. New files: contracts/test/pentest/fourth/F4_ResumeTrap.t.sol, contracts/test/pentest/fourth/F4_ArbitrumOneToday.t.sol, scripts/pentest/fourth/F4_resume_replays_stale_promo.sh, scripts/pentest/fourth/F4_env_file_changes_the_build.sh. All proofs pass (6/6 tests, both scripts). Logs: scratchpad/pentest4/F-work/F4-tests.log, F4-resume.log, F4-env.log.
Headline. The v0.33 pins hold against everything a wrong or malicious deployment could vary: on Arbitrum One every field of the escrow's Config is pinned, derived from a pin, or bounded, so exactly one configuration passes DeployAll and the gate (up to promoEnds within an hour, and the build environment, R4-F-2). The judge pin fails closed on the real chain today: 0x…1d6e has no code, safeThreshold is 0, DeployAll refuses the documented .env.mainnet before anything is sent, a real Safe without the pin is refused with the message that says what to do, and nothing can put code at that address on Arbitrum One. No way was found to burn the promised addresses through the scripts, to fool RESUME=1 with a stale or edited broadcast, or to make the service serve a record whose tokens don't match the chain. What remains are two Low gaps in the procedure around the gate, not in what the gate compares: a sharp edge of the new ±1 h promo bound when the runbook's own --resume advice is followed, and the build environment the gate compiles "this build" in.
Findings
R4-F-1. The runbook's --resume after a stalled first send gives a correct deployment the gate refuses forever (Low)
- Attacker and preconditions: no attacker; yescrow's own launch. The first of DeployAll's five sends stalls (the node refuses it: an underfunded deployer or a base-fee spike, both named in docs/mainnet.md §2; or an RPC outage), and more than an hour passes before the operator follows docs/mainnet.md §2 ("a stall, not a lost nonce: top up and run the same
forge scriptcommand with--resume"). - Steps (
F4_ResumeTrap.t.sol: Arbitrum One fork at block 519,194,000, real USDC, USDT0, Kleros and its resolver, the router through the real keyless proxy, the rehearsal's stand-in at 0x…1d6e sinceMAINNET_JUDGEis unpinned;F4_resume_replays_stale_promo.sh: forge 1.8.5 on a local anvil): 1.scripts/deploy-all.shcomputesPROMO_ENDS = now + 92 days(line 74) and runsforge script DeployAll --broadcast --slow(line 88). DeployAll's simulation at T0 passes (promoEndswithin an hour ofblock.timestamp + 92 days, DeployAll.s.sol:171–175). The five transactions,promoEndsinside the escrows' constructor arguments, are signed and written tobroadcast/DeployAll.s.sol/42161/run-latest.jsonbefore the first send (shell proof:hash: null, receipts 0; the deployer's nonce stays 0). 2.RESUME=1 deploy-all.shcan't take that file (txhisnull;cast receipt null→ "invalid tx hash"), so the operator does what §2 says:forge script … --resume. forge does not runrun()again ("No files changed, compilation skipped", norun() executedline): it replays the saved transactions as signed at T0. The proof's contract holds the stale constructor argument (ends() = 1000although the environment said 2000, two hours later). 3. On launch day that is: the five contracts land at T1 = T0 + 2 h from the mainnet deployer at nonces 0–4, both escrows at the promised addresses with every pinned value right andpromoEnds = T0 + 92 days(test_F4_1_stallThenResume_gateRefusesTheCorrectDeployment). 4.RESUME=1 deploy-all.shthen runs the gate withDEPLOY_TIME= the first verifier's block time = T1 (lines 101, 111–113):CheckDeploymentrequirespromoEnds + 1 h >= DEPLOY_TIME + 92 days(CheckDeployment.s.sol:345) and reverts "escrow: promoEnds too early";set -eexits before the record is written (line 164). The same gate told the deployment happened at T0 passes in full, Kleros cases included: the time bound is its only objection. The bound is exact: a stall of 1 h passes, 1 h + 1 s fails (test_F4_1_boundary_…). 5. The refusal is permanent: a second DeployAll is refused by its own nonce check ("the mainnet deployer has sent something",test_F4_1_afterwards_…); docs/mainnet.md §3 step 3 can't run as written (no record to readdeployBlockfrom), and a hand-written record's gate fails the same way. 6. Control (test_F4_control_freshRunAfterTheStall_passes): after a stall before the first send the nonce is still 0, so a freshdeploy-all.sh arbitrum-onerun recomputesPROMO_ENDS, passes DeployAll's check at T1, deploys at the promised addresses and passes the gate. The runbook doesn't say this; it says--resume. - Impact: yescrow is left with an immutable, otherwise perfect version at the addresses Kleros was told about that the launch gate (and the launch-day re-run) refuses, with no documented way out but writing the record by hand or relaxing the gate, which undermines the gate's purpose; the NAMEPROS promo is shorter than the promised 92 days by the stall. No party's funds; no fee loss. Not Medium: no deal deadline moves and nobody can force the stall. Low rather than Info: the trap is in the documented recovery path of a one-shot immutable deployment, the gate's message doesn't say the version is otherwise right, and the fix is cheap.
- Commands:
cd contracts && forge test --match-path test/pentest/fourth/F4_ResumeTrap.t.sol -vv;scripts/pentest/fourth/F4_resume_replays_stale_promo.sh - Root cause:
scripts/deploy-all.sh:74fixesPROMO_ENDSfrom the clock at simulation time and:111measures the gate'sDEPLOY_TIMEfrom the first receipt;contracts/script/DeployAll.s.sol:171–175checks the promo end only against the simulation'sblock.timestamp;contracts/script/CheckDeployment.s.sol:343–346(the v0.33 ±1 h bound) and:90(DEPLOY_TIMErequired on mainnet);docs/mainnet.md:70(§2 "run the sameforge scriptcommand with--resume") and:111(§3RESUME=1"if step 2 stops after deploying"), neither distinguishing a stall before the first transaction landed from one after it; forge's--resumereplays saved transactions without re-runningrun(). - Suggested fix: docs/mainnet.md §2/§3: "if the deployer's nonce is still 0, run
deploy-all.shagain (it recomputes the promo end); use--resumeonly when the first verifier is on chain, thenRESUME=1". Scripts: make the stall unlikely (deploy-all.shorDeployAll.checkrefuses to start on mainnet when the deployer's balance is below the five sends' estimated cost, docs §2's 0.01 ETH), and inRESUME=1mode compare the broadcast file'stimestampwith the first receipt's block time and explain "the simulation ran N hours before the first send: the promo end is N hours short" instead of the bare "promoEnds too early". A contract-level option for a later version: the escrow computespromoEnds = block.timestamp + promoDaysin its constructor, so the immutable always matches its deployment block.
R4-F-2. FOUNDRY_* in the env file or the shell steers the build; the gate, the rehearsal and source verification can't tell (Low)
- Attacker and preconditions: whoever writes
.env.mainnet, or an operator whose shell exports aFOUNDRY_*(orDAPP_*) variable from other work (FOUNDRY_OPTIMIZER_RUNS,FOUNDRY_EVM_VERSION,FOUNDRY_OPTIMIZER,FOUNDRY_SOLC— a version, or a path to any binary). The same class as R3-F-I2, which v0.33 closed forSKIP_VERIFY/REQUIRE_KLEROSonly. - Steps (
F4_env_file_changes_the_build.sh, forge 1.8.5, a throwaway project on a local anvil, then the yescrow project's ownforge config): 1.scripts/deploy-all.sh:27exports every variable of the env file (set -a; . "$env_file") into the process that runsforge scriptfor DeployAll and CheckDeployment andforge verify-contract;rehearse-mainnet.shgoes through the same script. Neither scrubsFOUNDRY_*(FOUNDRY_BROADCASTis the only one they set themselves). 2. An env file withFOUNDRY_OPTIMIZER_RUNS=1andFOUNDRY_EVM_VERSION=paris, sourced that way, changesforge configto runs 1 / paris withfoundry.tomluntouched; the deployed runtime differs from the committed build's (sha256e235e06e…vs68bde727…). 3. A gate modelled on CheckDeployment (a fresh build's codehash must equal the deployed code) passes in the same environment and fails ("code isn't this build's") from a clean shell. CheckDeployment's "this build" is exactly that:type(YescrowEscrow).creationCode(CheckDeployment.s.sol:135) and the fresh verifiers it compiles (:235–243), compiled in the deploying environment.forge verify-contractreads the same configuration, so Arbiscan and Sourcify compile with the overridden settings and the blocking verification passes too. 4. The yescrow project reads the same overrides (forge config, no build): runs 1 / paris;FOUNDRY_SOLC=0.8.30orFOUNDRY_SOLC=/usr/bin/falsereplace the pinned 0.8.38 compiler. - Impact: the immutable escrows and verifiers on Arbitrum One can be a build the audit never saw (another optimizer setting or EVM version, another solc version, or with a
FOUNDRY_SOLCpath any program's output) while every check the procedure has (DeployAll, the gate twice, the rehearsal, Arbiscan, Sourcify) passes; the third run's "the test build is the deploy artifact" guarantee is void. Only a clean checkout of the release commit disagrees, and nothing in the procedure runs one. Low: it needs yescrow's own environment to be wrong first (the R3-F-1 precondition class), and the honestFOUNDRY_PROFILE=coveragemistake is caught by EIP-170 as the third run showed. Not Info: the gate's stated job is to catch a deployment that isn't this commit's build (R2-F-1), and here it can't by construction. - Command:
scripts/pentest/fourth/F4_env_file_changes_the_build.sh - Root cause:
scripts/deploy-all.sh:27(set -aover the whole env file) with noFOUNDRY_*/DAPP_*scrub beforeforge script(lines 88, 113) andforge verify-contract(119–135);scripts/rehearse-mainnet.sh:45–48(same path);contracts/script/CheckDeployment.s.sol:135–141,:235–245,:394–397("this build" = the running process's build);contracts/script/Networks.solpins template hashes but no code hashes. - Suggested fix: (1) in
deploy-all.shandrehearse-mainnet.sh, after sourcing, unset everyFOUNDRY_*/DAPP_*variable exceptFOUNDRY_BROADCAST(or run forge throughenv -u …), and refuse an env file containing one, as v0.33 does forSKIP_VERIFY. (2) Pin the release build inNetworks.sollike the template hashes:keccak256(type(YescrowEscrow).creationCode)and the two verifiers' creation-code hashes, compared by DeployAll before sending and by the gate, with a unit test next toF_CheckDeploymentGapsthat fails when the committed build drifts, so a third party can check them against a clean build. (3) docs/mainnet.md §3: run from a clean shell and run step 3's gate from a fresh clone of the release commit.
Info
- I-1.
rehearse-mainnet.sh:15–16builds the fork URL fromFORK_PORT, anddeploy-all.sh:46acceptsREHEARSAL_RPC_URLby the globhttp://127.0.0.1:*|http://localhost:*, which also matches userinfo URLs:http://127.0.0.1:9@127.0.0.2:8603is accepted, andcast chain-id/cast rpc anvil_setBalancethrough it talk to 127.0.0.2 (shown with an anvil on 127.0.0.2 answering chain 42161). AFORK_PORTorREHEARSAL_RPC_URLset that way sends the rehearsal — DeployRouter signed by the relayer key, DeployAll by the mainnet deployer key, both valid on chain 42161 — to the URL's host, which could keep and later broadcast them (a deployment with the rehearsal'spromoEnds, R4-F-1's shape). Needs control of the operator's environment, so Info. Fix:[[ $port =~ ^[0-9]+$ ]]in the rehearsal and a strict^http://(127\.0\.0\.1|localhost):[0-9]+/?$in deploy-all.sh. - I-2.
CONFIRM_MAINNET=yesandRESUME=1are honoured from the env file (deploy-all.sh:27before:52,:82), unlikeSKIP_VERIFY/REQUIRE_KLEROSsince v0.33: an env file withCONFIRM_MAINNET=yespasses the confirmation (shown: the run stops at the next check, "no RPC"). Read both before sourcing. - I-3. docs/mainnet.md §3 step 3's "second RPC provider" loop takes
ARBITRUM_ONE_RPC_URL, which.env.mainnet.example:11sets to one URL, so it runs once; the three-provider list isRPC_URL(:12). - I-4. The service takes
chainIdfromCHAIN_IDor the record (service/src/config.ts:184) and never compares it with the RPC'seth_chainId;loadEscrowcompares the record's token address and mainnet symbol with the chain (escrow.ts:191–194) but copiesdecimalsas is (escrow.ts:206,config.ts:159), whichformatUnitsuses in every API text, alert email and relayer message (quotes.ts:102,alerts.ts:126,api/app.ts:1390,:1487,typed-data.ts:38) while the web formats with 6 fixed. Hand-edited-record mistakes with no money path (wrong chain: signatures invalid; wrong decimals: texts only); adecimals()andeth_chainIdcomparison at startup would close them. - I-5.
Networks.safeThresholdis the only property checked of the pinned judge: onceMAINNET_JUDGEis set, any address whosegetThreshold()answers ≥ 2 passes, including a mistyped or stranger's Safe. Pin an owner too (isOwnerof the main wallet, orgetOwners()).
Checked and found sound
- The judge pin fails closed on the real chain (
F4_ArbitrumOneToday.t.sol, Arbitrum One latest block, reads only; andcast):MAINNET_JUDGEis 0, somainnetJudge()is the stand-in 0x…1d6e; it has no code on Arbitrum One (cast code→0x,getThreshold()→ empty),safeThresholdreturns 0, andDeployAll.checkwith the documented.env.mainnet(router present) reverts "JUDGE must be a Safe of threshold 2 or more on mainnet" before anything is sent; a Safe-shaped address that isn't the pin reverts "JUDGE is not Networks.MAINNET_JUDGE: pin the judge Safe there"; only code at 0x…1d6e lets the unpinned check through, which is what the rehearsal'sanvil_setCodedoes on its fork; the gate'sexpected().judgeis the same pin with the same threshold requirement. Nothing can put code at 0x…1d6e on Arbitrum One: no key has that address, CREATE/CREATE2 can't target it, it is not an ArbOS precompile (0x64–0x72, 0xc8, 0xff), and EIP-7702 needs the account's key. - The rehearsal's stand-in can't leak into a real deployment: the stand-in exists only on the anvil fork; the real run reads
ARBITRUM_ONE_RPC_URLfrom.env.mainnet, neverREHEARSAL_RPC_URL(read before sourcing, local URLs only, but see I-1); the rehearsal's record and broadcast go to temporary directories (deploy-all.sh:47–48); the repo'scontracts/broadcast/DeployAll.s.sol/holds only421614, so no stale chain-42161 broadcast exists forRESUME=1. AJUDGEset in.env.mainnetbefore the pin fails the rehearsal with the pin's message. - Every
Configfield is pinned, derived or bounded on Arbitrum One:usdc(token per promised address),router(Networks.ROUTER, code present),arbitrator, bothextraData, both fee caps,resolver(and itsarbitrator()),judge(pin + threshold),templateData/templateDataMappings(hashes per address),feeRecipient,feeBps,referrerShareBps(from the creation arguments),promoSigner,promoEnds(±1 h ofDEPLOY_TIME+ 92 days), the three windows (exact),verifiers(exactly[VERIFIER], whose code and attestors equal fresh builds),smallDealMaxPrice; the list must be exactly[USDC, USDT]at the promised addresses; the code is this build's creation code run with those arguments at the escrow's address, tied to the escrow by its creation receipt. One configuration passes, up to R4-F-2's build environment. - DeployAll before broadcast: the refusals, the pins and the post-broadcast address
requires run in one simulatedrun(); forge sends nothing if any reverts;test_F4_1_afterwards_…shows the nonce check refusing a used deployer; the--resumeproof shows forge keeps the nonce unused on a stall. scripts/deploy-all.shv0.33:SKIP_VERIFYandREQUIRE_KLEROScome only from the command line (26–29); the broadcast's token check (97–100) plus the gate's exact-two-escrows rule leave an edited broadcast no freedom except the two RdapVerifier addresses, which the mainnet-blocking source verification (119–122) fails for any address without exactly that code and constructor argument;RESUME=1on a stalled broadcast fails atcast receipt null; the router is read from the escrow on chain (104) and compared withNetworks.ROUTERby the gate; the record is written only after the gate and the verification.- docs/mainnet.md §1–§3 commands:
cast block … --field timestampandcast receipt … blockNumberprint bare integers (checked on Sepolia), so step 3'sDEPLOY_TIMEparses; step 3 hard-codes the two promised escrows and takesVERIFIERfrom the record, which the gate checks by code hash and through each escrow's allow-list; step 1'sDeployRouterrefuses the mainnet deployer key, does nothing where the router exists, and refuses a build that wouldn't land atNetworks.ROUTER. - The service's startup checks:
escrowsOfrefuses a record naming an escrow twice (config.ts:155–156);loadEscrowreadsusdc()from the chain and refuses an entry whose token address differs, and on 42161 a symbol whose pinned token isn't the one held (escrow.ts:163–166,191–194); an entry without code fails startup; a strayESCROW_ADDRESSin the env meets the same comparisons. Networks.solagainst Arbitrum One today (test_F4_today_pins_matchTheChain): deployer 0xA8C2…D14D nonce 0 and balance 0;CREATE(deployer, 3/4)= the pinned escrow addresses, both empty; keyless proxy present; router not yet deployed on Arbitrum One (runbook step 1); resolver 0xb552…4323 allow-listed and pointing at core 0x991d…22ea;arbitrationCost(1,1,1)= 0.005 ETH and(34,3,1)= 0.00081 ETH, under the 0.05/0.01 ETH caps;mainnetTokenmaps each promised address to its token and anything else to 0.- Burning the promised addresses through the scripts:
DeployRouterandDeployTestUSDTrefuse the mainnet deployer or mainnet;Deploy.s.solis Sepolia-only (require(block.chainid == 421614)), soscripts/deploy.shanddeploy-escrow.shcan't spend a mainnet nonce;fund-testnet-wallets.shreadsARBITRUM_SEPOLIA_RPC_URL; a forge stall leaves the nonce unused (shown); a build over EIP-170 is refused by forge's size check (third run).
Unconfirmed
- U-1. A lying or compromised RPC provider could make every read the gate relies on pass (the third run's U-2). I-3 makes it worse in practice: with the example env the launch-day re-run uses the same single provider.
- U-2. If Arbitrum's sequencer timestamp and the VPS clock differed by more than an hour, DeployAll would refuse the promo end (fail closed; a stall of R4-F-1's kind if the operator then follows
--resume); not an attack. - U-3.
FOUNDRY_OPTIMIZER=falseor another solc version in the environment might push the escrow over EIP-170 and be stopped by forge's size check likeFOUNDRY_PROFILE=coverage; not built here (memory), and irrelevant to R4-F-2's point, which the verified overrides already make.
Verifications for area F
R4-F-1 and Info items I-1 to I-5:
Verdict: CONFIRMED Low
The attack's two proofs pass and show what it claims. The trap happens only when the first of the five sends stalls, and the attack's explanation of how forge behaves needs correcting. It sits at the bottom of Low.
Evidence
- scripts/deploy-all.sh:74 sets PROMO_ENDS from the clock + 92 days; :88 runs forge without --private-key, so the key comes only from inside run() via vm.startBroadcast(pk) (DeployAll.s.sol:72–77); :101, :111–113 give the gate DEPLOY_TIME = the first verifier's block time.
- DeployAll.s.sol:171–175 checks the promo end only against the simulation's block.timestamp; CheckDeployment.s.sol:90, :343–346 hold the ±1 h bound.
- docs/mainnet.md:70 gives the --resume advice, written for a stall at "a later one"; :103–107 (step 3) reads deployBlock from the record; :111 says RESUME=1 is for after deploying.
Own tests/scripts (logs scratchpad/pentest4/vF1-logs/)
- Attack's proofs: F4_ResumeTrap.t.sol 4/4; F4_resume_replays_stale_promo.sh passes (ends() = 1000).
- contracts/test/pentest/verify/V4_F-1_StallScope.t.sol 4/4 on the Arbitrum One fork: (1) the docs' own case (stall after the first verifier): the gate passes for a 2 h and a 10-day stall, the escrows' promo then 92 days minus the stall from their own creation (2206 h, 1968 h); the same 2 h before the first send is refused. (2) DeployAll's check at T1 refuses the stale PROMO_ENDS and accepts one recomputed at T1; the T0 transactions forge then sends fail the gate. (3) A way out from the chain alone: with DEPLOY_TIME = promoEnds − 92 days the whole gate passes (every pin, the code, Kleros); the stall comes out at exactly 5 h. (4) Funding: base fee 0.020 gwei at the fork block; the first send ~3.50M gas (~0.00018 ETH upfront), an escrow send ~7.16M gas (~0.00037 ETH); the documented 0.01 ETH only fails the first send above ~1.1 gwei, 54× that base fee.
- scripts/pentest/verify/V4_F-1_resume_reexecutes.sh (forge 1.8.5, anvil as chain 42161, a DeployAll-shaped script with a marker file per run()): the stall: "Failed to estimate gas for tx", nonce stays 0, run-latest.json holds unsigned requests with hash null. A: --resume with the stalled run's PROMO_ENDS: run() runs again at T1 and its check refuses; forge reports "No associated wallet", nothing sent. B: --resume with PROMO_ENDS recomputed: run() runs again and passes, but forge sends the saved T0 transactions; the gate's bound refuses them. C: --resume --private-key: run() doesn't run again; the stale transactions are sent. D: a fresh run passes. Forge rewrites the broadcast's timestamp on --resume.
- scripts/pentest/verify/V4_F-1_info_checks.sh: RESUME=1 on a stalled DeployAll broadcast stops at deploy-all.sh:97–100; I-1 to I-3 below.
What I checked
- Plausibility (all needed together): the first send fails (an unfunded deployer — forgotten, late or wrong-chain funding — or an RPC failure at that send; a fee spike would need ~54× the base fee); the operator uses forge --resume although nothing was sent; PROMO_ENDS recomputed at resume time (the literal reading of "the same forge script command": adding --resume to deploy-all.sh:88, which recomputes it at :74); more than an hour passes (plausible if the launch waits for the user to fund the deployer). Unlikely together, realistic for an agent-run launch.
- "Refused forever": true for the committed gate and both documented runs of it; not true of the deployment: the escrows are correct except the promo ends early by the stall, and there is a chain-only way out (test 3).
- What an operator would do: the run stops with "promoEnds too early"; then runs the gate by hand with DEPLOY_TIME = promoEnds − 92 days, verifies and records by hand, or ships a gate patch. The launch is delayed by hours.
- The lower bound measures the first verifier, not the escrows: the docs' own case passes with a promo 10 days short. So here the refusal is a false positive in a state that can't be undone.
Reason: Low kept. No funds, fees or deal deadlines affected (a shorter promo means yescrow charges sooner); the gate fails closed and there is a way out. Not Info: following the runbook ends a one-shot immutable deployment in a state the runbook can't finish, and the fix is cheap.
Corrections
1. Forge does run run() again on --resume here, to get the key from vm.startBroadcast(pk); the attack saw no log line only because the resume path doesn't print logs. DeployAll's check validates the environment at resume time, not what is sent; with the stalled run's own environment, nothing is sent.
2. The transactions are not "signed at simulation time": the saved requests (input, nonce, gas) are fixed then; forge signs and prices them at send time.
3. RESUME=1 on the stalled file fails earlier than the attack says, at the token check (deploy-all.sh:97–100), with the misleading message "the broadcast's escrows are not one USDC escrow then one USDT escrow"; it never reaches cast receipt null.
4. The suggested message based on the broadcast's timestamp can't work (forge rewrites it on resume); compute the gap from the chain: DEPLOY_TIME − (promoEnds − 92 days).
5. The docs' --resume advice is correct for the case they describe (a later send stalls); what's missing is what to do when nothing landed.
Fix (smallest correct)
1. Docs (docs/mainnet.md:70, :111): if cast nonce 0xA8C2…D14D is 0, nothing was sent: fund the deployer and run step 2 again, which recomputes the promo end; never --resume then; --resume only once the first verifier is on chain, then RESUME=1.
2. Prevention, one line: in the mainnet branch of DeployAll.check, require(deployer.balance >= 0.01 ether, …), or a cast balance check before :88 in deploy-all.sh.
3. Optional: when forge fails and the nonce is still 0, deploy-all.sh moves run-latest.json aside and says to run it again; RESUME=1 says "nothing was sent" when the first hash is null, and prints the computed gap alongside "promoEnds too early".
4. Don't base the gate's lower bound on the broadcast's timestamp. Computing promoEnds in the constructor would remove the problem but changes contract code under audit; it can wait.
Info items
- I-1, confirmed, Info: the glob accepts http://127.0.0.1:9@127.0.0.2:PORT, and cast got chain 42161 from 127.0.0.2; FORK_PORT builds the same URL. Needs control of the operator's environment, which already exposes the keys.
- I-2, confirmed, Info: CONFIRM_MAINNET=yes in the env file passes the confirmation (the run stops next at "no RPC"); RESUME=1 from the file is honoured and fails closed without a broadcast.
- I-3, confirmed, Info: the step 3 loop runs once with the example ARBITRUM_ONE_RPC_URL, against the same provider deploy-all.sh used; RPC_URL would give two.
- I-4, confirmed, Info: chainId from CHAIN_ID or the record (config.ts:184), decimals copied as is (config.ts:159, escrow.ts:206). A wrong chain fails closed (viem signs relayer transactions with the record's chain id, another chain's node rejects them; users' EIP-712 signatures invalid); decimals only change texts; deploy-all.sh:145 writes 6 and the gate requires 6.
- I-5, confirmed, Info: once pinned, only the pin passes (DeployAll.s.sol:170, CheckDeployment.s.sol:85), its only check getThreshold() ≥ 2 (Networks.sol:78–81); a typo gives an address with no code and fails closed. Pinning an owner is cheap hardening.
R4-F-2:
Verdict: CONFIRMED Low
The proof passes and the gap is real: the gate's "this build" is whatever the current environment compiles. On the real project the finding reaches much less than reported. Low stands.
Evidence (file:line)
- scripts/deploy-all.sh:27 exports the whole env file, then runs forge script (:88, :113) and forge verify-contract (:119–135); no FOUNDRY_/DAPP_ scrub.
- "This build" depends on the environment: CheckDeployment.s.sol:135 (type(YescrowEscrow).creationCode), :235–243 (fresh verifier builds), :394 (rebuilt runtime); Networks.sol pins no code hash.
- contracts/foundry.toml:14–18: the restrictions only force via_ir = true; every other field is null, so they limit none of the overrides.
- The router check is the only build check against a committed value: DeployRouter.s.sol:35 requires predicted() == ROUTER first; docs/mainnet.md:85–89 (step 1) runs it right after set -a; . ./.env.mainnet; scripts/rehearse-mainnet.sh:29 runs it in the caller's shell, without .env.mainnet; DeployAll.s.sol:120 only requires that the router has code.
- Pre-Cancun targets can't compile: lib/openzeppelin-contracts/contracts/utils/Bytes.sol:95 uses mcopy (via SignatureChecker.sol:9 from YescrowEscrow.sol:7); CheckDeployment.s.sol:221 too.
- Exact pragma solidity 0.8.38; in YescrowEscrow.sol, RdapVerifier.sol, DualRdapVerifier.sol, DepositRouter.sol (line 2).
- docs/mainnet.md:97 runs step 3 "from the same commit and build as step 2", in the same shell; docs/audit.md:24 puts 0.8.38, 200 runs, Cancun, no metadata, via-IR in the audit scope; docs/keys.md:8 claims only "verified on Arbiscan against this source".
Own scripts (scripts/pentest/verify/; logs scratchpad/pentest4/vF2-logs/)
- Attack's PoC re-run: passes as reported (committed 68bde727…, deployed e235e06e…; the gate passes in the same environment, refuses from a clean shell).
- V4_F-2_env_build_drift.sh (~25 s):
- Variables forge 1.8.5 honours: FOUNDRY_ OPTIMIZER, OPTIMIZER_RUNS, EVM_VERSION, VIA_IR, SOLC, BYTECODE_HASH, CBOR_METADATA, REVERT_STRINGS, OPTIMIZER_DETAILS, REMAPPINGS, LIBRARIES, PROFILE, ADDITIONAL_COMPILER_PROFILES, COMPILATION_RESTRICTIONS, CONFIG (replaces foundry.toml); DAPP_ BUILD_OPTIMIZE, BUILD_OPTIMIZE_RUNS, EVM_VERSION, REMAPPINGS. FOUNDRY_SOLC_VERSION and DAPP_SOLC_VERSION make forge config fail (closed).
- Sources a shell scrub can't remove: forge loads a .env next to foundry.toml itself (runs 7 and shanghai applied after env -u), and ~/.foundry/foundry.toml (revert_strings and optimizer_details applied with no FOUNDRY_* set). Neither exists on the VPS today (existence checked only).
- Router check across 20 overrides: refuses runs 1, 1000, 1e6, paris, shanghai, optimizer off, via_ir, cbor, revert-strings debug; silent for prague, osaka, revert-strings strip, runs 199/201, the coverage profile and the profile-targeted override.
- Pre-Cancun: solc analysis of DeployAll.s.sol and CheckDeployment.s.sol errors for shanghai and paris ("mcopy … Cancun").
- Toy project: adding optimizer_runs = 200, evm_version = "cancun" to the restrictions gives identical output on a clean build; a build under runs=1, prague or the targeted profile is refused ("Missing profile satisfying settings restrictions").
- Verification emulation (forge's --show-standard-json-input, recompiled with genuine solc against the on-chain code): a runs=1 deployment verifies from the deploy's build cache, even with the env scrubbed; after a clean rebuild the JSON says runs 200 and it fails. A source-editing FOUNDRY_SOLC wrapper passes the same-env gate (its code returns 32 where the source says 31) and verification fails.
- Testnet pin check (read-only): the v0.33 Sepolia creation inputs begin with this build's creation code (escrow 0x96edaaa1…dbb2, DualRdapVerifier 0xa5b84e8e…78bf); Sourcify reports match with 200 / cancun / viaIR.
- V4_F-2_viair_router_silent.sh (V4_F-2_viair_escrow_sweep.log): builds the real escrow and verifiers in a reduced copy, reproducing the committed hashes exactly (~4 s, 135 MB). Across 17 runs values (10 to 2³²−1), every value that changes the escrow or verifiers also moves the router; runs 190–210, prague, osaka and strip leave everything byte-identical; only FOUNDRY_ADDITIONAL_COMPILER_PROFILES=[{name="via-ir",via_ir=true,optimizer_runs=1}] changes all three while the router stays put.
What I checked
- Verification: Arbiscan and Sourcify pass for overridden settings from a genuine compiler; forge verify-contract submits the settings recorded in the build cache, which in deploy-all.sh holds the overridden build. "Verified" proves only that the published source plus published settings give the on-chain code, not that the settings are the audited ones (docs/audit.md:24); the settings are shown on Arbiscan (Sourcify gives only match, since cbor_metadata = false), but nothing compares them.
- Creation-code pins are workable: the creation code excludes constructor arguments; with no metadata, comments, paths and other files don't enter it (the full build, the reduced copies, Sourcify's own compile and the Sepolia chain all agree); forge isn't in the codegen path, so an upgrade changes a pin only if it changes the settings it passes (a unit test would flag it); a pin changes with any source, OZ, solc or settings change, as intended. A third party clones the release commit, runs forge build in a clean shell, and compares cast keccak of the artifact's bytecode with the pin and the creation-transaction input prefix.
- The scrub alone isn't enough: forge reads contracts/.env and the global config itself; steps 1 and 3 are run by hand after sourcing .env.mainnet; a dirty contracts/src or lib, or a replaced solc in ~/.local/share/svm, changes the build with no variable at all.
Why Low stands: what remains on the real project is a deliberate profile-targeted override (passes every documented check — rehearsal, step 1, DeployAll, the gate twice, Arbiscan, Sourcify — and leaves a permanent unaudited build at the promised addresses; R3-F-2 had the same operator-only precondition, Low), and honest drift in .env.mainnet (gets through only if the router is already on Arbitrum One and the operator continues past step 1's refusal; the rehearsal gives false assurance since it doesn't source .env.mainnet for the router step). Not higher: the audited source with other optimizer settings; no path to money, fees or deadlines; a gate from a clean clone refuses it. Not lower: the gate's code check (R2-F-1) is environment-relative by construction, and the router check guards only the router, by accident.
Corrections
1. "Another solc version" is wrong here: exact 0.8.38 pragmas make 0.8.30 fail, and FOUNDRY_SOLC_VERSION breaks forge.
2. The PoC's own env file (runs 1 + paris) can't build the real escrow (OZ's mcopy), so DeployAll fails and nothing is sent; prague and osaka are no-ops.
3. A FOUNDRY_SOLC path to any binary isn't "every check passes": source verification fails (after the immutable deployment), and whoever sets it can already run code (the env file is sourced by bash).
4. forge verify-contract reads the build cache, not the current configuration (the conclusion holds).
5. Honest runs drift is refused by DeployRouter.s.sol:35 in step 1 and in the rehearsal (shell, dotenv and global-config variants); on a first launch DeployAll.s.sol:120 then refuses too.
6. The router check's "(run forge build, retry)" points the wrong way in this case.
Fix
1. Pin the release build in Networks.sol, recomputed in the release commit after the audit's fixes; at 1e08fee: ESCROW_CREATION_HASH = 0x96edaaa19ba8452c34c9ed00c1f901c614498955418e82fabc1529a539e8dbb2, RDAP_VERIFIER_CREATION_HASH = 0x7a0cc9d9ff48045b55a170b7f72f7ead8e4244f29239a725ee3b6556746663e9, DUAL_VERIFIER_CREATION_HASH = 0xa5b84e8eb741c401bd56a19f256963ad6d075360824713591392222203ae78bf. On chain 42161, DeployAll.check and CheckDeployment.check require keccak256(type(X).creationCode) == pin for all three, with a message naming FOUNDRY_/DAPP_, contracts/.env and ~/.foundry/foundry.toml; a unit test next to F_CheckDeploymentGaps fails when the build drifts.
2. Pin the settings in foundry.toml: optimizer_runs = 200, evm_version = "cancun" in the three restrictions, plus a src/DepositRouter.sol entry (fails honest drift at compile time, no change to the output).
3. Refuse rather than scrub, in deploy-all.sh and rehearse-mainnet.sh: an env file with ^(export )?(FOUNDRY_|DAPP_) lines; a caller environment with any FOUNDRY_/DAPP_ other than FOUNDRY_BROADCAST; an existing contracts/.env or ~/.foundry/foundry.toml. The rehearsal's router step runs under the env file too.
4. Docs and message: run step 3 from a fresh clone of the release commit under env -i, and check that Arbiscan and Sourcify show 0.8.38, 200 runs, Cancun, via-IR; DeployRouter's error mentions FOUNDRY_* drift.
Area SV: service, keeper and web outside disputes
AI penetration test, fourth run — Area SV (service, keeper, web, outside disputes)
Saved by the orchestrator from the attack agent's final reply (the harness refused the agent's report file).
Finding lines
- R4-SV-1 — v0.33 same-registrar nameserver check accepts an unprovable list when the buyer's names gain glue after the transfer (R3-C-1 reopened for 15 of 25 TLDs) — Medium —
cd scripts/pentest/fourth && node SV4_ns_glue_undercount.ts - R4-SV-2 — Public pre-flight (
/v1/quotes,/v1/drafts) is an unauthenticated registry probe + unbounded serialized RDAP backlog — Low —cd scripts/pentest/fourth && node SV4_rdap_probe.ts
PoC files: scripts/pentest/fourth/SV4_ns_glue_undercount.ts, scripts/pentest/fourth/SV4_rdap_probe.ts. Both pass (exit 0). No edits to contracts/src, service/src, web/, or the running stack.
Commit 1e08fee (SPEC v0.33). Off-chain proofs drive the real service code over stubs (plain node, no chain, no DB, no live network), as the accepted third-run scripts do.
Result
Two findings: one Medium (the v0.33 R3-C-1 nameserver fix is defeated for a large subset of TLDs), one Low (the same pre-flight path is an unauthenticated registry-probe / backlog DoS). The v0.33 payout budget (R3-EG-1) and the inbound/returnDeposit/completeTransfer sanctions screening (R3-EG-2) hold against every attack tried.
R4-SV-1 (Medium). The v0.33 same-registrar nameserver check accepts a list the verifier can never hash, when the buyer's names gain glue after the transfer
Attacker/preconditions: the buyer of an IntraRegistrar deal, on any registry that pretty-prints host objects and serves ipAddresses glue for in-bailiwick nameservers — Identity Digital (.io, .ai, .info, .vc, .me, .bet, .pro, .sh, .ac, .bz, .live, .finance), PIR (.org), Nominet (.now, .bot). The buyer chooses nameservers that are subdomains of the domain being sold (ns1.<domain>…), i.e. in-bailiwick, so the registry must carry a glue host object for each once the seller sets them.
Mechanism: v0.33's checkNameserversProvable estimates the proof's nameservers capture from the registry's current answer (nameserversCaptureBytes, rdap.ts:98-110) — it derives a per-host overhead from the live record and refuses lists past 90% of the 8,192-byte cap (rdap.ts:120). But the current answer shows the seller's existing out-of-bailiwick nameservers, which carry no ipAddresses. After the seller sets the buyer's in-bailiwick names, each host object gains a glue block (~150 escaped bytes with the registry's own pretty-printing), which the estimate never accounted for. So the estimate under-counts, the check accepts, and the real post-transfer capture exceeds 8,192. RdapVerifier._facts then skips the hash (RdapVerifier.sol:148), returns nameserversHash == 0 (:170), and the IntraRegistrar proof fails check 6 (YescrowEscrow.sol:475). The deal can never be proven — the exact R3-C-1 harm v0.33 set out to prevent.
Impact: a same-registrar deal that matches its terms can never release automatically. The seller (who set exactly the hashed names) must dispute before the transfer deadline (fee paid up front) or, missing it, is left with the buyer refunded while the domain's DNS already points at the buyer; yescrow loses its fee on a dead deal. This is the Medium griefing/deadline class the third-run verifier assigned R3-C-1, reopened for 15 of the 25 TLDs. The buyer sets the trap at no net cost (price refunded when the proof fails). v0.33 added no keeper/ops alert for "live names match the terms but the proof yields no hash" (the third-run verifier's own open item), so nothing nudges the trapped seller to dispute in time.
Steps (as the PoC drives real code):
1. Buyer drafts an IntraRegistrar deal for victim.io with 13 in-bailiwick nameservers of 165 chars (POST /v1/drafts → proposal() → checkNameserversProvable; schemas.ts:21 allows 13 × ≤253).
2. The check fetches the current answer (seller's two cloudflare nameservers, no glue), estimates 7,347 B (< 7,372.8) → accepted.
3. The seller sets the 13 names; the registry now serves them with glue; the real capture is 9,365 B (> 8,192). submitProof reverts ProofRejected(6) forever.
PoC: cd scripts/pentest/fourth && node SV4_ns_glue_undercount.ts — prints estimate 7,347 B (accepted) vs real post-transfer capture 9,365 B (no hash); under-count 2,018 B. A control shows the identical list is correctly refused when the current answer already carries glue, isolating the cause to the layout change between check and proof.
Root cause: service/src/rdap.ts:98-110 (nameserversCaptureBytes derives per-host overhead from the current answer at line 107, which omits glue the post-transfer answer carries) and :117-126 (checkNameserversProvable trusts that estimate). The verifier side is unchanged and correct.
Fix (service only): when any proposed name is in-bailiwick (ends in .<domain>), assume glue — use the conservative default per-host (perHost = 600) or add an ipAddresses allowance; or, on Identity Digital/PIR/Nominet, estimate from a host-with-glue template. Also implement the keeper/ops alert telling the seller to dispute when live names match the terms but the capture yields no hash.
R4-SV-2 (Low). The public pre-flight path is an unauthenticated registry probe and an unbounded serialized RDAP backlog
Attacker/preconditions: anyone, no wallet or signature. POST /v1/quotes and POST /v1/drafts are public and carry no relayLimit (only the global 120/min per-IP cap). Both call proposal() → checkNameserversProvable → fetchRdap whenever the request is IntraRegistrar with a nameservers array (app.ts:563-566, 597, 624).
Mechanism: fetchRdap (rdap.ts:52-69) issues one upstream RDAP request per distinct domain (60s cache keyed by domain only), serialized per registry host through a promise chain with a forced ~1s gap (:57-65) and no cap on queue length. Many distinct attacker-chosen domains on one registry host make yescrow hit that registry at a sustained rate and grow an unbounded backlog.
Impact: yescrow becomes a proxy to probe/hammer any supported registry for any domain (risking yescrow's IP being throttled/banned, which degrades honest pre-flight), and honest IntraRegistrar quotes/drafts queue behind the attacker. Money is never at risk (pre-flight is advisory; the keeper proves via the prover/attestor, not fetchRdap). Low.
PoC: cd scripts/pentest/fourth && node SV4_rdap_probe.ts — 6 distinct attacker domains → 6 real upstream requests, max concurrency 1, ~6s elapsed (forced 1s gaps, independent of registry latency); same-domain repeat within 60s is cached.
Root cause: service/src/rdap.ts:52-69 (per-host queue with no size bound, attacker-chosen domain) reachable from service/src/api/app.ts:597 (/v1/quotes, no relayLimit) and :624 (/v1/drafts).
Fix: add config: relayLimit (or a tighter limit) to /v1/quotes and /v1/drafts; cap the fetchRdap per-host backlog (503 when deep); consider running checkNameserversProvable only on /v1/drafts.
Checked and found sound
- R3-EG-1 payout budget holds.
sendPayoutsroutes a cross-chain USDT payout's LayerZero fee through thepayoutbudget in the account's share (keeper.ts:280-295); belowRELAY_BRIDGE_MINit is skipped not sent (:281-283); a budget refusal holds the row for an hourly retry (V3_EG-1re-run passes).payoutOfrefuses a sub-floor cross-chain USDT payout at signing (app.ts:1057-1063). USDC payouts carry value 0; their destination mint is the already-budgetedcompletepath. - Payout starvation is delay-only. Global
payoutbudget with 25% per-account share; exhaustion holds other payouts for retry, and the party withdraws by hand after the 1-dayPAYOUT_PENDINGwindow (app.ts:1297-1307). No funds lost. - Pre-v0.33 below-floor payouts to another chain are skipped (
keeper.ts:281) — no unbudgeted LayerZero fee leaks for old rows. - No double-pay / wrong-skip.
creditOfpays seller only onReleased, buyer onRefunded/fundedCancelled, skipsSettled/split, retries "not indexed yet" (payouts.ts:268-278); the signature is one-time and amount/recipient-bound by the contract. - R3-EG-2 screening holds and fails closed. Inbound completions screen the box's buyer (
keeper.ts:552-557);returnDepositscreens the buyer (keeper.ts:383); the relayedcompleteTransferscreens each forwarded deal's buyer+seller (app.ts:1408-1427). A missing list throwsSCREENING_UNAVAILABLEon every path (keeper → retry, relay → 503) — none fails open. - Settlement guard / texts. The
settletyped-data endpoint now rejects non-dispute states (DEAL_WRONG_STATE,app.ts:947-949); the stage-2 and period-decode texts (typed-data.ts:118-135) match the escrow'ssettlementStage(YescrowEscrow.sol:835-844). - Seller web steps. The v0.33 "set nameservers, wait for the proof, then push" guidance (
deal-state.ts:204-216,277-287) is the documentedIntraRegistrarflow; the danger-toned buyer account-check is retained. No new money path.
Unconfirmed
- IDN
unicodeNameunder-count.nameserversHashacceptsxn--(LDH) names; a registry serves bothldhName(xn--) andunicodeName(the decode).nameserversCaptureBytesassumescopies × nameLen, but the escapedunicodeNamemay be larger — an R4-SV-1-style under-count without needing glue. Depends on how yescrow's attestor escapes non-ASCII (raw UTF-8 vs\uXXXX), which no fixture exercises; could not confirm the byte count against the real attestor. - Relayed
completeTransferover-screens the seller. The relay handler screens buyer+seller, but the keeper's inbound path screens only the buyer (keeper.ts:552, by design). An honest buyer whose counterparty seller is listed is refused (451) via the relay while the keeper would complete the same transfer. Money-safe (self-complete or keeper, thenreturnDeposit); an inconsistency, Info.
Verifications for area SV
R4-SV-1 and the unconfirmed items:
Verdict: CONFIRMED Medium
Evidence (file:line)
- service/src/rdap.ts:98-110: the estimate takes the per-host cost (:107) and the name copies (:106) from the current capture. The comment at :113-116 says the 10% margin is "for hosts with glue addresses", but 819 B only covers about 5 hosts with one address each; :120 refuses only above 7,372.8 B.
- service/src/api/app.ts:565-566: the check runs only when terms.nameservers is given; a hash-only proposal (deals.ts:124) or an on-chain proposal skips it (the seller then gets a warning, deal-state.ts:192).
- contracts/src/RdapVerifier.sol:148,170: a capture over 8,192 escaped bytes gives no hash; YescrowEscrow.sol:475 then reverts ProofRejected(6).
- service/src/keeper.ts:315-327: failed proofs are only logged ("proof not yet passing"); neither the seller nor ops is alerted.
- web/lib/deal-state.ts:207: the seller's only guard is a time-based line ("If no proof comes within a few hours… don't push: open a dispute"); per :253 the dispute asks whether the domain reached the buyer's account, so a seller who followed that line and didn't push can't win it.
- Signed fixtures: wikipedia.org.json: PIR serves ipAddresses for hosts under a different .org domain (wikimedia.org); x.ai.json: Identity Digital without glue, 235 B per host.
- Live (5 GETs): Identity Digital and Nominet serve glue in the domain answer (nic.io in-bailiwick hosts, nic.info hosts under afilias-nst.info, nic.now); no glue at nic.xyz (CentralNic), nic.co (.CO), nor in the google.com (Verisign) and web.dev (Google) fixtures. Affected: 15 of 25 TLDs, as claimed.
Own script/test
- scripts/pentest/verify/V4_SV-1_glue_bands.ts (node … <live dir>), exit 0; live answers in scratchpad/pentest4/V4_SV-1_live/, output scratchpad/pentest4/V4_SV-1_glue_bands.log. Calls the real checkNameserversProvable, nameserversCaptureBytes, nameserversHash; current answers are real signed captures, after-change answers real glue layouts with only the names and the number of addresses changed. On the real x.ai answer, exactly the attack's numbers (13 × 165 chars: estimate 7,347 B accepted, 9,365 B after). Name lengths accepted but unprovable, with 1 IPv4 + 1 IPv6 per host:
| Registry | 13 hosts | 12 | 11 | 10 | 9 or fewer |
|---|---|---|---|---|---|
| Identity Digital | 120–165 | 147–189 | 178–217 | 215–251 | none |
| PIR | 121–167 | 147–191 | 178–219 | 215–252 | none |
| Nominet, seller's hosts like google.now's | 32–34 | 45–46 | 60 | none | none |
| Nominet, seller's hosts like google.bot's | none | none | none | none | none |
With more addresses per host, ordinary names work: 13 hosts × 27–40 chars with 4 + 4 addresses each (Identity Digital, PIR); 7 hosts × 18 chars with 13 + 13; Nominet 12 hosts × 35 chars with 2 + 2.
- contracts/test/pentest/verify/V4_SV-1_GlueTrap.t.sol 3/3 (inherits the third run's V3_C1 helpers; run with --match-test test_V4); lists rebuilt from PIR's signed glue capture: 13 × 121-char in-bailiwick names: 8,209 B (same as the script), the real dual verifier returns hash 0; 13 × 167: 9,405 B, hash 0; the same 13 × 167 with the glue stripped: 7,350 B and hashed (the estimate was 7,360 B: exact without glue; the glue is the whole error); 13 × 120 with glue: 8,183 B, hashed. Through the escrow: ProofRejected(6) at +5 h and at the last second of the window; the control deal is proven; the trapped deal stays Active. Same-TLD variant: 13 × 30-char names under buyer-dns.org with 5 + 5 addresses: 9,123 B, hash 0; with 1 + 1, hashed.
- The attack's PoC passes again. On all 28 signed contexts, JSON.stringify length − 2 equals the raw escaped bytes the verifier measures.
What I checked
- Real code: the PoC drives the real rdap.ts path (fetchRdap, getOnce, the capture regex and parseRdap, through a stubbed fetch); proposal() passes the same normalised list; the after-change size, measured off-chain by the PoC, confirmed on-chain.
- Realistic glue objects: the PoC's hostGlue is byte-for-byte the live b0.nic.io object, handle included; PIR's signed objects use the same layout; the PoC's synthetic current answer has x.ai's exact per-host cost.
- In-bailiwick names need the seller's work: under EPP's host-object model a domain can only point at an existing host; a name under the domain being sold can only be created by that domain's registrar, with the glue addresses. So the report's case requires the seller to first create 13 child nameservers with 165-char names and the buyer's addresses — the deal page never mentions this, and a seller is likely to balk. The realistic route is hosts under the buyer's own domain in the same TLD: the buyer creates them, chooses how many addresses each has, and can add more after the check; the seller only types the names.
- Not tested at registrars: whether a dashboard accepts 13 names, 165-char names, or several addresses per host (the API schema and wizard accept them; the registry allows 13).
- Warnings: the seller gets only the time-based text; the keeper stays silent.
Reason (Medium kept): not High — v0.33's "push only after the proof" means a seller who follows the page keeps the domain. What remains is the scale's Medium: free for the buyer (refunded); a deal that matches its terms can never be proven, so the seller must let it lapse or dispute, the transfer window is wasted, yescrow earns no fee; 15 of 25 TLDs; the same-TLD variant needs nothing unusual from the seller. Aggravating: while the seller waits, the buyer's nameservers serve the domain (mail, ACME certificates); disputing without pushing, as the page advises, can't win.
Corrections 1. Not only in-bailiwick names: hosts under any domain in the same TLD get glue (live nic.info, signed wikipedia.org); that variant is the practical one and defeats the report's suggested fix. 2. In-bailiwick hosts must be created by the seller. 3. At .io the band at 13 hosts is 120–165 chars, not just 165, and 10 hosts are enough (215–251); Nominet's band is narrow and depends on the seller's current hosts. 4. A seller following v0.33 doesn't push and keeps the domain; "buyer refunded while the DNS points at the buyer" holds only until the seller changes the nameservers back. 5. Looking up the buyer's hosts at check time wouldn't fix it: the buyer can add addresses afterwards.
Fix (service and web, no contract change)
1. checkNameserversProvable: refuse in-bailiwick names (ending in .<domain>) for same-registrar deals; for in-registry names (TLD on the same RDAP base) at Identity Digital, PIR and Nominet, refuse them or count each at a worst-case glue allowance from the registry's maximum addresses per host (measured: ~156 B for 1 + 1 and 29/33 B per extra address at Identity Digital and PIR; ~76 B and 19/21 B at Nominet); until those per-host limits are confirmed, refusing in-registry names is the safe rule; count the unicodeName copy of xn-- names at 4 bytes per character, or refuse such names.
2. Keeper backstop (also covers hash-only and on-chain proposals): in tryProof, when a same-registrar attempt yields a zero hash with a capture over 8,192 B, or the registry lists exactly the draft's names, email or push-notify the seller and ops at once, and show it on the deal page.
3. Rewrite deal-state.ts:207: "don't push, set your nameservers back, the buyer is refunded at the deadline — or push and dispute with your registrar's push confirmation".
4. Correct SPEC §6.2 and the R3-C-1 row in docs/audit.md.
Unconfirmed items
- IDN unicodeName: plausible, still unconfirmed. Contexts use RFC 8785 canonical JSON (prover/src/canonical.ts), so non-ASCII counts as raw UTF-8, matching rdap.ts:91; the verifier never sees a \u escape that would make it revert. But the estimate counts each name at its xn-- length while the decoded unicodeName can be much longer (a 57-char xn--e28h… label decodes to 200 bytes, a 61-char "ü" label to 110). Needs one live answer with an IDN nameserver; the same fix family applies.
- Relay over-screening: confirmed by reading, not run. app.ts:1427 screens both buyer and seller on a relayed completeTransfer, while keeper.ts:549-552 deliberately screens only the buyer. No money at risk (completion is permissionless and the keeper path exists); Info.
R4-SV-2:
Verdict: CONFIRMED Low
The proof passes and the impact is real at 1e08fee. Corrections: the surface isn't new in v0.33, and the effects are somewhat wider than reported. None reaches funds or a running deal deadline, so Low stands.
Evidence
- service/src/rdap.ts:47-69: a per-host promise chain with no depth limit. The 1 s sleep sits inside run (:58-64), so every answer, even with an empty queue, takes at least 1 s. The cache Map (:49) is only ever written (:67) and read (:53); nothing deletes from it.
- service/src/api/app.ts: global limit 120/min per address (:148-155, defaults config.ts:212); relayLimit 20/min (:214) isn't set on /v1/preflight (:549), /v1/quotes (:597) or /v1/drafts (:624); proposal() calls checkNameserversProvable (:566).
- docs/runbook.md:287-313: Caddy sends /api/* straight to the service with the client's address; no Caddy rate limit; each IPv6 address is its own rate-limit key.
- git log -S: /v1/preflight → fetchRdap and the per-host queue exist since a75d071 (M3), with no relayLimit; only the nameserver check is from 053b285 (v0.33).
- service/src/main.ts:28,55,88: the API, keeper and relayer run in one Node process.
- docker-compose.yml (attestor) and SPEC.md:243,438: yescrow's attestor runs on the same VPS, same public IP; since v0.23 every proof needs its attestation.
- docs/dependencies.md:379-394,468-469: CentralNic publishes 2 requests/s per /24; Verisign and Google forbid high-volume automated access; Nominet throttled the VPS for ~7 hours on 2026-10-26.
- Web: web/lib/api.ts:39-52 sets no client timeout; the wizard calls pre-flight first (Wizard.tsx:143); the buy-link page waits on it (BuyFromLink.tsx:76).
Attack PoC re-run: node SV4_rdap_probe.ts exits 0: 6 upstream requests, one at a time, 6055 ms (drives the real fetchRdap over a stub, not the HTTP routes).
Own script: scripts/pentest/verify/V4_SV-2_rdap_backlog.ts (node --expose-gc …), the real buildApp with production limits and a local RDAP stub, exit 0:
- A. From one address, 120 /v1/quotes with IntraRegistrar nameservers are accepted and wait; the 121st gets 429; one upstream request a second. Control: /v1/proofs (relayLimit) refuses the 21st.
- B. A lone pre-flight takes 1024 ms. After 5 attacker /v1/preflight calls (pre-v0.33 path): an honest .net pre-flight takes 6095 ms (.com and .net share rdap.verisign.com). After 5 attacker /v1/quotes: an honest .com quote 6104 ms, an honest .com pre-flight 7125 ms; a pre-flight on another host (.io) 1023 ms.
- C. Over real HTTP on 127.0.0.1, a client that hangs up while queued still gets its upstream request sent: no need to hold connections open.
- D. 3000 quotes from 3000 addresses in one IPv6 /64 all accepted and waiting, ~13.7 KB each. One IPv4 address at 120/min for a day would queue 172,800 requests: 48 h of backlog on one host and ~2.4 GB of memory.
- E. Each cached domain keeps ~3.7 KB after GC and is never removed; the capture substring keeps the whole response text alive.
What I checked
- Limits: the global per-address limit applies, relayLimit doesn't, Caddy adds none.
- Same surface as preflight: yes — /v1/preflight has been an unauthenticated registry probe since M3 (a better one: it returns the RDAP data); v0.33 adds two more ways into the same queue.
- Backlog: a waiting request holds no worker thread but holds its request objects and a promise; nothing times out (Fastify's, Caddy's and the web's fetch defaults); at 60/min one address keeps a host's queue from draining, at 120/min it grows ~1 s every second; it outlives the attack until a restart.
- Registry throttling: the keeper's proofs don't go through fetchRdap, but the attestor uses the same IP and every proof needs it; if a registry throttled that IP, automatic release would stop for that registry and sellers would have to dispute before the transfer deadline (SPEC.md:232,438).
Reason (Low): not Info — anyone, free and without a wallet, can stall all deal creation for any registry in the web app, grow memory without bound in the process that runs the keeper and relayer, and send sustained lookups from the attestor's IP. Not Medium — nothing reaches money or a running deal (pre-flight, quotes and drafts are advisory and before a deal exists; on-chain flows never call fetchRdap); running out of memory restarts the process in seconds and clears the queue; deadlines are days long; the queue holds 1 request/s per host, the rate dependencies.md §4.2 calls polite and below CentralNic's cap; registry throttling at that rate is plausible (Nominet's precedent) but unproven — if shown, the forced disputes would make it Medium.
Corrections
1. Root cause: the unbounded queue in rdap.ts:47-69, fed by three public routes; /v1/preflight (app.ts:549, since M3) already allowed the same before v0.33.
2. Every answer, not only queued ones, carries the forced 1 s gap.
3. Unreported: ~13.7 KB held per waiting request; the cache is never pruned (~3.7 KB per domain); IPv6 addresses aren't grouped for the rate limit; the attestor shares the service's IP, so "the keeper's proofs are unaffected" holds only if no registry throttles that IP.
4. Side note: a burst of 121 in the same event-loop tick gets 429 on all (@fastify/rate-limit's local store shares one counter object); no effect on the finding.
Fix
1. rdap.ts: a waiting count per host, fail fast with ApiError(503, 'RDAP_BUSY') above ~20; do the 1 s sleep only in the stored chain (queues.set(host, run.then(sleep, sleep))) so callers aren't delayed; a bounded LRU cache (~5,000 entries) dropping expired entries, storing a copy of nameserversCapture, not a slice.
2. app.ts: config: relayLimit on /v1/preflight, /v1/quotes, /v1/drafts (the wizard makes ~4 such calls per deal, well under 20/min).
3. Rate-limit key: group IPv6 addresses by /64 with a keyGenerator.
4. Run checkNameserversProvable only on /v1/drafts; quotes are dry runs and read the cache only.
5. The attestor's shared IP: a per-host pre-flight budget (e.g. 30/min) below the registry's rate.
Regressions of the earlier runs' proofs
AI pentest, fourth run: regressions on 1e08fee (SPEC v0.33)
Saved by the orchestrator from the regression agent's final reply (subagents can't write report files).
Result: every fix holds. On v0.33 the third run's fixes hold, and so do the second run's and the first run's. Nothing failed on the default build: all 402 pentest proofs, 36 fork tests, 62 tests in the fix suites, the whole unit, fuzz, invariant and fork suite (359), every earlier off-chain script, the service tests (143), the prover tests (16) and the web unit tests (82). The only failures came from the no-L/E compiler check (second run's F_build-checks.sh part b), which builds differently on purpose: three live-gate tests fail there because the live testnet escrows were built with the default pipeline, which is the gate refusing code that isn't this build's.
What ran
Working copy scratchpad/pentest4/R, an export of 1e08fee, byte-identical to the commit before and after (595 tracked files compared). Logs in scratchpad/pentest4/R-logs/. Every heavy job under the shared lock and a 2,500 MB cap; forks and anvil read from public RPCs only, every transaction to a local anvil; no .env read; the docker stack untouched; a throwaway Postgres (yescrow-check-pg-r4) removed afterwards.
| Run | Result |
|---|---|
FOUNDRY_DYNAMIC_TEST_LINKING=false forge test -j 1 --match-path 'test/pentest/[A-D]_*.t.sol' |
80/80 (10 suites) |
… 'test/pentest/[E-G]_*.t.sol' |
36/36 (9) |
… 'test/pentest/second/[A-D]_*.t.sol' |
45/45 (9) |
… 'test/pentest/second/[E-F]_*.t.sol' |
20/20 (6) |
… 'test/pentest/third/[A-D]*.t.sol' |
33/33 (8) |
… 'test/pentest/third/F3_*.t.sol' |
12/12 (3), including F3_LiveV032Gate 3/3 against the live v0.33 Sepolia deployment |
… 'test/pentest/verify/V_*.t.sol' |
71/71 (17) |
… 'test/pentest/verify/V2_*.t.sol' |
39/39 (8) |
… 'test/pentest/verify/V3_*.t.sol' |
66/66 (12) |
| All pentest proofs | 402/402 |
forge test -j 1 --match-path 'test/fork/*.t.sol' |
36/36 |
forge test -j 1 --match-path 'test/{ThirdRunFixes,SecondRunFixes,Hardening,Judge}.t.sol' |
62/62 |
forge test -j 1 --no-match-path 'test/pentest/**' |
359/359 (24 suites) |
First-run scripts (C_template_re2, E_foreign_forward_keeper, F_verify-profiles.sh, G_sponsor_drain) |
all exit 0 |
Second-run scripts (C_engines crafted cases, D_appeal_budget, E_forward_griefing_keeper, G_appeal_screen_gap) |
all exit 0 with their regression lines |
second/F_build-checks.sh (a), (b) |
(a) router record under FOUNDRY_BROADCAST, router at 0x8f5d…1a52; (b) escrow 24,134 B default, 24,364 B no-L/E; no-L/E 758/761 (the 3 live-gate tests, expected), default 761/761 |
Verifier scripts, second run (V2_E-1 on an Ethereum fork, V2_G-1, V2_G-2) |
all OK/PASS |
Third-run scripts (EG3_*, F3_eip170_script_sim.sh, F3_resume_edited_broadcast.sh, V3_EG-1, V3_EG-2, V3_F-2_mainnet_resume_step3.sh, V3_F-2_service_routing.ts, V3_D-I2_stranded_v032.ts on Postgres) |
all "Regression … OK" / as designed |
| Service tests | 56/56 without Postgres + appeals 7, alerts-api 7, api 64, deposit-chains 9 on Postgres = 143/143 |
| Prover | 16/16 |
Web node --test lib/*.test.ts |
82/82 |
The inverted proofs are genuine. Against the third run's original files: 101 test functions on both sides, none dropped; 32 renamed test_fixed_* (or test_fix*) with the same attack path and the outcome flipped; proofs of Kleros behaviour that stays by design (D3_WinnersStake, D3_Delay, C3_*, V3_C_1) still assert it.
Third-run findings: does the fix hold?
| Finding | Holds? | Evidence |
|---|---|---|
| R3-D-1 / R3-AB-1 | Yes (contract) | YescrowEscrow.sol:551 cost <= _capFor(...); D3_CapBand 4/4, AB3_CapBand 4/4, ThirdRunFixes R3D1 ×4, V3_D-1_CapBand 5/5, V3_D-1_FixCheck 5/5; passes on both builds; docs one rule |
| R3-D-2 | Yes (web, texts, docs) | appeal.ts newJurorsOptions/guardEarlierStakes + appeal.test.ts; Dispute.tsx single-side "Fund it" only on older escrows; alerts asserted in appeals.test.ts; D3_WinnersStake 4/4 and V3_D-2 4/4 (funding both sides is safe from a later one-sided appeal) |
| R3-D-3 | Yes (docs/UI) | D3_Delay 2/2, V3_D-3_DelayScope 4/4 still measure the delay; Dispute.tsx shows both ends; texts in SPEC, guide, quotes.ts, dependencies.md |
| R3-EG-1 | Yes (service) | EG3_payout_lzfee_unbudgeted.ts, V3_EG-1_payout_paths.ts OK; config.test.ts; API AMOUNT_TOO_SMALL read only |
| R3-EG-2 | Yes (service) | EG3_inbound_completion_unscreened.ts, V3_EG-2_inbound_screen.ts OK; relayer-caps |
| R3-F-1 | Yes (scripts) | F3_UnpinnedSettings 6/6, V3_F-1_UnpinnedSettings 8/8, F_DeployAllGuards 3/3, F_CheckDeploymentGaps 11/11, V_F-1 3/3; the judge pin fails closed (read) |
| R3-F-2 | Yes (scripts, service) | F3_UnpinnedSettings, F3_LiveV032Gate 3/3, V3_F-2_GateTokenBinding 7/7, F3_resume_edited_broadcast.sh, V3_F-2_mainnet_resume_step3.sh; extra check driving the real escrowsOf/loadChain on a stub chain: honest record accepted, hand-swapped refused (escrow.ts:192), symbols-only swap refused (:194), duplicate refused (config.ts:156) |
| R3-AB-2 | Yes (contract) | AB3_StageFallback 3/3, ThirdRunFixes R3AB2 ×2, V3_AB-2_FixRegression 2/2, V3_AB-2_StageFallback 8/8, V3_AB-2_RealKlerosFork 11/11, Hardening |
| R3-D-I1–I3, R3-AB-I1–I3, R3-C-2, R3-C-3, R3-F-I1–I4 | Yes | docs, V3_D-I2 on Postgres, texts in relayer-caps, app.ts:947-949 (read), comment, check.sh parts with dynamic linking off, deploy-all.sh switches checked offline |
| R3-C-1 | Yes (service, web; the verifier's cap unchanged by design) | rdap.test.ts 3/3; app.ts runs the check in proposal (quotes, drafts); migration 016; deal-state.ts; C3_MarginEdges 7/7, V3_C_1_LayoutTrap 8/8 |
Second- and first-run fixes on v0.33
All hold: second run 104/104 proofs (65 + 39 V2_*), SecondRunFixes 13/13, SecondRunFixesFork 10/10, its scripts and service tests; first run 187/187 proofs (116 + 71 V_*), Hardening 24/24, Judge 19/19, its scripts; the earlier proofs adapted to the pins pass.
Failures
None on the default build. On the no-L/E build only (F_build-checks part b), the three F3_LiveV032Gate tests fail as expected: the live testnet code was built with the default pipeline.
Notes (not regressions)
scripts/pentest/verify/V3_F-2_mainnet_resume_step3.shis committed as mode 100644; run it withbash, or commit it 100755.scripts/pentest/verify/V3_F-2_service_routing.tsexercises onlyescrowsOf/escrowFor/escrowAt, not v0.33'sloadEscrowcheck; no committed test coversloadEscrow's refusals (config.test.tscovers only the duplicate).- Three v0.33 API paths are covered only by reading: R3-C-1's
NAMESERVERS_TOO_LONG, the draft keeping its list and the list shown to the seller; R3-EG-1'sAMOUNT_TOO_SMALLat signing; R3-AB-I2'sDEAL_WRONG_STATE. R3-C-1's check runs only inproposal(quotes and drafts), not on a relayedpropose, a seller's/v1/transactionsproposal or a direct contract call; the seller-side texts cover those. deploy-all.shprints "resuming from contracts//abs/path…" with an absoluteFOUNDRY_BROADCAST(cosmetic).053b285's message says web unit 84; the committed tree has 82 (the working tree's extra two belong to another session's uncommitteddeal-state.test.ts).- Unchanged:
F_verify-profiles.sh's stale label;C_template_re2case 2 /C_enginescase 11's known refusal;C_engines --livenot run. - Leftovers in the copy:
contracts/out-nole,contracts/cache-nole,scratchpad/pentest4/R-tmp.
Part 3. How the run went
- Who ran it. At the user's choice ("small targeted run"), from the session that wrote v0.33, after the user had
the commits through
1e08feepushed. The orchestrator exported the commit, launched the agents, saved their reports and verdicts (the harness refuses report files from subagents, so every report and verdict came back as text and was saved by the orchestrator unchanged, apart from shortening absolute scratch paths) and assembled this report. - Copies.
git archive 1e08feeintopentest4/base(never modified) with a warm build of the sources, scripts and mocks, one copy per attack area (D,AB,F,SV),Rfor regressions and one per verifier; the other sessions' uncommitted edits were not included. Every heavy job ran one at a time under a shared lock and a 2,500 MB memory cap, with forge only on named files (a whole-suite build runs out of memory on this 2-core, 4 GB server). - Agents. Four attack agents on Fable 5.1 at maximum effort, each given the procedure's prompt word for word with a brief of what v0.33 changed in its area. One regression agent (Opus 5.5). Seven verifiers on Opus 5.5 (six at maximum effort, one for the settlement area's Info notes at the default), with the user's rule.
- Interruption. The 5-hour usage limit stopped five verifiers; they resumed from their saved state after the user logged in again.
- Timeline. 2026-11-01 about 17:30 to 2026-11-02 00:00 (server time), including the pause.
- Files. The attack proofs (
contracts/test/pentest/fourth/*,scripts/pentest/fourth/*) and the verifiers' tests (contracts/test/pentest/verify/V4_*,scripts/pentest/verify/V4_*) passed in their own agents' runs, in copies of1e08fee; they go into the repository with the fixes, turned into regression tests where they assert a weakness that gets fixed.