refactor(perps): unify dual DEX discovery caches to prevent desync bugs#28705
refactor(perps): unify dual DEX discovery caches to prevent desync bugs#28705abretonc7s merged 6 commits intomainfrom
Conversation
|
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. |
Automated dev run — TAT-2760
|
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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.
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.
…return Prevents #getAllAvailableDexs() from populating state.validated without the hip3Enabled gate, which #getValidatedDexs() would then return from cache — bypassing the kill switch entirely.
🔍 Smart E2E Test Selection
click to see 🤖 AI reasoning detailsE2E Test Selection:
Selected tags:
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: |
|
|
✅ E2E Fixture Validation — Schema is up to date |
Validation LogsPlatform: Android (Pixel 6a - API 36) 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=trueFull output (22/22 passed) |
No, it shouldnt for this |



Description
Consolidate three separate DEX discovery caches (
mainnetDexCache,testnetDexCache,dexDiscoveryTimestamp) into a singleDexDiscoveryStatemanaged by a newDexDiscoveryCacheManagerservice. 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, eliminatingSOCKET_NOT_CONNECTEDerrors on reconnect.Changelog
CHANGELOG entry: null
Related issues
Fixes: TAT-2760
Manual testing steps
Screenshots/Recordings
Before
N/A — internal refactor
After
Validated live:
yarn jest HyperLiquidProvider --no-coverage— 282 tests passingandroid-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