Skip to content

fix(perps): [perps-controller] Reduce noisy Sentry error: WebSocket permanently terminated (METAMASK-ZHT9) - #10651

Merged
abretonc7s merged 4 commits into
mainfrom
TAT-4053-fix-reduce-sentry-websocket-errors
Oct 2, 2026
Merged

abretonc7s merged 4 commits into
mainfrom
TAT-4053-fix-reduce-sentry-websocket-errors

Conversation

@abretonc7s

@abretonc7s abretonc7s commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Explanation

Sentry METAMASK-ZHT9 (WebSocketRequestError: WebSocket connection permanently terminated, ~282k events / ~6k users) is connectivity noise from the HyperLiquid unified-account setup.

Current behavior:

  • Once the HyperLiquid WebSocket hits its reconnect limit, HyperLiquidClientService keeps the dead WS InfoClient.
  • Every #ensureReady entry then runs #ensureUnifiedAccountEnabled. Its userAbstraction lookup fails on the dead socket.
  • The generic catch reports that through logger.error as "Could not enable Unified Account (user rejected, or network error)" and tracks AccountSetup failed.
  • It also sets the retry flag, so #ensureReady repeats the same lookup and the same report on every preload tick and read until the socket is rebuilt.

This PR (all in HyperLiquidProvider):

  • New transport branch in the catch. WebSocket transport failures now go to a dedicated branch: a closed or terminated socket, request timeout, or abort (ReconnectingWebSocketError, or WebSocketRequestError with 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 no failed analytics.
  • Background setup waits out the cooldown. #ensureReady skips 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() and disconnect() end the cooldown.
  • Action time is not gated. Trading and withdraw call the setup directly and still attempt it.
  • Real failures are still reported. HyperLiquid's own error replies (WebSocketRequestError without a cause, carrying server text) and signer rejections still go to Sentry.

Things reviewers may not expect:

  • Analytics. Transport failures no longer emit Perp Account Setup with status: failed. These were connectivity events, not setup outcomes, so setup-failure dashboards will drop.
  • No socket rebuild after termination. The provider still does not rebuild the socket itself after a permanent termination; recovery is 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.
  • Follow-up. The action-time lookup still uses the WS info client. Reading it over HTTP would let the migration finish while the socket is down.

Validation:

  • Targeted suite: 47 passed. All provider suites: 1548 passed.
  • With the fix reverted, 8 of the 14 new tests fail.
  • A headless proof terminates a real SDK socket and drives the built provider. Over 4 entries: 4 Sentry reports before the fix, 0 after. Back-to-back lookups: 3 before, 1 after. A signer rejection is still reported.

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)
# MetaMask Recipe Run

Status: pass
Duration: 23s
Nodes: 20/20 passed

## Steps
- PASS status (app.status, 4ms): platform=core
- PASS build (command, 2.8s): exitCode=0, stdout=05:41:50 PM - Projects in this build: 
    * ../messenger/tsconfig.build.json
    * ../utils/tsconfig.build.json
    * ../base-controller/tsconfig.build.json
    * ../json-rpc-engine/tsconfig.build.json
    * ../eth-json-rpc-provider/tsconfig.build.json
    * ../controller-utils/tsconfig.build.json
    * ../keyring-controller/tsconfig.build.json
    * ../geolocation-controller/tsconfig.build.json
    * ../analytics-controller/tsconfig.build.json
    * ../polling-controller/tsconfig.build.json
    * ../remote-feature-flag-controller/tsconfig.build.json
    * ../config-registry-controller/tsconfig.build.json
    * ../connectivity-controller/tsconfig.build.json
    * ../eth-block-tracker/tsconfig.build.json
    * ../message-manager/tsconfig.build.json
    * ../eth-json-rpc-middleware/tsconfig.build.json
    * ../network-controller/tsconfig.build.json
    * ../accounts-controller/tsconfig.build.json
    * ../snap-account-service/tsconfig.build.json
    * ../multichain-account-service/tsconfig.build.json
    * ../seedless-onboarding-controller/tsconfig.build.json
    * ../address-book-controller/tsconfig.build.json
    * ../profile-sync-controller/tsconfig.build.json
    * ../account-tree-controller/tsconfig.build.json
    * ../storage-service/tsconfig.build.json
    * ../base-data-service/tsconfig.build.json
    * ../authenticated-user-storage/tsconfig.build.json
    * ../approval-controller/tsconfig.build.json
    * ../chomp-api-service/tsconfig.build.json
    * ../delegation-controller/tsconfig.build.json
    * ../money-account-api-data-service/tsconfig.build.json
    * ../money-account-balance-service/tsconfig.build.json
    * ../core-backend/tsconfig.build.json
    * ../gas-fee-controller/tsconfig.build.json
    * ../transaction-controller/tsconfig.build.json
    * ../money-account-utils/tsconfig.build.json
    * ../money-account-upgrade-controller/tsconfig.build.json
    * ../subscription-controller/tsconfig.build.json
    * tsconfig.build.json

