Skip to content

refactor(perps): unify dual DEX discovery caches to prevent desync bugs#28705

Merged
abretonc7s merged 6 commits intomainfrom
feat/tat-2760-refactorperps-unify-dual-dex-d
Apr 15, 2026
Merged

refactor(perps): unify dual DEX discovery caches to prevent desync bugs#28705
abretonc7s merged 6 commits intomainfrom
feat/tat-2760-refactorperps-unify-dual-dex-d

Conversation

@abretonc7s
Copy link
Copy Markdown
Contributor

@abretonc7s abretonc7s commented Apr 12, 2026

Description

Consolidate three separate DEX discovery caches (mainnetDexCache, testnetDexCache, dexDiscoveryTimestamp) into a single DexDiscoveryState managed by a new DexDiscoveryCacheManager service. This eliminates desync bugs where mainnet and testnet caches could get out of step, and reduces HyperLiquidProvider by ~170 lines.

Also fixes a WebSocket leak in HyperLiquidSubscriptionService.clearAll() — it now unsubscribes all active subscriptions before clearing refs, eliminating SOCKET_NOT_CONNECTED errors on reconnect.

Changelog

CHANGELOG entry: null

Related issues

Fixes: TAT-2760

Manual testing steps

Feature: Perps DEX discovery cache consolidation

  Scenario: user opens perps and switches networks
    Given the app is on mainnet with perps tab visible

    When user navigates to perps and views available DEXs
    Then DEX list loads correctly from unified cache

  Scenario: user triggers reconnection after background
    Given the app has active WebSocket subscriptions

    When user backgrounds and foregrounds the app
    Then subscriptions reconnect without SOCKET_NOT_CONNECTED errors

Screenshots/Recordings

Before

N/A — internal refactor

After

Validated live:

  • 22/22 iOS evals passing
  • 22/22 Android evals passing
  • 0 SOCKET_NOT_CONNECTED errors across both platforms
  • yarn jest HyperLiquidProvider --no-coverage — 282 tests passing
android-validation.mp4
ios-validation.mp4

Validation Recipe

