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:
- 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 inapi/app.ts,typed-data.ts; SPEC §14.17). - v0.35: one deal may take half the day's sponsor budget (
sponsor.ts; SPEC §4.2, §14.7, §14.17). - v0.34's service fixes (
d739a9e), never attacked: glue-record refusal and theproof_blocksflag (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). - 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.
- Medium: R9-G-2 (reported as Info). A buyer who opens a real dispute can keep a seller with no ETH from having
its answer paid for the whole 72-hour fee period, at no cost to itself and none to yescrow. One second before its
challenge it opens 12 sham disputes and pays their fees itself (refunded at
feeTimeout). The keeper handles disputes most urgent first, so each time the 24-hour budget window frees up it sets the money aside for the shams' unanswered sides before it reaches the seller's signed, kept answer. Meanwhile the seller's deal page says yescrow "pays the court's fee before the deadline… Nothing more is needed from you", the 24-hour and last-hour fee reminders are off, and the fee button is hidden even once the seller holds ETH. Unless an admin approves the answer by hand (the ops alert names it, every 6 hours), the seller loses the deal atfeeTimeout: in the proof the buyer got back 1,500 USDC after the domain had moved. - Medium: R9-G-5 (found by R9-G-4's verifier). If the service stops between booking a sponsored fee and
broadcasting it, the booking row stays unsent, and from then on the service tells that party "yescrow already paid
your fee once in this deal": its answer (or challenge) is refused, the keeper sends nothing, and an admin's approval
gets 409. A party with no ETH loses the deal at
feeTimeout; on the keeper's path its page still says "Nothing more is needed from you" and its reminders are off. On SIGTERM the service's loops stop within about 25 ms without finishing a send, so any routine restart in the ~0.6 s before a keeper send's broadcast is enough, and a Postgres restart crashes the service (its pool has no'error'listener). Nobody can cause it on purpose; a random restart hits a window about 1 in 10⁴ times at launch volume. Shown end to end with the realmain.ts, SIGTERM and SIGKILL on the fork. - Low: R9-G-1. Approving a kept answer on the admin page (
POST /v1/admin/answers/:dealId) makes the relayer pay the fee pastSPONSOR_DAILY_WEIwith no cap, and any admin wallet can do it, the everyday ones included. It also skips the minimum deal age and the relayer's 0.001 ETH reserve, and no spend alert counts it. SPEC §14.17 and the admin page say so, butdocs/keys.mdand SPEC §12 still say an admin wallet can only manage promo codes. A stolen admin wallet or session can drain the relayer (0.008 ETH at launch, about 9 approvals) into Kleros: yescrow's ETH, no party's money, and the stall is the relayer outage SPEC §12 already accepts. - Low: R9-G-4 (found by R9-G-3's verifier). When the keeper sets an answer's fee aside at the budget's edge while
another sponsored fee is being booked, and the answer is relayed in that same millisecond, the keeper's over-budget
clean-up deletes the row the relay has just claimed: the fee is paid but never counted, and the day passes
SPONSOR_DAILY_WEIby one fee (0.00081 ETH). Shown on real Postgres with the real app, keeper and relayer; the window is about 1 ms, once per dispute, so it is practically unreachable on purpose. Its verifier also proved the attack agent's unconfirmed idea that a receipt timeout after a fee went out deletes its row the same way. - Info: R9-G-3. The G-1 regression (
scripts/pentest/G_sponsor_drain.ts) has failed since v0.36 (d8df9d3): its stand-in database doesn't know the new set-aside statements. The verifier found two more off-chain regressions broken since the fourth run: R2-G-1'sverify/V2_G-1_screen_heals.ts(since v0.34'sproof_blocksquery) andverify/V4_D-1_page_offers.test.ts(a wording change in the plain-words pass). Nothing runsscripts/pentest/automatically, and runs 5 to 8 re-ran only the Foundry proofs, so nobody noticed. The product still holds G-1's properties (the verifier's re-run with every statement ofsponsor.tsmodelled).
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
- Targeted and short, on the four items above; nothing else in area G, and no other area, was attacked in this
round. The earlier runs' off-chain proofs were re-run only by R9-G-3's verifier (the list in its verification);
the Foundry proofs were not re-run (no Solidity changed since
1e08fee). - Two findings came from verifiers. R9-G-4 and R9-G-5 were found while verifying other findings, each then checked by its own fresh verifier; R9-G-5's verifier listed three more leads, which stay unconfirmed. The round stopped there.
- The fixes aren't attacked yet. With two Medium findings, step 4 of the procedure calls for the fixes, each proof as a regression test, and another targeted round on the fixed commit.
- Where the proofs ran. On a scratch
git archiveofdc3e837. After they were copied into the repository, the stub, database and SIGTERM proofs were run again from there (results in Part 2); the two fork proofs of R9-G-1 and R9-G-2 ran in the copy only. Two proofs were changed before the commit, outside what they prove:V9_G-5_sigterm_main_fork.tskeeps its scratch folder in the system temp directory instead of the repository's parent, andV9_G-4_setaside_race_pg.tsno longer crashes in its own teardown. - Not independent of the author. Launched from a session that didn't write the code under test, at the user's request; each agent had a fresh context and the procedure's prompt word for word; the attack agent used Claude Fable 5.1, the verifiers Claude Opus 5.5, all at maximum reasoning effort. No model from another vendor was run.
- A fork, not mainnet. The proofs run on an Anvil fork of Arbitrum Sepolia (Kleros's court 8 there) with mainnet's sponsor settings and fee period; the mainnet gas figures in R9-G-1 are worked out from the measured gas limit, not run. The ops emails, the admin's response time and Resend's sending limits were reasoned, not tested.
- The web dialogs were read and their logic tested in node, not driven in a browser or a wallet.
- No findings is not proof of no bugs (the area report's "Checked and found sound" lists what was tried).
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
- IDN
unicodeNamebyte under-count (carried from run 4).nameserversCaptureBytesnow adds4 × n.lengthfor anxn--host (rdap.ts:125). If yescrow's attestor escapes non-ASCII as\uXXXXrather than raw UTF-8, a short, heavily-expanding IDN's decodedunicodeNamecould cost ~6 bytes per decoded char, which for some names exceeds the4×(xn-- length)allowance — an R4-SV-1-style under-count that could let an unprovable same-registrar list through. For the common raw-UTF-8 case the allowance over-counts (safe). I could not confirm the attestor's escaping without the real attestor; no fixture exercises an IDN nameserver. Same status as run 4's listing. - Relayer/RPC flakiness drops a sponsored-fee row.
sendSponsored/the relay pathreleasethe reservation in thecatchwhenrelayer.callthrows, including a receipt-wait timeout after the tx actually mined (relayer.ts:95,answers.ts:82-85). The row is then deleted while the fee was really paid, sospentTodayunder-counts andsettleRefundsnever books the refund — a small accounting drift toward over-spend on RPC flakiness. Pre-existing (not v0.36-specific) and self-limited to an RPC edge; not reproduced.
Checked and found sound
- Sponsor budget accounting (the core v0.36 logic).
reserve()is a per-(escrow, deal_id, role)mutex, so the relay path andanswers.tick()can't pay one fee twice; a set-aside converts to exactly one sent fee (counted once, not doubled); a failed'new'send is released and frees the budget while a failed set-aside send is restored; andspentToday(set-asides + sent fees, net of refunds, approved excluded) is a hard ceiling that an ordinary sponsored answer cannot cross. Proven:node scripts/pentest/ninth/G9_setaside_mutex_sound.ts→ PASS. Stale set-asides are dropped when the deal leavesAwaitingFee(sponsor.ts:44-48); set-asides/opener fees are bounded by the budget, so saturation is bounded (the documented G-1 residual). - Kept-answer signatures.
keepAnswerverifies the stored signature is the answering party'sPayArbitrationFeeover the deal atphase:1, matching what the GET typed-data endpoint produced (actionPhaseOf= 1 inAwaitingFee) and what the contract's_authorizere-checks (YescrowEscrow.sol:949);feeBpsisn't a signed field, so verifying withfeeBps:0yields the same digest. A kept answer is bound to one deal and phase, single-use, and valid only until the fee deadline (answers.ts:49-67). The relay deadline cap is extended to the fee deadline only forpayArbitrationFee(app.ts/answers.ts:32-35); every other action stays atMAX_ACTION_DEADLINE(B-4 holds). - Signing dialogs (scope item 4). The authoritative review panel
SignReviewrenders every signed field and shows token amounts viacoin(value, symbol)with the deal's real symbol; every step builder that carries a token amount passessymbol: sym(steps.ts,AutoPayout, settlement). Themoney()→"$" change is confined to surrounding prose;coin()keeps the coin where a party must send/withdraw exactly that (PayWithWallet, the lock buttons, the signed review).SignDialogenables confirm for a no-warning step and requires the checkbox otherwise (useState(false)+setAck(!step?.warnings?.length), line 46); the ack and warnings are both visible;WithdrawTo's non-self-recipient warning is preserved. No see-vs-sign mismatch found.buy-link.test.ts,format.test.ts,appeal.test.tspass. - R4-SV-1 glue refusal (v0.34, never attacked).
checkNameserversProvablerefuses in-bailiwick names (under the domain) at any registry and same-RDAP-host names at the glue registries (Identity Digital, PIR, Nominet), before any lookup; the glue-registry set matches live behaviour (I confirmedrdap.identitydigital.servicesservesipAddressesglue for in-registry hosts —nic.rocks,nic.live,nic.finance— and the host-object layout).SV4_ns_glue_undercount.tspasses. The keeper'sproof_blocksbackstop only fires when the proof genuinely can't hash and the live nameservers already match the terms (keeper.ts:343-379), so it can't wrongly block a provable deal. - RDAP queue / rate limit (R4-SV-2).
clientKeycollapses IPv6 to /64 (tightening, no bypass found across::, full, and mapped-v4 forms);fetchRdapbounds the per-host wait queue (MAX_WAITING) and the cache (CACHE_MAX), and quotes read cache-only. No on-chain/money path callsfetchRdap. - Startup chainId/decimals check (v0.34).
loadChainrejects an RPC whoseeth_chainId/tokendecimals()differ from the record (escrow.ts), a config guard with no attacker surface. - Deposit-funded / settlement-stage typed data.
settlementStageOfnow returnsendsAtforAwaitingFee/Judging, butendsAtfeeds only the summary string, not the signedSettlemessage, so it can't change what's signed.
Not done
- No Foundry/fork run: all in-scope changes are off-chain and the two findings/observations are provable with pure-function scripts; contracts are unchanged (area F, 8 runs). The full service integration suite was not run per the round's server limits; I ran only the targeted scripts and three small unit/regression tests.
- R9-G-2's terminal loss step (operator latency →
feeTimeout) and the IDN/RPC-flakiness items in "Unconfirmed" were reasoned, not reproduced.
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).
- That wallet approved 6 kept answers. For each, the relayer sent
payArbitrationFeeand Kleros created a case. - In total the relayer paid 0.00486 ETH in fees, more than the whole 0.004 ETH daily budget.
- None of it counted:
spentTodaystayed at 0.00324 ETH throughout. Eachsponsored_feesrow hasapproved_byset to the everyday wallet.
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)
- It needs one of yescrow's own credentials: an admin wallet, or a live 12-hour
AdminSessiontoken. The token is a bearer token kept in the tab's sessionStorage; the token alone is enough. - No party's money moves. The ETH lost is the relayer's, and it goes to Kleros, not to the attacker.
- The loss is bounded. It stops at the relayer's balance plus any top-ups made before the wallet is removed. At launch the relayer gets 0.008 ETH (
docs/mainnet.md:110), about 9 approvals. Removing an everyday wallet cuts the session off at once (verified on the stack). - The resulting stall is a known risk. It is the relayer outage SPEC §12 already accepts ("it can be drained or go offline"). By my reasoning (not run), an attacker with no key can already drain a 0.008 ETH relayer in about 2 days through the 0.004 ETH/day sponsor budget. A stolen admin credential makes that minutes, with no daily limit.
- What would make it Medium: a real party missing a deadline during the stall, such as a no-ETH answerer's fee deadline or a proof before the transfer deadline. That needs the operator to ignore the relayer-low alert for that long, the same as any relayer outage.
Proof result (all commands from the copy's root)
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,spentTodayunchanged. - Limits: it runs the realreserve()/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%.eval "$(scripts/test-db.sh start)", thennode 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'sSPONSOR_DAILY_WEIof 0.004 ETH and deal share of 5000 bps. - Step 0: the everyday session showsmain=false, andPOST /v1/admin/walletsreturns 403NOT_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,spentToday0 → 0. - Step 2: two sponsored disputes filled the budget to 0.00324 of 0.004 ETH. An ordinary sponsoreddisputethen returned 400FEE_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) andspentTodaystayed 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_wei0). - 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.
scripts/test-db.sh stop→ stopped; no Anvil or Postgres left running.
Corrections to the finding
- "The only skipped check is the budget" is wrong.
approveAnswer(answers.ts:186-198) never callssponsorship(), so it also skips: - the minimum deal age (step 1);
- the relayer-low reserve of fee + 0.001 ETH (sponsor.ts:132). On Arbitrum One, approvals keep going down to about 0.0009 ETH, below mainnet's
RELAYER_LOW_WEIof 0.001 ETH (worked out from the measured gas limit, not run); - the per-deal share, which doesn't matter here since there is one answer fee per deal.
- The attacker doesn't need to fill the budget. With mainnet's 6-hour minimum age, any fresh sham deal's answer is kept (step 1). The opener's own fee comes back to it.
- The loss per sham is a full fee, not "small … or up to a full fee". The attacker controls both sides, so it always makes the relayer-paid side lose. Yescrow loses 0.00081 ETH plus gas each time; the attacker loses only gas (step 4).
- "An ordinary sponsored answer is then refused" is not what the real service does. Past the budget, an answer is kept (app.ts:1294, 1300), which is exactly what makes it approvable. What gets refused is the opening step (step 2).
- "No gasless actions for anyone" overstates the moment of the drain. Approvals stop with about 0.0009 ETH left. What stops at once is fee sponsorship and the sending of kept answers. Gas-only steps continue for a few deals on what's left. The proof's 50 approvals (0.0405 ETH) would need top-ups; launch funding allows about 9.
- Detection, which the finding doesn't cover: the operator gets no spend alert for approvals.
- The "sponsor" alert counts only rows with
approved_by IS NULL(sponsor.ts:92). - The hourly spend alert treats every sent sponsored fee as already budgeted, approved ones included (ops.ts:109-110, 275).
- The per-answer alert fires only if the 5-minute ops loop (main.ts:76) sees the answer before it's approved. The approval itself only writes a
req.log.warn(app.ts:932). - So the only email is the relayer-low alert at 0.001 ETH.
- More documents contradict the power than the finding lists:
docs/keys.md:18(the relayer row: fees "capped … per day bySPONSOR_DAILY_WEI");- SPEC.md:454 and :456 ("nobody more, and an ops alert reports a fast spend");
.env.mainnet.example:81;docs/mainnet.md:110;- the header comments of
service/src/relayer.ts:4-6andservice/src/admin.ts:1-4. - The containment steps in keys.md:22 don't stop this.
PROMO_CODES=offandpromo-cli.ts offhave no effect on approvals. Neither doesSPONSOR_DAILY_WEI=0for answers already kept. Only removing the wallet works. - A misquote: keys.md says "Worst case …: codes that waive the fee on new deals". The words "at most" come from SPEC.md:451. All of the finding's line references are correct.
Fix assessment
- Docs only: this closes the "does more than keys.md says" part, but only if it also covers every place listed above and the containment list. It caps nothing.
- A daily cap on approved spend: this closes the uncapped part, if it does four things:
- counts
approved_by IS NOT NULLrows over 24 hours; - is checked in
reserve()with the same insert, sum and release-on-overshoot steps as the main budget (sponsor.ts:159-168), so parallel approvals can't overshoot it; - keeps the fee + 0.001 ETH relayer reserve;
- adds approved fees to an ops alert.
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
scripts/pentest/ninth/verify/V9_G-1_admin_approve_real_stack.ts(the real-stack proof)- Logs (scratchpad):
v9g1-run2.log(the final run) andv9g1-run1.log. Ignore run 1: its last line wrongly claimed step 5 ran, which the script now fixes.
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.
- The finding's version (two sham disputes whose openers' fees yescrow pays) does fill the 0.004 ETH budget. It holds it for 24 hours, though, not 72. The budget only counts fees from the last 24 hours (
sponsor.ts:66-67,created_at > now() - interval '1 day'). When those four rows drop out of the window, the keeper's next tick sends the kept answer by itself. - The strongest true version needs the attacker to be the opener of the real dispute. That is the scenario SPEC §14.17 itself describes: a buyer who has received the domain and then challenges.
- One second before its challenge, it opens 12 sham disputes and pays their fees with its own ETH. It gets those fees back at
feeTimeout. - Each sham is answered by a wallet with no ETH, so the keeper wants to set that answer's fee aside.
Answers.tickhandles disputes most urgent first, by fee deadline (answers.ts:121). It sets aside fees for the unanswered shams (:135-136) before it reaches the real kept answer (:139-143).- So every time the 24-hour window frees the budget, the keeper itself refills it with the attacker's next four shams.
- The real answer is never sent. At
feeTimeoutthe honest seller, who already moved the domain, loses the whole deal. yescrow spends nothing. - Proven end to end on the real stack.
Severity: Medium, at the upper edge (reported as Info).
- Why higher than Info
- v0.36's automatic protection ("the service sends it once a fee can be set aside for it") is defeated deterministically, with no race, by the keeper's own ordering.
- It costs the attacker almost nothing: 12 sham fees of 1% each, plus gas for 13 transactions. About 0.0105 ETH is locked for 72 hours and fully refunded.
- The victim is actively misled:
- The deal page says yescrow "pays the court's fee before the deadline" and "Nothing more is needed from you".
- The fee reminders are switched off.
- The fee button is hidden even after the party gets ETH.
- If nobody acts, the seller loses the domain and the whole price (1,500 USDC in the proof).
- Why not High
- The loss still needs yescrow's own operator to miss an explicit ops alert for 72 hours.
- The alert names the deal and the deadline and says "or the seller loses by default". It is emailed at once and again every 6 hours (
ops.ts:30,:230). - The fix is one click by any admin wallet.
docs/mainnet.md:298anddocs/runbook.md:368make that an operator duty. - That is the condition under which this procedure's precedent, R2-D-1 (second run), was read as Medium ("Medium if the operator commits to answering … within the period").
- When to read it as High: if a solo operator can't guarantee a response within 72 hours.
- The attacker picks its timing, and can retry for almost nothing on every AI-court deal it buys.
- The operator's own texts say the budget may free up by itself:
answers.ts:211: "The service sends it by itself if yescrow can pay for it first".Admin.tsx:330: "Yescrow sends it by itself once the budget allows".- Under this attack that never happens.
- Unconfirmed, not tested live: ops emails use the same Resend key as testnet (
.env.mainnet.example:115). The docs list Resend's free plan at 100 emails a day (docs/decisions.md:152), and free testnet deals could use that up.
Proof result (all commands run from the copy's root)
node --check scripts/pentest/ninth/verify/V9_G-2_kept_answer_starved.tsprintedSYNTAX-OK.eval "$(scripts/test-db.sh start)" && node scripts/pentest/ninth/verify/V9_G-2_kept_answer_starved.tsfailed on my own test data:UNSUPPORTED_DOMAINfor an upper-case sham domain. I fixed the script.- The same command again (with
--no-warnings, using the database from step 2) printedPASS, exit 0. The main lines: - Setup. The harness escrow has a 300-second fee period, so the script deploys an escrow with mainnet'sFEE_PERIODof 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 thesponsored_feesandkept_answersrows 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, sovictim-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 arekept, 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, andAnswers.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 toalertsDue. - 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-425giveskept=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 gaveDisputed, Kleros case 188. So a party can protect itself only by getting about 0.0009 ETH and paying outside the page.
scripts/test-db.sh stop. No Anvil or Postgres process is left running.
Corrections to the finding
- "Hold … for the 72-hour fee period": wrong. The hold is 24 hours (
sponsor.ts:63-70), after which the service sends the kept answer itself (Part A). The "documented G-1 residual (open shams hold their budget share for 72h)" is also misstated. The first run's G-1 verifier described a rolling 24-hour budget that has to be kept drained for the whole 72 hours. - "Placed only if the budget has room when the keeper first sees it": wrong. The set-aside is retried on every tick while the answer is unsigned (
answers.ts:135-136). Once the answer is kept, every tick tries to send it (:139-147). The real gap is the order: most urgent first (:121) lets unanswered sham disputes with earlier deadlines take freed budget ahead of a signed answer. - "Any third party" is too broad. A third party gets only the 24-hour delay deterministically, or the full starvation by winning a race against the keeper at every 24-hour boundary (not proven). The deterministic 72-hour version needs the attacker to time the real dispute, so it must be the opener.
- "Payment depends entirely on an operator approving": only true in the strong version. In the finding's version the service pays by itself after 24 hours.
- Line references.
deal-state.ts:334is the condition; the quoted text is at:336-337. The card also shows the fee deadline and promises yescrow "pays … before the deadline".alerts.ts:142is correct: it suppresses the 24-hour and 1-hour reminders. - The hidden button.
DealPage.tsx:424-425hides the button whenever the answer is kept, even when the party holds ETH. Paying is still possible, but only off the page. - Cost. The shams' openers can pay with the attacker's own ETH, which comes back at
feeTimeout. The shams must have been active for 6 hours, and for more than 3 days if the attacker wants to avoid the ops "sponsor" alert. Transfer windows of up to 60 days allow both.
Fix assessment
- Rewording the kept-answer text: needed, doesn't close the hole.
- It should say that yescrow hasn't paid yet, that payment is a manual approval, and that the party loses by default if it isn't made, and it should offer a way out.
web/lib/deal-state.test.ts:176-180checks the current text and would need updating.- A near-deadline reminder for kept answers: needed, doesn't close the hole.
- The line is
alerts.ts:142. - The fee button at
DealPage.tsx:424-425should also come back near the deadline, or whenever the party's wallet holds the fee, so the reminder has something to act on. - "A per-answer reserve a later sham can't take" misses this attack.
- The starving shams come earlier, and v0.36's set-aside already protects against later ones.
- A reserve only for signed answers would be taken by the attacker's own signed sham answers. Those would then be sent and cost yescrow about one fee each.
- What would actually reduce or close it:
1. In
answers.ts:119-148, send kept (signed) answers before placing set-asides for unanswered disputes.- This defeats the free version proven here.
- A determined opener would then have to get its sham answers signed and sent, costing yescrow about its 0.004 ETH budget a day, the G-1 residual. That spend is visible to the ops alerts.
- It doesn't affect the existing v0.36 test in
api.test.ts. 2. A last resort that doesn't need a person. - Pay a kept answer past the budget automatically once its deadline is near, for example with 12 hours left, under a separate small daily cap.
- Escalate the ops alert near the deadline.
- Optionally, put answers to a buyer's
challengeafter an accepted proof ahead of others, since a sham of that shape needs a real registrar transfer. 3. Docs. - Make the SPEC §14.17 claims (lines 684-685) conditional on the operator.
- Add the limit to
docs/audit.md§5. - Drop "sends it by itself…" from
answers.ts:211andAdmin.tsx:330.
Files written
scripts/pentest/ninth/verify/V9_G-2_kept_answer_starved.ts(the proof).- Run logs (scratchpad):
V9_G-2_run1.log(the failed first run),V9_G-2_run2.log(the passing run).
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).
- It is the G-1 regression. It says so in its header. The run-1 to run-4 reports list it as G-1's regression, and the run-4 report records it exiting 0 (
reports/AI pentest, fourth run (2026-11-02).md:683). - Which commit broke it. I exported the trees read-only with
git archive. It passes at560df85, which isd8df9d3^, and prints the same figures as before. It fails atd8df9d3(2026-11-04, "Answers to disputes can't be starved"). - It is run by hand only.
scripts/check.shruns only the forge proofs undercontracts/test/pentest/(:16-25).- The Makefile only calls
check.sh. - There is no CI and there are no git hooks.
- No service test imports anything under
scripts/pentest/. - The systemd units are backup, hygiene and secret scan.
- Runs 5 to 8 never re-ran it, though they say they did. All four ran on commits after the break. Run 5's report says "Every earlier proof ran as a regression test (the full suite, 829 tests…)" (
:8), and run 7's says the same (:9-10). Those counts are the forge suite only:docs/audit.md:261gives "845 tests (485 of them pentest proofs)". So the off-chain proofs were not in those runs. - The product is sound for G-1. My exact-statement re-run below shows all four G-1 properties hold on v0.36.
Severity: Info (kept)
- No money moves, and no party loses or is delayed. The G-1 properties hold (
V9_G-3_g1_regression_v036.ts). - The script fails loudly (exit 1), not with a false pass. Nothing automatic ran it before v0.36 either.
- The real cost is coverage. Several G-1 properties are now covered by no test that runs:
- The per-deal share refusal.
api.test.ts:678even sets the share to 10,000 bps. reserve()'s re-check after insert and its release (sponsor.ts:165-166).- The end-to-end case where refunded sham fees free the budget for an honest party.
sponsorUse()'s real SQL.ops.test.tsstubs its result, and so did G-1's own script.- What does still run:
relayer-caps.test.ts:226-240tests only the pure functionsrefundOfanddealCap.appeals.test.tshas no sponsorship coverage at all.api.test.ts(harness) covers the 6-hour age gate on one deal (:627-631), refund booking after a real case (:667-670), and v0.36's set-aside, kept answers andapproved_by(:674-764).- The forge proofs
G_SponsoredFeeDrain.t.solandV_G-1_OneSponsoredSide.t.solshow only the on-chain ETH loss.
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:
- Pass (exit 0):
E_foreign_forward_keeper.tssecond/D_appeal_budget.ts,second/E_forward_griefing_keeper.ts,second/G_appeal_screen_gap.tsthird/EG3_inbound_completion_unscreened.ts,third/EG3_payout_lzfee_unbudgeted.tsverify/V2_G-2_unbudgeted_cctp.ts,verify/V3_EG-1_payout_paths.ts,verify/V3_EG-2_inbound_screen.tsfourth/SV4_ns_glue_undercount.ts,fourth/SV4_rdap_probe.tsfourth/D4_llms_text.test.ts,verify/V4_D-23_texts.test.tsverify/V4_SV-1_glue_bands.ts,verify/V4_SV-2_rdap_backlog.tsverify/V3_F-2_service_routing.ts deployments/arbitrum-sepolia.json(prints only; it has no assertions)- Broken for the same reason:
verify/V2_G-1_screen_heals.ts, R2-G-1's regression. Exit 1 at:46, withactual: ['sanctions', 'proof-blocked:undefined']. - v0.34 (
d739a9e, 2026-11-02) added aproof_blocksquery toOps.problems()(ops.ts:195-202). - The script's stub answers any unknown query with a row (
:38), so a fake problem appears. - Its parts B and C never run. It passes at
d739a9e^and fails atd739a9e. - Broken for another reason:
verify/V4_D-1_page_offers.test.ts. 4 subtests pass and 1 fails (:200). It still expects "on Kleros's case page", but the plain-words pass in819d1d5changed the text to "the court's case page" (web/components/deal/steps.ts:313). The property it tests still holds; only the expected wording is stale. - Not run (they need the harness), judged from reading:
verify/V2_E-1_real_tx_costs.tsneeds Anvil, andrelayer.tsandbudgets.tshaven't changed since1e08fee.verify/V3_D-I2_stranded_v032.tsuses a real migrated Postgres with no SQL stub, andKeeper's new constructor argument is optional.- Neither is exposed to this cause.
Corrections to the finding
- Where "re-run clean as recently as run 4" comes from. It is in the run-4 report (
:683), notdocs/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. - "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.
- 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 norowCount, so the realsponsorship()skips its "already paid once" check (:121). - The set-aside sweep insettleRefunds(sponsor.ts:45-48) is answered by the release branch (:96) with no parameters, so it does nothing. - The two cited proofs don't back "the product behaviour is sound" for G-1.
-
G9_setaside_mutex_sound.tsnever callssponsorship()orsponsorUse(). It sets the share to 10,000 bps, which disables the per-deal cap. It answerssettleRefundswith empty results. - It models its 7 statements faithfully at statement level: the primary key from migration 008, the claim'sset_aside AND tx_hash IS NULL, and theapproved_by IS NULLsums from 018. Each statement is atomic and actors interleave only between statements, as autocommit statements on apg.Pooldo. 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 callsrelease(…, '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-budgetrelease(…, 'new')(sponsor.ts:166) then deletes the claimed row, because itsDELETE(:176) has noset_asidecondition. The sent fee goes uncounted and the day passesSPONSOR_DAILY_WEIby 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
- The suggested fix is necessary but not enough. Adding the
set_aside/approved_bycolumns and the claim and restoreUPDATEs would make the script pass. It would still answer the two statements in correction 3 with the wrong branches; my routing audit counts 59 and 112 such answers in one run. - G9's stub is not a drop-in. It has no deals or events, which steps 3–4 need, and no branch for
SELECT set_aside,SELECT block_timeorsponsorUse. - Better: match statements whole against
sponsor.ts.V9_G-3_sponsor_db.tsdoes this and refuses to load if that file's SQL changes. It also models deals and events. A fixed regression should add v0.36's once-per-party and set-aside-sweep checks. - The root cause is the process. Nothing runs
scripts/pentest/. Add the light stub-based scripts toscripts/check.sh; they need seconds and no database. That would also have caughtV2_G-1_screen_heals.ts, which needs its stub to route theproof_blocksquery and to throw on unknown SQL, andV4_D-1_page_offers.test.ts, which needs its regex updated. - Optional: a Postgres service test for the per-deal share refusal and
reserve()'s re-check, since nothing that runs covers them now.
Files written
- Proofs in
scripts/pentest/ninth/verify/:V9_G-3_sponsor_db.ts,V9_G-3_g1_regression_v036.ts,V9_G-3_setaside_release_race.ts. - Scratch, not proofs: run logs and exported trees of
d8df9d3^,d8df9d3,d739a9e^andd739a9ein the session's scratchpad. - Nothing written under the real repository; its git history was only read.
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)
scripts/test-db.sh startprintedexport TEST_DATABASE_URL=postgres://postgres@127.0.0.1:55433/postgres, which I exported.node scripts/pentest/ninth/verify/V9_G-4_setaside_race_pg.tsexited 0 in 3.9 s, with all assertions passing. - Each API caller gets its ownbuildApp()instance sharing the pool, the chain and one relayer queue, because AsyncLocalStorage didn't surviveapp.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")
- The impact when it happens is High-class. An honest party with no ETH loses its deal by default, which breaks SPEC §14.17's v0.36 promise that the budget "can't leave a real party's answer unpaid".
- On the keeper path nobody acts. The deal page tells the party "Your answer is in … Nothing more is needed from you" and shows no fee button. Fee reminders stop. The ops alert gives the reason "yescrow already paid your fee once in this deal", and the admin's approval answers 409 "yescrow is already paying this answer's fee".
- The trigger is reachable, but only by coincidence. No party can open the window, widen it or cause the restart, and I found no request or loop that crashes the process.
- It needs a restart or crash inside one send's pre-broadcast window. I measured that at about 0.6 s: 5–6 relayer round trips at a 100 ms RPC with an idle relayer, plus about 1 ms of database work. It is longer only behind a busy queue or a slow RPC.
- Keeper path: any SIGTERM in that window is enough.
- API path: it takes a request still running 10 s after SIGTERM, a SIGKILL, an OOM kill or a crash.
- At mainnet's budget (about 5 sponsored fees a day), a random restart lands in a window with odds of roughly 1 in 10⁴.
- The odds rise with sponsored volume and during RPC trouble. Calls then hang up to about 40 s each, and a restart is the natural remedy (changing
RPC_URLneeds one). - The API-path party still has a way out: it can pay 0.00081 ETH from its own wallet, and the page offers that.
- The opener's side is within an accepted limit. SPEC §14.17 already has an opener with no sponsorship pay its own fee.
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)
-
eval "$(scripts/test-db.sh start)"setTEST_DATABASE_URL=postgres://postgres@127.0.0.1:55433/postgres(Postgres 18.6). -
node --no-warnings scripts/pentest/ninth/verify/V9_G-5_stuck_reservation_pg.tsexited 0 with 24 "ok" lines and 0 FAIL. It uses real Postgres,buildApp,Answers,sponsor.ts,Relayer,answerProblems,keptListandalertsDue, 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/transactionsreturnsvalue 810000000000000 wei; the party's balance is 0.- Operator:
answerProblemsreturns 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
spentTodayis 0 wei and A is still "already paid". - After the deals close, all five rows remain (the sweep drops only
set_asiderows). E's is never booked:refunded_wei=NULL. - Manual fix: either
UPDATE … SET set_aside = true(A then relays 200, sent) orDELETE …(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.
- A: the answer through the API claims its set-aside, then dies before the broadcast. Row is
-
systemd-run --user --scope -q -p MemoryMax=2500M -p MemorySwapMax=2G -- node --no-warnings scripts/pentest/ninth/verify/V9_G-5_sigterm_main_fork.tsexited 0 with 16 "ok" lines and 0 FAIL (final run; 4 runs in all). A JSON-RPC proxy holds the relayer'seth_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). RowpayArbitrationFee set_aside=false tx_hash=NULL, the relayer's nonce unchanged, noeth_sendRawTransactionfor 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. ThenSIGKILL … gone; the buyer's request gotECONNRESET. Rowset_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). Rowtx_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
todoForon the realGET /v1/dealsshows "Your answer is in — Yescrow has been told and pays the court's fee before the deadline … Nothing more is needed from you".answerKeptis 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 sendConnection: close.
- The deal page's own
-
scripts/test-db.sh stopstopped the database. No Anvil or service process is left, and the real repository is untouched.
Corrections to the finding
- "A deploy past Docker's 10 s grace": this is true only for the API path.
app.close()(main.ts:106) waits for in-flight requests: C was sent and exited cleanly 3.1 s after SIGTERM. - For the loops there is no grace at all. The handler (
main.ts:103-110) only sets each loop's stop flag (main.ts:33-50), so the process exits about 25 ms after SIGTERM and abandons any loop's send. - So for kept answers, any routine restart is enough:
yescrow-ctl restart|up(ops/yescrow-ctl:492-512) passes no timeout, and the agent may restart mainnet services. - This applies to every loop, not only the answers loop.
- Crash: now a concrete route.
createDb(service/src/db.ts:6-8) adds no pool'error'listener, so any dropped idle connection kills the process (exit 1). That includes a Postgres restart, recreate or failover. I found no request that crashes it, and did not test OOM (the service has nomem_limit). - Keeper path: verified by running, not just reasoned. Both a new reservation (B1, K) and a claimed set-aside (B2) get 409 at
answers.ts:194-195. It is worse than reported: - the misleading page and missing button (
DealPage.tsx:424-425,deal-state.ts:331-340), - no reminders (
alerts.ts:142), - an alert reason of "already paid",
- and a 409 text that says the fee "is already paying".
- The window: it runs from
reserve()to the broadcast, not to the receipt. - A death after the broadcast (E) is harmless to the party. It only leaves
tx_hash=NULL, so the refund is never booked (sponsor.ts:49-58) and the fee counts in full for 24 h. - Bounds: about 6 sequential relayer RPCs plus the queue. The queue is one send at a time, with the keeper lane first and unbounded, up to
RELAY_QUEUE_MAX20 API items, each held through its receipt (relayer.ts:59-67, 92-96, 120 s timeout). Each RPC can take up to about 40 s on a hung endpoint (viem: 10 s timeout, 3 retries). - Openers: C is refused the same way. If the opener can't pay its own fee, the default is a release at the end of the challenge window (Proven) or a refund at the transfer deadline (Active).
- Line references: the check is at
sponsor.ts:119-121, the insert atsponsor.ts:159-162, and the sweep atsponsor.ts:45-48(it drops only rows still set aside). The ops alert is added atops.ts:128.
Fix assessment (the observation proposed none)
- Root fix: stop treating an unsent row as a paid fee.
- A row with
tx_hash IS NULL AND NOT set_asidethat is older than any send can take (say 10 minutes) should be checked on chain. For an answer: the deal is still AwaitingFee andotherFeePayeris zero. For an opener: no dispute has opened. - If so, take the row over atomically (
UPDATE … WHERE tx_hash IS NULL AND created_at < now() - interval '10 minutes' RETURNING) and send. - Do this in
sponsorship(),reserve()andapproveAnswer().appeals.ts:192-194already does this for a stake leftsendingby a stopped service. - Narrow the gap (none of these closes it alone):
- Record the hash when
sendTransactionreturns, not after the receipt. - On SIGTERM, stop new sends and await each loop's in-flight iteration and the relayer queue.
- Set
stop_grace_periodto about 150 s for the service. - Add
pool.on('error', …)indb.ts. - Visibility:
- Add an ops alert for an unsent row older than a few minutes on an open deal.
- Add a runbook line with the SQL fix (proof 1 shows both forms work).
- Turn proof 1's cases into a service test.
Unconfirmed
- Whether an outsider can grow the service's memory to an OOM kill (no
mem_limit; for example, slow large uploads): not checked. - Whether the keeper's transfer and payout sends, abandoned the same way on SIGTERM, are reconciled after a restart (
appeals.tsis): not checked. - Whether mainnet's
RPC_URLis a fallback list, which would shorten hung calls: not checked (it lives in the vault).
Files written
scripts/pentest/ninth/verify/V9_G-5_stuck_reservation_pg.tsscripts/pentest/ninth/verify/V9_G-5_sigterm_main_fork.ts- Run logs in the session's scratchpad (final runs
g5_pg_run3.txt,g5_fork_run4.txt), and each fork run's temporarydeployment.json.
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.