diff --git a/.github/workflows/check-template-and-add-labels.yml b/.github/workflows/check-template-and-add-labels.yml index 63e3423d1904..7d0c6b3e0f4b 100644 --- a/.github/workflows/check-template-and-add-labels.yml +++ b/.github/workflows/check-template-and-add-labels.yml @@ -8,17 +8,33 @@ on: merge_group: jobs: + is-fork-pull-request: + name: Determine whether this is a pull request from a fork + runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request_target' }} + outputs: + IS_FORK: ${{ steps.is-fork.outputs.IS_FORK }} + steps: + - uses: actions/checkout@v4 + - name: Determine whether this PR is from a fork + id: is-fork + run: echo "IS_FORK=$(gh pr view --json isCrossRepository --jq '.isCrossRepository' "${PR_NUMBER}" )" >> "$GITHUB_OUTPUT" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.number }} + check-template-and-add-labels: runs-on: ubuntu-latest - if: ${{ github.event_name != 'merge_group' }} # Skip this step for merge_group events + needs: [is-fork-pull-request] + if: ${{ always() && github.event_name != 'merge_group' && (github.event_name == 'issues' || (github.event_name == 'pull_request_target' && needs.is-fork-pull-request.outputs.IS_FORK == 'false')) }} steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 1 # This retrieves only the latest commit. - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' diff --git a/.yarn/patches/@ethereumjs-util-npm-9.1.0-7e85509408.patch b/.yarn/patches/@ethereumjs-util-npm-9.1.0-7e85509408.patch new file mode 100644 index 000000000000..bf8ee135174c --- /dev/null +++ b/.yarn/patches/@ethereumjs-util-npm-9.1.0-7e85509408.patch @@ -0,0 +1,13 @@ +diff --git a/dist/cjs/account.js b/dist/cjs/account.js +index 9c7b96d50a1e1e9a08463e0be74f1462576b8b53..04f390b50c11a10b20971bccb46d212978c2ac89 100644 +--- a/dist/cjs/account.js ++++ b/dist/cjs/account.js +@@ -476,7 +476,7 @@ const pubToAddress = function (pubKey, sanitize = false) { + // Only take the lower 160bits of the hash + return (0, keccak_js_1.keccak256)(pubKey).subarray(-20); + }; +-exports.pubToAddress = pubToAddress; ++exports.pubToAddress = require('@metamask/native-utils').pubToAddress; + exports.publicToAddress = exports.pubToAddress; + /** + * Returns the ethereum public key of a given private key. diff --git a/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch b/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch new file mode 100644 index 000000000000..73fc040895e4 --- /dev/null +++ b/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch @@ -0,0 +1,21 @@ +diff --git a/dist/curves/ed25519.cjs b/dist/curves/ed25519.cjs +index 3f6b0951c046dbda89f18edb7f17e6d23b839fc5..d2aee95598d942c0219128a77f6f83abdf206b80 100644 +--- a/dist/curves/ed25519.cjs ++++ b/dist/curves/ed25519.cjs +@@ -14,6 +14,7 @@ const isValidPrivateKey = (_privateKey) => true; + exports.isValidPrivateKey = isValidPrivateKey; + exports.deriveUnhardenedKeys = false; + exports.publicKeyLength = 33; ++const nativeUtils = require('@metamask/native-utils') + const getGetPublicKey = () => { + let hasSetWindowSize = false; + const getPublicKey = (privateKey, _compressed) => { +@@ -21,7 +22,7 @@ const getGetPublicKey = () => { + ed25519_1.ed25519.ExtendedPoint.BASE._setWindowSize(4); + hasSetWindowSize = true; + } +- const publicKey = ed25519_1.ed25519.getPublicKey(privateKey); ++ const publicKey = nativeUtils.getPublicKeyEd25519(privateKey); + return (0, utils_1.concatBytes)([new Uint8Array([0]), publicKey]); + }; + return getPublicKey; diff --git a/app/__mocks__/@metamask/native-utils.js b/app/__mocks__/@metamask/native-utils.js new file mode 100644 index 000000000000..0fb68eb792c5 --- /dev/null +++ b/app/__mocks__/@metamask/native-utils.js @@ -0,0 +1,42 @@ +/* eslint-disable import/no-extraneous-dependencies */ +/* eslint-disable import/no-commonjs */ +/** + * Mock for @metamask/native-utils + * + * This module uses react-native-nitro-modules which requires native code. + * In Jest tests, we use the original JavaScript implementations from @noble packages. + */ + +const { secp256k1 } = require('@noble/curves/secp256k1'); +const { ed25519 } = require('@noble/curves/ed25519'); +const { keccak_256 } = require('@noble/hashes/sha3'); +const { hmac } = require('@noble/hashes/hmac'); +const { sha512 } = require('@noble/hashes/sha2'); + +export const getPublicKey = secp256k1.getPublicKey; +export const keccak256 = keccak_256; +export const hmacSha512 = (key, data) => hmac(sha512, key, data); +export const getPublicKeyEd25519 = ed25519.getPublicKey; +export const multiply = (a, b) => a * b; + +/** + * Reimplemented from @ethereumjs/util. + * + * We cannot import pubToAddress from @ethereumjs/util directly because it's + * patched to use @metamask/native-utils, which would cause infinite recursion: + * 1. Our mock's pubToAddress is called + * 2. It requires @ethereumjs/util + * 3. @ethereumjs/util's exports.pubToAddress = require('@metamask/native-utils').pubToAddress + * 4. That returns our mock's pubToAddress + * 5. We call ourselves → stack overflow + */ +export const pubToAddress = (pubKey, sanitize = false) => { + let key = pubKey; + if (sanitize && pubKey.length !== 64) { + key = secp256k1.ProjectivePoint.fromHex(pubKey).toRawBytes(false).slice(1); + } + if (key.length !== 64) { + throw new Error('Expected pubKey to be of length 64'); + } + return keccak_256(key).subarray(-20); +}; diff --git a/app/components/UI/FundActionMenu/FundActionMenu.test.tsx b/app/components/UI/FundActionMenu/FundActionMenu.test.tsx index 3a314384ca20..1e236008980c 100644 --- a/app/components/UI/FundActionMenu/FundActionMenu.test.tsx +++ b/app/components/UI/FundActionMenu/FundActionMenu.test.tsx @@ -493,6 +493,11 @@ describe('FundActionMenu', () => { text: 'Buy', location: 'FundActionMenu', chain_id_destination: 1, + region: undefined, + ramp_routing: undefined, + is_authenticated: false, + preferred_provider: undefined, + order_count: 0, }); expect(mockTrackEvent).toHaveBeenCalledWith(mockBuild()); }); @@ -536,6 +541,11 @@ describe('FundActionMenu', () => { text: 'Buy', location: 'FundActionMenu', chain_id_destination: 137, + region: undefined, + ramp_routing: undefined, + is_authenticated: false, + preferred_provider: undefined, + order_count: 0, }); }); }); diff --git a/app/components/UI/FundActionMenu/FundActionMenu.tsx b/app/components/UI/FundActionMenu/FundActionMenu.tsx index 262efca3f083..febd793b05fe 100644 --- a/app/components/UI/FundActionMenu/FundActionMenu.tsx +++ b/app/components/UI/FundActionMenu/FundActionMenu.tsx @@ -50,7 +50,7 @@ const FundActionMenu = () => { const rampUnifiedV1Enabled = useRampsUnifiedV1Enabled(); const { goToBuy, goToAggregator, goToSell, goToDeposit } = useRampNavigation(); - const depositButtonClickData = useRampsButtonClickData(); + const rampsButtonClickData = useRampsButtonClickData(); const closeBottomSheetAndNavigate = useCallback( (navigateFunc: () => void) => { @@ -119,6 +119,10 @@ const FundActionMenu = () => { location: 'FundActionMenu', chain_id_destination: getChainIdForAsset(), region: rampGeodetectedRegion, + ramp_routing: rampsButtonClickData.ramp_routing, + is_authenticated: rampsButtonClickData.is_authenticated, + preferred_provider: rampsButtonClickData.preferred_provider, + order_count: rampsButtonClickData.order_count, }, navigationAction: () => { if (customOnBuy) { @@ -142,10 +146,10 @@ const FundActionMenu = () => { chain_id_destination: getDecimalChainId(chainId), ramp_type: 'DEPOSIT', region: rampGeodetectedRegion, - ramp_routing: depositButtonClickData.ramp_routing, - is_authenticated: depositButtonClickData.is_authenticated, - preferred_provider: depositButtonClickData.preferred_provider, - order_count: depositButtonClickData.order_count, + ramp_routing: rampsButtonClickData.ramp_routing, + is_authenticated: rampsButtonClickData.is_authenticated, + preferred_provider: rampsButtonClickData.preferred_provider, + order_count: rampsButtonClickData.order_count, }, traceName: TraceName.LoadDepositExperience, navigationAction: () => goToDeposit(), @@ -163,6 +167,10 @@ const FundActionMenu = () => { location: 'FundActionMenu', chain_id_destination: getChainIdForAsset(), region: rampGeodetectedRegion, + ramp_routing: rampsButtonClickData.ramp_routing, + is_authenticated: rampsButtonClickData.is_authenticated, + preferred_provider: rampsButtonClickData.preferred_provider, + order_count: rampsButtonClickData.order_count, }, traceName: TraceName.LoadRampExperience, traceProperties: { tags: { rampType: RampType.BUY } }, @@ -188,6 +196,10 @@ const FundActionMenu = () => { location: 'FundActionMenu', chain_id_source: getDecimalChainId(chainId), region: rampGeodetectedRegion, + ramp_routing: rampsButtonClickData.ramp_routing, + is_authenticated: rampsButtonClickData.is_authenticated, + preferred_provider: rampsButtonClickData.preferred_provider, + order_count: rampsButtonClickData.order_count, }, traceName: TraceName.LoadRampExperience, traceProperties: { tags: { rampType: RampType.SELL } }, @@ -208,7 +220,7 @@ const FundActionMenu = () => { goToAggregator, goToSell, goToDeposit, - depositButtonClickData, + rampsButtonClickData, ], ); diff --git a/app/components/UI/FundActionMenu/FundActionMenu.types.ts b/app/components/UI/FundActionMenu/FundActionMenu.types.ts index df311d6c37f0..d7cc67dd8a06 100644 --- a/app/components/UI/FundActionMenu/FundActionMenu.types.ts +++ b/app/components/UI/FundActionMenu/FundActionMenu.types.ts @@ -26,7 +26,7 @@ export interface ActionConfig { isVisible: boolean; isDisabled?: boolean; analyticsEvent: IMetaMetricsEvent; - analyticsProperties: Record; + analyticsProperties: Record; traceName: TraceName; traceProperties?: Record< string, diff --git a/app/components/UI/Perps/services/HyperLiquidClientService.test.ts b/app/components/UI/Perps/services/HyperLiquidClientService.test.ts index 27b3351b8f22..8ed9d7492094 100644 --- a/app/components/UI/Perps/services/HyperLiquidClientService.test.ts +++ b/app/components/UI/Perps/services/HyperLiquidClientService.test.ts @@ -15,10 +15,16 @@ import { CandlePeriod } from '../constants/chartConfig'; // Mock WebSocket for Jest environment (React Native provides this globally) (global as any).WebSocket = jest.fn(); -// Mock HyperLiquid SDK +// Mock HyperLiquid SDK - using 'mock' prefix for Jest compatibility const mockExchangeClient = { initialized: true }; -const mockInfoClient = { +const mockInfoClientWs = { initialized: true, + transport: 'websocket', + candleSnapshot: jest.fn(), +}; +const mockInfoClientHttp = { + initialized: true, + transport: 'http', candleSnapshot: jest.fn(), }; const mockSubscriptionClient = { @@ -32,9 +38,17 @@ const mockHttpTransport = { url: 'http://mock', }; +// Counter for InfoClient mock - using 'mock' prefix so Jest allows it +let mockInfoClientCallCount = 0; jest.mock('@nktkas/hyperliquid', () => ({ ExchangeClient: jest.fn(() => mockExchangeClient), - InfoClient: jest.fn(() => mockInfoClient), + InfoClient: jest.fn(() => { + mockInfoClientCallCount++; + // First call is WebSocket (default), second is HTTP (fallback) + return mockInfoClientCallCount % 2 === 1 + ? mockInfoClientWs + : mockInfoClientHttp; + }), SubscriptionClient: jest.fn(() => mockSubscriptionClient), WebSocketTransport: jest.fn(() => mockWsTransport), HttpTransport: jest.fn(() => mockHttpTransport), @@ -65,6 +79,7 @@ describe('HyperLiquidClientService', () => { beforeEach(() => { jest.clearAllMocks(); + mockInfoClientCallCount = 0; // Reset InfoClient call counter mockWallet = { request: jest.fn().mockResolvedValue('0x123'), @@ -132,8 +147,14 @@ describe('HyperLiquidClientService', () => { transport: mockHttpTransport, }); - // InfoClient uses HTTP transport - expect(InfoClient).toHaveBeenCalledWith({ transport: mockHttpTransport }); + // InfoClient is created twice: once with WebSocket (default), once with HTTP (fallback) + expect(InfoClient).toHaveBeenCalledTimes(2); + expect(InfoClient).toHaveBeenNthCalledWith(1, { + transport: mockWsTransport, + }); + expect(InfoClient).toHaveBeenNthCalledWith(2, { + transport: mockHttpTransport, + }); // SubscriptionClient uses WebSocket transport expect(SubscriptionClient).toHaveBeenCalledWith({ @@ -196,10 +217,32 @@ describe('HyperLiquidClientService', () => { expect(exchangeClient).toBe(mockExchangeClient); }); - it('should provide access to info client', () => { + it('should provide access to info client (WebSocket by default)', () => { const infoClient = service.getInfoClient(); - expect(infoClient).toBe(mockInfoClient); + expect(infoClient).toBe(mockInfoClientWs); + expect((infoClient as any).transport).toBe('websocket'); + }); + + it('should provide access to HTTP info client when useHttp option is true', () => { + const infoClient = service.getInfoClient({ useHttp: true }); + + expect(infoClient).toBe(mockInfoClientHttp); + expect((infoClient as any).transport).toBe('http'); + }); + + it('should return WebSocket info client when useHttp option is false', () => { + const infoClient = service.getInfoClient({ useHttp: false }); + + expect(infoClient).toBe(mockInfoClientWs); + expect((infoClient as any).transport).toBe('websocket'); + }); + + it('should return WebSocket info client when options is empty object', () => { + const infoClient = service.getInfoClient({}); + + expect(infoClient).toBe(mockInfoClientWs); + expect((infoClient as any).transport).toBe('websocket'); }); it('should provide access to subscription client', () => { @@ -431,7 +474,9 @@ describe('HyperLiquidClientService', () => { { t: 1700003600000, o: 50500, h: 51500, l: 50000, c: 51000, v: 150 }, ]; - mockInfoClient.candleSnapshot = jest.fn().mockResolvedValue(mockResponse); + mockInfoClientWs.candleSnapshot = jest + .fn() + .mockResolvedValue(mockResponse); // Act const result = await service.fetchHistoricalCandles( @@ -463,7 +508,7 @@ describe('HyperLiquidClientService', () => { }, ], }); - expect(mockInfoClient.candleSnapshot).toHaveBeenCalledWith({ + expect(mockInfoClientWs.candleSnapshot).toHaveBeenCalledWith({ coin: 'BTC', interval: '1h', startTime: expect.any(Number), @@ -475,7 +520,9 @@ describe('HyperLiquidClientService', () => { // Arrange const mockResponse: any[] = []; - mockInfoClient.candleSnapshot = jest.fn().mockResolvedValue(mockResponse); + mockInfoClientWs.candleSnapshot = jest + .fn() + .mockResolvedValue(mockResponse); // Act const result = await service.fetchHistoricalCandles( @@ -495,7 +542,7 @@ describe('HyperLiquidClientService', () => { it('should handle API errors gracefully', async () => { // Arrange const errorMessage = 'API request failed'; - mockInfoClient.candleSnapshot = jest + mockInfoClientWs.candleSnapshot = jest .fn() .mockRejectedValue(new Error(errorMessage)); @@ -513,7 +560,9 @@ describe('HyperLiquidClientService', () => { candles: [], }; - mockInfoClient.candleSnapshot = jest.fn().mockResolvedValue(mockResponse); + mockInfoClientWs.candleSnapshot = jest + .fn() + .mockResolvedValue(mockResponse); // Act await service.fetchHistoricalCandles( @@ -523,7 +572,7 @@ describe('HyperLiquidClientService', () => { ); // Assert - expect(mockInfoClient.candleSnapshot).toHaveBeenCalledWith({ + expect(mockInfoClientWs.candleSnapshot).toHaveBeenCalledWith({ coin: 'ETH', interval: '5m', startTime: expect.any(Number), @@ -531,7 +580,7 @@ describe('HyperLiquidClientService', () => { }); // Verify time range calculation - const callArgs = mockInfoClient.candleSnapshot.mock.calls[0][0]; + const callArgs = mockInfoClientWs.candleSnapshot.mock.calls[0][0]; const timeDiff = callArgs.endTime - callArgs.startTime; const expectedTimeDiff = 50 * 5 * 60 * 1000; // 50 intervals * 5 minutes * 60 seconds * 1000ms expect(timeDiff).toBe(expectedTimeDiff); @@ -550,7 +599,7 @@ describe('HyperLiquidClientService', () => { // Reset mock before each iteration jest.clearAllMocks(); - mockInfoClient.candleSnapshot = jest + mockInfoClientWs.candleSnapshot = jest .fn() .mockResolvedValue(mockResponse); @@ -558,7 +607,7 @@ describe('HyperLiquidClientService', () => { await service.fetchHistoricalCandles('BTC', interval, 10); // Assert - const callArgs = mockInfoClient.candleSnapshot.mock.calls[0][0]; + const callArgs = mockInfoClientWs.candleSnapshot.mock.calls[0][0]; const timeDiff = callArgs.endTime - callArgs.startTime; expect(timeDiff).toBe(10 * expected); } @@ -571,7 +620,9 @@ describe('HyperLiquidClientService', () => { const mockResponse: any[] = []; - mockInfoClient.candleSnapshot = jest.fn().mockResolvedValue(mockResponse); + mockInfoClientWs.candleSnapshot = jest + .fn() + .mockResolvedValue(mockResponse); // Act await testnetService.fetchHistoricalCandles( @@ -581,7 +632,7 @@ describe('HyperLiquidClientService', () => { ); // Assert - expect(mockInfoClient.candleSnapshot).toHaveBeenCalled(); + expect(mockInfoClientWs.candleSnapshot).toHaveBeenCalled(); // The testnet configuration is handled in the service initialization }); @@ -658,7 +709,7 @@ describe('HyperLiquidClientService', () => { }, ]; - mockInfoClient.candleSnapshot = jest + mockInfoClientWs.candleSnapshot = jest .fn() .mockResolvedValue(mockHistoricalData); @@ -683,7 +734,7 @@ describe('HyperLiquidClientService', () => { await new Promise((resolve) => setTimeout(resolve, 100)); // Assert - should have fetched historical data - expect(mockInfoClient.candleSnapshot).toHaveBeenCalledWith( + expect(mockInfoClientWs.candleSnapshot).toHaveBeenCalledWith( expect.objectContaining({ coin: 'BTC', interval: '1h', @@ -728,7 +779,7 @@ describe('HyperLiquidClientService', () => { }, ]; - mockInfoClient.candleSnapshot = jest + mockInfoClientWs.candleSnapshot = jest .fn() .mockResolvedValue(mockHistoricalData); @@ -777,7 +828,7 @@ describe('HyperLiquidClientService', () => { }, ]; - mockInfoClient.candleSnapshot = jest + mockInfoClientWs.candleSnapshot = jest .fn() .mockResolvedValue(mockHistoricalData); @@ -845,7 +896,7 @@ describe('HyperLiquidClientService', () => { }, ]; - mockInfoClient.candleSnapshot = jest + mockInfoClientWs.candleSnapshot = jest .fn() .mockResolvedValue(mockHistoricalData); @@ -921,7 +972,7 @@ describe('HyperLiquidClientService', () => { }, ]; - mockInfoClient.candleSnapshot = jest + mockInfoClientWs.candleSnapshot = jest .fn() .mockResolvedValue(mockHistoricalData); @@ -964,7 +1015,7 @@ describe('HyperLiquidClientService', () => { it('should handle empty historical data', async () => { // Arrange - mockInfoClient.candleSnapshot = jest.fn().mockResolvedValue([]); + mockInfoClientWs.candleSnapshot = jest.fn().mockResolvedValue([]); (mockSubscriptionClient as any).candle = jest .fn() @@ -991,7 +1042,7 @@ describe('HyperLiquidClientService', () => { it('should invoke unsubscribe when cleanup function called', async () => { // Arrange - mockInfoClient.candleSnapshot = jest.fn().mockResolvedValue([]); + mockInfoClientWs.candleSnapshot = jest.fn().mockResolvedValue([]); const mockWsUnsubscribe = jest.fn(); (mockSubscriptionClient as any).candle = jest @@ -1026,7 +1077,9 @@ describe('HyperLiquidClientService', () => { resolveSnapshot = resolve; }); - mockInfoClient.candleSnapshot = jest.fn().mockReturnValue(delayedPromise); + mockInfoClientWs.candleSnapshot = jest + .fn() + .mockReturnValue(delayedPromise); const mockCandleSubscription = jest.fn(); (mockSubscriptionClient as any).candle = mockCandleSubscription; @@ -1057,7 +1110,7 @@ describe('HyperLiquidClientService', () => { it('should cleanup WebSocket when unsubscribed during subscription establishment', async () => { // Arrange - fast snapshot, slow WebSocket subscription - mockInfoClient.candleSnapshot = jest.fn().mockResolvedValue([]); + mockInfoClientWs.candleSnapshot = jest.fn().mockResolvedValue([]); let resolveWsSubscription: (value: any) => void = () => { /* noop */ diff --git a/app/components/UI/Perps/services/HyperLiquidClientService.ts b/app/components/UI/Perps/services/HyperLiquidClientService.ts index ce50b57ad527..41834e087476 100644 --- a/app/components/UI/Perps/services/HyperLiquidClientService.ts +++ b/app/components/UI/Perps/services/HyperLiquidClientService.ts @@ -40,7 +40,8 @@ export enum WebSocketConnectionState { */ export class HyperLiquidClientService { private exchangeClient?: ExchangeClient; - private infoClient?: InfoClient; + private infoClient?: InfoClient; // WebSocket transport (default) + private infoClientHttp?: InfoClient; // HTTP transport (fallback) private subscriptionClient?: SubscriptionClient; private wsTransport?: WebSocketTransport; private httpTransport?: HttpTransport; @@ -88,8 +89,11 @@ export class HyperLiquidClientService { transport: this.httpTransport, }); - // InfoClient uses HTTP transport for read operations (queries, metadata, etc.) - this.infoClient = new InfoClient({ transport: this.httpTransport }); + // InfoClient with WebSocket transport (default) - multiplexed requests over single connection + this.infoClient = new InfoClient({ transport: this.wsTransport }); + + // InfoClient with HTTP transport (fallback) - for specific calls if WebSocket has issues + this.infoClientHttp = new InfoClient({ transport: this.httpTransport }); // SubscriptionClient uses WebSocket transport for real-time pub/sub (price feeds, position updates) this.subscriptionClient = new SubscriptionClient({ @@ -102,7 +106,7 @@ export class HyperLiquidClientService { testnet: this.isTestnet, timestamp: new Date().toISOString(), connectionState: this.connectionState, - note: 'Using HTTP for InfoClient/ExchangeClient, WebSocket for SubscriptionClient', + note: 'Using WebSocket for InfoClient (default), HTTP fallback available', }); } catch (error) { const errorInstance = ensureError(error); @@ -192,6 +196,7 @@ export class HyperLiquidClientService { return !!( this.exchangeClient && this.infoClient && + this.infoClientHttp && this.subscriptionClient ); } @@ -245,9 +250,19 @@ export class HyperLiquidClientService { /** * Get the info client + * @param options.useHttp - Force HTTP transport instead of WebSocket (default: false) + * @returns InfoClient instance with the selected transport */ - public getInfoClient(): InfoClient { + public getInfoClient(options?: { useHttp?: boolean }): InfoClient { this.ensureInitialized(); + + if (options?.useHttp) { + if (!this.infoClientHttp) { + throw new Error(strings('perps.errors.infoClientNotAvailable')); + } + return this.infoClientHttp; + } + if (!this.infoClient) { throw new Error(strings('perps.errors.infoClientNotAvailable')); } @@ -612,6 +627,7 @@ export class HyperLiquidClientService { this.subscriptionClient = undefined; this.exchangeClient = undefined; this.infoClient = undefined; + this.infoClientHttp = undefined; this.wsTransport = undefined; this.httpTransport = undefined; diff --git a/app/components/Views/QRScanner/constants.ts b/app/components/Views/QRScanner/constants.ts index 9ed0ef07989a..16cbe1b96e79 100644 --- a/app/components/Views/QRScanner/constants.ts +++ b/app/components/Views/QRScanner/constants.ts @@ -48,6 +48,7 @@ export const ScanResult = { UNRECOGNIZED_QR_CODE: 'unrecognized_qr_code', INVALID_ADDRESS_FORMAT: 'invalid_address_format', URL_NAVIGATION_CANCELLED: 'url_navigation_cancelled', + ADDRESS_TYPE_NOT_SUPPORTED: 'address_type_not_supported', // System state outcomes WALLET_LOCKED: 'wallet_locked', @@ -63,4 +64,5 @@ export type ScanResultValue = | typeof ScanResult.UNRECOGNIZED_QR_CODE | typeof ScanResult.INVALID_ADDRESS_FORMAT | typeof ScanResult.URL_NAVIGATION_CANCELLED + | typeof ScanResult.ADDRESS_TYPE_NOT_SUPPORTED | typeof ScanResult.WALLET_LOCKED; diff --git a/app/components/Views/QRScanner/index.test.tsx b/app/components/Views/QRScanner/index.test.tsx index 0f52d1e1b566..8713e79fa560 100644 --- a/app/components/Views/QRScanner/index.test.tsx +++ b/app/components/Views/QRScanner/index.test.tsx @@ -5,7 +5,7 @@ import { useCameraDevice, useCodeScanner, } from 'react-native-vision-camera'; -import { Linking } from 'react-native'; +import { Linking, Alert } from 'react-native'; import renderWithProvider from '../../../util/test/renderWithProvider'; import QrScanner from './'; @@ -21,6 +21,8 @@ const mockCreateEventBuilder = jest.fn(); const mockBuild = jest.fn(); const mockAddProperties = jest.fn(); const mockLinkingOpenURL = jest.fn(); +const mockNavigateToSendPage = jest.fn(); +const mockDispatch = jest.fn(); jest.mock('@react-navigation/native', () => { const actualReactNavigation = jest.requireActual('@react-navigation/native'); @@ -30,6 +32,10 @@ jest.mock('@react-navigation/native', () => { navigate: mockNavigate, goBack: mockGoBack, }), + useFocusEffect: jest.fn(() => { + // No-op to avoid infinite loops during render + // Component refs are already initialized to true by default + }), }; }); @@ -100,6 +106,59 @@ jest.mock('react-native/Libraries/Linking/Linking', () => ({ getInitialURL: jest.fn().mockResolvedValue(null), })); +jest.mock('react-native/Libraries/Alert/Alert', () => ({ + alert: jest.fn(), +})); + +const { InteractionManager } = jest.requireActual('react-native'); + +InteractionManager.runAfterInteractions = jest.fn(async (callback) => + callback(), +); + +jest.mock('@solana/addresses', () => ({ + isAddress: jest.fn().mockReturnValue(false), +})); + +jest.mock('../../../core/Multichain/utils', () => ({ + isTronAddress: jest.fn().mockReturnValue(false), + isBtcMainnetAddress: jest.fn().mockReturnValue(false), +})); + +jest.mock('../confirmations/hooks/useSendNavigation', () => ({ + useSendNavigation: jest.fn(() => ({ + navigateToSendPage: mockNavigateToSendPage, + })), +})); + +const mockDerivePredefinedRecipientParams = jest.fn(); +jest.mock('../confirmations/utils/address', () => ({ + derivePredefinedRecipientParams: (address: string) => + mockDerivePredefinedRecipientParams(address), +})); + +jest.mock('../../../actions/transaction', () => ({ + newAssetTransaction: jest.fn((asset) => ({ + type: 'NEW_ASSET_TRANSACTION', + payload: asset, + })), +})); + +jest.mock('../../../util/transactions', () => ({ + getEther: jest.fn((currency) => ({ + type: 'ETHER', + currency, + })), +})); + +const mockUseSelector = jest.fn(); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, + useSelector: (selector: (state: unknown) => unknown) => + mockUseSelector(selector), +})); + const mockUseCameraDevice = useCameraDevice as jest.MockedFunction< typeof useCameraDevice >; @@ -110,6 +169,9 @@ const mockUseCodeScanner = useCodeScanner as jest.MockedFunction< typeof useCodeScanner >; +// Cast Alert.alert as a mock for better TypeScript support +const mockAlert = Alert.alert as jest.MockedFunction; + const initialState = { engine: { backgroundState, @@ -131,6 +193,25 @@ describe('QrScanner', () => { mockGoBack.mockClear(); mockLinkingOpenURL.mockClear(); + // Setup useSelector mock + mockUseSelector.mockImplementation( + (selector: (state: unknown) => unknown) => { + const selectorString = selector?.toString() || ''; + if (selectorString.includes('selectChainId')) { + return '0x1'; + } + if (selectorString.includes('selectNativeCurrencyByChainId')) { + return 'ETH'; + } + // For other selectors, try to call with empty state + try { + return selector({}); + } catch { + return undefined; + } + }, + ); + // Setup Linking mock (Linking.openURL as jest.Mock) = mockLinkingOpenURL.mockResolvedValue(true); @@ -194,7 +275,7 @@ describe('QrScanner', () => { expect(toJSON()).toMatchSnapshot(); }); - it('should request permission when hasPermission is false', async () => { + it('requests permission when hasPermission is false', async () => { const mockRequestPermission = jest.fn().mockResolvedValue('granted'); mockUseCameraPermission.mockReturnValue({ hasPermission: false, @@ -210,7 +291,7 @@ describe('QrScanner', () => { }); }); - it('should not request permission when hasPermission is true', async () => { + it('does not request permission when hasPermission is true', async () => { const mockRequestPermission = jest.fn(); mockUseCameraPermission.mockReturnValue({ hasPermission: true, @@ -226,7 +307,7 @@ describe('QrScanner', () => { }); }); - it('should call onScanError when camera error occurs', () => { + it('calls onScanError when camera error occurs', () => { const mockOnScanError = jest.fn(); mockUseCameraPermission.mockReturnValue({ hasPermission: true, @@ -241,7 +322,7 @@ describe('QrScanner', () => { expect(mockOnScanError).toBeDefined(); }); - it('should render camera not available message when no camera device', () => { + it('renders camera not available message when no camera device', () => { mockUseCameraDevice.mockReturnValue(undefined); mockUseCameraPermission.mockReturnValue({ hasPermission: true, @@ -737,4 +818,615 @@ describe('QrScanner', () => { }); }); }); + + describe('QR Code Scanning - Address Handling with Send Flow', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockNavigateToSendPage.mockClear(); + mockDispatch.mockClear(); + mockNavigate.mockClear(); + mockGoBack.mockClear(); + + // Setup metrics mocks (same as global beforeEach) + mockBuild.mockReturnValue({ event: 'mock-event' }); + mockAddProperties.mockReturnValue({ build: mockBuild }); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, + }); + mockUseMetrics.mockReturnValue({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + isEnabled: jest.fn().mockReturnValue(true), + enable: jest.fn(), + addTraitsToUser: jest.fn(), + createDataDeletionTask: jest.fn(), + checkDataDeleteStatus: jest.fn(), + getMetaMetricsId: jest.fn(), + isDataRecorded: jest.fn().mockReturnValue(true), + getDeleteRegulationId: jest.fn(), + getDeleteRegulationCreationDate: jest.fn(), + } as ReturnType); + + mockUseCameraDevice.mockReturnValue({ + id: 'back', + position: 'back', + name: 'Back Camera', + hasFlash: false, + } as unknown as ReturnType); + + mockUseCameraPermission.mockReturnValue({ + hasPermission: true, + requestPermission: jest.fn(), + }); + + mockUseCodeScanner.mockImplementation((config) => { + if (config?.onCodeScanned) { + onCodeScannedCallback = config.onCodeScanned as ( + codes: { value: string }[], + ) => void; + } + return { + codeTypes: ['qr'], + onCodeScanned: config?.onCodeScanned || jest.fn(), + }; + }); + + // Reset address validation mocks to defaults + const solanaModule = jest.requireMock('@solana/addresses'); + (solanaModule.isAddress as jest.Mock).mockReturnValue(false); + + const multichainModule = jest.requireMock( + '../../../core/Multichain/utils', + ); + (multichainModule.isTronAddress as jest.Mock).mockReturnValue(false); + (multichainModule.isBtcMainnetAddress as jest.Mock).mockReturnValue( + false, + ); + + const ethereumjsUtilModule = jest.requireMock('ethereumjs-util'); + (ethereumjsUtilModule.isValidAddress as jest.Mock).mockReturnValue(true); + + // Default: return EVM for 0x addresses, undefined for everything else + mockDerivePredefinedRecipientParams.mockImplementation( + (address: string) => { + if (address?.startsWith('0x') && address.length === 42) { + return { address, chainType: 'evm' }; + } + return undefined; + }, + ); + }); + + describe('Ethereum Address Scanning', () => { + it('handles scanning Ethereum address with 0x prefix', async () => { + const ethereumAddress = '0x1234567890123456789012345678901234567890'; + + const mockOnScanSuccess = jest.fn(); + renderWithProvider(, { + state: initialState, + }); + + await waitFor(() => { + expect(onCodeScannedCallback).toBeDefined(); + }); + + await act(async () => { + onCodeScannedCallback?.([{ value: ethereumAddress }]); + }); + + expect(mockGoBack).toHaveBeenCalled(); + + // Wait for navigateToSendPage (happens in InteractionManager callback) + await waitFor(() => { + expect(mockNavigateToSendPage).toHaveBeenCalledWith({ + location: 'qr_scanner', + predefinedRecipient: { + address: ethereumAddress, + chainType: 'evm', + }, + }); + }); + }); + + it('handles scanning ethereum: URL with address', async () => { + const ethereumAddress = '0x1234567890123456789012345678901234567890'; + const ethereumUrl = `ethereum:${ethereumAddress}`; + + const ethUrlParserModule = jest.requireMock('eth-url-parser'); + (ethUrlParserModule.parse as jest.Mock).mockReturnValue({ + target_address: ethereumAddress, + chain_id: '1', + }); + + renderWithProvider(, { + state: initialState, + }); + + await waitFor(() => { + expect(onCodeScannedCallback).toBeDefined(); + }); + + await act(async () => { + onCodeScannedCallback?.([{ value: ethereumUrl }]); + }); + + expect(mockGoBack).toHaveBeenCalled(); + + await waitFor(() => { + expect(mockNavigateToSendPage).toHaveBeenCalledWith({ + location: 'qr_scanner', + predefinedRecipient: { + address: ethereumAddress, + chainType: 'evm', + }, + }); + }); + }); + + it('navigates to send flow without initializing transaction', async () => { + const ethereumAddress = '0x1234567890123456789012345678901234567890'; + + renderWithProvider(, { + state: initialState, + }); + + await waitFor(() => { + expect(onCodeScannedCallback).toBeDefined(); + }); + + await act(async () => { + onCodeScannedCallback?.([{ value: ethereumAddress }]); + }); + + // Verify navigation happens but NO transaction initialization + // Transaction will be initialized after user selects asset in send flow + await waitFor(() => { + expect(mockNavigateToSendPage).toHaveBeenCalledWith({ + location: 'qr_scanner', + predefinedRecipient: { + address: ethereumAddress, + chainType: 'evm', + }, + }); + }); + }); + }); + + describe('Callback-based Origins (SendTo, ContactForm)', () => { + beforeEach(() => { + // Reset isValidAddressInputViaQRCode to return true (may be set to false by previous tests) + const addressUtilsModule = jest.requireMock('../../../util/address'); + ( + addressUtilsModule.isValidAddressInputViaQRCode as jest.Mock + ).mockReturnValue(true); + }); + + it('calls onScanSuccess with target_address when origin is SEND_TO', async () => { + const ethereumAddress = '0x1234567890123456789012345678901234567890'; + const mockOnScanSuccess = jest.fn(); + + renderWithProvider( + , + { state: initialState }, + ); + + await waitFor(() => { + expect(onCodeScannedCallback).toBeDefined(); + }); + + await act(async () => { + onCodeScannedCallback?.([{ value: ethereumAddress }]); + }); + + expect(mockGoBack).toHaveBeenCalled(); + + await waitFor(() => { + expect(mockOnScanSuccess).toHaveBeenCalledWith( + { target_address: ethereumAddress }, + ethereumAddress, + ); + }); + + // Does NOT navigate to send page - uses callback instead + expect(mockNavigateToSendPage).not.toHaveBeenCalled(); + }); + + it('calls onScanSuccess with target_address when origin is CONTACT_FORM', async () => { + const ethereumAddress = '0x1234567890123456789012345678901234567890'; + const mockOnScanSuccess = jest.fn(); + + renderWithProvider( + , + { state: initialState }, + ); + + await waitFor(() => { + expect(onCodeScannedCallback).toBeDefined(); + }); + + await act(async () => { + onCodeScannedCallback?.([{ value: ethereumAddress }]); + }); + + expect(mockGoBack).toHaveBeenCalled(); + + await waitFor(() => { + expect(mockOnScanSuccess).toHaveBeenCalledWith( + { target_address: ethereumAddress }, + ethereumAddress, + ); + }); + + // Does NOT navigate to send page - uses callback instead + expect(mockNavigateToSendPage).not.toHaveBeenCalled(); + }); + + it('extracts target_address from ethereum: URL when origin is SEND_TO', async () => { + const ethereumAddress = '0x1234567890123456789012345678901234567890'; + const ethereumUrl = `ethereum:${ethereumAddress}`; + const mockOnScanSuccess = jest.fn(); + + const ethUrlParserModule = jest.requireMock('eth-url-parser'); + (ethUrlParserModule.parse as jest.Mock).mockReturnValue({ + target_address: ethereumAddress, + chain_id: '1', + }); + + renderWithProvider( + , + { state: initialState }, + ); + + await waitFor(() => { + expect(onCodeScannedCallback).toBeDefined(); + }); + + await act(async () => { + onCodeScannedCallback?.([{ value: ethereumUrl }]); + }); + + expect(mockGoBack).toHaveBeenCalled(); + + await waitFor(() => { + expect(mockOnScanSuccess).toHaveBeenCalledWith( + { target_address: ethereumAddress }, + ethereumUrl, + ); + }); + + expect(mockNavigateToSendPage).not.toHaveBeenCalled(); + }); + + it('tracks QR_SCANNED metrics with COMPLETED result for callback-based origins', async () => { + const ethereumAddress = '0x1234567890123456789012345678901234567890'; + + renderWithProvider( + , + { state: initialState }, + ); + + await waitFor(() => { + expect(onCodeScannedCallback).toBeDefined(); + }); + + await act(async () => { + onCodeScannedCallback?.([{ value: ethereumAddress }]); + }); + + await waitFor(() => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.QR_SCANNED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + [QRScannerEventProperties.SCAN_SUCCESS]: true, + [QRScannerEventProperties.QR_TYPE]: QRType.SEND_FLOW, + [QRScannerEventProperties.SCAN_RESULT]: ScanResult.COMPLETED, + }); + }); + }); + }); + + ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) + describe('Solana Address Scanning', () => { + beforeEach(() => { + const solanaModule = jest.requireMock('@solana/addresses'); + (solanaModule.isAddress as jest.Mock).mockReturnValue(true); + + mockDerivePredefinedRecipientParams.mockImplementation( + (address: string) => ({ address, chainType: 'solana' }), + ); + }); + + it('navigates to send flow with Solana recipient when Solana address scanned', async () => { + const solanaAddress = 'B43FvNLyahfDqEZD7erAnr5bXZsw58nmEKiaiAoJmXEr'; + + renderWithProvider(, { + state: initialState, + }); + + await waitFor(() => { + expect(onCodeScannedCallback).toBeDefined(); + }); + + await act(async () => { + onCodeScannedCallback?.([{ value: solanaAddress }]); + }); + + expect(mockGoBack).toHaveBeenCalled(); + + await waitFor(() => { + expect(mockNavigateToSendPage).toHaveBeenCalledWith({ + location: 'qr_scanner', + predefinedRecipient: { + address: solanaAddress, + chainType: 'solana', + }, + }); + }); + }); + + it('navigates to send flow for Solana without initializing EVM transaction', async () => { + const solanaAddress = 'B43FvNLyahfDqEZD7erAnr5bXZsw58nmEKiaiAoJmXEr'; + + renderWithProvider(, { + state: initialState, + }); + + await waitFor(() => { + expect(onCodeScannedCallback).toBeDefined(); + }); + + await act(async () => { + onCodeScannedCallback?.([{ value: solanaAddress }]); + }); + + expect(mockGoBack).toHaveBeenCalled(); + + // Verify navigation happens but NO EVM transaction initialization + // Solana transactions are handled by the send flow, not here + await waitFor(() => { + expect(mockNavigateToSendPage).toHaveBeenCalledWith({ + location: 'qr_scanner', + predefinedRecipient: { + address: solanaAddress, + chainType: 'solana', + }, + }); + }); + }); + + it('tracks QR_SCANNED metrics for Solana address', async () => { + const solanaAddress = 'B43FvNLyahfDqEZD7erAnr5bXZsw58nmEKiaiAoJmXEr'; + + renderWithProvider(, { + state: initialState, + }); + + await waitFor(() => { + expect(onCodeScannedCallback).toBeDefined(); + }); + + await act(async () => { + onCodeScannedCallback?.([{ value: solanaAddress }]); + }); + + await waitFor(() => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.QR_SCANNED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + [QRScannerEventProperties.SCAN_SUCCESS]: true, + [QRScannerEventProperties.QR_TYPE]: QRType.SEND_FLOW, + [QRScannerEventProperties.SCAN_RESULT]: ScanResult.COMPLETED, + }); + }); + }); + }); + + describe('Bitcoin Address Scanning', () => { + beforeEach(() => { + const multichainModule = jest.requireMock( + '../../../core/Multichain/utils', + ); + (multichainModule.isBtcMainnetAddress as jest.Mock).mockReturnValue( + true, + ); + (multichainModule.isTronAddress as jest.Mock).mockReturnValue(false); + + mockDerivePredefinedRecipientParams.mockImplementation( + (address: string) => ({ address, chainType: 'bitcoin' }), + ); + }); + + it('navigates to send flow with Bitcoin recipient when Bitcoin address scanned', async () => { + const bitcoinAddress = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'; + + renderWithProvider(, { + state: initialState, + }); + + await waitFor(() => { + expect(onCodeScannedCallback).toBeDefined(); + }); + + await act(async () => { + onCodeScannedCallback?.([{ value: bitcoinAddress }]); + }); + + expect(mockGoBack).toHaveBeenCalled(); + + await waitFor(() => { + expect(mockNavigateToSendPage).toHaveBeenCalledWith({ + location: 'qr_scanner', + predefinedRecipient: { + address: bitcoinAddress, + chainType: 'bitcoin', + }, + }); + }); + }); + + it('does not call EVM transaction methods for Bitcoin address', async () => { + const { getEther } = jest.requireMock('../../../util/transactions'); + const { newAssetTransaction } = jest.requireMock( + '../../../actions/transaction', + ); + + const bitcoinAddress = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'; + + renderWithProvider(, { + state: initialState, + }); + + await waitFor(() => { + expect(onCodeScannedCallback).toBeDefined(); + }); + + await act(async () => { + onCodeScannedCallback?.([{ value: bitcoinAddress }]); + }); + + expect(mockGoBack).toHaveBeenCalled(); + await waitFor(() => { + expect(mockNavigateToSendPage).toHaveBeenCalled(); + }); + + expect(getEther).not.toHaveBeenCalled(); + expect(newAssetTransaction).not.toHaveBeenCalled(); + }); + }); + ///: END:ONLY_INCLUDE_IF + + describe('Tron Address Scanning', () => { + beforeEach(() => { + const multichainModule = jest.requireMock( + '../../../core/Multichain/utils', + ); + (multichainModule.isTronAddress as jest.Mock).mockReturnValue(true); + (multichainModule.isBtcMainnetAddress as jest.Mock).mockReturnValue( + false, + ); + + mockDerivePredefinedRecipientParams.mockImplementation( + (address: string) => ({ address, chainType: 'tron' }), + ); + }); + + it('shows error alert when Tron address scanned (temporarily disabled)', async () => { + const tronAddress = 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9'; + + renderWithProvider(, { + state: initialState, + }); + + await waitFor(() => { + expect(onCodeScannedCallback).toBeDefined(); + }); + + await act(async () => { + onCodeScannedCallback?.([{ value: tronAddress }]); + }); + + expect(mockGoBack).toHaveBeenCalled(); + + await waitFor(() => { + expect(mockAlert).toHaveBeenCalledWith( + 'Error', + 'Tron addresses are not currently supported', + ); + }); + + // Does NOT navigate to send flow + expect(mockNavigateToSendPage).not.toHaveBeenCalled(); + }); + + it('does not call EVM transaction methods for Tron address', async () => { + const tronAddress = 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9'; + + renderWithProvider(, { + state: initialState, + }); + + await waitFor(() => { + expect(onCodeScannedCallback).toBeDefined(); + }); + + await act(async () => { + onCodeScannedCallback?.([{ value: tronAddress }]); + }); + + expect(mockGoBack).toHaveBeenCalled(); + + // Does NOT navigate to send flow or call EVM methods + expect(mockNavigateToSendPage).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('tracks QR_SCANNED metrics with failure for Tron address (temporarily disabled)', async () => { + const tronAddress = 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9'; + + renderWithProvider(, { + state: initialState, + }); + + await waitFor(() => { + expect(onCodeScannedCallback).toBeDefined(); + }); + + await act(async () => { + onCodeScannedCallback?.([{ value: tronAddress }]); + }); + + await waitFor(() => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.QR_SCANNED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + [QRScannerEventProperties.SCAN_SUCCESS]: false, + [QRScannerEventProperties.QR_TYPE]: QRType.SEND_FLOW, + [QRScannerEventProperties.SCAN_RESULT]: + ScanResult.ADDRESS_TYPE_NOT_SUPPORTED, + }); + }); + }); + }); + + describe('Camera State Management', () => { + it('sets isCameraActive to false when scanning address', async () => { + const ethereumAddress = '0x1234567890123456789012345678901234567890'; + + renderWithProvider(, { + state: initialState, + }); + + await waitFor(() => { + expect(onCodeScannedCallback).toBeDefined(); + }); + + await act(async () => { + onCodeScannedCallback?.([{ value: ethereumAddress }]); + }); + + await waitFor(() => { + expect(mockGoBack).toHaveBeenCalled(); + }); + + // Camera should be deactivated to prevent multiple scans + // This is tested indirectly through the shouldReadBarCodeRef behavior + }); + }); + }); }); diff --git a/app/components/Views/QRScanner/index.tsx b/app/components/Views/QRScanner/index.tsx index b877ba0a93ea..3cf13991bfb7 100644 --- a/app/components/Views/QRScanner/index.tsx +++ b/app/components/Views/QRScanner/index.tsx @@ -2,9 +2,8 @@ /* eslint @typescript-eslint/no-require-imports: "off" */ 'use strict'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { parse } from 'eth-url-parser'; -import { isValidAddress } from 'ethereumjs-util'; import React, { useCallback, useRef, useEffect, useState } from 'react'; import { Alert, Image, InteractionManager, View, Linking } from 'react-native'; import Text, { @@ -17,7 +16,6 @@ import { useCodeScanner, Code, } from 'react-native-vision-camera'; -import { useSelector } from 'react-redux'; import { strings } from '../../../../locales/i18n'; import { PROTOCOLS } from '../../../constants/deeplinks'; import Routes from '../../../constants/navigation/Routes'; @@ -29,8 +27,10 @@ import AppConstants from '../../../core/AppConstants'; import SharedDeeplinkManager from '../../../core/DeeplinkManager/SharedDeeplinkManager'; import Engine from '../../../core/Engine'; import type { EngineContext } from '../../../core/Engine/types'; -import { selectChainId } from '../../../selectors/networkController'; +import { useSendNavigation } from '../confirmations/hooks/useSendNavigation'; +import { InitSendLocation } from '../confirmations/constants/send'; import { isValidAddressInputViaQRCode } from '../../../util/address'; +import { derivePredefinedRecipientParams } from '../confirmations/utils/address'; import { getURLProtocol } from '../../../util/general'; import { failedSeedPhraseRequirements, @@ -40,6 +40,7 @@ import createStyles from './styles'; import { useTheme } from '../../../util/theme'; import { ScanSuccess, StartScan } from '../QRTabSwitcher'; import SDKConnectV2 from '../../../core/SDKConnectV2'; +import { ChainType } from '../confirmations/utils/send'; import useMetrics from '../../../components/hooks/useMetrics/useMetrics'; import { MetaMetricsEvents } from '../../../core/Analytics/MetaMetrics.events'; import { QRType, QRScannerEventProperties, ScanResult } from './constants'; @@ -67,11 +68,13 @@ const QRScanner = ({ const shouldReadBarCodeRef = useRef(true); const [permissionCheckCompleted, setPermissionCheckCompleted] = useState(false); + const [isCameraActive, setIsCameraActive] = useState(true); const cameraDevice = useCameraDevice('back'); const { hasPermission, requestPermission } = useCameraPermission(); - const currentChainId = useSelector(selectChainId); + const { navigateToSendPage } = useSendNavigation(); + const theme = useTheme(); const styles = createStyles(theme); const { trackEvent, createEventBuilder } = useMetrics(); @@ -115,6 +118,21 @@ const QRScanner = ({ createEventBuilder, ]); + // Reset camera state when screen is focused (e.g., when navigating back from send screen) + useFocusEffect( + useCallback(() => { + mountedRef.current = true; + shouldReadBarCodeRef.current = true; + setIsCameraActive(true); + + return () => { + mountedRef.current = false; + shouldReadBarCodeRef.current = false; + setIsCameraActive(false); + }; + }, []), + ); + const end = useCallback(() => { mountedRef.current = false; navigation.goBack(); @@ -161,14 +179,19 @@ const QRScanner = ({ // Early exit if no codes detected if (!codes.length) return; - const response = { data: codes[0].value }; - let content = response.data; /** * Barcode read triggers multiple times * shouldReadBarCodeRef controls how often the logic below runs * Think of this as a allow or disallow bar code reading */ - if (!shouldReadBarCodeRef.current || !mountedRef.current || !content) { + if (!shouldReadBarCodeRef.current || !mountedRef.current) { + return; + } + + const response = { data: codes[0].value }; + let content = response.data; + + if (!content) { return; } @@ -192,11 +215,12 @@ const QRScanner = ({ return; } } + if (SDKConnectV2.isConnectDeeplink(response.data)) { // SDKConnectV2 handles the connection entirely internally (establishes WebSocket, etc.) // and bypasses the standard deeplink saga flow. We don't call onScanSuccess here because // parent components don't need to be notified. - // See: app/core/DeeplinkManager/handleDeeplink.ts for details. + // See: app/core/DeeplinkManager/Handlers/handleDeeplink.ts for details. shouldReadBarCodeRef.current = false; trackEvent( createEventBuilder(MetaMetricsEvents.QR_SCANNED) @@ -208,6 +232,7 @@ const QRScanner = ({ }) .build(), ); + SDKConnectV2.handleConnectDeeplink(response.data); end(); return; @@ -223,7 +248,6 @@ const QRScanner = ({ !isWalletConnect && !isSDK ) { - // Convert dapp:// protocol to https:// if (contentProtocol === PROTOCOLS.DAPP) { content = content.replace(PROTOCOLS.DAPP, PROTOCOLS.HTTPS); } @@ -317,7 +341,6 @@ const QRScanner = ({ onScanSuccess(data, content); return; } - // Check if wallet is unlocked before processing other scan types const { KeyringController } = Engine.context as EngineContext; const isUnlocked = KeyringController.isUnlocked(); @@ -343,32 +366,160 @@ const QRScanner = ({ return; } - if ( - (content.split(`${PROTOCOLS.ETHEREUM}:`).length > 1 && - !parse(content).function_name) || - (content.startsWith('0x') && isValidAddress(content)) - ) { - const handledContent = content.startsWith('0x') - ? `${PROTOCOLS.ETHEREUM}:${content}@${currentChainId}` - : content; + let addressToValidate = content; + const hasEthereumProtocol = + content.split(`${PROTOCOLS.ETHEREUM}:`).length > 1; + + let isEthereumUrl = false; + if (hasEthereumProtocol) { + try { + const parsed = parse(content); + if (!parsed.function_name) { + isEthereumUrl = true; + addressToValidate = parsed.target_address; + } + } catch { + isEthereumUrl = false; + } + } + + const predefinedRecipient = + derivePredefinedRecipientParams(addressToValidate); + + if (predefinedRecipient || isEthereumUrl) { shouldReadBarCodeRef.current = false; - data = parse(handledContent); - const action = 'send-eth'; - data = { ...data, action }; + setIsCameraActive(false); + + // Handle Tron special case - temporarily disabled + if (predefinedRecipient?.chainType === ChainType.TRON) { + trackEvent( + createEventBuilder(MetaMetricsEvents.QR_SCANNED) + .addProperties({ + [QRScannerEventProperties.SCAN_SUCCESS]: false, + [QRScannerEventProperties.QR_TYPE]: QRType.SEND_FLOW, + [QRScannerEventProperties.SCAN_RESULT]: + ScanResult.ADDRESS_TYPE_NOT_SUPPORTED, + }) + .build(), + ); + end(); + Alert.alert( + strings('qr_scanner.error'), + strings('qr_scanner.tron_address_not_supported'), + ); + return; + } + + // Handle callback-based origins (ContactForm, SendTo) + // These origins expect onScanSuccess() with target_address instead of navigation + if ( + origin === Routes.SEND_FLOW.SEND_TO || + origin === Routes.SETTINGS.CONTACT_FORM + ) { + trackEvent( + createEventBuilder(MetaMetricsEvents.QR_SCANNED) + .addProperties({ + [QRScannerEventProperties.SCAN_SUCCESS]: true, + [QRScannerEventProperties.QR_TYPE]: QRType.SEND_FLOW, + [QRScannerEventProperties.SCAN_RESULT]: ScanResult.COMPLETED, + }) + .build(), + ); + end(); + onScanSuccess({ target_address: addressToValidate }, content); + return; + } + + ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) + // Handle non-EVM addresses when keyring-snaps is enabled (Solana, Bitcoin) + if ( + predefinedRecipient && + predefinedRecipient.chainType !== ChainType.EVM + ) { + trackEvent( + createEventBuilder(MetaMetricsEvents.QR_SCANNED) + .addProperties({ + [QRScannerEventProperties.SCAN_SUCCESS]: true, + [QRScannerEventProperties.QR_TYPE]: QRType.SEND_FLOW, + [QRScannerEventProperties.SCAN_RESULT]: ScanResult.COMPLETED, + }) + .build(), + ); + end(); + InteractionManager.runAfterInteractions(() => { + navigateToSendPage({ + location: InitSendLocation.QRScanner, + predefinedRecipient, + }); + }); + return; + } + ///: END:ONLY_INCLUDE_IF + + // If non-EVM and keyring-snaps is disabled, show error + if ( + predefinedRecipient && + predefinedRecipient.chainType !== ChainType.EVM + ) { + trackEvent( + createEventBuilder(MetaMetricsEvents.QR_SCANNED) + .addProperties({ + [QRScannerEventProperties.SCAN_SUCCESS]: false, + [QRScannerEventProperties.QR_TYPE]: QRType.SEND_FLOW, + [QRScannerEventProperties.SCAN_RESULT]: + ScanResult.ADDRESS_TYPE_NOT_SUPPORTED, + }) + .build(), + ); + showAlertForInvalidAddress(); + end(); + return; + } + + // Handle EVM addresses + if ( + predefinedRecipient && + predefinedRecipient.chainType === ChainType.EVM + ) { + trackEvent( + createEventBuilder(MetaMetricsEvents.QR_SCANNED) + .addProperties({ + [QRScannerEventProperties.SCAN_SUCCESS]: true, + [QRScannerEventProperties.QR_TYPE]: QRType.SEND_FLOW, + [QRScannerEventProperties.SCAN_RESULT]: ScanResult.COMPLETED, + }) + .build(), + ); + + end(); + + InteractionManager.runAfterInteractions(() => { + navigateToSendPage({ + location: InitSendLocation.QRScanner, + predefinedRecipient, + }); + }); + + return; + } + + // Fallback for unknown chain types trackEvent( createEventBuilder(MetaMetricsEvents.QR_SCANNED) .addProperties({ - [QRScannerEventProperties.SCAN_SUCCESS]: true, + [QRScannerEventProperties.SCAN_SUCCESS]: false, [QRScannerEventProperties.QR_TYPE]: QRType.SEND_FLOW, - [QRScannerEventProperties.SCAN_RESULT]: ScanResult.COMPLETED, + [QRScannerEventProperties.SCAN_RESULT]: + ScanResult.ADDRESS_TYPE_NOT_SUPPORTED, }) .build(), ); + showAlertForInvalidAddress(); end(); - onScanSuccess(data, handledContent); return; } + // Checking if it can be handled like deeplinks const handledByDeeplink = await SharedDeeplinkManager.parse(content, { origin: AppConstants.DEEPLINKS.ORIGIN_QR_CODE, onHandled: () => { @@ -394,6 +545,7 @@ const QRScanner = ({ return; } + // I can't be handled by deeplinks, checking other options if ( content.length === 64 || (content.substring(0, 2).toLowerCase() === '0x' && @@ -437,6 +589,7 @@ const QRScanner = ({ .build(), ); } else { + // EIP-945 allows scanning arbitrary data data = content; const qrType = getQRType(content, origin, data as ScanSuccess); trackEvent( @@ -462,7 +615,7 @@ const QRScanner = ({ navigation, onStartScan, onScanSuccess, - currentChainId, + navigateToSendPage, trackEvent, createEventBuilder, ], @@ -530,7 +683,7 @@ const QRScanner = ({ { }); expect(mockNavigate.mock.calls[0][0]).toEqual('Send'); }); + + describe('with predefinedRecipient', () => { + it('navigates to Asset screen when predefinedRecipient is provided without asset', () => { + const mockNavigate = jest.fn(); + const predefinedRecipient = { + address: '0x97A5b8a38f376B8a0C3C16e0A927b5b02dEf0576', + chainType: ChainType.EVM, + }; + + handleSendPageNavigation(mockNavigate, { + location: InitSendLocation.QRScanner, + isSendRedesignEnabled: true, + asset: undefined, + predefinedRecipient, + }); + + expect(mockNavigate).toHaveBeenCalledWith('Send', { + screen: 'Asset', + params: { + asset: undefined, + location: InitSendLocation.QRScanner, + predefinedRecipient, + }, + }); + }); + + it('navigates to Amount screen when both asset and predefinedRecipient provided', () => { + const mockNavigate = jest.fn(); + const predefinedRecipient = { + address: '0x97A5b8a38f376B8a0C3C16e0A927b5b02dEf0576', + chainType: ChainType.EVM, + }; + const asset = { name: 'ETHEREUM' } as AssetType; + + handleSendPageNavigation(mockNavigate, { + location: InitSendLocation.QRScanner, + isSendRedesignEnabled: true, + asset, + predefinedRecipient, + }); + + expect(mockNavigate).toHaveBeenCalledWith('Send', { + screen: 'Amount', + params: { + asset, + location: InitSendLocation.QRScanner, + predefinedRecipient, + }, + }); + }); + + it('navigates to Recipient screen for ERC721 NFTs with predefinedRecipient', () => { + const mockNavigate = jest.fn(); + const predefinedRecipient = { + address: '0x97A5b8a38f376B8a0C3C16e0A927b5b02dEf0576', + chainType: ChainType.EVM, + }; + const nft = { + name: 'MyNFT', + standard: TokenStandard.ERC721, + } as AssetType; + + handleSendPageNavigation(mockNavigate, { + location: InitSendLocation.QRScanner, + isSendRedesignEnabled: true, + asset: nft, + predefinedRecipient, + }); + + expect(mockNavigate).toHaveBeenCalledWith('Send', { + screen: 'Recipient', + params: { + asset: nft, + location: InitSendLocation.QRScanner, + predefinedRecipient, + }, + }); + }); + + it('navigates to SendFlowView when send redesign is disabled', () => { + const mockNavigate = jest.fn(); + const predefinedRecipient = { + address: '0x97A5b8a38f376B8a0C3C16e0A927b5b02dEf0576', + chainType: ChainType.EVM, + }; + + handleSendPageNavigation(mockNavigate, { + location: InitSendLocation.QRScanner, + isSendRedesignEnabled: false, + asset: undefined, + predefinedRecipient, + }); + + // Legacy flow doesn't use params, just navigates to SendFlowView + expect(mockNavigate).toHaveBeenCalledWith('SendFlowView'); + }); + + it('handles empty address in predefinedRecipient', () => { + const mockNavigate = jest.fn(); + + handleSendPageNavigation(mockNavigate, { + location: InitSendLocation.QRScanner, + isSendRedesignEnabled: true, + asset: undefined, + predefinedRecipient: { + address: '', + chainType: ChainType.EVM, + }, + }); + + expect(mockNavigate).toHaveBeenCalledWith('Send', { + screen: 'Asset', + params: { + asset: undefined, + location: InitSendLocation.QRScanner, + predefinedRecipient: { + address: '', + chainType: ChainType.EVM, + }, + }, + }); + }); + }); }); describe('prepareEVMTransaction', () => { diff --git a/app/core/Encryptor/lib/quick-crypto.ts b/app/core/Encryptor/lib/quick-crypto.ts index 546fd09cb11a..a9bfad6a9201 100644 --- a/app/core/Encryptor/lib/quick-crypto.ts +++ b/app/core/Encryptor/lib/quick-crypto.ts @@ -45,7 +45,7 @@ class QuickCryptoEncryptionLibrary implements EncryptionLibrary { salt: string, opts: KeyDerivationOptions, ): Promise => { - const passBuffer = Buffer.from(password, 'utf-8'); + const passBuffer = new TextEncoder().encode(password); const baseKey = await Crypto.subtle.importKey( 'raw', @@ -77,7 +77,7 @@ class QuickCryptoEncryptionLibrary implements EncryptionLibrary { * @returns A promise that resolves to the encrypted data as a base64 string. */ encrypt = async (data: string, key: string, iv: string): Promise => { - const dataBuffer = Buffer.from(data, 'utf-8'); + const dataBuffer = new TextEncoder().encode(data); const ivBuffer = Buffer.from(iv, 'hex'); const cryptoKey = await this.importKey(key); diff --git a/app/selectors/accountsController.ts b/app/selectors/accountsController.ts index 995459cad1e1..72b23f885d26 100644 --- a/app/selectors/accountsController.ts +++ b/app/selectors/accountsController.ts @@ -54,20 +54,29 @@ export const selectInternalAccounts = createDeepEqualSelector( selectAccountsControllerState, selectFlattenedKeyringAccounts, (accountControllerState, orderedKeyringAccounts): InternalAccount[] => { - const keyringAccountsMap = new Map( - orderedKeyringAccounts.map((account, index) => [ - toFormattedAddress(account), - index, - ]), - ); - const sortedAccounts = Object.values( + // Build index map from formatted keyring addresses: O(n) calls to toFormattedAddress + const keyringIndexMap = new Map(); + for (let i = 0; i < orderedKeyringAccounts.length; i++) { + keyringIndexMap.set(toFormattedAddress(orderedKeyringAccounts[i]), i); + } + + const accounts = Object.values( accountControllerState.internalAccounts.accounts, - ).sort( - (a, b) => - (keyringAccountsMap.get(toFormattedAddress(a.address)) || 0) - - (keyringAccountsMap.get(toFormattedAddress(b.address)) || 0), ); - return sortedAccounts; + + // Pre-compute sort index for each account: O(m) calls to toFormattedAddress + const sortIndices = new Map(); + for (const account of accounts) { + sortIndices.set( + account, + keyringIndexMap.get(toFormattedAddress(account.address)) ?? 0, + ); + } + + // Sort using pre-computed indices: O(m log m) but NO toFormattedAddress calls + return [...accounts].sort( + (a, b) => (sortIndices.get(a) ?? 0) - (sortIndices.get(b) ?? 0), + ); }, ); diff --git a/app/util/address/index.test.ts b/app/util/address/index.test.ts index 1d26437ac649..19a8ac65cdca 100644 --- a/app/util/address/index.test.ts +++ b/app/util/address/index.test.ts @@ -316,6 +316,50 @@ describe('isValidAddressInputViaQRCode', () => { const mockInput = 'https://www.metamask.io'; expect(isValidAddressInputViaQRCode(mockInput)).toBe(false); }); + + describe('Bitcoin mainnet addresses', () => { + it('should be valid for P2WPKH address (bc1)', () => { + const mockInput = 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k'; + expect(isValidAddressInputViaQRCode(mockInput)).toBe(true); + }); + + it('should be valid for P2PKH address (1)', () => { + const mockInput = '1P5ZEDWTKTFGxQjZphgWPQUpe554WKDfHQ'; + expect(isValidAddressInputViaQRCode(mockInput)).toBe(true); + }); + + it('should be invalid for testnet address', () => { + const mockInput = 'tb1q63st8zfndjh00gf9hmhsdg7l8umuxudrj4lucp'; + expect(isValidAddressInputViaQRCode(mockInput)).toBe(false); + }); + + it('should be invalid for regtest address', () => { + const mockInput = 'bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw'; + expect(isValidAddressInputViaQRCode(mockInput)).toBe(false); + }); + }); + + describe('Tron addresses', () => { + it('should be valid for Tron mainnet address', () => { + const mockInput = 'TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7'; + expect(isValidAddressInputViaQRCode(mockInput)).toBe(true); + }); + + it('should be valid for another Tron mainnet address', () => { + const mockInput = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; + expect(isValidAddressInputViaQRCode(mockInput)).toBe(true); + }); + + it('should be invalid for invalid Tron address (wrong length)', () => { + const mockInput = 'TLa2f6VPqDgRE67v1736s7bJ8Ray5w'; + expect(isValidAddressInputViaQRCode(mockInput)).toBe(false); + }); + + it('should be invalid for invalid Tron address (does not start with T)', () => { + const mockInput = 'RLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7'; + expect(isValidAddressInputViaQRCode(mockInput)).toBe(false); + }); + }); }); describe('stripHexPrefix', () => { diff --git a/app/util/address/index.ts b/app/util/address/index.ts index 39eed9903a54..368cb7a8be8d 100644 --- a/app/util/address/index.ts +++ b/app/util/address/index.ts @@ -4,6 +4,11 @@ import { isValidChecksumAddress, isHexPrefixed, } from 'ethereumjs-util'; +import { isAddress as isSolanaAddress } from '@solana/addresses'; +import { + isBtcMainnetAddress, + isTronAddress, +} from '../../core/Multichain/utils'; import { getChecksumAddress, type Hex, @@ -777,13 +782,25 @@ export async function validateAddressOrENS( confusableCollection, }; } -/** Method to evaluate if an input is a valid ethereum address +/** Method to evaluate if an input is a valid ethereum, solana, bitcoin, or tron address * via QR code scanning. * * @param {string} input - a random string. * @returns {boolean} indicates if the string is a valid input. */ export function isValidAddressInputViaQRCode(input: string) { + if (isSolanaAddress(input)) { + return true; + } + + if (isBtcMainnetAddress(input)) { + return true; + } + + if (isTronAddress(input)) { + return true; + } + if (input.includes(PROTOCOLS.ETHEREUM)) { const { pathname } = new URL(input); // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/docs/perps/hyperliquid/init-flow.md b/docs/perps/hyperliquid/init-flow.md index 54693c681314..d3d748c300b0 100644 --- a/docs/perps/hyperliquid/init-flow.md +++ b/docs/perps/hyperliquid/init-flow.md @@ -310,6 +310,97 @@ public setDexAssetCtxsCache(dex: string, assetCtxs: AssetCtx[]): void { - Main DEX: key = `''` - HIP-3 DEXes: key = dex name (e.g., `'xyz'`, `'hyna'`, `'flx'`, `'vntl'`) +## Transport Architecture + +### Overview + +The HyperLiquid SDK supports two transports: HTTP and WebSocket. For optimal performance, we use WebSocket as the default transport for InfoClient API calls during initialization, multiplexing all requests over a single connection. + +```mermaid +flowchart TD + subgraph Clients["SDK Clients"] + EC[ExchangeClient] + IC_WS[InfoClient
WebSocket Default] + IC_HTTP[InfoClient
HTTP Fallback] + SC[SubscriptionClient] + end + + subgraph Transports["Transports"] + HTTP[HttpTransport] + WS[WebSocketTransport] + end + + subgraph API["HyperLiquid API"] + REST[REST Endpoints] + WSS[WebSocket Server] + end + + EC --> HTTP + IC_WS --> WS + IC_HTTP --> HTTP + SC --> WS + + HTTP --> REST + WS --> WSS +``` + +### Transport Selection + +Transport selection is handled at the **HyperLiquidClientService** level: + +```typescript +// HyperLiquidClientService.ts + +// WebSocket InfoClient (default) - multiplexed requests +private infoClient?: InfoClient; // Uses wsTransport + +// HTTP InfoClient (fallback) - per-request connections +private infoClientHttp?: InfoClient; // Uses httpTransport + +/** + * Get the info client + * @param options.useHttp - Force HTTP transport instead of WebSocket (default: false) + */ +public getInfoClient(options?: { useHttp?: boolean }): InfoClient { + if (options?.useHttp) { + return this.infoClientHttp; // HTTP fallback + } + return this.infoClient; // WebSocket default +} +``` + +### Transport Usage by Client + +| Client | Transport | Use Case | +| --------------------- | --------- | --------------------------------------------------------------- | +| ExchangeClient | HTTP | Write operations (orders, approvals) - must be HTTP for signing | +| InfoClient (default) | WebSocket | Read operations (market data, user data) - multiplexed | +| InfoClient (fallback) | HTTP | Fallback if WebSocket has issues with specific calls | +| SubscriptionClient | WebSocket | Real-time pub/sub (price feeds, position updates) | + +### Benefits of WebSocket Transport + +| Metric | HTTP | WebSocket | +| ------------------- | ------------- | ----------- | +| Network connections | 1 per request | 1 shared | +| TLS handshakes | Per request | Once | +| Connection overhead | High | Minimal | +| Request latency | Per-request | Multiplexed | + +### Fallback Strategy + +If a specific API call has issues with WebSocket transport, it can be overridden in `HyperLiquidProvider`: + +```typescript +// Default (WebSocket): +const infoClient = this.clientService.getInfoClient(); + +// Force HTTP for specific call if needed: +const infoClient = this.clientService.getInfoClient({ useHttp: true }); +``` + +This architecture keeps transport selection as an implementation detail, invisible to higher layers like `PerpsController`. + ## WebSocket Subscriptions After initialization, the following WebSocket subscriptions are active: diff --git a/docs/perps/hyperliquid/rate-limits.md b/docs/perps/hyperliquid/rate-limits.md new file mode 100644 index 000000000000..199a955067bd --- /dev/null +++ b/docs/perps/hyperliquid/rate-limits.md @@ -0,0 +1,34 @@ +# Rate limits and user limits + +The following rate limits apply per IP address: + +- REST requests share an aggregated weight limit of 1200 per minute. + - All documented `exchange` API requests have a weight of `1 + floor(batch_length / 40)`. For example, unbatched actions have weight `1` and a batched order request of length 79 has weight `2`. Here, `batch_length`is the length of the array in the action, e.g. the number of orders in a batched order action. + - The following `info` requests have weight 2: `l2Book, allMids, clearinghouseState, orderStatus, spotClearinghouseState, exchangeStatus.` + - The following `info` requests have weight 60: `userRole` . + - All other documented `info` requests have weight 20. + - The following `info` endpoints have an additional rate limit weight per 20 items returned in the response: `recentTrades`, `historicalOrders`, `userFills`, `userFillsByTime`, `fundingHistory`, `userFunding`, `nonUserFundingUpdates`, `twapHistory`, `userTwapSliceFills`, `userTwapSliceFillsByTime`, `delegatorHistory`, `delegatorRewards`, `validatorStats` . + - The `candleSnapshot` info endpoint has an additional rate limit weight per 60 items returned in the response. + - All `explorer` API requests have a weight of 40. `blockList` has an additional rate limit of 1 per block. Note that older blocks which have not been recently queried may be weighted more heavily. For large batch requests, use the S3 bucket instead. +- Maximum of 100 websocket connections +- Maximum of 1000 websocket subscriptions +- Maximum of 10 unique users across user-specific websocket subscriptions +- Maximum of 2000 messages sent to Hyperliquid per minute across all websocket connections +- Maximum of 100 simultaneous inflight post messages across all websocket connections +- Maximum of 100 EVM JSON-RPC requests per minute for `rpc.hyperliquid.xyz/evm`. Note that other JSON-RPC providers have more sophisticated rate limiting logic and archive node functionality. + +Use websockets for lowest latency realtime data. See the python SDK for a full-featured example. + +### Address-based limits + +Address-based limits apply per user, with sub-accounts treated as separate users. + +The rate limiting logic allows 1 request per 1 USDC traded cumulatively since address inception. For example, with an order value of 100 USDC, this requires a fill rate of 1%. Each address starts with an initial buffer of 10000 requests. When rate limited, an address is allowed one request every 10 seconds. Cancels have cumulative limit `min(limit + 100000, limit * 2)` where `limit` is the default limit for other actions. This way, hitting the address-based rate limit still allows open orders to be canceled. Note that this rate limit only applies to actions, not info requests. + +Each user has a default open order limit of 1000 plus one additional order for every 5M USDC of volume, capped at a total of 5000 open orders. When an order is placed with at least 1000 other open orders by the same user, it will be rejected if it is reduce-only or a trigger order. + +During high congestion, addresses are limited to use 2x their maker share percentage of the block space. During high traffic, it can therefore be helpful to not resend cancels whose results have already been returned via the API. + +### Batched Requests + +A batched request with `n` orders (or cancels) is treated as one request for IP based rate limiting, but as `n` requests for address-based rate limiting. diff --git a/e2e/api-mocking/mock-responses/polymarket/polymarket-mocks.ts b/e2e/api-mocking/mock-responses/polymarket/polymarket-mocks.ts index 74804f629c2f..b91a51e82a12 100644 --- a/e2e/api-mocking/mock-responses/polymarket/polymarket-mocks.ts +++ b/e2e/api-mocking/mock-responses/polymarket/polymarket-mocks.ts @@ -60,6 +60,9 @@ import { createTransactionSentinelResponse } from './polymarket-transaction-sent // Global variable to track current USDC balance let currentUSDCBalance = MOCK_RPC_RESPONSES.USDC_BALANCE_RESULT; +// Global variable to track current block number (to invalidate NetworkController block cache) +let currentBlockNumber = 0x1000000; // Start at block 16777216 + // Global Set to track when Celtics vs Nets orders have been submitted const celticsOrderSubmitted = new Set(); @@ -768,7 +771,8 @@ export const POLYMARKET_USDC_BALANCE_MOCKS = async ( result = MOCK_RPC_RESPONSES.EMPTY_RESULT; } } else if (body?.method === 'eth_blockNumber') { - result = MOCK_RPC_RESPONSES.BLOCK_NUMBER_RESULT; + // Return current block number (dynamically updated to invalidate cache) + result = `0x${currentBlockNumber.toString(16)}`; } else if (body?.method === 'eth_getBalance') { result = MOCK_RPC_RESPONSES.ETH_BALANCE_RESULT; } else if (body?.method === 'eth_getTransactionCount') { @@ -1111,72 +1115,61 @@ export const POLYMARKET_UPDATE_USDC_BALANCE_MOCKS = async ( positionType: string, ) => { // Update global balance based on position type (similar to POLYMARKET_USDC_BALANCE_MOCKS pattern) - let balance: string; if (positionType === 'claim') { - balance = POST_CLAIM_USDC_BALANCE_WEI; // 48.16 USDC + currentUSDCBalance = POST_CLAIM_USDC_BALANCE_WEI; // 48.16 USDC } else if (positionType === 'cash-out') { - balance = POST_CASH_OUT_USDC_BALANCE_WEI; // 58.66 USDC + currentUSDCBalance = POST_CASH_OUT_USDC_BALANCE_WEI; // 58.66 USDC } else if (positionType === 'open-position') { - balance = POST_OPEN_POSITION_USDC_BALANCE_WEI; // 17.76 USDC + currentUSDCBalance = POST_OPEN_POSITION_USDC_BALANCE_WEI; // 17.76 USDC } else { throw new Error(`Unknown positionType: ${positionType}`); } + // Increment block number to invalidate NetworkController's block cache + // This forces eth_call requests to fetch fresh data instead of using cached responses + currentBlockNumber++; + await mockServer .forPost('/proxy') - .matching((request) => { + .matching(async (request) => { const urlParam = new URL(request.url).searchParams.get('url'); - return Boolean( - urlParam?.includes('polygon') || urlParam?.includes('infura'), - ); + + if (!urlParam?.includes('polygon') && !urlParam?.includes('infura')) { + return false; + } + + // Parse body to ensure this is a USDC balance call + try { + const bodyText = await request.body.getText(); + const body = bodyText ? JSON.parse(bodyText) : undefined; + if (body?.method !== 'eth_call') { + return false; + } + const toAddress = body?.params?.[0]?.to?.toLowerCase(); + const callData = body?.params?.[0]?.data; + const isMatch = + toAddress === USDC_CONTRACT_ADDRESS.toLowerCase() && + callData?.toLowerCase()?.startsWith('0x70a08231'); + // Only match USDC balanceOf calls + return isMatch; + } catch (error) { + return false; + } }) .asPriority(PRIORITY.BALANCE_REFRESH_PROXY) // Higher priority (1005) to catch balance refresh calls before base mocks .thenCallback(async (request) => { const bodyText = await request.body.getText(); const body = bodyText ? JSON.parse(bodyText) : undefined; - let result: string | object = '0x'; - - // Handle USDC balance calls - if (body?.method === 'eth_call') { - const toAddress = body?.params?.[0]?.to?.toLowerCase(); - const callData = body?.params?.[0]?.data; - if (toAddress === USDC_CONTRACT_ADDRESS.toLowerCase()) { - // USDC contract call - check function selector - if (callData?.toLowerCase()?.startsWith('0x70a08231')) { - // balanceOf(address) selector - return updated balance - result = balance; - } else if (callData?.toLowerCase()?.startsWith('0xdd62ed3e')) { - // allowance(address,address) selector - return max allowance (uint256 max) - result = - '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; - } else { - // Other USDC contract calls - return updated balance as fallback - result = balance; - } - } else { - // For other eth_call, return empty result (let base mocks handle if needed) - result = MOCK_RPC_RESPONSES.EMPTY_RESULT; - } - } else if (body?.method === 'eth_getTransactionCount') { - // Return a valid nonce (transaction count) - needed for claim flow - // This is critical for transaction construction, must be a valid hex number - result = MOCK_RPC_RESPONSES.TRANSACTION_COUNT_RESULT; - } else if (body?.method === 'eth_getTransactionReceipt') { - // Return a mock transaction receipt indicating the transaction is confirmed - // This is CRITICAL for TransactionController to mark transactions as confirmed - // TransactionController polls for receipts to determine transaction status - // Without this, transactions will remain in "pending" status - result = MOCK_RPC_RESPONSES.TRANSACTION_RECEIPT_RESULT; - } - // For other methods, return empty result (base mocks will handle them) + // Return the current global balance (not a captured value) + // This ensures the mock always returns the latest balance after updates return { statusCode: 200, json: { id: body?.id ?? 50, jsonrpc: '2.0', - result, + result: currentUSDCBalance, }, }; }); @@ -1516,9 +1509,6 @@ export const POLYMARKET_POST_OPEN_POSITION_MOCKS = async ( }); await POLYMARKET_ADD_CELTICS_POSITION_MOCKS(mockServer); await POLYMARKET_ADD_CELTICS_ACTIVITY_MOCKS(mockServer); - - // Update balance after opening position - // await POLYMARKET_UPDATE_USDC_BALANCE_MOCKS(mockServer, 'open-position'); }; /** diff --git a/e2e/pages/swaps/QuoteView.ts b/e2e/pages/swaps/QuoteView.ts index ffdf3f09da53..a2f42f36ecc5 100644 --- a/e2e/pages/swaps/QuoteView.ts +++ b/e2e/pages/swaps/QuoteView.ts @@ -44,6 +44,14 @@ class QuoteView { return Matchers.getElementByText(QuoteViewSelectorText.NETWORK_FEE); } + get maxLink(): DetoxElement { + return Matchers.getElementByText(QuoteViewSelectorText.MAX); + } + + get includedLabel(): DetoxElement { + return Matchers.getElementByText(QuoteViewSelectorText.INCLUDED); + } + token(chainId: string, symbol: string): Detox.NativeElement { const elementId = `asset-${chainId}-${symbol}`; return element(by.id(elementId)).atIndex(0); @@ -136,6 +144,12 @@ class QuoteView { elemDescription: 'Cancel swap', }); } + + async tapMax(): Promise { + await Gestures.waitAndTap(this.maxLink, { + elemDescription: 'Tap Max link to use maximum balance', + }); + } } export default new QuoteView(); diff --git a/e2e/selectors/swaps/QuoteView.selectors.ts b/e2e/selectors/swaps/QuoteView.selectors.ts index f82e1c37e975..20b7787f5c50 100644 --- a/e2e/selectors/swaps/QuoteView.selectors.ts +++ b/e2e/selectors/swaps/QuoteView.selectors.ts @@ -9,6 +9,8 @@ export const QuoteViewSelectorText = { SELECT_ALL: enContent.bridge.see_all, CANCEL: 'Cancel', FEE_DISCLAIMER: enContent.bridge.fee_disclaimer, + MAX: enContent.bridge.max, + INCLUDED: enContent.bridge.included, }; export const QuoteViewSelectorIDs = { diff --git a/e2e/specs/predict/predict-open-position.spec.ts b/e2e/specs/predict/predict-open-position.spec.ts index 32ba76c4377a..9755fdcb4ab9 100644 --- a/e2e/specs/predict/predict-open-position.spec.ts +++ b/e2e/specs/predict/predict-open-position.spec.ts @@ -18,6 +18,7 @@ import { POLYMARKET_UPDATE_USDC_BALANCE_MOCKS, } from '../../api-mocking/mock-responses/polymarket/polymarket-mocks'; import ActivitiesView from '../../pages/Transactions/ActivitiesView'; +import PredictActivityDetails from '../../pages/Transactions/predictionsActivityDetails'; /* Test Scenario: Open position on Celtics vs. Nets market @@ -74,7 +75,6 @@ describe(SmokePredictions('Predictions'), () => { await PredictDetailsPage.tapOpenPositionValue(); await POLYMARKET_POST_OPEN_POSITION_MOCKS(mockServer); - await POLYMARKET_UPDATE_USDC_BALANCE_MOCKS(mockServer, 'open-position'); await PredictDetailsPage.tapPositionAmount( positionDetails.positionAmount, @@ -111,6 +111,21 @@ describe(SmokePredictions('Predictions'), () => { await TabBarComponent.tapActivity(); await ActivitiesView.tapOnPredictionsTab(); await ActivitiesView.tapPredictPosition(positionDetails.name); + + /* + When opening a position, the balance is optimistically updated in PredictController + with a cache valid for 5 seconds. When getBalance() is called after cache expiration + it invalidates the NetworkController's block cache and + makes a fresh RPC balance request. The mock is placed here to + verify that when the cache expires and a balance refresh request + is made, it successfully returns the updated balance. + */ + await POLYMARKET_UPDATE_USDC_BALANCE_MOCKS(mockServer, 'open-position'); + + await PredictActivityDetails.tapBackButton(); + await TabBarComponent.tapActions(); + await WalletActionsBottomSheet.tapPredictButton(); + await Assertions.expectTextDisplayed(positionDetails.newBalance); }, ); }); diff --git a/e2e/specs/swaps/gasless-swap.spec.ts b/e2e/specs/swaps/gasless-swap.spec.ts new file mode 100644 index 000000000000..43589ba7c025 --- /dev/null +++ b/e2e/specs/swaps/gasless-swap.spec.ts @@ -0,0 +1,94 @@ +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { LocalNode, LocalNodeType } from '../../framework/types'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import Assertions from '../../framework/Assertions'; +import WalletView from '../../pages/wallet/WalletView'; +import { SmokeTrade } from '../../tags'; +import { loginToApp } from '../../viewHelper'; +import { logger } from '../../framework/logger'; +import { AnvilPort } from '../../framework/fixtures/FixtureUtils'; +import { AnvilManager } from '../../seeder/anvil-manager'; +import QuoteView from '../../pages/swaps/QuoteView'; +import { setupMockRequest } from '../../api-mocking/helpers/mockHelpers'; +import { GASLESS_SWAP_QUOTES_ETH_MUSD } from './helpers/constants'; + +describe(SmokeTrade('Gasless Swap - '), (): void => { + const chainId = '0x1'; + + beforeEach(async (): Promise => { + jest.setTimeout(120000); + }); + + it('displays included label for gasless ETH to MUSD swap quote', async (): Promise => { + await withFixtures( + { + fixture: ({ localNodes }: { localNodes?: LocalNode[] }) => { + const node = localNodes?.[0] as unknown as AnvilManager; + const rpcPort = + node instanceof AnvilManager + ? (node.getPort() ?? AnvilPort()) + : undefined; + + return new FixtureBuilder() + .withNetworkController({ + providerConfig: { + chainId, + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Localhost', + ticker: 'ETH', + }, + }) + .withMetaMetricsOptIn() + .withPreferencesController({ + smartTransactionsOptInStatus: true, + }) + .build(); + }, + localNodeOptions: [ + { + type: LocalNodeType.anvil, + options: { + chainId: 1, + }, + }, + ], + testSpecificMock: async (mockServer) => { + // Mock ETH->MUSD quote (gasless swap) + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: /getQuote.*destTokenAddress=0xacA92E438df0B2401fF60dA7E4337B687a2435DA/i, + response: GASLESS_SWAP_QUOTES_ETH_MUSD, + responseCode: 200, + }); + }, + restartDevice: true, + endTestfn: async () => { + logger.debug('Gasless swap test completed'); + }, + }, + async () => { + await loginToApp(); + await WalletView.tapWalletSwapButton(); + await device.disableSynchronization(); + await Assertions.expectElementToBeVisible(QuoteView.selectAmountLabel, { + description: 'Swap amount selection visible', + }); + + // Tap Max to use maximum balance + await QuoteView.tapMax(); + + // Verify network fee shows "Included" for gasless swap + await Assertions.expectElementToBeVisible(QuoteView.networkFeeLabel, { + timeout: 60000, + description: 'Network fee label visible', + }); + + await Assertions.expectElementToBeVisible(QuoteView.includedLabel, { + timeout: 10000, + description: 'Gas included in quote', + }); + }, + ); + }); +}); diff --git a/e2e/specs/swaps/helpers/constants.ts b/e2e/specs/swaps/helpers/constants.ts index 9804870fb392..53aae29e3454 100644 --- a/e2e/specs/swaps/helpers/constants.ts +++ b/e2e/specs/swaps/helpers/constants.ts @@ -711,3 +711,92 @@ export const GET_TOP_ASSETS_BASE_RESPONSE = [ { address: '0x0000000000000000000000000000000000000000', symbol: 'ETH' }, { address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', symbol: 'USDC' }, ]; + +export const GASLESS_SWAP_QUOTES_ETH_MUSD = [ + { + quote: { + requestId: + '0xf75136205d474cdedb32d1c6f6811fe289b95678aa74679b9abb69252ed0b266', + bridgeId: 'openocean', + srcChainId: 1, + destChainId: 1, + aggregator: 'openocean', + aggregatorType: 'AGG', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', + coingeckoId: 'ethereum', + aggregators: [], + occurrences: 100, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + metadata: {}, + }, + srcTokenAmount: '991250000000000000', + destAsset: { + address: '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + chainId: 1, + assetId: 'eip155:1/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'MUSD', + decimals: 6, + name: 'MetaMask USD', + coingeckoId: 'metamask-usd', + aggregators: ['metamask', 'liFi', 'socket', 'rubic', 'rango'], + occurrences: 5, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xaca92e438df0b2401ff60da7e4337b687a2435da.png', + metadata: { storage: {} }, + }, + destTokenAmount: '3839447765', + minDestTokenAmount: '3762658809', + walletAddress: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + destWalletAddress: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + gasIncluded: true, + gasIncluded7702: false, + feeData: { + metabridge: { + amount: '8750000000000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', + coingeckoId: 'ethereum', + aggregators: [], + occurrences: 100, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + metadata: {}, + }, + quoteBpsFee: 87.5, + baseBpsFee: 87.5, + }, + }, + bridges: ['openocean'], + protocols: ['openocean'], + steps: [], + slippage: 2, + priceData: { + totalFromAmountUsd: '3865.21', + totalToAmountUsd: '3832.3211880033778', + priceImpact: '0.008508932760864812', + totalFeeAmountUsd: '33.8205875', + }, + }, + trade: { + chainId: 1, + to: '0x881D40237659C251811CEC9c364ef91dC08D300C', + from: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + value: '0xde0b6b3a7640000', + data: '0x5f575529000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000136f70656e4f6365616e46656544796e616d6963000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000aca92e438df0b2401ff60da7e4337b687a2435da0000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000e0123b42', + gasLimit: 448721, + }, + estimatedProcessingTimeInSeconds: 0, + }, +]; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 161702ae662e..1660c02e910d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -446,7 +446,7 @@ PODS: - nanopb/encode (= 2.30910.0) - nanopb/decode (2.30910.0) - nanopb/encode (2.30910.0) - - NativeUtils (0.5.0): + - NativeUtils (0.8.0): - DoubleConversion - glog - hermes-engine @@ -3474,7 +3474,7 @@ SPEC CHECKSUMS: lottie-react-native: 7f3fc3f396b1d6c7b1454b77596bd2ad3151871e MultiplatformBleAdapter: b1fddd0d499b96b607e00f0faa8e60648343dc1d nanopb: 438bc412db1928dac798aa6fd75726007be04262 - NativeUtils: ff6b807548ac292267c8bd50b6f1dfb4c9f056d3 + NativeUtils: e1d5591114bd87ba0b91348477f77b029cd361b8 NitroModules: 54cf4604a7e458d788aeecb3ba1ff7db43ed17f2 OpenSSL-Universal: 6082b0bf950e5636fe0d78def171184e2b3899c2 Permission-BluetoothPeripheral: 34ab829f159c6cf400c57bac05f5ba1b0af7a86e diff --git a/jest.config.js b/jest.config.js index fa804826a36e..b10900e055bf 100644 --- a/jest.config.js +++ b/jest.config.js @@ -29,7 +29,7 @@ const config = { setupFilesAfterEnv: ['/app/util/test/testSetup.js'], testEnvironment: 'jest-environment-node', transformIgnorePatterns: [ - 'node_modules/(?!((@metamask/)?(@react-native|react-native|redux-persist-filesystem|@react-navigation|@react-native-community|@react-native-masked-view|react-navigation|react-navigation-redux-helpers|@sentry|d3-color|d3-shape|d3-path|d3-scale|d3-array|d3-time|d3-format|d3-interpolate|d3-selection|d3-axis|d3-transition|internmap|react-native-wagmi-charts|@notifee|expo-file-system|expo-modules-core|expo(nent)?|@expo(nent)?/.*)|@noble/.*|@nktkas/hyperliquid|@metamask/design-system-twrnc-preset|@metamask/design-system-react-native|@tommasini/react-native-scrollable-tab-view))', + 'node_modules/(?!((@metamask/)?(@react-native|react-native|redux-persist-filesystem|@react-navigation|@react-native-community|@react-native-masked-view|react-navigation|react-navigation-redux-helpers|@sentry|d3-color|d3-shape|d3-path|d3-scale|d3-array|d3-time|d3-format|d3-interpolate|d3-selection|d3-axis|d3-transition|internmap|react-native-wagmi-charts|react-native-nitro-modules|@notifee|expo-file-system|expo-modules-core|expo(nent)?|@expo(nent)?/.*)|@noble/.*|@nktkas/hyperliquid|@metamask/design-system-twrnc-preset|@metamask/design-system-react-native|@metamask/native-utils|@tommasini/react-native-scrollable-tab-view))', ], transform: { '^.+\\.[jt]sx?$': ['babel-jest', { configFile: './babel.config.tests.js' }], @@ -64,6 +64,8 @@ const config = { '\\webview/index.html': '/app/__mocks__/htmlMock.ts', '^@expo/vector-icons@expo/vector-icons$': 'react-native-vector-icons', '^@expo/vector-icons/(.*)': 'react-native-vector-icons/$1', + '^@metamask/native-utils$': + '/app/__mocks__/@metamask/native-utils.js', '^@nktkas/hyperliquid(/.*)?$': '/app/__mocks__/hyperliquidMock.js', '^expo-auth-session(/.*)?$': '/app/__mocks__/expo-auth-session.js', '^expo-apple-authentication(/.*)?$': diff --git a/locales/languages/en.json b/locales/languages/en.json index de0a2d0321a7..5ccdc45103b1 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3365,7 +3365,8 @@ "url_redirection_alert_desc": "Links can be used to try to defraud or phish people, so make sure to only visit websites that you trust.", "label": "Scan a QR code", "open_settings": "Settings", - "camera_not_available": "Camera not available" + "camera_not_available": "Camera not available", + "tron_address_not_supported": "Tron addresses are not currently supported" }, "action_view": { "cancel": "Cancel", diff --git a/package.json b/package.json index df6a601cc443..e9828b79819a 100644 --- a/package.json +++ b/package.json @@ -168,8 +168,14 @@ "@expo/fingerprint": "^0.15.0", "appwright@^0.1.45": "patch:appwright@npm%3A0.1.45#./.yarn/patches/appwright-npm-0.1.45-f282bc1c1b.patch", "@scure/bip32": "1.7.0", + "js-sha3": "0.9.3", "@metamask/snaps-sdk": "^10.0.0", "react-native@0.76.9": "patch:react-native@npm%3A0.76.9#./.yarn/patches/react-native-npm-0.76.9-1c25352097.patch", + "@ethereumjs/util@npm:^9.0.3": "patch:@ethereumjs/util@npm%3A9.1.0#~/.yarn/patches/@ethereumjs-util-npm-9.1.0-7e85509408.patch", + "@ethereumjs/util@npm:^9.1.0": "patch:@ethereumjs/util@npm%3A9.1.0#~/.yarn/patches/@ethereumjs-util-npm-9.1.0-7e85509408.patch", + "@ethereumjs/util@npm:^9.0.2": "patch:@ethereumjs/util@npm%3A9.1.0#~/.yarn/patches/@ethereumjs-util-npm-9.1.0-7e85509408.patch", + "@metamask/key-tree@npm:^10.1.1": "patch:@metamask/key-tree@npm%3A10.1.1#~/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch", + "@metamask/key-tree@npm:^10.0.2": "patch:@metamask/key-tree@npm%3A10.1.1#~/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch", "@metamask/transaction-controller@npm:^62.5.0": "patch:@metamask/transaction-controller@npm%3A62.5.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" }, "dependencies": { @@ -226,7 +232,7 @@ "@metamask/gator-permissions-controller": "^0.3.0", "@metamask/json-rpc-engine": "^10.2.0", "@metamask/json-rpc-middleware-stream": "^8.0.7", - "@metamask/key-tree": "^10.1.1", + "@metamask/key-tree": "patch:@metamask/key-tree@npm%3A10.1.1#~/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch", "@metamask/keyring-api": "^21.2.0", "@metamask/keyring-controller": "^24.0.0", "@metamask/keyring-internal-api": "^9.1.0", @@ -242,7 +248,7 @@ "@metamask/multichain-api-middleware": "1.2.4", "@metamask/multichain-network-controller": "^2.0.0", "@metamask/multichain-transactions-controller": "^6.0.0", - "@metamask/native-utils": "^0.5.0", + "@metamask/native-utils": "^0.8.0", "@metamask/network-controller": "^27.0.0", "@metamask/network-enablement-controller": "patch:@metamask/network-enablement-controller@npm%3A3.1.0#~/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch", "@metamask/notification-services-controller": "^20.0.0", @@ -288,6 +294,7 @@ "@ngraveio/bc-ur": "^1.1.6", "@nktkas/hyperliquid": "^0.27.1", "@noble/curves": "1.9.6", + "@noble/hashes": "1.8.0", "@notifee/react-native": "^9.0.0", "@react-native-async-storage/async-storage": "^1.23.1", "@react-native-clipboard/clipboard": "^1.16.1", @@ -374,6 +381,7 @@ "human-standard-token-abi": "^2.0.0", "humanize-duration": "^3.27.2", "is-url": "^1.2.4", + "js-sha3": "0.9.3", "lodash": "^4.17.21", "lottie-react-native": "6.7.2", "luxon": "^3.5.0", diff --git a/shim.js b/shim.js index 2006631c3869..0a4f52e66dc0 100644 --- a/shim.js +++ b/shim.js @@ -12,13 +12,7 @@ import { } from './app/util/test/utils.js'; import { defaultMockPort } from './e2e/api-mocking/mock-config/mockUrlCollection.json'; -import { getPublicKey } from '@metamask/native-utils'; - -// polyfill getPublicKey with much faster C++ implementation -// IMPORTANT: This patching works only if @noble/curves version in root package.json is same as @noble/curves version in package.json of @scure/bip32. -// eslint-disable-next-line import/no-commonjs, import/no-extraneous-dependencies -const secp256k1_1 = require('@noble/curves/secp256k1'); -secp256k1_1.secp256k1.getPublicKey = getPublicKey; +import './shimPerf'; // Needed to polyfill random number generation import 'react-native-get-random-values'; diff --git a/shimPerf.js b/shimPerf.js new file mode 100644 index 000000000000..2ecf33bd869e --- /dev/null +++ b/shimPerf.js @@ -0,0 +1,69 @@ +/* eslint-disable import/no-extraneous-dependencies */ +/* eslint-disable import/no-commonjs */ +/* eslint-disable import/no-nodejs-modules */ +import { Buffer } from '@craftzdog/react-native-buffer'; + +import { getPublicKey, hmacSha512, keccak256 } from '@metamask/native-utils'; + +// Monkey patch getPublicKey from @noble/curves with much faster C++ implementation +// IMPORTANT: This patching works only if @noble/curves version in root package.json is same as @noble/curves version in package.json of @scure/bip32. +const secp256k1_1 = require('@noble/curves/secp256k1'); +secp256k1_1.secp256k1.getPublicKey = getPublicKey; + +// Monkey patch hmacSha512 from @noble/hashes +const nobleHashesHmac = require('@noble/hashes/hmac'); +const nobleHashesSha2 = require('@noble/hashes/sha2'); +const originalHmac = nobleHashesHmac.hmac; +nobleHashesHmac.hmac = (hash, key, message) => { + if (hash === nobleHashesSha2.sha512) { + try { + return hmacSha512(key, message); + } catch (error) { + console.error( + 'Error in @metamask/native-utils.hmacSha512, falling back to original implementation', + error, + ); + } + } + return originalHmac(hash, key, message); +}; + +// Monkey patch keccak256 from @noble/hashes +const nobleHashesSha3 = require('@noble/hashes/sha3'); +const originalNobleHashesSha3Keccak256 = nobleHashesSha3.keccak_256; +const patchedNobleHashesSha3Keccak256 = (value) => { + try { + return keccak256(value); + } catch (error) { + console.error( + 'Error in @metamask/native-utils.keccak256, falling back to original implementation', + error, + ); + } + return originalNobleHashesSha3Keccak256(value); +}; +// We need to use Object.assign to ensure added properties are not overridden (e.g. keccak_256.create()) +Object.assign( + patchedNobleHashesSha3Keccak256, + originalNobleHashesSha3Keccak256, +); +nobleHashesSha3.keccak_256 = patchedNobleHashesSha3Keccak256; + +// Monkey patch keccak256 from js-sha3 +const jsSha3 = require('js-sha3'); +const originalJsSha3Keccak256 = jsSha3.keccak_256; +const patchedJsSha3Keccak256 = (value) => { + try { + // js-sha3 returns hex string not Uint8Array + return Buffer.from(keccak256(value)).toString('hex'); + } catch (error) { + console.error( + 'Error in @metamask/native-utils.keccak256, falling back to original js-sha3 implementation', + error, + ); + } + return originalJsSha3Keccak256(value); +}; +// We need to use Object.assign to ensure added properties are not overridden (e.g. keccak256.create()) +Object.assign(patchedJsSha3Keccak256, originalJsSha3Keccak256); +jsSha3.keccak_256 = patchedJsSha3Keccak256; diff --git a/yarn.lock b/yarn.lock index afbd88b6bbf2..0acee573d820 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2668,6 +2668,16 @@ __metadata: languageName: node linkType: hard +"@ethereumjs/util@npm:9.1.0": + version: 9.1.0 + resolution: "@ethereumjs/util@npm:9.1.0" + dependencies: + "@ethereumjs/rlp": "npm:^5.0.2" + ethereum-cryptography: "npm:^2.2.1" + checksum: 10/4e22c4081c63eebb808eccd54f7f91cd3407f4cac192da5f30a0d6983fe07d51f25e6a9d08624f1376e604bb7dce574aafcf0fbf0becf42f62687c11e710ac41 + languageName: node + linkType: hard + "@ethereumjs/util@npm:^10.0.0": version: 10.0.0 resolution: "@ethereumjs/util@npm:10.0.0" @@ -2689,13 +2699,13 @@ __metadata: languageName: node linkType: hard -"@ethereumjs/util@npm:^9.0.2, @ethereumjs/util@npm:^9.0.3, @ethereumjs/util@npm:^9.1.0": +"@ethereumjs/util@patch:@ethereumjs/util@npm%3A9.1.0#~/.yarn/patches/@ethereumjs-util-npm-9.1.0-7e85509408.patch": version: 9.1.0 - resolution: "@ethereumjs/util@npm:9.1.0" + resolution: "@ethereumjs/util@patch:@ethereumjs/util@npm%3A9.1.0#~/.yarn/patches/@ethereumjs-util-npm-9.1.0-7e85509408.patch::version=9.1.0&hash=f1f3d0" dependencies: "@ethereumjs/rlp": "npm:^5.0.2" ethereum-cryptography: "npm:^2.2.1" - checksum: 10/4e22c4081c63eebb808eccd54f7f91cd3407f4cac192da5f30a0d6983fe07d51f25e6a9d08624f1376e604bb7dce574aafcf0fbf0becf42f62687c11e710ac41 + checksum: 10/ab8e9ff226989daf026de6297932598f180de2306b6d059db628c1fdb5338d36dfa622a94c7a4d0f768a95975c31e526c5b12837ab6d1c322ce5d6888d40b398 languageName: node linkType: hard @@ -8191,7 +8201,7 @@ __metadata: languageName: node linkType: hard -"@metamask/key-tree@npm:^10.0.2, @metamask/key-tree@npm:^10.1.1": +"@metamask/key-tree@npm:10.1.1": version: 10.1.1 resolution: "@metamask/key-tree@npm:10.1.1" dependencies: @@ -8204,6 +8214,19 @@ __metadata: languageName: node linkType: hard +"@metamask/key-tree@patch:@metamask/key-tree@npm%3A10.1.1#~/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch": + version: 10.1.1 + resolution: "@metamask/key-tree@patch:@metamask/key-tree@npm%3A10.1.1#~/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch::version=10.1.1&hash=40cbda" + dependencies: + "@metamask/scure-bip39": "npm:^2.1.1" + "@metamask/utils": "npm:^11.0.1" + "@noble/curves": "npm:^1.8.1" + "@noble/hashes": "npm:^1.3.2" + "@scure/base": "npm:^1.0.0" + checksum: 10/27e41df10066976063d91cfa66fa5dd2c9e460afd12e79550f72eb94ddcd9d3b6603922a57614e128dfefc14f93dcfbf493067e4a20ae57a754516ca4ba3834d + languageName: node + linkType: hard + "@metamask/keyring-api@npm:^21.0.0, @metamask/keyring-api@npm:^21.2.0": version: 21.2.0 resolution: "@metamask/keyring-api@npm:21.2.0" @@ -8568,14 +8591,14 @@ __metadata: languageName: node linkType: hard -"@metamask/native-utils@npm:^0.5.0": - version: 0.5.0 - resolution: "@metamask/native-utils@npm:0.5.0" +"@metamask/native-utils@npm:^0.8.0": + version: 0.8.0 + resolution: "@metamask/native-utils@npm:0.8.0" peerDependencies: react: "*" react-native: "*" - react-native-nitro-modules: ^0.26.3 - checksum: 10/9e1eb64b0ff0c854a3a64617c6aaa1d538537022f9332e26cb9500060309dbb2ab27b9611e0af232a0b949cb86b6b5b1f12a0cd7a67ab8f219260c8d3c4a2d8d + react-native-nitro-modules: ^0.29.4 + checksum: 10/2dd16fe5e9511e453cd3b15b6066311471e41a9a58b82f898ae82fdd73c0684893fa4c92ae954b6f31fb1c811c918531fc93e81b5c2986fa8dec491e7a738e83 languageName: node linkType: hard @@ -32233,28 +32256,7 @@ __metadata: languageName: node linkType: hard -"js-sha3@npm:0.5.5": - version: 0.5.5 - resolution: "js-sha3@npm:0.5.5" - checksum: 10/9ce8bfabdba2cfb94b911125fc278e2f46cc01b6590c98833d9361199e2c2b4bca0427d04da6aa083f05c2c3982029200964a3d6e417b0c126c80f2e32c2d5eb - languageName: node - linkType: hard - -"js-sha3@npm:0.8.0": - version: 0.8.0 - resolution: "js-sha3@npm:0.8.0" - checksum: 10/a49ac6d3a6bfd7091472a28ab82a94c7fb8544cc584ee1906486536ba1cb4073a166f8c7bb2b0565eade23c5b3a7b8f7816231e0309ab5c549b737632377a20c - languageName: node - linkType: hard - -"js-sha3@npm:^0.5.7": - version: 0.5.7 - resolution: "js-sha3@npm:0.5.7" - checksum: 10/32885c7edb50fca04017bacada8e5315c072d21d3d35e071e9640fc5577e200076a4718e0b2f33d86ab704accb68d2ade44f1e2ca424cc73a5929b9129dab948 - languageName: node - linkType: hard - -"js-sha3@npm:^0.9.2": +"js-sha3@npm:0.9.3": version: 0.9.3 resolution: "js-sha3@npm:0.9.3" checksum: 10/8daacb93b18609a0dc081f2f6199b80a96df36f9975b4b9c7476ae92822e07100b9e1969fc76f4b58e703cd6175f0de7656a99cbb2335cfb554c66f988fbead5 @@ -34023,7 +34025,7 @@ __metadata: "@metamask/gator-permissions-controller": "npm:^0.3.0" "@metamask/json-rpc-engine": "npm:^10.2.0" "@metamask/json-rpc-middleware-stream": "npm:^8.0.7" - "@metamask/key-tree": "npm:^10.1.1" + "@metamask/key-tree": "patch:@metamask/key-tree@npm%3A10.1.1#~/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch" "@metamask/keyring-api": "npm:^21.2.0" "@metamask/keyring-controller": "npm:^24.0.0" "@metamask/keyring-internal-api": "npm:^9.1.0" @@ -34040,7 +34042,7 @@ __metadata: "@metamask/multichain-api-middleware": "npm:1.2.4" "@metamask/multichain-network-controller": "npm:^2.0.0" "@metamask/multichain-transactions-controller": "npm:^6.0.0" - "@metamask/native-utils": "npm:^0.5.0" + "@metamask/native-utils": "npm:^0.8.0" "@metamask/network-controller": "npm:^27.0.0" "@metamask/network-enablement-controller": "patch:@metamask/network-enablement-controller@npm%3A3.1.0#~/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch" "@metamask/notification-services-controller": "npm:^20.0.0" @@ -34091,6 +34093,7 @@ __metadata: "@ngraveio/bc-ur": "npm:^1.1.6" "@nktkas/hyperliquid": "npm:^0.27.1" "@noble/curves": "npm:1.9.6" + "@noble/hashes": "npm:1.8.0" "@notifee/react-native": "npm:^9.0.0" "@octokit/rest": "npm:^21.0.0" "@open-rpc/mock-server": "npm:^1.7.5" @@ -34259,6 +34262,7 @@ __metadata: jest: "npm:^29.7.0" jest-junit: "npm:^15.0.0" jetifier: "npm:2.0.0" + js-sha3: "npm:0.9.3" koa: "npm:^2.14.2" lint-staged: "npm:10.5.4" listr2: "npm:^8.0.2"