From 6ee3baecb052ba07ae60934d666af579123c5b0e Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 18:37:08 +0000 Subject: [PATCH 1/6] bump semvar version to 7.70.1 && build version to 4152 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index c0b1c20531f3..3163524754ae 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,7 +187,7 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.70.0" + versionName "7.70.1" versionCode 4130 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/bitrise.yml b/bitrise.yml index 059ee847570f..8dda5005f94b 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3531,13 +3531,13 @@ app: PROJECT_LOCATION_IOS: ios - opts: is_expand: false - VERSION_NAME: 7.70.0 + VERSION_NAME: 7.70.1 - opts: is_expand: false VERSION_NUMBER: 4130 - opts: is_expand: false - FLASK_VERSION_NAME: 7.70.0 + FLASK_VERSION_NAME: 7.70.1 - opts: is_expand: false FLASK_VERSION_NUMBER: 4130 diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index c102cc09846c..3cb97f03d610 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1319,7 +1319,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.70.0; + MARKETING_VERSION = 7.70.1; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1385,7 +1385,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.70.0; + MARKETING_VERSION = 7.70.1; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1454,7 +1454,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.70.0; + MARKETING_VERSION = 7.70.1; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1518,7 +1518,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.70.0; + MARKETING_VERSION = 7.70.1; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1684,7 +1684,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.70.0; + MARKETING_VERSION = 7.70.1; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "$(inherited)", @@ -1751,7 +1751,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.70.0; + MARKETING_VERSION = 7.70.1; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "$(inherited)", diff --git a/package.json b/package.json index f7e7702bf8df..1822d4892488 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask", - "version": "7.70.0", + "version": "7.70.1", "private": true, "scripts": { "install:foundryup": "yarn mm-foundryup", From 0ae0e308fdc23691a70807e7a2df3c3cde76abd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Loureiro?= <175489935+joaoloureirop@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:25:40 +0000 Subject: [PATCH 2/6] Revert "bump semvar version to 7.70.1 && build version to 4152" This reverts commit 6ee3baecb052ba07ae60934d666af579123c5b0e. --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 3163524754ae..c0b1c20531f3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,7 +187,7 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.70.1" + versionName "7.70.0" versionCode 4130 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/bitrise.yml b/bitrise.yml index 8dda5005f94b..059ee847570f 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3531,13 +3531,13 @@ app: PROJECT_LOCATION_IOS: ios - opts: is_expand: false - VERSION_NAME: 7.70.1 + VERSION_NAME: 7.70.0 - opts: is_expand: false VERSION_NUMBER: 4130 - opts: is_expand: false - FLASK_VERSION_NAME: 7.70.1 + FLASK_VERSION_NAME: 7.70.0 - opts: is_expand: false FLASK_VERSION_NUMBER: 4130 diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 3cb97f03d610..c102cc09846c 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1319,7 +1319,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.70.1; + MARKETING_VERSION = 7.70.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1385,7 +1385,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.70.1; + MARKETING_VERSION = 7.70.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1454,7 +1454,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.70.1; + MARKETING_VERSION = 7.70.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1518,7 +1518,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.70.1; + MARKETING_VERSION = 7.70.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1684,7 +1684,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.70.1; + MARKETING_VERSION = 7.70.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "$(inherited)", @@ -1751,7 +1751,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.70.1; + MARKETING_VERSION = 7.70.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "$(inherited)", diff --git a/package.json b/package.json index 1822d4892488..f7e7702bf8df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask", - "version": "7.70.1", + "version": "7.70.0", "private": true, "scripts": { "install:foundryup": "yarn mm-foundryup", From dff0679d26ecc6cd561fd404f37ad20d2d887d27 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:11:56 +0000 Subject: [PATCH 3/6] chore(runway): cherry-pick fix(perps): fix stale data and missing price change after reconnection cp-7.70.1 (#27826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(perps): fix stale data and missing price change after reconnection (#27530) ## **Description** Two fixes for perps foreground reconnection: 1. **Stale data after background** — `connect()` returned early when `isConnected=true` (grace period kept state alive) without checking if the WebSocket was dead. Fixed by adding `ensureConnected()` that always forces disconnect + reconnect on foreground return. 2. **Price change "–%" persists after reconnect** — Prewarm called `subscribeToPrices()` without `includeMarketData`, so `assetCtxs` subscriptions (which provide `prevDayPx` for `percentChange24h`) were never re-established. Fixed by moving the `assetCtxs` subscription out of the `includeMarketData` guard in `subscribeToPrices()`. This is safe because `assetCtxs` is 1 subscription per DEX (2-3 total), not per-symbol. The expensive per-symbol `activeAssetCtx` subscriptions remain gated behind `includeMarketData`. ## **Changelog** CHANGELOG entry: Fixed stale perps data and missing 24h price change after returning from background ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Perps foreground reconnection Scenario: user returns after short background (grace period still active) Given user is on Perps screen with live data When user backgrounds app for 10s and returns Then data refreshes with live prices and positions And 24h price change % displays correctly (not "--%" ) Scenario: user returns after long background (grace period already fired) Given user is on Perps screen with live data When user backgrounds app for 60s and returns Then data refreshes with live prices and positions And 24h price change % displays correctly (not "--%" ) Scenario: initial mount unchanged Given user opens app fresh When user navigates to Perps Then connection establishes normally via connect() And 24h price change % displays correctly ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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. --- > [!NOTE] > **Medium Risk** > Touches perps WebSocket lifecycle and subscription behavior; regressions could cause extra reconnects or missed/duplicated subscriptions, though changes are scoped and covered by updated tests. > > **Overview** > Fixes perps reconnection reliability by switching foreground handling from `connect()` to a new `PerpsConnectionManager.ensureConnected()` that **cancels any grace period, force-disconnects, resets ref-count, and reconnects**, deduplicating concurrent calls. > > Restores 24h % change after reconnection/prewarm by ensuring `HyperLiquidSubscriptionService.subscribeToPrices()` always establishes lightweight per-DEX `assetCtxs` subscriptions even when `includeMarketData` is false; price prewarm explicitly passes `includeMarketData: false` and documents the N² connection risk. > > Updates unit tests and architecture docs to reflect `ensureConnected()` usage and the new subscription expectations. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1549f85b775097e5f10768f093f6548ba92e4253. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [1bf5d78](https://github.com/MetaMask/metamask-mobile/commit/1bf5d785381031f824a1abc652368a7ba7d6da15) Co-authored-by: abretonc7s <107169956+abretonc7s@users.noreply.github.com> --- .../providers/PerpsAlwaysOnProvider.test.tsx | 20 ++-- .../Perps/providers/PerpsAlwaysOnProvider.tsx | 2 +- .../providers/PerpsStreamManager.test.tsx | 4 +- .../UI/Perps/providers/PerpsStreamManager.tsx | 6 +- .../services/PerpsConnectionManager.test.ts | 112 ++++++++++++++++++ .../Perps/services/PerpsConnectionManager.ts | 60 +++++++++- .../HyperLiquidSubscriptionService.test.ts | 4 +- .../HyperLiquidSubscriptionService.ts | 59 +++++---- docs/perps/perps-connection-architecture.md | 19 +-- 9 files changed, 229 insertions(+), 57 deletions(-) diff --git a/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.test.tsx b/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.test.tsx index c2c760bbcad1..ce59bb177075 100644 --- a/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.test.tsx +++ b/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.test.tsx @@ -41,6 +41,7 @@ jest.mock('../index', () => ({ const mockUseSelector = useSelector as jest.MockedFunction; const mockConnect = PerpsConnectionManager.connect as jest.Mock; const mockDisconnect = PerpsConnectionManager.disconnect as jest.Mock; +const mockEnsureConnected = PerpsConnectionManager.ensureConnected as jest.Mock; describe('PerpsAlwaysOnProvider', () => { let mockAppStateListener: ((state: string) => void) | null = null; @@ -53,6 +54,7 @@ describe('PerpsAlwaysOnProvider', () => { mockConnect.mockResolvedValue(undefined); mockDisconnect.mockResolvedValue(undefined); + mockEnsureConnected.mockResolvedValue(undefined); mockSubscriptionRemove = jest.fn(); addEventListenerSpy = jest @@ -167,7 +169,7 @@ describe('PerpsAlwaysOnProvider', () => { expect(mockDisconnect).toHaveBeenCalledTimes(1); }); - it('calls connect after delay when app returns to foreground', () => { + it('calls ensureConnected after delay when app returns to foreground', () => { render( child @@ -175,7 +177,7 @@ describe('PerpsAlwaysOnProvider', () => { ); // Clear the initial mount connect call - mockConnect.mockClear(); + mockEnsureConnected.mockClear(); act(() => { mockAppStateListener?.('background'); @@ -185,13 +187,13 @@ describe('PerpsAlwaysOnProvider', () => { }); // Should not reconnect immediately — uses a timer delay - expect(mockConnect).not.toHaveBeenCalled(); + expect(mockEnsureConnected).not.toHaveBeenCalled(); act(() => { jest.runAllTimers(); }); - expect(mockConnect).toHaveBeenCalledTimes(1); + expect(mockEnsureConnected).toHaveBeenCalledTimes(1); }); it('cancels pending reconnect timer if app goes background before timer fires', () => { @@ -201,7 +203,7 @@ describe('PerpsAlwaysOnProvider', () => { , ); - mockConnect.mockClear(); + mockEnsureConnected.mockClear(); // Goes active — schedules reconnect timer act(() => { @@ -217,8 +219,8 @@ describe('PerpsAlwaysOnProvider', () => { jest.runAllTimers(); }); - // connect should NOT have been called (timer was cancelled) - expect(mockConnect).not.toHaveBeenCalled(); + // ensureConnected should NOT have been called (timer was cancelled) + expect(mockEnsureConnected).not.toHaveBeenCalled(); expect(mockDisconnect).toHaveBeenCalledTimes(1); }); @@ -250,7 +252,7 @@ describe('PerpsAlwaysOnProvider', () => { , ); - mockConnect.mockClear(); + mockEnsureConnected.mockClear(); mockDisconnect.mockClear(); // Pull-down: active → inactive → active @@ -266,7 +268,7 @@ describe('PerpsAlwaysOnProvider', () => { }); expect(mockDisconnect).toHaveBeenCalledTimes(1); - expect(mockConnect).toHaveBeenCalledTimes(1); + expect(mockEnsureConnected).toHaveBeenCalledTimes(1); }); it('calls disconnect and removes AppState subscription on unmount', () => { diff --git a/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx b/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx index 0573d565a90e..138e0dfad95e 100644 --- a/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx +++ b/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx @@ -58,7 +58,7 @@ export const PerpsAlwaysOnProvider: React.FC<{ children: React.ReactNode }> = ({ } else if (nextState === 'active') { // Small delay to allow system to stabilize after background reconnectTimer = setTimeout(() => { - PerpsConnectionManager.connect().catch((err) => { + PerpsConnectionManager.ensureConnected().catch((err) => { Logger.error(ensureError(err, 'PerpsAlwaysOnProvider.reconnect'), { tags: { feature: PERPS_CONSTANTS.FeatureName }, context: { diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx index 32c0363acf4c..16a8dae1cb6e 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx @@ -1046,9 +1046,10 @@ describe('PerpsStreamManager', () => { await Promise.resolve(); }); - // Now subscribeToPrices should have been called + // Now subscribeToPrices should have been called without includeMarketData expect(mockSubscribeToPrices).toHaveBeenCalledWith({ symbols: ['BTC-PERP', 'ETH-PERP'], + includeMarketData: false, callback: expect.any(Function), }); @@ -1237,6 +1238,7 @@ describe('PerpsStreamManager', () => { expect(mockSubscribeToPrices).toHaveBeenCalledTimes(1); expect(mockSubscribeToPrices).toHaveBeenCalledWith({ symbols: ['ETH-PERP'], + includeMarketData: false, callback: expect.any(Function), }); diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index af12dc4c8464..a904007ec502 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -520,9 +520,13 @@ class PriceStreamChannel extends StreamChannel> { }, ); - // Subscribe to all market prices + // WARNING: Do NOT set includeMarketData: true here. It triggers + // per-symbol activeAssetCtx subscriptions (N symbols × N DEXs = N² + // WebSocket connections). assetCtxs (1 per DEX) is always established + // by the subscription service regardless of this flag. const unsub = controller.subscribeToPrices({ symbols: this.allMarketSymbols, + includeMarketData: false, callback: (updates: PriceUpdate[]) => { const priceMap: Record = {}; updates.forEach((update) => { diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.test.ts b/app/components/UI/Perps/services/PerpsConnectionManager.test.ts index 1f42dec83381..4cbcbeba7bec 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.test.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.test.ts @@ -123,6 +123,7 @@ const resetManager = (manager: unknown) => { initPromise: Promise | null; disconnectPromise: Promise | null; pendingReconnectPromise: Promise | null; + ensureConnectedPromise: Promise | null; unsubscribeFromStore: (() => void) | null; previousAddress: string | undefined; previousPerpsNetwork: 'mainnet' | 'testnet' | undefined; @@ -157,6 +158,7 @@ const resetManager = (manager: unknown) => { m.initPromise = null; m.disconnectPromise = null; m.pendingReconnectPromise = null; + m.ensureConnectedPromise = null; m.unsubscribeFromStore = null; m.previousAddress = undefined; m.previousPerpsNetwork = undefined; @@ -1275,6 +1277,116 @@ describe('PerpsConnectionManager', () => { }); }); + describe('ensureConnected', () => { + it('cancels grace period, disconnects, and reconnects when connected', async () => { + // Establish connection first + await PerpsConnectionManager.connect(); + expect(PerpsConnectionManager.getConnectionState().isConnected).toBe( + true, + ); + + // Simulate the state after AlwaysOnProvider calls disconnect() which + // decrements refCount to 0 and starts grace period + const m = PerpsConnectionManager as unknown as { + isInGracePeriod: boolean; + gracePeriodTimer: number | null; + connectionRefCount: number; + }; + m.connectionRefCount = 0; + m.isInGracePeriod = true; + m.gracePeriodTimer = 123; + + // Clear mocks to track ensureConnected calls + (Engine.context.PerpsController.disconnect as jest.Mock).mockClear(); + (Engine.context.PerpsController.init as jest.Mock).mockClear(); + + await PerpsConnectionManager.ensureConnected(); + + // Grace period should be cancelled + expect(m.isInGracePeriod).toBe(false); + + // Should have disconnected then reconnected + expect(Engine.context.PerpsController.disconnect).toHaveBeenCalled(); + expect(Engine.context.PerpsController.init).toHaveBeenCalled(); + expect(PerpsConnectionManager.getConnectionState().isConnected).toBe( + true, + ); + }); + + it('reconnects after long background when grace period already fired', async () => { + // Establish connection, then simulate grace period already fired + await PerpsConnectionManager.connect(); + + // Simulate performActualDisconnection already ran (grace period fired) + const m = PerpsConnectionManager as unknown as { + isConnected: boolean; + isInitialized: boolean; + hasPreloaded: boolean; + isPreloading: boolean; + connectionRefCount: number; + }; + m.isConnected = false; + m.isInitialized = false; + m.hasPreloaded = false; + m.isPreloading = false; + // Grace period fired → refCount was already 0 when disconnect ran + m.connectionRefCount = 0; + + (Engine.context.PerpsController.init as jest.Mock).mockClear(); + + await PerpsConnectionManager.ensureConnected(); + + // Should have reconnected (connect() runs full init path since isConnected=false) + expect(Engine.context.PerpsController.init).toHaveBeenCalled(); + expect(PerpsConnectionManager.getConnectionState().isConnected).toBe( + true, + ); + }); + + it('connects when not previously connected', async () => { + // Manager starts in disconnected state (from resetManager in beforeEach) + (Engine.context.PerpsController.init as jest.Mock).mockClear(); + + await PerpsConnectionManager.ensureConnected(); + + expect(Engine.context.PerpsController.init).toHaveBeenCalled(); + expect(PerpsConnectionManager.getConnectionState().isConnected).toBe( + true, + ); + }); + + it('resets connectionRefCount to 1 after ensureConnected', async () => { + // Simulate refCount drift: connect() twice so refCount = 2 + await PerpsConnectionManager.connect(); + await PerpsConnectionManager.connect(); + + const m = PerpsConnectionManager as unknown as { + connectionRefCount: number; + }; + expect(m.connectionRefCount).toBe(2); + + await PerpsConnectionManager.ensureConnected(); + + // ensureConnected resets to 0 then connect() brings it to 1 + expect(m.connectionRefCount).toBe(1); + }); + + it('deduplicates concurrent ensureConnected calls', async () => { + (Engine.context.PerpsController.init as jest.Mock).mockClear(); + + // Fire two calls concurrently + const [result1, result2] = await Promise.all([ + PerpsConnectionManager.ensureConnected(), + PerpsConnectionManager.ensureConnected(), + ]); + + expect(result1).toBeUndefined(); + expect(result2).toBeUndefined(); + // init should only be called once since second call reuses the promise + expect(Engine.context.PerpsController.init).toHaveBeenCalledTimes(1); + }); + }); + describe('getActiveProviderName', () => { it('returns activeProvider from PerpsController state', () => { // Arrange diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts index d24d66c988c4..96e5b5f3305f 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts @@ -50,6 +50,7 @@ class PerpsConnectionManagerClass { private connectionRefCount = 0; private initPromise: Promise | null = null; private disconnectPromise: Promise | null = null; + private ensureConnectedPromise: Promise | null = null; private hasPreloaded = false; private isPreloading = false; private prewarmCleanups: (() => void)[] = []; @@ -470,9 +471,12 @@ class PerpsConnectionManagerClass { } /** - * Perform the actual disconnection after grace period expires + * Perform the actual disconnection after grace period expires. + * @param options.force - Bypass refCount guard (used by ensureConnected). */ - private async performActualDisconnection(): Promise { + private async performActualDisconnection( + options: { force?: boolean } = {}, + ): Promise { DevLogger.log( `PerpsConnectionManager: Grace period expired, performing disconnection (refCount: ${this.connectionRefCount})`, ); @@ -481,8 +485,8 @@ class PerpsConnectionManagerClass { this.gracePeriodTimer = null; this.isInGracePeriod = false; - // Only disconnect if we still have no references - if (this.connectionRefCount <= 0) { + // Only disconnect if we still have no references (unless forced) + if (options.force || this.connectionRefCount <= 0) { if (this.isConnected || this.isInitialized) { // Track that we're disconnecting this.isDisconnecting = true; @@ -1061,6 +1065,54 @@ class PerpsConnectionManagerClass { } } + /** + * Called on foreground return. Always forces a full reconnect. + * + * The grace period timer handles battery savings (disconnects after 30s + * in background). But regardless of whether the timer fired or not, + * we cannot trust the WebSocket state after backgrounding: + * - Grace period fired: already disconnected, need fresh connect + * - Grace period didn't fire (iOS suspends JS timers): WebSocket + * likely dead but isConnected still true, need fresh connect + * + * By always doing disconnect + connect, behavior is identical on both + * iOS and Android regardless of how long the app was backgrounded. + */ + async ensureConnected(): Promise { + // Guard against concurrent calls (e.g. rapid foreground transitions) + if (this.ensureConnectedPromise) { + return this.ensureConnectedPromise; + } + + this.ensureConnectedPromise = this.performEnsureConnected(); + try { + await this.ensureConnectedPromise; + } finally { + this.ensureConnectedPromise = null; + } + } + + private async performEnsureConnected(): Promise { + // Cancel grace period if still pending — we're taking over + this.cancelGracePeriod(); + + // Force clean state so connect() runs the full init → ping → preload path. + // Uses force: true to bypass the refCount guard — ensureConnected must + // always tear down, regardless of how many components hold references. + if (this.isConnected || this.isInitialized) { + await this.performActualDisconnection({ force: true }); + } + + // Reset refCount so connect() brings it to exactly 1. + // Without this, repeated background/foreground cycles would drift + // refCount upward (1→2→3…), eventually preventing grace-period + // disconnects from firing (they require refCount ≤ 0). + this.connectionRefCount = 0; + + // Full reconnect: init → ping → preload + await this.connect(); + } + async disconnect(): Promise { this.connectionRefCount--; DevLogger.log( diff --git a/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts b/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts index a1e2877498a6..95b4801741da 100644 --- a/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts +++ b/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts @@ -3275,8 +3275,8 @@ describe('HyperLiquidSubscriptionService', () => { await jest.runAllTimersAsync(); - // Should not call meta/metaAndAssetCtxs when market data not requested - expect(mockInfoClient.meta).not.toHaveBeenCalled(); + // assetCtxs subscription is always established (lightweight, 1 per DEX) + // so meta may be called for the assetCtxs mapping, but metaAndAssetCtxs should not expect(mockInfoClient.metaAndAssetCtxs).not.toHaveBeenCalled(); unsubscribe(); diff --git a/app/controllers/perps/services/HyperLiquidSubscriptionService.ts b/app/controllers/perps/services/HyperLiquidSubscriptionService.ts index 7c7cc8dda64a..2b3e64732e19 100644 --- a/app/controllers/perps/services/HyperLiquidSubscriptionService.ts +++ b/app/controllers/perps/services/HyperLiquidSubscriptionService.ts @@ -1031,32 +1031,39 @@ export class HyperLiquidSubscriptionService { // Ensure global subscriptions are established this.#ensureGlobalAllMidsSubscription(); - // HIP-3: Establish assetCtxs subscriptions ONLY for DEXs with requested symbols - // Performance: Avoid unnecessary WebSocket connections for unused DEXs - if (includeMarketData) { - // Extract unique DEXs from requested symbols - const dexsNeeded = new Set(); - symbols.forEach((symbol) => { - const { dex } = parseAssetName(symbol); - dexsNeeded.add(dex); + // Extract unique DEXs from requested symbols + const dexsNeeded = new Set(); + symbols.forEach((symbol) => { + const { dex } = parseAssetName(symbol); + dexsNeeded.add(dex); + }); + + // Always ensure assetCtxs subscriptions (1 per DEX, lightweight). + // Provides prevDayPx for percentChange24h even without includeMarketData + // (e.g., prewarm after reconnection). Uses incrementRefCount: false when + // not explicitly requested so lifecycle is managed by component subscriptions. + dexsNeeded.forEach((dex) => { + const dexName = dex ?? ''; + this.#ensureAssetCtxsSubscription(dexName, { + incrementRefCount: includeMarketData, + }).catch((error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.subscribeToPrices', + ), + this.#getErrorContext( + 'subscribeToPrices.ensureAssetCtxsSubscription', + { dex: dexName }, + ), + ); }); + }); - // Only subscribe to DEXs that have requested symbols + // dexAllMids and activeAssetCtx only when market data explicitly requested + if (includeMarketData) { dexsNeeded.forEach((dex) => { const dexName = dex ?? ''; - this.#ensureAssetCtxsSubscription(dexName).catch((error) => { - this.#logErrorUnlessClearing( - ensureError( - error, - 'HyperLiquidSubscriptionService.subscribeToPrices', - ), - this.#getErrorContext( - 'subscribeToPrices.ensureAssetCtxsSubscription', - { dex: dexName }, - ), - ); - }); - this.#ensureDexAllMidsSubscription(dexName).catch((error) => { this.#logErrorUnlessClearing( ensureError( @@ -1110,14 +1117,6 @@ export class HyperLiquidSubscriptionService { // Cleanup DEX-level assetCtxs subscriptions if (includeMarketData) { - // Extract unique DEXs from requested symbols - const dexsNeeded = new Set(); - symbols.forEach((symbol) => { - const { dex } = parseAssetName(symbol); - dexsNeeded.add(dex); - }); - - // Cleanup assetCtxs subscription for each DEX dexsNeeded.forEach((dex) => { const dexName = dex ?? ''; this.#cleanupAssetCtxsSubscription(dexName); diff --git a/docs/perps/perps-connection-architecture.md b/docs/perps/perps-connection-architecture.md index 300bfe6ef2d3..397dd2621a5c 100644 --- a/docs/perps/perps-connection-architecture.md +++ b/docs/perps/perps-connection-architecture.md @@ -67,7 +67,7 @@ graph TD - Call `connect()` on mount (when `isPerpsEnabled`) - Call `disconnect()` when app goes to background (triggers 20s grace period in Manager) -- Call `connect()` when app returns to foreground (with `ReconnectionDelayAndroidMs` stabilization delay) +- Call `ensureConnected()` when app returns to foreground (forces disconnect + fresh reconnect after stabilization delay) - Call `disconnect()` on unmount **Does NOT**: @@ -362,13 +362,14 @@ The stream hooks used by PerpsHomeView gracefully handle the not-yet-connected s ### Manager Layer Methods -| Method | Signature | Purpose | -| --------------------------- | ----------------------------------------------- | ----------------------------------------------- | -| `connect()` | `() => Promise` | Initialize connection if first provider | -| `disconnect()` | `() => Promise` | Disconnect if last provider (with grace period) | -| `reconnectWithNewContext()` | `(options?: ReconnectOptions) => Promise` | Coordinate full reconnection | -| `getConnectionState()` | `() => ConnectionState` | Get current connection state (for polling) | -| `resetError()` | `() => void` | Clear error state | +| Method | Signature | Purpose | +| --------------------------- | ----------------------------------------------- | ----------------------------------------------------- | +| `connect()` | `() => Promise` | Initialize connection if first provider | +| `disconnect()` | `() => Promise` | Disconnect if last provider (with grace period) | +| `ensureConnected()` | `() => Promise` | Foreground return: force disconnect + fresh reconnect | +| `reconnectWithNewContext()` | `(options?: ReconnectOptions) => Promise` | Coordinate full reconnection | +| `getConnectionState()` | `() => ConnectionState` | Get current connection state (for polling) | +| `resetError()` | `() => void` | Clear error state | ### Controller Layer Methods @@ -518,7 +519,7 @@ The Manager's `pendingReconnectPromise` ensures only one reconnection happens at | Account switch | `reconnectWithNewContext()` | default | Manager (automatic via Redux subscription) | Clears caches immediately before reconnection | | Network switch | `reconnectWithNewContext()` | default | Manager (automatic via Redux subscription) | Same as account switch | | App background | `disconnect()` | - | PerpsAlwaysOnProvider → Manager | Grace period (20s) before actual disconnect | -| App foreground | `connect()` | - | PerpsAlwaysOnProvider → Manager | ReconnectionDelayAndroidMs stabilization | +| App foreground | `ensureConnected()` | - | PerpsAlwaysOnProvider → Manager | Forces disconnect + reconnect after delay | --- From 8c73c1944857bbca0847ddc3c8a05bf7f3cadc9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Loureiro?= <175489935+joaoloureirop@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:21:43 +0000 Subject: [PATCH 4/6] bump ota version --- app/constants/ota.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/constants/ota.ts b/app/constants/ota.ts index 70e0dd691f3b..ec21ae8bdf2e 100644 --- a/app/constants/ota.ts +++ b/app/constants/ota.ts @@ -6,7 +6,7 @@ import otaConfig from '../../ota.config.js'; * Reset to v0 when releasing a new native build * We keep this OTA_VERSION here to because changes in ota.config.js will affect the fingerprint and break the workflow in Github Actions */ -export const OTA_VERSION: string = 'v7.65.1'; +export const OTA_VERSION: string = 'v7.70.1'; export const RUNTIME_VERSION = otaConfig.RUNTIME_VERSION; export const PROJECT_ID = otaConfig.PROJECT_ID; export const UPDATE_URL = otaConfig.UPDATE_URL; From 0e0cb2534b9b5fd73a20ca8b54b0ed02b8e3bef2 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:15:27 +0000 Subject: [PATCH 5/6] chore(runway): cherry-pick fix(perps): fix HIP-3 asset ID lookup failure from dual-cache desync cp-7.70.1 (#27860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(perps): fix HIP-3 asset ID lookup failure from dual-cache desync cp-7.70.1 (#27854) ## **Description** Fix HIP-3 asset ID lookup failure (`"Asset ID not found for xyz:BRENTOIL"`) that blocked trading on HIP-3 markets when navigating via the old Perps tab layout. **Root cause**: Dual-cache desync between `#cachedValidatedDexs` (string DEX names) and `#cachedAllPerpDexs` (raw API objects for `perpDexIndex` computation). The standalone preload path (`#getStandaloneValidatedDexs`) populated one cache but not the other. When `#buildAssetMapping` later ran, it found "xyz" in `dexsToMap` but couldn't compute its `perpDexIndex` because `#cachedAllPerpDexs` was null. **Why old Perps tab vs new Homepage Sections**: Both layouts sit inside `Wallet/index.tsx`, which calls `startMarketDataPreload()` on mount. This fires standalone HTTP calls that populate `#cachedValidatedDexs` but not `#cachedAllPerpDexs`. - **New homepage sections**: `PerpsSectionWithProvider` mounts immediately. Stream hooks fire `ensureReady()` before or concurrently with the standalone preload. Since `#cachedValidatedDexs` is often still null, `fetchValidatedDexsInternal` runs fresh and sets **both** caches correctly. - **Old tab layout**: The Perps tab doesn't mount until the user taps it. By that time, `startMarketDataPreload()` has already completed → `#cachedValidatedDexs` is populated by standalone. When the tab mounts → `getValidatedDexs()` → **cache hit** → `fetchValidatedDexsInternal` is never called → `#cachedAllPerpDexs` stays null → `buildAssetMapping` can't find "xyz". **Changes (1 file, 3 sites)**: 1. **Root cause fix**: `#getStandaloneValidatedDexs` now sets `this.#cachedAllPerpDexs = allDexs` after a successful `perpDexs()` call, keeping both caches in sync. 2. **Cache poisoning fix**: Removed `this.#cachedAllPerpDexs = this.#cachedAllPerpDexs ?? [null]` from the catch block in `#buildAssetMapping`. 3. **Cache poisoning fix**: Replaced persistent `if (!cache) { cache = [null] }` with local `const allPerpDexs = cache ?? [null]` — consumers read the cache, only the owner writes it. ## **Changelog** CHANGELOG entry: Fixed a bug where closing positions on HIP-3 markets (e.g., xyz:BRENTOIL) failed with "Asset ID not found" when navigating via the Perps tab ## **Related issues** Fixes: HIP-3 asset ID lookup failure on old Perps tab layout ## **Manual testing steps** ```gherkin Feature: HIP-3 position management via Perps tab Scenario: user closes a HIP-3 position from the old Perps tab Given user has an open position on a HIP-3 market (e.g., xyz:BRENTOIL) And user is using the old tab layout (homepage redesign v1 disabled) When user navigates to the Perps tab And user taps close on the xyz:BRENTOIL position Then the position closes successfully without "Asset ID not found" error Scenario: user opens a HIP-3 position from the old Perps tab Given user is on the Perps tab (old layout) When user navigates to xyz:BRENTOIL market and places a market order Then the order executes successfully with correct asset ID routing ``` ## **Screenshots/Recordings** ### **Before** Metro logs show the desync: ``` getValidatedDexs CACHE HIT {"cachedAllNull": true, "dexs": [null, "xyz"]} buildAssetMapping state {"allPerpDexsLen": 1, "cachedAllNull": true} Could not find perpDexIndex for DEX xyz Asset ID not found for xyz:BRENTOIL ``` ### **After** Metro logs show both caches in sync: ``` buildAssetMapping state {"allPerpDexsLen": 8, "cachedAllNull": false, "dexsToMap": [null, "xyz"]} Asset map state at order time {"assetExistsInMap": true, "hip3AssetsCount": 54, "totalAssetsInMap": 283} Resolved DEX-specific asset ID {"assetId": 110049, "coin": "xyz:BRENTOIL"} usePerpsClosePosition: Close result {"success": true, "orderId": "359617825254"} ``` ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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. --- > [!NOTE] > **Medium Risk** > Touches HIP-3 market routing/asset-ID mapping in `HyperLiquidProvider`, so a mistake could break trading on some perps markets; scope is small and localized to cache population/fallback behavior. > > **Overview** > Fixes a HIP-3 asset mapping failure where `#cachedValidatedDexs` could be populated via the standalone preload path while `#cachedAllPerpDexs` stayed `null`, leading to missing `perpDexIndex` during `#buildAssetMapping`. > > `#getStandaloneValidatedDexs()` now also populates `#cachedAllPerpDexs` after a successful `perpDexs()` call, and `#buildAssetMapping()` no longer “poisons” the shared cache with a persistent `[null]` fallback (it uses a local fallback instead). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c925609ab6e324afaf50556d96abf4acca2460ee. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [2898ec8](https://github.com/MetaMask/metamask-mobile/commit/2898ec839aa04320d74dc6bc4ea9b7ec24668d17) Co-authored-by: abretonc7s <107169956+abretonc7s@users.noreply.github.com> --- .../perps/providers/HyperLiquidProvider.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/controllers/perps/providers/HyperLiquidProvider.ts b/app/controllers/perps/providers/HyperLiquidProvider.ts index 764f4e42ea1e..480ac72df401 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.ts @@ -2119,16 +2119,13 @@ export class HyperLiquidProvider implements PerpsProvider { '[buildAssetMapping] getValidatedDexs failed, falling back to main DEX', { error: String(dexError) }, ); - this.#cachedAllPerpDexs = this.#cachedAllPerpDexs ?? [null]; dexsToMap = [null]; } - // Use cached perpDexs array (populated by getValidatedDexs) - // Defensive: ensure non-null even if getValidatedDexs had an unexpected issue - if (!this.#cachedAllPerpDexs) { - this.#cachedAllPerpDexs = [null]; - } - const allPerpDexs = this.#cachedAllPerpDexs; + // Local fallback only — never write [null] into #cachedAllPerpDexs here. + // That cache is owned exclusively by #fetchValidatedDexsInternal; writing a + // fallback here would prevent subsequent callers from retrying perpDexs(). + const allPerpDexs = this.#cachedAllPerpDexs ?? [null]; this.#deps.debugLogger.log( 'HyperLiquidProvider: Starting asset mapping rebuild', @@ -4772,6 +4769,12 @@ export class HyperLiquidProvider implements PerpsProvider { return [null]; } + // Populate #cachedAllPerpDexs so buildAssetMapping can compute perpDexIndex. + // Without this, getValidatedDexs returns from #cachedValidatedDexs (string names) + // but #cachedAllPerpDexs (raw objects for index computation) stays null, + // causing "Could not find perpDexIndex for DEX xyz" failures. + this.#cachedAllPerpDexs = allDexs; + // Extract HIP-3 DEX names (filter out null which represents main DEX) const availableHip3Dexs: string[] = []; allDexs.forEach((dex) => { From c6515db9e2f3d059cbe1c1e53887c1e8a17887b6 Mon Sep 17 00:00:00 2001 From: chloeYue <105063779+chloeYue@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:20:08 +0100 Subject: [PATCH 6/6] chore(release): release-changelog/7.70.1 (#27841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Adds **7.70.1** release notes for hotfixes on `release/7.70.1`, and updates changelog compare links per release process. User-facing changes documented: - Fixed stale perpetuals data and missing 24h price change after returning from background ([#27530](https://github.com/MetaMask/metamask-mobile/pull/27530)) - Fixed a bug where closing positions on HIP-3 markets (e.g., xyz:BRENTOIL) failed with "Asset ID not found" when navigating via the Perps tab ([#27854](https://github.com/MetaMask/metamask-mobile/pull/27854)) ## Changelog CHANGELOG entry: null ## Related - Release PR: [#27824](https://github.com/MetaMask/metamask-mobile/pull/27824) (`release/7.70.1` → `stable`) - Cherry-picks: [#27826](https://github.com/MetaMask/metamask-mobile/pull/27826) (reconnection), [#27860](https://github.com/MetaMask/metamask-mobile/pull/27860) (HIP-3 / dual-cache) ## Compare links - `[Unreleased]` → `v7.70.1...HEAD` - `[7.70.1]` → `v7.70.0...v7.70.1` (patch compares to previous minor tag) - Older version links (e.g. `[7.69.1]`) unchanged. ## Branch - Merged current `release/7.70.1` into this branch so the changelog PR stays aligned with [#27824](https://github.com/MetaMask/metamask-mobile/pull/27824). Merge with **Create a merge commit** (not squash), per release changelog workflow. --- > [!NOTE] > **Low Risk** > Low risk: documentation-only change to `CHANGELOG.md` with no runtime or build impact. > > **Overview** > Documents the `7.70.1` hotfix in `CHANGELOG.md`, adding two *Fixed* items related to perpetuals market data refresh and HIP-3 perps position closing. > > Updates the changelog compare links so `Unreleased` now compares from `v7.70.1`, and adds the new `7.70.1` compare URL. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 33bab8a9792f642a8e77098d13ecb59d5f14dca9. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: metamaskbot --- CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index caa3c7d63bf9..ab5a9a1c29b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.70.1] + +### Fixed + +- Fixed stale perpetuals data and missing 24h price change after returning from background (#27530) +- Fixed a bug where closing positions on HIP-3 markets (e.g., xyz:BRENTOIL) failed with "Asset ID not found" when navigating via the Perps tab (#27854) + ## [7.69.1] ### Fixed @@ -10931,7 +10938,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957) - [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954) -[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.1...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.1...HEAD +[7.70.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.0...v7.70.1 [7.69.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.0...v7.69.1 [7.69.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.68.3...v7.69.0 [7.68.3]: https://github.com/MetaMask/metamask-mobile/compare/v7.68.2...v7.68.3