05:41:50 PM - Project '../messenger/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../messenger/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../utils/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../utils/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../base-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../base-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../json-rpc-engine/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../json-rpc-engine/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../eth-json-rpc-provider/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../eth-json-rpc-provider/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../controller-utils/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../controller-utils/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../keyring-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../keyring-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../geolocation-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../geolocation-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../analytics-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../analytics-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../polling-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../polling-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../remote-feature-flag-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../remote-feature-flag-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../config-registry-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../config-registry-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../connectivity-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../connectivity-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../eth-block-tracker/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../eth-block-tracker/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../message-manager/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../message-manager/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../eth-json-rpc-middleware/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../eth-json-rpc-middleware/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../network-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../network-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../accounts-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../accounts-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../snap-account-service/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../snap-account-service/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../multichain-account-service/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../multichain-account-service/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../seedless-onboarding-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../seedless-onboarding-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../address-book-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../address-book-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../profile-sync-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../profile-sync-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../account-tree-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../account-tree-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../storage-service/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../storage-service/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../base-data-service/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../base-data-service/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../authenticated-user-storage/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../authenticated-user-storage/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../approval-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../approval-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../chomp-api-service/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../chomp-api-service/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../delegation-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../delegation-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../money-account-api-data-service/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../money-account-api-data-service/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../money-account-balance-service/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../money-account-balance-service/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../core-backend/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../core-backend/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../gas-fee-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../gas-fee-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../transaction-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../transaction-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../money-account-utils/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../money-account-utils/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../money-account-upgrade-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../money-account-upgrade-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project '../subscription-controller/tsconfig.build.json' is up to date because newest input '../../types/global.d.ts' is older than output '../subscription-controller/tsconfig.build.tsbuildinfo'

05:41:50 PM - Project 'tsconfig.build.json' is out of date because output 'tsconfig.build.tsbuildinfo' is older than input 'src/providers/HyperLiquidProvider.ts'

05:41:50 PM - Building project 'tsconfig.build.json'...

- PASS build-ok (assert_exit_code, 1ms): source=build, expected=0, actual=0
- PASS dist-has-fix (assert_file, 3ms): path=packages/perps-controller/dist/providers/HyperLiquidProvider.js
{
  "premise": {
    "terminationReasonName": "ReconnectingWebSocketError",
    "terminationReasonCode": "RECONNECTION_LIMIT",
    "lookupErrorName": "WebSocketRequestError",
    "lookupErrorMessage": "WebSocket connection permanently terminated",
    "lookupErrorCauseName": "ReconnectingWebSocketError",
    "lookupErrorCauseCode": "RECONNECTION_LIMIT"
  },
  "transport": {
    "lookupsDuringCooldown": 1,
    "lookupsAfterCooldown": 1,
    "unifiedAccountErrorReports": 0,
    "accountSetupFailedEvents": 0
  },
  "genuine": {
    "unifiedAccountErrorReports": 1,
    "accountSetupFailedEvents": 1
  }
}

- PASS proof-ok (assert_exit_code, 1ms): source=run-proof, expected=0, actual=0
- PASS revert-check (command, 8.0s): exitCode=0, stdout=REVERTED_TO=ebdd04c009ed6f90c1651680ef3deabd625e3195
REVERTED_JEST_EXIT=1
RESTORED_OK
, stderr=FAIL perps-controller tests/src/providers/HyperLiquidProvider.account-mode.test.ts (5.61 s)
  HyperLiquidProvider
    getUserNonFundingLedgerUpdates
      ○ skipped returns non-funding ledger updates
      ○ skipped returns empty array on error
    HIP-3 Private Methods
      getUsdcTokenId
        ○ skipped returns cached token ID when available
        ○ skipped fetches and caches token ID on first call
        ○ skipped throws error when USDC token not found in metadata
      findSourceDexWithBalance
        ○ skipped finds main DEX with sufficient balance
        ○ skipped returns null when insufficient balance
      getAllAvailableDexs
        ○ skipped returns cached DEX list when cache is populated
        ○ skipped fetches DEX list from API when cache is empty
        ○ skipped returns fallback when API returns null
        ○ skipped returns fallback when API returns non-array
        ○ skipped returns fallback and logs error when API throws
        ○ skipped filters out null entries from cached DEX list
        ○ skipped returns only main DEX when cached list contains only null
      ensureReadyForTrading
        ○ skipped calls ensureReady first before trading setup
        ○ skipped returns immediately when tradingSetupComplete is true
        ○ skipped sets tradingSetupComplete to true after successful setup
        ○ skipped keeps tradingSetupComplete false when keyring is locked
      autoTransferForHip3Order
        ○ skipped returns null when target DEX has sufficient balance
        ○ skipped transfers from main DEX when target has insufficient balance
        ○ skipped throws error when no source has sufficient balance
        ○ skipped throws error when transfer fails
      calculateHip3RequiredMargin
        ○ skipped calculates total margin when increasing existing long position
        ○ skipped calculates incremental margin when reversing position
        ○ skipped calculates margin for new position when no existing position
        ○ skipped calculates total margin when increasing existing short position
    ensureUnifiedAccountEnabled
      ○ skipped does not call userAbstraction when useUnifiedAccount is false
      ○ skipped does not call userAbstraction when global cache indicates already attempted
      ○ skipped waits for in-flight then returns when another provider already cached the result
      ○ skipped waits for in-flight then runs its own attempt when no cache was written (deferred migration case)
      ○ skipped lets only one of several waiters take the lock after an attempt that cached nothing
      ○ skipped returns early when re-check cache (inside lock) shows another provider completed
      ○ skipped tracks already_enabled and caches success when mode is already unifiedAccount
      ○ skipped does NOT migrate portfolioMargin users — tracks already_enabled and skips exchange call
      ○ skipped calls agentSetAbstraction silently when mode is default
      ○ skipped calls agentSetAbstraction silently when mode is disabled
      ○ skipped tracks migration_required then success for default → unifiedAccount
      ○ skipped skips migration and does not cache success for unknown abstraction mode futureMode
      ○ skipped skips migration and does not cache success for unknown abstraction mode dexAbstraction
      ○ skipped skips unified account migration for Hyperliquid multi-sig accounts
      ○ skipped caches attempted-but-not-enabled readiness for Hyperliquid multi-sig accounts
      ○ skipped treats a Multi-sig required rejection as benign instead of reporting an error
      ○ skipped still migrates single-signer accounts when the multi-sig probe fails
      ○ skipped does not query the multi-sig signer set when no migration write is needed
      ○ skipped records unifiedAccount mode when account is already unifiedAccount
      ○ skipped records portfolioMargin mode when account is already portfolioMargin
      ○ skipped records unifiedAccount mode after migrating from default → unifiedAccount
      ○ skipped defers default migration on init when every signature needs confirmation
      ○ skipped defers disabled migration on init when every signature needs confirmation
      ○ skipped does NOT call setUserAbstractionMode when migration fails
      ○ skipped does NOT cache when silent agentSetAbstraction fails (default/disabled paths retry on next entry)
      ○ skipped retries migration on the next #ensureReady after a silent agent failure
      ○ skipped does NOT cache or log to Sentry when KEYRING_LOCKED is thrown
      ○ skipped does NOT cache or log to Sentry when a wrapped KEYRING_LOCKED error is thrown
      ○ skipped does NOT cache failure when userAbstraction read itself rejects
      ○ skipped uses testnet network key when client is in testnet mode
      ○ skipped sets in-flight lock with unifiedAccount key and releases it on success
      WebSocket transport failures
        ✕ does not report a terminated WebSocket lookup to Sentry and keeps the retry flag (16 ms)
        ✕ does not report a bare ReconnectingWebSocketError from the lookup (5 ms)
        ✕ does not report a client-side WebSocketRequestError without a cause: WebSocket connection closed (4 ms)
        ✕ does not report a client-side WebSocketRequestError without a cause: WebSocket connection closed before the request was sent (4 ms)
        ✕ does not report a client-side WebSocketRequestError without a cause: WebSocket connection permanently terminated (3 ms)
        ✕ skips the lookup on every entry inside the cooldown after a transport failure (4 ms)
        ✕ runs the setup again after the cooldown when an action-time lookup overlapped it (5002 ms)
        ✓ ends the cooldown when the provider reconnects (4 ms)
        ✓ ends the cooldown when the provider disconnects (4 ms)
        ✓ does not carry a cooldown started during disconnect into the next session (20 ms)
        ✓ still reports a server error frame returned for the lookup (4 ms)
        ✕ does not apply the cooldown to action-time setup (5 ms)
        ✓ still reports a non-transport lookup failure and retries it on the next entry (5 ms)
        ✓ still reports a migration write the user rejected (4 ms)

  ● HyperLiquidProvider › ensureUnifiedAccountEnabled › WebSocket transport failures › does not report a terminated WebSocket lookup to Sentry and keeps the retry flag

    expect(jest.fn()).not.toHaveBeenCalled()

    Expected number of calls: 0
    Received number of calls: 1

    1: [WebSocketRequestError: WebSocket connection permanently terminated], {"context": {"data": {"method": "ensureUnifiedAccountEnabled", "note": "Could not enable Unified Account (user rejected, or network error)"}, "name": "HyperLiquidProvider"}, "tags": {"feature": "perps", "network": "mainnet", "provider": "hyperliquid"}}

      2003 |         await provider.getMarketDataWithPrices();
      2004 |
    > 2005 |         expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled();
           |                                                           ^
      2006 |         expect(
      2007 |           mockPlatformDependencies.metrics.trackPerpsEvent,
      2008 |         ).not.toHaveBeenCalledWith(

      at Object.<anonymous> (tests/src/providers/HyperLiquidProvider.account-mode.test.ts:2005:59)

  ● HyperLiquidProvider › ensureUnifiedAccountEnabled › WebSocket transport failures › does not report a bare ReconnectingWebSocketError from the lookup

    expect(jest.fn()).not.toHaveBeenCalled()

    Expected number of calls: 0
    Received number of calls: 1

    1: [ReconnectingWebSocketError: WebSocket permanently terminated: RECONNECTION_LIMIT], {"context": {"data": {"method": "ensureUnifiedAccountEnabled", "note": "Could not enable Unified Account (user rejected, or network error)"}, "name": "HyperLiquidProvider"}, "tags": {"feature": "perps", "network": "mainnet", "provider": "hyperliquid"}}

      2042 |         await provider.getMarketDataWithPrices();
      2043 |
    > 2044 |         expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled();
           |                                                           ^
      2045 |       });
      2046 |
      2047 |       // The SDK raises these without a cause: the close-time rejections

      at Object.<anonymous> (tests/src/providers/HyperLiquidProvider.account-mode.test.ts:2044:59)

  ● HyperLiquidProvider › ensureUnifiedAccountEnabled › WebSocket transport failures › does not report a client-side WebSocketRequestError without a cause: WebSocket connection closed

    expect(jest.fn()).not.toHaveBeenCalled()

    Expected number of calls: 0
    Received number of calls: 1

    1: [WebSocketRequestError: WebSocket connection closed], {"context": {"data": {"method": "ensureUnifiedAccountEnabled", "note": "Could not enable Unified Account (user rejected, or network error)"}, "name": "HyperLiquidProvider"}, "tags": {"feature": "perps", "network": "mainnet", "provider": "hyperliquid"}}

      2067 |           await provider.getMarketDataWithPrices();
      2068 |
    > 2069 |           expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled();
           |                                                             ^
      2070 |         },
      2071 |       );
      2072 |

      at tests/src/providers/HyperLiquidProvider.account-mode.test.ts:2069:61

  ● HyperLiquidProvider › ensureUnifiedAccountEnabled › WebSocket transport failures › does not report a client-side WebSocketRequestError without a cause: WebSocket connection closed before the request was sent

    expect(jest.fn()).not.toHaveBeenCalled()

    Expected number of calls: 0
    Received number of calls: 1

    1: [WebSocketRequestError: WebSocket connection closed before the request was sent], {"context": {"data": {"method": "ensureUnifiedAccountEnabled", "note": "Could not enable Unified Account (user rejected, or network error)"}, "name": "HyperLiquidProvider"}, "tags": {"feature": "perps", "network": "mainnet", "provider": "hyperliquid"}}

      2067 |           await provider.getMarketDataWithPrices();
      2068 |
    > 2069 |           expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled();
           |                                                             ^
      2070 |         },
      2071 |       );
      2072 |

      at tests/src/providers/HyperLiquidProvider.account-mode.test.ts:2069:61

  ● HyperLiquidProvider › ensureUnifiedAccountEnabled › WebSocket transport failures › does not report a client-side WebSocketRequestError without a cause: WebSocket connection permanently terminated

    expect(jest.fn()).not.toHaveBeenCalled()

    Expected number of calls: 0
    Received number of calls: 1

    1: [WebSocketRequestError: WebSocket connection permanently terminated], {"context": {"data": {"method": "ensureUnifiedAccountEnabled", "note": "Could not enable Unified Account (user rejected, or network error)"}, "name": "HyperLiquidProvider"}, "tags": {"feature": "perps", "network": "mainnet", "provider": "hyperliquid"}}

      2067 |           await provider.getMarketDataWithPrices();
      2068 |
    > 2069 |           expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled();
           |                                                             ^
      2070 |         },
      2071 |       );
      2072 |

      at tests/src/providers/HyperLiquidProvider.account-mode.test.ts:2069:61

  ● HyperLiquidProvider › ensureUnifiedAccountEnabled › WebSocket transport failures › skips the lookup on every entry inside the cooldown after a transport failure

    expect(jest.fn()).toHaveBeenCalledTimes(expected)

    Expected number of calls: 1
    Received number of calls: 3

      2085 |         await provider.getMarketDataWithPrices();
      2086 |
    > 2087 |         expect(userAbstraction).toHaveBeenCalledTimes(1);
           |                                 ^
      2088 |
      2089 |         // Still dead after the cooldown: one more lookup, then a new cooldown.
      2090 |         nowSpy.mockReturnValue(1_000_000 + AFTER_COOLDOWN_MS);

      at Object.<anonymous> (tests/src/providers/HyperLiquidProvider.account-mode.test.ts:2087:33)

  ● HyperLiquidProvider › ensureUnifiedAccountEnabled › WebSocket transport failures › runs the setup again after the cooldown when an action-time lookup overlapped it

    thrown: "Exceeded timeout of 5000 ms for a test.
    Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."

      at _getError (../../node_modules/jest-circus/build/jestAdapterInit.js:1985:12)
          at Array.map (<anonymous>)

    Cause:
        thrown: "Exceeded timeout of 5000 ms for a test.
        Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."

          2097 |       });
          2098 |
        > 2099 |       it('runs the setup again after the cooldown when an action-time lookup overlapped it', async () => {
               |       ^
          2100 |         // Completed DEX discovery keeps init memoized, so a kept memo would
          2101 |         // stop #ensureReady from ever running the setup again.
          2102 |         const readyProvider = createTestProvider({

          at tests/src/providers/HyperLiquidProvider.account-mode.test.ts:2099:7
          at tests/src/providers/HyperLiquidProvider.account-mode.test.ts:1970:5
          at tests/src/providers/HyperLiquidProvider.account-mode.test.ts:983:3
          at Object.<anonymous> (tests/src/providers/HyperLiquidProvider.account-mode.test.ts:172:1)

  ● HyperLiquidProvider › ensureUnifiedAccountEnabled › WebSocket transport failures › does not apply the cooldown to action-time setup

    expect(jest.fn()).toHaveBeenCalledTimes(expected)

    Expected number of calls: 2
    Received number of calls: 3

      2253 |         // #ensureReady skipped its run inside the cooldown; the trading
      2254 |         // path still looked the account up.
    > 2255 |         expect(userAbstraction).toHaveBeenCalledTimes(2);
           |                                 ^
      2256 |         expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalledWith(
      2257 |           expect.anything(),
      2258 |           expect.objectContaining({

      at Object.<anonymous> (tests/src/providers/HyperLiquidProvider.account-mode.test.ts:2255:33)

Test Suites: 1 failed, 1 total
Tests:       8 failed, 57 skipped, 6 passed, 71 total
Snapshots:   0 total
Time:        6.182 s
Ran all test suites matching tests/src/providers/HyperLiquidProvider.account-mode.test.ts with tests matching "WebSocket transport failures".

- PASS revert-restored (assert_output, 1ms): source=revert-check, stream=stdout, contains=RESTORED_OK
- PASS revert-failed (assert_output, 0ms): source=revert-check, stream=stderr, contains=Tests:       8 failed, 57 skipped, 6 passed, 71 total
- PASS revert-symptom (assert_output, 1ms): source=revert-check, stream=stderr, contains=✕ does not report a terminated WebSocket lookup to Sentry and keeps the retry flag
- PASS regression-tests (command, 2.1s): exitCode=0, stderr=PASS perps-controller tests/src/providers/HyperLiquidProvider.account-mode.test.ts
  HyperLiquidProvider
    getUserNonFundingLedgerUpdates
      ✓ returns non-funding ledger updates (8 ms)
      ✓ returns empty array on error (4 ms)
    HIP-3 Private Methods
      getUsdcTokenId
        ○ skipped returns cached token ID when available
        ○ skipped fetches and caches token ID on first call
        ○ skipped throws error when USDC token not found in metadata
      findSourceDexWithBalance
        ○ skipped finds main DEX with sufficient balance
        ○ skipped returns null when insufficient balance
      getAllAvailableDexs
        ○ skipped returns cached DEX list when cache is populated
        ○ skipped fetches DEX list from API when cache is empty
        ○ skipped returns fallback when API returns null
        ○ skipped returns fallback when API returns non-array
        ○ skipped returns fallback and logs error when API throws
        ○ skipped filters out null entries from cached DEX list
        ○ skipped returns only main DEX when cached list contains only null
      ensureReadyForTrading
        ○ skipped calls ensureReady first before trading setup
        ○ skipped returns immediately when tradingSetupComplete is true
        ○ skipped sets tradingSetupComplete to true after successful setup
        ○ skipped keeps tradingSetupComplete false when keyring is locked
      autoTransferForHip3Order
        ○ skipped returns null when target DEX has sufficient balance
        ○ skipped transfers from main DEX when target has insufficient balance
        ○ skipped throws error when no source has sufficient balance
        ○ skipped throws error when transfer fails
      calculateHip3RequiredMargin
        ○ skipped calculates total margin when increasing existing long position
        ○ skipped calculates incremental margin when reversing position
        ○ skipped calculates margin for new position when no existing position
        ○ skipped calculates total margin when increasing existing short position
    ensureUnifiedAccountEnabled
      ✓ does not call userAbstraction when useUnifiedAccount is false (7 ms)
      ✓ does not call userAbstraction when global cache indicates already attempted (3 ms)
      ✓ waits for in-flight then returns when another provider already cached the result (4 ms)
      ✓ waits for in-flight then runs its own attempt when no cache was written (deferred migration case) (3 ms)
      ✓ lets only one of several waiters take the lock after an attempt that cached nothing (16 ms)
      ✓ returns early when re-check cache (inside lock) shows another provider completed (4 ms)
      ✓ tracks already_enabled and caches success when mode is already unifiedAccount (6 ms)
      ✓ does NOT migrate portfolioMargin users — tracks already_enabled and skips exchange call (4 ms)
      ✓ calls agentSetAbstraction silently when mode is default (4 ms)
      ✓ calls agentSetAbstraction silently when mode is disabled (3 ms)
      ✓ tracks migration_required then success for default → unifiedAccount (3 ms)
      ✓ skips migration and does not cache success for unknown abstraction mode futureMode (3 ms)
      ✓ skips migration and does not cache success for unknown abstraction mode dexAbstraction (6 ms)
      ✓ skips unified account migration for Hyperliquid multi-sig accounts (3 ms)
      ✓ caches attempted-but-not-enabled readiness for Hyperliquid multi-sig accounts (4 ms)
      ✓ treats a Multi-sig required rejection as benign instead of reporting an error (3 ms)
      ✓ still migrates single-signer accounts when the multi-sig probe fails (2 ms)
      ✓ does not query the multi-sig signer set when no migration write is needed (3 ms)
      ✓ records unifiedAccount mode when account is already unifiedAccount (2 ms)
      ✓ records portfolioMargin mode when account is already portfolioMargin (1 ms)
      ✓ records unifiedAccount mode after migrating from default → unifiedAccount (1 ms)
      ✓ defers default migration on init when every signature needs confirmation (1 ms)
      ✓ defers disabled migration on init when every signature needs confirmation (5 ms)
      ✓ does NOT call setUserAbstractionMode when migration fails (2 ms)
      ✓ does NOT cache when silent agentSetAbstraction fails (default/disabled paths retry on next entry) (2 ms)
      ✓ retries migration on the next #ensureReady after a silent agent failure (2 ms)
      ✓ does NOT cache or log to Sentry when KEYRING_LOCKED is thrown (2 ms)
      ✓ does NOT cache or log to Sentry when a wrapped KEYRING_LOCKED error is thrown (2 ms)
      ✓ does NOT cache failure when userAbstraction read itself rejects (1 ms)
      ✓ uses testnet network key when client is in testnet mode (1 ms)
      ✓ sets in-flight lock with unifiedAccount key and releases it on success (1 ms)
      WebSocket transport failures
        ✓ does not report a terminated WebSocket lookup to Sentry and keeps the retry flag (7 ms)
        ✓ does not report a bare ReconnectingWebSocketError from the lookup (3 ms)
        ✓ does not report a client-side WebSocketRequestError without a cause: WebSocket connection closed (2 ms)
        ✓ does not report a client-side WebSocketRequestError without a cause: WebSocket connection closed before the request was sent (2 ms)
        ✓ does not report a client-side WebSocketRequestError without a cause: WebSocket connection permanently terminated (2 ms)
        ✓ skips the lookup on every entry inside the cooldown after a transport failure (3 ms)
        ✓ runs the setup again after the cooldown when an action-time lookup overlapped it (7 ms)
        ✓ ends the cooldown when the provider reconnects (4 ms)
        ✓ ends the cooldown when the provider disconnects (2 ms)
        ✓ does not carry a cooldown started during disconnect into the next session (21 ms)
        ✓ still reports a server error frame returned for the lookup (2 ms)
        ✓ does not apply the cooldown to action-time setup (3 ms)
        ✓ still reports a non-transport lookup failure and retries it on the next entry (2 ms)
        ✓ still reports a migration write the user rejected (2 ms)

Test Suites: 1 passed, 1 total
Tests:       24 skipped, 47 passed, 71 total
Snapshots:   0 total
Time:        0.811 s, estimated 6 s
Ran all test suites matching packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts.

- PASS tests-exit (assert_exit_code, 1ms): source=regression-tests, expected=0, actual=0
- PASS tests-count (assert_output, 0ms): source=regression-tests, stream=stderr, contains=Tests:       24 skipped, 47 passed, 71 total
- PASS package (index_artifacts, 15ms)
- PASS done (end, 0ms)

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

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 userAbstraction lookup fails that way, the provider debug-logs, keeps the retry flag, and starts a one-minute cooldown instead of calling logger.error or emitting Perp Account Setup failed. #ensureReady skips init-time unified-account setup until the cooldown ends so repeated preload/read paths do not hammer the same dead socket; reconnect() and disconnect() clear the cooldown. Trading and withdraw still run setup at action time without the cooldown gate.

Venue WebSocketRequestError replies (server text, no cause), signing rejections, and other non-transport failures are unchanged. A shared isWebSocketTransportError helper uses hasErrorInCauseChain plus the SDK’s fixed client-side messages. Changelog and a large WebSocket transport failures test 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.

…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 abretonc7s changed the title chore: prepare farmslot publication pkg-91273ef7-mupcpfzq fix(perps): [perps-controller] Reduce noisy Sentry error: WebSocket permanently terminated (METAMASK-ZHT9) Oct 1, 2026
@abretonc7s
abretonc7s marked this pull request as ready for review October 1, 2026 10:19
@abretonc7s
abretonc7s requested review from a team as code owners October 1, 2026 10:19
@abretonc7s
abretonc7s deployed to default-branch October 1, 2026 10:19 — with GitHub Actions Active
@abretonc7s
abretonc7s enabled auto-merge October 1, 2026 15:40
@abretonc7s
abretonc7s added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit b9a496c Oct 2, 2026
45 checks passed
@abretonc7s
abretonc7s deleted the TAT-4053-fix-reduce-sentry-websocket-errors branch October 2, 2026 03:37
@abretonc7s abretonc7s mentioned this pull request Oct 6, 2026
4 tasks done
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants