Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.70.0]

### Added
Expand Down Expand Up @@ -11008,7 +11015,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.70.0...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.70.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.1...v7.70.0
[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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ jest.mock('../index', () => ({
const mockUseSelector = useSelector as jest.MockedFunction<typeof useSelector>;
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;
Expand All @@ -53,6 +54,7 @@ describe('PerpsAlwaysOnProvider', () => {

mockConnect.mockResolvedValue(undefined);
mockDisconnect.mockResolvedValue(undefined);
mockEnsureConnected.mockResolvedValue(undefined);

mockSubscriptionRemove = jest.fn();
addEventListenerSpy = jest
Expand Down Expand Up @@ -167,15 +169,15 @@ 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(
<PerpsAlwaysOnProvider>
<Text>child</Text>
</PerpsAlwaysOnProvider>,
);

// Clear the initial mount connect call
mockConnect.mockClear();
mockEnsureConnected.mockClear();

act(() => {
mockAppStateListener?.('background');
Expand All @@ -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', () => {
Expand All @@ -201,7 +203,7 @@ describe('PerpsAlwaysOnProvider', () => {
</PerpsAlwaysOnProvider>,
);

mockConnect.mockClear();
mockEnsureConnected.mockClear();

// Goes active — schedules reconnect timer
act(() => {
Expand All @@ -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);
});

Expand Down Expand Up @@ -250,7 +252,7 @@ describe('PerpsAlwaysOnProvider', () => {
</PerpsAlwaysOnProvider>,
);

mockConnect.mockClear();
mockEnsureConnected.mockClear();
mockDisconnect.mockClear();

// Pull-down: active → inactive → active
Expand All @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});

Expand Down Expand Up @@ -1237,6 +1238,7 @@ describe('PerpsStreamManager', () => {
expect(mockSubscribeToPrices).toHaveBeenCalledTimes(1);
expect(mockSubscribeToPrices).toHaveBeenCalledWith({
symbols: ['ETH-PERP'],
includeMarketData: false,
callback: expect.any(Function),
});

Expand Down
6 changes: 5 additions & 1 deletion app/components/UI/Perps/providers/PerpsStreamManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -520,9 +520,13 @@ class PriceStreamChannel extends StreamChannel<Record<string, PriceUpdate>> {
},
);

// 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<string, PriceUpdate> = {};
updates.forEach((update) => {
Expand Down
112 changes: 112 additions & 0 deletions app/components/UI/Perps/services/PerpsConnectionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ const resetManager = (manager: unknown) => {
initPromise: Promise<void> | null;
disconnectPromise: Promise<void> | null;
pendingReconnectPromise: Promise<void> | null;
ensureConnectedPromise: Promise<void> | null;
unsubscribeFromStore: (() => void) | null;
previousAddress: string | undefined;
previousPerpsNetwork: 'mainnet' | 'testnet' | undefined;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
60 changes: 56 additions & 4 deletions app/components/UI/Perps/services/PerpsConnectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class PerpsConnectionManagerClass {
private connectionRefCount = 0;
private initPromise: Promise<void> | null = null;
private disconnectPromise: Promise<void> | null = null;
private ensureConnectedPromise: Promise<void> | null = null;
private hasPreloaded = false;
private isPreloading = false;
private prewarmCleanups: (() => void)[] = [];
Expand Down Expand Up @@ -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<void> {
private async performActualDisconnection(
options: { force?: boolean } = {},
): Promise<void> {
DevLogger.log(
`PerpsConnectionManager: Grace period expired, performing disconnection (refCount: ${this.connectionRefCount})`,
);
Expand All @@ -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;
Expand Down Expand Up @@ -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<void> {
// 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<void> {
// 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<void> {
this.connectionRefCount--;
DevLogger.log(
Expand Down
2 changes: 1 addition & 1 deletion app/constants/ota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading