Published copy of reports/AI pentest (2026-10-29).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 (2026-10-29)
An attack test of yescrow by AI agents, run as docs/ai-pentest.md describes, on the code that is to go to mainnet. It
complements an independent human audit and doesn't replace one (see "Limits" at the end of Part 1).
| Commit under test | 3ca0628 (main, 2026-10-29): escrow v0.30 (52f98ee), its service and its docs. Since then main changed only the web app (d2fab6d) and docs/ai-pentest.md (9d7f12c); contracts, service, prover and attestor are the same |
| Attack agents | 7, one per area A–G, each with a fresh context: Claude Fable 5.1 (claude-fable-5-1), maximum reasoning effort |
| Verifiers | 21, one per finding (one for area F's list of Info notes), each with a fresh context: Claude Opus 5.5 (claude-opus-5-5), maximum reasoning effort |
| Orchestration | A Claude Code session (Opus 5.5) that took no part in writing the code. It ran no attacks itself, and checked only D-4 (one line in the docs) and that no agent changed the sources |
| Date | 2026-10-29 |
| Forks | Arbitrum One at block 519,194,000 (517,026,000 for one USDT0 test), Base at 52,978,000, Ethereum at 26,254,000; public RPC reads only |
| Proofs | 18 attack test files and 16 verifier test files in contracts/test/pentest/, 4 scripts in scripts/pentest/; all pass together (Part 3, D) |
Part 1 is the summary written for this report. Part 2 is each area's report as its attack agent wrote it, with the verifier's verdict after every finding. Part 3 has the prompts word for word, the proof-of-concept files and the deviations from the procedure.
Part 1. Summary
Result
No Critical finding: no agent found a way to steal a party's money, release a deal on a false proof, pay a deal twice or change a final state. The proofs held: no proof passed for a fact the registry didn't state, and no signature worked for another deal, escrow, chain, phase, amount or a second time.
One High, four Medium and seven Low findings survived verification:
- High: D-1. A party that lost the Kleros vote can fund an appeal alone, and Kleros then rules for it. The flip costs it only gas, because Kleros refunds a one-sided appeal stake. Nothing in yescrow tells the winner or helps it counter-fund.
- Medium: B-1, B-2, E-1, G-1. Three ways to make yescrow's relayer spend its ETH for nothing (B-1, B-2, G-1). B-2 can also stall the keeper behind a queue of useless transactions. In E-1 anyone can forward a buyer's deposit from Base or Ethereum in a way yescrow never completes.
- Low: B-3, B-4, C-1, C-2, E-2, F-1, G-2.
Five findings were downgraded by their verifier (one to Low, four to Info) and one Info note was dropped because it came from this run's own setup (next sections); per the rule below, no finding was dropped for argument alone.
All findings
"Attacker" is the severity the attack agent claimed; "Verifier" is the verdict of the fresh agent that reran the proof and tried to refute it. "Final" is the verifier's severity. The rule (the user's, 2026-10-29): a finding whose proof of concept runs and shows the claimed impact can't be dropped by the verifier's argument alone; the verifier may change its severity with a stated reason, and every such change is set side by side in the next section.
| ID | Finding | Attacker | Verifier | Final | Where the fix goes |
|---|---|---|---|---|---|
| D-1 | The loser of a Kleros vote flips the ruling by funding an appeal alone; yescrow neither tells the winner nor helps it counter | High | Confirmed | High | Product now (alerts, the relayer funds the winner's side); contract check in applyRuling (a design decision) |
| B-1 | A relayed USDT withdrawTo of one base unit costs the relayer the whole LayerZero fee |
Medium | Confirmed; larger with gas (about 0.83 ETH a day per IP) | Medium | Service |
| B-2 | Relayed zero-value USDC authorizations on Ethereum burn the relayer's gas and back up the keeper's queue | Medium | Confirmed; ETH capped by the queue, keeper stall added | Medium | Service |
| E-1 | Anyone can forward a deposit off Base or Ethereum; yescrow only completes burns it made itself |
Medium | Confirmed; two impact claims narrowed, one test proves the wrong thing | Medium | Service |
| G-1 | One actor playing both sides drains the sponsored arbitration-fee budget | Medium | Confirmed; a cheaper variant, and the budget can be held at zero for free | Medium | Service |
| C-1 | A 17th nameserver makes every InterRegistrar proof revert | Medium | Downgraded | Low | Verifier (one line) |
| B-3 | A settlement offer becomes a free option for the other side once Kleros's votes are visible | Low | Confirmed; mainly the AI court | Low | Contract or web warning |
| B-4 | Confirm and Cancel signatures aren't bound to the phase they were signed in |
Low | Confirmed; one suggested fix wouldn't work | Low | Contract; API deadline cap |
| C-2 | The two attestations must agree on nameservers and "last changed" even for InterRegistrar deals | Low | Confirmed; the fix must also cover status flags | Low | Dual verifier, SPEC, prover |
| E-2 | A stranger's forward locks for good a mistaken mainnet deposit to a testnet deal address |
Low | Confirmed | Low | Docs and testnet UI |
| F-1 | CheckDeployment doesn't read back the promo key, promo end, juror template or the full verifier list |
Low | Confirmed; compare against values pinned elsewhere | Low | Script |
| G-2 | The keeper proposes and accepts deposit-funded deals for sanctioned parties | Low | Confirmed by reading (no proof of concept) | Low | Service |
| A-1 | A promo voucher is refused while yescrow's judge has the deal | Low | Downgraded | Info | SPEC wording |
| A-2 | Anyone can push a party's balance to its Arbitrum One address ahead of its withdrawTo |
Low | Downgraded | Info | Docs |
| F-2 | The runbook's router verification command is refused as ambiguous | Low | Downgraded | Info | Runbook |
| E-3 | Tokens sent to the router's own address can't be recovered | Low / Info | Downgraded | Info | Docs |
| D-2 | yescrow earns more when its judge lets the 14 days pass than when it rules | Info | Confirmed; holds at every price on stalled cases | Info | Contract (judgeTimeout) and SPEC §8 |
| D-3 | A Kleros fee rise between opening and answering comes out of the winner's refund | Info | Confirmed; in docs/security.md, not in SPEC or docs/audit.md §5 |
Info | Docs |
| B-5 | A party whose own code approves every signature (EIP-1271, EIP-7702) can be acted for by anyone | Info | Confirmed | Info | Docs |
| G-3 | The settlement summary shows the deployment fee, not the deal's promo fee | Info | Confirmed by reading; confirm has the same bug |
Info | Service |
| D-4 | docs/keys.md still lists rule, which v0.30 removed |
Info | Checked by the orchestrating session | Info | Docs |
| F I-1–I-6 | Deployment notes (size figure, REQUIRE_KLEROS default, comment drift, fork-test fallback, gas stipend, a cache entry) |
Info | I-1, I-3, I-4, I-5 confirmed; I-2 corrected; I-6 dropped (it came from this run's own setup) | Info | Docs and scripts |
Contract changes are still possible: the v0.30 escrow and verifiers aren't deployed on mainnet yet. The findings that touch them are D-1 (optional check), D-2, B-3, B-4, C-1 and C-2.
Findings a verifier challenged or downgraded
Side by side, so readers can judge. Each full text is in Part 2.
| ID | The attack agent's claim | The verifier's reason |
|---|---|---|
| C-1 (Medium → Low) | 17 nameservers block every "after" proof of an InterRegistrar deal, forcing the seller into a dispute; High if a registry accepts 17 or more | Every registry of the 25 TLDs caps a domain at 13 nameservers (Google Registry's code, registrars' TLD documentation), below the verifier's 16: a latent defect until a registry raises its cap. Even if reachable, the seller who delivered can dispute and win, so it would be Medium griefing, not High |
| A-1 (Low → Info) | SPEC §14.12 says a voucher works until the deal closes; the contract refuses it in Judging, so the seller pays the fee the code waived |
v0.30 refuses vouchers in Judging on purpose (test_judging_noFeeVoucher, docs/security.md), and SPEC §8 charges the fee on those exits; the API and deal page already refuse the code there. What is wrong is one stale sentence in §14.12 |
| A-2 (Low → Info) | withdraw(account) lets a stranger choose where a party's balance lands, which loses money for a party whose Arbitrum One address isn't its own or that Tether has blocked |
A party that can't control its Arbitrum One address can't use withdrawTo either, so the push changes nothing; pushing into a blocked wallet is Tether's freeze working as intended. What remains is that anyone can override a party's chosen chain: undocumented, no loss |
| F-2 (Low → Info) | Without --compilation-profile default the router stays unverified on every chain |
The command fails loudly and can be rerun any day; Sourcify already matches the identical router on two testnets; the CREATE2 address proves identical code without verification |
| E-3 (Low / Info → Info) | Tokens sent to the router are stuck | The same accepted limit as donations to the escrow (docs/audit.md §5, SPEC §2 rule 1); no attacker, nobody gains, only the documentation misses the router |
| F I-6 (Info → dropped) | A stale cache entry makes forge log an error on every run | The entry came from a probe test this run's setup created and deleted; it isn't in the repository, and forge removes it on its first run without an error |
| E-1 (kept, Medium) | The buyer's way back on Base is removed, and with Circle paused the loss is permanent | The keeper's own forward empties the box within 30 s anyway, and the keeper also forwards into a paused route; test_3 passes for the wrong reason (its reverts happen without a pause), though a separate check shows the pause claim is true |
| B-2 (kept, Medium) | More IPs burn more ETH | The relayer sends one transaction at a time per chain, which caps the burn at about 0.59 ETH a day at 1 gwei; but the same backlog stalls the keeper on every chain, which the attacker didn't claim |
| B-3 (kept, Low) | The offeree waits about a day for the AI court's votes | The proof compresses time: at its 5,000 USDC price the deal goes to the General Court, where a 24-hour offer expires 54 hours before any vote is public; the exposure is real in the AI court (deals up to 2,000) |
| D-1 (kept, High) | yescrow doesn't let the winner respond | The deal page links to the Kleros case, where anyone may counter-fund; High holds because nothing tells the winner, sponsored parties hold almost no ETH, and a second appeal would cost the winner about 0.54 ETH |
| G-1 (kept, Medium) | The escalation: an honest seller with no ETH loses by fee default | Not shown by the proof; needs the budget held at zero for the whole 72-hour fee period and a victim who can't find about $2 of ETH. The loss to yescrow stands |
What the verifiers added
Several verifiers found more than the attacker claimed, or showed a suggested fix wouldn't work. In short:
- D-1: paying on the jurors'
winningChoicewould let a first-round winner block every appeal; any contract check must compare the ruling with the jurors' choice, not testoverriddenalone. For the deployed version, the strongest fix is for the relayer to fund the winner's side when the appeal period opens (0.00378 ETH in the AI court, returned whole if nobody opposes). - D-2: on a stalled case, or a refused one where yescrow paid the loser's fee, the timeout pays yescrow more at every
price; the fix is one line in
judgeTimeout. - B-4: binding
provenAtdoesn't help (a dispute leaves it unchanged); bind the dispute phase, asSettledoes. - C-2: the suggested fix still compares
statusFlags, so a buyer could toggle the transfer lock between the two sessions; a fuller fix is sketched and tested. - E-1: completions should be keyed by CCTP message, not by transaction hash, or a batched forward leaves money behind.
- G-1 and B-1: per-account or per-hour limits are easy to get around with more accounts; use global budgets reserved before sending, counted net of refunds.
- G-2: the keeper's CCTP completions also mint to recipients nobody screened.
- G-3: the
confirmsummary has the same fee bug. - F-1: rebuilding the expected values from the same
.env.mainnetwould miss a wrong key in that file; compare with values pinned elsewhere (docs/keys.md, a committed template hash, the creation transaction).
Limits
- Not independent. The code was written with Claude Opus. The attack agents used Fable 5.1, a different model, and the verifiers Opus 5.5, which may share the author's blind spots; that is why a verifier could not drop a finding by argument. No model from another vendor was run (step 5 of the procedure).
- No findings is not proof of no bugs. Each area's "checked and found sound" list (Part 2) shows what was tried; nobody is accountable for what wasn't.
- Some proofs are partial. B-1 and B-2 prove the on-chain half on a fork and the service half by reading the code
(a local stack would have needed the live Docker Postgres). G-2 and G-3 have no proof of concept. E-1's
test_3passes for the wrong reason (see its verification). - New ground is where it is weakest. The proof parsing and the judge logic are new; the human review should spend its time there and on D-1.
- Not fixed yet. Per the procedure, fixes, regression tests and a second run on the fixed commit (step 4) come after the user has read this report.
Part 2. The area reports, with each verification
Each area report is the attack agent's own text (Fable 5.1), unchanged except that paths point into the repository and forge replaces the session's memory-capped wrapper. After each finding, the fresh verifier's verdict (Opus 5.5) follows as a quote.
Pentest report, area A: state machine and money
Commit under test: 3ca0628 (main, 2026-10-29). Working copy: a clean copy of the commit. Scope: every SPEC §5 transition and deadline, balances and withdraw/withdrawTo, the fee, promo vouchers, the referrer share and fee payers, rounding, USDC/USDT0 quirks, donations, re-entrancy through token and ETH transfers, the 63/64 gas rule.
Proofs: contracts/test/pentest/A_StateMoney.t.sol (21 tests, mocks), A_ArbitrumOneFork.t.sol (5 tests on a fork of Arbitrum One at block 519,194,000: real USDC, real CCTP V2, real Kleros core and public resolver) and A_Usdt0Fork.t.sol (2 tests on a fork at block 517,026,000: real USDT0). All 28 pass. Run from contracts:
forge test --match-path test/pentest/A_StateMoney.t.sol -vv
forge test --match-path test/pentest/A_ArbitrumOneFork.t.sol -vv
forge test --match-path test/pentest/A_Usdt0Fork.t.sol -vv
Bottom line. No Critical, High or Medium finding in this area. Every transition, deadline boundary, payout path and fee refund I could construct credits exactly what was paid in, to the parties SPEC §8 names; no signature, gas amount or re-entrant call changes where money goes. Two Low findings: a promo voucher is refused once a deal is in Judging (the spec says a voucher works until the deal closes), and withdraw(account) being open to anyone lets a third party decide where a party's balance lands (on Arbitrum One, into its own address) ahead of the party's withdrawTo, which with Tether's blocklist semantics means a stranger can push a blocked party's movable balance into its frozen wallet.
Findings
A-1. A fee voucher is refused while a deal is in Judging, so a seller whose promo code lands late pays the fee the code waived
- Severity: Low (a seller loses yescrow's fee it was promised; needs a late voucher).
- Attacker and preconditions: nobody has to attack. A deal with a promo code whose voucher isn't on-chain yet when the deal reaches
Judging(Kleros refused the case, or no final ruling in 90 days). The keeper normally submits the voucher seconds after the proposal, so this needs the keeper to have been down or the code entered on the deal page late (SPEC §14.12 allows a code "while the deal is open"). - Steps: buyer challenges, seller answers, Kleros refuses →
Judging.applyFeeVoucher(dealId, 0, expiry, sig)reverts withWrongState(Judging= 10 >Disputed). ThenjudgeTimeout(or a settlement inJudging) charges the deal's full fee: on a 5,000 USDC deal split 50/50 the seller gets 2,475 USDC instead of 2,500; yescrow and the referrer keep 25 USDC. The same voucher one state earlier (AwaitingFee) is honoured and the seller keeps the whole half. - Impact: the seller pays
FonjudgeTimeoutand on a settlement inJudgingalthough the code waived it. Only the judge's own ruling (judgeRuling) waives the fee. The service agrees with the contract rather than the spec: the keeper'sapplyPromostreatsstate > 5as closed and gives the code's use back (service/src/keeper.ts:142). - PoC:
forge test --match-path test/pentest/A_StateMoney.t.sol --match-test test_finding_voucherRefusedInJudging -vv - Root cause:
contracts/src/YescrowEscrow.sol:733if (d.state == State.None || d.state > State.Disputed) revert WrongState();predatesJudgingbeing appended to the enum as value 10 (v0.30);judgeTimeout(line 645) andsettle(line 590) inJudgingstill charge_feeBpsOf(d).test/Judge.t.sol::test_judging_noFeeVoucherpins the current behaviour as intended, but SPEC §14.12 ("works only while the deal is open, from its proposal until it closes") and §7.5 ("a deal yescrow judges pays no fee") say otherwise. - Suggested fix: accept vouchers in
Judging(if (d.state == State.None || (d.state > State.Disputed && d.state != State.Judging))) and let the keeper keep a pending voucher alive throughJudging; or, if the current behaviour is wanted, say in SPEC §14.12 that a code can't be applied once the judge has the deal, and havejudgeTimeoutwaive the fee asjudgeRulingdoes.
Verification of A-1:
Verdict: DOWNGRADED to Info
Proof of concept:
forge test --match-path test/pentest/A_StateMoney.t.sol --match-test test_finding_voucherRefusedInJudging -vvpassed, on cached artifacts and after a fresh compile. - InJudgingthe voucher reverts withWrongState. - AfterjudgeTimeoutthe seller has 2,475 USDC; yescrow plus the referrer have 25. - The same voucher applied inAwaitingFeeleaves the seller its whole 2,500.My extra tests,
contracts/test/pentest/verify/V_A-1_judgingVoucher.t.sol(4/4 pass): - A buyer's concession inJudgingwithout the voucher pays the seller 4,950 and yescrow plus the referrer 50. -judgeRulingcharges no fee, voucher or not. - A voucher applied during a stall's 90 days inDisputedstill applies inJudging.Sources unchanged: yes. Only
contracts/test/pentest/,scripts/pentest/,REPORT-A.mdand build output differ frombase.What I checked: - The test: parties act as themselves and yescrow's promo key signs the voucher, as in normal use. The refusal is the mock's allow-list revert. The revert comes from the state check. The amounts match
_close. - The cited lines (733, 645, 590,keeper.ts:142) are correct. - Docs and code: SPEC §7.5, §8 and §14.12,docs/security.md, audit §5 (not listed),Judge.t.sol, and the promo endpoint, deal view, keeper and relayer.Reason: 1. Deliberate, not a leftover. v0.30 added
test_judging_noFeeVoucher("A promo code can't change a deal while the judge decides it").docs/security.mdgives the voucher window as "Proposed to Disputed" and lists "no fee voucher while judging" and "the 14-day timeout (usual fee on the seller's half…)" as tested. 2. The spec charges the fee on these exits. SPEC §8 lists "Refused case, judge's 14 days pass: P/2 − F + A" and "conceded or settled: S − F + A", and a stalled timeout pays as ruling 0, with F. §7.5 waives the fee only when the judge rules. The seller gets exactly §8's amounts. 3. The deal page breaks no promise. PastDisputed, the API refuses a code (DEAL_WRONG_STATE) and the page hides the field (canApplyneeds state ≤ 5). A submission that loses a race reverts,relayer.callthrows on the receipt, and the code's use is given back with an error. 4. Only one case is silent: a draft's code whose voucher yescrow's keeper or relayer failed to submit from the proposal until Kleros refused the case, then a close other thanjudgeRuling. The same outage already loses the voucher on every other closing path by design (keeper.ts:126: "a deal that closed first gives the use back");Judgingonly moves the cutoff earlier. The fee goes to yescrow, which offered the waiver, and the referrer.What is real is one stale sentence: SPEC §14.12's "from its proposal until it closes" dates from v0.14, when
Disputedwas the last open state. That is a documentation inconsistency, so Info.Corrections: - Root cause: v0.30 kept line 733 on purpose. The defect is §14.12's wording, plus the v0.30 status line's "a deal yescrow judges pays no fee", which §7.5 and §8 narrow. - Impact: up to F on the whole price (50 USDC on 5,000) through a buyer's concession, not only 25. - Fix, option 1: reword §14.12 ("…until it closes or goes to yescrow's judge"), or accept vouchers in
Judging. Accepting them means also changing that test,docs/security.md, and the API, keeper and deal view (all usestate > 5). - Fix, option 2 (fee-freejudgeTimeout): contradicts §8 and doesn't fix this finding. It is a separate design question with some merit: on a stalled case yescrow earns F on the seller's half (25 USDC on 5,000) if its judge lets the 14 days pass, and nothing if it rules (verified).
A-2. withdraw(account) is open to anyone, so a third party chooses where a party's balance lands, ahead of the party's own withdrawTo
- Severity: Low (griefing; a loss only for a party whose address on Arbitrum One isn't its own, or which Tether has blocked).
- Attacker and preconditions: anyone (the counterparty, a bot, or the relayer through
POST /v1/relaywithaction: "withdraw", which relayswithdraw(account)for any account with no signature,service/src/api/app.ts:1058). The victim has a balance it meant to send elsewhere withwithdrawTo. - Steps:
1. A deal is released; the seller is owed 4,950 USDC and intends
withdrawTo(Base)(or signed an automatic payout to Base). A stranger callswithdraw(seller)first: the whole balance is transferred to the seller's address on Arbitrum One; the seller'swithdrawTothen fails withNothingToWithdraw. For an EOA that is only an inconvenience. For a party whose address is a smart-contract wallet it controls on Base but not on Arbitrum One (a Base-native account-abstraction wallet, or a Safe that can't be reproduced at the same address), the tokens are now at an address nobody controls. 2. USDT0: Tether's blocklist stops transfers from a blocked address only (proven on the real token). A seller Tether blocks after being paid could still move its 1,485 USDT escrow balance to another wallet withwithdrawTo; a stranger callswithdraw(seller)and the tokens land in the blocked wallet, where they are frozen (transferreverts) and can be destroyed by Tether. (Circle's USDC checkstoas well, so therewithdrawto a blacklisted party simply reverts and the balance stays movable; see the sound list.) - Impact: a party loses the choice SPEC §4.1 gives it ("Only the account chooses where") for the whole balance, and in the two cases above loses the money. The spec's own warning ("A smart-contract wallet is offered Arbitrum One only") limits case 1 to parties who ignore the interface; whether case 2 is a loss or the intended compliance outcome is the operator's call, but today it is decided by whoever sends the transaction first.
- PoC:
forge test --match-path test/pentest/A_StateMoney.t.sol --match-test test_finding_anyoneCanForceAPayout -vvandforge test --match-path test/pentest/A_Usdt0Fork.t.sol --match-test test_fork_usdt0_finding -vv - Root cause:
contracts/src/YescrowEscrow.sol:674-679(withdrawhas no caller check;withdrawEth, line 718, is the same for ETH). SPEC §2 rule 3 and §12 ("Anyone may trigger it; nobody can redirect it") consider only the recipient, not the chain or a freeze. - Suggested fix: let only the account trigger
withdraw(msg.sender == account, or aWithdrawTosignature, aswithdrawToalready requires); keep a permissionless path only forwithdrawEthif the keeper needs it. If the openwithdrawis kept, drop the unauthenticatedwithdrawrelay from the API and say in the spec that a balance can be pushed to the party's own Arbitrum One address by anyone.
Verification of A-2:
Verdict: DOWNGRADED to Info
Proof of concept: -
forge test --match-path test/pentest/A_StateMoney.t.sol --match-test test_finding_anyoneCanForceAPayout -vv: PASS. Afterrelease, a stranger'swithdraw(seller)sends the whole 4,950 USDC to the seller's own address, and the seller'swithdrawTo(Base)then revertsNothingToWithdraw. The seller is an EOA and still holds the 4,950, so no loss is shown. -forge test --match-path test/pentest/A_Usdt0Fork.t.sol --match-test test_fork_usdt0_finding -vv(real USDT0, block 517,026,000): PASS. A-vvvvtrace rules out its silentreturns. Tether's owner (0x4DFF…0bf8) blocks the seller, the stranger moves 1,485 USDT0 into the blocked wallet, the seller'stransferreverts with "from is blocked", andwithdrawTorevertsNothingToWithdraw. -test_fork_usdt0_blockedParty: PASS. Without the push, the blocked seller moves its 1,485 USDT0 to another wallet withwithdrawTo.Sources unchanged: yes.
diff -ragainst base shows onlycontracts/test/pentest/,scripts/pentest/and REPORT-A.md differ;contracts/libandfoundry.tomlare identical.What I checked: - In YescrowEscrow.sol:
withdraw(674-679),withdrawTo(685-711),withdrawEth(718) and_isValidSignature(926-932). Signatures are checked by ECDSA or EIP-1271 on Arbitrum One only. - The design is deliberate. SPEC §2 rule 2 says "Anyone may trigger a payout".docs/decisions.mdadds that a relayer can push withdrawals for gasless users, anddocs/security.mdanddocs/keys.mdagree. SPEC §14.14 already covers a balance "already withdrawn" and offers contract wallets Arbitrum One only. The web's gasless withdraw relayswithdrawunsigned (Dashboard.tsx:153). Not in audit.md §5. - My testcontracts/test/pentest/verify/V_A-2_pushVsWithdrawTo.t.sol(2/2 PASS): - A buyer whose address is an undeployed CREATE2 wallet pays through the deposit address and is credited a 5,000 USDC refund. -withdrawTois closed to it: the owner's signature givesInvalidSignature, a call from the owner's key givesNotAuthorized. - Once the wallet is deployed, the full 5,000 comes back either way: by the signedwithdrawTo, or after a stranger's push by the owner'sexecute(transfer).Reason: 1. The open
withdrawdoesn't cause the loss in case 1. -withdrawToneeds a transaction fromaccount, or a signature valid foraccounton Arbitrum One. Either means the party controls that address there, which is where a push lands. - A party without that control can't usewithdrawToeither. Its balance is stuck whether pushed or not, and recovered the same way in both cases. 2. Case 2 is Tether's freeze working as intended. - Without the push, the escrow lets a blocked party move its funds out. - SPEC §12 refuses relays and automatic payouts for sanctioned addresses, and warns that issuers can freeze the contract itself, which would hit every party. - The attack report rates the same exit on USDC "no loss" (sound list 8). 3. What remains is minor. Anyone can override a party's chosen chain, including the keeper's signed payout. It works reliably within the keeper's 30-second loop and needs no mempool. The party still gets its whole balance at once, at its own Arbitrum One address. Nothing is held back or delayed and nobody gains, so it is an undocumented interaction, not griefing.Corrections: - Root cause:
withdrawEth(718) has no alternative destination, so it isn't an instance. - Suggested fix: don't change the contract. The change would break the unsigned gasless withdraw. It would lock forever the refunds of typed-address buyers who can't sign. It would also give Tether-blocked parties a way out. Instead: - Document in SPEC §4.1/§14.14 that anyone may push a balance to the party's own Arbitrum One address ahead of its payout. - Optionally, have the relayer refusewithdrawfor an account with a pending automatic payout.
Known issues, quantified (not counted as findings)
- Kleros raises its fee between opening and answering (
docs/security.md"Kleros fee change"): at exactly 2A the case is created with nothing held, so the winner gets no fee back; at 2A + 1 wei the case is refused instead, the winner gets its whole fee back and yescrow keeps the loser's. A one-wei cliff that only Kleros's governor can cause.test_known_klerosFeeRise_winnerRefundCliff.
Checked and found sound
Each item names the proof. Line numbers are in contracts/src/YescrowEscrow.sol.
- Every deadline, at, just before and just after its boundary (
test_sound_boundary_*):acceptBy(accept at T−1, only expire at T, the funded proposer credited the price), the transfer deadline (proof and dispute at T−1, only refund at T), the challenge deadline (challenge at T−1, release at T),feeDeadline(answer at T−1, timeout at T with the opener's fee back), the 90-day stall (TooEarly at T−1, Judging at T, the judge's deadline exactly +14 days) and the judge's deadline (ruling at T−1, timeout at T, both fees back whole). State deadlines are exclusive on both sides; signature deadlines, voucher expiry andpromoEndsare inclusive on both sides: no second is lost on either side of any boundary. - The 63/64 rule, on mocks and on the real Kleros. Sweeping the answer's gas in 5k steps across the floor and bisecting to the exact floor: every answer either reverts with
GasTooLow(nothing changes) or creates the case; none sends a deal to the judge. Numbers: the exact floor is 5,239,819 gas with the mocks and 5,249,700 on the real resolver and core; at the floor the court (mock) receives 4,915,699 gas, i.e. the 5M budget minus the resolver's own layer and its 1/64; a normal answer on the real Kleros uses 818,864 gas in total; the largest court gas need the fixed budget still serves is about 4.76M with the mock resolver (the real resolver's own writes after the court returns are larger, so the real headroom is somewhat lower, still ~5× what court 34 needs). A court that needs more is refused deterministically, not by a party.test_sound_gasSweep_cannotFakeARefusal,test_sound_courtNeedingTheWholeBudget_stillCreatedAtTheFloor,test_sound_courtBudget_largestCourtServed,test_fork_realKleros_caseCreatedAtTheExactGasFloor(a real case in court 34 at the exact floor).applyRuling's 1M floor leaves Kleros's two getters (~50k) ample gas (existingtest_applyRuling_needsAmpleGas). - Kleros's dispute IDs and getters (read from kleros-v2's source):
DisputeResolver.createDisputeForTemplatereturns the arbitrator's dispute ID (not its local index), sodisputeIdis the right key for the core;KlerosCoreBase.Disputeis(courtID, arbitrated, period, ruled, lastPeriodChange, rounds[]), so_read(..., 2)is the period andexecution= 4;currentRulingreturns(ruling, tied, overridden), so index 0 is the rulingexecuteRulingwould apply._read(line 963) checksreturndatasizebefore copying, so a shorter or reverting getter counts as "not ruled" and can't brickapplyRuling. - Re-entrancy through ETH transfers. A fee-payer contract re-entering on its refund (
withdrawEth,withdraw) and a buyer contract re-enteringreturnDepositfrom the deposit box's ETH return: every re-entrant call fails on the guard, each payment is made once, and ETH held equals ETH owed. Token transfers (USDC, USDT0, CCTP, the OFT) have no hooks into the escrow; EIP-1271 and verifier calls are static.test_sound_reentrancy_feeRefund,test_sound_reentrancy_returnDepositEth. - Every closing path credits exactly the price and exactly the ETH held, including the v0.30 paths: refused → judge rules 0/1/2, judge timeout, concession and settlement in
Judging; stalled → the same four. Fuzzed over the price, the referrer share (0–100%) and a voucher fee: seller + referrer + fee recipient = price, fee ≤feeBps; fuzzed over the arbitration fee (odd wei included): a refused split credits A to yescrow and A across the two payers.test_sound_judgingPaths_conserveMoney,testFuzz_sound_releaseConservesThePrice,testFuzz_sound_refusedSplitConservesEth. Rounding:price/2floors toward the seller,held/2toward the seller's side, fee and referrer share floor; no path can overflow (0.8 checked arithmetic, windows capped). - Settlement signatures and phases. A
Settlesigned while Kleros had the case (caseCreated = true) stays valid after the case stalls intoJudgingand pays the same (held fee split 50/50), so waiting for the stall gains nothing; one signed inAwaitingFeeis unusable after a refusal (E3 fix, existing tests). The service buildscaseCreatedasDisputed || Judging, matching line 586.test_sound_settleSignedInDisputed_validAfterStall_sameMoney. - Fee payers and refunds (SPEC §2 rule 2, §14.17): the opener's fee goes back to its payer on a missed answer, a concession or a settlement before the case; once the case exists the held fee goes to the winner's side payer or is split; on a refused case
judgeRulingmoves exactly one A to the fee recipient (lines 628–633) and_closepays the rest once (lines 1028–1046). A sponsor only ever gets its own ETH back (existingSponsoredFees.t.soland the conservation tests above). - USDC on the real token (
A_ArbitrumOneFork.t.sol): a blacklisted buyer can't fund by signature; blacklisting the seller doesn't trap the buyer's refund;withdrawto a blacklisted party reverts (its own problem only) while its balance stays and can leave throughwithdrawTo(other): the issuer's blacklist binds token holders, not escrow balances (Info, no loss). CCTP's burn limit is 10,000,000 USDC per message on Arbitrum One: a largerwithdrawToto Base reverts, the balance is untouched, and it can go in parts or on Arbitrum One; nothing is stuck. - USDT0 on the real token (
A_Usdt0Fork.t.sol):receiveWithAuthorizationworks unchanged (existing fork test); a blocked buyer can't fund; Tether's blocklist checksfromonly (see A-2 for what that allows). - Fees on transfer. Neither token takes a fee;
_fundmeasures the balance difference anyway (lines 849–851) and_pullDepositlikewise (lines 865–873), so a short delivery reverts and a deposit funds only what arrived. - Donations. Tokens or ETH sent to the escrow outside a deal change no balance and no deal (every amount is a measured delta or a credited balance); they stay there, as documented. ETH can't be sent directly (no
receive), and no path leaves ETH in the escrow:withdrawToon the same chain requiresmsg.value == 0, the router refuses value on CCTP routes and refunds the OFT's excess to the caller. - Deposits go only to their deal (existing
Deposits.t.solplus the re-entrancy test): the box address commits to escrow, deal ID and buyer;pullon Arbitrum One sweeps only the caller's own namespace, so a non-escrow caller reaches only boxes salted with its own address;returnDepositcreditsterms.buyer, and the deal token is returnable only once the deposit can no longer fund the deal (checked atacceptBy, state and term validity). - No transition reachable twice, no final state changed: every closing function requires the exact non-final state,
_openDealadmits Active…Disputed and Judging only,_cancelonly from Proposed, andapplyRulingonly from Disputed, so a late Kleros ruling, a secondrelease, or ajudgeTimeoutafter a ruling all revert (WrongState); re-checked by reading every entry point. - Deployment settings (
script/DeployAll.s.sol,Networks.sol): courts 34 (3 jurors) and 1 (1 juror), Classic kit,smallDealMaxPrice2,000, mainnet minimums 24 h / 72 h / 72 h enforced, the resolver must point at the core, the judge must be a contract and not the fee wallet, the deployer must be the fresh one at nonce 0: all as SPEC §9 and §14.20 promise.CheckDeployment.s.solreads them back. (The resolver and core addresses themselves are checked live by the existing mainnet fork test at block 519,194,000.) - Signature deadlines and replay in this area:
WithdrawTois consumed by digest (lines 697–700) so neither the signature nor a malleated copy works twice;Authactions bind deal, domain, parties, price, action and deadline and each is valid in one state that is left for good; aReferrerSharesignature is consumed by nonce; a voucher can't be re-applied because it must lower the fee. Two mainnet escrows can share a deal ID, but EIP-712 domains, EIP-3009 token domains and box salts all include the escrow (existingMisc.t.solandDeposits.t.sol).
Unconfirmed, or outside what a Foundry test can prove
- Kleros's
pause(guardian or governor) makescreateDisputerevert, which the escrow treats as a refusal: every answer sent during a pause goes to yescrow's judge, and the answering party chooses whether to answer during the pause or wait. Not a party's lever (Kleros only), and consistent with SPEC §7.1's "Kleros may refuse", but worth a line in §12 next to the resolver's key. - A buyer paying from Base with a smart-contract wallet that exists only on Base (EIP-1271 on Base's USDC through
POST /v1/relay depositTransfer):terms.buyeris then an address the buyer doesn't control on Arbitrum One, where refunds and excess are credited and can be pushed by anyone (A-2). SPEC §14.11 says the interface must warn; the API doesn't checkterms.buyer's code on Arbitrum One (service/src/api/app.ts:1068-1081). Needs a running stack to prove; the loss would be the buyer's whole refund. - One-sided appeal funding at Kleros: in the Classic dispute kit a party that loses a round can fund an appeal, and if the winner's side isn't funded in time the funded side wins. The escrow applies whatever the core's
currentRulingsays at execution, so yescrow's interface and keeper must tell a winning party to fund its appeal side. Area B/C territory; flagged here because it decides money. - Two settlements both parties signed are both valid until their deadlines and the first one submitted wins (
test_info_twoSignedSettlements_firstSubmittedWins): keep settlement deadlines short in the interface. Not a contract flaw. - A "before" proof fetched in the proposal's second is rejected (
fetchedAt > proposedAtis strict), so an acceptance needs a later second (test_info_acceptNeedsASecondAfterTheProposal). Liveness only. - Sponsored fees can be burned by self-dealing on 1-USDC deals in the AI court (each costs yescrow the court's fee plus gas); capped by
SPONSOR_DAILY_WEIand accepted indocs/security.md. returnDepositwith an attacker's ERC-20 trusts the box's reported amount only for theDepositReturnedevent (the deal token is measured by balance delta): a watcher could display a fake amount. Info.- Terms naming the escrow, the router or a box as
referrerorbuyercredit a balance nobody can use (awithdrawto the escrow is a self-transfer). Self-harm only. Info.
Files
contracts/test/pentest/A_StateMoney.t.sol: boundaries, gas sweep and bisection, re-entrancy, promo voucher in Judging (A-1), permissionless withdraw (A-2), Kleros fee cliff, settlement phases, fuzzed conservation.contracts/test/pentest/A_ArbitrumOneFork.t.sol: real USDC blacklist, real CCTP burn limit, real Kleros case at the exact gas floor.contracts/test/pentest/A_Usdt0Fork.t.sol: real USDT0 blocklist semantics and the A-2 USDT0 scenario.
No file outside contracts/test/pentest/ was changed, and no transaction was sent to any live network (forks read public RPC state only). REPORT-A.md was not written because the harness refuses report files from subagents; the text above is the full report.
Pentest report, area B: signatures
Commit under test: 3ca0628 (main, 2026-10-29). Scope: EIP-712 domains and type hashes; EIP-3009 with the deal ID as
nonce; EIP-1271, ERC-6492 and EIP-7702 wallets; settlements bound to their phase; payout signatures the keeper submits
later; malleability; replay across deals, escrows, tokens, chains and phases. Code read: contracts/src/YescrowEscrow.sol,
contracts/src/DepositRouter.sol, contracts/src/DepositBox.sol, OpenZeppelin 5.4 ECDSA, SignatureChecker,
EIP712; service/src/typed-data.ts, erc6492.ts, relayer.ts, payouts.ts, promo.ts, admin.ts, sponsor.ts,
keeper.ts (payouts), api/app.ts (typed data, relay, payouts, deposit authorizations, withdraw fee); the real USDC
(FiatToken v2.2) and USDT0 on a fork of Arbitrum One, and USDC on a fork of Ethereum.
Proofs: 22 Foundry tests in contracts/test/pentest/B_Signatures.t.sol (mock stack, 12 tests) and
contracts/test/pentest/B_Eip3009Fork.t.sol (forks: Arbitrum One at block 519,194,000, Ethereum at 26,254,000; 10
tests). All pass. Run everything from contracts/:
forge test --match-path 'test/pentest/B_*.t.sol' -vv
No off-chain script was written: a local stack needs the Docker Postgres the harness expects (service/test/harness.ts
line 144), which this run must not touch. The two off-chain findings are proved by their on-chain half on a fork plus
the exact service lines that make the relayer pay.
Summary. No Critical or High. The on-chain signature layer holds: nothing signed works for another deal, escrow, chain, action, amount or a second time, the EIP-3009 authorization can only be spent by its escrow, and both real tokens enforce that. Two Medium findings are about yescrow's relayer paying real money for signatures anyone can produce (LayerZero fees on micro-withdrawals of USDT, Ethereum gas on zero-value deposit authorizations). Two Low findings are about signed offers and actions outliving the moment they were meant for (a settlement offer is a free option against its signer once Kleros's votes are visible; action signatures aren't bound to the phase they were signed in). One Info note on the EIP-1271 trust boundary.
Findings
B-1. Relayed USDT withdrawals to another chain: each costs the relayer the whole LayerZero fee, for any amount, with no cap but a per-IP rate limit
Severity: Medium (loss of yescrow's ETH; a drained relayer stops every gasless step, including sponsored dispute
answers, which docs/keys.md §4 names as the relayer's worst case).
Attacker and preconditions: anyone with a balance in the USDT escrow of at least a few base units (1 USDT is plenty; a deposit that can no longer fund a deal gives a balance to anyone, as the proof does). No deal needed.
Steps:
1. Get a balance: send 1 USDT0 to the deposit address of any terms whose acceptBy is about to pass, then call
returnDeposit (anyone may): usdcBalance[attacker] = 1e6.
2. Sign WithdrawTo(attacker, chainId = 1, attacker, amount = 1, deadline) once per withdrawal with a different
deadline (each is a new digest, so withdrawalUsed never blocks it).
3. POST /v1/relay {action: 'withdrawTo', escrow: <USDT escrow>, account, chainId: 1, recipient, amount: '1', auth},
20 times a minute per IP (relayLimit, RELAY_RATE_LIMIT_PER_MINUTE default 20). The API computes withdrawFee
as 1.2 x USDT0's quoteSend and the relayer sends it as msg.value; the router refunds only the excess.
Impact (measured on the Arbitrum One fork, block 519,194,000): LayerZero's fee to carry 1 base unit (0.000001
USDT) to Ethereum is 17,218,985,393,064 wei (0.0000172 ETH) and does not depend on the amount. Five withdrawals of one
unit cost the relayer 86,094,926,965,320 wei. At the API's rate limit one IP burns about 0.496 ETH of the relayer's
balance a day while moving 0.0288 USDT; more IPs, more. A relayer out of ETH stops relaying every signed step and
sponsoring arbitration fees, so a sponsored party could then miss feeDeadline and lose by default (SPEC §7.1).
Proof: test_fork_B_FINDING_relayerPaysTheWholeLayerZeroFeePerMicroWithdrawal
forge test --match-path test/pentest/B_Eip3009Fork.t.sol --match-test relayerPaysTheWholeLayerZeroFee -vv
Root cause: service/src/api/app.ts lines 280–291 (withdrawFee: the relayer pays 1.2 x the quote for any
amount) and lines 1082–1096 (case 'withdrawTo': no minimum amount, no per-account or per-day budget; the schema's
amount is any decimal string, service/src/api/schemas.ts line 7). On-chain, YescrowEscrow.withdrawTo (lines
685–714) accepts amount >= 1 and DepositRouter._send (lines 209–244, send{value: fee.nativeFee}) spends the
caller's ETH on the message whatever the amount. The contract is right to allow it (a party paying its own fee may
bridge dust); the service shouldn't pay for it blindly.
Suggested fix: in the service, relay a cross-chain USDT withdrawTo only above a minimum amount (for example the
fee's value in USDT times ten, or a flat 20 USDT), cap relayed LayerZero fees per account per day the way sponsored
arbitration fees are capped (SPONSOR_DAILY_WEI), and refuse to pay more than once per account per hour; a party can
always send the transaction itself with the fee. The keeper's automatic payouts are unaffected (they carry the whole
payout amount).
Verification of B-1:
Verdict: CONFIRMED (Medium stands)
Proof of concept: in contracts:
forge test --match-path test/pentest/B_Eip3009Fork.t.sol --match-test relayerPaysTheWholeLayerZeroFee -vv→ PASS. Fee per 1-unit withdrawal to Ethereum: 17,218,985,393,064 wei. 5 withdrawals cost the relayer 86,094,926,965,320 wei (exactly 5× the fee; the 0.2× margin came back). 495,906,779,320,243,200 wei a day per IP. My tests incontracts/test/pentest/verify/V_B-1_FeeVsGas.t.sol(3 PASS): - The fee is identical for 1 unit, 1 USDT, 1,500 USDT and 1,000,000 USDT. - With gas (≈571k at the fork's 0.020 gwei base fee, L2 only), one relayed micro-withdrawal costs ≈28,664,122,297,064 wei: about 0.83 ETH a day per IP. - A same-chain 1-unitwithdrawTocosts ≈120k gas (≈2.4e12 wei, about 0.069 ETH a day per IP). The LayerZero leg makes this plain gas griefing about 12× worse.Sources unchanged: yes.
diff -ragainst base differs only incontracts/test/pentest/, an emptyscripts/pentest/andREPORT-B.md.What you checked: - The test. The balance comes from a real
returnDepositof the attacker's own USDT0, signed with the attacker's own key.dealonly funds the attacker and relayer. The mock arbitrator and verifier are never touched bywithdrawTo. The router isnew DepositRouter()(no constructor arguments, so the deployed code), with the real USDT0 OFT and LayerZero.vm.prank(relayer)with 1.2× the quote matches the service exactly. - The service.amountis any decimal, with no minimum. The only hooks are sanctions and idempotency.withdrawFeepays 1.2×quoteSendfor any amount, thenrelayer.callsimulates and sends. There is no budget..env.mainnet.examplehasDEPOSIT_CHAINS=8453,1, so the route is live. Sponsored fees have a daily budget; LayerZero fees have none. - Known or intended? It is not indocs/audit.md§5. SPEC §14.14 has the relayer pay USDT0's fee "as for any relayed withdrawal", but an unbounded spend is documented nowhere:docs/security.mdlists only per-IP rate limits. - Root cause lines: correct.case 'withdrawTo'is lines 1082–1091.Reason / corrections: 1. The impact is understated. The PoC counts only
msg.value, because forge charges pranked calls no gas. At about 0.00057 ETH a minute, the 0.005 ETHRELAYER_LOW_WEIalert fires only about 9 minutes before the relayer is empty. Across IPs, the relayer's serialized queue caps throughput, and these relays hold up honest users' relays in it. 2. The sponsored-party escalation is overstated..env.mainnet.examplehasSPONSOR_DAILY_WEI=0, so sponsorship is off. When it is on,sponsor.ts:45refuses once the relayer is low, and the API'sFEE_NOT_SPONSOREDtells the party to pay from its wallet within the 3-day fee period. So Medium rests on griefing, yescrow's lost ETH and an outage of gasless steps (parties can still act directly, SPEC §12), not on a party's loss. 3. Only the on-chain leg is proven. The API leg rests on code reading, not the local-stack scriptdocs/ai-pentest.mdasks for. I traced it and found nothing that bounds it. 4. The suggested fix needs a change. Per-account or once-an-hour limits are easy to get around with more accounts, each needing only a few base units and onereturnDeposit. Use instead: - a minimum amount for relayed cross-chainwithdrawTo; - a global daily budget for relayed LayerZero fees, reserved before sending (SPONSOR_DAILY_WEIis global too); - an alert on the relayer's spend rate.Consider a minimum for same-chain relays too: the same vector at about 1/12 of the cost.
B-2. Relayed zero-value USDC transferWithAuthorization on Ethereum: free signatures make the relayer burn mainnet gas
Severity: Medium (loss of yescrow's ETH on Ethereum; same knock-on as B-1 for deposits waiting there).
Attacker and preconditions: anyone. A draft deal with any terms is enough (proposalOf accepts drafts); the
attacker needs no USDC at all, because Circle's token accepts a zero-value authorization.
Steps:
1. Create a draft (POST /v1/drafts) or pick any existing deal; get its deposit address.
2. Sign TransferWithAuthorization(from = attacker, to = deposit address, value = 0, validAfter = 0, validBefore,
nonce = random) with the Ethereum USDC domain; every nonce is a fresh authorization.
3. POST /v1/relay {action: 'depositTransfer', dealId, chainId: 1, from, value: '0', validBefore, authorizationNonce,
signature} at the rate limit. The Ethereum relayer simulates (it passes), estimates gas and sends.
Impact (measured on the Ethereum fork, block 26,254,000): 84,037 gas per zero-value authorization, paid by the relayer, nothing moved. The relayer's queue waits for each receipt, so one IP gets roughly 3,000–7,000 of them through a day: at 1 gwei about 0.25–0.6 ETH a day, ten times that at 10 gwei; more IPs, more. Base is also reachable the same way but its gas is negligible.
Proof: test_fork_B_FINDING_zeroValueTransferAuthorization_anySubmitter
forge test --match-path test/pentest/B_Eip3009Fork.t.sol --match-test zeroValueTransferAuthorization -vv
Root cause: service/src/api/app.ts lines 1068–1081 (case 'depositTransfer'): any from, any value
(including 0, schemas.ts line 7), any draft, no check that from holds value, that value is at least what the
deal still needs, or that this deal already got a relayed transfer; relayer.call only simulates for reverts.
Suggested fix: relay a deposit authorization only when value is at least the deal's missing amount (or the price)
and from's token balance covers it (one balanceOf call), at most once per deal and chain, and only for drafts
whose terms the API accepted; put Ethereum relays under a daily gas budget like the sponsored fees.
Verification of B-2:
Verdict: CONFIRMED (Medium stands), with corrections to the impact numbers and the fix.
Proof of concept: run from
B/contracts:forge test --match-path test/pentest/B_Eip3009Fork.t.sol --match-test zeroValueTransferAuthorization -vv→ PASS: 84,037 gas per zero-value authorization (average of 3), nothing moved. My check,forge test --match-path test/pentest/verify/V_B-2_realTxCost.t.sol -vv, also passes: - A griefer holding 0 USDC spends a fresh authorization each time; balances and supply don't change. - The node'seth_estimateGasfor the whole transaction at block 26,254,000 is 82,416 gas (24,176 of it base cost plus calldata), so the PoC's figure is within about 2%. - That block's base fee was 0.084 gwei.Sources unchanged: yes. Excluding build output, only
contracts/test/pentest/, an emptyscripts/pentest/andREPORT-B.mddiffer.What I checked: - The test: the attacker's own key, Circle's real USDC on an Ethereum fork, no
deal,storeoretchon real contracts.vm.prankonly stands in for the relayer; gas is the same without it (81,078 vs 81,080). -app.ts1068–1081 (identical to pristine) never checksvalue,from's balance, how many relays a deal has had, or thatauthorizationNoncecame fromPOST /v1/deposit-authorizations(the schema says it does). - Getting a deal ID:schemas.ts:7accepts"0".proposalOftakes drafts.POST /v1/draftshas only the global 120/min limit, and any on-chain deal ID also works. - Mainnet:.env.mainnet.exampleturns on Ethereum (DEPOSIT_CHAINS=8453,1).relayer.tssimulates, then sends; value 0 passes. - Known issues: not indocs/audit.md§5. SPEC §12 anddocs/keys.md§4 accept that the relayer may run dry, but name only the per-IP rate limit as protection. The relayer's ETH is budgeted only for sponsored fees (§14.17).Reason / corrections: 1. "More IPs, more" is wrong for the ETH burned. Each chain's relayer sends one transaction at a time and waits for its receipt (
relayer.ts45–59). That caps Ethereum at about one relay per block (≤7,200 a day), and one IP at 20/min already fills it. Most it can burn: about 0.59 ETH a day at 1 gwei, about 0.05 ETH a day (plus tips) at the fork block's 0.084 gwei. 2. A bigger harm, not claimed (from the code; the service wasn't run). The backlog grows by about 900 an hour per IP, and the keeper shares that queue (main.ts21/27/52). - Honest gasless deposits (authorizations valid one hour) expire while waiting, then go out with their pre-estimated gas and revert. -Keeper.tickwaits behind the backlog for its EthereumforwardandreceiveMessagecalls (keeper.ts119–120, 320) whenever ≥10 USDC sits at a deal address there. The attacker can arrange that with its own draft. - Meanwhile every Arbitrum keeper duty stops: releases, refunds, automatic proofs, deposit-funded accepts beforeacceptBy, Kleros calls and payouts. -ops.tsalerts only on loops that throw, so nothing reports the stall.Anyone can still call every step, so no party loses funds. The result is delays past promised deadlines plus loss of yescrow's ETH: Medium. 3. Labels disagree: the PoC's comment says "Low", the report says Medium.
Root cause: correct. Also: nothing checks where the nonce came from, and each chain's queue is unbounded and shared with the keeper.
Fix: - "Value ≥ the missing amount" isn't enough: a price can be 1 base unit (
_termsOkanddeals.ts:119only reject 0) and drafts are free. Also requirevalue ≥ FORWARD_MINfor that chain (10 USDC on Ethereum,keeper.ts:30) andbalanceOf(from) ≥ value. - Relay only nonces the service issued, each once. - Add a daily gas budget per deposit chain. - Cap the queue, and keep the keeper's sends out of the API's queue. - "Only drafts whose terms the API accepted" already holds.
B-3. A settlement offer is a free option against the party that signed it once Kleros's votes are visible
Severity: Low (no party is forced, the signer consented to the amount; but the design shifts expected value to the offeree, systematically, through the web app's 24-hour offer links).
Attacker and preconditions: the party that receives a settlement offer during a dispute. The web app
(web/components/deal/steps.ts lines 211–222) has the offerer sign Settle(..., sellerAmount, caseCreated = true,
deadline = now + 24 h) and share a link carrying that signature. Kleros's AI court rules in about a day, with votes
and currentRuling public on-chain, and the escrow accepts settle in Disputed until someone calls applyRuling.
Steps:
1. Seller offers 50/50 (signs the link). Buyer does nothing.
2. Kleros votes; the case reaches its execution period with ruling 1 (the seller delivered): currentRuling is final
and readable, the escrow could apply it.
3. The buyer, who would get nothing, counter-signs the old offer and calls settle before anyone calls applyRuling
(no race needed: the buyer can already read the vote direction during the vote and appeal periods, before
applyRuling is possible at all). Had the vote gone the buyer's way, the buyer lets the ruling apply.
Impact (mock Kleros, price 5,000 USDC): the seller gets 2,475 USDC and half the held fee instead of 4,950 USDC
and the whole held fee; 2,475 USDC shifts to the buyer by the exercised option. applyRuling then reverts
(WrongState), so the Kleros ruling is dead.
Proof: test_B_FINDING_settlementOfferIsAFreeOptionOnceTheRulingIsVisible
forge test --match-path test/pentest/B_Signatures.t.sol --match-test settlementOfferIsAFreeOption -vv
Root cause: contracts/src/YescrowEscrow.sol settle (lines 573–591) and _settleDigest (902–922): the phase
flag caseCreated says only whether the case exists, not how far it is, and a signed offer can't be revoked; the
web's 24-hour validity (steps.ts line 212) spans the AI court's whole case.
Suggested fix: any of: (a) in settle, while Disputed, read the case's period as applyRuling does
(_read(arbitrator, IKlerosCore.disputes.selector, id, 2)) and refuse once it is past the evidence period (or
once currentRuling is final), so a settlement is only possible before the jurors speak; (b) a per-party settlement
nonce in the Settle digest with revokeSettlementOffers(dealId) so an offerer can withdraw; (c) in the web, a
short offer validity (an hour or two) and a sentence in the signing summary that the other side may wait for the
votes before accepting.
Verification of B-3:
Verdict: CONFIRMED (Low)
Proof of concept:
forge test --match-path test/pentest/B_Signatures.t.sol --match-test settlementOfferIsAFreeOption -vv, run frompentest/B/contracts: PASS. - Seller's payout under ruling 1: 4,950 USDC. After the exercised 50/50 offer: 2,475 USDC, so 2,475 shifts to the buyer. - The seller's held fee is A/2 instead of A, andapplyRulingthen revertsWrongState. - My timing checks,contracts/test/pentest/verify/V_B-3_Timing.t.sol: 3 tests, all PASS.Sources unchanged: yes. Apart from out/ and cache/,
diff -ragainstbasediffers only incontracts/test/pentest/,scripts/pentest/andREPORT-B.md.src,script,lib,foundry.toml,Base.t.soland the mocks are identical.What I checked: - The test. The pranks are the buyer acting for itself and the seller paying its own fee. The seller's key signs only its own voluntary offer. There is no
vm.store,vm.etchor extradeal. The assertions are exact. - The mocks. They match Kleros v2:rulereaches the resolver, never the escrow, sosettlestays open inDisputeduntilapplyRuling. - The code.settle(573–591) and_settleDigest(902–922) have no period check, no nonce and no way to withdraw an offer.steps.tslines 211–212 set the deadline to chain time + 24 h.Dispute.tsxtells the offerer only that the link is "valid for 24 hours". - The spec. SPEC §7.2 allows a settlement "at any point before the ruling" on purpose. Nothing in SPEC oraudit.md§5 covers an offer that outlives the vote. E3 (caseCreated) binds the phase, not the Kleros period. - Mainnet KlerosCore, read today. Court 34 has hidden votes and periods of 10 min / 45 min / 30 min / 36 h. Court 1 has hidden votes and periods of 78 h / 162 h / 162 h / 108 h. Both matchdependencies.md§1.8, which gives court 34 a median of 19.1 h to its ruling.Reason / corrections: 1. The PoC compresses time. At its own price, a 5,000 deal goes to the General Court. There, an offer signed when the case opens expires at least 54 h before any vote can be public (V test 3:
SignatureExpired). Only offers signed within 24 h before the reveal are exposed (V test 3: 2,475 instead of 4,950). "Rules in about a day" describes the AI court, which takes deals up to 2,000. There a 50/50 offer shifts at most about 1,000 (V test 1: 990 instead of 1,980). 2. In the AI court the exposure is real, and no race is needed. Offers signed in about the first hour of the case, before the reveal, are exposed. That is exactly when a party would sign to settle "instead of waiting for Kleros". The offeree then has the ~18 h appeal period to decide, whileapplyRulingrevertsTooEarly(V test 1). If the vote goes its way, it lets the ruling apply (V test 2). 3. Low is right. The offerer gets exactly what it signed; nobody is forced and nothing is stolen. 4. Fix (a) is too broad. It contradicts SPEC §7.2 and blocks informed settlements, for example both sides seeing the vote and settling instead of appealing. - A narrower fix: bindSettleto the period it was signed in, for examplelastPeriodChange, word 4 ofdisputes(id)through_read. The offer then lapses when the case moves on. Fix (b) also works. - Fix (c): a shorter validity can't beat a sub-hour AI vote. Its useful part is the warning that an offer can't be withdrawn and the other side may wait for the vote.
B-4. Action signatures aren't bound to the phase they were signed in: a stale Confirm can close a dispute the buyer opened after signing, taking the fee it paid
Severity: Low (needs a signature that outlives its submission; the web app relays at once with a one-hour
deadline, but the API lets a client pick any deadline).
Attacker and preconditions: whoever ends up holding a party's action signature after the deal moved on: the
relayer (its queue can submit late), the counterparty if the signature reached it, or an integrator that asked for
a long deadline (GET /v1/deals/{id}/typed-data?deadline= is unbounded, service/src/api/app.ts line 765).
Steps: the buyer signs Confirm in Proven; the submission doesn't land; the buyer challenges instead (pays A)
and the seller answers (Disputed); the seller submits the stale Confirm.
Impact: Released: the seller gets the price less the fee and the held fee; the buyer's arbitration fee is
gone. For a seller's stale Cancel the mirror image holds. Settlements were given a phase flag for exactly this
reason in v0.27 (E3); the five other action types weren't.
Proof: test_B_INFO_staleConfirmLandsDuringDispute_signerLosesItsFee
forge test --match-path test/pentest/B_Signatures.t.sol --match-test staleConfirmLandsDuringDispute -vv
Root cause: _authorize (lines 887–900): the digest has dealId, domain, buyer, seller, price, deadline and
no phase; confirm/cancel accept every state from Active to Judging (_openDeal, lines 956–959).
Suggested fix: cap the API's deadline (reject more than an hour ahead) and have the relayer re-check the deal's
state right before sending a confirm/cancel; for the next contract version, add the state the signer saw (or
provenAt) to the action digest, as Settle has caseCreated.
Verification of B-4:
Verdict: CONFIRMED (Low), with corrections to the suggested fix.
Proof of concept:
forge test --match-path test/pentest/B_Signatures.t.sol --match-test staleConfirmLandsDuringDispute -vv(frompentest/B/contracts): PASS. The buyer signs a Confirm in Proven (one-hour deadline), then challenges (A = 1e13 wei); the seller answers and then submits the Confirm. Result:Released, the seller is credited 4,950 USDC (5,000 less the 1% fee) plus the held fee A, and the buyer gets 0.My extra tests, all 7 passing (
contracts/test/pentest/verify/V_B-4_staleActions.t.sol): - In net ETH the buyer is down 1e13 and the seller is even. The buyer's fee went to Kleros; the seller only got its own back. - The mirror case holds: a stale Cancel endsRefundedand the seller's fee is gone. - A Confirm with a 365-day deadline still lands in Judging after the 90-day stall. - If the Confirm lands in AwaitingFee, or in Judging after a refusal, the buyer's fee comes back whole. Only the dispute is lost. - Had the buyer won the case, it would have had 5,000 USDC and A back.Sources unchanged: yes. Only
contracts/test/pentest/,scripts/pentest/andREPORT-B.mddiffer frombase;lib,broadcastandfoundry.tomlare identical.What you checked: - The test signs only the buyer's own Confirm with the buyer's key and pranks only the two parties as themselves. There's no
vm.storeorvm.etch;confirm/_closedon't depend on the mocks. - The root cause is right._authorize(lines 887–900) binds no phase, and_openDeal(956–959) accepts every state from Active to Judging. Only Confirm and Cancel are exposed: the other four action types each work in one state that the deal leaves for good, so "the five other action types" overstates it. - It isn't a known issue indocs/audit.md§5. §3 claims phase binding for settlements only.docs/security.md's threat model says actions bind "deal, terms and deadline" and that the relayer may "submit or withhold signed actions". SPEC §5 allows concessions in every open state but says nothing about signatures made before a dispute. E3 fixed the same flaw inSettle. - The service: the typed-datadeadlinehas no maximum (app.tsline 765; the schema has onlyminimum: 0). The web app uses the one-hour default for actions; the relayer sends from an in-memory queue and stores nothing; Arbitrum has no public mempool. In the shipped flows only yescrow's relayer holds the signature, for seconds.Reason (Low stands): the signer did sign the release. The extra harm is the signer's own arbitration fee plus the dispute it opened. The fee goes to Kleros; the submitter gains nothing. Today it's about 0.0008 ETH in the AI court, or one juror's fee in the General Court (SPEC §9). A counterparty needs an unexpired signature, and only an API client that asked for a long deadline and leaked it would give it one. This isn't Medium griefing, because no attacker can get the signature on its own.
Corrections to the fix: - Binding
provenAtdoesn't work. A challenge and an answer leave it unchanged (test_V_B4_provenAtUnchangedByDispute), so a Confirm bound to it stays valid in Disputed. Bind the dispute phase instead, asSettledoes: no dispute (Active/Proven), AwaitingFee, case created (Disputed/Judging). - A one-hour cap must leavesettlealone, because the web app's settlement offers last 24 h (steps.tsline 212). Cap the six action types. Also checkauth.deadlineinPOST /v1/relay, since the contract accepts any deadline a client builds itself. - The relayer's re-check should compare the deal's state when the request arrived with its state just beforesendTransaction. Refusing dispute states outright would break the web app's own concessions (DealPage.tsxline 473).
B-5 (Info). The EIP-1271 fallback makes a party's own code the judge of its signatures
_isValidSignature (lines 926–932) tries ECDSA, then EIP-1271. A party whose address runs permissive code (an
EIP-7702 delegation to a "sweeper" or any contract answering the magic value for every digest) can be acted for by
anyone: test_B_INFO_permissiveDelegate_letsAnyoneActForTheParty has a stranger's key release the buyer's price. This
is the party's choice and the standard EIP-1271 trust boundary, not an escrow bug; ECDSA-first is right (the EOA key
is the root of a 7702 account and can always re-delegate). Worth one line in the docs, since 7702 phishing
delegations are common.
Verification of B-5:
Verdict: CONFIRMED (Info)
Proof of concept:
forge test --match-path test/pentest/B_Signatures.t.sol --match-test permissiveDelegate -vvvv(fromB/contracts): PASS. Trace: ecrecover of the stranger's Confirm gives 0x981F… (not the buyer), then the buyer'sisValidSignaturereturns0x1626ba7eand the deal emitsReleased(sellerAmount 4,950,000,000, fee 50,000,000). The test asserts only the state.My checks, in
contracts/test/pentest/verify/V_B-5_permissiveDelegate.t.sol(3 tests, all PASS), use a real 7702 designator signed by the buyer's own key (vm.signAndAttachDelegation), notvm.etchof raw code: 1. From Active, before any transfer, the seller signs "the buyer's" Confirm with its own key and withdraws 4,950,000,000. The buyer gets 0. 2. After a refund, a stranger with no part in the deal signs WithdrawTo(buyer → itself) and takes the buyer's 5,000,000,000. 3. On a fork of Arbitrum One (block 519,194,000), real USDC: the same delegate lets the same strangertransferWithAuthorizationthe wallet's 5,000 USDC to itself, no escrow involved (FiatToken v2.2's SignatureChecker makes the sameisValidSignaturecall).Also PASS: the repo's
test_eip7702DelegatedEoa_signsWithItsKey(ECDSA first) andtest_fork_B_INFO_usdt0_eip1271WalletFunding(USDT0 also accepts EIP-1271).Sources unchanged: yes. Only
contracts/test/pentest/,scripts/pentest/andREPORT-B.mddiffer from the pristine export;src/,script/,lib/,foundry.tomlandbroadcast/are identical.What I checked: -
_isValidSignature, YescrowEscrow.sol lines 926–932 (same in the pristine export), tries ECDSA, thenSignatureChecker.isValidERC1271SignatureNow. Every party signature goes through it:_authorize(accept, confirm, cancel, dispute, challenge, payArbitrationFee),settle,withdrawTo. - The PoC'svm.etchwrites only into the victim's own account, standing in for a delegation the victim signs; a real designator behaves the same (check 1). No prank as a signer, nothing written into yescrow's or third-party contracts; the stranger signs with its own key. -docs/audit.md§5 lists only the opposite 7702 limit (USDC funding needs a delegate with 1271).docs/security.md"EIP-7702 delegated accounts" justifies ECDSA-first but doesn't say a party's code can approve anything. Not a known issue. SPEC §14.16 supports EIP-1271 wallets on purpose.Reason (severity stands): The PoC undersells the reach. It releases a deal already Proven, which would release after the challenge window anyway: the buyer loses only its right to challenge, and the stranger gains nothing. The real reach is every party action and the party's balance: - a release from Active with no transfer (checked: the seller gains 4,950 USDC); - any split in
settlewith one side forged (from the code, not tested); -withdrawToof the party's whole escrow balance by anyone, to any address and chain (checked).It is still not an escrow weakness: - An EIP-1271 "yes" is by definition the account's own signature, and only the party's own key or choice of wallet installs that code, so the precondition equals a leaked key. - A sweeper's controller already acts as the party through the no-signature
msg.senderpath, so the fallback gives it nothing new. - A permissive delegate already hands anyone the wallet's USDC at Circle's contract (check 3), and its USDT0 too.The escrow is never the weakest link, so Info is right.
Root cause and fix: The line is right as the place where trust passes to the party's code (line 931), but it is not a defect. The docs line belongs in
docs/security.md's 7702 section anddocs/audit.md§4 or §5, and should namewithdrawTo(anyone can take the balance), not only "acting for the party". Optional, off-chain: the web app couldeth_callisValidSignature(random digest, junk signature)on a party's address and warn if it answers yes. Don't change the contract (e.g. ECDSA only for 7702 designators): that would lock out 7702 wallets that sign with passkeys or session keys, and protect nothing the tokens and themsg.senderpath don't already expose.
Checked and found sound
Each item names the evidence (tests in B_Signatures.t.sol unless marked fork).
- EIP-712 domain.
EIP712("yescrow", "1")with chain ID and verifying contract (OpenZeppelin 5.4 recomputes the separator whenblock.chainidchanges). The same deal ID exists at the USDC and USDT escrows (the ID doesn't name the escrow), and nothing signed for one works at the other: Accept, Confirm, Settle, WithdrawTo, FeeVoucher, ReferrerShare and the EIP-3009 funding all refused (test_B_sameDealId_signaturesDoNotCrossEscrows); a signature made under one chain ID is refused under another and works again on the right one (test_B_signaturesDoNotCrossChains). - Type hashes and encodings. Every
*_TYPEHASHstring matches itsabi.encodeorder and types (string hashed, bool and small integers as words), and the service builds the same typed data (typed-data.tsACTION_FIELDS,settleTypedDatawithcaseCreated,withdrawToTypedData;promo.tsFeeVoucheruint16/uint64;admin.tsReferrerShare), so a mismatch couldn't even reach the chain. - Replay within a deal. Each action type is accepted in exactly one set of states that the action leaves for good
(Accept: Proposed; Dispute: Active; Challenge: Proven; PayArbitrationFee: AwaitingFee; Confirm/Cancel: close the
deal), and final states never reopen, so no action signature works twice. Settle closes the deal. Existing tests
(
Misc.t.sol,Transitions.t.sol,SponsoredFees.t.sol) cover wrong signer, other action, other deal, expiry. - Settlement phase.
caseCreatedseparates AwaitingFee from everything after (Hardening tests). Disputed and Judging sharetrue, which is safe: a settlement signed while the case was at Kleros still works after the case stalls, with identical token and held-fee splits (test_B_settleSignedInDisputed_sameMoneyAfterStall). A refused case jumps straight from AwaitingFee (false) to Judging (true), so no earliertruesignature can exist for it. - Malleability and encodings. The high-s twin of a valid signature is refused (OpenZeppelin
ECDSA), 64-byte compact signatures are refused, all-zero signatures are refused;withdrawalUsedis keyed by digest, so no other encoding of the same WithdrawTo could spend it twice (test_B_malleableSignatureRejected,test_B_compactSignatureRejected). - EIP-3009 at the real tokens (fork). Only the payee spends a receive authorization: USDC answers "FiatTokenV2:
caller must be the payee", USDT0 "TetherToken: to != msg.sender", so nobody can push the buyer's money into the
escrow outside the deal and strand it (
test_fork_B_usdc/usdt0_receiveAuthorization_onlyThePayeeSpendsIt). A receive signature is no transfer signature on either token, while a real transfer authorization works for any submitter (..._receiveSignatureIsNoTransferSignature). An authorization names one escrow; a second escrow on the same token can't spend it, and once spent the deal-ID nonce is gone (test_fork_B_authorizationNamesOneEscrow). The buyer can cancel its authorization before use (test_fork_B_buyerCancelsItsAuthorizationBeforeUse). Both real tokens accept an EIP-1271 wallet asfrom(test_fork_B_eip1271WalletFundsAndConfirms_realUsdc,test_fork_B_INFO_usdt0_eip1271WalletFunding: accepted). The nonce is the deal ID, which commits to every term and the proposer; funding measures the balance delta (InsufficientDeposit, existing test). - Front-running signed steps. A stranger submitting the buyer's funding first creates exactly the deal the buyer
signed for; the authorization can't fund other terms (
test_B_frontRunningAFundingSignature_createsTheSameDeal). A counterparty submitting the other side's signed Challenge or PayArbitrationFee with its own ETH becomes that fee's payer and gets only its own ETH back; the signer's deal money and ETH are unchanged whoever wins (test_B_counterpartySubmitsSignedChallenge_noLossToSigner,test_B_openerSubmitsSignedAnswer_noLossToSigner). Refunds-to-payer (SPEC §2 rule 2) make holding someone's fee signature worthless. - EIP-1271 edge cases.
SignatureChecker.isValidERC1271SignatureNow(OZ 5.4): staticcall, result >= 32 bytes, magic compare. A precompile named as referrer (identity, sha256) can't be made to approve a WithdrawTo: the identity precompile echoes selector||hash, never the bare magic (test_B_precompileCannotBeMadeToApproveAWithdrawal). The escrow itself or a token as referrer has noisValidSignature(reverts -> false).address(0)never holds a balance. - EIP-7702. ECDSA first, then 1271: a delegated EOA keeps signing with its key (no lockout), and nobody holds a
key for a real contract address. The known limit (Circle's token needs the delegate to implement 1271 for EIP-3009)
is in
docs/audit.md§5; the deposit address is the fallback. - ERC-6492. Nothing on-chain;
service/src/erc6492.tsdeploys only through the two Coinbase factories and only ifeth_callof the carrieddatareturns the signer's own address, withdryRunnever deploying; the signer is taken from the deal's role, not the request (app.tslines 945–963). Worst case: a factory view call (getAddress) that returns the signer makes the relayer send one pointless transaction (Arbitrum gas, cents). - WithdrawTo. Bound to account, chain, recipient, amount, deadline and escrow; one use by digest; existing
Deposits.t.soltests cover redirection. A chain the router doesn't serve reverts (NoRoute), USDC crosses by CCTP with no fee, USDT by the OFT withminAmountLD = amount, and a front-runner can't make a signed USDT withdrawal succeed with too littlemsg.value(WrongValue). Keeper payouts (payouts.ts): recipient = the signer itself, the amount fixed, stored privately, deadline a year pastacceptBywith a per-deal spread so two identical payouts don't collide; the keeper sends only after a close that credited at least the signed amount. - FeeVoucher. Only lowers (
FeeNotLower, so no replay), only open deals, only untilpromoEnds/expiry, domain-bound (cross-escrow test above;Promo.t.solfor the rest). The promo key can't raise a fee or touch the price. - ReferrerShare. Nonce, deadline, domain; applies to deals proposed afterwards (
ReferrerShare.t.sol, cross-escrow test above). - The judge has no signature surface (
msg.sender == judge), rules only inJudging, 0–2, before its deadline. - AdminSession (off-chain). Bound to the admin address, the site URL and the chain ID, 12 hours, checked against the allow-list on every request; a bearer replay inside those 12 hours is the design; it only reaches promo codes.
- Sponsored fees. A party can only make yescrow lose the AI-court fee it chose to sponsor (one per party per deal,
daily budget,
sponsor.ts); refunds come back to the relayer.
Unconfirmed
- ERC-6492 factory view trick (Info).
deployedSignatureaccepts a 6492 envelope whosedatais the Coinbase factory'sgetAddress(owners, nonce)(returns the signer's address, deploys nothing): the relayer sends one useless transaction, then the main step fails simulation. Not scripted (no local stack); cost is cents of Arbitrum gas. - AdminSession across stacks. If two services share
WEB_URLand chain ID (a preview and production), one session token works on both. Configuration-dependent; not checked against the live stacks. - Rate limit keying. Both Medium findings are bounded per IP only if
trustProxymatches the real proxy chain (app.tsline 105). If it doesn't, every relay shares one bucket: the attack is slower, but 20 relays a minute then also starve honest gasless steps. - Stale-signature window (B-4) in practice. The relayer simulates before queueing and sends with a 120-second
receipt wait, so a stale
Confirmneeds the state change to land between simulation and inclusion, or a client that asked for a longdeadline. Not measured against the live relayer. - Settlement links carry the signature in the URL (browser history, referrers). A third party can't use it (the other party's signature is needed too), so no loss; noted for hygiene.
- Two escrows on one token. The deal-ID nonce is per token, so the same buyer can't fund the same deal ID by signature at two escrows that share a token (proved on the fork). A curiosity, not a loss: nobody wants to fund the same terms twice, and the deposit address still works.
AI pentest, area C: proofs (RdapVerifier, DualRdapVerifier, Domains, RdapStatus, the template, the attestor patch)
Commit under test: 3ca0628 (main, 2026-10-29). Working copy:
the repository root.
Model: Claude (Fable 5.1), 2026-10-29.
Proofs of concept (all passing):
cd contracts
forge test --match-path test/pentest/C_InterProofBlocking.t.sol -vv # 6 tests, all pass
cd the repository root
node scripts/pentest/C_template_re2.ts # the template under RE2 "sgiu"
Files added: contracts/test/pentest/C_InterProofBlocking.t.sol, scripts/pentest/C_template_re2.ts. Nothing under
contracts/src/ was changed.
Summary: no way was found to make a proof pass for a fact the registry didn't state, to pass a proof with one attestor, to reuse a stale attestation, or to make the signed parameters say anything but the domain's own registry URL under yescrow's exact template. The two findings are griefing paths through which the party holding the domain blocks the other party's proof, which SPEC §6.3 says must not be possible through nameservers.
Findings
C-1. A 17th nameserver makes every InterRegistrar proof revert (the count cap is a revert, not the V3 "no hash")
- Severity: Medium (griefing: the seller who delivered is forced into a dispute, and loses the price if they miss
the transfer deadline; the same lever lets a seller lock a deposit-paying buyer's money until
acceptBy). It would be High if a registry among the 25 TLDs accepts 17 or more nameservers for a domain: see the precondition. - Attacker and preconditions: the buyer after delivery (InterRegistrar deal: they hold the domain), or the seller
before acceptance of their own proposal. The registry must accept 17 or more nameservers for the domain. The common
cap is 13, but it is an operational convention from the 512-byte DNS UDP limit, not a specification (RFC 5731 sets
none); I could not confirm the current cap of Identity Digital, Google Registry, CentralNic, Nominet (whose
gTLD platform runs
.now/.bot) or GoDaddy Registry (.co) from a primary source (see Unconfirmed U1). - Steps (buyer): the deal is Active with a passing "before" proof; the domain arrives at the buyer's registrar;
the buyer sets 17 nameservers (about 1.1 KB in the attestation, far below the 8 KB byte cap). The seller's "after"
proof, from both attestors, now reverts with
RdapVerifier.BadValue()inside_nameserversHash, whatever the seller does, until the transfer window ends;refundthen credits the buyer the whole price while they keep the domain. The seller's only recourse isdisputebefore the deadline (SPEC §6.5), paying the arbitration fee first. - Steps (seller): the seller proposes with
acceptBy30 days ahead, the buyer pays the deposit address (SPEC §4.1, no wallet needed), and the seller's domain lists 17 nameservers: no "before" proof can pass,acceptreverts, andreturnDepositrefuses the deal token untilacceptBy. The buyer's 5,000 USDC are locked for 30 days. - Impact (from the tests):
test_C1_seventeenNameservers_blockEveryAfterProof_buyerRefundedWithTheDomain: the buyer ends with 5,000 USDC back and the domain, the seller with 0.test_C1_sellerWithSeventeenNameservers_locks…: 5,000 USDC locked for 30 days.test_C1_sixteenNameservers_afterProofPassesandtest_C1_overTheByteCap_passes_soOnlyTheCountCapBlocksshow that 16 names pass and that a list over 8 KB passes withnameserversHash = 0, so only the count cap blocks.scripts/pentest/C_template_re2.ts(case 1) shows that the template, run as the attestor runs it (RE2, flagssgiu), extracts a 13-, 16-, 17- and 20-entry list unchanged, so both attestors sign such a record and the revert is the verifier's alone. - Proof of concept:
forge test --match-path test/pentest/C_InterProofBlocking.t.sol -vv(run from thecontractsdirectory of the working copy) andnode scripts/pentest/C_template_re2.ts(from the copy's root). - Root cause:
contracts/src/RdapVerifier.sol:319(if (n == MAX_NAMESERVERS) revert BadValue();in_nameserversHash), reached from_factsat line 170 for every proof whose nameservers capture is at mostMAX_NAMESERVERS_BYTES(line 148). SPEC §6.2/§6.3 and V3 promise that a long list "gives no nameservers hash" so an InterRegistrar proof can't be blocked; the count limit predates V3 and still reverts. The escrow (YescrowEscrow.sol:452) needs onlyregistrarId,domainHash,fetchedAt,expirationandstatusFlagsfor InterRegistrar deals. - Suggested fix: in
_nameserversHash, when the 17th"ldhName"is found returnbytes32(0)(the same outcome as a list over the byte cap) instead of reverting; an IntraRegistrar proof then fails as intended and an InterRegistrar proof passes. Alternatively count the names first and skip the hash aboveMAX_NAMESERVERS. Either is a one-line change in the verifier, which is a new deployment anyway (SPEC v0.24: deals keep their verifier).
Verification of C-1:
Verdict: DOWNGRADED to Low
Proof of concept:
forge test --match-path test/pentest/C_InterProofBlocking.t.sol -vv(fromC/contracts): PASS, 6/6. With 17 names, the "after" proof revertsBadValuein each half and insubmitProof. After the 10-day window,refundcredits the buyer 5,000 USDC (5_000e6) and the seller 0. The 16-name control passes. A 40-name list over 8 KB passes with no hash. A buyer who paid the deposit address gets 5,000 USDC back only atacceptBy(30 days). 16 × 253-character names cost 16,499,510 gas.node scripts/pentest/C_template_re2.ts(fromC/): PASS. It extracts 13, 16, 17 and 20 names unchanged (17 names: 1,148 bytes escaped).Sources unchanged: yes.
diff -rq -x out -x cache -x node_modules base Cdiffers only incontracts/test/pentest/,scripts/pentest/andREPORT-C.md.What I checked: - Root cause: correct.
RdapVerifier.sol:319reverts at the 17th"ldhName", and_facts(line 170) calls it whenever the capture is 8 KB or less (line 148). - Signatures: the PoC signs with the repo's test-only keys, on aDualRdapVerifierbuilt from twoRdapVerifiers deployed with those keys. That is production code apart from the attestor addresses. The revert happens in_facts, which reads only the context, so who signed doesn't matter. The context has the attestor's exact layout, and the Node script mirrors attestor-core 5.1.5's loop (RE2sgiu, no limits on captures). So both real attestors would sign this record, if a registry served 17 nameservers. - Test honesty: novm.storeorvm.etch; each prank is a party acting for itself; the mocks aren't on the path. The 16-name control shows the revert comes from the count, not from anotherBadValue. - Real fixtures (all 28): each has exactly one"ldhName"per nameserver object, and every capture parses as the nameservers array alone. Reaching 17 therefore takes 17 real hosts. - Precondition (U1): Google Registry's own code (NomulusDomainFlowUtils,MAX_NAMESERVERS_PER_DOMAIN = 13, "Only 13 nameservers are allowed per domain") covers.appand.dev. Registrars' TLD documentation lists 0–13 for .com, .net, .cc, .org, .io, .ai, .me, .live, .xyz, .art, .co and .fm (OpusDNS), and for .bot on Nominet's backend (GoDaddy). The other TLDs run on the same platforms. - Fix safety:YescrowEscrow._termsOk(line 834) requires a non-zero hash for IntraRegistrar deals, so returning 0 can't make an Intra proof pass.Reason: the defect is real. SPEC §6.3 and V3 promise that nameservers can't block an InterRegistrar proof, and the code breaks that promise. But no registry of the 25 TLDs lets a domain have 17 nameservers today: all cap it at 13, below the verifier's 16. The attack works only if a registry raises its cap above 16 (the verifier can't be changed) or a later verifier adds a TLD that allows more. That makes it a latent defect: Low.
Even if it were reachable, it wouldn't be High. The seller who delivered disputes before the deadline. They win by default if the buyer doesn't pay the fee, or before Kleros on the public RDAP record, and their fee comes back (SPEC §7.1, §7.2). That is griefing (Medium), the same class as V3 (Medium) and V5 (Low, "the seller disputes"). A buyer can already force that dispute by deleting the domain (the report's U4).
The seller path adds nothing new. Before acceptance the seller holds the domain and can make any "before" proof fail without touching nameservers: move the domain to the buyer's registrar, or start a transfer out (
pending transfer). A 30-day lock is the accepted remainder of E2.Fix: correct; also update SPEC §6.2 (over 16 names or 8 KB: no hash) and add a 17-name test.
C-2. The two attestations must agree on nameservers and "last changed" even for InterRegistrar deals
- Severity: Low (griefing with a timing window of seconds; the keeper retries, and the seller can dispute).
- Attacker and preconditions: the buyer after delivery, with a registrar that applies nameserver (or any) changes
to the registry quickly and often; the seller's prover runs the two attestor sessions concurrently (
prover/src/server.ts,Promise.all), 0–23 s apart on today's fixtures. - Steps: the holder keeps changing the nameservers (or anything that moves the domain's "last changed" event).
Whenever a change lands between the two sessions' fetches,
DualRdapVerifier.verifyreverts withFactsDifferalthough SPEC §6.2/§6.3 say an InterRegistrar proof "doesn't use" the nameservers hash, and the "last changed" event matters only for IntraRegistrar deals. Each half alone is a valid attestation of the domain at the buyer's registrar (test_C2_nameserverChangeBetweenTheTwoSessions_blocksTheAfterProofverifies both halves on their own verifiers, then shows the pair rejected; the same with only "last changed" moved). - Impact: the seller's proof fails for as long as the buyer keeps the record moving; the deal ends in
refundor a dispute. The practical window is narrow (the prover notes the retry itself inprover/src/facts.ts), so Low. - Proof of concept: same command as C-1 (
test_C2_…). - Root cause:
contracts/src/DualRdapVerifier.sol:35-38comparesnameserversHashandlastChangedfor every deal; the verifier doesn't know the deal's mode. - Suggested fix: compare only
domainHash,registrarId,expirationandstatusFlagsinDualRdapVerifier, and fornameserversHashandlastChangedreturn them only when both attestations agree (zero otherwise): an IntraRegistrar proof then still needs two agreeing attestations (no single attestor can set the Intra facts), and an InterRegistrar proof no longer depends on facts it doesn't use. Or add a second entry point the escrow calls for InterRegistrar deals. Service side, run the two sessions back to back from one prover and retry onFactsDiffer.
Verification of C-2:
Verdict: CONFIRMED (Low)
Proof of concept:
cd …/pentest/C/contracts && forge test --match-path test/pentest/C_InterProofBlocking.t.sol -vv: pass, 6/6, includingtest_C2_nameserverChangeBetweenTheTwoSessions_blocksTheAfterProof(6.12M gas). Each half verifies alone (registrarId 1068, the buyer's). The pair revertsFactsDifferfor a nameserver change, and again with only "last changed" moved. No tokens move: the 5,000 USDC stay locked in an Active deal (C-1's test shows the refund path). My checks:forge test --match-path test/pentest/verify/V_C-2_controlAndFix.t.sol --match-test test_V_C2 -vv: 3/3 pass.Sources unchanged: yes. The copy only adds
contracts/test/pentest/,scripts/pentest/andREPORT-C.md.src,script,lib,foundry.tomlandprover/srcmatch the base.What you checked: - The test runs the real
DualRdapVerifier/RdapVerifiercode. The repo's test-only attestor keys stand in for two honest attestors, which is fair here: the attacker needs no key, only a registry update. Novm.store/etch. The only prank is the seller's own proposal. Proofs are built beforevm.expectRevert, so the revert comes fromsubmitProof. Control (V_C-2 test 1): the same pair with onlyfetchedAt2 s apart passes to Proven, so the revert comes from comparing nameservers and "last changed". - Spec: §14.18 requires the two attestations to "agree on every fact exceptfetchedAt". That contradicts §6.3's stated reason for not checking nameservers. Not in audit.md §5; the closest is V5 (buyer-set data fails the proof, accepted at Low).prover/src/prove.ts:122already notes mid-change mismatches ("then try again"). - The window: in the rdap-dual fixtures, the two attestations are 0–1 s apart for 24 TLDs and 9–11 s apart for.now/.bot. In production,paced(server.ts:23) starts sessions to the same host 1 s apart, andoneAtATime(server.ts:32) runs zk TLDs one after the other. The keeper retries every 10 min (PROOF_RETRY_MS,service/src/config.ts:167) after a dry run (keeper.ts:217), so a failed pair costs nothing.Reason: Low stands. The Medium case (the seller forced to dispute) needs nearly every one of roughly 700–1,440 separate attempts in the transfer window to fail. The buyer would have to land a registry update inside each ~1 s window, which they can't observe, continuously for days: 10^5–10^6 updates through registrar and registry rate limits. Caching can widen one attempt's window to minutes (CentralNic
max-age=120; Google up to 1 h per server,docs/dependencies.md§4.4), but each retry is a fresh chance: the buyer only delays the proof. The seller can still dispute (§6.5).Corrections: 1. Precondition: "0–23 s" is how far the registry's "last update" timestamp lags behind the attestation (REPORT-C, "Checked" item 9), not the gap between the two sessions. The retry note is in
prove.ts:122–127, notfacts.ts. 2. Root cause: the line is right, but the contract implements SPEC §14.18 as written. The spec and its off-chain copy,combineFacts(prover/src/facts.ts:53), must change too, or the prover keeps rejecting pairs a fixed verifier would accept. 3. The suggested fix is incomplete. It still comparesstatusFlags, so a buyer can toggle the transfer lock between the two sessions where their registrar allows it. V_C-2 test 2: a pair that differs only inclient transfer prohibitedstill revertsFactsDiffer(same nameservers, "last changed" and expiration; no blocked status). Better: - require equality only fordomainHashandregistrarId; - setnameserversHashto zero unless both agree; - take the minimum oflastChangedand ofexpiration; - OR the twostatusFlags.Each check then still needs the honest attestation, and a single attestor can only add rejections. V_C-2 test 3 uses a sketch of this fix (
FixedDualForC2, not yescrow code). A pair where the nameservers, "last changed" and the lock all moved proves an InterRegistrar deal. Aclient holdseen by only one attestor is still rejected (ProofRejected(5)). Its service half (back-to-back sessions, retry) already exists.
Checked and found sound
Each line says what was examined and why it holds.
- Signature binding.
identifier = keccak256("http\n" ‖ parametersFor(domain) ‖ "\n" ‖ context), signed asidentifier\nowner\ntimestampS\nepochunder Ethereum personal-sign, exactlycreateSignDataForClaim/getIdentifierFromClaimInfoin attestor-core 5.1.4/5.1.5 (lib/index.js5691-5747; the owner is lowercased on both sides, the identifier is lowercase hex on both).ECDSA.recoverrejects high-s and badv, so a signature can't be mauled into another. The attestor is immutable; Reclaim's address inNetworks.RECLAIM_ATTESTORis the one the 26 real fixtures verify against;DualRdapVerifier's constructor refuses two verifiers with the same attestor. - Parameters rebuilt from the domain.
PARAMS_PREFIX+ base +domain/+ domain +"}(or the zk-write suffix for.now/.botonly) equalscanonicalize(claimParams(domain)): every real fixture's identifier recomputes from it on-chain (test_realProofs_everyRegistryAndTld) and off-chain (prover/test/template.test.ts). RFC 8785 key order (body, headers, method, responseMatches, responseRedactions, url, writeRedactionMode;User-Agentbeforeaccept) is what the prefix encodes.paramValues, a different URL, query string (?jscard=1fixture), another host (the registrar-RDAP fixture) or a different regex all change the signed string. The domain is validated byDomains.tldOf(lowercase LDH, one dot, labels 1–63 without edge hyphens, 25 TLDs) before any string is built, so nothing can be injected into the URL or the JSON. - Each TLD's registry URL. The 25 entries of
_rdapBasematch SPEC §6.1 andprover/src/template.ts; the index ranges (3,4,11–15,19–23 Identity Digital; 24.fm; 25.inc) matchDomains.tldOf. One live GET per host today (9 hosts) answered200application/rdap+jsonwithContent-Length, no*byte and no control byte in any body; the attestor checkshttps:, the exact path (no templates, so no*allowed in it), theHostheader, GET,Connection: close, and inprocessHandshakeverifies the server's certificate chain against the host name, so a route hijack alone can't substitute a server. - Context parsing.
_factsaccepts only{"extractedParameters":{…},"providerHash":"0x<64 lowercase hex>"}with the seven known keys in canonical order, none repeated, nothing trailing (tested: an extra top-level key, an unknown key, a repeated key, out-of-order keys, a short hash, trailing bytes all revert)._entryskips\xpairs exactly as JSON string syntax does, so a value can't end early or late;_unescapedecodes\" \\ \/ \n \r \tand reverts on\b \f \u(a raw control byte in a capture fails closed). The attestor buildsextractedParametersandproviderHashitself and canonicalizes the context before signing (assertValidProviderTranscript), so a prover's own context keys can only make the proof fail. - Field parsers.
_stringAfterrequires"key" : "plain string"with no escapes (fails closed);_uinttakes 1–9 digits;_eventDaterequiresYYYY-MM-DDTHH:MM:SS[.frac]Z, 1970 ≤ year ≤ 9999, month 1–12, day 1–31, second ≤ 60, and the repository fuzzes the round trip; fractional seconds as sent by CentralNic (.0) and Nominet (nanoseconds) parse;+00:00, missing seconds, lowercasezand a 5-digit year revert._daysFromCivilis Hinnant's algorithm._statusFlagslowercases each string and maps the 23 RFC 8056 values; unknown values are ignored. - Template versus Solidity (first match is the only match). Inverted "appears twice" regexes cover the IANA ID,
expiration, last changed, last update of RDAP database and nameservers; blocked statuses are refused anywhere in the
response. JSON string values can't contain an unescaped
", so holder-controlled text (vCard fields, remarks) can't form"eventAction","IANA Registrar ID","ldhName"or a status string; nameserver names are LDH. Theiflag and RE2's Unicode case folding only widen what the attestor refuses; Solidity's case-sensitive key lookups ("eventDate","identifier","ldhName") fail closed on exotic casing. Where yescrow's attestor falls back to JavaScript'sRegExp(no RE2),\sis wider and case folding narrower than RE2's: both directions only add refusals. - Layouts of the live responses (9 hosts, today). The first
"ldhName"is the domain's in every layout (Verisign at byte 63, CentralNic and.coat about 180, Google at 1489 afterentitiesandevents, Identity Digital, PIR and Nominet at 3.7–4 KB afternotices); no entity, event, link or notice carriesldhName. The top-level status array is followed byentities/port43/noticeswhere it is captured (Nominet's isn't:statusFlagsis 0 there, and a blocked status is still refused by the inverted regex).nameserversis followed bysecureDNSorrdapConformanceeverywhere. Each unique field appears once; no nested object carries events except Nominet's nameserver objects (registrationonly today, see U2). - Full reveal.
FULLY_REVEALEDneeds theHTTP/1.1 200line, header lines without*except anaccess-control-…: *, and a body without*ending in}plus whitespace; attestor-core ≥ 3.2.0 matches it over headers + de-chunked body, and an older client's body-only string (with*runs deleted) can't start with the status line. The hardened attestor additionally refuses clients below 3.2.0, server records not revealed by their key, and incomplete HTTP messages; the patch applies cleanly to attestor-core 5.1.4 (patch --dry-run).assertTranscriptsMatchcompares the client's server bytes as a prefix of the tunnel's, so a prover can truncate the tail of a response at Reclaim's attestor but can't drop or reorder a record in the middle (byte equality plus per-record AEAD with sequence numbers); a truncated response fails the hardened attestor'sContent-Length/final-chunk check, and both attestors are required. - Timestamps and freshness.
fetchedAtis the registry's ownlast update of RDAP database, fresh within 0–23 s of the attestation time on all 26 fixtures (Verisign and Nominet lag 11–23 s; the rest 0–1 s). The escrow requiresnotBefore < fetchedAt ≤ nowandexpiration ≥ fetchedAt + 30 days;DualRdapVerifierreturns the earlierfetchedAt, so both attestations post-date the proposal (before) or the activation (after). One compromised attestor therefore can't forge a fact (the honest half must state it), can't resurrect an old honest attestation (the minimum is the old time), and can't help a stale record pass. Registry clock skew only delays or fails proofs. - Unicode, IDNs and escapes. Terms carry A-labels (
xn--passestldOf); Verisign's uppercaseldhNameand nameservers are lowercased before hashing and comparison; non-ASCII appears only inunicodeName, outside the captures or (inside nameserver objects) ignored by_nameserversHash; CentralNic's and.co's\/escapes decode; a\uescape or raw control byte in a capture reverts. A registry answering a different name, a trailing dot or a U-label failsDomainMismatch. - Gas. A dual proof with the costliest list the count cap allows (16 names of 253 characters) costs 16.5M gas in
submitProof(test_C1_gas_sixteenLongestNames_dualProof), under Arbitrum One's 32M; a list over 8 KB isn't hashed (10.8M for 40 long names). Nothing else in the context is holder-controlled, so no other field inflates the cost. - Replay and binding. A proof is public data and binds no deal, escrow or chain by design; the time checks stop stale reuse, a "before" proof can't serve as an "after" proof (registrar inequality) or the reverse, and two deals on one domain at the same registrar are the documented account gap (SPEC §6.5), not a verifier issue.
- Escrow checks.
acceptandsubmitProofcall the allow-listed verifier through a STATICCALL, compare the domain hash with the terms, check the registrar inequality (before) or equality (after), the blocked bits, the expiration margin, and for IntraRegistrar the nameservers hash andlastChanged > activatedAt;proposeasks the verifiersupportsDomainafter the allow-list check. - Deployment of the verifiers.
DeployAlldeploys Reclaim's verifier withNetworks.RECLAIM_ATTESTOR, refuses aYESCROW_ATTESTORthat is zero or equal to it, and the dual verifier needs both: SPEC §14.18 as promised.
Unconfirmed
- U1. Registry nameserver caps (C-1's precondition). The 13-nameserver cap is a convention from the 512-byte UDP
limit, not a specification; registries set their own (Norid 8,
.lv5, DENIC 13). I found no primary source for the current caps at Verisign, PIR, Identity Digital, Google Registry, CentralNic, Nominet's gTLD platform or GoDaddy Registry. If any of the 25 TLDs' registries accepts 17 or more, C-1 is reachable today; the fix costs one line either way. - U2. Nominet host-object events. Nominet embeds each nameserver object's
eventsin the domain response (registrationtoday). If a modified host object gets a"last changed"event, as the RDAP profile allows, the domain response carries two "last changed" events and the inverted regex makes both attestors refuse the whole response (scripts/pentest/C_template_re2.ts, case 2 shows the refusal on a Nominet-layout response). A buyer with an in-bailiwick host at.now/.botcould then block the seller's proof; a seller could block the "before" proof. Whether Nominet emits that event for hosts is unverified. - U3. A registrar whose entity repeats "last changed".
docs/security.mdnotes a MarkMonitor response the attestor refuses for a repeated "last changed". None of today's nine sampled domain responses embeds entity events, so I couldn't tell at which registry that happens. Where it does, a buyer who names that registrar makes every "after" proof impossible at that registry (the seller must dispute), which the pre-flight checks don't cover. - U4. Deleting the domain after delivery. A buyer who deletes the just-received domain puts it in
pending delete/redemption period(blocked, check 5), so the seller can't prove and must dispute; the buyer restores it for the registrar's restore fee. This is the SPEC §6.5 "delivered but can't prove" path, so not reported as new. - U5. Truncated transcripts at Reclaim's attestor. Reclaim's attestor accepts a server transcript that stops short,
and the holder controls the response length (nameserver names), so in principle a TLS record boundary can be placed
right after a
}so thatFULLY_REVEALEDstill matches a cut body; only the hardened attestor's completeness check stops it. The protection rests on yescrow's patch alone, and nothing hides from a complete response anyway. - U6. Empty nameserver list in IntraRegistrar terms. A deal whose
nameserversHashiskeccak256("")is "proven" by a domain whose nameservers array is[](the hash of an empty join is non-zero). It needs the buyer to sign such terms, so social engineering only (Info). - U7. Pipelined second request. After the first request's blank line the attestor treats the rest of the client
transcript as a body it doesn't check (the template's body is empty). A registry that ignored
Connection: closewould append a second response; first-match semantics and the inverted regexes leave nothing to gain, and no registry was tested for it. - U8. RE2 versus JavaScript
RegExp.\s(ASCII in RE2, Unicode in JS) and simple case folding (K,ſ) differ between the two attestors' engines; only refusals can result, and no registry output contains such characters today.
Pentest report, area D: Disputes (Kleros resolver, refusal, applyRuling, the 90-day stall, yescrow's judge, fee paths)
Commit under test: 3ca0628 (SPEC v0.30, 2026-10-29). Fork tests run on Arbitrum One at block 519,194,000 (the block
test/fork/ArbitrumOneMainnetFork.t.sol pins), against the real KlerosCoreNeo 0.10.0 (0x991d…22ea), the real public
DisputeResolver (0xb552…4323), DisputeKitClassicNeo 0.12.0 (0x70B4…5421), SortitionModuleNeo 0.9.0 and the real USDC,
with the escrows configured exactly as script/DeployAll.s.sol configures them (mainnet juror template included).
Proof files (all pass, 20 tests): contracts/test/pentest/D_AppealOverride.t.sol, D_KlerosReads.t.sol,
D_FeePaths.t.sol. Run from …/pentest/D/contracts with the shared wrapper, e.g.
cd contracts
forge test --match-path "test/pentest/D_*.t.sol" -vv
No file outside contracts/test/pentest/ was changed. No transaction was sent to any live network (reads only: cast call
at the pinned block, and Foundry's cached fork).
Findings
D-1 (High): the losing side of a Kleros vote flips the ruling by funding an appeal alone; yescrow neither tells the winner nor lets it respond, and the flip costs the attacker only gas
Severity: High. A party is forced into a loss (the whole price) under plausible conditions: the winner has to notice, within 36 h (AI court) or 108 h (General Court), an appeal funding that yescrow's deal page, alerts and keeper never mention, and counter it with ETH from a wallet that, for the deals yescrow sponsors, has none.
Attacker and preconditions: the buyer or the seller, after Kleros's jurors voted against it. Needs
3 × appealCost in ETH for the length of the appeal period (AI court: 0.00567 ETH ≈ $13; General Court, 1 juror:
0.045 ETH ≈ $106), which it gets back in full. The other party must not counter-fund 2 × appealCost before the appeal
period ends. Nothing about yescrow tells it to.
Steps (as proven on the real Kleros, test_D1_appealFundedByTheLoserAlone_flipsTheRuling_forFree):
1. A 1,000 USDC deal, the seller disputes, the buyer answers: a real case in court 34 through Kleros's resolver.
2. Three real stakers of court 34 are drawn (the sortition module's RNG is stubbed on the fork), commit and vote 3–0
"the seller delivered" (ruling 1). Control branch: nobody funds an appeal → execution → applyRuling → Released,
the seller is credited 990 USDC + its fee.
3. Attack branch: in the first half of the appeal period the buyer calls DisputeKitClassic.fundAppeal(disputeId, 2)
with 0.00567 ETH. The seller does nothing. No new round is created (the other side never funded).
4. The appeal period ends; passPeriod → execution. Kleros's currentRuling(disputeId) now returns (2, false, true):
the buyer's choice, overridden = true.
5. Anyone, in practice yescrow's keeper (service/src/keeper.ts:97-103 calls executeRuling then applyRuling as
soon as the period is 4), calls applyRuling. The escrow reads word 0 and refunds: the buyer is credited the full
1,000 USDC plus the held arbitration fee; the seller, who delivered per the jurors, gets 0 and loses its fee.
6. After executeRuling, the buyer calls withdrawFeesAndRewards(disputeId, buyer, 0, 2) and gets its whole
0.00567 ETH back (Kleros reimburses a one-sided funding). The flip cost gas.
Impact: the whole price moves against the jurors' verdict for free. In the AI court, where yescrow pays the fees
of parties with no ETH (SPEC §14.17), the honest winner typically has no ETH to counter-fund even if it noticed. An
attentive, funded winner is still forced to stake 2 × appealCost (refundable only if it wins the next round) every
round the loser escalates. Note the loser has the first half of the appeal period to fund, so the winner may be left
with 18 h (AI court) to react.
Proof of concept:
forge test --match-path test/pentest/D_AppealOverride.t.sol -vvv
Logs: control seller credited USDC 990000000; attack appeal stake locked 5670000000000000 wei,
buyer credited 1000000000, seller credited 0, buyer's ETH balance unchanged after withdrawing the stake.
Root cause: Kleros's classic dispute kit overrides the final ruling when exactly one side fully funds an appeal
(DisputeKitClassicBase.currentRuling, returned overridden); YescrowEscrow.applyRuling takes word 0 only
(contracts/src/YescrowEscrow.sol:606-609) and pays on it, which matches what Kleros's executeRuling would do, but
yescrow treats that as "the jurors decide" (SPEC §7.2 line 270: "Kleros handles appeals itself, and the contract only
acts on the final ruling"; §12 "Its jurors decide disputed deals"; docs/audit.md §3 "No party can force the other
into a loss by withholding … ETH"). Nothing in the product covers appeals: grep -ri appeal service/src web/components
finds no handling; web/components/deal/Dispute.tsx:71-77 tells the parties "Jurors answer one question … The winner
gets their fee back"; service/src/alerts.ts:209-210 alerts only on Ruling and Judged; watcher.ts indexes no
dispute-kit event (Contribution, ChoiceFunded).
Suggested fix:
- Contract (next version): read word 2 (overridden) of currentRuling in applyRuling; if set, do not pay on it:
hand the deal to yescrow's judge (_toJudge), who rules on the evidence and the jurors' votes within 14 days, or
pay on the jurors' winningChoice instead. Either stops money alone from deciding a yescrow deal.
- Product (now, for the deployed version): index the dispute kit's Contribution/ChoiceFunded events for the deal's
case; alert the other party at once ("the other side funded an appeal: fund yours by <deadline> or the ruling flips
to them"), show the appeal period and a fund button on the deal page, and consider sponsoring the counter-funding in
the AI court (the winner's stake comes back with the loser's when it wins the round). Document the mechanic in SPEC
§7.2/§13.5 and in the dispute panel's text.
Verification of D-1:
Verdict: CONFIRMED (High stands)
Proof of concept:
forge test --match-path test/pentest/D_AppealOverride.t.sol -vvv: PASS. Control: seller credited 990 USDC. Attack: the buyer stakes 0.00567 ETH and gets 1,000 USDC plus its fee; the seller gets 0; the stake comes back.Sources unchanged: yes. Only
contracts/test/pentest/, an emptyscripts/pentest/andREPORT-D.mddiffer.What you checked: - The test. The only stubs are the sortition RNG (
vm.etch, for drawing only) and the drawn real stakers' pranked votes (the precondition). Apart fromdealfunding the parties, nothing is written into Kleros, USDC or the escrow. The attack is the buyer's ownfundAppeal, then calls anyone can make. - The deployed code at the fork block: the dispute kit 0.12.0 and core, from Kleros's deployment files; bytecode matches the chain except__self. -currentRulingreturns the only funded choice at execution. -withdrawFeesAndRewardsrepays that side in full (feeRewards == paidFees). -executeRulingpasses the same ruling to the resolver. -isAppealFundedends the period early only when no side funded. - My tests,test/pentest/verify/V_D-1_appealWindow.t.sol, all 5 pass: - Loser funds in the last second of its half: the period can't be ended early, so the winner keeps 64,801 s (18 h) to stake 0.00378 ETH. - Anyone counters in the last second: a new round starts andapplyRulingrevertsTooEarly. - Reverse direction: a seller found not to have delivered funds alone and gets 990 USDC; the buyer gets 0. - Winner's side funded early, unopposed: Kleros returnsoverridden = truewith the jurors' own choice. - Docs and product: SPEC, audit §3 and §5,security.md, the keeper, the alerts and the deal page.Reason: - Not a known issue. SPEC §7.2 hands appeals to Kleros, so the contract matches its spec. But no document explains the one-sided override (
dependencies.mdgives only stakes and deadlines), and audit §5 is silent. The override also undoes §14.17's purpose (no loss for lack of ETH), because appeals aren't sponsored. - "Forced" is overstated for the first appeal. The deal page links to the Kleros case, and anyone may counter-fund. High holds as "a loss under plausible conditions": - nothing tells the winner it won the vote or that an appeal was funded; - parties yescrow sponsors hold less than A + 0.0001 ETH; - trying is free for an unopposed loser. - The second appeal comes close to "forced". It jumps to court 33, and the winner must stake 0.54 ETH (≈ $1,345) against the loser's refundable 0.81 ETH, more than many AI-court deals are worth. - The reverse direction is a false release that depends on the buyer doing nothing. That keeps it High, not Critical.Corrections: - Line references: the root cause is at
YescrowEscrow.sol:603–609. The panel text is atDispute.tsx:78–84. "Nor lets it respond" is wrong. - Contract fix, option "pay on the jurors'winningChoice": drop it. Kleros opens a round only when both sides fund, so the first-round winner could block every appeal and with it the human jurors §7.1 promises. - Contract fix, any check: it must compare the ruling with the jurors' choice, not testoverriddenalone. Otherwise defended cases go to the judge. - Contract fix,_toJudge: it lets any loser send any case to yescrow's judge for gas only: the user's trust decision. - Deployed version, strongest fix: the relayer funds the winner's side when the appeal period opens. That costs 0.00378 ETH, which comes back whole if nobody opposes and doubles if the next round confirms the ruling. It removes the race, at 36 h instead of 18 h per case; add alerts and a deal-page notice.
D-2 (Info): on a refused case, yescrow is paid more for letting its judge's deadline pass than for ruling
SPEC §7.5 says "a deal yescrow's judge rules on pays no fee, so yescrow gains nothing from either verdict", and §8's
rows agree: a ruling earns yescrow the loser's arbitration fee A (0.005 ETH ≈ $12 General, 0.00081 ETH ≈ $2 AI court)
and no fee on the price. But §8's "Refused case, judge's 14 days pass" row charges F on the seller's half: yescrow
earns 0.5% of P. Above P ≈ $2,400 (General Court) yescrow is paid more by not ruling; on a $50,000 deal $250 against
$12. Not a loss for a party beyond the specified outcomes, and the judge is yescrow's own; an incentive note.
test_info_judgeTimeoutPaysYescrowMoreThanRulingOnLargeDeals (ruling: A wei, 0 USDC; timeout: 25 USDC on a 5,000 deal).
Fix: charge no fee on the judge-timeout split of a refused case too (or send it to the parties), so the judge has no
reason to be slow.
Verification of D-2:
Verdict: CONFIRMED (Info stands), wider than reported
Proof of concept:
cd …/pentest/D/contracts && forge test --match-path test/pentest/D_FeePaths.t.sol --match-test test_info_judgeTimeoutPaysYescrowMoreThanRulingOnLargeDeals -vvv: PASS. On a refused 5,000 USDC case: - When the judge rules, the fee address gets 1e13 wei (the mock's A) and 0 USDC. - When the judge lets the 14 days pass, the fee address and the referrer together get 25,000,000 (25 USDC). That is the fee F on P/2.My extra tests,
contracts/test/pentest/verify/V_D-2_scope.t.sol, all pass (3/3): - Stalled case: a ruling pays yescrow 0 ETH and 0 USDC. The timeout pays it 18.75 USDC, plus 6.25 USDC to the referrer. - Refused case: of the PoC's 25 USDC, yescrow's own address gets 18.75 and the referrer gets 6.25. - Refused case where yescrow's relayer paid the loser's fee (§14.17): a ruling only returns the A that yescrow spent (net 0). The timeout returns it too and adds F on P/2.Sources unchanged: yes. Apart from
out/andcache/,Ddiffers frombaseonly bycontracts/test/pentest/, an emptyscripts/pentest/andREPORT-D.md.What I checked: - The code:
judgeRulingzeroes the fee (YescrowEscrow.sol:634-635).judgeTimeout(642-646) closes at P/2 without zeroing it, so_closecharges the deal's fee (1016). - The PoC: - The only cheats are the judge's own call (vm.prank(judge)),vm.warptorulingDeadline, andarb.setRefusestanding in for a Kleros refusal. - The fee arithmetic doesn't depend on the mock, and the assertions pass for the reason stated. - Spec, tests and known issues: - SPEC §8 specifies this on purpose. Line 327 gives the refused timeout as P/2 − F + A. Line 330 says the stalled timeout "pays as ruling 0", which includes F. -Judge.t.solasserts both. Its comment "yescrow didn't judge, so it keeps nothing" is true only for ETH. -docs/audit.md§5 doesn't list this. - §4 of the same file anddocs/security.mdsay the judge "can't charge a fee". That is true of its ruling, but when it doesn't rule, a fee is charged. - Mainnet A: 0.005 ETH in the General Court (1 juror) and 8.1e14 wei in the AI court (docs/dependencies.md§1.8).Reason (severity unchanged): - Info is the right severity: - The timeout split is a specified outcome, and no deadline is missed. - A judge who wants a party to lose can already rule against it, which
docs/audit.md§5 accepts. This gives the judge a motive, not a new power. - It does undercut the reason §7.5 gives for the no-fee rule ("yescrow gains nothing from either verdict"), and a deployed escrow can't change.Corrections: - It isn't limited to "P above ≈ $2,400 in the General Court": - On a stalled case, or a refused case where yescrow paid the loser's fee, a ruling nets yescrow nothing, so the timeout pays more at every price. - On refused AI-court deals (A ≈ 0.00081 ETH), the timeout pays more from about $390, at the report's ≈ $2,400 per ETH. - The 25 USDC includes the referrer's 25% share. yescrow keeps 0.375% of P, so with a referrer the General Court threshold is about $3,200. - The fix should cover the stalled-case timeout too: - In
judgeTimeout, setd.promo = true; d.promoFeeBps = 0;before_close, asjudgeRulingdoes. - SPEC §8: the refused timeout pays each side P/2 + A, and the stalled timeout pays as ruling 0 without F. - Update the two timeout tests inJudge.t.sol. - Make the change before the mainnet deployment. For an escrow already deployed, the only remedy is a commitment that the judge always rules within the 14 days.
D-3 (Info, documented): a Kleros fee rise of up to 2× between opening and answering comes out of the winner's refund
heldFee = pot − cost (YescrowEscrow.sol:548): if Kleros raised the court's fee to at most 2A after the opener
paid, the case opens and the winner gets back 2A − cost instead of the A that SPEC §8 promises (security.md "Kleros
fee change between opening and joining" states this). Only Kleros's governor can cause it. test_info_klerosFeeRaisedAfterOpening_winnerGetsBackLessThanA
(fee +50%: the winner gets A/2 back). Fix: none needed on-chain; state it in SPEC §8 as well.
Verification of D-3:
Verdict: CONFIRMED (Info stands)
Proof of concept:
cd …/pentest/D/contracts && forge test --match-path test/pentest/D_FeePaths.t.sol --match-test test_info_klerosFeeRaisedAfterOpening_winnerGetsBackLessThanA -vvvv: PASS. The trace: - The buyer challenges with A = 1e13 wei; the cost is then set to 1.5e13; the seller answers with 1e13. - The escrow sends 1.5e13 to Kleros; case 100 opens withheldFee= 5e12. - Ruling 1: the seller is credited 5e12 wei (A/2) and 4,950 USDC; the buyer gets 0. - Kleros holds 1.5e13; the escrow's ETH equals what it owes.My extra tests,
contracts/test/pentest/verify/V_D-3_feeChangeRange.t.sol, pass (3/3): - Cost exactly 2A: the case still opens, the held fee is 0, the winner gets nothing back. - +50% with ruling 0: each payer gets A/4. - Cost lowered to A/2: the winner gets 3A/2.Sources unchanged: yes. Apart from
out/andcache/, onlycontracts/test/pentest/,scripts/pentest/andREPORT-D.mddiffer betweenbaseandD.What I checked: - The code (
YescrowEscrow.sol:519-562): - The answer must equald.arbitrationFee(525). - Ifcost <= 2A, the currentcostgoes to Kleros (533, 543) andheldFee = pot − cost(548). - Otherwise: refused, to the judge, both fees held (558-561). -_close(1033-1044) paysheldFeeto the winner's payer, or 50/50. - Who can changecost: only Kleros's governor. -arbitrator,resolverandsmallDealMaxPriceare immutable; both extra-data values are set only in the constructor (342-343). - Kleros's cost isfeeForJuror × minJurors, which only the governor can change (changeCourtParametersor an upgrade,docs/dependencies.md§1). - The mainnet fee period is 72 h. - Realism: novm.store/vm.etchor forged signatures;arb.setCoststands in for the governor's fee change; the pranks are only the two parties' own dispute and answer. - Known issue: -docs/security.md"Kleros fee change between opening and joining" (lines 108-110) describes it exactly as it behaves: the current cost is forwarded,2A − costis kept for the winner, and above 2A the deal goes to the judge. - The project's ownTransitions.t.soltesttest_payArbitrationFee_costRiseWithinPotShrinksHeldFeealready assertsheldFee == A/2at +50%. -docs/audit.md§4 says Kleros can "change fees", but §5 doesn't list this. - SPEC doesn't state it: §7.1 (line 252) says the escrow "forwards one fee … the other fee is held to refund whoever wins", and §8's rows promise "+ A".Reason (severity unchanged): Info is right. - Only Kleros's governor can cause it (already in the trust table), and it has at most 72 h. - The loss is capped at the winner's own A (≈ $1.91 AI court, ≈ $11.79 General Court at the pinned fees). - The money goes to Kleros as its published fee, not to an attacker; the loser loses its A either way. - No party can trigger it; an answerer can only time its answer around an announced change (worth less than A).
Corrections: - Not only the winner bears it: after ruling 0, a settlement after the case, or a stalled case's 50/50 timeout, each payer gets (2A − cost)/2. - It works both ways: if Kleros cuts its fee, the winner gets more than A (part of the loser's fee). SPEC §8 doesn't say that either. - Fix, docs only (the contract is immutable): - SPEC §7.1, line 252: say the escrow "forwards the court's current cost (at most both fees); the rest is held for the winner, or split". - SPEC §8: a note under the table. -
docs/audit.md§5: list it, so auditors don't report it again. - Optionally, qualify "the winner gets their fee back" inweb/components/deal/Dispute.tsx:81andweb/app/(site)/guides/disputes/page.tsx(lines 28, 57, 61).
D-4 (Info, docs): docs/keys.md still lists rule (msg.sender == address(arbitrator)) in its §2 and §3 tables
Since v0.30 the escrow implements no rule (Kleros rules on the resolver; applyRuling reads the core). The rows at
docs/keys.md:76 and :110 are stale; the claims ("KlerosCore only; nothing") are still true in spirit. No test.
Checked and found sound
Each item names the proof (D_… = this area's files) or the review that supports it.
- Opening cases through Kleros's public resolver. The real resolver's
_createDisputecalls the core first and returns the core'sarbitratorDisputeID(verified against the kleros-v2 source and empirically: with the resolver's local counter pushed one ahead byvm.store, the escrow stored core ID 328 while the resolver's local ID was 329, andkleros.disputes(328).arbitrated == resolver,dealOfDispute[328] == dealId). The existing mainnet fork test could not tell the two apart because all 328 mainnet cases came through the resolver, so the IDs coincide.D_KlerosReads.t.sol: test_sound_resolverReturnsTheCoreId_localIdsDiverge. - Evidence group. The resolver's
DisputeRequest._externalDisputeIDis its local ID (329 in that test), which the service records asevidenceIdfrom the same transaction (watcher.ts:evidenceIdOf) and files evidence under (api/app.tssubmitEvidence). Correct, and it would stay correct once the IDs diverge. - Refusal detection and gas starvation. On the real resolver, the least gas with which the answer passes
GasTooLow(5,348,481 for the call, buyer answering, 1,000 USDC deal) still opens the real case in court 34: no gas budget sends a case Kleros would take to the judge (test_sound_leastGasAnswer_stillOpensTheRealCase). With the real 1,428-byte mainnet template in storage, at the least passing gas (5,341,101) the resolver receives 5,001,470 gas at entry, i.e. the whole 5M budget plus the call stipend (D_FeePaths: test_sound_gasFloor_realTemplate_resolverGetsTheWholeBudget), so the 100k margin inARBITRATOR_GAS_NEEDEDcovers the escrow's own work between the check and the call. A real case needs about 655k. A resolver or core that burned all forwarded gas still leaves the escrow 1/64 (≈81k after the 5M call, ≈15k after_readinapplyRuling), enough for the refusal (≈50k) or stall (≈7k) bookkeeping (reviewed). - The resolver pointing at another arbitrator.
_read(resolver.arbitrator())must equal the immutablearbitratorexactly: another address, dirty high bits (test_sound_resolverArbitratorWord_mustMatchExactly), no code or a short answer (existingHardening.t.sol) all count as a refusal with nothing sent anywhere. The real resolver'sarbitrator()is the core, its governor is Kleros's EOA, no proxy (cast at the pinned block). _readon the real core. Word 2 ofdisputes(id)is the period (4 at executed cases 300 and 310), word 0 ofcurrentRuling(id)the ruling (2 and 1 there), an unknown ID reverts at the core (panic 0x32) and reads as not ok (test_sound_readWordsMatchTheRealCore). Malformed answers: one word fordisputes(no period), period 4 with an emptycurrentRuling, a 96+-byte revert reason, all read as "not ruled" and only reach the judge after 90 days; a ruling getter cut to one word still applies; a ruling above 2 splits 50/50 (D_FeePaths: test_sound_read_*). The assembly is memory-safe (scratch past the free pointer,returndatasizechecked before the copy,oknever set back to 1 after a revert).- A case ID that isn't the deal's.
d.disputeIdis written only from the resolver's return in the same transaction; a refused case never entersDisputed, soapplyRulingcan't read ID 0 for it (reviewed). - Appeals and the execution period.
fundAppealis refused outside the appeal period; a two-sided funding creates a new round and the period returns to evidence, so period 4 is only reached when no appeal can follow;currentRulingat period 4 is whatexecuteRulingwould apply (observed on the fork in D-1's control branch). - The 90-day stall.
rulingDeadlineis set when the case is created;applyRulingbefore it revertsTooEarlyunless the case is at execution; a final ruling after 90 days still wins (existingJudge.t.sol); once inJudginga late Kleros ruling is a no-op (test_sound_judgingRaces…). Judging(value 10, appended). Every comparison onStatewas reviewed:applyFeeVoucherexcludes it (> Disputed),_openDealincludes it explicitly,settlelists it,_depositCanFundonly looks atNone/Proposed. No path leads back fromJudgingexcept to a final state.- yescrow's judge.
judgeRuling: onlyjudge, onlyJudging, only beforerulingDeadline, rulings 0–2, once; money only through_closeto the deal's payees, no fee (promo = true, promoFeeBps = 0set first), the loser's A (half of each on a split) tofeeRecipientonly on a refused case (bothFeesHeld); the judge itself is never credited (invariant_judgeIsNeverPaid).judgeTimeout: anyone, at or after the deadline, 50/50. The judge's powers matchdocs/keys.mdanddocs/audit.md§4. ExistingJudge.t.solplustest_sound_spec8_judgeRows. - Races. At
rulingDeadline − 1the judge rules and the timeout reverts; at the deadline the reverse; a settlement works at any moment inJudginguntil someone splits; a concession that lands first makes the judge's ruling and the timeout revert with no money moved (test_sound_judgingRaces_deadlineBoundary_concession_settlement). All entry points arenonReentrantand state-guarded, so only one closing transition can ever succeed. - Every fee path of SPEC §7.5 and §8 with exact amounts, including sponsored fees: fee timeouts, rulings 1/2/0,
concessions after the case, settlement after the case, judge rulings on refused cases (seller: P + A, buyer: P + A,
split: P/2 + A/2 each, yescrow A), the refused timeout (P/2 − F + A / P/2 + A), a refused case conceded (both fees
back whole), stalled rulings (yescrow keeps nothing, the held fee to the winner) and the stalled timeout (as ruling
0). After each sequence the escrow's ETH and token balances equal exactly what is owed; one fee per created case sits
with Kleros, none for refused cases (
test_sound_spec8_*; existingJudge.t.sol,SponsoredFees.t.sol). - Signatures. A
PayArbitrationFeesignature works only for its deal (InvalidSignatureon another deal of the same parties), only inAwaitingFee(WrongStateafter a refusal), and not as any other action (typehash); a settlement signed before the case can't be used after it and vice versa (existingHardening.t.sol); the samecaseCreated = truesignature is valid inDisputedand inJudging-after-stall, which pay identically. The opener may carry the other party's signed answer with its own ETH: it then holds both fee-payer slots and only ever gets its own ETH back, the signer loses nothing (test_sound_openerSubmitsTheOthersSignedAnswer_noLossToTheSigner). - Judged deals pay no fee; a refused case keeps the loser's fee including a sponsored one (existing
test_refused_sponsoredFees,test_sound_spec8_judgeRows): a sponsor's fee that loses goes tofeeRecipient, one that wins goes back to the sponsor, as SPEC §7.5 says. - Deployment settings (
Networks.solagainst the chain at the pinned block, bycast): resolver0xb552…4323on the allow-list and pointing at the core, courts 34 and 1 with dispute kit 1 only, costs 8.1e14 and 5e15 wei, court 34 periods 600/2700/1800/129600 s with hidden votes, court 1 280800/583200/583200/388800 s;DeployAllrefuses a judge that is the deployer, the fee wallet or not a contract on mainnet, andCheckDeploymentsimulates a case from the escrow's own address. - Kleros's governor and resolver key can only do what
docs/keys.mdlists: refuse (allow-list, arbitrator change, court change, cost) and decide disputed deals; the resolver'sruleis never read by the escrow.
Unconfirmed
- U-1, stalling to the judge by sustained appeals (design). A party that keeps funding appeals can push a General
Court case past 90 days (each round ≤ 19.5 days; loser stakes 0.045, 0.105, 0.225, 0.465, 0.945 ETH for the next
rounds), after which
applyRulinghands the deal to yescrow's judge and the Kleros case, with everyone's appeal stakes still at risk there, no longer counts for the escrow. The judge's 14-day timeout is a 50/50 split, so a sure loser who can afford ≈ 1.8 ETH buys a chance at half the price if the judge is slow or wrong. By design (SPEC §7.5), but the keeper callsapplyRulingat the 90-day mark even when a final ruling is minutes away; consider letting the keeper wait while a case is at the appeal or execution period. - U-2, sponsorship griefing (service, off by default).
sponsor.tssponsors one fee per party per deal in the AI court for any wallet short of ETH, with no minimum price and no way to see that buyer and seller are one person. Two empty wallets could create 1-unit deals, dispute and answer with yescrow's ETH, settle withsellerAmount = 0(no fee), and repeat untilSPONSOR_DAILY_WEIis spent (≈ A ≈ $2 per deal plus relayed gas).SPONSOR_DAILY_WEIdefaults to 0, so this matters only if it is enabled; then the daily budget bounds it. Not scripted (needs the local stack); Low. - U-3, Kleros 2.0.0's subgraph and the five-field
DisputeRequest.docs/dependencies.md§1.9 records an open question to Kleros whether cases opened with the resolver's five-field event keep their template under the coming subgraph; if not, jurors would see no question or policy for yescrow's cases and the AI court might rule 0 (a 50/50 split) or refuse. Depends on Kleros's roadmap; unconfirmed. - U-4, a core upgrade that changes
disputes()'s layout._readtakes word 2 as the period; if an upgrade moved another field there, a value of 4 (for example a court ID) would make a live, non-finalcurrentRulinglook final. A trust assumption on Kleros's governor already (SPEC §7.4); a cheap hardening is to also require word 3 (ruled, set only byexecuteRulingat the execution period) orcurrentRuling'soverridden/tiedwords to decode as booleans (0 or 1). - U-5, evidence groups are open. Kleros's EvidenceModule lets anyone file evidence under any group ID, and its
subgraph groups by
_externalDisputeIDalone, so a third party can plant text (including instructions aimed at the AI jurors) in a yescrow case's evidence, and another allow-listed arbitrable using the same external ID would share the group. Kleros's design, known in SPEC §12; nothing in the escrow to change.
Method
Read SPEC §5–§8, §12, docs/audit.md, docs/keys.md, docs/security.md, docs/dependencies.md §1.5–1.9, the
escrow, the vendored Kleros interfaces, the mocks, Base.t.sol, Judge.t.sol, Hardening.t.sol, the invariant
handler, DeployAll/CheckDeployment/Networks, the juror template, and the service's keeper.ts, judge.ts,
sponsor.ts, relayer.ts, watcher.ts, api/app.ts (evidence), deals.ts, alerts.ts and the web's
Dispute.tsx. Verified the real resolver's source (kleros-v2 master) and its on-chain storage, the real core's getters
at executed cases, the dispute kit's hashVote, castCommit, castVote, fundAppeal, appealCost, appealPeriod
and the court parameters by cast call at the pinned block, then wrote the three proof files above.
Pentest report, area E: deposit router
Commit under test: 3ca0628 (main, 2026-10-29). Scope: contracts/src/DepositRouter.sol, DepositBox.sol, the
escrow's deposit and withdrawTo paths, script/DeployRouter.s.sol, script/Networks.sol, and the service's
forwarding, completion and deposit-status code (service/src/keeper.ts, bridges.ts, api/app.ts).
Proofs: 10 Foundry tests in contracts/test/pentest/E_*.t.sol on forks of Base (block 52,978,000), Ethereum
(26,254,000) and Arbitrum One (519,194,000) against the real USDC, CCTP V2 (TokenMessengerV2, MessageTransmitterV2,
TokenMinterV2), USDT0 and its LayerZero adapter, and the real keyless CREATE2 proxy; and one Node script in
scripts/pentest/ that drives the service's own Keeper class. All pass. No transaction was sent to any network.
Run everything from contracts/:
forge test --match-path 'test/pentest/E_*.t.sol' -vv
(cd ../scripts/pentest && node E_foreign_forward_keeper.ts)
Findings
E-1. Anyone can forward a buyer's USDC off Base or Ethereum, and yescrow never completes a burn it didn't make
Severity: Medium (griefing: funding delayed past acceptBy, the deal dies, the money is invisible until a
manual completion; the buyer's own way back on Base, promised by SPEC §4.1 and audit finding R1, is removed). If
Circle has stopped receiving on Arbitrum One, R1's own scenario, the same action makes the loss permanent.
Attacker and preconditions: any third party with a few cents of gas on Base or Ethereum. The victim is a buyer
whose USDC sits at a deal address on Base or Ethereum (an exchange withdrawal, a gasless EIP-3009 transfer, or
money the buyer meant to take back with returnStray). Deal addresses of on-chain seller proposals are public
(depositAddress(dealId, buyer)), so the attacker can watch them and act the moment USDC lands.
Steps:
1. On Base, before the keeper's next tick (or just before the buyer's returnStray), call
DepositRouter.forward(escrow, dealId, buyer, USDC). The router sweeps the box and burns the whole amount
through CCTP V2 for the same address on Arbitrum One, destinationCaller = 0. Cost: 366,580 gas, about
2.2e-6 ETH at Base's 0.006 gwei.
2. The USDC is now neither on Base nor on Arbitrum One. It appears on Arbitrum One only when someone calls
MessageTransmitterV2.receiveMessage with Circle's attestation for that burn transaction.
3. Nothing in yescrow does: Keeper.completeTransfers() iterates pendingTransfers(db), the bridge_transfers
rows the keeper inserts only after its own forward succeeded (forwardFromDepositChains, keeper.ts:303–341,
recordTransfer at l.321) or when it relays a withdrawTo. No code reads the router's Forwarded or CCTP's
DepositForBurn events (grep -rn Forwarded service/src finds only the ABI). The keeper's own forward now
finds the box empty (l.317) and records nothing.
4. The API's depositView (app.ts:240–275) sums the box balances on each chain plus pending rows, so the deal shows
received 0, coming 0, missing = price: to the buyer and the seller, nothing was ever paid. A buyer who pays
again locks twice the price.
5. On Arbitrum One the keeper's accept reverts InsufficientDeposit, returnDeposit reverts TooEarly, and at
acceptBy the seller's proposal expires. Once someone finally completes the mint, returnDeposit credits the
buyer of a deal that is gone.
Impact: 5,000 USDC (the proof's price) unreachable by anyone in yescrow's stack, the deal cancelled, and the
buyer's self-service exit on Base gone, for half a cent. Repeatable against every Base/Ethereum deposit, so it is
also a cheap denial of service of cross-chain funding. With Circle's receiving side paused on Arbitrum One (the
transmitter's pauser role, reproduced on the fork), the burn on Base still succeeds and the money is in a route
that cannot deliver; the buyer could have kept it on Base.
Proof:
forge test --match-path test/pentest/E_ForeignForward.t.sol -vv # the burn, the dead deal, the pause
(cd ../scripts/pentest && node E_foreign_forward_keeper.ts) # the keeper's two cases
test_1 burns the deposit from a stranger's account on the real Base messenger and shows the buyer's returnStray
then reverts; test_2 deploys an escrow on the Arbitrum One fork, shows accept/returnDeposit failing, the
proposal expiring, and the money reappearing only after a simulated receiveMessage (the real TokenMessengerV2's
handleReceiveFinalizedMessage, called as its transmitter would); test_3 pauses the real transmitter with its
pauser and shows no path mints. The script runs the real Keeper.completeTransfers with a stub database, stub
relayers and a stub Circle API: with its own row it asks Circle and sends receiveMessage; without one (a stranger's
burn) it never asks.
Root cause: contracts/src/DepositRouter.sol:83–100 (forward is open to anyone, by design) combined with
service/src/keeper.ts:303–341 and 355–385 (completion is driven only by rows written after the keeper's own
sends) and service/src/api/app.ts:257–259 (coming counts only those rows). The contract's permissionlessness is
fine; the service assumes it is the only forwarder.
Suggested fix: off-chain, cheap. Each tick, for every watched box and deposit chain, read the router's
Forwarded(escrow, dealId) logs (and CCTP's DepositForBurn with mintRecipient == box, for burns made directly)
since the last scanned block, and insert a bridge_transfers row keyed by the event's transaction hash; Circle's
API indexes attestations by that hash, so completeTransfers then finishes them, and depositView shows them as
coming. Add a "complete a transfer" action that takes a Base/Ethereum transaction hash, for buyers and support. Also
state in SPEC §4.1 that a deposit forwarded by someone else still needs a completion on Arbitrum One. No contract
change is needed for this; destinationCaller must stay 0 so that the keeper (or anyone) can complete it.
Verification of E-1:
Verdict: CONFIRMED (Medium stands), with corrections to the stated impact, to test_3 and to the suggested fix.
Proof of concept: -
forge test --match-path test/pentest/E_ForeignForward.t.sol -vv(fromE/contracts): 3/3 pass. - test_1 (Base fork): a stranger'sforwardburns 5,000e6 USDC from the box, using 366,580 gas. The box is left with 0,mintRecipientis the box,destinationCalleris 0. The buyer'sreturnStraythen reverts. - test_2 (Arbitrum One fork):acceptrevertsInsufficientDepositand the proposal expires. The buyer is credited 0 until a simulated mint, then 5,000e6. -node E_foreign_forward_keeper.ts: pass. With a row, the realKeeper.completeTransfersasks Circle and sendsreceiveMessage. Without one, it does neither. - My checks,forge test --match-path test/pentest/verify/V_E-1_Checks.t.sol --match-test test_V_: 2/2 pass.Sources unchanged: yes. Apart from build output, the only differences are the added
test/pentest/,scripts/pentest/andREPORT-E.md.What I checked: - The attacker's only action is a permissionless
forward. The other cheatcodes set up the scenario; none gives the attacker a power it lacks: -dealfunds the victim's box; -vm.etchplaces yescrow's own router at its pinned address; -vm.prank(transmitter)simulates the rescue mint; -vm.prank(pauser)simulates Circle's pause.USDC and TokenMessengerV2 are the real contracts. - Service: - Incoming rows are written only after the keeper's own successful forward (keeper.ts:321). - Outgoing rows come from the watcher's
WithdrawnToindexing (watcher.ts:90–102), not from a relay. - Nothing readsForwardedorDepositForBurn. - This is a bug, not the design. SPEC §4.1 requires showing "what is crossing" and completing CCTP transfers. The issue is not in audit §5. - Realistic: -ProposedexposesdealIdandbuyer. - The keeper ticks every 30 s and Base makes a block every 2 s, so a bot wins the race. - USDT is unaffected, because USDT0 delivers by itself. - My test 2: if anyone completes the mint beforeacceptBy,acceptfunds the deal normally. So the money is delayed, not lost.Reason: - Medium is right; High is not. The deal dies, the money is hidden and delayed with no end date, and yescrow loses its fee, for about a cent per Base deposit. Repeatable against every deposit, though paying on Arbitrum One still works. Nothing is lost, which rules out High. - Two impact claims are not the attacker's doing: - "The buyer's way back on Base is removed": the keeper's own forward empties the same box within 30 s anyway. - "With Circle paused, the loss becomes permanent": nothing in
service/srcchecks Circle's pause state, so the keeper also forwards into a paused route. "For good" also needs Circle to strand burned messages forever, which is R1's accepted risk and unchanged by this finding. - test_3 passes for the wrong reason. Its two bareexpectRevert()calls revert with no pause at all: - "Invalid attestation length" (a garbage message); - "Invalid message transmitter" (a stranger calling the handler).My test 1 shows a paused transmitter reverts with "Pausable: paused". So the pause claim is true, but test_3 doesn't prove it. - Root cause: correct, except that
forwardis DepositRouter.sol:83–97, not 83–100. - The fix needs one change: key rows by message, not by transaction hash.source_txisUNIQUE(written withON CONFLICT DO NOTHING), andcctpAttestation(bridges.ts:76–83) reads onlymessages[0]. An attacker who batches several forwards into one transaction would leave one row and one completion. - Key rows by (tx hash, CCTP nonce). - Complete the message whosemintRecipientis the row's box. - FilterDepositForBurnon its indexeddepositor= the router. That catches every router burn, batched ones included.
E-2. A stranger's forward locks for good money that the buyer could have taken back: boxes whose escrow has no code on Arbitrum One
Severity: Low (needs a buyer's mistake first; then a third party makes it permanent, against SPEC §4.1 "it isn't lost" and §13.7).
Attacker and preconditions: anyone. A buyer has sent real USDC on Base (or Ethereum) to a deal address of an
escrow that exists only elsewhere: the active Arbitrum Sepolia USDC escrow 0x57Af…c836
(deployments/arbitrum-sepolia.json) has no code on Arbitrum One, and a testnet deal's address is a live address on
Base mainnet (audit R3). Picking "Base" instead of "Base Sepolia" at an exchange does it.
Steps:
1. On Base the buyer can take the money back (returnStray, msg.sender = buyer): proven in test_1.
2. Before that, anyone calls forward(0x57Af…c836, dealId, buyer, USDC): the USDC is burned for the same box on
Arbitrum One (test_2).
3. On Arbitrum One pull is keyed by msg.sender, so only 0x57Af…c836 can sweep that box; returnStray and
forward revert NotHere on the settlement chain for everyone, including the buyer; the box has no other
function (test_3). The only key is code at that address on Arbitrum One, which only yescrow's testnet deployer's
CREATE at the matching nonce could produce, and which docs/keys.md says must never be used on a mainnet chain.
Impact: 1,000 USDC in the proof, locked at the deal address on Arbitrum One. The same holds for any escrow
address without code on the settlement chain (a future escrow version's precomputed address before it is deployed,
an escrow of another settlement chain, a typo in an integrator's escrow argument).
Proof: forge test --match-path test/pentest/E_StrandedBox.t.sol -vv
Root cause: contracts/src/DepositRouter.sol:76–79 (pull only by the escrow in the salt) with :108
(returnStray refuses the settlement chain unconditionally): on Arbitrum One there is no path for a box whose
escrow can never call. forward (l.83) cannot know, on Base, whether the salt's escrow exists on Arbitrum One.
Suggested fix: in the next router version, let the buyer in the salt reclaim on the settlement chain a box whose
escrow has no code: in returnStray, if (here.settlement == block.chainid && (escrow.code.length != 0 ||
msg.sender != buyer)) revert NotHere();. An escrow deployed later at that address loses nothing: no deal can exist
before it does, and the money is the buyer's. Until then: the testnet interface should say that money sent on a
mainnet network to a testnet address can be reclaimed on that network only until someone forwards it, and support
should know the rescue (code at the escrow address) is a mainnet use of the testnet deployer key.
Verification of E-2:
Verdict: CONFIRMED (Low), with corrections below.
Proof of concept:
forge test --match-path test/pentest/E_StrandedBox.t.sol -vv, run frompentest/E/contracts: pass, 3 of 3 tests. -test_1: the buyer'sreturnStrayon Base returns 1,000,000,000 base units (1,000 USDC). -test_2: a stranger'sforwardburns the 1e9 through the real TokenMessengerV2 (depositForBurn(1e9, domain 3, box 0xeE34…04B7, maxFee 0, 2000)). The buyer'sreturnStraythen revertsNothingThere. -test_3: 1e9 is minted to the same box on Arbitrum One. The buyer and the stranger getNotHerefromreturnStrayandforward, and theirpulls return 0. The box keeps 1e9 and has no code.Sources unchanged: yes.
diff -ragainstbasediffers only in the pentest folders,REPORT-E.md, the node_modules symlinks and build output.What I checked: - The
-vvvvtrace: the router lands atNetworks.ROUTERthrough the real CREATE2 proxy (noetch);dealonly funds the buyer's mistaken deposit; the mint is the real TokenMessengerV2, called as its transmitter calls it after Circle's attestation; the final prank as0x57Af…only shows who could sweep. -0x57Af…c836is the active testnet USDC escrow and names router0x7073…, which isNetworks.ROUTER. So the box is a real testnet deal address. - Arbitrum One, block 519,343,733: no code at0x57Af…, no router on Arbitrum One or Base yet. The testnet deployer0x934f…6795has nonce 0 there; the DeployAll broadcast shows0x57Af…is its CREATE at nonce 56. - yescrow's keeper never does this forward. It forwards only its own deals (keeper.ts:303), and only on chains that settle to its own escrow's chain (escrow.ts:243). Only a deliberate third party does. - The issue isn't in audit §5. R3 (§6) accepts that testnet addresses are live on mainnets, and its fix (never use the testnet key there) is what makes this lock permanent. - The root-cause lines (76–79, 108, 83) are right.Reason: 1. Low stands. The attack needs the victim's own mistake on a testnet labelled "no real money" (banner, FAQ, Sepolia network names), plus a griefer who gains nothing. A direct send on Arbitrum One to a testnet deal address is already locked with no griefer at all. That is R3's root cause, rated Low. One interface line makes the mistake easier:
web/components/deal/DepositPanel.tsx:85also tells testnet buyers to "buy it … at an exchange and withdraw it straight to this address", and exchanges offer only mainnets. 2. "For good" holds only under the key rule. The testnet deployer's nonce on Arbitrum One is still 0, so yescrow could rescue the money: send 56 filler transactions, then deploy at nonce 56 a contract with no owner whose only call isrouter.pull(dealId, buyer, token, buyer). That breaks the rule in keys.md, so it's the user's call. Done at every testnet escrow address on Arbitrum One, it would also close R3's theft risk. 3. The suggested router change doesn't fix the PoC's own scenario. Existing testnet escrows name0x7073…forever, and anyone can deploy that code on Base or Ethereum through the keyless proxy, so their deal addresses keep the oldforward. The change protects only escrows that name a new router, which is still possible before mainnet. For existing testnet deals, the remedies are the rescue above, a line in SPEC §4.1 "Wrong network" and §13.7 that testnet addresses on mainnets aren't covered (direct sends on Arbitrum One included), and hiding the exchange line on testnet builds. 4. "A future escrow's precomputed address" normally means only a delay. Once that escrow is deployed,returnDepositcan return the money. The loss is permanent only for addresses that will never have code: testnet escrows and typos.
E-3. Tokens sent to the router's own address are unrecoverable
Severity: Low / Info (user error; no attacker).
Preconditions: someone sends USDC or USDT0 to 0x7073…Cb3D itself, on any chain (the router's address appears
on block explorers next to every deposit transaction and is verified source, so it will be pasted somewhere).
Steps and impact: the router "holds nothing between calls" but has no function over its own balance: forward
bridges exactly what the box's sweep returned, bridge exactly what it pulled from msg.sender, returnStray
and pull only touch boxes, and there is no owner or sweep. 100 USDC sent to the router stay there through every
public function (proof). ETH cannot get stuck the same way: there is no receive, forward{value} with USDC
reverts WrongValue, and the USDT path refunds the excess.
Proof: forge test --match-path test/pentest/E_RouterStray.t.sol -vv
Root cause: contracts/src/DepositRouter.sol:119–129 and 209–241: every send moves an explicit amount
from a box or from the caller, never the router's balance.
Suggested fix: none on-chain for this version (immutable, and a sweep would need an owner). Never show the router
address as a destination; say in the explorer's contract notes and docs/security.md that it holds nothing and
takes nothing. A future router could let anyone bridge the router's own stray balance to the fee recipient on the
settlement chain, which keeps it keyless.
Verification of E-3:
Verdict: DOWNGRADED to Info
Proof of concept:
forge test --match-path test/pentest/E_RouterStray.t.sol -vv(frompentest/E/contracts): PASS. On a Base fork (block 52,978,000), 100,000,000 base units (100 USDC) stay at0x7073…Cb3Dafter: -forwardbridges the box's 1 USDC through the real CCTP; - a strangerbridges its own 2 USDC; -returnStrayrevertsNothingThere; -pullrevertsNotHere.My extra check,
forge test --match-path "test/pentest/verify/V_E-3_*" -vv(contracts/test/pentest/verify/V_E-3_RouterStrayEverywhere.t.sol): 2 PASS. - Base: the router lands atNetworks.ROUTERthrough the real CREATE2 proxy, so the PoC'svm.etchfallback never runs. 100 USDC sent by a real ERC-20 transfer (notdeal) stay throughforward, the buyer'sreturnStrayandforwardon an empty box. - Arbitrum One (block 519,194,000): 100 USDC, 100 USDT0 and 1 ETH forced in by SELFDESTRUCT all stay throughpull(exactly the box's 7 USDC / 5 USDT0),bridgevia CCTP and via USDT0's OFT, andforward/returnStray(NotHere). The router's own ETH can't pay USDT0's fee (WrongValue).Sources unchanged: yes. Excluding build output,
diff -ragainst the pristine export shows onlycontracts/test/pentest/,scripts/pentest/andREPORT-E.md.What I checked: - The PoC test: no prank of a party that wouldn't sign (the buyer may call
returnStray), no mocks, real CCTP.dealstands only for the sender's mistake. The assertions read the router's balance after each call. - The router's ABI:boxAddress,boxCodeHash,pull,forward,returnStray,bridge,routeOf; no receive or fallback. Every outgoing transfer moves an amount taken from a box or the caller in that same call. - The docs:docs/audit.md§5,docs/security.md"Donations", SPEC §2 rule 1. - Where yescrow shows the address: only on /about/ (web/components/site/Contracts.tsx:22–23), never in the deposit panel.Reason: the behaviour is real, but it is the router's copy of an accepted, by-design limit. §5 says: "Donations to the escrow outside a deal stay there forever (no recovery function, by design)". SPEC §2 rule 1 rules out any recovery function. §5 doesn't name the router, so this is a documentation gap, not something made worse. - There is no attacker, and nobody gains. - No deal party's money is involved: deposits go to box addresses, and no yescrow flow asks anyone to pay the router. - The loss needs someone to paste a contract's address, like sending USDC to the USDC contract. - The scale's "Critical: permanently lost" is about the protocol losing a party's money, not a self-inflicted transfer outside any deal. Low overstates it.
Corrections: - Root cause: the cited lines are
bridge(119–128) and_send(209–243, not 241);forward(83–97, amount from the sweep at 91) belongs there too. The cause is the deliberate absence of any function over the router's own balance (line 21, SPEC §2 rule 1), not a defect in those lines. - ETH: "can't get stuck" is too strong. A plain transfer to the deployed router reverts. But ETH forced in by SELFDESTRUCT stays (shown above). So does anything sent to0x7073…Cb3Don a chain where the router isn't deployed, which today is every mainnet (E_Soundtest_1). - Fix: - Add the router (any token, ETH, every chain) to "Donations" in audit.md §5 and security.md. - /about/ shows the address as "Deposit router (same address on every chain)" with a copy button, which echoes the deposit panel's wording. Relabel it "not a payment address". - The "future router" sweep means a new router address, so every box address changes and the escrows (router immutable) redeploy. It also needs a fee-recipient constant in a contract with no settings, and makes yescrow hold strangers' mistakes. Documentation is enough.
Checked and found sound
Executable claims are in contracts/test/pentest/E_Sound.t.sol (forge test --match-path
test/pentest/E_Sound.t.sol -vv); the rest was checked by reading and by cast reads of public chain state.
- The router's address is pinned and reproducible. This build's
out/DepositRouter.sol/DepositRouter.json(solc 0.8.30, optimizer 200, legacy pipeline, no metadata) hashes to creation code0x520ee3…e491and lands atNetworks.ROUTER=0x7073…Cb3Dthrough the real proxy on Arbitrum One, Base and Ethereum (test_1). The routers already at that address on Arbitrum Sepolia and Base Sepolia are byte-identical to this build once theboxCodeHashimmutable is filled in (5,917 bytes). A file that imports only the router andNetworks, asDeployRouter.s.soldoes, gets the same creation code as the artifact (test_1asserts it), sopredicted()guards the deploy correctly. The address is not deployed on the three mainnet chains yet; anyone may deploy it, and only identical code lands there (CREATE2 commits to the init-code hash), so nobody can squat it. - Every
routeOfconstant matches the real bridge on its chain (test_2): CCTP domains 0/3/6 equal each transmitter'slocalDomain, V2 (version()1,messageBodyVersion1), the remote TokenMessenger for each destination is registered, the burn token on each side maps to the other side's USDC in the minter (getLocalToken), the per-message cap is 10,000,000 USDC; USDT0's Ethereum adapter wraps legacy USDT as a lockbox (approvalRequiredtrue), the Arbitrum OFT wraps USDT0 (mint/burn), their endpoints are EIDs 30101 and 30110 and each is the other's peer. Enforced options on both sides givelzReceive80,000 gas (0x0003010011010000…13880), so the router's emptyextraOptionsare delivered by LayerZero's executor. - Who may move what (
test_3, plustest/DepositRouter.t.sol): on Arbitrum One a box is swept only by the escrow in its salt (pullkeyed bymsg.sender; a stranger sweeps its own empty box),forwardandreturnStrayrevertNotHere, andbridgerevertsNoRoutefor the same chain, a testnet, an unknown chain and USDT0 to Base, with the real tokens and nothing leaving the caller. Off the settlement chain a bridgeable token can be returned only by the buyer in the salt, anything else by anyone, to the buyer only. - The box.
sweeponly by the deploying router (immutableowner), ETH or any token, to atothe router chooses: the escrow (funding), the buyer (returns), the router itself (bridging). Its creation code has no deployer-dependent part, soboxCodeHashand every box address are the same on every chain; the salt isabi.encodeof fixed-size fields (no collision). Malicioustokenarguments toreturnStray/returnDepositrun in the token's context only; the router and box keep no state to corrupt, and the escrow isnonReentrant. - A burn goes only to its own box and can't be re-pointed.
forwardsends toboxAddress(escrow, dealId, buyer)from the same salt it swept,destinationCaller0,maxFee0, finality 2000 (standard, free); USDT0 withminAmountLD = amount. The burn'smessageSenderis the router, and the V2 messenger implementation (0x555E…3ec8) has noreplaceDepositForBurn(selector absent), so neither the router nor anyone else can change a burn's recipient. Testnet routes can never reach Arbitrum One (there.settlement == here.settlement). - The escrow side.
_pullDeposittrusts the escrow's balance difference, not the box's count;_fundrequires the price and credits the excess toterms.buyer;returnDepositpays onlyterms.buyerand the deal token only once_depositCanFundis false (a buyer's proposal already funded, a seller's proposal pastacceptByor closed, invalid terms), other tokens and ETH straight to the buyer; the deal ID commits to the buyer, so nothing can be redirected.withdrawTois authorized only by the account (transaction or one-use EIP-712 over chainId, recipient, amount, deadline), requiresmsg.value0 for USDC, quotes and pays USDT0's fee in the same call and refunds the excess to the caller; a bridge revert reverts the whole call, so no signature is burned. - Approvals and value.
forceApprovehandles USDT's approve-from-nonzero restriction; CCTP and the Ethereum adapter consume the router's allowance exactly; the Arbitrum OFT (approvalRequiredfalse) leaves a dangling allowance from the router, but the router holds nothing andsendburns onlymsg.sender's own balance, so it is unusable.forwardwith USDC refuses anymsg.value; the USDT path's refund reverts the call ifrefundTorejects ETH, which only hurts the caller. - Deploying elsewhere. On a chain the router doesn't know,
routeOfreturns settlement 0:pull,forwardandbridgeare refused andreturnStrayreturns everything to the buyer in the salt, as SPEC §13.7 says. A fake proxy or a chain with different CREATE2 rules puts the router at a different address, where no yescrow escrow names it. - Service checks.
loadDepositChainuses a chain only if the router is deployed there and its route's settlement is this escrow's chain; the API relays EIP-3009 transfers only to a known deal's own box; the keeper forwards aboveFORWARD_MINonly, simulates every call, and records withdrawals to other chains for completion. - CCTP denylist state. The router is not on Circle's denylist on Base (
isDenylistedfalse at the fork block); see unconfirmed 2 for the systemic case.
Unconfirmed
maxFee = 0is immutable. Circle's attestation service refuses to attest a burn whose required fee exceedsmaxFee. Standard transfers are free today (checked 2026-10-22 indocs/dependencies.md), but if Circle ever charges for them, everyforwardand cross-chainwithdrawToburn would be made and never minted, with no way to change the constant. Not provable on a fork (off-chain policy). Recommendation: before each forward and relay, the keeper queries Circle's fee endpoint (/v2/burn/USDC/fees/{src}/{dest}) and stops, with an operator alert and a UI warning, if the standard-transfer minimum fee is nonzero; buyers then reclaim withreturnStray.- Circle's and Tether's denylists are checked on
msg.sender(andtx.origin). TokenMessengerV2 and MessageTransmitterV2 areDenylistable; a shared router means a denylisting of0x7073…Cb3Dwould stop every CCTP forward and USDC withdrawal to another chain at once (money stays at the boxes on Base and Ethereum, reclaimable by buyers; on Arbitrum One local withdrawals still work). A sanctioned party routing funds through the permissionless router could, in theory, provoke it. The same holds for Tether's blocklist on USDT0 and the OFT owner0x4DFF…0bf8, who sets peers and enforced options. Trust assumptions, already partly in SPEC §12. - The keeper's own forwards can be orphaned the same way as E-1 if the process dies between the transaction's
receipt and
recordTransfer(keeper.ts:320–321). The fix for E-1 (indexForwardedlogs) covers this. - Relayer gas griefing through minimum deposits. A gasless 1 USDC EIP-3009 deposit on Base costs the relayer a
Base transaction, a forward, a mint on Arbitrum One and later a
returnDeposit; repeated against drafts it drains the relayer slowly. SPEC §12 accepts that the relayer can be drained;FORWARD_MINonly bounds the per-deposit cost. Needs a stack to measure, so not proven.
Not findings (reasoned out)
- Dust or over-10M griefing of
forward: a stranger's USDC sent to a box only enlarges the buyer's deposit, and the buyer (R1) orreturnDepositreturns it; pushing a box over the 10M cap costs the attacker that USDC. - Smart-contract or 7702 buyers:
returnStrayfor bridgeable tokens needsmsg.sender == buyeron that chain, which a wallet that exists only on Arbitrum One can't satisfy, butforwardplusreturnDepositpluswithdraw(account)(callable by anyone, paying the account itself) always gets the money to the buyer on Arbitrum One. The cross-chain address-ownership caveat for such wallets is already in SPEC §14.11. - A burn made directly through CCTP with a
destinationCallerof the sender's choice only lets that sender time the arrival of its own money at a box; whatever arrives funds the deal on its fixed terms or returns to the buyer.
Pentest area F: Deployment (commit 3ca0628, v0.30)
Scope: the two compiler pipelines (contracts/foundry.toml compilation_restrictions: via-IR for the escrow and
the verifiers, legacy for the router), whether what the tests run is what deploys, via-IR codegen surprises,
DeployAll.s.sol's order and guards, Networks.sol against Arbitrum One (fork at block 519,194,000, the mainnet fork
test's pinned block), CheckDeployment.s.sol's coverage of every immutable, and the promised escrow addresses.
Proofs: contracts/test/pentest/F_*.t.sol (17 tests, all passing on the Arbitrum One fork against the real USDC,
USDT0, KlerosCoreNeo, Kleros's public DisputeResolver, template registry, evidence module and USDT0's OFT) and
scripts/pentest/F_verify-profiles.sh. Run everything from
contracts:
forge test --match-path 'test/pentest/F_*.t.sol' -vv # all 17 (log: ../REPORT-F-run.log)
../scripts/pentest/F_verify-profiles.sh # the verification profiles (no network)
Summary. No way to lose, steal, lock or misdirect money through the deployment was found. The build is sound:
every test suite (unit, fuzz, invariant, fork) and the service's Anvil harness exercise byte-for-byte the via-IR
escrow and verifiers that DeployAll deploys, the router artifact that DeployRouter deploys is the legacy build
whose init code lands at Networks.ROUTER, every Networks.sol constant matches Arbitrum One, the promised escrow
addresses are the mainnet deployer's nonces 3 and 4 and are still free, and the 5M Kleros gas budget holds at the
exact gas floor of the via-IR build against the real resolver. Two low-severity process findings: the launch gate
CheckDeployment.s.sol does not read back four settings (a misdeployed escrow passes it), and the router's source
verification command in the mainnet runbook is refused by forge as ambiguous.
Findings
F-1. CheckDeployment.s.sol does not read back promoSigner, promoEnds, the juror template or the whole verifier allow-list: a misdeployed escrow passes the launch gate
- Severity: Low (a gap in the safety net, not an exploit path; the impact if a deployment ever carried one of these mistakes would be Medium, loss of yescrow's fees, or High for a rogue verifier).
- Attacker and preconditions: none on-chain. A wrong value in
.env.mainnet(PROMO_SIGNERderived from a key that isn't the service's), an editedDeployAll.s.sol/template file, or a manual deployment.DeployAllbuilds the config correctly today; the finding is that the check that docs/audit.md §8 and docs/mainnet.md §3 step 3 rely on ("every setting read back") would not notice. - Steps (PoC
F_CheckDeploymentGaps.t.sol): deploy an escrow with DeployAll's exact mainnet config exceptpromoSigner= a stranger's key,promoEnds= +365 days (inside DeployAll's 366-day bound),templateData="{}", and a second verifier (aMockVerifierthat accepts any facts) on the allow-list; runCheckDeployment.run()withREQUIRE_KLEROS=trueagainst the real Kleros on the fork. It printsALL CHECKS PASSED(both simulated cases are created). Then: (1) the stranger signsFeeVoucher(dealId, 0, expiry), anyone submits it, the buyer confirms: the seller is credited 1,000 USDC instead of 990 and the fee recipient 0 instead of 10 USDC (test_F_impact_promoKeyNotChecked_feeWaivedByAStranger); (2) a deal whose terms name the rogue verifier is proven withabi.encode(RdapFacts)nobody attested and released: 990 USDC to the seller for a domain that never moved (test_F_impact_rogueVerifierNotChecked_paysOnUnattestedFacts; needs terms that name that verifier, which the web app and API never produce); (3) the stored template is 2 bytes instead of the 1,428-byte juror template, read from storage slot 6 since the contract has no getter. - Impact: a deployment mistake in any of these four settings reaches mainnet unnoticed by the gate. Each escrow's settings are immutable, so the only remedy would be a new deployment.
- Command:
forge test --match-path test/pentest/F_CheckDeploymentGaps.t.sol -vv - Root cause:
contracts/script/CheckDeployment.s.solcheckEscrow(lines 52–93) reads token, router, arbitrator, botharbitratorExtraData,smallDealMaxPrice,feeRecipient,feeBps,referrerShareBps, the window floors,isVerifier(verifier)for the one given verifier (line 79),resolver,judgeand the EIP-712 domain, but neverpromoSigner()/promoEnds()(public immutables,YescrowEscrow.sollines 206–208), nor the template (string internal templateData, line 231, no getter), and a mapping can't be enumerated for extra entries. - Suggested fix: the strongest single check is to decode the escrow's own creation transaction: the
Configstruct is the constructor argument in the CREATE input (cast tx <hash> inputminus the creation code, orvm.rpc("eth_getTransactionByHash")in the script), soCheckDeploymentcanabi.decodeit and compare every field (template, mappings, verifier list, promo key and end, windows) with the config it rebuilds throughDeployAll.config(...)for that escrow address. Cheaper:require(e.promoSigner() == vm.envOr("PROMO_SIGNER", address(0)) && e.promoEnds() == vm.envOr("PROMO_ENDS", 0))(deploy-all.sh exports both), readtemplateDatafrom slot 6 (as the PoC's_templateDatadoes) and compare it with the renderedscript/dispute-template.json, and add atemplateData()getter in the next contract version.
Verification of F-1:
Verdict: CONFIRMED (Low)
Proof of concept:
forge test --match-path test/pentest/F_CheckDeploymentGaps.t.sol -vvfromF/contracts: 4/4 pass, also with-j 1(the tests'vm.setEnvcan't race). On the Arbitrum One fork (block 519,194,000) the misdeployed escrow0x1d14…f211gets "ALL CHECKS PASSED" withREQUIRE_KLEROS=true(real Kleros cases 328 in the AI court and 329 in the General Court). It stores a 2-byte template instead of 1,428 bytes. A stranger's voucher leaves the seller 1,000 USDC and the fee recipient 0 (instead of 990 and 10). The rogue-verifier deal pays the seller 990 USDC on facts nobody attested.Sources unchanged: yes.
diff -rqagainstbasediffers only incontracts/test/pentest/,scripts/pentest/,REPORT-F*and build output.What I checked: -
CheckDeployment.s.solcheckEscrow(lines 52–93) never readspromoSigner(),promoEnds()or the template. It checksisVerifieronly for the verifier it is given (line 79). TheYescrowEscrow.solreferences (lines 206–208, 231) are right. - The test is clean: - Nothing is stored or etched into yescrow's or a third party's contracts. The judge is an etched stand-in at0x1d6e, as inrehearse-mainnet.sh. -dealonly funds the buyer's deposit address. - The pranks are normal party actions: the buyer confirms; the seller accepts and submits a proof. - The stranger's key andMockVerifierare the stated misconfiguration, not stand-ins for real contracts. - My controls,test/pentest/verify/V_F-1_controls.t.sol(3/3 pass,-j 1): - On a DeployAll-built escrow, slot 6 is the 1,428-byte rendered template and slot 7 the empty mappings, so the PoC reads the right slot. - The gate reverts ("escrow: fee recipient") on a setting it checks, so its pass is meaningful. - The gate also passespromoEnds = 2^64−1: DeployAll's 366-day bound isn't checked again at launch. - Not a known issue (docs/audit.md§5). Area F's brief asks for "CheckDeployment covering every immutable", anddocs/mainnet.md§0 and §3 step 3 claim "every setting".Reason (caveats; the severity stands): - A gap in the safety net: each loss shown needs a separate mistake first. -
deploy-all.sh(lines 53–58) derivesPROMO_SIGNERfromPROMO_SIGNER_PRIVATE_KEYin.env.mainnet, and the mainnet service signs with that same key (ENV_FILE=.env.mainnet,service/src/config.ts:149). So "a stranger's key" needs a manual or edited deployment and someone else holding the named key. That is the leaked-promo-key worst casedocs/keys.mdalready accepts: fee income only, untilpromoEnds. A wrong address nobody holds only stops promos:promoOn(service/src/promo.ts:38–41) won't issue vouchers. - The rogue-verifier release needs all three: - edited deploy code (DeployAllalways passes exactly one verifier); - a buyer who funds terms naming that verifier; - no challenge in the 72-hour window. - So "Medium/High if deployed" is an upper bound.Corrections: - Root cause:
templateDataMappings(line 232) isn't read back either. The windows andfeePeriodare checked only against floors. - Both suggested fixes are circular for the first precondition named. A config rebuilt throughDeployAll.config, or aPROMO_SIGNERfrom the same env and checkout, matches a wrong.env.mainnetkey or an edited template. - "deploy-all.sh exports both" holds only insidedeploy-all.sh. The separate launch gate (docs/mainnet.md§3 step 3) sources.env.mainnet. Per the example file, that hasPROMO_SIGNER_PRIVATE_KEYandPROMO_DAYS, noPROMO_SIGNER, and an emptyPROMO_ENDS.envOrwould return 0 and fail a correct NAMEPROS deployment. - Compare against independent values instead: - the promo address pinned indocs/keys.md(0xE4e9…b8dE); -promoEnds≤ deploy time plusPROMO_DAYS; - a template hash committed to the repo; - the exact verifier list, decoded from the creation transaction recorded indeployments/arbitrum-one.json.
F-2. The router's source verification, as the mainnet runbook runs it, is refused: "Ambiguous compilation profiles found in cache: default, via-ir"
- Severity: Low (no funds; the public cannot read the verified router source at
0x7073…Cb3D, which docs/keys.md §5 and SPEC §4.1's "identical code" promise rely on). - Attacker and preconditions: none; a launch-day process failure.
- Steps (PoC
scripts/pentest/F_verify-profiles.sh, offline via--show-standard-json-input):forge verify-contract 0x7073f01f36773e8A67731d287365191Ab207Cb3D src/DepositRouter.sol:DepositRouter(docs/mainnet.md §3 step 1, for chains 42161, 8453 and 1) exits with the ambiguity error becauseDepositRouter.solis compiled in both jobs (out/DepositRouter.sol/DepositRouter.jsonandDepositRouter.via-ir.json). With--compilation-profile defaultforge submitsviaIR=false, which is the deployed build; withvia-irit submits a build that lands at0x3460…a8a1and can never match. The escrow and the verifiers submit correctly (viaIR=true,bytecodeHash none,appendCBOR false, cancun, 200 runs). - Impact: without the flag the router stays unverified on Arbiscan and Sourcify on every chain; with the wrong profile it is "verified" against nothing (the submission is simply rejected).
- Command:
../scripts/pentest/F_verify-profiles.sh - Root cause:
contracts/foundry.tomllines 10–15 (two profiles for one source) and docs/mainnet.md §3 step 1'sforge verify-contract … src/DepositRouter.sol:DepositRouterloop, which names no profile.scripts/deploy-all.shverifies only the five via-IR contracts (fine). - Suggested fix: add
--compilation-profile defaultto the router's verify commands in docs/mainnet.md (Arbiscan and Sourcify), or move the router verification intoscripts/deploy-all.sh/DeployRouterdocs with the flag, and rehearse it once on Arbitrum Sepolia where the same router already exists.
Verification of F-2:
Verdict: DOWNGRADED to Info
Proof of concept:
../scripts/pentest/F_verify-profiles.sh, run frompentest/F/contracts. It runs, and its results match the attacker's: - The escrow, RdapVerifier and DualRdapVerifier submitviaIR=True, 200 runs, cancun,bytecodeHash none,appendCBOR False. - The router, as docs/mainnet.md runs it: "Ambiguous compilation profiles found in cache: default, via-ir". - With--compilation-profile default:viaIR=False. Withvia-ir:viaIR=True.Sources unchanged: yes.
diff -ragainstbasediffers only incontracts/test/pentest/,scripts/pentest/and the two report files.lib/is identical.What I checked: - The failure doesn't depend on a test build. I used a copy of
base/contractswithoutout/orcache/(pentest/verify/F-2-sim). Running only the runbook'sforge script script/DeployRouter.s.sol:DeployRouter(no RPC, no key, nothing sent) leavesDepositRouterin both profiles in the cache, and no test file was compiled. The cause issrc/YescrowEscrow.sol:16, which imports./DepositRouter.solinto the via-IR job. - The exact docs/mainnet.md:83 command fails with exit 1 offline on 42161, 8453 and 1. On the real submission path, pointed at a closed local verifier URL, it fails before sending any request.--verifier sourcifyfails the same way. With--compilation-profile defaultit gets past this check and stops only at the connection. - The fix works. I compiled the standard JSON that forge submits, with solc 0.8.30: - default profile: 6,802 creation bytes, init-code hash0x520ee3…e491, which lands at0x7073f01f…Cb3Dthrough the CREATE2 proxy; - via-IR profile: 5,903 bytes, which lands at0x34600Fa5…a8a1. - Public state (read only): there is no code at0x7073…Cb3Don Arbitrum One yet. On Arbitrum Sepolia and Base Sepolia, Sourcify already shows this router as a creation and runtime "match" (verified 2026-10-29 08:03 UTC). - Known issues: not among them in docs/audit.md §5.deploy-all.shverifies only sources compiled in one profile, so it is fine.Reason: The bug is real, but it doesn't touch money, deadlines or fees. Its only effect is a command that fails loudly: exit 1 on each chain, with forge's message naming the missing flag. - The router is immutable, so it can be verified on any later day. "Stays unverified" assumes the operator ignores three errors. - "The public cannot read the router source" is overstated: Sourcify already matches the byte-identical router's source on two chains. - SPEC §4.1's "identical code" doesn't depend on verification. The CREATE2 address commits to the init-code hash, so the bytecode alone proves it. Only docs/keys.md (line 8, §5 step 1) promises verified source on Arbiscan. - The attacker's own report rates I-2 (the silent
REQUIRE_KLEROS=falsedefault) as Info, and this failure is louder and milder.Root-cause corrections: - In
foundry.toml, the lines that matter are 13 (the via-IR profile) and 15 (the escrow's restriction); lines 10–12 are comments. Besides tests,src/YescrowEscrow.sol:16andscript/Deploy*.s.solpull the router into the via-IR job. - The same router verification without a profile is inscripts/deploy-escrow.sh:87(Arbitrum Sepolia), where|| truehides the error.Fix: - Add
--compilation-profile defaultto docs/mainnet.md:83 and to deploy-escrow.sh:87. - Rehearse offline instead of on Arbitrum Sepolia, where a rehearsal would likely just report "already verified":--show-standard-json-input, then compile it and compare the CREATE2 address, as above.
Info (no proof needed, or covered by the confirmations below)
- I-1. Size drift in the docs. The via-IR escrow runtime is 22,948 bytes (
out/…/YescrowEscrow.json, and the deployed code intest_F_escrowAndVerifiers…), not the 22,906 in docs/audit.md §1 and docs/security.md; 1,628 bytes remain under EIP-170, so audit fixes must stay small or the pipeline needs the next trick (optimizer_runs, splitting). - I-2.
scripts/deploy-all.shrunsCheckDeploymentwithREQUIRE_KLEROS=${REQUIRE_KLEROS:-false}even on arbitrum-one, so the deployment record (active: true) is written even if Kleros refuses cases; docs/mainnet.md §3 step 3 reruns the gate withtrue. Default it totruefor arbitrum-one so the record can't be written past a refusal. - I-3. Comment drift:
DeployAll.s.solline 30 still saysFEE_RECIPIENTis "a multisig on mainnet"; the decision (docs/mainnet.md, docs/keys.md) is an EOA, whichCheckDeploymentonly notes. - I-4. The mainnet fork test's fallback hides a wrong router artifact:
ArbitrumOneMainnetFork.t.soletches the deployed code atNetworks.ROUTERif the artifact lands elsewhere (meant for coverage builds). Should foundry ever change which build is written asDepositRouter.json, the test would still pass. Assertlanded == Networks.ROUTERunlessFOUNDRY_PROFILE=coverage. - I-5. The Kleros call's budget arrives with the EVM's 2,300 value stipend (5,002,300 at the callee's entry, 5,001,495 after its dispatch at the exact floor), so "5M" is a floor, not an exact figure; nothing depends on it.
- I-6. Stale cache entry:
contracts/cache/solidity-files-cache.json(copied from the checkout) still liststest/pentest/Probe.t.sol, which doesn't exist; forge logs an ERROR on every run and continues. Harmless; aforge cleanremoves it.
Verification of these Info items:
Verdict: - I-1: CONFIRMED. - I-2: CORRECTED. The facts hold, but the suggested fix wouldn't stop the record being written. - I-3: CONFIRMED. - I-4: CONFIRMED. - I-5: CONFIRMED. - I-6: DROPPED.
None of them deserves more than Info.
Proof of concept: I ran this from
F/contracts:
forge test --match-path 'test/pentest/F_*.t.sol' --match-test 'test_F_escrowAndVerifiers_testBuildEqualsDeployArtifact_andFitsEip170|test_F_atTheExactFloor_calleeStillGets4_99MOfThe5MBudget|test_F_calleeOverTheBudget_isARefusal_evenWith30MGas' -vvAll 3 tests pass on the fork: - The escrow is 22,948 bytes, both as the artifact and as deployed. - At the exact floor of 5,348,481 gas, the callee still had 5,001,495 gas after its dispatch.
Sources unchanged: yes.
diff -ragainstbase(checked before and after the run) shows onlytest/pentest/,scripts/pentest/and the report files.What I checked, and the reasons, item by item:
- I-1 (size drift in the docs):
- The two builds produce identical runtime and init code: the base copy's artifact (built in the checkout at 09:38) and F's rebuild from the commit's source.
- The old build-info's source differs from the commit's only by a trailing blank line.
docs/audit.md:12anddocs/security.md:273both say 22,906. The headroom is 24,576 − 22,948 = 1,628 bytes.- SPEC:669 and GOAL-3:274 (24,178) are historical notes, not drift.
- I-2 (
REQUIRE_KLEROSdefaults to false):- The facts hold:
deploy-all.sh:136forcesREQUIRE_KLEROS=${REQUIRE_KLEROS:-false}on every network.- That overrides
CheckDeployment's own default,vm.envOr("REQUIRE_KLEROS", n.mainnet)(line 35, documented as "default true on mainnet").docs/mainnet.md:91reruns the gate withtrue, andrehearse-mainnet.sh:41passestrue.- What's wrong: the record (
deployments/arbitrum-one.json,active: true) is written at lines 97–112, before the gate runs at all (line 136). Defaulting totruewould only make the script exit non-zero after the record already exists.- The fix:
- Delete the override, so that
n.mainnetapplies: true on arbitrum-one, false on Sepolia.- If no record should exist past a refusal, also move the
CheckDeploymentcall before the jq write.- Since v0.30, refused cases go to the judge, and the record only takes effect when the stack starts after step 3. So it stays Info.
- I-3 (comment drift):
DeployAll.s.sol:30says "a multisig on mainnet".docs/keys.md:39anddocs/mainnet.md:33say an EOA (the user's choice of 2026-10-29). SPEC:437 only recommends a multisig.CheckDeployment.s.sol:90–92only logs a note.- I-4 (fork test hides a wrong router artifact):
ArbitrumOneMainnetFork.t.sol:80–86behaves as described. Nothing later in the test pins the router orboxCodeHash, so a different build written asDepositRouter.jsonwould pass.- Production is guarded:
DeployRouter.s.sol:33requirespredicted() == ROUTERon its own legacytype(DepositRouter).creationCode.- The escrow asks the router for box addresses (
YescrowEscrow.sol:792).- So only test fidelity is at stake: Info.
- The suggested fix works, because coverage runs as
FOUNDRY_PROFILE=coverage(scripts/check.sh:19,foundry.toml:47–52). Alternatively,vm.isContext(VmSafe.ForgeContext.Coverage)from the vendored forge-std avoids relying on an environment variable.- I-5 (the 2,300 value stipend):
- The call is
createDisputeForTemplate{value: cost, gas: ARBITRATOR_GAS}(YescrowEscrow.sol:543). The callee had 5,001,495 gas left, more than the 5,000,000 cap, which only the 2,300 value stipend explains.- The 5,002,300 figure at entry is inferred (805 gas of dispatch), not measured.
- No document is wrong.
security.md:65says "a fixed budget of 5M", and the budget is fixed whatever gas the caller sends. A callee needing 5,010,000 is still a refusal at 30M gas.- I-6 (stale cache entry): the provenance is wrong, and it isn't a property of the code under review.
- Where it came from:
- The entry's
lastModificationDateis 10:16:25 UTC, five minutes after the export's file times (10:11:35).- It was compiled in build
946a6067…at 10:17:18, which leftout/Probe.t.sol/ProbeTest.json.- So it was a probe the pentest setup created in
baseand then deleted.- It isn't in the checkout:
- The checkout's cache (09:51) has 93 entries, none of them missing on disk.
contracts/cache/is gitignored (.gitignore:8).- All seven area copies' caches were rewritten between 11:29 and 11:33 without it.
- No ERROR reproduced:
- In a throwaway copy with the entry put back, forge 1.8.5 logged no ERROR on
build,test(including atest/pentestglob),scriptorverify-contract.build,testandscriptsilently pruned the entry on their first run.
Checked and found sound
Build pipeline: the tests run what deploys.
- contracts/cache/solidity-files-cache.json and out/build-info: one via-IR job (build cbd4ea1d…) compiles
YescrowEscrow.sol, RdapVerifier.sol, DualRdapVerifier.sol, DeployAll.s.sol, Deploy.s.sol,
CheckDeployment.s.sol, Base.t.sol and every escrow test, the invariant suite and the three escrow fork tests;
one legacy job (634abc37…) compiles DeployRouter.s.sol, DepositRouter.t.sol and MultiChainFork.t.sol. So
the unit, fuzz, invariant and fork tests deploy the via-IR escrow and verifiers, and the router script and the
router's own tests deploy the legacy router. (F_DeploymentConstants.t.sol
test_F_escrowAndVerifiers_testBuildEqualsDeployArtifact_andFitsEip170: type(X).creationCode in a test equals
vm.getCode of the artifact for the escrow and both verifiers; the deployed escrow is 22,948 bytes.)
- Artifacts carry the right settings: YescrowEscrow.json, RdapVerifier.json, DualRdapVerifier.json are viaIR
true; DepositRouter.json / DepositBox.json are the legacy builds and *.via-ir.json the others; all 200 runs,
cancun, no metadata hash, no CBOR.
- The legacy router artifact's init-code hash 0x520ee3…e491 lands at 0x7073f01f36773e8A67731d287365191Ab207Cb3D
through the keyless proxy, and is byte-identical to the init code in the recorded deployments
broadcast/DeployRouter.s.sol/421614 and /84532: the v0.30 compiler split did not move the router. The via-IR
router (what a file that also imports the escrow gets, as the escrow unit tests do) would land at
0x34600Fa5…a8a1 with another boxCodeHash, so every deal would get other deposit addresses; DeployRouter.run's
predicted() == ROUTER refuses it (test_F_routerArtifact_isLegacy_andLandsAtNetworksROUTER_viaIrBuildWouldNot).
- forge verify-contract --show-standard-json-input submits viaIR true for the escrow and the verifiers (F-2 for the
router).
- The service's Anvil harness (service/test/harness.ts line 57) and scripts/export-abi.sh read
contracts/out/<Name>.sol/<Name>.json: the via-IR escrow and the legacy router, as deployed.
via-IR codegen.
- No expression in YescrowEscrow.sol, DepositRouter.sol, DepositBox.sol, RdapVerifier.sol, Domains.sol
evaluates two side-effecting sub-expressions (the one documented semantic difference between the pipelines:
argument evaluation order). Increments in the verifier are single-effect (c[++i], out[n++], j = n++).
- Every assembly block is memory-safe as annotated: _read writes only at and after the free memory pointer without
bumping it (allowed by the Solidity memory-safety rules) and copies one word back; Domains.tldOf and
RdapVerifier._find keccak over slices inside allocated arrays; _unescape shrinks a length to at most its
allocation. _read returns ok=false on short returns (returndatasize check) and reads word 2 of KlerosCore's
5-word disputes getter, matching the real core (fork test: period 0 before applyRuling).
- gasleft() is non-movable in Yul, modifiers have a single _, return inside the try runs nonReentrant's
tail, immutables and OpenZeppelin's ShortString EIP-712 are via-IR safe. The vm.getBlockTimestamp() note in
docs/security.md is a test-only artefact (within one call the real EVM's timestamp is constant).
The Kleros gas budget on the via-IR build, against the real resolver (F_RealKlerosGasFloor.t.sol).
- A real AI-court case through Kleros's resolver with the real 1,428-byte template costs 727,474 gas (General Court
710,292): 15% of the 5M budget.
- The least gas with which payArbitrationFee doesn't revert GasTooLow is 5,348,481 (169,116 of work before the
check: the cold arbitrationCost call, two SSTOREs and the two storage-string copies); at exactly that gas the real
case is created in court 34 (Disputed, heldFee = the fee), and a callee that needs 4.96M gas still had
5,001,495 at entry: the 100,000 margin covers everything between the check and the call. A callee needing more
than the budget is a refusal at any caller gas (30M), so the caller can't choose between Kleros and the judge.
- The relayer sends estimateGas × 1.2 (service/src/relayer.ts 39–46), which clears the floor.
DeployAll.s.sol (F_DeployAllGuards.t.sol). At the fork block the router isn't on Arbitrum One and check
refuses to run ("deposit router not deployed"); with it deployed the launch settings pass, and each of the thirteen
wrong settings is refused with its own message: another deployer, a used deployer (nonce 1), an EOA judge, judge =
fee wallet, fee recipient or judge = deployer, each window below its floor, PROMO_ENDS past 366 days or in the
past, yescrow's attestor = Reclaim's, a short policy URI, and a resolver whose arbitrator() is another court.
settings() on mainnet defaults to 24 h / 72 h / 72 h, 1%, 25%, 2,000e6, no promo key. The deploy order (two
RdapVerifier, DualRdapVerifier, USDC escrow, USDT escrow) with computeCreateAddress(deployer, getNonce) before
each escrow and the post-deploy requires binds each template to its own escrow; the --slow broadcast and the
rehearsal script exercise the real key on a local fork only.
Promised addresses. CREATE(0xA8C2…D14D, 3) = 0xe13E539Ac355CDe25eA62e6591Ba2E3870227aBa and nonce 4 =
0x3A73eC53e860ad4F41bD9499102A13B6F3011fbB (cast and vm.computeCreateAddress); at block 519,194,000 the deployer
has nonce 0 and balance 0, both addresses are empty, the CREATE2 proxy is present; nobody but the key holder can put
code there (CREATE), and since v0.30 nothing depends on the listing anyway.
Networks.sol against Arbitrum One at block 519,194,000 (test_F_networks_*, test_F_routerRoutes_onArbitrumOne).
USDC 0xaf88…5831 (6 decimals, "USDC"), USDT0 0xFd08…Cbb9 (6 decimals, name "USD₮0", EIP-3009
authorizationState); KlerosCoreNeo 0x991d…22ea version 0.10.0, governor 0xf1C7…0c59 (the resolver's governor is
the same key); the resolver 0xb552…4323 points at the core, is on the allow-list and uses the template registry
0x0cFB…a5A2 (339 templates); the evidence module 0x48e0…1EB3 accepts evidence; court 1 (parent 0) feeForJuror
0.005 ETH, court 34 (parent 33) 0.00027 ETH, jurorsForCourtJump 7, periods 600/2,700/1,800/129,600 s, both
hidden-vote, enabled, supporting dispute kit 1; arbitrationCost(abi.encode(1,1,1)) = 5e15 and (34,3,1) = 8.1e14
wei, exactly what the escrow's extraData encodes; the router's route: CCTP V2 TokenMessenger 0x28b5…cf5d and
USDT0's OFT 0x14E4…8D92 (token() = USDT0, a quote to Ethereum = 1.72e13 wei). Floors 24 h / 72 h / 72 h equal
SPEC §9's minimums.
CheckDeployment.s.sol covers the token and its decimals, router, arbitrator, both court settings, the small-deal
cap, fee recipient, fee, referrer share, window floors, the given verifier (and the dual verifier's two attestors and
TLD table), the resolver and its arbitrator, the judge (and that it has code on mainnet), the EIP-712 domain, and a
simulated case in each court from the escrow's own address, passing on a correct deployment
(test_F_checkDeployment_passes_theRightDeployment). Gaps: F-1.
Unconfirmed
- solc 0.8.30 via-IR known bugs. The live solc bug list wasn't fetched (offline); from memory no published via-IR
bug affects 0.8.30, and the code review above found none of the historically affected patterns (argument-order with
side effects, non-memory-safe assembly, multi-
_modifiers). - Court 34 is the "Agentic Commerce Court". Court names live only in Kleros's config/policy files; on-chain it is consistent with docs/dependencies.md §1.8 (parent 33, fee 2.7e14, 10/45/30 min and 36 h periods, jump 7).
- Arbiscan/Sourcify acceptance of the via-IR escrow (
--guess-constructor-argswith the 1.4 KB template in the constructor args) wasn't exercised on a network; the standard JSON forge would submit is correct. - Browser wallets' gas estimates at the 5.35M floor. Arbitrum's
eth_estimateGasbisects to the first non-reverting limit, so it clears theGasTooLowfloor; a wallet that caps or trims the estimate would make the answering party's own transaction revert (retry within the 72-hour fee period). Not provable offline; the relayed path is fine. - A failed CREATE mid-
DeployAll(for example the USDC escrow's transaction dropped after the verifiers) moves the deployer's nonce, so thegetNonce == 0guard blocks every rerun and the promised addresses become unreachable; since v0.30 no money depends on them, but the runbook has no recovery step.
Pentest report — Area G: off-chain parts
Scope (area G): only where an off-chain part could make a party lose money or sign something it didn't mean — the
relayer, the keeper's automatic acceptances and payouts, the prover's parameter checks, the attestor patch, sanctions
screening, and the web app's signing dialogs. Commit under test: 3ca0628.
Headline: I found no way for an off-chain part to steal, lose, double-pay or misdirect a buyer's or seller's
escrowed funds, and no way to make a party sign a harmful payload. The off-chain services hold no key that can move a
deal's money (verified against docs/keys.md), and the contract rejects anything the relayer/keeper would submit that
a signature or deadline didn't authorise. The reportable issues are one Medium (yescrow's own ETH, griefing, with a
plausible escalation to a seller loss) and two Low/Info. The bulk of the review is in "Checked and found sound".
Finding G-1 (Medium) — Sponsored arbitration fees let a zero-ETH attacker drain yescrow's daily ETH budget for free and deny the sponsored-dispute path to honest no-ETH parties
Attacker: any user, controlling two wallets that hold no ETH (buyer + seller of the same deal), or just a no-ETH
buyer issuing frivolous challenges. Preconditions: the service runs the relayer with SPONSOR_DAILY_WEI > 0
(SPEC §14.17, mainnet is meant to sponsor AI-court fees); the deal price is <= smallDealMaxPrice (the AI court).
Root cause: service/src/sponsor.ts sponsorship() (lines 26–47). It grants { sponsored: true } to any party
whose wallet holds < feeWei + 0.0001 ETH, in the AI court, once per (escrow, dealId, role), within the global
daily budget. There is no check that the two sponsored sides of one dispute are different actors, and no cap on
the number of deals — only SPONSOR_DAILY_WEI. So one actor gets yescrow to pay both fees of a dispute it fully
controls, on as many fresh deals as it likes. The arbitration cost forwarded to Kleros (cost ≈ A) is never refunded
(only heldFee = 2A − cost returns), so each sham dispute that reaches a Kleros case costs yescrow ~A of ETH while
costing the attacker nothing (every step is relayed; the USDC deposit is refunded on a concession).
Steps:
1. Attacker owns buyer and seller, both with 0 ETH. Buyer funds a small-deal-priced deal from the deposit address
(no gas); the keeper/relayer proposes + accepts (no gas).
2. Seller dispute (signed; relayer sends it and pays A — sponsored). → AwaitingFee.
3. Buyer payArbitrationFee (signed; relayer pays A — sponsored). → Kleros case created; A is forwarded to Kleros for
good.
4. Seller cancel → Refunded: the buyer's full deposit is credited back; the heldFee = A goes to the relayer.
Net: relayer paid 2A, recovered A, is out exactly one fee A; attacker is whole.
5. Repeat on new deals until spentToday + feeWei > SPONSOR_DAILY_WEI. A legitimate no-ETH seller mid-dispute then
gets FEE_NOT_SPONSORED and must pay from an empty wallet or lose the deal by fee-default (SPEC §7.1).
Impact: yescrow loses up to ~SPONSOR_DAILY_WEI/day of relayer ETH to shams at zero attacker cost (loss of
yescrow's funds + griefing = Medium). Escalation toward High: exhausting the budget denies the advertised
sponsored-dispute path to a genuine unbanked no-ETH seller who delivered the domain; if they can't fund the fee before
feeDeadline, feeTimeout refunds the buyer and the seller loses both the domain and the payment — a party forced
into a loss under plausible conditions.
PoC commands (from .../pentest/G/contracts):
- Off-chain (the service decision that makes it free and drains the budget — needs only node):
node ../scripts/pentest/G_sponsor_drain.ts
→ both sides of one deal sponsored: true; 12 fees over 6 shams consume the whole 0.01 ETH budget; an honest no-ETH
seller is then refused.
- On-chain money flow (exact ETH loss and full attacker refund):
forge test --match-path 'test/pentest/G_SponsoredFeeDrain.t.sol' -vv
→ relayer ETH lost per sham dispute (wei): 10000000000000 (= A), attacker USDC recovered: 1000000000 (full).
Suggested fix: cap sponsorship per funder/deal more tightly and tie the two sponsored sides to distinct, non-colluding actors where possible; at minimum refuse to sponsor both sides of one dispute (sponsor only the party that is answering an opponent who paid its own fee), require a small refundable anti-spam bond or a minimum deal age, and alert/rate-limit when the daily budget is being consumed by many fresh small deals from correlated addresses. The budget bound is the current mitigation; the gap is that it is drainable for free.
Verification of G-1:
Verdict: CONFIRMED (Medium), with corrections below.
Proof of concept: -
node ../scripts/pentest/G_sponsor_drain.ts(fromG/contracts): PASS. One deal gets both sides{ sponsored: true }. 12 fees across 6 shams book 0.00972 ETH of the 0.01 ETH budget. Then deal #7 and an honest seller with no ETH are refused. -forge test --match-path 'test/pentest/G_SponsoredFeeDrain.t.sol' -vv: PASS. The relayer loses 10000000000000 wei (the mock's A) per sham; the attacker's 1,000 USDC comes back in full. - My testcontracts/test/pentest/verify/V_G-1_OneSponsoredSide.t.sol: 2/2 PASS (see Reason).Sources unchanged: yes.
diff -ragainstbaseshows only the PoC files andREPORT-G.mdadded.What I checked: -
sponsor.tsand its only caller,/v1/relay(app.ts:1010–1030). Sponsorship looks only at (escrow, dealId, role), the party's ETH, the AI court and one global 24-hour sum. Nothing links fees to an actor or caps deals. The Node PoC's stand-in database matches migration 008: primary key (escrow, deal_id, role), no refund tracking. - The Foundry PoC. The attacker's own keys sign every step.vm.prank(seller)stands in for a signed step the relayer would send, with the same end state.MockArbitratorkeeps the fee, as KlerosCore does. Novm.storeorvm.etch. The refunds match_close(YescrowEscrow.sol:1027–1044). - Not a known issue (docs/audit.md§5). SPEC §14.17 sets exactly these limits, without considering one actor owning both sides. - Mainnet AI-court fee: 0.00081 ETH (≈ $2), per the fork test.Reason (corrections; the severity stays Medium): 1. Impact figure. The PoC books 2A of budget per sham but loses only A, because the held fee comes back. That drains about half of
SPONSOR_DAILY_WEIa day. The whole budget can be lost another way, which I verified: sponsor only the answer, and have the attacker pay the opener's fee from a third wallet, then concede the other way to win it back. The relayer loses A for each A of budget. The attacker ends whole: the price is 1 base unit (the contract only requires price ≠ 0), so the 1% fee rounds to 0. 2. The denial of service costs yescrow nothing. A sponsored dispute conceded before the answer refunds the relayer in full (verified). ButspentToday()(sponsor.ts:20–23) still counts the row for 24 hours. One actor can keep the budget at zero for free, and the relayer's balance shows no loss. 3. Root cause.sponsor.ts:26–47is right. Addsponsor.ts:20–23: one global pool that counts gross fees and ignores refunds. 4. Fix. "At minimum, refuse to sponsor both sides" doesn't stop the loss (point 1 sponsors one side). Better: - Count only fees that reached Kleros, net of refunds. - Give each deal or wallet pair a small share of the budget, with a minimum deal age. - Alert the operator when fresh small deals use most of the budget. - Document that self-dealing can take the whole budget daily, and keep it small. 5. Precondition.SPONSOR_DAILY_WEIis 0 in.env.exampleand.env.mainnet.example. The attack exists only once the operator turns sponsoring on, as SPEC §14.17 plans.Why not High: the loss is yescrow's own ETH, capped by the budget the operator sets: on this scale, a loss of yescrow's fees. The PoC doesn't show the escalation, an honest seller losing by fee default; it only shows
sponsored: false. That needs a victim who can't get about $2 of ETH within the 72-hour fee period, and an attacker who keeps the rolling 24-hour budget drained for all 72 hours. SPEC §14.17 says an unsponsored party pays its own fee. Still, note the targeted version: a buyer challenging a delivered domain while draining the budget reopens the gap §14.17 closed.
Finding G-2 (Low) — The keeper proposes/accepts deals for sanctioned parties from deposit addresses, bypassing the API's sanctions screen
Attacker/condition: a buyer or seller on OFAC's SDN list, using the deposit-address funding flow. Root cause:
screening is a Fastify preHandler hook that only runs on API requests (service/src/sanctions.ts
registerScreening / screened). The keeper's deposit path (service/src/keeper.ts deposits() → propose /
acceptFromDeposit, lines 260–297) runs in a background loop and calls this.screen nowhere — only
sendPayouts() screens (line 179). So when a listed party funds a deal's deposit address, yescrow's own relayer
submits the propose/accept that creates and activates the deal, even though POST /v1/relay//drafts for the same
party would be refused with SANCTIONED_ADDRESS.
Impact: a compliance gap, not a direct money loss: no funds reach the sanctioned party through this path (the
escrow just holds the buyer's USDC; the eventual payout is screened by sendPayouts, and an on-chain withdraw is
permissionless anyway). SPEC §12 scopes keeper screening to "automatic payouts", so this is arguably within spec, but
it means the keeper actively advances deals the API declines to touch. No runnable PoC (would require the full
stack); confirmed by code reading at the lines above.
Suggested fix: screen terms.buyer/terms.seller in keeper.deposits() before propose/accept (and in
forwardFromDepositChains), skipping (not reverting) a listed deal, mirroring sendPayouts.
Verification of G-2:
Verdict: CONFIRMED (Low), by code reading only. This finding has no proof of concept.
Proof of concept: none. The attack agent supplied none (
contracts/test/pentest/holds only G-1's test), and I ran none: the gap is in TypeScript (service/), which a forge test can't exercise, and a live run needs the full stack. Every claim below was checked by reading the code at commit 3ca0628.Sources unchanged: yes.
diff -rqof the attack copy against the pristine export differs only incontracts/test/pentest/,scripts/pentest/andREPORT-G.md.What you checked (each claim): - Screening runs only in the API: true.
registerScreening(sanctions.ts:141–151) is a FastifypreHandler, registered only at api/app.ts:131.relayer.tsdoesn't screen. - The keeper's deposit path never screens; onlysendPayoutsdoes: true.this.screenappears once, at keeper.ts:179.deposits()/acceptFromDeposit()(260–297) callpropose/acceptunscreened, although main.ts:52 hands the keeper the screen. - The API would refuse the same calls: true. A/v1/relayproposecarriestermsandproposer; anacceptcarriesdealId, whose parties the hook loads fromdeals.POST /v1/draftsis screened onterms. - No funds reach the listed party on this path: true.propose/acceptwith empty funding pull the box into the escrow (_fund→_pullDeposit). The keeper'sreturnDepositwith the deal's token only creditsusdcBalance[buyer](YescrowEscrow.sol:656–671). - Within spec: true. SPEC §12 and security.md promise keeper screening only for automatic payouts. It isn't a known issue in audit.md §5.Reason (severity and preconditions): Low fits: no party loses money, no false release, no deadline moves. It isn't Info, because it breaks the design's stated intent (sanctions.ts:4: "anyone can still call the contracts directly; yescrow takes no part"). Here yescrow's relayer pays the gas, and its prover makes the "before" proof, for a deal the API would refuse.
The preconditions differ between the two halves: -
accept(realistic): a seller proposes directly on-chain, with no API call. The watcher indexes everyProposed(watcher.ts:85). The deposit address is in the unscreenedGET /v1/deals/{id}(app.ts:561) or the on-chaindepositAddress(). Once the price lands, the keeper accepts. Neither party ever makes a screened request. -propose(narrow): drafts come only from the screenedPOST /v1/drafts, so this needs a party listed after its draft was made (within 30 days).Corrections to the fix: 1. A stronger case the fix misses (code reading, untested). The watcher records every cross-chain
WithdrawnTo(watcher.ts:91), including one a listed party sends directly.completeTransfers()(keeper.ts:355–384) then has yescrow's remote relayer call CCTPreceiveMessage(line 377), which mints USDC to the listed recipient on Base or Ethereum. This is the only keeper path that actually delivers tokens to an address. Mitigating: the burn'sdestinationCalleris 0 (DepositRouter.sol:226), so the party could complete the transfer themselves. Fix: screenaccount/recipientof outbound transfers there, and leave a listed one pending. 2. The rest of the tick is also unscreened:submitProof(with yescrow's prover),release,refund,expire,feeTimeout,applyRuling,judgeTimeoutandreturnDeposit, though/v1/relayrefuses each of them for a listed party's deal. Leaving the deadline calls unscreened is defensible: they protect the non-listed party and only credit pull balances. So the reporter's narrow fix is right, and SPEC §12 should say so explicitly. 3. Fail closed: indeposits()andforwardFromDepositChains(), treatSCREENING_UNAVAILABLEas "skip", assendPayoutsdoes, not as "proceed".
Finding G-3 (Info) — settle typed-data summary shows the deployment fee, not the deal's promo fee
service/src/api/app.ts (settle branch of GET /v1/deals/{dealId}/typed-data) passes e.params.feeBps to
settleTypedData, not dealFeeBps(e, d). If a promo voucher already lowered the deal's fee, the human-readable
summary overstates the fee and understates the seller's take. The signed Settle message carries only sellerAmount
(the fee is computed on-chain from the deal's current fee), so there is no money impact — the seller merely
receives at least as much as the summary says. Info only.
Verification of G-3:
Verdict: CONFIRMED (Info)
Proof of concept: none was supplied. I checked the claims by reading the code, plus two checks of my own: -
node --input-type=module -e …on the realsettleTypedData(service/src/typed-data.ts): with feeBps 100, as app.ts passes, the summary reads "2475 USDC to the seller (after the 25 USDC fee) and 2500 USDC back to the buyer". With the deal's waived fee it would read 2500 and 0. The signedSettlefields contain no fee. -forge test --match-path 'test/{Promo.t.sol,pentest/verify/V_G-3_SettleSummary.t.sol}' -vv(run from G/contracts) passed 13/13. The new test iscontracts/test/pentest/verify/V_G-3_SettleSummary.t.sol. Setup: price 5,000 USDC, sellerAmount 2,500, deployment fee 100 bps. - Voucher to 0 bps before signing: seller credited 2,500 (summary: 2,475), fee 0 (summary: 25), buyer 2,500 (summary: 2,500). - Voucher to 50 bps after both parties signed: the signatures still work. Seller 2,487.50, fee 12.50, buyer 2,500. - Existing Promo.t.sol: 11/11, includingtest_onlyLowers(FeeNotLower) andtest_settlementInADispute_usesTheLowerFee.Sources unchanged: yes.
diff -rq base Gshows only build output, REPORT-G.md, contracts/test/pentest/ and scripts/pentest/.What I checked: - app.ts:775 passes
e.params.feeBps. The helperdealFeeBps(app.ts:317) exists and is used for the deal view (:555) and the payout typed data (:847), but not here. -e.params.feeBpsis the escrow's on-chain immutablefeeBps()(escrow.tsloadEscrow), so it is never below a deal's fee: -applyFeeVoucherreverts withFeeNotLowerunless the new fee is lower. -promoEndsonly stops new vouchers; a promo already applied stays. - The judge's 0 fee is set only insidejudgeRuling, which closes the deal. -SETTLE_TYPEHASHbinds dealId, domain, parties, price, sellerAmount, caseCreated and deadline._closechargessellerAmount × _feeBpsOf(d) / 10000at submission. - Web app: - The Settlement panel (Dispute.tsx) takes the gross "Amount to the seller (before the yescrow fee)". Its offer card shows the gross amount and the buyer's refund, both exact. - SignReview.tsx shows the server's summary word for word as the dialog's headline, then the signed fields exactly. - The deal page's Terms row shows the deal's real promo fee, so the dialog contradicts the page. - Not in audit.md §5, and not intended: §12 requires typed data that shows the terms (met); the summary is extra.Reason: severity stands. No one can be misled into signing something worse than shown: - The buyer's refund (price − sellerAmount) is exact and doesn't depend on the fee. - The seller's net is understated by sellerAmount × (feeBps − deal fee) / 10,000. At 100 bps that is at most 1% of sellerAmount. - Only yescrow's fee recipient and the referrer get less than the summary implies, and neither signs
Settle.One theoretical side effect: parties who bargain over the seller's net as the summary states it could agree on a sellerAmount up to about 1% higher than needed, at the buyer's expense. Even then, each signer's own figure is exact. Info stands.
Correction to the root cause and the fix: the same bug is at app.ts:786, where every other action's typed data gets
feeBps: e.params.feeBps. Only theconfirmsummary uses it. On a deal with a waived fee, the buyer is told "Release 4950 USDC to the seller (the 50 USDC fee goes to yescrow)" while the seller actually receives 5,000. It errs the same way and has the same severity, because the buyer releases the full price either way.Fix: pass
dealFeeBps(e, d)at both line 775 and line 786. A voucher can still land after signing, so the summary's fee stays a ceiling, which errs the safe way. Both lines are unchanged at HEAD 9d7f12c and in the working tree (checked read-only).
Checked and found sound
- Automatic payouts can't over-pay or double-pay (SPEC §14.14).
payouts.tspayoutAmountcomputes the seller's net with the same integerfeeOf(price,feeBps)the contract uses onrelease(_close,fee = sellerAmount*feeBps/BPS), socredited == signedexactly; a promo applied after signing only raises the credit, andcreditOfsends the signed (smaller) amount with the remainder left as balance.keeper.sendPayoutsskips whencredited < amount, skipsSettled/split outcomes, and the contract re-checks the signature, deadline, one-time use (withdrawalUsed) and that the balance covers it — so a replay, a wrong amount, or a send after a manual withdraw all revert. The signature is only ever submitted withrecipient = account = signer, verified at save time. - No signature crosses deal/escrow/chain/phase/amount or works twice. EIP-712 domain binds
yescrow/1/chainId/ escrow; actions bind the deal ID and are valid in one state only;SettlebindssellerAmountandcaseCreated;WithdrawTois one-time by digest; EIP-3009 funding nonce = deal ID. The web requests typed data byroleand the server derives the signer/account, so a mismatched role yields a signature that failsverifyTypedDataat save (checked inpayouts/alerts/emailhandlers). - The relayer can't move a party's money or call arbitrary targets.
relayer.tsonly ever sends to an escrow / router / token / Kleros address derived from config or the resolved deal, or an allow-listed wallet factory; it simulates first; every money step still needs the party's signature (verified on-chain). ERC-6492 deploy (erc6492.ts) is restricted to the two Coinbase factories and only when the call creates the signer's own address. - Deposit router isolation (
DepositRouter.sol,DepositBox.sol). A box is swept only by the escrow in its salt on the settlement chain (pull), or bridged only to the same box address on the settlement chain (forward, CCTPmintRecipient/USDT0to), or returned to the salt's buyer (returnStray).minAmountLD = amountand CCTPmaxFee 0mean nothing is skimmed in transit. A deposit can go nowhere but its deal or back to its buyer. - The prover's parameter check is defence-in-depth and can't forge an on-chain-valid proof (
prover/src/server.ts). Even if a caller supplied craftedresponseMatches, the on-chainRdapVerifierrebuildsparametersfrom the domain and checks the attestor's signature overkeccak256("http\n"‖parameters‖"\n"‖context), so any deviation from the fixed template yields a different identifier and fails the signature check.server.tsadditionally pinsurl == rdapUrl(domain)andmethod == GET. - The attestor patch is wired correctly (
attestor/yescrow-hardening.diff).AttestorVersion.ATTESTOR_VERSION_3_2_0is a real enum member (= 7) in attestor-core's proto, soclientVersion < ATTESTOR_VERSION_3_2_0genuinely refuses pre-3.2.0 clients; the server-record-fully-revealed and response-completeness checks are placed before body parsing and before signing. Closing the "old client / hidden-byte" vector (audit §V1/V2) on yescrow's side is sufficient because aDualRdapVerifierproof needs that attestor too. - Sanctions screening of the signing paths.
screened()catches everyPOST /v1/*write and every GET whose URL containstyped-data(verified:payout-typed-dataandwithdraw-typed-datamatch the substring regex and are screened);addressesInscans body/terms/params/query address fields and the hook also loads the deal's parties.isListedfails closed (throwsSCREENING_UNAVAILABLE), andkeeper.sendPayoutstreatslisted !== false(including the throw → null) as "skip", so an unavailable list never pays a listed address.MIN_ADDRESSES = 50guards against a truncated list. - The web signing dialogs show exactly what is signed (
SignReview.tsx,SignDialog.tsx,Warnings.tsx): every EIP-712 field and the domain/verifying-contract/chain are rendered with the raw payload in a details pane, risk warnings must be acknowledged first, and amounts use the deal's token symbol. The relayer-can't-change-this note is accurate. No field is hidden or relabelled in a way that changes its meaning. - Admin / promo / referrer-share paths touch only yescrow's own fee.
admin.ts/promo.tsvouchers only lower the fee (contract enforcesFeeNotLower,promoEnds);setReferrerShareneedsfeeRecipient's signature and splits only the fee betweenfeeRecipientandreferrer, never a party's amount; admin sessions are EIP-712, 12 h, and a removed wallet loses its session (checked each request). A leaked admin/promo/fee-recipient key matchesdocs/keys.md. - Buy links / wizard / referral are untrusted-input safe (
web/lib/buy-link.ts,wizard.ts,referral.ts): strict validation, HTML/BBCode escaping, the referrer can only be a validated address (and the API rejects a referrer that is a party), and a hostile link's worst case is locking the buyer's funds until the transfer deadline (SPEC §4.2), refunded after. - Deployment settings match SPEC §14.20 and
Networks.sol(DeployAll.s.sol): escrows at the deployer's nonces 3 and 4 (= the allow-list-requested addresses, asserted on mainnet); feeBps 100, referrerShare 2500, windows 24h/72h/ 72h, smallDealMaxPrice 2,000e6, courts 34 (3 AI jurors) and 1 (1 juror), Classic dispute kit, resolver0xb5526D…4323; the mainnet branch refuses any other deployer, a non-contract judge, judge == fee wallet, a used deployer nonce, and promoEnds > 1 year. Confirmed consistent with the mainnet fork test's assertions. - Keeper auto-accept window caps match the contract.
MAX_AUTO_TRANSFER_WINDOW/MAX_AUTO_CHALLENGE_WINDOW(60/30 days) equal the contract'sMAX_TRANSFER_WINDOW/MAX_CHALLENGE_WINDOW, so a deposit-paying buyer is never locked longer than SPEC §9 promises. - Judge service holds no key and only builds the judge wallet's tx (
service/src/judge.ts): reasons are hashed, thejudgeRulingtx is for the judge Safe to send, and the on-chain_closefee/ETH accounting on refused/stalled/ timeout paths credits each wei once (traced against SPEC §8 and the mainnet fork test).
Unconfirmed (reasoning, no proof)
- RE2 vs JavaScript
RegExpdivergence between the two attestors (docs/security.md§141–142: yescrow's attestor runssgiJS RegExp, Reclaim's runssgiuRE2). A crafted registry response that the two engines parse differently would makeDualRdapVerifierrevertFactsDiffer— a liveness risk (a legit seller can't get a proof), not a forgery, since a false fact would need both engines to agree on the same wrong capture. A malicious registry can already deny proofs by serving garbage, so this adds no capability. Not pursued: proving it needs the real attestor image (Docker), which is out of scope here. - DNS-rebinding SSRF on webhook and Web-Push endpoints (
webhooks.tsassertPublicUrl, used byPOST /v1/webhooksandPOST /v1/deals/{id}/alerts). The private-range check resolves the host once; the laterfetch/web-pushre-resolves, so a host that returns a public IP at check time and a private IP at send time yields a blind POST to an internal address (responses aren't returned to the attacker). No deal money is involved; worth pinning the resolved IP or re-checking at send time. - Idempotency key can be bricked: if the process dies between the
idempotencyINSERT (status NULL) andonSendfor a non-5xx response, that(key, route)returnsIDEMPOTENCY_IN_PROGRESS(409) forever. Availability only; the client picks a new key.
Part 3. Appendices
A. The attack prompt, word for word
Each attack agent got this prompt with {AREA BRIEF} replaced by its row of the table below (as <letter>. <name>: <brief>), from docs/ai-pentest.md at commit 3ca0628:
You are attacking yescrow, an escrow on Arbitrum One that holds buyers' USDC or USDT0 until a domain transfer is proven or a dispute is decided. Its contracts are immutable: a bug can't be fixed after launch, so assume every weakness you miss costs real people their money. Your job is to find ways to lose, steal, lock or misdirect money, or to make the contracts do what the specification says they must not, and to prove each one.
Read first:
SPEC.md(the rules; §5 state machine, §6 proofs, §7 disputes and §7.5 yescrow's judge, §8 money, §12 trust),docs/audit.md(scope, invariants, trust assumptions, known issues),docs/keys.md(what each key can and can't do),docs/security.md. Code:contracts/src/,contracts/script/,prover/src/template.ts,attestor/yescrow-hardening.diff, and the parts ofservice/src/that submit transactions or sign anything.Attackers to play: the buyer; the seller; any third party; a referrer; a front-runner; a party that withholds gas, ETH, a signature or a case; a smart-contract wallet (EIP-1271, ERC-6492, EIP-7702); a registrar or registry serving crafted RDAP; one compromised attestor; yescrow's relayer, keeper, prover or API; the holder of a key in
docs/keys.md(only within the powers listed there: show any power beyond them); Kleros's resolver and governor.Your area: {AREA BRIEF}
What counts as a finding: any party loses money it shouldn't, or someone gets money they shouldn't; a deal is paid twice, or a final state changes; a party can force the other into a loss or past a deadline the specification promises; a proof passes for a fact the registry didn't state; a signature works for another deal, escrow, chain, phase, amount or a second time; a deposit goes anywhere but its deal; a key does more than
docs/keys.mdsays; an invariant indocs/audit.md§3 breaks; a deployment setting differs from what the specification andcontracts/script/Networks.solpromise.Proof required. For every finding, write a Foundry test in
contracts/test/pentest/that passes and shows the impact in token amounts, on a fork of Arbitrum One where real contracts matter (real USDC, USDT0, Kleros, CCTP) or with the real proof fixtures incontracts/test/fixtures/. For off-chain parts, a script inscripts/pentest/against a local stack. Don't changecontracts/src/to make an attack work. Never send a transaction to a live network. Ideas you can't prove go in a separate list, "unconfirmed", with your reasoning.Report each finding as: title; severity (Critical: any party's funds stolen or permanently lost, or a false release. High: a loss under plausible conditions, or a party forced into one. Medium: griefing, delays past promised deadlines, or loss of yescrow's fees. Low and Info otherwise); attacker and preconditions; steps; impact; the command that runs the proof of concept; root cause (file and line); suggested fix.
Also report what you checked and found sound, with a sentence on why, so readers can see how deep you went. Don't report the known issues in
docs/audit.md§5 unless you can make them worse than described there.
Area briefs
| Area | Brief |
|---|---|
| A. State machine and money | Every transition of SPEC §5, each deadline at, just before and just after its boundary; balances, withdraw, withdrawTo; the fee, promo vouchers, the referrer share and fee payers; rounding; USDT0 and USDC quirks (blocklists, fees on transfer); donations; reentrancy through token or ETH transfers; the 63/64 gas rule |
| B. Signatures | EIP-712 domains and type hashes; EIP-3009 with the deal ID as nonce; EIP-1271, ERC-6492 and EIP-7702 wallets; settlements bound to their phase; payout signatures the keeper submits later; malleability; replay across deals, escrows, tokens, chains and phases |
| C. Proofs | RdapVerifier, DualRdapVerifier, Domains, RdapStatus; the two attestations agreeing; the parameters rebuilt from the domain; the template's regexes as RE2 runs them versus what Solidity checks; full reveal; JSON escapes, Unicode and IDNs, nested objects, long nameserver lists, timestamps and freshness; each TLD's registry URL; gas |
| D. Disputes | Opening cases through Kleros's public DisputeResolver (0xb552…4323 on Arbitrum One); refusal detection with Kleros's fixed 5M-gas budget and gas starvation (the answer reverts below about 5.18M gas); the resolver pointing at another arbitrator; applyRuling reading the period and currentRuling from Kleros's core through a raw staticcall (_read): malformed or short return data, a case ID that isn't the deal's, appeals; the 90-day stall; the Judging state (value 10, appended) and yescrow's judge: judgeRuling limited to rulings 0, 1, 2 and its 14 days, judgeTimeout, races between the judge, a concession, a settlement and the timeout; judged deals paying no fee; on a refused case yescrow keeping the loser's fee (half each on a split), including sponsored fees; every fee path in SPEC §7.5 and §8 |
| E. Deposit router | CREATE2 deposit addresses and who can deploy them on which chain; forwarding through CCTP V2 (recipient, destination caller, finality) and USDT0 (LayerZero options); returnStray; tokens sent by mistake; anyone deploying the router or boxes on another chain |
| F. Deployment | Since v0.30 the escrow and the verifiers compile through via-IR (contracts/foundry.toml, compilation_restrictions) and the router through the legacy pipeline: check that what the tests run is what deploys, and look for via-IR codegen surprises; DeployAll.s.sol order and guards, Networks.sol constants against what is on Arbitrum One today (check them on the fork), CheckDeployment.s.sol covering every immutable, the promised escrow addresses |
| G. Off-chain parts | Only where they could make a party lose money or sign something it didn't mean: the relayer, the keeper's automatic acceptances and payouts, the prover's parameter checks, the attestor patch, sanctions screening, the web app's signing dialogs |
B. Operating notes given to every attack agent with the prompt
These were added after the prompt, under a line saying they are not part of the published prompt. <copy> was the agent's own copy of the commit, <workdir> the session's scratch folder, <area> its letter.
Operating notes from the session running this test (not part of the published prompt):
- Commit under test: 3ca0628 (main, 2026-10-29). Your working copy: <copy>, a clean export of that commit with contracts/lib populated and contracts/ already built. Work only in that copy. The other six areas run at the same time in sibling copies; don't read or write them.
- Never touch <checkout> (the user's checkout, shared with other sessions), any .env file, any key, any Docker container (the yescrow-* containers are live stacks) or any live network. Reading public chain state through a public RPC is fine; sending a transaction is not. node_modules in prover/, service/ and web/ are symlinks into the user's checkout: don't install or change packages.
- The server has 2 cores and 4 GB of memory, shared with other sessions. Run forge only through the wrapper <workdir>/pforge (same arguments as forge, e.g. `cd <copy>/contracts && ../../pforge test --match-path test/pentest/<area>_Example.t.sol -vvv`). It takes a shared lock so only one forge job runs on the machine at a time, under a 3 GB memory cap, so it may wait for other jobs: give each Bash call a 600000 ms timeout. Always filter with --match-path or --match-test; never run the whole suite. A new test file that imports the escrow takes about a minute to compile. Don't run other heavy jobs (anvil, docker, next build, browsers) outside the lock; if you need one, run it as `flock <workdir>/forge.lock systemd-run --user --scope -p MemoryMax=2G --quiet -- <command>`.
- Fork tests: follow contracts/test/fork/ArbitrumOneMainnetFork.t.sol (`vm.envOr("ARBITRUM_ONE_RPC_URL", string("https://arbitrum.gateway.tenderly.co"))` at a pinned block; reuse its FORK_BLOCK where you can so RPC responses come from Foundry's cache). Don't set RPC environment variables. If the public RPC rate-limits you, wait and retry.
- Name proof-of-concept files contracts/test/pentest/<area>_<ShortName>.t.sol (scripts: scripts/pentest/<area>_<name>.*) so they merge with the other areas' without clashes. Don't edit files outside contracts/test/pentest/ and scripts/pentest/; you may read and reuse the existing tests, helpers and mocks.
- When you finish, write your full report to <copy>/REPORT-<area>.md and return the same report as your final message. For each finding, give the exact command that runs its proof of concept from <copy>/contracts, and keep the "checked and found sound" and "unconfirmed" sections.
C. The verifier's prompt
Each verifier got this text, with the finding pasted in where it says so, and the instruction to read it first. <workdir> is the session's scratch folder. For the findings without a proof of concept (G-2, G-3) and for area F's Info list, the instruction added that the claims were to be checked by reading the code.
You are a verifier in the AI penetration test of yescrow (docs/ai-pentest.md, "How to run", step 3). yescrow is an escrow on Arbitrum One that holds buyers' USDC or USDT0 until a domain transfer is proven (zkTLS-attested RDAP) or a dispute is decided (Kleros, or yescrow's judge for refused/stalled cases). Its contracts are immutable once deployed. An attack agent working on area <area name> reported the finding below. Your job is to try to refute it, and to run its proof of concept again yourself. You start with a fresh context: read what you need.
**The finding, as the attack agent reported it:**
<the finding, pasted from the area report>
**Where things are:**
- The attack agent's working copy (a clean export of commit 3ca0628 plus its proof-of-concept files): <copy>. Its full area report: <copy>/REPORT-<area>.md. Don't edit its files.
- A pristine export of the same commit, never modified: <workdir>/base. Use it to confirm that the attack copy's `contracts/src/`, `contracts/script/` and every other file the PoC relies on (outside `contracts/test/pentest/` and `scripts/pentest/`) are unchanged (`diff -r`).
- The rules and context: `SPEC.md`, `docs/audit.md` (§3 invariants, §5 known issues), `docs/keys.md`, `docs/security.md`, `docs/ai-pentest.md` (the attackers' prompt and severity scale).
**Do this:**
1. Check the copy's sources against the pristine export (step above).
2. Rerun the proof of concept with the reported command (from <copy>/contracts unless it says otherwise). Then read the test line by line: does it really show the claimed impact in token amounts? Look for things a real attacker couldn't do: `vm.prank` as a party that would never sign, `vm.store`/`vm.etch` into yescrow's or a real third party's contracts, `deal` beyond funding an attacker, mocks standing in for real contracts that behave differently, signatures from keys the attacker doesn't hold, assertions that pass for the wrong reason.
3. Try to refute it: is it one of the known issues in `docs/audit.md` §5 and not made worse? Does the specification allow this behaviour on purpose? Are the preconditions realistic? Is the root cause right (file and line)? Is the severity right on this scale: Critical: any party's funds stolen or permanently lost, or a false release. High: a loss under plausible conditions, or a party forced into one. Medium: griefing, delays past promised deadlines, or loss of yescrow's fees. Low and Info otherwise.
4. If you need to check a claim with code, write extra tests only as `contracts/test/pentest/verify/V_<ID>_<name>.t.sol` in that copy.
**Rules for your verdict (the user's):** a finding whose proof of concept runs and shows the claimed impact can't be dropped by your argument alone. You may downgrade (or upgrade) its severity with a stated reason, or keep it and state your challenge; the report will show the attacker's claim and your reason side by side so readers can judge. Drop a finding only if its proof of concept doesn't run, or doesn't show the claimed impact.
**Return** (under 600 words): `Verdict:` one of CONFIRMED (severity stands), DOWNGRADED to <severity>, UPGRADED to <severity>, CHALLENGED (kept at its severity, with your objection), DROPPED; `Proof of concept:` the command you ran, pass or fail, and the key numbers it showed; `Sources unchanged:` yes/no; `What you checked:` a few lines; `Reason:` your argument for anything other than a plain CONFIRMED; any correction to the root cause or the suggested fix. Also write the same text to <workdir>/verify/<ID>.md.
---
Operating notes (same as the attack agents had):
- Never touch <checkout> (the user's checkout, shared with other sessions), any .env file, any key, any Docker container (the yescrow-* containers are live stacks) or any live network. Reading public chain state through a public RPC is fine; sending a transaction is not. node_modules in prover/, service/ and web/ are symlinks into the user's checkout: don't install or change packages.
- The server has 2 cores and 4 GB of memory, shared with other sessions. Run forge only through the wrapper <workdir>/pforge (same arguments as forge). It takes a shared lock so only one forge job runs on the machine at a time, under a 3 GB memory cap, so it may wait for other jobs: give each Bash call a 600000 ms timeout. Always filter with --match-path or --match-test; never run the whole suite. Don't run other heavy jobs (anvil, docker, next build, browsers) outside the lock; if you need one, run it as `flock <workdir>/forge.lock systemd-run --user --scope -p MemoryMax=2G --quiet -- <command>`.
- Fork tests use `vm.envOr("ARBITRUM_ONE_RPC_URL", string("https://arbitrum.gateway.tenderly.co"))` at a pinned block. Don't set RPC environment variables. If the public RPC rate-limits you, wait and retry.
D. Proof-of-concept files and how to run them
All tests together, from contracts/ (171 tests in 36 suites, all passing on 2026-10-29 after forge fmt):
forge test --match-path 'test/pentest/**/*.t.sol'
The fork tests read public RPCs at pinned blocks (ARBITRUM_ONE_RPC_URL, BASE_RPC_URL, ETHEREUM_RPC_URL if set,
Tenderly's public gateways otherwise). A test that proves a finding asserts the weakness, so it will fail once the
finding is fixed; step 4 of the procedure turns each into a regression test. The tests depend on the via-IR build
(exact gas floors, artifacts, stack depth), so scripts/check.sh leaves test/pentest/ out of its coverage run.
File (contracts/test/pentest/) |
Proves |
|---|---|
A_StateMoney.t.sol |
A-1, A-2; deadline boundaries, the 63/64 gas sweep, re-entrancy, conservation fuzzing, the Kleros fee cliff |
A_ArbitrumOneFork.t.sol |
Real USDC blacklist, CCTP burn limit, a real Kleros case at the exact gas floor |
A_Usdt0Fork.t.sol |
A-2 on the real USDT0; Tether's blocklist semantics |
B_Signatures.t.sol |
B-3, B-4, B-5; signatures across escrows and chains, malleability, front-running |
B_Eip3009Fork.t.sol |
B-1, B-2; EIP-3009 on the real USDC and USDT0 |
C_InterProofBlocking.t.sol |
C-1, C-2 |
D_AppealOverride.t.sol |
D-1, on the real Kleros core, dispute kit and resolver |
D_FeePaths.t.sol |
D-2, D-3; every fee path of SPEC §7.5 and §8; the gas floor with the real template |
D_KlerosReads.t.sol |
The resolver's dispute IDs and _read against the real core |
E_ForeignForward.t.sol |
E-1 (see its verification about test_3) |
E_StrandedBox.t.sol |
E-2 |
E_RouterStray.t.sol |
E-3 |
E_Sound.t.sol |
The router's address, routes and permissions on three chains |
F_CheckDeploymentGaps.t.sol |
F-1 |
F_DeployAllGuards.t.sol |
DeployAll's guards and settings |
F_DeploymentConstants.t.sol |
Test build equals the deployed build; Networks.sol against Arbitrum One |
F_RealKlerosGasFloor.t.sol |
The 5M Kleros gas budget on the via-IR build against the real resolver |
G_SponsoredFeeDrain.t.sol |
G-1 (on-chain half) |
verify/V_<ID>_*.t.sol |
The verifiers' own checks for that finding (16 files) |
Script (scripts/pentest/) |
Proves | Run |
|---|---|---|
C_template_re2.ts |
C-1: the template under RE2 extracts 17 or more nameservers unchanged | node scripts/pentest/C_template_re2.ts (repository root) |
E_foreign_forward_keeper.ts |
E-1: the keeper completes only its own burns | cd scripts/pentest && node E_foreign_forward_keeper.ts |
F_verify-profiles.sh |
F-2: what forge verify-contract submits for each contract |
scripts/pentest/F_verify-profiles.sh |
G_sponsor_drain.ts |
G-1: the service's sponsorship logic | cd contracts && node ../scripts/pentest/G_sponsor_drain.ts |
The Node scripts need Node 22 or later (they run TypeScript directly) and the service's and prover's node_modules.
E. How the run went, and deviations from the procedure
- Commit. Frozen at
3ca0628, then the currentmain. Each agent worked on its own clean export of it (git archive, plus the two library submodules), never on the live stacks or the shared checkout. A check after the run found every copy's sources identical to the export, apart from the added test, script and report files. - Models. The user chose Fable 5.1 for the attack agents and Opus 5.5 for the verifiers, both at maximum effort.
- Verification rule. The user added it during the run (
9d7f12c): a finding whose proof passes can't be dropped by argument, only re-rated with a reason. It was applied to every verdict. - Resources. The seven attack agents ran at the same time, then the verifiers; every forge job took one shared lock under a 3 GB memory cap (2 cores, 4 GB server).
- Reads of live systems. Area C made one GET to each of the nine RDAP hosts to compare live layouts with the fixtures; agents read public chain state through public RPCs, Kleros's published deployment files and registrars' public documentation. No transaction was sent anywhere.
- Area A's report file. The tool refused the agent's write of its report file, so the session saved the agent's final message, which was the full report, word for word.
- Not checked by a separate agent. D-4 (one line in
docs/keys.md) was checked by the orchestrating session. - Formatting.
forge fmtwas applied to the proof files before they were committed; the full run above was made after formatting. - Not done yet. Step 4 (fix, regression tests, a second run on the fixed commit) waits for the user. Step 5 (a model from another vendor) was not run.