recipe.json (22 steps — disconnect, background, long-background, restart)
{
  "title": "DEX Discovery — validate cache across disconnect, background, and restart",
  "inputs": {
    "symbol": {
      "type": "string",
      "default": "BTC",
      "description": "Market symbol to verify"
    },
    "test_disconnect": {
      "type": "boolean",
      "default": true,
      "description": "Test disconnect/init cycle clears and restores cache"
    },
    "test_background": {
      "type": "boolean",
      "default": true,
      "description": "Test background/foreground preserves cache"
    },
    "test_restart": {
      "type": "boolean",
      "default": false,
      "description": "Test full app restart reloads cache (slower, needs ~20s boot)"
    },
    "background_duration_ms": {
      "type": "number",
      "default": 5000,
      "description": "How long to keep the app in background (ms)"
    }
  },
  "validate": {
    "workflow": {
      "pre_conditions": ["wallet.unlocked", "perps.feature_enabled"],
      "entry": "nav-market-list",
      "nodes": {
        "nav-market-list": {
          "action": "navigate",
          "target": "PerpsTrendingView",
          "next": "wait-markets"
        },
        "wait-markets": {
          "action": "wait_for",
          "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){return JSON.stringify({count:ms.length})})",
          "assert": { "operator": "gt", "field": "count", "value": 0 },
          "next": "check-price"
        },
        "check-price": {
          "action": "eval_async",
          "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){var m=ms.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({found:!!m,price:m?m.price:'0'})})",
          "assert": { "operator": "neq", "field": "price", "value": "0" },
          "next": "check-dexs"
        },
        "check-dexs": {
          "action": "eval_async",
          "description": "Verify DEX discovery cache is populated",
          "expression": "Engine.context.PerpsController.getAvailableDexs().then(function(dexs){return JSON.stringify({count:dexs.length,hasMainDex:dexs.indexOf('')>=0})})",
          "assert": { "operator": "gt", "field": "count", "value": 0 },
          "next": "disconnect"
        },
        "disconnect": {
          "action": "eval_async",
          "description": "Tear down provider — clears DEX discovery cache",
          "when": { "operator": "eq", "field": "inputs.test_disconnect", "value": true },
          "expression": "Engine.context.PerpsController.disconnect().then(function(){return JSON.stringify({disconnected:true})})",
          "assert": { "operator": "eq", "field": "disconnected", "value": true },
          "next": "reconnect"
        },
        "reconnect": {
          "action": "eval_async",
          "description": "Re-init provider — must rebuild cache from scratch",
          "when": { "operator": "eq", "field": "inputs.test_disconnect", "value": true },
          "expression": "Engine.context.PerpsController.init().then(function(){return JSON.stringify({reinited:true})})",
          "assert": { "operator": "eq", "field": "reinited", "value": true },
          "next": "wait-reconnect"
        },
        "wait-reconnect": {
          "action": "wait_for",
          "when": { "operator": "eq", "field": "inputs.test_disconnect", "value": true },
          "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){return JSON.stringify({count:ms.length})})",
          "assert": { "operator": "gt", "field": "count", "value": 0 },
          "timeout_ms": 20000,
          "next": "verify-dexs-reconnect"
        },
        "verify-dexs-reconnect": {
          "action": "eval_async",
          "description": "Confirm DEX cache restored after reconnect",
          "when": { "operator": "eq", "field": "inputs.test_disconnect", "value": true },
          "expression": "Engine.context.PerpsController.getAvailableDexs().then(function(dexs){return JSON.stringify({count:dexs.length})})",
          "assert": { "operator": "gt", "field": "count", "value": 0 },
          "next": "background-app"
        },
        "background-app": {
          "action": "app_background",
          "when": { "operator": "eq", "field": "inputs.test_background", "value": true },
          "duration_ms": "{{background_duration_ms}}",
          "next": "foreground-app"
        },
        "foreground-app": {
          "action": "app_foreground",
          "when": { "operator": "eq", "field": "inputs.test_background", "value": true },
          "next": "wait-post-foreground"
        },
        "wait-post-foreground": {
          "action": "wait_for",
          "when": { "operator": "eq", "field": "inputs.test_background", "value": true },
          "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){return JSON.stringify({count:ms.length})})",
          "assert": { "operator": "gt", "field": "count", "value": 0 },
          "timeout_ms": 15000,
          "next": "verify-post-foreground"
        },
        "verify-post-foreground": {
          "action": "eval_async",
          "description": "Confirm markets + price survive short background",
          "when": { "operator": "eq", "field": "inputs.test_background", "value": true },
          "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){var m=ms.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({found:!!m,price:m?m.price:'0'})})",
          "assert": { "operator": "neq", "field": "price", "value": "0" },
          "next": "long-background-app"
        },
        "long-background-app": {
          "action": "app_background",
          "description": "Background 30s — exceeds WS grace period, forces full reconnection",
          "when": { "operator": "eq", "field": "inputs.test_background", "value": true },
          "duration_ms": 30000,
          "next": "long-foreground-app"
        },
        "long-foreground-app": {
          "action": "app_foreground",
          "when": { "operator": "eq", "field": "inputs.test_background", "value": true },
          "next": "wait-post-long-foreground"
        },
        "wait-post-long-foreground": {
          "action": "wait_for",
          "when": { "operator": "eq", "field": "inputs.test_background", "value": true },
          "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){return JSON.stringify({count:ms.length})})",
          "assert": { "operator": "gt", "field": "count", "value": 0 },
          "timeout_ms": 30000,
          "next": "verify-post-long-foreground"
        },
        "verify-post-long-foreground": {
          "action": "eval_async",
          "description": "Confirm markets + DEXs recover after full WS reconnection",
          "when": { "operator": "eq", "field": "inputs.test_background", "value": true },
          "expression": "Engine.context.PerpsController.getAvailableDexs().then(function(dexs){return JSON.stringify({count:dexs.length})})",
          "assert": { "operator": "gt", "field": "count", "value": 0 },
          "next": "restart-app"
        },
        "restart-app": {
          "action": "app_restart",
          "when": { "operator": "eq", "field": "inputs.test_restart", "value": true },
          "boot_wait_ms": 20000,
          "next": "detect-post-restart"
        },
        "detect-post-restart": {
          "action": "eval_sync",
          "description": "Detect login vs wallet after restart",
          "when": { "operator": "eq", "field": "inputs.test_restart", "value": true },
          "expression": "(function(){try{var r=globalThis.__AGENTIC__.getRoute();return JSON.stringify({route:r.name})}catch(e){return JSON.stringify({route:'unknown'})}})()",
          "next": "check-needs-unlock"
        },
        "check-needs-unlock": {
          "action": "switch",
          "when": { "operator": "eq", "field": "inputs.test_restart", "value": true },
          "cases": [
            {
              "label": "needs-login",
              "when": { "operator": "eq", "field": "nodes.detect-post-restart.result.route", "value": "Login" },
              "next": "enter-password"
            }
          ],
          "default": "nav-after-restart"
        },
        "enter-password": {
          "action": "set_input",
          "test_id": "login-password-input",
          "value": "qwerasdf",
          "next": "press-login"
        },
        "press-login": {
          "action": "press",
          "test_id": "log-in-button",
          "next": "nav-after-restart"
        },
        "nav-after-restart": {
          "action": "navigate",
          "when": { "operator": "eq", "field": "inputs.test_restart", "value": true },
          "target": "PerpsTrendingView",
          "next": "wait-post-restart"
        },
        "wait-post-restart": {
          "action": "wait_for",
          "when": { "operator": "eq", "field": "inputs.test_restart", "value": true },
          "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){return JSON.stringify({count:ms.length})})",
          "assert": { "operator": "gt", "field": "count", "value": 0 },
          "timeout_ms": 30000,
          "next": "verify-post-restart"
        },
        "verify-post-restart": {
          "action": "eval_async",
          "description": "Confirm markets reload cleanly after full restart",
          "when": { "operator": "eq", "field": "inputs.test_restart", "value": true },
          "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){var m=ms.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({found:!!m,price:m?m.price:'0'})})",
          "assert": { "operator": "neq", "field": "price", "value": "0" },
          "next": "done"
        },
        "done": { "action": "end", "status": "pass" }
      }
    }
  }
}

