← yescrow security review

Published copy of reports/AI pentest, ninth run (2026-11-06).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, ninth run (2026-11-06)

A short targeted step 4 of docs/ai-pentest.md on commit dc3e837 (current main, SPEC v0.36): area G (off-chain parts) only, scoped to what changed in the service and the web app's signing since the last run that covered area G (the fourth run, on 1e08fee). No Solidity changed since 1e08fee (git diff --quiet 1e08fee dc3e837 -- contracts/src), so the contracts were out of scope; area F ran eight times. One fresh attack agent on area G, with the procedure's prompt word for word and the brief narrowed to the list below; a fresh verifier checked each finding, on the service's real local stack. Proofs are under scripts/pentest/ninth/, the verifiers' under scripts/pentest/ninth/verify/.

The terms of this round (the user's, 2026-11-06). Attack only:

  1. v0.36 (d8df9d3): dispute answers the sponsor budget can't cover are kept and sent later, and the answering party's fee is set aside when a dispute opens (service/src/answers.ts, sponsor.ts, keeper.ts, migrations/018_kept_answers.sql, the answer and admin endpoints in api/app.ts, typed-data.ts; SPEC §14.17).
  2. v0.35: one deal may take half the day's sponsor budget (sponsor.ts; SPEC §4.2, §14.7, §14.17).
  3. v0.34's service fixes (d739a9e), never attacked: glue-record refusal and the proof_blocks flag (rdap.ts, keeper.ts, migrations/017), the RDAP queue and rate limits (rdap.ts, quotes.ts, api/app.ts, client-key.ts), the startup chainId/decimals check (escrow.ts, main.ts), "fund both sides" jurors first (web/lib/appeal.ts, web/components/deal/Dispute.tsx).
  4. The signing dialogs changed since 1e08fee (web/components/sign/*, web/components/deal/PayWithWallet.tsx): does what a party is shown match what it signs?

The whole change: git diff 1e08fee dc3e837 -- service/src web/lib web/components/sign web/components/deal (51 files, +1739/−654). Proofs are scripts against a local stack: the service's harness (service/test/harness.ts: an Anvil fork of Arbitrum Sepolia with real Kleros, a scratch Postgres with the real migrations, the real app and keeper), with the database from scripts/test-db.sh. Report first: nothing is fixed until the user has seen it.

Part 1. Summary

Result

No Critical or High finding, and no finding needs a contract change. Two findings are Medium, both in v0.36's answers that yescrow pays for, and in both a seller (or buyer) with no ETH who did everything right can lose its deal by default; two are Low and one Info. Three of the five came from the verifiers, not the attack agent: R9-G-2's verifier proved a far stronger attack than the Info reported, and R9-G-4 and R9-G-5 were found while verifying other findings, each then checked by its own fresh verifier.

The parts the round was asked to look at and found sound: the kept answer's signature (the answering party's PayArbitrationFee at phase 1, single use, bound to its deal, valid only until the fee deadline, and the longer relay deadline only for that action); one fee per deal and role, never counted twice; v0.35's half-the-budget deal share; the signing dialogs (every signed field shown, token amounts with the deal's real coin, the checkbox required where there are warnings); the v0.34 glue refusal and proof_blocks flag; the RDAP queue and rate limits; the startup chainId/decimals check. The budget accounting is sound apart from R9-G-4 and R9-G-5, which the attack agent's "checked and found sound" had missed.

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
R9-G-2 The opener of a real dispute keeps a no-ETH party's signed answer unpaid for the whole fee period, for free, while the deal page says nothing more is needed Info Upgraded: the keeper's most-urgent-first order lets the opener's own shams take each freed budget first; proven end to end, the seller loses 1,500 USDC if nobody approves Medium answers.ts:119-148 sends signed (kept) answers before placing set-asides; a last-resort payment of a kept answer near its deadline under its own small cap, and a near-deadline ops escalation; honest kept-answer copy (deal-state.ts:334-337), reminders (alerts.ts:142) and the fee button (DealPage.tsx:424-425); SPEC §14.17, docs/audit.md §5
R9-G-5 A fee booking left unsent by a stopped service blocks that party for good: refused, never sent, approval 409 — (found by R9-G-4's verifier, "High if reachable") Confirmed with the real main.ts: loops abandon a send ~25 ms after SIGTERM; a Postgres restart crashes the service; reachable only by coincidence Medium Treat an unsent row older than any send as unpaid: check the chain, take it over atomically and send (in sponsorship(), reserve(), approveAnswer(), as appeals.ts:192-194 does for stakes); record the hash at broadcast; drain loops and the relayer queue on SIGTERM; stop_grace_period; pool.on('error'); an ops alert for unsent rows
R9-G-1 Any admin wallet approves kept answers past the daily budget, uncapped, beyond what docs/keys.md says Low Confirmed on the real route with an everyday wallet; also skips the minimum deal age and the relayer reserve, and no alert counts it Low A separate daily cap on approved spend, checked in reserve(); the fee + 0.001 ETH relayer reserve in approveAnswer; approved fees in an ops alert; docs/keys.md, SPEC §12 and the other texts listed; main-only approval is the user's call
R9-G-4 The keeper's over-budget release deletes a set-aside the relay path already claimed: a sent fee goes uncounted — (found by R9-G-3's verifier, suggested Low) Confirmed on real Postgres; also a receipt timeout after the broadcast deletes the row the same way Low Insert and re-check in one transaction under an advisory lock, no DELETE (the minimal fix, AND set_aside on the keeper's release, closes two of three schedules); release a fee's row only if nothing was broadcast
R9-G-3 The G-1 regression proof fails since v0.36, so it guards nothing Info Confirmed; two more off-chain regressions are broken (V2_G-1_screen_heals.ts, V4_D-1_page_offers.test.ts); runs 5–8 ran only the Foundry proofs Info Repair the three scripts (statements matched whole against sponsor.ts, as V9_G-3_sponsor_db.ts does; unknown SQL throws); run the light scripts/pentest/ scripts from scripts/check.sh

Unconfirmed (in the area report and the verifications): an IDN nameserver's decoded unicodeName costing more bytes than rdap.ts:125 allows if the attestor escapes non-ASCII as \uXXXX (carried from the fourth run; no IDN fixture); whether the keeper's transfer and payout sends, abandoned on SIGTERM the same way as R9-G-5's, are reconciled after a restart (appeals.ts is; the others weren't checked); whether an outsider can grow the service's memory to an OOM kill (it has no mem_limit); whether mainnet's RPC_URL is a fallback list (it is in the vault).

The regression run

The service's tests, once, on the copy of dc3e837 after the verifications (in service/: node --test --test-concurrency=1 --test-timeout=900000 test/*.test.ts, with the database from scripts/test-db.sh): 151 tests, 151 pass, 0 fail, none skipped, in 59 s. They include the fork-backed lifecycles, the fourth run's regressions (R4-SV-1's nameserver refusal and its keeper flag, the IPv6 /64 rate-limit key) and v0.36's own test ("sets the answer's fee aside when the dispute opens, and keeps an answer the budget can't cover for an admin to pay"). None of this round's proofs is in that suite yet: they become regression tests with the fixes.

Limits

The fixes (added after the report)

All five were fixed in SPEC v0.37, commit f5e3aab, in the service and the web app only (docs/audit.md §6f), each with a regression test: four service tests on the fork, alert and ops tests, and scripts/pentest/ninth/G9_fixed_stop_and_restart.ts; scripts/check.sh now runs the off-chain proofs. The proofs in scripts/pentest/ninth/ show the findings on dc3e837 and don't run on the fixed code. The tenth run attacked the fixes and found nothing (reports/AI pentest, tenth run (2026-11-06).md).


Part 2. Area report, with each verification

Area G: off-chain parts

AI penetration test, ninth run, area G: off-chain parts (commit dc3e837)

The attack agent's report, as it wrote it. The verifications after it correct it where they say so.

Attack run 9 of the documented procedure (docs/ai-pentest.md, step 4), on commit dc3e837 (current main, SPEC v0.36), working only in the exported copy. Area G, narrowed to what changed in the service and the web app's signing since the last area-G round (run 4, 1e08fee): the v0.36 kept-answers / set-aside sponsor budget (answers.ts, sponsor.ts, migration 018, the answer/admin endpoints, typed-data.ts); v0.35's "half the day's budget" deal share; v0.34's service fixes never attacked (glue refusal + proof_blocks, the RDAP queue/rate-limit, the startup chainId/decimals check); and the signing dialogs (web/components/sign/*, PayWithWallet.tsx). I read SPEC.md §4.2/§5/§7/§8/§12/§14.17, docs/audit.md, docs/keys.md, the full CHANGES-since-1e08fee.diff, the changed service/web sources, and the escrow's payArbitrationFee/_authorize. I ran the proofs below (pure-function, no Anvil needed), plus three existing regressions/unit tests. Contracts are out of scope and unchanged.

Result

One Low finding (a key power beyond docs/keys.md), two Info items, and a set of areas checked and found sound (with proofs). No Critical/High/Medium. No buyer/seller funds are at risk in anything found. I did not change contracts/src, service/src, or web/.

ID Finding Severity Proof command (from the copy's root)
R9-G-1 Admin "Answers to pay" approval spends relayer ETH past SPONSOR_DAILY_WEI, uncapped, and is open to everyday admin wallets — a power beyond docs/keys.md/SPEC §12 Low node scripts/pentest/ninth/G9_admin_approve_past_budget.ts → PASS
R9-G-2 The v0.36 "answers can't be starved" guarantee is operator-conditional; an attacker can cheaply force every honest AI-court answer into the kept state, where the deal page says "nothing more is needed" and reminders stop Info reasoned from SPEC §14.17 + alerts.ts:142, deal-state.ts:334 (no dedicated PoC)
R9-G-3 The published G-1 regression PoC no longer runs against v0.36 (throws on the new set_aside SQL), so it is not guarding anything Info node scripts/pentest/G_sponsor_drain.ts → errors (unexpected SQL)

R9-G-1. Admin answer-approval spends relayer ETH past the daily budget, uncapped, and any admin wallet can do it

Severity: Low. (A reviewer who weights the undocumented widening of a stolen admin wallet's blast radius — from "waive yescrow's fee income" to "uncapped relayer ETH + relayer exhaustion" — could argue Medium under the brief's "griefing / loss of yescrow's funds." I keep it Low: no buyer/seller funds, and it needs a key compromise plus attacker-run shams.)

Attacker and preconditions: the holder of any admin wallet or a leaked 12-hour AdminSession (main or everyday — an everyday wallet is added by a main one and holds no key on the server), plus the ability to run its own deals (both parties) to produce kept answers to approve. The attack targets yescrow's own relayer ETH, not any party's money.

Steps: 1. v0.36 adds POST /v1/admin/answers/:dealId → approveAnswer (answers.ts:186). It takes admin(req) (any listed admin; no Admin.main check, app.ts:929-931) and calls sendSponsored(..., { approvedBy: admin }). 2. sendSponsored → reserve(..., { approvedBy }), which returns 'approved' without the budget check (sponsor.ts:164) and writes approved_by. 3. spentToday excludes approved_by IS NOT NULL rows (sponsor.ts:63-70), so an approved fee never counts against SPONSOR_DAILY_WEI and never blocks the next approval. 4. The attacker sets up sham AI-court deals it controls on both sides, opens disputes, and signs the (zero-ETH) answering side's answers so they are kept. Then, with the stolen admin wallet, it approves each one. Each approval makes the relayer send a payArbitrationFee (gas + the fee value), past the budget, with no cap on count. The proof fills the day's budget, shows an ordinary sponsored answer is then refused, and shows 50 admin approvals all succeed with spentToday unchanged.

Impact: a documented key (admin wallet) can cause the relayer to spend ETH on arbitration fees with no daily cap — the budget SPONSOR_DAILY_WEI is the stated bound on this exact spend. Net ETH lost per approved sham case is small (Kleros's cost, or up to a full fee if the attacker forces the answerer's side to lose) but the count is unbounded, so a stolen admin wallet can drain the relayer's ETH (→ no gasless actions, keeper calls, payouts or forwarding for anyone: a service-wide availability loss). This exceeds docs/keys.md's admin-wallet row ("Only what the promo key can do… at most codes that waive the fee") and SPEC §12/SPEC.md:451 ("Admin wallets can only create and change promo codes… at most waive yescrow's own fee… No buyer or seller loses anything"). It is correctly limited to AI-court, no-ETH kept answers (the kept-answer path is only reached after sponsorship confirms those), so the only skipped check is the budget — by design per SPEC §14.17, but not reflected in the key-powers doc.

Proof: node scripts/pentest/ninth/G9_admin_approve_past_budget.ts → prints the budget filling at 4 fees, an ordinary answer refused, then 50 approvals (0.0405 ETH) accepted past the 0.004 ETH budget with spentToday unchanged. Printed PASS.

Root cause: service/src/sponsor.ts:164 (reserve returns 'approved' with no budget check and no running cap) + service/src/sponsor.ts:63-70 (approved fees excluded from spentToday); service/src/answers.ts:186-198 (approveAnswer); service/src/api/app.ts:929-931 (route takes any admin, no main check). Documentation gap: docs/keys.md admin-wallets row and SPEC.md:451.

Suggested fix: update docs/keys.md and SPEC §12 to state that an admin wallet can also authorise relayer payment of a kept answer's arbitration fee past the daily budget (yescrow's own ETH, no party funds). Add a daily cap on admin-approved spend (a second budget), and/or restrict /v1/admin/answers/:dealId to main admin wallets, so a leaked everyday wallet/session can't drain the relayer.

R9-G-2. "Answers can't be starved" is operator-conditional under a cheaply-saturatable budget (Info)

Attacker/preconditions: any third party who can run self-dealt deals; targets honest zero-ETH answerers in the AI court.

Steps / impact: SPEC §14.17 headlines that since v0.36 budget exhaustion "can't leave a real party's answer unpaid." The actual guarantee is weaker: the set-aside (answers.ts tick) only protects a dispute from later ones, and is placed only if the budget has room when the keeper first sees it. An attacker who pre-saturates the budget (two self-dealt, never-answered disputes, aged past SPONSOR_MIN_DEAL_AGE_HOURS, hold the whole mainnet 0.004 ETH / 50% deal share for the 72-hour fee period at ~zero net cost — the opener's sponsored fee is refunded on feeTimeout and the answerer set-aside is dropped) then opens a real dispute: no budget → no set-aside → the honest answer is kept, and payment depends entirely on an operator approving (R9-G-1) before the fee deadline. Once kept, the deal page shows "Your answer is in… nothing more is needed" (deal-state.ts:334), and alertsDue stops the "pay the fee or lose by default" reminders (alerts.ts:142). If the operator is slow, the party loses its whole deal at feeTimeout without further warning. This is essentially the documented G-1 residual (open shams hold their budget share for 72h) plus the documented operator-approval backstop; I report it only because the SPEC §14.17 headline over-states the guarantee and an attacker can force every AI-court answer onto that manual path cheaply. The hidden self-pay button (DealPage.tsx:425) is not a real reduction: it is hidden only after the party has signed, and such parties have no ETH to self-pay anyway.

Proof: reasoned from SPEC §14.17 and the two code lines; no dedicated PoC (the terminal step — loss at feeTimeout after operator latency — is the contract's existing, well-tested behaviour and the operator-failure precondition is an operational assumption).

Suggested fix: soften the "nothing more is needed" copy to name the fee deadline and that it depends on yescrow acting; keep (or add) a near-deadline reminder even when kept; consider a small per-answer reserve that a later sham can't take.

R9-G-3. The G-1 regression PoC no longer runs against v0.36 (Info)

scripts/pentest/G_sponsor_drain.ts (the published regression for finding G-1, re-run clean as recently as run 4 per docs/audit.md) now throws unexpected SQL: UPDATE sponsored_fees SET set_aside = false … because v0.36's reserve (sponsor.ts:156) issues a set-aside-claim query its in-memory DB stub doesn't model. The product behaviour is sound (see the two new proofs below); but that regression is no longer exercising anything, so a future break of the G-1 properties would not be caught by it.

Proof: node scripts/pentest/G_sponsor_drain.ts → throws at G_sponsor_drain.ts:102. Suggested fix: update the stub to handle the set_aside/approved_by columns and the claim/UPDATE set_aside queries (as scripts/pentest/ninth/G9_*.ts do).

Unconfirmed

Checked and found sound

Not done

Proof files written (in the copy): scripts/pentest/ninth/G9_admin_approve_past_budget.ts, scripts/pentest/ninth/G9_setaside_mutex_sound.ts. The scratch Postgres was started and stopped; no live system was touched.

Verifications for area G

Each by a fresh agent (Claude Opus 5.5, maximum reasoning effort) that tried to refute the finding and ran its proof again, on the service's real local stack, under the user's rule. R9-G-4 and R9-G-5 were not in the attack agent's report: R9-G-3's verifier found R9-G-4 and R9-G-4's verifier found R9-G-5, and each went to its own fresh verifier.

R9-G-1: Admin answer approval spends relayer ETH past the daily budget, uncapped, from any admin wallet — Verdict: CONFIRMED

Verdict

The finding holds on the real local stack. I went through the real POST /v1/admin/answers/:dealId route with a real admin session from an everyday admin wallet (one a main wallet added; the API reports it as main=false).

The power is deliberate. SPEC §14.17 (SPEC.md:685) says any admin wallet can approve past the budget, and the admin page says so too (Admin.tsx:8-10, 330). But docs/keys.md:22 and SPEC.md:451 still say an admin wallet can only manage promo codes, and the relayer docs say its fee spending is capped per day. So this is "a key does more than docs/keys.md says", and the extra power has no cap at all.

Severity: Low (unchanged)

Proof result (all commands from the copy's root)

  1. node scripts/pentest/ninth/G9_admin_approve_past_budget.ts → PASS, exit 0. - Printed: budget filled at 4 fees (3.24e15 wei), an ordinary fee then refused (reserve() = false), 50 approvals (4.05e16 wei) accepted, spentToday unchanged. - Limits: it runs the real reserve()/spentToday() over an in-memory table. There is no route, no admin check, no relayer and no chain, and it uses a 100% deal share instead of mainnet's 50%.
  2. eval "$(scripts/test-db.sh start)", then node scripts/pentest/ninth/verify/V9_G-1_admin_approve_real_stack.ts → PASS, exit 0. - Setup: the harness (Anvil fork of Arbitrum Sepolia, real Kleros court 8, scratch Postgres with the real migrations, buildApp), with mainnet's SPONSOR_DAILY_WEI of 0.004 ETH and deal share of 5000 bps. - Step 0: the everyday session shows main=false, and POST /v1/admin/wallets returns 403 NOT_MAIN_ADMIN. - Step 1: with the budget empty and mainnet's 6-hour minimum deal age, a fresh sham deal's answer was kept ("…active for at least 6 hours"). The approval returned 200: the relayer paid 0.00174537 ETH, Kleros case 187, spentToday 0 → 0. - Step 2: two sponsored disputes filled the budget to 0.00324 of 0.004 ETH. An ordinary sponsored dispute then returned 400 FEE_NOT_SPONSORED ("today's budget … is used up"). - Step 3: five more answers were kept and listed on the everyday wallet's page as "0.00324 of 0.004". Each approval returned 200 (Kleros cases 190–194) and spentToday stayed at 0.00324.
    • Total: 6 approvals, 0.00486 ETH of fees, 0.0103784 ETH with gas.
    • sponsorUse, which feeds the ops "sponsor" alert, still shows 0.00324 ETH over 2 deals.
    • Step 4: the relayer-paid side conceded on one sham, so the deal was Released. The attacker's seller is owed 0.00081 ETH (its own fee back); the relayer gets 0 (refunded_wei 0).
    • Step 5 (not shown on the fork): an approval below the relayer-low reserve. The approval transaction has a gas limit of 6,458,568 and used 916,558. At Anvil's 1 gwei minimum tip it needs 0.0065 ETH upfront, more than the reserve, so the fork can't reach that state. The same gas limit at Arbitrum One's 0.01 gwei needs about 0.00008 ETH.
    • Step 6: the main wallet removed the everyday one (200). The same session's next approval returned 403 NOT_ADMIN.
  3. scripts/test-db.sh stop → stopped; no Anvil or Postgres left running.

Corrections to the finding

Fix assessment

Only admins can reach this budget, so sham disputes can't use it up and it doesn't reopen v0.36's starvation hole. Size it for a few real rescues a day. - Main-only: this closes the everyday-wallet part. The cost is that each rescue inside a 72-hour fee window then needs the main wallet, which is meant to stay offline. SPEC §14.17 and the page's "Pay the fee" button would need changing, and a stolen main wallet would still be uncapped. The existing api.test.ts approval test signs in with a main wallet, so it passes either way. - Recommendation: docs, the cap, the reserve and the alert together. Main-only is the user's call, since it trades away rescue speed.

Files written

Nothing outside the copy and scratchpad was touched, and no live system was contacted except the public Arbitrum Sepolia RPC the fork reads from.

R9-G-2: The opener of a real dispute can keep a no-ETH party's signed answer unpaid for the whole 72-hour fee period, for free, while the deal page tells that party nothing more is needed — Verdict: UPGRADED to Medium

Verdict

The finding is confirmed, and the real attack is stronger than reported, but it works by a different mechanism.

Severity: Medium, at the upper edge (reported as Info).

Proof result (all commands run from the copy's root)

  1. node --check scripts/pentest/ninth/verify/V9_G-2_kept_answer_starved.ts printed SYNTAX-OK.
  2. eval "$(scripts/test-db.sh start)" && node scripts/pentest/ninth/verify/V9_G-2_kept_answer_starved.ts failed on my own test data: UNSUPPORTED_DOMAIN for an upper-case sham domain. I fixed the script.
  3. The same command again (with --no-warnings, using the database from step 2) printed PASS, exit 0. The main lines: - Setup. The harness escrow has a 300-second fee period, so the script deploys an escrow with mainnet's FEE_PERIOD of 259,200 s and the 24 h / 72 h challenge floors. Everything else is the harness's escrow config. The service runs with mainnet's sponsor settings: 0.004 ETH a day, a 5000 bps share per deal, a 6-hour minimum deal age. The budget's 24-hour window runs on Postgres's clock, so every chain warp moves the sponsored_fees and kept_answers rows back by the same amount. - Part A (the finding's version).
    • 2 opener fees sent by the relayer + 2 answer fees set aside = 0.00324 ETH of 0.004 ETH: full.
    • The real answer is kept ("today's budget for fees yescrow pays is used up").
    • 24 h + 1 min later (the shams still open … 48 h left on their fee period) … the keeper's next tick SENT the kept answer itself -> victim-a Disputed, Kleros case 187.
    • Part B (the strong version).
    • 12 sham disputes mined in one block at 1792444980 (fee deadline 1792704180). The challenge lands at …981, so victim-one's fee deadline 1792704181, 1 s after the shams'.
    • First keeper tick: it set aside the answer fees of B-sham2, B-sham4, B-sham6, B-sham7 -> 0.00324 ETH; nothing for victim-one or victim-two. Both honest sellers sign (keepsAnswer=true) and are kept, until 1792704181.
    • +24 h: … set aside B-sham1, B-sham9, B-sham11, B-sham12 … victim-one still kept, 48 h left.
    • +48 h: … set aside B-sham10, B-sham3, B-sham5, B-sham8 … 24 h left.
    • at 1792704179 … budget 0.00324 ETH … victim-one still kept.
    • at 1792704186: … feeTimeout on the 12 shams and on victim-one -> victim-one Refunded: the attacker (buyer) is owed 1500 USDC … and 0.00081 ETH; the honest seller, who moved the domain, 0 USDC.
    • yescrow's relayer sent 0 sponsored fees in part B, and Answers.tick errors during the run: 0.
    • (b) What the party and the operator see. The texts come from the real API deal passed to todoFor (web/lib/deal-state.ts) and the database snapshot passed to alertsDue.
    • Deal page card: "Your answer is in" / "Yescrow has been told and pays the court's fee before the deadline, so the jurors decide. Nothing more is needed from you…", with a "Fee deadline" countdown.
    • The logic at DealPage.tsx:424-425 gives kept=true, answering=false: no fee button.
    • Party alerts: the only fee alert is the one at the dispute's opening, before the party signs. At 23 hours and at 30 minutes before the deadline a kept answer gets 0 alerts. Without the kept answer the party would have got "23 hours left…" and "30 minutes left…".
    • Operator: the ops alert text is quoted above, and the admin list shows "72 h left".
    • After the loss, the seller's next alert is "The buyer was refunded for victim-one.app / The deal is closed."
    • (c) Can the party protect itself? victim-two is given ETH. The typed data then says "your wallet has enough ETH to pay the fee", but its page still shows the answer as kept, with no button. It can still pay from its own wallet through POST /v1/transactions, which gave Disputed, Kleros case 188. So a party can protect itself only by getting about 0.0009 ETH and paying outside the page.
  4. scripts/test-db.sh stop. No Anvil or Postgres process is left running.

Corrections to the finding

Fix assessment

Files written

No source file was changed.

R9-G-3: The G-1 regression proof no longer runs on v0.36 — Verdict: CONFIRMED, Info

Verdict

Confirmed at Info. node scripts/pentest/G_sponsor_drain.ts exits 1 with unexpected SQL: UPDATE sponsored_fees SET set_aside = false …. The throw is at G_sponsor_drain.ts:102, reached from reserve() at sponsor.ts:156 via the first pay() in step 3 (:164).

Severity: Info (kept)

Proof result (each from the copy's root; I wrapped the commands in timeout and nice)

Command Result
node scripts/pentest/G_sponsor_drain.ts Steps 1–2 print and pass, then Error: unexpected SQL: UPDATE sponsored_fees SET set_aside = false … at :102, from sponsor.ts:156. Exit 1.
Same script on git archive trees d8df9d3^ / d8df9d3 (in the scratchpad) Exit 0 ("12 sham disputes…", "11 sham cases… 8910000000000000 wei", "Regression for G-1 OK") / exit 1 (the same throw)
node scripts/pentest/ninth/G9_setaside_mutex_sound.ts 1. … new false, then 2. … spentToday = 1620000000000000, 3. …, 4. … total = 3240000000000000 budget = 4000000000000000, PASS. Exit 0.
node scripts/pentest/ninth/G9_admin_approve_past_budget.ts PASS. Exit 0.
node scripts/pentest/ninth/verify/V9_G-3_g1_regression_v036.ts (mine) "the stub models all 13 SQL statements in service/src/sponsor.ts". Steps 1–4 print the same figures as the pre-v0.36 run (12 shams, 11 cases, 8910000000000000 wei). Step 5: "yescrow already paid your fee once in this deal", but { sponsored: true } under the original stub's answer. Step 6: 6 shams hold 9720000000000000 wei, the honest answer gets later: true, the budget is back to 0 once they close, and the honest answer is sponsored again. Step 7 flags sweep x112 -> drop and used x59 -> dealSpend as WRONG BRANCH. Exit 0.
node scripts/pentest/ninth/verify/V9_G-3_setaside_release_race.ts (mine, side check) "4. K's re-check counts 3 + D + X = 5 fees > the budget … D's row now: GONE". "the relayer paid 5 fees today (4050000000000000 wei) … the budget counts 3240000000000000 wei". Exit 0.

Other proofs under scripts/pentest/ that use service code, run with their documented commands:

Corrections to the finding

  1. Where "re-run clean as recently as run 4" comes from. It is in the run-4 report (:683), not docs/audit.md; §6d there doesn't name the script. The finding should also say runs 5–8 skipped it while stating that every earlier proof ran.
  2. "No longer exercising anything" is slightly overstated. Steps 1–2 (the age gate and the share cap) still run and pass. Steps 3–4 never run: the refunds freeing the budget, the count of only what Kleros kept, and the ops "fresh" figure.
  3. Two more stub faults the finding misses, both silent: - v0.36's SELECT set_aside … (sponsor.ts:119) is answered by the dealSpend substring branch (G_sponsor_drain.ts:71). That returns a sum with no rowCount, so the real sponsorship() skips its "already paid once" check (:121). - The set-aside sweep in settleRefunds (sponsor.ts:45-48) is answered by the release branch (:96) with no parameters, so it does nothing.
  4. The two cited proofs don't back "the product behaviour is sound" for G-1. - G9_setaside_mutex_sound.ts never calls sponsorship() or sponsorUse(). It sets the share to 10,000 bps, which disables the per-deal cap. It answers settleRefunds with empty results. - It models its 7 statements faithfully at statement level: the primary key from migration 008, the claim's set_aside AND tx_hash IS NULL, and the approved_by IS NULL sums from 018. Each statement is atomic and actors interleave only between statements, as autocommit statements on a pg.Pool do. So its four checks are trustworthy for what they literally do. - It claims two things it never runs. Check 3's "a set-aside whose send fails is restored" is untested; it only calls release(…, 'new'). Check 1's "(the relay path and the keeper's answers.tick() race safely)" is untested; it races two plain reservations. - The second claim is false in at least one schedule. The keeper sets a fee aside (answers.ts:136), the relay path claims it (app.ts:1298), and a third reservation lands at the budget's edge. The keeper's over-budget release(…, 'new') (sponsor.ts:166) then deletes the claimed row, because its DELETE (:176) has no set_aside condition. The sent fee goes uncounted and the day passes SPONSOR_DAILY_WEI by one fee. - This side observation is outside R9-G-3 and is shown only on the stub, with the timing forced. I'd suggest Low. It should go to a fresh verifier. A likely fix: release a set-aside insert only while it is still set aside. (It became R9-G-4, below.)

Fix assessment

Files written

R9-G-4: the keeper's over-budget release deletes a set-aside that the relay path has already claimed, so a fee that was sent is never counted — Verdict: CONFIRMED, Low

R9-G-4 was not in the attack agent's report: R9-G-3's verifier found it on the stub (correction 4 there), and this fresh verifier checked it.

Verdict

I reproduced it on real Postgres. Setup: Postgres 18.6 with the real migrations 001–018 and the real code on the whole path: buildApp() handling POST /v1/relay through app.inject, the keeper's Answers.tick(), sponsor.ts, and the real Relayer class (queue, simulate, send, receipt). Only the chain RPC and the wallet's broadcast are stubbed. Each actor gets a view of the one shared pg.Pool that can hold a statement back. Every statement still runs on Postgres in autocommit, as in the service.

Every schedule, worked out from the code: - Only one delete can remove someone else's row. It is the keeper's over-budget release of a set-aside it just inserted (answers.ts:136 → sponsor.ts:165-166 → :176). - Every other insert has set_aside = false, and the claim only matches set-aside rows. Those inserts are API 'new' rows, the keeper's kept-answer sends and admin approvals. - The sweep (sponsor.ts:46-48) only drops unclaimed set-asides. - That release needs a third actor. It only runs when the keeper's re-check fails. Between the keeper's check and its re-check, only a new INSERT can raise the count: rows ageing out, refunds and the sweep only lower it, and the keeper's loop is sequential. - So a third actor N must make a new sponsored INSERT through the API: a challenge, a dispute, or an answer with no set-aside. - N's own check must run before the keeper's INSERT, and N's INSERT before the keeper's re-check SUM. - The claim must land inside the keeper's window, between its INSERT and its DELETE. The claim comes from R, the answer's relay (app.ts:1298 → answers.ts:76 → sponsor.ts:156). In theory an admin approval (answers.ts:194) could claim too. - R's recordSent waits for the relayer queue and the receipt, so in practice it always comes after the DELETE. - Outcomes: - S1, the reported schedule (N re-checks after the DELETE): 5 fees paid, 4 counted. - S2 (both re-check before either deletes): N is refused, R's fee is still uncounted, and a later sponsored dispute pushes the day past the budget. - S3 (N re-checks before the keeper's INSERT): 5 paid, 4 counted. - No schedule counts a fee twice. The primary key allows one row per (escrow, deal, role), and the set-aside and its answer share that row.

The schedule is reachable in the real service. The keeper loop, the API handlers and the relayer share one pool of autocommit statements, and nothing serialises reservations. The relayer queue serialises sends only, and that widens R's window between claim and recordSent. But R's claim has to fit a window of about 1 ms.

Severity: Low. I agree with the suggested Low. - What it costs. Only yescrow's own sponsorship ETH. Each occurrence costs at most one AI-court fee (0.00081 ETH, about $2 at the SPEC's own "about $9 a day" for 0.004 ETH) past SPONSOR_DAILY_WEI in that 24-hour window. - It comes back to the relayer in full if the answering side wins. - No party's money is touched, nothing is released falsely, and no deadline is missed: the answer is sent. - sponsorship() still refuses when the relayer holds less than the fee + 0.001 ETH, which caps real spending. - How reachable. The keeper's set-aside INSERT happens once per dispute: the first answers tick (every 30 s) after AwaitingFee is indexed and the budget has room. - Its INSERT-to-DELETE window measured 0.82 ms median and 3.05 ms p99 over 300 runs. - An attacker can't see the INSERT before the window closes. The only outside sign is the typed-data sponsored flip at the budget's edge, which arrives an HTTP round trip plus RPC calls later. - They get one try per sham dispute, on a deal at least 6 hours old, with the budget at its edge and a concurrent N. - An untimed answer has about a 3·10⁻⁵ chance per try (1 ms out of 30 s), before N's timing is even counted. Natural traffic would need an answer within about 1 ms of the keeper's first set-aside, and real answers come hours later. - Why not Medium. This is a bounded overspend of one fee past a soft cap, not lost revenue, and it is practically unreachable.

Proof result (all from the copy's root)

  1. scripts/test-db.sh start printed export TEST_DATABASE_URL=postgres://postgres@127.0.0.1:55433/postgres, which I exported.
  2. node scripts/pentest/ninth/verify/V9_G-4_setaside_race_pg.ts exited 0 in 3.9 s, with all assertions passing. - Each API caller gets its own buildApp() instance sharing the pool, the chain and one relayer queue, because AsyncLocalStorage didn't survive app.inject. - S1 as Postgres ran it (K = keeper, N = X's buyer's sponsored challenge, R = D's seller's answer):
     5 K today     counts 3.00 fees
     6 K insert    D/seller set_aside=true -> inserted
      .. K held before its sweep
     7 N insert    X/buyer set_aside=false -> inserted
     8 R claim     D/seller -> 1 row(s)
     9 chain     relayer BROADCASTS payArbitrationFee on D with 1.00 fees (810000000000000 wei)
    10 K today     counts 5.00 fees
    11 K drop      D/seller -> 1 row(s)
    12 N today     counts 4.00 fees
    14 chain     relayer BROADCASTS challenge on X with 1.00 fees (810000000000000 wei)
    15 N sent      X/buyer -> 1 row(s)
    16 R sent      D/seller -> 0 row(s)
   R: HTTP 200 …  N: HTTP 200 …
   after S1: relayer paid 5.00 fees (4050000000000000 wei) today; the budget counts 4.00 fees (3240000000000000 wei)
   D closes Refunded (…the seller's answer fee… goes to Kleros): D/buyer challenge sent refunded=1.00 fees
   counted after the close: 3.00 fees; sponsorUse (ops alert input): spent 3.00 fees, fresh 3.00 fees, deals 4

Summary of the schedules (budget = 4.94 fees):

Schedule Paid Counted Past budget Uncounted fee
S1 5 4 yes yes
S1 + fix 4 4 no no
S2 5 4 yes yes
S2 + fix 4 4 no no
S3 5 4 yes yes
S3 + fix 5 5 yes no
S4 5 3 yes yes

The other runs: - S4 (no race): the receipt wait fails after the broadcast. The answer gets HTTP 500, its row is restored as a set-aside, and once the deal is indexed as Disputed: D/seller: NO ROW (swept as an unused set-aside). A 'new' challenge with the same failure: Y/buyer row: NO ROW (deleted by release(…, "new")). - S5: see the side observation below. - W: median 0.82 ms, p90 1.13 ms, p99 3.05 ms, max 3.39 ms. 3. node scripts/pentest/ninth/verify/V9_G-3_setaside_release_race.ts reprints the finding's quoted lines ("…5 fees > the budget … D's row now: GONE", "paid 5 fees today (4050000000000000 wei) … counts 3240000000000000 wei"). 4. node scripts/pentest/ninth/G9_setaside_mutex_sound.ts prints PASS, but its check 1 only races two plain reservations (new false). 5. scripts/test-db.sh stop stopped the database; the test database was dropped and the cluster deleted. The real repository is untouched.

Corrections to the finding 1. Line references: all correct. The claim UPDATE itself is at sponsor.ts:156. 2. "A third reservation at the budget's edge": it must be a new INSERT from the API (not the keeper, not an approval). Its check must come before the keeper's INSERT and its INSERT before the keeper's re-check, and the claim must fall in the keeper's ~1 ms window. 3. "Passes the budget by one fee": it passes by up to one fee per occurrence. That happens at once in S1 and S3, and in S2 only if another sponsored fee comes that day. While the deal is open the whole fee goes uncounted; after it closes, the part Kleros kept is never counted. 4. "Shown only on a stub": it is now shown on real Postgres with the real app, keeper and relayer. 5. G9's check 4 ("the daily budget is a hard ceiling") is also false. 6. Nothing notices or repairs it: - Nothing reconciles on-chain fee payers with sponsored_fees. - sponsorUse (the ops 'sponsor' alert) and the admin page's spentWei read the same table, so they miss it. - The hourly spend alert would book the fee as gas, but 0.00081 ETH is far under its 0.01 ETH/h threshold. - The relayer-low alert sees the balance but doesn't attribute it. - The undercount simply ages out after 24 h. 7. Refund of the uncounted fee: unaffected on chain. The escrow credits it to the relayer's ETH balance according to the outcome, and it is withdrawn with withdrawEth (docs/runbook.md:368). The database books nothing, since there is no row. 8. Missing from the finding — the same impact without any race (S4): sendSponsored releases the row on any error from relayer.call, including errors after the broadcast (the 120 s receipt timeout at relayer.ts:95, RPC errors). Nobody can trigger it on purpose, but it is likelier in practice than the race. Same Low. (The attack agent listed this one as unconfirmed; this run proves it.)

Fix assessment - The suggested fix, emulated by adding AND set_aside to the keeper's DELETE: - It closes S1 and S2: 4 paid, 4 counted. - It does not close S3: the send that passes the budget still goes out, but it is now counted (5) and the excess stays at one fee. - It breaks nothing if it goes only in reserve()'s over-budget branch for o.setAside. release() is shared with sendSponsored's failure path, which must keep deleting 'new' rows. - Only answers.ts:136 passes setAside and it ignores the result; service/test/api.test.ts:703-713 never reaches that branch; V9_G-3_sponsor_db.ts would need the new statement text. - A complete fix: run each reservation's INSERT and re-check as one transaction on a pool client, after SELECT pg_advisory_xact_lock(<const>), then COMMIT or ROLLBACK, with no DELETE. - Nothing can be claimed before it is confirmed, and reservations run one at a time, so S1–S3 all close. - An answer that races the keeper's uncommitted insert waits on the primary key and falls back to being kept; the keeper sends it through the set-aside on its next tick. This is reasoned, not run. - S4 needs its own fix: record the tx hash at broadcast (a hook in Relayer.call before the receipt wait), and release only if nothing went out or the tx reverted.

Side observation (not R9-G-4; a lead for a fresh verifier)

S5: if the process dies between the reservation and the broadcast, the row is left claimed or new and never sent. Ways that can happen: - a deploy that runs past Docker's default 10 s grace period (no stop_grace_period in docker-compose.yml); - an OOM kill or a crash; - the SIGTERM path in main.ts, which doesn't wait for the answers loop's in-flight send.

The row then blocks that party permanently, because the "already paid" check in sponsorship() has no time limit. On real Postgres: - After a restart the seller's answer gets HTTP 400 "yescrow already paid your fee once in this deal…". - No answer is kept, no ops alert fires, and the keeper sends nothing. - A party with no ETH would then lose by default.

On the keeper's own path, the kept answer stays in the alerts, but an admin approval would hit the primary key and get 409 FEE_ALREADY_PAID (answers.ts:194-195; reasoned, not run). This could be High (a party forced into a loss) if the shutdown window turns out to be reachable. (It became R9-G-5, below.)

Files written - scripts/pentest/ninth/verify/V9_G-4_setaside_race_pg.ts (the proof); its full output is in the session's scratchpad (v9g4_run.txt).

R9-G-5: An unsent sponsored-fee reservation left by a stopped service blocks that party for good — Verdict: CONFIRMED, Medium

R9-G-5 was not in the attack agent's report: R9-G-4's verifier found it (its side observation S5), and this fresh verifier checked it.

Verdict

Confirmed on the real local stack. I tested both paths with the service's own service/src/main.ts, run as node src/main.ts (as the Dockerfile does), on the harness's Anvil fork with every loop running. When the process stops between reserve() and the broadcast, the sponsored_fees row stays at set_aside=false, tx_hash=NULL. That looks the same whether the row was new or a claimed set-aside.

After a restart: - Typed data: GET …/typed-data returns sponsored:false, "yescrow already paid your fee once in this deal", and no keepsAnswer. - Relay: POST /v1/relay returns 400 FEE_NOT_SPONSORED. - Keeper: it sends nothing. - Fee deadline: the keeper's feeTimeout releases the deal to the opener. On chain: 24.75 USDC was credited to each seller and nothing to the buyer, on two 25 USDC deals.

Nothing ever clears the row. Neither settleRefunds nor time does; it is still blocking 25 h later.

Severity: Medium (the reporter suggested "High if the shutdown window turns out to be reachable")

So it is not High: it needs an uncontrolled, improbable coincidence. It is not Low: when it hits, a party that did everything right loses the deal, silently on the keeper path.

Proof result (each command run from the copy's root)

  1. eval "$(scripts/test-db.sh start)" set TEST_DATABASE_URL=postgres://postgres@127.0.0.1:55433/postgres (Postgres 18.6).

  2. node --no-warnings scripts/pentest/ninth/verify/V9_G-5_stuck_reservation_pg.ts exited 0 with 24 "ok" lines and 0 FAIL. It uses real Postgres, buildApp, Answers, sponsor.ts, Relayer, answerProblems, keptList and alertsDue, with the chain stubbed. A "restart" is a new pool, relayer, keeper and app on the same database. Key lines: - Rows left after the deaths:

    • A: the answer through the API claims its set-aside, then dies before the broadcast. Row is set_aside=false tx_hash=NULL, 0 broadcasts.
    • B1: a kept answer (the deal was too young) that the keeper sends as a new reservation. Same row state, 0 broadcasts.
    • B2: a kept answer (the relayer was low) that claims its set-aside in the keeper. Same row state, 0 broadcasts.
    • C: the opener's sponsored challenge. Same row state, 0 broadcasts.
    • E: death after the broadcast. 1 broadcast, row still tx_hash=NULL.
    • For A, B1, B2 and C after the restart:
    • Typed data: sponsored=false notSponsoredBecause="yescrow already paid your fee once in this deal" keepsAnswer=absent.
    • Relay: HTTP 400 FEE_NOT_SPONSORED: yescrow already paid your fee once in this deal: send this step from your wallet with the fee (POST /v1/transactions).
    • POST /v1/transactions returns value 810000000000000 wei; the party's balance is 0.
    • Operator: answerProblems returns 2 alerts (B1 and B2, each with the reason "yescrow already paid your fee once in this deal") and none for A.
    • Approve A: 404 NO_KEPT_ANSWER.
    • Approve B1 and B2: 409 FEE_ALREADY_PAID: yescrow is already paying this answer's fee.
    • Still 1 broadcast in total (E's).
    • Party alerts 50 min before the deadline:
    • A's seller gets "50 minutes left: … pay the fee … or you lose the dispute by default".
    • B1's buyer gets none (alerts.ts:142).
    • 25 h later spentToday is 0 wei and A is still "already paid".
    • After the deals close, all five rows remain (the sweep drops only set_aside rows). E's is never booked: refunded_wei=NULL.
    • Manual fix: either UPDATE … SET set_aside = true (A then relays 200, sent) or DELETE … (B1's kept answer is then sent by the keeper) lets the answer through.
    • W: from a new reservation's INSERT to reserve() returning, over 200 runs: median 1.04 ms, p99 3.80 ms.
  3. systemd-run --user --scope -q -p MemoryMax=2500M -p MemorySwapMax=2G -- node --no-warnings scripts/pentest/ninth/verify/V9_G-5_sigterm_main_fork.ts exited 0 with 16 "ok" lines and 0 FAIL (final run; 4 runs in all). A JSON-RPC proxy holds the relayer's eth_estimateGas; Docker's SIGTERM-then-SIGKILL at 10 s is reproduced. - K, keeper path (answer kept while the relayer was low; relayer then topped up): SIGTERM -> service #1 exited by itself after 27 ms (code 0). Row payArbitrationFee set_aside=false tx_hash=NULL, the relayer's nonce unchanged, no eth_sendRawTransaction for K. Earlier runs gave 22–24 ms. - A, API path (set-aside claimed, estimate held 15 s): SIGTERM -> service #2 still running 10 s later: app.close() waits for the in-flight request. Then SIGKILL … gone; the buyer's request got ECONNRESET. Row set_aside=false tx_hash=NULL, nothing broadcast. - C, control (held 3 s): the answer's HTTP 200 after 3111 ms; service #3 exited by itself after 3132 ms (code 0). Row tx_hash=set, C is Disputed. - Window M (100 ms RPC latency, idle relayer): simulate -> eth_sendRawTransaction: 597 ms; the relayer's own calls at +ms: eth_call +0, eth_estimateGas +109, eth_getTransactionCount +269, eth_maxPriorityFeePerGas +484, eth_sendRawTransaction +597. From the broadcast to the last receipt poll took 106 ms; the whole relay request took 1466 ms. - K after the restart:

    • The deal page's own todoFor on the real GET /v1/deals shows "Your answer is in — Yescrow has been told and pays the court's fee before the deadline … Nothing more is needed from you". answerKept is set, so there is no fee button.
    • The relay returns 400; the admin approval returns 409 FEE_ALREADY_PAID; one ops alert fires with the reason "already paid".
    • A after the restart: the page shows "Pay the arbitration fee or lose by default", and the button would be the wallet's ETH route. The relay returns 400, the approval 404, and there is no alert.
    • Fee deadline (warp 400 s): "K and A: Released by feeTimeout … K's seller +24750000, A's seller +24750000, the buyer +0".
    • Postgres restart: terminated 4 connections -> service #4 exited (code 1): throw er; // Unhandled 'error' event.
    • Note: run 2 showed C lingering past 10 s. That came from the test client's keep-alive connection. nginx proxies /api/ without upstream keep-alive, so runs 3–4 send Connection: close.
  4. scripts/test-db.sh stop stopped the database. No Anvil or service process is left, and the real repository is untouched.

Corrections to the finding

Fix assessment (the observation proposed none)

Unconfirmed

Files written

Proofs re-run from the repository

After the proofs were copied into scripts/pentest/ninth/, each was run from the repository's root (dc3e837 plus the proofs), the database ones with eval "$(scripts/test-db.sh start)":

Proof Result
node scripts/pentest/ninth/G9_admin_approve_past_budget.ts PASS, exit 0
node scripts/pentest/ninth/G9_setaside_mutex_sound.ts PASS, exit 0 (its closing line, "the daily budget is a hard ceiling", is wrong: R9-G-4)
node scripts/pentest/ninth/verify/V9_G-3_g1_regression_v036.ts exit 0
node scripts/pentest/ninth/verify/V9_G-3_setaside_release_race.ts exit 0
node scripts/pentest/ninth/verify/V9_G-4_setaside_race_pg.ts First run: every schedule as in the verification, then exit 1 in the proof's own teardown: dropping its database terminated a closing connection of a pool with no 'error' listener, R9-G-5's crash route. With a listener added before pool.end(): 3 runs, exit 0, the same summary (S1, S2, S3 and S4 past the budget with an uncounted fee; the fix closes S1 and S2)
node --no-warnings scripts/pentest/ninth/verify/V9_G-5_stuck_reservation_pg.ts exit 0, "CONFIRMED on real Postgres: a reservation left by a process that died before its broadcast blocks the party for good"
systemd-run --user --scope -q -p MemoryMax=2500M -p MemorySwapMax=2G -- node --no-warnings scripts/pentest/ninth/verify/V9_G-5_sigterm_main_fork.ts exit 0, 16 checks, 0 FAIL, "CONFIRMED with the real main.ts on the fork…"

V9_G-1_admin_approve_real_stack.ts and V9_G-2_kept_answer_starved.ts (fork runs of several minutes each) ran only in the copy, in their verifications.