Repository navigation
fix(perps): [perps-controller] Reduce noisy Sentry error: WebSocket permanently terminated (METAMASK-ZHT9) - #10651
Merged
Conversation
…account setup Once the HyperLiquid socket hits its reconnect limit, every #ensureReady entry repeated the userAbstraction lookup on the dead socket and reported WebSocketRequestError to Sentry as a failed migration (METAMASK-ZHT9). Treat WebSocket transport failures in #ensureUnifiedAccountEnabled as connectivity: debug-log them, keep the retry flag, and have #ensureReady wait a one-minute cooldown before running the setup again. reconnect() and disconnect() end the cooldown, action-time setup is not gated, and server error frames and signer failures are still reported.
abretonc7s
marked this pull request as ready for review
October 1, 2026 10:19
abretonc7s
enabled auto-merge
October 1, 2026 15:40
geositta
approved these changes
Oct 2, 2026
Naz-Ovh
pushed a commit
to 0x-fork/metamask-core
that referenced
this pull request
Oct 7, 2026
## Explanation Releases `@metamask/perps-controller` **19.0.0 → 20.0.0**. The monorepo version goes **1314.0.0 → 1315.0.0**. No other package is being published. Perps-controller has no in-monorepo dependents that need a workspace range bump. The bump is **major**. Public unions and Lighter order validation change: - **BREAKING:** Lighter `placeOrder` / `validateOrder` accept supported native attached TP/SL instead of refusing all attachments. Gate forwarding on `attachedTpsl`. ([MetaMask#10638](MetaMask#10638)) - **BREAKING:** Grouped Lighter signer calls require grouping/count `1/2` or `2/2` with two orders, or `3/3` with three orders. ([MetaMask#10638](MetaMask#10638)) - **BREAKING:** `ScaleOrderChild.state` adds `canceled`. ([MetaMask#10638](MetaMask#10638)) - **BREAKING:** `DirectProviderOrderCapabilitiesUnavailableReason` and `OrderCapabilitiesUnavailableReason` add `order_market_unsupported`. ([MetaMask#10638](MetaMask#10638)) - **BREAKING:** Stray `triggerPrice` on Lighter basic market/limit orders is refused with `ORDER_TRIGGER_PRICE_NOT_SUPPORTED`. ([MetaMask#10638](MetaMask#10638)) - **BREAKING:** Lighter position TP/SL replacement and removal preserve independent partial triggers. ([MetaMask#10638](MetaMask#10638)) - **BREAKING:** `OrderFill.pnl` is optional when the venue omits realized PnL. Treat missing as unknown, not zero. ([MetaMask#10605](MetaMask#10605)) Also ships Lighter Scale/Chase/TWAP probe work, fee quote attribution (`feeSource`, `metamaskFeeDiscountBips`), HyperLiquid agent/signing fixes, quieter optional-messenger failures, and watchlist hydration/write races. ## Changelog Moved Unreleased entries in `packages/perps-controller/CHANGELOG.md` under `[20.0.0]`. Removed Uncategorized monorepo release markers (1311–1314) that do not affect package consumers. Dropped a duplicate Chase probe summary already covered by more specific entries. ## References - Source PRs: [MetaMask#10638](MetaMask#10638), [MetaMask#10618](MetaMask#10618), [MetaMask#10605](MetaMask#10605), [MetaMask#10643](MetaMask#10643), [MetaMask#10650](MetaMask#10650), [MetaMask#10651](MetaMask#10651), [MetaMask#10665](MetaMask#10665), [MetaMask#10670](MetaMask#10670), [MetaMask#10683](MetaMask#10683) - No in-monorepo consumer packages to bump. Mobile and Extension exhaustive union matches need the new members on upgrade. Lighter attached TP/SL stays gated on `attachedTpsl` and client rollout. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [x] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Explanation
Sentry METAMASK-ZHT9 (
WebSocketRequestError: WebSocket connection permanently terminated, ~282k events / ~6k users) is connectivity noise from the HyperLiquid unified-account setup.Current behavior:
HyperLiquidClientServicekeeps the dead WSInfoClient.#ensureReadyentry then runs#ensureUnifiedAccountEnabled. ItsuserAbstractionlookup fails on the dead socket.logger.erroras "Could not enable Unified Account (user rejected, or network error)" and tracksAccountSetupfailed.#ensureReadyrepeats the same lookup and the same report on every preload tick and read until the socket is rebuilt.This PR (all in
HyperLiquidProvider):ReconnectingWebSocketError, orWebSocketRequestErrorwith a cause or one of the SDK's fixed client-side messages). The branch debug-logs, keeps the retry flag, and starts a 60 s cooldown. It sends nothing to Sentry and nofailedanalytics.#ensureReadyskips the init-time setup until the cooldown ends, so a dead socket costs at most one lookup a minute instead of one per entry.reconnect()anddisconnect()end the cooldown.WebSocketRequestErrorwithout a cause, carrying server text) and signer rejections still go to Sentry.Things reviewers may not expect:
Perp Account Setupwithstatus: failed. These were connectivity events, not setup outcomes, so setup-failure dashboards will drop.reconnect()(client retry UI) or reinit. Auto-rebuild would turn a dead network into a reconnect storm, and changing the rews limits is out of scope.Validation:
References
Validation Recipe
recipe.json (0 steps — TAT-4053 — a terminated Hyperliquid WebSocket no longer floods Sentry from the unified-account setup)
{ "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json", "title": "TAT-4053 — a terminated Hyperliquid WebSocket no longer floods Sentry from the unified-account setup", "description": "Builds perps-controller, then drives the built HyperLiquidProvider's getMarketDataWithPrices -> #ensureReady -> #ensureUnifiedAccountEnabled path against a real @nktkas/hyperliquid InfoClient whose WebSocketTransport hit RECONNECTION_LIMIT. Proves the METAMASK-ZHT9 failure is no longer sent to logger.error, the lookup is rate-limited by a cooldown and still retried later, and a genuine signer rejection is still reported. Also proves the new Jest regression tests fail with the fix reverted and pass with it restored.", "workflow": { "entry": "status", "nodes": { "status": { "action": "app.status", "next": "build", "intent": "Resolve the Core checkout and confirm headless readiness" }, "build": { "action": "command", "cmd": "yarn workspace @metamask/perps-controller run build:all", "timeout_ms": 600000, "next": "build-ok", "intent": "Build perps-controller and its project references so the proof runs against emitted dist, not source" }, "build-ok": { "action": "assert_exit_code", "source": "build", "expected": 0, "next": "dist-has-fix", "intent": "Confirm the build that produced the proof's dist succeeded" }, "dist-has-fix": { "action": "assert_file", "path": "packages/perps-controller/dist/providers/HyperLiquidProvider.js", "contains": "UNIFIED_ACCOUNT_TRANSPORT_RETRY_COOLDOWN_MS", "next": "run-proof", "intent": "Prove the dist being exercised contains the fix, so a stale build cannot pass the proof" }, "run-proof": { "action": "command", "timeout_ms": 180000, "next": "proof-ok", "intent": "Terminate a real SDK WebSocket transport, then drive the built provider's preload path against it and record what it reports" }, "proof-ok": { "action": "assert_exit_code", "source": "run-proof", "expected": 0, "next": "premise", "intent": "Confirm the proof driver completed" }, "premise": { "action": "assert_json", "assert": { "all": [ { "path": "$.premise.terminationReasonCode", "operator": "eq", "value": "RECONNECTION_LIMIT" }, { "path": "$.premise.lookupErrorName", "operator": "eq", "value": "WebSocketRequestError" }, { "path": "$.premise.lookupErrorMessage", "operator": "eq", "value": "WebSocket connection permanently terminated" }, { "path": "$.premise.lookupErrorCauseName", "operator": "eq", "value": "ReconnectingWebSocketError" } ] }, "next": "transport-not-reported", "intent": "Premise: the real SDK produces exactly the METAMASK-ZHT9 error on a socket that exhausted its reconnect budget" }, "transport-not-reported": { "action": "assert_json", "assert": { "all": [ { "path": "$.transport.unifiedAccountErrorReports", "operator": "eq", "value": 0 }, { "path": "$.transport.accountSetupFailedEvents", "operator": "eq", "value": 0 } ] }, "next": "transport-bounded", "intent": "The terminated-socket lookup failure is not sent to logger.error (Sentry) and emits no AccountSetup FAILED event, on any of the four #ensureReady entries" }, "transport-bounded": { "action": "assert_json", "assert": { "all": [ { "path": "$.transport.ensureReadyEntriesDuringCooldown", "operator": "eq", "value": 3 }, { "path": "$.transport.lookupsDuringCooldown", "operator": "eq", "value": 1 } ] }, "next": "transport-retries-later", "intent": "Three back-to-back #ensureReady entries on the dead socket make one userAbstraction lookup, not three" }, "transport-retries-later": { "action": "assert_json", "assert": { "all": [ { "path": "$.transport.lookupsAfterCooldown", "operator": "eq", "value": 1 }, { "path": "$.transport.readinessCacheWritten", "operator": "eq", "value": false } ] }, "next": "genuine-reported", "intent": "The retry contract is kept: nothing is cached and the first entry after the cooldown runs the lookup again" }, "genuine-reported": { "action": "assert_json", "assert": { "all": [ { "path": "$.genuine.unifiedAccountErrorReports", "operator": "eq", "value": 1 }, { "path": "$.genuine.reportedMessages[0]", "operator": "eq", "value": "User rejected the request." }, { "path": "$.genuine.accountSetupFailedEvents", "operator": "eq", "value": 1 } ] }, "next": "revert-check", "intent": "A genuine migration failure (signer rejection) is still reported through logger.error with the ensureUnifiedAccountEnabled context" }, "revert-check": { "action": "command", "timeout_ms": 600000, "next": "revert-restored", "intent": "Revert HyperLiquidProvider.ts to the merge base, run the new regression tests, and restore the fix in the same process" }, "revert-restored": { "action": "assert_output", "source": "revert-check", "stream": "stdout", "contains": "RESTORED_OK", "next": "revert-failed", "intent": "The fixed source is back on disk byte-for-byte after the red run" }, "revert-failed": { "action": "assert_output", "source": "revert-check", "stream": "stderr", "contains": "Tests: 8 failed, 57 skipped, 6 passed, 71 total", "next": "revert-symptom", "intent": "With the fix reverted, the regression tests that guard it fail (literal count)" }, "revert-symptom": { "action": "assert_output", "source": "revert-check", "stream": "stderr", "contains": "✕ does not report a terminated WebSocket lookup to Sentry and keeps the retry flag", "next": "regression-tests", "intent": "The red run fails on the ticket's symptom: the terminated-socket lookup is reported" }, "regression-tests": { "action": "command", "cmd": "yarn workspace @metamask/perps-controller run jest --no-coverage packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts --reporters=default --verbose", "timeout_ms": 600000, "next": "tests-exit", "intent": "Run the account-mode suite that covers the unified-account setup with the fix in place" }, "tests-exit": { "action": "assert_exit_code", "source": "regression-tests", "expected": 0, "next": "tests-count", "intent": "Confirm the suite passed" }, "tests-count": { "action": "assert_output", "source": "regression-tests", "stream": "stderr", "contains": "Tests: 24 skipped, 47 passed, 71 total", "next": "package", "intent": "Assert the literal test count so a vanished or filtered-out test fails the proof" }, "package": { "action": "index_artifacts", "artifacts": [ { "type": "report", "category": "validation", "label": "Recorded logger.error, AccountSetup analytics and userAbstraction calls with the fix" }, { "type": "report", "category": "validation", "label": "Same driver against the pre-fix build (one Sentry report and one lookup per #ensureReady entry)" }, { "type": "script", "category": "validation", "label": "Headless proof driver" }, { "type": "script", "category": "validation", "label": "Atomic revert -> run -> restore red check" } ], "next": "done", "intent": "Preserve the recorded state and the scripts in the review package" }, "done": { "action": "end", "status": "pass" } } } }Validation Logs
Full output (20/20 passed, pass)
Checklist
Screenshots/Recordings
Note
Medium Risk
Changes unified-account setup retry and error-reporting semantics on a critical HyperLiquid path; behavior is well covered by new tests but affects observability (fewer failed setup analytics) and timing of background migration retries.
Overview
Reduces METAMASK-ZHT9 noise by treating dead HyperLiquid WebSocket transport (terminated socket, close, timeout, abort) as connectivity during
#ensureUnifiedAccountEnabled, not as a failed account setup.When the
userAbstractionlookup fails that way, the provider debug-logs, keeps the retry flag, and starts a one-minute cooldown instead of callinglogger.erroror emittingPerp Account Setupfailed.#ensureReadyskips init-time unified-account setup until the cooldown ends so repeated preload/read paths do not hammer the same dead socket;reconnect()anddisconnect()clear the cooldown. Trading and withdraw still run setup at action time without the cooldown gate.Venue
WebSocketRequestErrorreplies (server text, no cause), signing rejections, and other non-transport failures are unchanged. A sharedisWebSocketTransportErrorhelper useshasErrorInCauseChainplus the SDK’s fixed client-side messages. Changelog and a largeWebSocket transport failurestest block document and lock the behavior.Reviewed by Cursor Bugbot for commit 5df2327. Bugbot is set up for automated code reviews on this repo. Configure here.