From 69ed80dcc69ef22646b1dc416bf699fd25c69597 Mon Sep 17 00:00:00 2001 From: Curtis David Date: Mon, 8 Dec 2025 12:41:25 -0500 Subject: [PATCH 1/7] test: fix open predict position e2e (#23750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** > Polymarket mocks now track USDC balance and block number globally to invalidate cache and return up-to-date balances; e2e open-position test updated to rely on these mocks and remove manual balance update. > > - **Mocks (Polymarket)**: > - Implement global `currentUSDCBalance` usage across balance responses and add `currentBlockNumber` to serve dynamic `eth_blockNumber` (cache invalidation). > - `POLYMARKET_UPDATE_USDC_BALANCE_MOCKS` now updates global balance, increments block, and narrowly matches USDC `balanceOf` calls; proxy responses return `currentUSDCBalance`. > - Base RPC mock returns dynamic block number instead of static `MOCK_RPC_RESPONSES.BLOCK_NUMBER_RESULT`. > - **Tests**: > - In `e2e/specs/predict/predict-open-position.spec.ts`, remove explicit `POLYMARKET_UPDATE_USDC_BALANCE_MOCKS` call; rely on `POLYMARKET_POST_OPEN_POSITION_MOCKS` and global balance handling. > ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Polymarket mocks now track USDC balance and block number globally (dynamic eth_blockNumber) and the open-position e2e test relies on these mocks, refreshing balance after cache expiry. > > - **Polymarket mocks**: > - Add global `currentBlockNumber`; base RPC now returns dynamic `eth_blockNumber`. > - Use global `currentUSDCBalance` for USDC reads. > - Revise `POLYMARKET_UPDATE_USDC_BALANCE_MOCKS` to set global balance, increment block, narrowly match USDC `balanceOf` via `/proxy`, and return `currentUSDC_BALANCE`. > - **E2E test (`e2e/specs/predict/predict-open-position.spec.ts`)**: > - Remove pre-action balance update and trigger `POLYMARKET_UPDATE_USDC_BALANCE_MOCKS('open-position')` after viewing activity to validate cache expiry. > - Add `PredictActivityDetails` import. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 47857a8129048638a38d64f98195da7f45d74938. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../polymarket/polymarket-mocks.ts | 86 ++++++++----------- .../predict/predict-open-position.spec.ts | 17 +++- 2 files changed, 54 insertions(+), 49 deletions(-) diff --git a/e2e/api-mocking/mock-responses/polymarket/polymarket-mocks.ts b/e2e/api-mocking/mock-responses/polymarket/polymarket-mocks.ts index 74804f629c2..b91a51e82a1 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/specs/predict/predict-open-position.spec.ts b/e2e/specs/predict/predict-open-position.spec.ts index 32ba76c4377..9755fdcb4ab 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); }, ); }); From e9f564e888ba0a25e1cc00d8d5426a8e93c0121d Mon Sep 17 00:00:00 2001 From: sethkfman <10342624+sethkfman@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:52:21 -0700 Subject: [PATCH 2/7] chore: use native utils for crypto functions (#23270) (#23746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR is from an external contributor. Initial review and context [here](https://github.com/MetaMask/metamask-mobile/pull/23270) Credit @Nodonisko This PR implements new `@metamask/native-utils` package for C++ cryptography instead of JS implementation. That provides significant performance improvements all across the app. Most visible improvements are during app startup and SRP imports, but for example `keccak256` helps also in many other places. This PR should also have really nice synergy with https://github.com/MetaMask/core/pull/6654 that could shave of another tens of percent for login times. For now I am patching `@metamask/key-tree`, once https://github.com/MetaMask/key-tree/pull/223 is done, patch could be removed, but it may take a while. ### Performance Optimization Results Device: Pixel 4a 5G Tested on SRP with 200+ accounts. | Metric | Before Optimization* | After Optimization | Improvement | % Faster | | :--- | :--- | :--- | :--- | :--- | | **App Loaded to Login screen** | 7s 333ms | **4s 750ms** | ⚡️ 2.58s faster | **35.2%** | | **Dashboard Loaded** | 14s 0ms | **6s 333ms** | ⚡️ 7.67s faster | **54.8%** | | **App is Responsive (60 FPS)** | 18s 783ms | **12s 166ms** | ⚡️ 6.62s faster | **35.2%** | | **SRP Import (Discovery)** | 276s 616ms | **203s 450ms** | ⚡️ 73.17s faster | **26.5%** | _* Before optimalization version has `@metamask/native-utils` completely removed (including secp256k1 that was merged before)._ There should be around 200 - 300ms improvement in account creation time but I am not including this in result because I did many measurements but spread was too big to conclude any results from it. Another big improvement for acccount creation should be https://github.com/MetaMask/core/pull/6654 ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** 1. All accounts are discovered 2. Balances for tokens and total balance is correct 3. Correct receive addresses are generated ## **Screenshots/Recordings** ### Startup + Login https://github.com/user-attachments/assets/24c8ca90-5475-4fa8-9062-30f6fa5133b2 ### SRP Import Observe also FPS counter, as you can see optimized version is maintaining higher FPS (around ~20) compared to non-optimized (around ~10). That should be enough to make app usable even on very slow device during running accounts discovery. To improve FPS even more we need to optimize rerenders and some selectors. In order to reduce discovery total time even more it would require different strategies of discovery, for example instead doing account detail requests one by one, until you find empty one, you could for example request them in batches of 3 which should improve total time significantly. https://github.com/user-attachments/assets/9f6c9825-5d97-415e-903c-8f4327273a2d ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Replaces JS crypto with native implementations via @metamask/native-utils, adds perf shims, patches key libs, and updates tests/config to support it. > > - **Crypto performance**: > - Add `shimPerf` to monkey-patch `@noble` and `js-sha3` (`secp256k1.getPublicKey`, `hmacSha512`, `keccak256`) to use native C++ via `@metamask/native-utils`. > - Update `shim.js` to load `shimPerf`. > - Switch `quick-crypto` string encoding to `TextEncoder` for key derivation/encryption. > - **Library patches**: > - Patch `@ethereumjs/util` to export `pubToAddress` from `@metamask/native-utils`. > - Patch `@metamask/key-tree` `ed25519` to use `native-utils.getPublicKeyEd25519`. > - **Selectors**: > - Optimize `selectInternalAccounts` sorting to pre-compute indices and reduce `toFormattedAddress` calls. > - **Testing/config**: > - Add Jest mock `app/__mocks__/@metamask/native-utils.js` and map in `jest.config.js`; extend `transformIgnorePatterns`. > - **Dependencies/Pods/Lockfiles**: > - Bump `@metamask/native-utils` to `^0.8.0` (iOS `NativeUtils` pod 0.8.0). > - Add `js-sha3@0.9.3`, pin `@noble/hashes@1.8.0`, add Yarn patches/locks for `@ethereumjs/util@9.1.0` and `@metamask/key-tree@10.1.1`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 95728edc12a89b426d0326f8fdf69a30c3f13f0a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). ## **Description** ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: Daniel Suchý --- ...ethereumjs-util-npm-9.1.0-7e85509408.patch | 13 ++++ ...amask-key-tree-npm-10.1.1-0bfab435ac.patch | 21 ++++++ app/__mocks__/@metamask/native-utils.js | 42 +++++++++++ app/core/Encryptor/lib/quick-crypto.ts | 4 +- app/selectors/accountsController.ts | 33 +++++---- ios/Podfile.lock | 4 +- jest.config.js | 4 +- package.json | 12 +++- shim.js | 8 +-- shimPerf.js | 69 ++++++++++++++++++ yarn.lock | 70 ++++++++++--------- 11 files changed, 221 insertions(+), 59 deletions(-) create mode 100644 .yarn/patches/@ethereumjs-util-npm-9.1.0-7e85509408.patch create mode 100644 .yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch create mode 100644 app/__mocks__/@metamask/native-utils.js create mode 100644 shimPerf.js 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 00000000000..bf8ee135174 --- /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 00000000000..73fc040895e --- /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 00000000000..0fb68eb792c --- /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/core/Encryptor/lib/quick-crypto.ts b/app/core/Encryptor/lib/quick-crypto.ts index 546fd09cb11..a9bfad6a920 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 995459cad1e..72b23f885d2 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/ios/Podfile.lock b/ios/Podfile.lock index 161702ae662..1660c02e910 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 fa804826a36..b10900e055b 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/package.json b/package.json index df6a601cc44..e9828b79819 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 2006631c386..0a4f52e66dc 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 00000000000..2ecf33bd869 --- /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 afbd88b6bbf..0acee573d82 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" From 64c7cf770b56d32031898f6cd18b66bb819c3fa2 Mon Sep 17 00:00:00 2001 From: Vince Howard Date: Mon, 8 Dec 2025 12:07:59 -0700 Subject: [PATCH 3/7] fix: QR scanner navigation and recipient pre-population in redesigned send flow (#21498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ### Problem: Fixed four critical issues with QR scanner in the redesigned send flow: 1. Navigation failure - QR scanner wasn't navigating to send flow after scanning addresses. The camera modal would close but no navigation occurred. 2. Missing recipient pre-population - Scanned addresses weren't appearing in the recipient input field in the redesigned send flow. 3. Limited blockchain support - Only supported Ethereum addresses. Solana, Bitcoin, and Tron addresses were not recognized or handled. 4. Camera resource leak - Camera continued scanning after navigation, causing continuous barcode processing, log spam, wasted CPU/battery, and potential privacy concerns ### Solution: Navigation & Recipient Pre-population: - Implemented proper navigation timing by closing the QR scanner modal first, then using `InteractionManager.runAfterInteractions()` to navigate to send flow - Added `predefinedRecipient` parameter with address and chain type to `navigateToSendPage()` to pre-populate recipient field - Added `QRScanner` to `InitSendLocation` constants for proper metrics tracking - Updated send flow logic to start at asset selection screen when recipient is pre-populated from QR scan Multi-chain Support: - Added support for Solana addresses using `@solana/addresses `validation - Added support for Bitcoin addresses using `isBtcMainnetAddress()` - Added support for Tron addresses using `isTronAddress()` - Tron is currently disabled and will show an alert for now - Each blockchain type is properly identified and passed with correct chainType (ChainType.SOLANA, ChainType.BITCOIN, ChainType.TRON, ChainType.EVM) ## **Changelog** CHANGELOG entry: Fixed QR scanner navigation by chain type and recipient address pre-population in send flow ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-75 https://github.com/MetaMask/metamask-mobile/issues/17283 ## **Manual testing steps** ```gherkin Feature: QR Scanner recipient pre-population Scenario: user scans QR code with EVM address Given user is on wallet home screen When user taps QR scanner icon And user scans QR code containing "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb" Then app navigates to send flow asset selection screen And recipient address field is pre-populated with scanned address ``` ## **Screenshots/Recordings** Sending EVM https://github.com/user-attachments/assets/a7999672-4a06-4e38-abdb-2608d9605bb5 Send SOL https://github.com/user-attachments/assets/43cb23a8-130d-47fa-94d5-ac8d7693a13f Send BTC https://github.com/user-attachments/assets/b15b5df5-2bab-4292-9d84-9a58544bdc6c Tron Alert https://github.com/user-attachments/assets/dd416270-2c97-4e31-9444-021aaf30878e ### **Before** https://github.com/user-attachments/assets/5950a84f-45cc-480b-b6ea-9dadb28db91c ### **After** https://github.com/user-attachments/assets/a7999672-4a06-4e38-abdb-2608d9605bb5 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > QR scanner now closes camera and navigates to the redesigned send flow with a prefilled recipient across EVM, Solana, and Bitcoin (Tron temporarily blocked), with improved metrics, validation, and tests. > > - **QR Scanner behavior**: > - Closes scanner and, via `InteractionManager`, navigates to send flow using `useSendNavigation` with `predefinedRecipient` and `InitSendLocation.QRScanner`. > - Manages camera lifecycle with `useFocusEffect` and `isCameraActive` to prevent repeated scans. > - **Multi-chain address support**: > - Adds handling/validation for `Solana` (`@solana/addresses`) and `Bitcoin` (`isBtcMainnetAddress`); routes to send flow with correct `chainType`. > - Detects `Tron` (`isTronAddress`) but blocks with alert and metrics; treats as `address_type_not_supported`. > - Extends QR address validation (`isValidAddressInputViaQRCode`) to Solana/Bitcoin/Tron. > - **Metrics & constants**: > - Tracks outcomes with new `ScanResult.ADDRESS_TYPE_NOT_SUPPORTED` and refined QR scan events. > - **Send flow**: > - Updates navigation tests to start at `Asset`/`Amount`/`Recipient` screens based on `predefinedRecipient` and asset. > - **i18n**: > - Adds `qr_scanner.tron_address_not_supported` copy. > - **Tests**: > - Extensive unit tests for EVM, Solana, Bitcoin, Tron paths, URL confirmations, and camera/permission states. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit dd53ceba3429a9a4d3999e646855509f983341dd. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/Views/QRScanner/constants.ts | 2 + app/components/Views/QRScanner/index.test.tsx | 702 +++++++++++++++++- app/components/Views/QRScanner/index.tsx | 207 +++++- .../Views/confirmations/constants/send.ts | 1 + .../Views/confirmations/utils/send.test.ts | 124 ++++ app/util/address/index.test.ts | 44 ++ app/util/address/index.ts | 19 +- locales/languages/en.json | 3 +- 8 files changed, 1068 insertions(+), 34 deletions(-) diff --git a/app/components/Views/QRScanner/constants.ts b/app/components/Views/QRScanner/constants.ts index 9ed0ef07989..16cbe1b96e7 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 0f52d1e1b56..8713e79fa56 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 b877ba0a93e..3cf13991bfb 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/util/address/index.test.ts b/app/util/address/index.test.ts index 1d26437ac64..19a8ac65cdc 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 39eed9903a5..368cb7a8be8 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/locales/languages/en.json b/locales/languages/en.json index de0a2d0321a..5ccdc45103b 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", From 8257b59a4c0eb1b7368ca78c1588c9c3378b4f39 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Tue, 9 Dec 2025 03:59:38 +0800 Subject: [PATCH 4/7] perf(perps): default to websocket transport for all posts requests (#23752) ## **Description** Follow-up to #23747 (HIP-3 connection optimization). This PR further optimizes Perps initialization by using WebSocket transport for InfoClient API calls instead of HTTP. ### Background: Previous 429 Rate Limit Issue In #22242, we encountered 429 rate limit errors when HyperLiquid SDK v0.25.9 defaulted ALL operations (including write operations like orders) to WebSocket transport. The solution was to separate transports: - HTTP for ExchangeClient/InfoClient (request/response) - WebSocket only for SubscriptionClient (real-time subscriptions) ### Why WebSocket for InfoClient is Now Safe 1. **Reduced API calls**: #23747 reduced init calls from 19 to 15 through caching 2. **Read-only operations**: InfoClient only performs read operations (market data, user data) - not rate-limited like write operations 3. **ExchangeClient stays HTTP**: Write operations (orders, cancellations) remain on HTTP to avoid exhausting WebSocket rate limits 4. **Fallback available**: HTTP InfoClient available per-call via `getInfoClient({ useHttp: true })` **Problem:** - Previous optimization (#23747) reduced API calls from 19 to 15 - However, each InfoClient call still created a separate HTTP connection - 15 HTTP requests = 15 TLS handshakes and connection overhead **Solution:** - Use WebSocket transport for InfoClient reads (same transport already used for SubscriptionClient) - All 15+ InfoClient API calls now multiplex over a single WebSocket connection - Keep HTTP InfoClient as fallback for specific calls if needed - ExchangeClient (orders, cancellations) remains on HTTP **Result:** Same number of API calls, but significantly reduced network overhead (1 connection instead of 15+) ### Architecture Change | Client | Before | After | |--------|--------|-------| | ExchangeClient | HTTP | HTTP (unchanged - required for signing) | | InfoClient | HTTP | **WebSocket** (default) | | InfoClient (fallback) | N/A | HTTP (new - per-call override) | | SubscriptionClient | WebSocket | WebSocket (unchanged) | ### Transport Selection ```typescript // Default (WebSocket - multiplexed): const infoClient = this.clientService.getInfoClient(); // Override for specific call (HTTP fallback): const infoClient = this.clientService.getInfoClient({ useHttp: true }); ``` ## **Changelog** CHANGELOG entry: null ## **Related issues** Follow-up to: #23747 (HIP-3 connection optimization - reduced API calls from 19 to 15) Related: #22242 (429 rate limit fix - established HTTP/WebSocket transport separation) - https://github.com/MetaMask/metamask-mobile/issues/23607 ## **Manual testing steps** ```gherkin Feature: Perps WebSocket Transport Optimization Scenario: Faster initialization with multiplexed WebSocket Given user has MetaMask mobile app with Perps feature enabled When user navigates to Perps tab Then Perps markets should load successfully And network inspector should show single WebSocket connection for InfoClient calls And HIP-3 markets (stocks like TSLA, NVDA) should appear alongside crypto ``` ## **Screenshots/Recordings** N/A - Performance optimization, no UI changes ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Switches InfoClient to WebSocket by default with an HTTP fallback, updating service logic, tests, and docs. > > - **Perps Service (`HyperLiquidClientService`)**: > - Default `InfoClient` to WebSocket transport; add `infoClientHttp` as HTTP fallback. > - New `getInfoClient(options?: { useHttp?: boolean })` to select transport per-call. > - Update initialization to create both InfoClients; `isInitialized` now requires both; disconnect clears both. > - Historical candles now fetched via WebSocket `InfoClient`. > - **Tests** (`HyperLiquidClientService.test.ts`): > - Adjust mocks to return WS then HTTP `InfoClient`; assert dual initialization and transport selection. > - Extend tests for `getInfoClient({ useHttp })`, candle fetch via WS, and subscription/unsubscribe flows. > - **Docs**: > - Add transport architecture and selection details in `docs/perps/hyperliquid/init-flow.md`. > - Add new `docs/perps/hyperliquid/rate-limits.md` outlining REST/WebSocket limits. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 913ab25ce334c3ca4a2425297d9e50782911305b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../services/HyperLiquidClientService.test.ts | 109 +++++++++++++----- .../services/HyperLiquidClientService.ts | 26 ++++- docs/perps/hyperliquid/init-flow.md | 91 +++++++++++++++ docs/perps/hyperliquid/rate-limits.md | 34 ++++++ 4 files changed, 227 insertions(+), 33 deletions(-) create mode 100644 docs/perps/hyperliquid/rate-limits.md diff --git a/app/components/UI/Perps/services/HyperLiquidClientService.test.ts b/app/components/UI/Perps/services/HyperLiquidClientService.test.ts index 27b3351b8f2..8ed9d749209 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 ce50b57ad52..41834e08747 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/docs/perps/hyperliquid/init-flow.md b/docs/perps/hyperliquid/init-flow.md index 54693c68131..d3d748c300b 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 00000000000..199a955067b --- /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. From 9621caf5b4297921355d6481bfa7c5e5366b2cd8 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 8 Dec 2025 13:22:16 -0700 Subject: [PATCH 5/7] feat(ramps): cp-7.61.0 add ramps analytics data to all fund action menu items (#23784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Added ramps analytics data to all FundActionMenu buttons (buy-unified, buy, sell) to match the deposit button's analytics payload. This ensures consistent analytics tracking across all ramp action buttons. Changes: - Added `ramp_routing`, `is_authenticated`, `preferred_provider`, and `order_count` to buy-unified, buy, and sell button analytics - Renamed `depositButtonClickData` to `rampsButtonClickData` to reflect broader usage - Updated `ActionConfig` type to support boolean and undefined values in analytics properties ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: TRAM-2901, https://github.com/MetaMask/metamask-mobile/issues/23777 ## **Manual testing steps** Feature: FundActionMenu Analytics Scenario: user taps buy button Given the user is on a screen with the FundActionMenu When user taps the buy button Then analytics event BUY_BUTTON_CLICKED is fired with ramp_routing, is_authenticated, preferred_provider, and order_count properties Scenario: user taps sell button Given the user is on a screen with the FundActionMenu When user taps the sell button Then analytics event SELL_BUTTON_CLICKED is fired with ramp_routing, is_authenticated, preferred_provider, and order_count properties## **Screenshots/Recordings** ### **Before** N/A - Analytics change only ### **After** N/A - Analytics change only ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Unifies ramp analytics across FundActionMenu actions using shared button click data and updates types/tests accordingly. > > - **UI / FundActionMenu**: > - Use `useRampsButtonClickData` and add shared analytics fields (`ramp_routing`, `is_authenticated`, `preferred_provider`, `order_count`) to `buy-unified`, `buy`, and `sell` button events; keep existing `region` and chain ID fields. > - Rename `depositButtonClickData` → `rampsButtonClickData`; update memo deps accordingly. > - **Types**: > - Broaden `ActionConfig.analyticsProperties` to accept `boolean` and `undefined`. > - **Tests**: > - Update `FundActionMenu.test.tsx` to mock `RampsButtonClickData` and assert new analytics properties for buy flows; maintain behavior that custom `onBuy` skips analytics. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f194d92f959c27af49744e87f49d74a07d5921a3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/FundActionMenu/FundActionMenu.test.tsx | 10 ++++++++ .../UI/FundActionMenu/FundActionMenu.tsx | 24 ++++++++++++++----- .../UI/FundActionMenu/FundActionMenu.types.ts | 2 +- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/app/components/UI/FundActionMenu/FundActionMenu.test.tsx b/app/components/UI/FundActionMenu/FundActionMenu.test.tsx index 3a314384ca2..1e236008980 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 262efca3f08..febd793b05f 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 df311d6c37f..d7cc67dd8a0 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, From c8c5ac4def1f62f79eefcbfcaea16104b58b58eb Mon Sep 17 00:00:00 2001 From: Nicholas Ellul <15018469+NicholasEllul@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:37:12 -0500 Subject: [PATCH 6/7] chore: update template and label script (#23790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds a fork-detection job and conditionally runs the template/label check; updates checkout and setup-node to v4. > > - **CI Workflow** (`.github/workflows/check-template-and-add-labels.yml`): > - **Fork gating**: > - Adds `is-fork-pull-request` job using `gh pr view` to set `IS_FORK`. > - Updates `check-template-and-add-labels` to `needs` this job and conditionally run only for issues or non-fork PRs. > - **Actions upgrades**: > - Bumps `actions/checkout` to `v4` and `actions/setup-node` to `v4`. > - **Condition updates**: > - Replaces simple `merge_group` skip with combined `always()` + event/fork checks. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 653e28732ea8df404ed4d06da71c5123d63dc85d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../check-template-and-add-labels.yml | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check-template-and-add-labels.yml b/.github/workflows/check-template-and-add-labels.yml index 63e3423d190..7d0c6b3e0f4 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' From 16ee0cd2a25b617b50d5ab70e047ca7b9410e11f Mon Sep 17 00:00:00 2001 From: Harika <153644847+hjetpoluru@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:58:42 -0500 Subject: [PATCH 7/7] test: Add e2e test for gas less swap (#23507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add e2e test for the gas less swap ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MMQA-1178 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds a gasless ETH→MUSD swap e2e test verifying the "Included" network fee, with mocks and QuoteView/selectors support for Max and Included. > > - **Tests**: > - Add `e2e/specs/swaps/gasless-swap.spec.ts` to validate gasless ETH→MUSD swap shows `Included` under network fee and uses `Max` amount. > - **Mocks/Constants**: > - Add `GASLESS_SWAP_QUOTES_ETH_MUSD` in `e2e/specs/swaps/helpers/constants.ts` and wire via API mock. > - **Page Objects**: > - Update `e2e/pages/swaps/QuoteView.ts` with `maxLink`, `includedLabel`, and `tapMax()` helpers. > - **Selectors**: > - Extend `QuoteViewSelectorText` with `MAX` and `INCLUDED`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 36da9e1b8ea079409d063e44f16f34f2ccbcbc87. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- e2e/pages/swaps/QuoteView.ts | 14 ++++ e2e/selectors/swaps/QuoteView.selectors.ts | 2 + e2e/specs/swaps/gasless-swap.spec.ts | 94 ++++++++++++++++++++++ e2e/specs/swaps/helpers/constants.ts | 89 ++++++++++++++++++++ 4 files changed, 199 insertions(+) create mode 100644 e2e/specs/swaps/gasless-swap.spec.ts diff --git a/e2e/pages/swaps/QuoteView.ts b/e2e/pages/swaps/QuoteView.ts index ffdf3f09da5..a2f42f36ecc 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 f82e1c37e97..20b7787f5c5 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/swaps/gasless-swap.spec.ts b/e2e/specs/swaps/gasless-swap.spec.ts new file mode 100644 index 00000000000..43589ba7c02 --- /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 9804870fb39..53aae29e345 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, + }, +];