Pre-merge author checklist

Pre-merge reviewer checklist

  • I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed).
  • I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.

@abretonc7s abretonc7s added DO-NOT-MERGE Pull requests that should not be merged agentic labels Apr 12, 2026
@github-actions
Copy link
Copy Markdown
Contributor

CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes.

@metamaskbot metamaskbot added the team-perps Perps team label Apr 12, 2026
@abretonc7s
Copy link
Copy Markdown
Contributor Author

Automated dev run — TAT-2760

Metric Value
Run e896d468
Duration ?
Model claude/opus
Nudges 0
Cost estimate $23.65

Merge #cachedValidatedDexs, #cachedAllPerpDexs, and #perpDexsCache into
a single #dexDiscoveryState field written atomically via #updateDexDiscovery().

Extract duplicated testnet/mainnet filtering into #computeValidatedDexs()
to eliminate code duplication between #fetchValidatedDexsInternal and
#getStandaloneValidatedDexs.

Also fixes a latent bug where disconnect() never cleared #cachedValidatedDexs
and #cachedAllPerpDexs, leaving stale data across reconnections.
@abretonc7s abretonc7s marked this pull request as ready for review April 12, 2026 16:39
@abretonc7s abretonc7s requested a review from a team as a code owner April 12, 2026 16:40
@abretonc7s abretonc7s changed the title feat(perps): unify dual DEX discovery caches to prevent desync bugs refactor(perps): unify dual DEX discovery caches to prevent desync bugs Apr 12, 2026
@github-actions github-actions Bot added size-M risk-medium Moderate testing recommended · Possible bug introduction risk labels Apr 12, 2026
@abretonc7s abretonc7s enabled auto-merge April 12, 2026 16:46
Comment thread app/controllers/perps/providers/HyperLiquidProvider.ts
@github-actions github-actions Bot added risk-medium Moderate testing recommended · Possible bug introduction risk and removed risk-medium Moderate testing recommended · Possible bug introduction risk labels Apr 12, 2026
@abretonc7s abretonc7s added the skip-sonar-cloud Only used for bypassing sonar cloud when failures are not relevant to the changes. label Apr 12, 2026
@github-actions github-actions Bot added risk-medium Moderate testing recommended · Possible bug introduction risk and removed risk-medium Moderate testing recommended · Possible bug introduction risk labels Apr 13, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ed766a9. Configure here.

