← yescrow security review

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:

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:

Limits


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

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 -vv passed, on cached artifacts and after a fresh compile. - In Judging the voucher reverts with WrongState. - After judgeTimeout the seller has 2,475 USDC; yescrow plus the referrer have 25. - The same voucher applied in AwaitingFee leaves 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 in Judging without the voucher pays the seller 4,950 and yescrow plus the referrer 50. - judgeRuling charges no fee, voucher or not. - A voucher applied during a stall's 90 days in Disputed still applies in Judging.

Sources unchanged: yes. Only contracts/test/pentest/, scripts/pentest/, REPORT-A.md and build output differ from base.

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.md gives 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. Past Disputed, the API refuses a code (DEAL_WRONG_STATE) and the page hides the field (canApply needs state ≤ 5). A submission that loses a race reverts, relayer.call throws 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 than judgeRuling. 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"); Judging only 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 Disputed was 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 use state > 5). - Fix, option 2 (fee-free judgeTimeout): 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

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. After release, a stranger's withdraw(seller) sends the whole 4,950 USDC to the seller's own address, and the seller's withdrawTo(Base) then reverts NothingToWithdraw. 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 -vvvv trace rules out its silent returns. Tether's owner (0x4DFF…0bf8) blocks the seller, the stranger moves 1,485 USDT0 into the blocked wallet, the seller's transfer reverts with "from is blocked", and withdrawTo reverts NothingToWithdraw. - test_fork_usdt0_blockedParty: PASS. Without the push, the blocked seller moves its 1,485 USDT0 to another wallet with withdrawTo.

Sources unchanged: yes. diff -r against base shows only contracts/test/pentest/, scripts/pentest/ and REPORT-A.md differ; contracts/lib and foundry.toml are 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.md adds that a relayer can push withdrawals for gasless users, and docs/security.md and docs/keys.md agree. SPEC §14.14 already covers a balance "already withdrawn" and offers contract wallets Arbitrum One only. The web's gasless withdraw relays withdraw unsigned (Dashboard.tsx:153). Not in audit.md §5. - My test contracts/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. - withdrawTo is closed to it: the owner's signature gives InvalidSignature, a call from the owner's key gives NotAuthorized. - Once the wallet is deployed, the full 5,000 comes back either way: by the signed withdrawTo, or after a stranger's push by the owner's execute(transfer).

Reason: 1. The open withdraw doesn't cause the loss in case 1. - withdrawTo needs a transaction from account, or a signature valid for account on Arbitrum One. Either means the party controls that address there, which is where a push lands. - A party without that control can't use withdrawTo either. 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 refuse withdraw for an account with a pending automatic payout.


Known issues, quantified (not counted as findings)


Checked and found sound

Each item names the proof. Line numbers are in contracts/src/YescrowEscrow.sol.

  1. 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 and promoEnds are inclusive on both sides: no second is lost on either side of any boundary.
  2. 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 (existing test_applyRuling_needsAmpleGas).
  3. Kleros's dispute IDs and getters (read from kleros-v2's source): DisputeResolver.createDisputeForTemplate returns the arbitrator's dispute ID (not its local index), so disputeId is the right key for the core; KlerosCoreBase.Dispute is (courtID, arbitrated, period, ruled, lastPeriodChange, rounds[]), so _read(..., 2) is the period and execution = 4; currentRuling returns (ruling, tied, overridden), so index 0 is the ruling executeRuling would apply. _read (line 963) checks returndatasize before copying, so a shorter or reverting getter counts as "not ruled" and can't brick applyRuling.
  4. Re-entrancy through ETH transfers. A fee-payer contract re-entering on its refund (withdrawEth, withdraw) and a buyer contract re-entering returnDeposit from 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.
  5. 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/2 floors toward the seller, held/2 toward the seller's side, fee and referrer share floor; no path can overflow (0.8 checked arithmetic, windows capped).
  6. Settlement signatures and phases. A Settle signed while Kleros had the case (caseCreated = true) stays valid after the case stalls into Judging and pays the same (held fee split 50/50), so waiting for the stall gains nothing; one signed in AwaitingFee is unusable after a refusal (E3 fix, existing tests). The service builds caseCreated as Disputed || Judging, matching line 586. test_sound_settleSignedInDisputed_validAfterStall_sameMoney.
  7. 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 judgeRuling moves exactly one A to the fee recipient (lines 628–633) and _close pays the rest once (lines 1028–1046). A sponsor only ever gets its own ETH back (existing SponsoredFees.t.sol and the conservation tests above).
  8. 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; withdraw to a blacklisted party reverts (its own problem only) while its balance stays and can leave through withdrawTo(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 larger withdrawTo to Base reverts, the balance is untouched, and it can go in parts or on Arbitrum One; nothing is stuck.
  9. USDT0 on the real token (A_Usdt0Fork.t.sol): receiveWithAuthorization works unchanged (existing fork test); a blocked buyer can't fund; Tether's blocklist checks from only (see A-2 for what that allows).
  10. Fees on transfer. Neither token takes a fee; _fund measures the balance difference anyway (lines 849–851) and _pullDeposit likewise (lines 865–873), so a short delivery reverts and a deposit funds only what arrived.
  11. 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: withdrawTo on the same chain requires msg.value == 0, the router refuses value on CCTP routes and refunds the OFT's excess to the caller.
  12. Deposits go only to their deal (existing Deposits.t.sol plus the re-entrancy test): the box address commits to escrow, deal ID and buyer; pull on Arbitrum One sweeps only the caller's own namespace, so a non-escrow caller reaches only boxes salted with its own address; returnDeposit credits terms.buyer, and the deal token is returnable only once the deposit can no longer fund the deal (checked at acceptBy, state and term validity).
  13. No transition reachable twice, no final state changed: every closing function requires the exact non-final state, _openDeal admits Active…Disputed and Judging only, _cancel only from Proposed, and applyRuling only from Disputed, so a late Kleros ruling, a second release, or a judgeTimeout after a ruling all revert (WrongState); re-checked by reading every entry point.
  14. Deployment settings (script/DeployAll.s.sol, Networks.sol): courts 34 (3 jurors) and 1 (1 juror), Classic kit, smallDealMaxPrice 2,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.sol reads them back. (The resolver and core addresses themselves are checked live by the existing mainnet fork test at block 519,194,000.)
  15. Signature deadlines and replay in this area: WithdrawTo is consumed by digest (lines 697–700) so neither the signature nor a malleated copy works twice; Auth actions bind deal, domain, parties, price, action and deadline and each is valid in one state that is left for good; a ReferrerShare signature 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 (existing Misc.t.sol and Deposits.t.sol).

Unconfirmed, or outside what a Foundry test can prove


Files

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 in contracts/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-unit withdrawTo costs ≈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 -r against base differs only in contracts/test/pentest/, an empty scripts/pentest/ and REPORT-B.md.

What you checked: - The test. The balance comes from a real returnDeposit of the attacker's own USDT0, signed with the attacker's own key. deal only funds the attacker and relayer. The mock arbitrator and verifier are never touched by withdrawTo. The router is new 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. amount is any decimal, with no minimum. The only hooks are sanctions and idempotency. withdrawFee pays 1.2× quoteSend for any amount, then relayer.call simulates and sends. There is no budget. .env.mainnet.example has DEPOSIT_CHAINS=8453,1, so the route is live. Sponsored fees have a daily budget; LayerZero fees have none. - Known or intended? It is not in docs/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.md lists 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 ETH RELAYER_LOW_WEI alert 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.example has SPONSOR_DAILY_WEI=0, so sponsorship is off. When it is on, sponsor.ts:45 refuses once the relayer is low, and the API's FEE_NOT_SPONSORED tells 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 script docs/ai-pentest.md asks 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 one returnDeposit. Use instead: - a minimum amount for relayed cross-chain withdrawTo; - a global daily budget for relayed LayerZero fees, reserved before sending (SPONSOR_DAILY_WEI is 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's eth_estimateGas for 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 empty scripts/pentest/ and REPORT-B.md differ.

What I checked: - The test: the attacker's own key, Circle's real USDC on an Ethereum fork, no deal, store or etch on real contracts. vm.prank only stands in for the relayer; gas is the same without it (81,078 vs 81,080). - app.ts 1068–1081 (identical to pristine) never checks value, from's balance, how many relays a deal has had, or that authorizationNonce came from POST /v1/deposit-authorizations (the schema says it does). - Getting a deal ID: schemas.ts:7 accepts "0". proposalOf takes drafts. POST /v1/drafts has only the global 120/min limit, and any on-chain deal ID also works. - Mainnet: .env.mainnet.example turns on Ethereum (DEPOSIT_CHAINS=8453,1). relayer.ts simulates, then sends; value 0 passes. - Known issues: not in docs/audit.md §5. SPEC §12 and docs/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.ts 45–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.ts 21/27/52). - Honest gasless deposits (authorizations valid one hour) expire while waiting, then go out with their pre-estimated gas and revert. - Keeper.tick waits behind the backlog for its Ethereum forward and receiveMessage calls (keeper.ts 119–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 before acceptBy, Kleros calls and payouts. - ops.ts alerts 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 (_termsOk and deals.ts:119 only reject 0) and drafts are free. Also require value ≥ FORWARD_MIN for that chain (10 USDC on Ethereum, keeper.ts:30) and balanceOf(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 from pentest/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, and applyRuling then reverts WrongState. - 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 -r against base differs only in contracts/test/pentest/, scripts/pentest/ and REPORT-B.md. src, script, lib, foundry.toml, Base.t.sol and 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.etch or extra deal. The assertions are exact. - The mocks. They match Kleros v2: rule reaches the resolver, never the escrow, so settle stays open in Disputed until applyRuling. - The code. settle (573–591) and _settleDigest (902–922) have no period check, no nonce and no way to withdraw an offer. steps.ts lines 211–212 set the deadline to chain time + 24 h. Dispute.tsx tells 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 or audit.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 match dependencies.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, while applyRuling reverts TooEarly (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: bind Settle to the period it was signed in, for example lastPeriodChange, word 4 of disputes(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 (from pentest/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 ends Refunded and 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/ and REPORT-B.md differ from base; lib, broadcast and foundry.toml are 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.store or vm.etch; confirm/_close don'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 in docs/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 in Settle. - The service: the typed-data deadline has no maximum (app.ts line 765; the schema has only minimum: 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 provenAt doesn'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, as Settle does: no dispute (Active/Proven), AwaitingFee, case created (Disputed/Judging). - A one-hour cap must leave settle alone, because the web app's settlement offers last 24 h (steps.ts line 212). Cap the six action types. Also check auth.deadline in POST /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 before sendTransaction. Refusing dispute states outright would break the web app's own concessions (DealPage.tsx line 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 (from B/contracts): PASS. Trace: ecrecover of the stranger's Confirm gives 0x981F… (not the buyer), then the buyer's isValidSignature returns 0x1626ba7e and the deal emits Released(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), not vm.etch of 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 stranger transferWithAuthorization the wallet's 5,000 USDC to itself, no escrow involved (FiatToken v2.2's SignatureChecker makes the same isValidSignature call).

Also PASS: the repo's test_eip7702DelegatedEoa_signsWithItsKey (ECDSA first) and test_fork_B_INFO_usdt0_eip1271WalletFunding (USDT0 also accepts EIP-1271).

Sources unchanged: yes. Only contracts/test/pentest/, scripts/pentest/ and REPORT-B.md differ from the pristine export; src/, script/, lib/, foundry.toml and broadcast/ are identical.

What I checked: - _isValidSignature, YescrowEscrow.sol lines 926–932 (same in the pristine export), tries ECDSA, then SignatureChecker.isValidERC1271SignatureNow. Every party signature goes through it: _authorize (accept, confirm, cancel, dispute, challenge, payArbitrationFee), settle, withdrawTo. - The PoC's vm.etch writes 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 settle with one side forged (from the code, not tested); - withdrawTo of 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.sender path, 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 and docs/audit.md §4 or §5, and should name withdrawTo (anyone can take the balance), not only "acting for the party". Optional, off-chain: the web app could eth_call isValidSignature(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 the msg.sender path don't already expose.


Checked and found sound

Each item names the evidence (tests in B_Signatures.t.sol unless marked fork).

Unconfirmed


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

Verification of C-1:

Verdict: DOWNGRADED to Low

Proof of concept: forge test --match-path test/pentest/C_InterProofBlocking.t.sol -vv (from C/contracts): PASS, 6/6. With 17 names, the "after" proof reverts BadValue in each half and in submitProof. After the 10-day window, refund credits 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 at acceptBy (30 days). 16 × 253-character names cost 16,499,510 gas. node scripts/pentest/C_template_re2.ts (from C/): 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 C differs only in contracts/test/pentest/, scripts/pentest/ and REPORT-C.md.

What I checked: - Root cause: correct. RdapVerifier.sol:319 reverts 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 a DualRdapVerifier built from two RdapVerifiers 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 (RE2 sgiu, no limits on captures). So both real attestors would sign this record, if a registry served 17 nameservers. - Test honesty: no vm.store or vm.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 another BadValue. - 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 (Nomulus DomainFlowUtils, MAX_NAMESERVERS_PER_DOMAIN = 13, "Only 13 nameservers are allowed per domain") covers .app and .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

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, including test_C2_nameserverChangeBetweenTheTwoSessions_blocksTheAfterProof (6.12M gas). Each half verifies alone (registrarId 1068, the buyer's). The pair reverts FactsDiffer for 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/ and REPORT-C.md. src, script, lib, foundry.toml and prover/src match the base.

What you checked: - The test runs the real DualRdapVerifier/RdapVerifier code. 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. No vm.store/etch. The only prank is the seller's own proposal. Proofs are built before vm.expectRevert, so the revert comes from submitProof. Control (V_C-2 test 1): the same pair with only fetchedAt 2 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 except fetchedAt". 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:122 already 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, and oneAtATime (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, not facts.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 compares statusFlags, 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 in client transfer prohibited still reverts FactsDiffer (same nameservers, "last changed" and expiration; no blocked status). Better: - require equality only for domainHash and registrarId; - set nameserversHash to zero unless both agree; - take the minimum of lastChanged and of expiration; - OR the two statusFlags.

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. A client hold seen 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.

  1. Signature binding. identifier = keccak256("http\n" ‖ parametersFor(domain) ‖ "\n" ‖ context), signed as identifier\nowner\ntimestampS\nepoch under Ethereum personal-sign, exactly createSignDataForClaim / getIdentifierFromClaimInfo in attestor-core 5.1.4/5.1.5 (lib/index.js 5691-5747; the owner is lowercased on both sides, the identifier is lowercase hex on both). ECDSA.recover rejects high-s and bad v, so a signature can't be mauled into another. The attestor is immutable; Reclaim's address in Networks.RECLAIM_ATTESTOR is the one the 26 real fixtures verify against; DualRdapVerifier's constructor refuses two verifiers with the same attestor.
  2. Parameters rebuilt from the domain. PARAMS_PREFIX + base + domain/ + domain + "} (or the zk-write suffix for .now/.bot only) equals canonicalize(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-Agent before accept) is what the prefix encodes. paramValues, a different URL, query string (?jscard=1 fixture), another host (the registrar-RDAP fixture) or a different regex all change the signed string. The domain is validated by Domains.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.
  3. Each TLD's registry URL. The 25 entries of _rdapBase match SPEC §6.1 and prover/src/template.ts; the index ranges (3,4,11–15,19–23 Identity Digital; 24 .fm; 25 .inc) match Domains.tldOf. One live GET per host today (9 hosts) answered 200 application/rdap+json with Content-Length, no * byte and no control byte in any body; the attestor checks https:, the exact path (no templates, so no * allowed in it), the Host header, GET, Connection: close, and in processHandshake verifies the server's certificate chain against the host name, so a route hijack alone can't substitute a server.
  4. Context parsing. _facts accepts 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). _entry skips \x pairs exactly as JSON string syntax does, so a value can't end early or late; _unescape decodes \" \\ \/ \n \r \t and reverts on \b \f \u (a raw control byte in a capture fails closed). The attestor builds extractedParameters and providerHash itself and canonicalizes the context before signing (assertValidProviderTranscript), so a prover's own context keys can only make the proof fail.
  5. Field parsers. _stringAfter requires "key" : "plain string" with no escapes (fails closed); _uint takes 1–9 digits; _eventDate requires YYYY-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, lowercase z and a 5-digit year revert. _daysFromCivil is Hinnant's algorithm. _statusFlags lowercases each string and maps the 23 RFC 8056 values; unknown values are ignored.
  6. 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. The i flag 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's RegExp (no RE2), \s is wider and case folding narrower than RE2's: both directions only add refusals.
  7. Layouts of the live responses (9 hosts, today). The first "ldhName" is the domain's in every layout (Verisign at byte 63, CentralNic and .co at about 180, Google at 1489 after entities and events, Identity Digital, PIR and Nominet at 3.7–4 KB after notices); no entity, event, link or notice carries ldhName. The top-level status array is followed by entities/port43/notices where it is captured (Nominet's isn't: statusFlags is 0 there, and a blocked status is still refused by the inverted regex). nameservers is followed by secureDNS or rdapConformance everywhere. Each unique field appears once; no nested object carries events except Nominet's nameserver objects (registration only today, see U2).
  8. Full reveal. FULLY_REVEALED needs the HTTP/1.1 200 line, header lines without * except an access-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). assertTranscriptsMatch compares 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's Content-Length/final-chunk check, and both attestors are required.
  9. Timestamps and freshness. fetchedAt is the registry's own last 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 requires notBefore < fetchedAt ≤ now and expiration ≥ fetchedAt + 30 days; DualRdapVerifier returns the earlier fetchedAt, 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.
  10. Unicode, IDNs and escapes. Terms carry A-labels (xn-- passes tldOf); Verisign's uppercase ldhName and nameservers are lowercased before hashing and comparison; non-ASCII appears only in unicodeName, outside the captures or (inside nameserver objects) ignored by _nameserversHash; CentralNic's and .co's \/ escapes decode; a \u escape or raw control byte in a capture reverts. A registry answering a different name, a trailing dot or a U-label fails DomainMismatch.
  11. 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.
  12. 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.
  13. Escrow checks. accept and submitProof call 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 and lastChanged > activatedAt; propose asks the verifier supportsDomain after the allow-list check.
  14. Deployment of the verifiers. DeployAll deploys Reclaim's verifier with Networks.RECLAIM_ATTESTOR, refuses a YESCROW_ATTESTOR that is zero or equal to it, and the dual verifier needs both: SPEC §14.18 as promised.

Unconfirmed


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 empty scripts/pentest/ and REPORT-D.md differ.

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 from deal funding the parties, nothing is written into Kleros, USDC or the escrow. The attack is the buyer's own fundAppeal, 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. - currentRuling returns the only funded choice at execution. - withdrawFeesAndRewards repays that side in full (feeRewards == paidFees). - executeRuling passes the same ruling to the resolver. - isAppealFunded ends 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 and applyRuling reverts TooEarly. - 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 returns overridden = true with 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.md gives 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 at Dispute.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 test overridden alone. 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/ and cache/, D differs from base only by contracts/test/pentest/, an empty scripts/pentest/ and REPORT-D.md.

What I checked: - The code: judgeRuling zeroes the fee (YescrowEscrow.sol:634-635). judgeTimeout (642-646) closes at P/2 without zeroing it, so _close charges the deal's fee (1016). - The PoC: - The only cheats are the judge's own call (vm.prank(judge)), vm.warp to rulingDeadline, and arb.setRefuse standing 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.sol asserts 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 and docs/security.md say 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, set d.promo = true; d.promoFeeBps = 0; before _close, as judgeRuling does. - 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 in Judge.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 with heldFee = 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/ and cache/, only contracts/test/pentest/, scripts/pentest/ and REPORT-D.md differ between base and D.

What I checked: - The code (YescrowEscrow.sol:519-562): - The answer must equal d.arbitrationFee (525). - If cost <= 2A, the current cost goes to Kleros (533, 543) and heldFee = pot − cost (548). - Otherwise: refused, to the judge, both fees held (558-561). - _close (1033-1044) pays heldFee to the winner's payer, or 50/50. - Who can change cost: only Kleros's governor. - arbitrator, resolver and smallDealMaxPrice are immutable; both extra-data values are set only in the constructor (342-343). - Kleros's cost is feeForJuror × minJurors, which only the governor can change (changeCourtParameters or an upgrade, docs/dependencies.md §1). - The mainnet fee period is 72 h. - Realism: no vm.store/vm.etch or forged signatures; arb.setCost stands 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 − cost is kept for the winner, and above 2A the deal goes to the judge. - The project's own Transitions.t.sol test test_payArbitrationFee_costRiseWithinPotShrinksHeldFee already asserts heldFee == A/2 at +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" in web/components/deal/Dispute.tsx:81 and web/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.

Unconfirmed

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 (from E/contracts): 3/3 pass. - test_1 (Base fork): a stranger's forward burns 5,000e6 USDC from the box, using 366,580 gas. The box is left with 0, mintRecipient is the box, destinationCaller is 0. The buyer's returnStray then reverts. - test_2 (Arbitrum One fork): accept reverts InsufficientDeposit and 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 real Keeper.completeTransfers asks Circle and sends receiveMessage. 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/ and REPORT-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: - deal funds the victim's box; - vm.etch places 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 WithdrawnTo indexing (watcher.ts:90–102), not from a relay. - Nothing reads Forwarded or DepositForBurn. - 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: - Proposed exposes dealId and buyer. - 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 before acceptBy, accept funds 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/src checks 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 bare expectRevert() 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 forward is DepositRouter.sol:83–97, not 83–100. - The fix needs one change: key rows by message, not by transaction hash. source_tx is UNIQUE (written with ON CONFLICT DO NOTHING), and cctpAttestation (bridges.ts:76–83) reads only messages[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 whose mintRecipient is the row's box. - Filter DepositForBurn on its indexed depositor = 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 from pentest/E/contracts: pass, 3 of 3 tests. - test_1: the buyer's returnStray on Base returns 1,000,000,000 base units (1,000 USDC). - test_2: a stranger's forward burns the 1e9 through the real TokenMessengerV2 (depositForBurn(1e9, domain 3, box 0xeE34…04B7, maxFee 0, 2000)). The buyer's returnStray then reverts NothingThere. - test_3: 1e9 is minted to the same box on Arbitrum One. The buyer and the stranger get NotHere from returnStray and forward, and their pulls return 0. The box keeps 1e9 and has no code.

Sources unchanged: yes. diff -r against base differs only in the pentest folders, REPORT-E.md, the node_modules symlinks and build output.

What I checked: - The -vvvv trace: the router lands at Networks.ROUTER through the real CREATE2 proxy (no etch); deal only 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 as 0x57Af… only shows who could sweep. - 0x57Af…c836 is the active testnet USDC escrow and names router 0x7073…, which is Networks.ROUTER. So the box is a real testnet deal address. - Arbitrum One, block 519,343,733: no code at 0x57Af…, no router on Arbitrum One or Base yet. The testnet deployer 0x934f…6795 has nonce 0 there; the DeployAll broadcast shows 0x57Af… 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:85 also 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 is router.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 name 0x7073… forever, and anyone can deploy that code on Base or Ethereum through the keyless proxy, so their deal addresses keep the old forward. 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, returnDeposit can 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 (from pentest/E/contracts): PASS. On a Base fork (block 52,978,000), 100,000,000 base units (100 USDC) stay at 0x7073…Cb3D after: - forward bridges the box's 1 USDC through the real CCTP; - a stranger bridges its own 2 USDC; - returnStray reverts NothingThere; - pull reverts NotHere.

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 at Networks.ROUTER through the real CREATE2 proxy, so the PoC's vm.etch fallback never runs. 100 USDC sent by a real ERC-20 transfer (not deal) stay through forward, the buyer's returnStray and forward on an empty box. - Arbitrum One (block 519,194,000): 100 USDC, 100 USDT0 and 1 ETH forced in by SELFDESTRUCT all stay through pull (exactly the box's 7 USDC / 5 USDT0), bridge via CCTP and via USDT0's OFT, and forward/returnStray (NotHere). The router's own ETH can't pay USDT0's fee (WrongValue).

Sources unchanged: yes. Excluding build output, diff -r against the pristine export shows only contracts/test/pentest/, scripts/pentest/ and REPORT-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. deal stands 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 to 0x7073…Cb3D on a chain where the router isn't deployed, which today is every mainnet (E_Sound test_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.

  1. 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 code 0x520ee3…e491 and lands at Networks.ROUTER = 0x7073…Cb3D through 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 the boxCodeHash immutable is filled in (5,917 bytes). A file that imports only the router and Networks, as DeployRouter.s.sol does, gets the same creation code as the artifact (test_1 asserts it), so predicted() 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.
  2. Every routeOf constant matches the real bridge on its chain (test_2): CCTP domains 0/3/6 equal each transmitter's localDomain, V2 (version() 1, messageBodyVersion 1), 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 (approvalRequired true), 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 give lzReceive 80,000 gas (0x0003010011010000…13880), so the router's empty extraOptions are delivered by LayerZero's executor.
  3. Who may move what (test_3, plus test/DepositRouter.t.sol): on Arbitrum One a box is swept only by the escrow in its salt (pull keyed by msg.sender; a stranger sweeps its own empty box), forward and returnStray revert NotHere, and bridge reverts NoRoute for 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.
  4. The box. sweep only by the deploying router (immutable owner), ETH or any token, to a to the router chooses: the escrow (funding), the buyer (returns), the router itself (bridging). Its creation code has no deployer-dependent part, so boxCodeHash and every box address are the same on every chain; the salt is abi.encode of fixed-size fields (no collision). Malicious token arguments to returnStray/returnDeposit run in the token's context only; the router and box keep no state to corrupt, and the escrow is nonReentrant.
  5. A burn goes only to its own box and can't be re-pointed. forward sends to boxAddress(escrow, dealId, buyer) from the same salt it swept, destinationCaller 0, maxFee 0, finality 2000 (standard, free); USDT0 with minAmountLD = amount. The burn's messageSender is the router, and the V2 messenger implementation (0x555E…3ec8) has no replaceDepositForBurn (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).
  6. The escrow side. _pullDeposit trusts the escrow's balance difference, not the box's count; _fund requires the price and credits the excess to terms.buyer; returnDeposit pays only terms.buyer and the deal token only once _depositCanFund is false (a buyer's proposal already funded, a seller's proposal past acceptBy or closed, invalid terms), other tokens and ETH straight to the buyer; the deal ID commits to the buyer, so nothing can be redirected. withdrawTo is authorized only by the account (transaction or one-use EIP-712 over chainId, recipient, amount, deadline), requires msg.value 0 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.
  7. Approvals and value. forceApprove handles USDT's approve-from-nonzero restriction; CCTP and the Ethereum adapter consume the router's allowance exactly; the Arbitrum OFT (approvalRequired false) leaves a dangling allowance from the router, but the router holds nothing and send burns only msg.sender's own balance, so it is unusable. forward with USDC refuses any msg.value; the USDT path's refund reverts the call if refundTo rejects ETH, which only hurts the caller.
  8. Deploying elsewhere. On a chain the router doesn't know, routeOf returns settlement 0: pull, forward and bridge are refused and returnStray returns 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.
  9. Service checks. loadDepositChain uses 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 above FORWARD_MIN only, simulates every call, and records withdrawals to other chains for completion.
  10. CCTP denylist state. The router is not on Circle's denylist on Base (isDenylisted false at the fork block); see unconfirmed 2 for the systemic case.

Unconfirmed

  1. maxFee = 0 is immutable. Circle's attestation service refuses to attest a burn whose required fee exceeds maxFee. Standard transfers are free today (checked 2026-10-22 in docs/dependencies.md), but if Circle ever charges for them, every forward and cross-chain withdrawTo burn 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 with returnStray.
  2. Circle's and Tether's denylists are checked on msg.sender (and tx.origin). TokenMessengerV2 and MessageTransmitterV2 are Denylistable; a shared router means a denylisting of 0x7073…Cb3D would 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 owner 0x4DFF…0bf8, who sets peers and enforced options. Trust assumptions, already partly in SPEC §12.
  3. 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 (index Forwarded logs) covers this.
  4. 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_MIN only bounds the per-deposit cost. Needs a stack to measure, so not proven.

Not findings (reasoned out)


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

Verification of F-1:

Verdict: CONFIRMED (Low)

Proof of concept: forge test --match-path test/pentest/F_CheckDeploymentGaps.t.sol -vv from F/contracts: 4/4 pass, also with -j 1 (the tests' vm.setEnv can't race). On the Arbitrum One fork (block 519,194,000) the misdeployed escrow 0x1d14…f211 gets "ALL CHECKS PASSED" with REQUIRE_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 -rq against base differs only in contracts/test/pentest/, scripts/pentest/, REPORT-F* and build output.

What I checked: - CheckDeployment.s.sol checkEscrow (lines 52–93) never reads promoSigner(), promoEnds() or the template. It checks isVerifier only for the verifier it is given (line 79). The YescrowEscrow.sol references (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 at 0x1d6e, as in rehearse-mainnet.sh. - deal only 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 and MockVerifier are 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 passes promoEnds = 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", and docs/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) derives PROMO_SIGNER from PROMO_SIGNER_PRIVATE_KEY in .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 case docs/keys.md already accepts: fee income only, until promoEnds. 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 (DeployAll always 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 and feePeriod are checked only against floors. - Both suggested fixes are circular for the first precondition named. A config rebuilt through DeployAll.config, or a PROMO_SIGNER from the same env and checkout, matches a wrong .env.mainnet key or an edited template. - "deploy-all.sh exports both" holds only inside deploy-all.sh. The separate launch gate (docs/mainnet.md §3 step 3) sources .env.mainnet. Per the example file, that has PROMO_SIGNER_PRIVATE_KEY and PROMO_DAYS, no PROMO_SIGNER, and an empty PROMO_ENDS. envOr would return 0 and fail a correct NAMEPROS deployment. - Compare against independent values instead: - the promo address pinned in docs/keys.md (0xE4e9…b8dE); - promoEnds ≤ deploy time plus PROMO_DAYS; - a template hash committed to the repo; - the exact verifier list, decoded from the creation transaction recorded in deployments/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"

Verification of F-2:

Verdict: DOWNGRADED to Info

Proof of concept: ../scripts/pentest/F_verify-profiles.sh, run from pentest/F/contracts. It runs, and its results match the attacker's: - The escrow, RdapVerifier and DualRdapVerifier submit viaIR=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. With via-ir: viaIR=True.

Sources unchanged: yes. diff -r against base differs only in contracts/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/contracts without out/ or cache/ (pentest/verify/F-2-sim). Running only the runbook's forge script script/DeployRouter.s.sol:DeployRouter (no RPC, no key, nothing sent) leaves DepositRouter in both profiles in the cache, and no test file was compiled. The cause is src/YescrowEscrow.sol:16, which imports ./DepositRouter.sol into 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 sourcify fails the same way. With --compilation-profile default it 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 hash 0x520ee3…e491, which lands at 0x7073f01f…Cb3D through the CREATE2 proxy; - via-IR profile: 5,903 bytes, which lands at 0x34600Fa5…a8a1. - Public state (read only): there is no code at 0x7073…Cb3D on 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.sh verifies 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=false default) 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:16 and script/Deploy*.s.sol pull the router into the via-IR job. - The same router verification without a profile is in scripts/deploy-escrow.sh:87 (Arbitrum Sepolia), where || true hides the error.

Fix: - Add --compilation-profile default to 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)

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

All 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 -r against base (checked before and after the run) shows only test/pentest/, scripts/pentest/ and the report files.

What I checked, and the reasons, item by item:


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


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 (from G/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 test contracts/test/pentest/verify/V_G-1_OneSponsoredSide.t.sol: 2/2 PASS (see Reason).

Sources unchanged: yes. diff -r against base shows only the PoC files and REPORT-G.md added.

What I checked: - sponsor.ts and 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. MockArbitrator keeps the fee, as KlerosCore does. No vm.store or vm.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_WEI a 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). But spentToday() (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–47 is right. Add sponsor.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_WEI is 0 in .env.example and .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 -rq of the attack copy against the pristine export differs only in contracts/test/pentest/, scripts/pentest/ and REPORT-G.md.

What you checked (each claim): - Screening runs only in the API: true. registerScreening (sanctions.ts:141–151) is a Fastify preHandler, registered only at api/app.ts:131. relayer.ts doesn't screen. - The keeper's deposit path never screens; only sendPayouts does: true. this.screen appears once, at keeper.ts:179. deposits()/acceptFromDeposit() (260–297) call propose/accept unscreened, although main.ts:52 hands the keeper the screen. - The API would refuse the same calls: true. A /v1/relay propose carries terms and proposer; an accept carries dealId, whose parties the hook loads from deals. POST /v1/drafts is screened on terms. - No funds reach the listed party on this path: true. propose/accept with empty funding pull the box into the escrow (_fund → _pullDeposit). The keeper's returnDeposit with the deal's token only credits usdcBalance[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 every Proposed (watcher.ts:85). The deposit address is in the unscreened GET /v1/deals/{id} (app.ts:561) or the on-chain depositAddress(). Once the price lands, the keeper accepts. Neither party ever makes a screened request. - propose (narrow): drafts come only from the screened POST /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 CCTP receiveMessage (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's destinationCaller is 0 (DepositRouter.sol:226), so the party could complete the transfer themselves. Fix: screen account/recipient of 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, judgeTimeout and returnDeposit, though /v1/relay refuses 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: in deposits() and forwardFromDepositChains(), treat SCREENING_UNAVAILABLE as "skip", as sendPayouts does, 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 real settleTypedData (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 signed Settle fields 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 is contracts/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, including test_onlyLowers (FeeNotLower) and test_settlementInADispute_usesTheLowerFee.

Sources unchanged: yes. diff -rq base G shows only build output, REPORT-G.md, contracts/test/pentest/ and scripts/pentest/.

What I checked: - app.ts:775 passes e.params.feeBps. The helper dealFeeBps (app.ts:317) exists and is used for the deal view (:555) and the payout typed data (:847), but not here. - e.params.feeBps is the escrow's on-chain immutable feeBps() (escrow.ts loadEscrow), so it is never below a deal's fee: - applyFeeVoucher reverts with FeeNotLower unless the new fee is lower. - promoEnds only stops new vouchers; a promo already applied stays. - The judge's 0 fee is set only inside judgeRuling, which closes the deal. - SETTLE_TYPEHASH binds dealId, domain, parties, price, sellerAmount, caseCreated and deadline. _close charges sellerAmount × _feeBpsOf(d) / 10000 at 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 the confirm summary 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

Unconfirmed (reasoning, no proof)


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 of service/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.md says; an invariant in docs/audit.md §3 breaks; a deployment setting differs from what the specification and contracts/script/Networks.sol promise.

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 in contracts/test/fixtures/. For off-chain parts, a script in scripts/pentest/ against a local stack. Don't change contracts/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