fix(bridge-core): close the transient-classifier coverage gaps and unbounded/racing timeouts - #97
Conversation
PR SummaryHigh Risk Overview Classifier ( Timeouts — AVNU paymaster RPC defaults move above the 30s LB (build 45s, execute 60s via Fail-closed deposit — Reviewed by Cursor Bugbot for commit d22d8cc. Bugbot is set up for automated code reviews on this repo. Configure here. |
…rim comments Review nits on #97: - TRANSIENT_RE matches only 408/429/500/502/503/504/520-524, not any 5xx-shaped number: calldata felts and amounts ("540", "550") are no longer read as HTTP statuses. EVM/viem revert markers (`execution reverted`, `contract function reverted`, `reverted on`) and `insufficient max fee` join the terminal vocabulary so a revert whose message carries `gas: 500` stays terminal. - Replace the stale `\b(429|50[234])\b` note in errors.ts. - polygonMint's per-request-budget comment no longer claims pollIris consults errors.ts — the fetch outcome is transient unconditionally. - Cut the incident narrative from the added comments; it lives in the PR body. - Note in errorText that the 200-char body slice leaves the status load-bearing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…bounded/racing timeouts Extends #95 beyond the single AbortSignal TimeoutError shape: - Safari `Load failed` / Firefox `NetworkError…` now classify transient (+ a humanizer network row). - HTTP allowlist widened to 408/429/5xx (Cloudflare 520-524, plain 500), keeping the hex/decimal collision guard without a lookbehind (Safari < 16.4 parse error). - `REVERTED`/`REJECTED` anchored as case-sensitive tx-status tokens so a WAF block page appended to a 503 body no longer reads terminal; user-cancel vocabulary made explicit (Argent `User abort` / `Rejected by user`). - `isTransientError` and `sanitizeErrorMessage` share one extractor (`core/errorText.ts`) and the classifier walks `err.cause` two levels, terminal and transient alike. - Every Iris GET carries a 15s abort budget; the poll deadline was only checked after the awaited fetch, so a blackholed connection stalled the CCTP mint forever. - AVNU client budgets nested outside the 30s LB cut (build 45s, execute 60s); no fail-closed guard changed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… that a signal exists Both budget cases now spy on AbortSignal.timeout, so the default and the injected fetchTimeoutMs are pinned to their actual milliseconds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rim comments Review nits on #97: - TRANSIENT_RE matches only 408/429/500/502/503/504/520-524, not any 5xx-shaped number: calldata felts and amounts ("540", "550") are no longer read as HTTP statuses. EVM/viem revert markers (`execution reverted`, `contract function reverted`, `reverted on`) and `insufficient max fee` join the terminal vocabulary so a revert whose message carries `gas: 500` stays terminal. - Replace the stale `\b(429|50[234])\b` note in errors.ts. - polygonMint's per-request-budget comment no longer claims pollIris consults errors.ts — the fetch outcome is transient unconditionally. - Cut the incident narrative from the added comments; it lives in the PR body. - Note in errorText that the 200-char body slice leaves the status load-bearing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3aefe7a to
d22d8cc
Compare
Follows #95 (now merged; this PR is rebased onto
main) — #95 fixed the one error shape from the 2026-09-09 incident (AbortSignal.timeout→TimeoutError). This PR closes the rest of the classifier's coverage gaps around it, and removes two unbounded/racing timeouts that produce exactly that shape in production.1. Cross-browser network wordings (
core/errors.ts,core/errorMessages.ts)TRANSIENT_REknew onlyfailed to fetch/fetch failed. Safari/iOS saysLoad failedand FirefoxNetworkError when attempting to fetch resource.(one word — the regex hadnetwork errorwith a space), so on those browsers a plain network drop classified terminal: no auto-retry, no resumable-deposit branch. Added\bload failed\band widenednetwork error→network\s?error, plus a humanizer row alongside #95's timeout row: "Network request failed. Check your connection and try again."2. HTTP status allowlist (
core/errors.ts)\b(429|50[234])\bomitted 500, 408 and Cloudflare's 520-524 — all reachable through the offmarket nginx/Google-LB path in front of AVNU, the RPCs and Iris. Widened to the justified set only —(408|429|50[0234]|52[0-4]), i.e. 408, 429, 500/502/503/504 and Cloudflare 520-524 — never a whole5\d\drange, so a calldata felt or an amount ("540",550) is not read as a status. The hex-collision protection is preserved and strengthened: the old guard was the leading\b(digits inside0x…are word characters, so no boundary exists); the new prefix(^|[^\w.])keeps that and additionally rejects a decimal fraction (0.503). A lookbehind would have been cleaner but is a parse-timeSyntaxErroron Safari < 16.4 — that would white-screen the SPA, so it is deliberately avoided. Negative tests pin0x503abc503def,0.503, a calldata"540"andERC20 transfer of 550 failed. The terminal vocabulary also gained the EVM/viem revert markers (execution reverted,contract function reverted,reverted on) andinsufficient max fee, so a revert whose message carriesgas: 500stays terminal — pinned with viem's exact string.3. Bare
REJECTEDvs a WAF block page — chose anchoring, not reorderinglib/safe-json.ts:56-59appends up to 200 chars of the upstream body to the status line, so a WAF-blocked 503 arrives as… failed (503 Service Unavailable) — The requested URL was rejected. Please consult with your administrator.TERMINAL_RE's case-insensitive bareREJECTEDmatched the word rejected in that prose and made a plain gateway failure terminal. I anchored the markers to their real vocabulary rather than reordering the rules. Reordering (HTTP-status transient before body-text terminal) would silently downgrade every terminal marker that co-occurs with a 5xx-looking number — including a genuine revert whose message carries a status code — which is a fail-open change on the value path. Anchoring is the narrow fix and it already has in-repo precedent:tx.ts'sisRevertedOrRejectedmatches/\bREVERTED\b|\bREJECTED\b/case-sensitively, because those are the literal tokenssubmitAndTrackwrites. SoREVERTED/REJECTEDmoved into a case-sensitiveTERMINAL_TX_STATUS_RE, and the user-cancel vocabulary became explicit (user (rejected|denied|abort\b),rejected by user). Terminal is still the default verdict, so this only changes messages that also carry a transient token. Tests pin the exact WAF string, a realsubmitAndTrack: 0xabc REJECTED(with and without a transient token riding along), and the wallet-cancel wordings.4. One extractor for both classifiers (
core/errorText.ts,core/errors.ts,core/tx.ts)isTransientErrorread onlyerr.message(so a non-Error object stringified to[object Object]), whilesanitizeErrorMessageclassified overrpcErrorReason(err), which unwrapsbaseError.{code,message,status,statusText,body}. The classifier therefore could not see an HTTP-shapedbaseErrorthe UI was showing. ExtractederrorText()into its own module — not intotx.ts, because a dozen specsvi.mock('./tx')and a cross-import there breaks them — and both call it.isTransientErroralso walkserr.causetwo levels, running the terminal and transient checks (and theTimeoutError/AbortErrorname checks) over every level, so a terminal cause can never be out-voted by a transient wrapper. TheNON_RETRYABLE/TRANSIENTbrands are deliberately read off the thrown object only — call sites brand what they rethrow.sanitizeErrorMessagestays single-level by design (display text should be the error's own message, not a concatenated chain).5. Iris attestation fetch had no timeout (
core/polygonMint.ts)fetchIrisMessagesOnceawaited a barefetch, andpollIrischecks its 30-minute deadline only after that await resolves — so a blackholed connection stalls the CCTP mint forever, deadline and all. Every Iris GET now carriesAbortSignal.timeout(IRIS_FETCH_TIMEOUT_MS)(15s, injectable per-poll viaPollOpts.fetchTimeoutMs, following the file's existingsleep/randomconvention). The abort'sTimeoutErroris caught by the existingcatch→{ kind: 'transient' }→ exponential backoff; the new spec asserts that #95's classification makes that the correct branch, and that the poll resumes and resolves.6. Client fetch budget nested outside the LB (
core/avnuPaymaster.ts)DEFAULT_RPC_TIMEOUT_MS = 30_000equalled Google LB's 30s backend cut, so the client abort raced the LB's definitive 502/504 and replaced a known outcome with an unknown-statusTimeoutError— the incident's actual ambiguity. Budgets are now nested outside the proxy's (nginx: 5s connect + ≤15s next_upstream + 25s read): build/default 45s, execute 60s via the newrpcTimeoutMs(method, timeoutMs?); an explicitopts.timeoutMsstill wins, so existing injected budgets are untouched. No fail-closed guard was changed —paymasterSubmissionStartedand themoveIntoPoolNON_RETRYABLEbrand from #95 are untouched, and #95's own double-submit specs still pass.7. Argent wallet-cancel wordings
User abort/Rejected by useradded to the terminal vocabulary (#3) and to a new humanizer row — "You cancelled the request in your wallet." — placed before theREVERTED/REJECTEDrows so a cancellation no longer reads as an on-chain failure.abort\b(notabort) so Argent'sUser abortmatches while theAbortErrorwording "The user aborted a request." does not: a caller abort is judged by name, per #95. The Argent strings are marked unverified live in a test comment.Test evidence
Red before / green after,
pnpm exec vitest runinsidepackages/bridge-core:errors.classifierCoverage.test.tserrorMessages.test.tsavnuPaymaster.timeoutBudget.test.tspolygonMint.irisFetchTimeout.test.tsRed run: 17 failed / 57 passed. Green run: 180 passed across the four specs above plus every spec exercising a table I widened (
errors,errorMessages,avnuPaymaster, allpolygonMint.*, and #95'sdeposit.paymaster+moveIntoPool.bughunt.transientBypass). Two further targeted sweeps of the classifier's blast radius are also green:tx/walletErrors/ alldeposit*/ allmoveIntoPool*(419 passed — this is what caught thevi.mock('./tx')fragility and drove the separateerrorText.tsmodule), and allbridgeOut*/bridgeBack*/onramp*/returnIn*/resolveOpenReturn*/pendingReturnBurn*(318 passed).typecheck/lint/build/ the full suite are CI's.Both Iris budget cases spy on
AbortSignal.timeoutand assert the actual milliseconds; reverting just thesignal:line inpolygonMint.tsturns 3 of the 4 red, so they are non-vacuous. Nothing undersrc/react/useshumanizeError/sanitizeErrorMessage, so the two new humanizer rows have no other spec exposure.AbortSignal.timeoutis a platform timer that vitest's fake timers do not drive, so the Iris test proves the wiring (a realAbortSignalon every request, injectable budget) and feeds the catch the exactDOMException('signal timed out','TimeoutError')the browser produces, rather than trying to make the platform abort fire under fake time.Follow-up
No version bump here. A
chore(release): bump bridge-core to 0.1.23PR should follow #95 + this one so offmarket can pick both up.🤖 Generated with Claude Code
This change is