Comment thread app/controllers/perps/providers/HyperLiquidProvider.ts Outdated
Extract cache operations into DexDiscoveryCacheManager service to reduce
HyperLiquidProvider complexity (~170 lines). Fix WebSocket leak in
HyperLiquidSubscriptionService.clearAll() — now unsubscribes all active
subscriptions before clearing refs, eliminating SOCKET_NOT_CONNECTED errors.
@github-actions github-actions Bot added size-L risk-medium Moderate testing recommended · Possible bug introduction risk and removed size-M risk-medium Moderate testing recommended · Possible bug introduction risk labels Apr 13, 2026
…return

Prevents #getAllAvailableDexs() from populating state.validated without
the hip3Enabled gate, which #getValidatedDexs() would then return from
cache — bypassing the kill switch entirely.
@github-actions github-actions Bot added risk-medium Moderate testing recommended · Possible bug introduction risk and removed risk-medium Moderate testing recommended · Possible bug introduction risk labels Apr 13, 2026
@github-actions
Copy link
Copy Markdown
Contributor

🔍 Smart E2E Test Selection

  • Selected E2E tags: SmokePerps, SmokeWalletPlatform, SmokeConfirmations
  • Selected Performance tags: @PerformancePreps
  • Risk Level: medium
  • AI Confidence: 88%
click to see 🤖 AI reasoning details

E2E Test Selection:
The changes are scoped entirely to the perps controller layer (app/controllers/perps/):

  1. DexDiscoveryCacheManager.ts (new): Consolidates three separate DEX discovery caches into a unified state object. This is a refactoring to eliminate cache desync bugs. The filtering logic for testnet/mainnet DEX selection has been moved here from HyperLiquidProvider.

  2. HyperLiquidProvider.ts: Refactored to use DexDiscoveryCacheManager. Removes three separate cache fields (#cachedAllPerpDexs, #cachedValidatedDexs, #perpDexsCache) and replaces with a single #dexDiscoveryCache. Also removes #extractDexsFromAllowlist() (moved to cache manager). The kill switch for HIP-3 is now checked before cache reads in #getValidatedDexs().

  3. HyperLiquidSubscriptionService.ts: Bug fix - now properly calls .unsubscribe() on all active subscriptions before clearing references in clearAll(), preventing SOCKET_NOT_CONNECTED errors on reconnect.

  4. perps-types.ts: Adds DexDiscoveryState type.

  5. HyperLiquidProvider.test.ts: Updates tests to use new unified cache API and adds new test cases.

Selected tags:

  • SmokePerps: Directly tests the perps functionality that was changed. DEX discovery and subscription management are core to perps trading functionality.
  • SmokeWalletPlatform: Required by SmokePerps tag description (Perps is a section inside Trending tab).
  • SmokeConfirmations: Required by SmokePerps tag description (Add Funds deposits are on-chain transactions).

No other tags are needed as the changes are isolated to the perps controller layer with no impact on other wallet features (accounts, networks, swaps, identity, etc.).

Performance Test Selection:
The HyperLiquidSubscriptionService subscription cleanup fix and the DexDiscoveryCacheManager refactoring could affect perps market loading performance. The subscription cleanup fix prevents orphaned subscriptions from causing errors on reconnect, which could impact the time to load perps market data. The unified cache manager changes the timing of DEX discovery state updates, which could affect perps market list loading performance. @PerformancePreps covers perps market loading, position management, add funds flow, and order execution - all of which depend on the changed DEX discovery and subscription management code.

View GitHub Actions results

@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
66.9% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@github-actions
Copy link
Copy Markdown
Contributor

E2E Fixture Validation — Schema is up to date
11 value mismatches detected (expected — fixture represents an existing user).
View details

@abretonc7s
Copy link
Copy Markdown
Contributor Author

Validation Logs

Platform: Android (Pixel 6a - API 36)
Command:

IOS_SIMULATOR="" ANDROID_DEVICE="Pixel 6a - 16 - API 36" ADB_SERIAL="29071JEGR20638" \
    node scripts/perps/agentic/validate-recipe.js \
    .task/feat/tat-2760-0412-2352/artifacts/recipe.json \
    --input test_restart=true
Full output (22/22 passed)
Running recipe: DEX Discovery — validate cache across disconnect, background, and restart
Pre-conditions: wallet.unlocked, perps.feature_enabled
Workflow nodes: 25

Pre-conditions: PASS

[nav-market-list] navigate to PerpsTrendingView
  result: {"navigated":"PerpsTrendingView",...}
  PASS

[wait-markets] wait for condition
  result: {"count":291}
  PASS

[check-price] eval_async
  result: {"found":true,"price":"$71,099"}
  PASS

[check-dexs] Verify DEX discovery cache is populated
  result: {"count":9,"hasMainDex":true}
  PASS

[disconnect] Tear down provider — clears DEX discovery cache
  result: {"disconnected":true}
  PASS

[reconnect] Re-init provider — must rebuild cache from scratch
  result: {"reinited":true}
  PASS

[wait-reconnect] wait for condition
  result: {"count":291}
  PASS

[verify-dexs-reconnect] Confirm DEX cache restored after reconnect
  result: {"count":9}
  PASS

[background-app] background app 5000ms
  backgrounded for 5000ms
  PASS

[foreground-app] foreground app
  foregrounded (io.metamask)
  PASS

[wait-post-foreground] wait for condition
  result: {"count":291}
  PASS

[verify-post-foreground] Confirm markets + price survive short background
  result: {"found":true,"price":"$71,096"}
  PASS

[long-background-app] Background 30s — exceeds WS grace period, forces full reconnection
  backgrounded for 30000ms
  PASS

[long-foreground-app] foreground app
  foregrounded (io.metamask)
  PASS

[wait-post-long-foreground] wait for condition
  result: {"count":291}
  PASS

[verify-post-long-foreground] Confirm markets + DEXs recover after full WS reconnection
  result: {"count":9}
  PASS

[restart-app] restart app
  waiting 20000ms for app boot + Metro reconnect...
  restarted (io.metamask)
  PASS

[detect-post-restart] Detect login vs wallet after restart
  result: {"route":"unknown"}
  PASS

[check-needs-unlock] evaluate branch
  branch -> nav-after-restart (default)
  PASS

[nav-after-restart] navigate to PerpsTrendingView
  result: {"navigated":"PerpsTrendingView",...}
  PASS

[wait-post-restart] wait for condition
  result: {"count":291}
  PASS

[verify-post-restart] Confirm markets reload cleanly after full restart
  result: {"found":true,"price":"$71,090"}
  PASS

----------------------------------------
Results: 22/22 passed
Recipe: PASS

@abretonc7s abretonc7s removed the DO-NOT-MERGE Pull requests that should not be merged label Apr 13, 2026
Copy link
Copy Markdown
Contributor

@aganglada aganglada left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to do an app state migration here?

@abretonc7s
Copy link
Copy Markdown
Contributor Author

abretonc7s commented Apr 14, 2026

Do we need to do an app state migration here?

No, it shouldnt for this

@abretonc7s abretonc7s added this pull request to the merge queue Apr 15, 2026
Merged via the queue into main with commit cf53733 Apr 15, 2026
126 of 128 checks passed
@abretonc7s abretonc7s deleted the feat/tat-2760-refactorperps-unify-dual-dex-d branch April 15, 2026 09:34
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 15, 2026
@metamaskbot metamaskbot added the release-7.74.0 Issue or pull request that will be included in release 7.74.0 label Apr 15, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

agentic release-7.74.0 Issue or pull request that will be included in release 7.74.0 risk-medium Moderate testing recommended · Possible bug introduction risk size-L skip-sonar-cloud Only used for bypassing sonar cloud when failures are not relevant to the changes. team-perps Perps team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants