From d94765c46fa037a79449fda9e3b915b1160be50e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Patryk=20=C5=81ucka?=
<5708018+PatrykLucka@users.noreply.github.com>
Date: Tue, 25 Nov 2025 12:30:07 +0100
Subject: [PATCH 1/9] feat(transactions): enhance collectible transfer handling
and add mint method support (#23043)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR fixes NFT transaction display in activity view.
Two major bugs has been fixed:
- Send NFT transaction was not displayed at all (when it was initiated
from different device)
- Mint transactions were not recognized (they were displayed as "Sent
ETH")
## **Changelog**
CHANGELOG entry: Fixed a bug that was preventing NFT transactions to be
displayed in activity view
## **Related issues**
Fixes: https://github.com/MetaMask/metamask-mobile/issues/18229
https://consensyssoftware.atlassian.net/browse/TMCU-126
## **Manual testing steps**
```gherkin
Feature: Show NFT transactions
Scenario: user checks activity tab for NFT transactions
Given user sent NFT with different device (ie. from extension)
When user opens Activity trab
Then "Sent Collectible" should be displayed correctly in the list
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Fixes collectible/transferFrom decoding and activity filtering, and
recognizes common NFT mint methods with updated action keys and tests.
>
> - **Transactions**:
> - **transferFrom decoding**: Decode recipient from calldata; fallback
to `tx.transferInformation` when data is truncated; determine direction
case‑insensitively from `from`; handle `tokenId` rendering and avoid
undefined/NaN values.
> - **Action keys/types**: Distinguish NFT (ERC721/1155) vs ERC20
`transferFrom`; map
`tokenMethodTransferFrom`/`tokenMethodSafeTransferFrom` to
`transferfrom`; handle `deployContract` and `contractInteraction` cases;
add labeling for mint.
> - **Mint support**: Recognize common NFT mint signatures
(`SAFE_MINT_SIGNATURE`, `MINT_SIGNATURE`, `MINT_TO_SIGNATURE`,
`SAFE_MINT_WITH_DATA`) and return `TOKEN_METHOD_MINT`.
> - **Activity filtering**:
> - Show outgoing transfers even if token isn’t in list; keep incoming
transfers requiring token presence in list.
> - **Localization**:
> - Add `transactions.mint` string.
> - **Tests**:
> - Add/adjust unit tests for transferFrom decoding, action key logic,
mint detection, and activity filters.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
560361677d5140df9385632229137adc40e38e21. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
app/components/UI/TransactionElement/utils.js | 88 +++++-
.../UI/TransactionElement/utils.test.js | 272 +++++++++++++++++-
app/util/activity/index.test.ts | 49 +++-
app/util/activity/index.ts | 12 +-
app/util/transactions/index.js | 86 ++++++
app/util/transactions/index.test.ts | 243 ++++++++++++++++
locales/languages/en.json | 1 +
7 files changed, 728 insertions(+), 23 deletions(-)
diff --git a/app/components/UI/TransactionElement/utils.js b/app/components/UI/TransactionElement/utils.js
index 27dcd5bf04b..b1fcb28046e 100644
--- a/app/components/UI/TransactionElement/utils.js
+++ b/app/components/UI/TransactionElement/utils.js
@@ -467,6 +467,7 @@ function decodeTransferFromTx(args) {
txParams,
txParams: { gas, data, to },
hash,
+ transferInformation,
},
txChainId,
collectibleContracts,
@@ -476,11 +477,32 @@ function decodeTransferFromTx(args) {
selectedAddress,
ticker,
} = args;
- const [addressFrom, addressTo, tokenId] = decodeTransferData(
- 'transferFrom',
- data,
- );
- const collectible = collectibleContracts.find((collectible) =>
+
+ // For transferFrom transactions, prioritize decoding from data when available
+ // transferInformation is used as fallback since the data may only contain the function signature
+ let addressFrom, addressTo, tokenId;
+
+ // Try to decode from data first if it's complete (4 bytes selector + 3×32 bytes params = 202 chars)
+ if (data && data.length < 202 && transferInformation) {
+ // Data is truncated, use transferInformation as fallback
+ tokenId =
+ transferInformation.tokenId ||
+ transferInformation.tokenAmount ||
+ transferInformation.value;
+ // For direction, use txParams.from as the sender
+ // Note: txParams.to is the contract, not the recipient, so we can't reliably set addressTo
+ addressFrom = txParams.from;
+ // We can't determine the actual recipient from truncated data
+ // Use txParams for direction logic, but this won't show the correct recipient in UI
+ addressTo = txParams.to;
+ } else {
+ // Data is complete or no transferInformation available - decode from data
+ [addressFrom, addressTo, tokenId] = decodeTransferData(
+ 'transferFrom',
+ data,
+ );
+ }
+ const collectible = collectibleContracts?.find((collectible) =>
areAddressesEqual(collectible.address, to),
);
let actionKey = args.actionKey;
@@ -489,16 +511,36 @@ function decodeTransferFromTx(args) {
}
const totalGas = calculateTotalGas(txParams);
- const renderCollectible = collectible?.symbol
- ? `${strings('unit.token_id')}${tokenId} ${collectible?.symbol}`
- : `${strings('unit.token_id')}${tokenId}`;
+
+ // Handle cases where tokenId might be undefined or NaN
+ let renderCollectible;
+ if (collectible?.symbol) {
+ renderCollectible =
+ tokenId != null && !isNaN(Number(tokenId))
+ ? `${strings('unit.token_id')}${tokenId} ${collectible.symbol}`
+ : collectible.symbol;
+ } else if (collectible?.name) {
+ renderCollectible =
+ tokenId != null && !isNaN(Number(tokenId))
+ ? `${strings('unit.token_id')}${tokenId} ${collectible.name}`
+ : collectible.name;
+ } else {
+ // Fallback: show just the contract address or generic label
+ renderCollectible =
+ tokenId != null && !isNaN(Number(tokenId))
+ ? `${strings('unit.token_id')}${tokenId}`
+ : strings('wallet.collectible');
+ }
const renderFrom = renderFullAddress(addressFrom);
const renderTo = renderFullAddress(addressTo);
const { SENT_COLLECTIBLE, RECEIVED_COLLECTIBLE } = TRANSACTION_TYPES;
const transactionType =
- renderFrom === selectedAddress ? SENT_COLLECTIBLE : RECEIVED_COLLECTIBLE;
+ (addressFrom?.toLowerCase() ?? txParams.from?.toLowerCase()) ===
+ selectedAddress?.toLowerCase()
+ ? SENT_COLLECTIBLE
+ : RECEIVED_COLLECTIBLE;
let transactionDetails = {
renderFrom,
@@ -539,12 +581,36 @@ function decodeTransferFromTx(args) {
};
}
+ // Handle value display - avoid showing #undefined or #NaN
+ let displayValue;
+ let displayFiatValue;
+
+ if (tokenId != null && !isNaN(Number(tokenId))) {
+ // We have a valid tokenId - show it
+ displayValue = `${strings('unit.token_id')}${tokenId}`;
+ displayFiatValue = collectible ? collectible.symbol : undefined;
+ } else if (collectible?.name) {
+ // Show collectible name
+ displayValue = collectible.name;
+ displayFiatValue = collectible.symbol;
+ } else if (collectible?.symbol) {
+ // Show collectible symbol
+ displayValue = collectible.symbol;
+ displayFiatValue = undefined;
+ } else {
+ // No tokenId or collectible info - show transaction fee
+ const totalGasFee = renderFromWei(totalGas);
+ displayValue =
+ totalGasFee === '0' ? `0 ${ticker}` : `${totalGasFee} ${ticker}`;
+ displayFiatValue = weiToFiat(totalGas, conversionRate, currentCurrency);
+ }
+
const transactionElement = {
renderTo,
renderFrom,
actionKey,
- value: `${strings('unit.token_id')}${tokenId}`,
- fiatValue: collectible ? collectible.symbol : undefined,
+ value: displayValue,
+ fiatValue: displayFiatValue,
transactionType,
};
diff --git a/app/components/UI/TransactionElement/utils.test.js b/app/components/UI/TransactionElement/utils.test.js
index 367137897b4..8bd3c1bb04e 100644
--- a/app/components/UI/TransactionElement/utils.test.js
+++ b/app/components/UI/TransactionElement/utils.test.js
@@ -196,17 +196,19 @@ describe('Transaction Element Utils', () => {
it('if incoming transfer', async () => {
// Arrange
+ const selectedAddress = '0x77648f1407986479fb1fa5cc3597084b5dbdb057';
+ const contractAddress = '0xdac17f958d2ee523a2206206994597c13d831ec7';
const args = {
tx: {
txParams: {
- to: '0x77648f1407986479fb1fa5cc3597084b5dbdb057',
+ to: contractAddress, // Token contract address (not recipient)
from: '0x1440ec793ae50fa046b95bfeca5af475b6003f9e',
value: '52daf0',
},
transferInformation: {
symbol: 'USDT',
decimals: 6,
- contractAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7',
+ contractAddress,
},
hash: '0x942d7843454266b81bf631022aa5f3f944691731b62d67c4e80c4bb5740058bb',
isTransfer: true,
@@ -217,7 +219,7 @@ describe('Transaction Element Utils', () => {
totalGas: '0x64',
actionKey: 'key',
primaryCurrency: 'ETH',
- selectedAddress: '0x77648f1407986479fb1fa5cc3597084b5dbdb057',
+ selectedAddress,
ticker: 'ETH',
txChainId: '0x1',
};
@@ -254,17 +256,19 @@ describe('Transaction Element Utils', () => {
it('if large value', async () => {
// Arrange
+ const selectedAddress = '0x77648f1407986479fb1fa5cc3597084b5dbdb057';
+ const contractAddress = '0xdac17f958d2ee523a2206206994597c13d831ec7';
const args = {
tx: {
txParams: {
- to: '0x77648f1407986479fb1fa5cc3597084b5dbdb057',
+ to: contractAddress, // Token contract address (not recipient)
from: '0x1440ec793ae50fa046b95bfeca5af475b6003f9e',
value: '3B9ACA00', // 1000000000 in decimal
},
transferInformation: {
symbol: 'USDT',
decimals: 6,
- contractAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7',
+ contractAddress,
},
hash: '0x942d7843454266b81bf631022aa5f3f944691731b62d67c4e80c4bb5740058bb',
isTransfer: true,
@@ -275,7 +279,7 @@ describe('Transaction Element Utils', () => {
totalGas: '0x64',
actionKey: 'key',
primaryCurrency: 'ETH',
- selectedAddress: '0x77648f1407986479fb1fa5cc3597084b5dbdb057',
+ selectedAddress,
ticker: 'ETH',
txChainId: '0x1',
};
@@ -447,5 +451,261 @@ describe('Transaction Element Utils', () => {
txChainId: '0x89',
});
});
+
+ it('sets SENT_COLLECTIBLE type when user is sender', async () => {
+ const selectedAddress = '0x1440ec793ae50fa046b95bfeca5af475b6003f9e';
+ const args = {
+ tx: {
+ type: TransactionType.tokenMethodTransferFrom,
+ txParams: {
+ to: '0x77648f1407986479fb1fa5cc3597084b5dbdb057',
+ from: selectedAddress,
+ data: '0x23b872dd',
+ gas: '0x5208',
+ },
+ transferInformation: {
+ tokenId: '123',
+ contractAddress: '0x77648f1407986479fb1fa5cc3597084b5dbdb057',
+ },
+ hash: '0x942d7843454266b81bf631022aa5f3f944691731b62d67c4e80c4bb5740058bb',
+ },
+ currentCurrency: 'usd',
+ conversionRate: 1,
+ totalGas: '0x5208',
+ actionKey: 'Sent Collectible',
+ primaryCurrency: 'ETH',
+ selectedAddress,
+ ticker: 'ETH',
+ txChainId: '0x1',
+ collectibleContracts: [],
+ };
+
+ const [transactionElement, transactionDetails] =
+ await decodeTransaction(args);
+
+ expect(transactionElement.transactionType).toBe(
+ TRANSACTION_TYPES.SENT_COLLECTIBLE,
+ );
+ expect(transactionDetails.transactionType).toBe(
+ TRANSACTION_TYPES.SENT_COLLECTIBLE,
+ );
+ });
+
+ it('sets RECEIVED_COLLECTIBLE type when user is receiver', async () => {
+ const selectedAddress = '0x77648f1407986479fb1fa5cc3597084b5dbdb057';
+ const senderAddress = '0x1440ec793ae50fa046b95bfeca5af475b6003f9e';
+ const contractAddress = '0xabcdef1234567890abcdef1234567890abcdef12';
+
+ // Complete transferFrom data with actual addresses encoded
+ const completeData =
+ '0x23b872dd' + // transferFrom signature
+ '000000000000000000000000' +
+ senderAddress.slice(2) + // from (sender)
+ '000000000000000000000000' +
+ selectedAddress.slice(2) + // to (recipient - the selected address)
+ '0000000000000000000000000000000000000000000000000000000000000456'; // tokenId (456 in hex)
+
+ const args = {
+ tx: {
+ type: TransactionType.tokenMethodTransferFrom,
+ txParams: {
+ to: contractAddress, // Contract address, not recipient
+ from: senderAddress,
+ data: completeData, // Complete data with recipient encoded
+ gas: '0x5208',
+ },
+ transferInformation: {
+ tokenId: '456',
+ contractAddress,
+ },
+ hash: '0x942d7843454266b81bf631022aa5f3f944691731b62d67c4e80c4bb5740058bb',
+ },
+ currentCurrency: 'usd',
+ conversionRate: 1,
+ totalGas: '0x5208',
+ actionKey: 'Received Collectible',
+ primaryCurrency: 'ETH',
+ selectedAddress,
+ ticker: 'ETH',
+ txChainId: '0x1',
+ collectibleContracts: [],
+ };
+
+ const [transactionElement, transactionDetails] =
+ await decodeTransaction(args);
+
+ expect(transactionElement.transactionType).toBe(
+ TRANSACTION_TYPES.RECEIVED_COLLECTIBLE,
+ );
+ expect(transactionDetails.transactionType).toBe(
+ TRANSACTION_TYPES.RECEIVED_COLLECTIBLE,
+ );
+ });
+
+ it('sets SENT_COLLECTIBLE type with case-insensitive address comparison', async () => {
+ const selectedAddress = '0xABCDEF1234567890ABcdef1234567890abcdef12';
+ const args = {
+ tx: {
+ type: TransactionType.tokenMethodTransferFrom,
+ txParams: {
+ to: '0x77648f1407986479fb1fa5cc3597084b5dbdb057',
+ from: '0xabcdef1234567890abcdef1234567890abcdef12',
+ data: '0x23b872dd',
+ gas: '0x5208',
+ },
+ transferInformation: {
+ tokenId: '789',
+ contractAddress: '0x77648f1407986479fb1fa5cc3597084b5dbdb057',
+ },
+ hash: '0x942d7843454266b81bf631022aa5f3f944691731b62d67c4e80c4bb5740058bb',
+ },
+ currentCurrency: 'usd',
+ conversionRate: 1,
+ totalGas: '0x5208',
+ actionKey: 'Sent Collectible',
+ primaryCurrency: 'ETH',
+ selectedAddress,
+ ticker: 'ETH',
+ txChainId: '0x1',
+ collectibleContracts: [],
+ };
+
+ const [transactionElement] = await decodeTransaction(args);
+
+ expect(transactionElement.transactionType).toBe(
+ TRANSACTION_TYPES.SENT_COLLECTIBLE,
+ );
+ });
+
+ it('decodes recipient from complete data instead of using contract address', async () => {
+ const selectedAddress = '0x1440ec793ae50fa046b95bfeca5af475b6003f9e';
+ const recipientAddress = '0x99999999999999999999999999999999999999aa';
+ const contractAddress = '0x77648f1407986479fb1fa5cc3597084b5dbdb057';
+
+ // Complete transferFrom data with actual addresses encoded
+ const completeData =
+ '0x23b872dd' + // transferFrom signature
+ '000000000000000000000000' +
+ selectedAddress.slice(2) + // from
+ '000000000000000000000000' +
+ recipientAddress.slice(2) + // to (actual recipient)
+ '0000000000000000000000000000000000000000000000000000000000000123'; // tokenId
+
+ const args = {
+ tx: {
+ type: TransactionType.tokenMethodTransferFrom,
+ txParams: {
+ to: contractAddress, // This is the contract, not the recipient
+ from: selectedAddress,
+ data: completeData,
+ gas: '0x5208',
+ },
+ transferInformation: {
+ tokenId: '291',
+ contractAddress,
+ },
+ hash: '0x942d7843454266b81bf631022aa5f3f944691731b62d67c4e80c4bb5740058bb',
+ },
+ currentCurrency: 'usd',
+ conversionRate: 1,
+ totalGas: '0x5208',
+ actionKey: 'Sent Collectible',
+ primaryCurrency: 'ETH',
+ selectedAddress,
+ ticker: 'ETH',
+ txChainId: '0x1',
+ collectibleContracts: [],
+ };
+
+ const [transactionElement] = await decodeTransaction(args);
+
+ // Should decode recipient from data, not use txParams.to (contract address)
+ expect(transactionElement.renderTo).toContain('9999'); // Should show recipient, not contract
+ expect(transactionElement.renderTo).not.toContain('7764'); // Should NOT show contract address
+ expect(transactionElement.transactionType).toBe(
+ TRANSACTION_TYPES.SENT_COLLECTIBLE,
+ );
+ });
+
+ it('uses transferInformation as fallback when data is truncated', async () => {
+ const selectedAddress = '0x1440ec793ae50fa046b95bfeca5af475b6003f9e';
+ const contractAddress = '0x77648f1407986479fb1fa5cc3597084b5dbdb057';
+
+ const args = {
+ tx: {
+ type: TransactionType.tokenMethodTransferFrom,
+ txParams: {
+ to: contractAddress,
+ from: selectedAddress,
+ data: '0x23b872dd', // Only function signature - truncated data
+ gas: '0x5208',
+ },
+ transferInformation: {
+ tokenId: '456',
+ contractAddress,
+ },
+ hash: '0x942d7843454266b81bf631022aa5f3f944691731b62d67c4e80c4bb5740058bb',
+ },
+ currentCurrency: 'usd',
+ conversionRate: 1,
+ totalGas: '0x5208',
+ actionKey: 'Sent Collectible',
+ primaryCurrency: 'ETH',
+ selectedAddress,
+ ticker: 'ETH',
+ txChainId: '0x1',
+ collectibleContracts: [],
+ };
+
+ const [transactionElement] = await decodeTransaction(args);
+
+ // With truncated data, we fall back to transferInformation
+ // Transaction type should still be determined correctly based on txParams.from
+ expect(transactionElement.transactionType).toBe(
+ TRANSACTION_TYPES.SENT_COLLECTIBLE,
+ );
+ expect(transactionElement.value).toContain('456'); // Should use tokenId from transferInformation
+ });
+
+ it('displays token ID 0 correctly', async () => {
+ const selectedAddress = '0x1440ec793ae50fa046b95bfeca5af475b6003f9e';
+ const contractAddress = '0x77648f1407986479fb1fa5cc3597084b5dbdb057';
+ const args = {
+ tx: {
+ type: TransactionType.tokenMethodTransferFrom,
+ txParams: {
+ to: contractAddress,
+ from: selectedAddress,
+ data: '0x23b872dd',
+ gas: '0x5208',
+ },
+ transferInformation: {
+ tokenId: '0',
+ contractAddress,
+ },
+ hash: '0x942d7843454266b81bf631022aa5f3f944691731b62d67c4e80c4bb5740058bb',
+ },
+ currentCurrency: 'usd',
+ conversionRate: 1,
+ totalGas: '0x5208',
+ actionKey: 'Sent Collectible',
+ primaryCurrency: 'ETH',
+ selectedAddress,
+ ticker: 'ETH',
+ txChainId: '0x1',
+ collectibleContracts: [
+ {
+ address: contractAddress,
+ name: 'TestNFT',
+ symbol: 'TNFT',
+ },
+ ],
+ };
+
+ const [transactionElement] = await decodeTransaction(args);
+
+ expect(transactionElement.value).toContain('#0');
+ expect(transactionElement.fiatValue).toBe('TNFT');
+ });
});
});
diff --git a/app/util/activity/index.test.ts b/app/util/activity/index.test.ts
index cf6fcf77dba..ce8a8f84b06 100644
--- a/app/util/activity/index.test.ts
+++ b/app/util/activity/index.test.ts
@@ -279,7 +279,7 @@ describe('Activity utils :: filterByAddressAndNetwork', () => {
expect(result).toEqual(false);
});
- it('should return false if the transaction does not meet the token condition for transfers', () => {
+ it('returns true for outgoing transfer even when token is not in list', () => {
const chainId = '0x1';
const transaction = {
chainId,
@@ -296,6 +296,32 @@ describe('Activity utils :: filterByAddressAndNetwork', () => {
// Empty tokens array so matching token is not found.
const tokens = [] as Token[];
+ const result = filterByAddressAndNetwork(
+ transaction,
+ tokens,
+ TEST_ADDRESS_ONE,
+ { '0x1': true },
+ );
+ expect(result).toEqual(true);
+ });
+
+ it('returns false for incoming transfer when token is not in list', () => {
+ const chainId = '0x1';
+ const transaction = {
+ chainId,
+ status: TX_SUBMITTED,
+ txParams: {
+ from: TEST_ADDRESS_TWO,
+ to: TEST_ADDRESS_ONE,
+ },
+ isTransfer: true,
+ transferInformation: {
+ contractAddress: TEST_ADDRESS_THREE,
+ },
+ } as DeepPartial as TransactionMeta;
+ // Empty tokens array so matching token is not found.
+ const tokens = [] as Token[];
+
const result = filterByAddressAndNetwork(
transaction,
tokens,
@@ -625,7 +651,7 @@ describe('Activity utils :: filterByAddress', () => {
expect(result).toEqual(false);
});
- it('returns false for transfer when token is not in list', () => {
+ it('returns true for outgoing transfer even when token is not in list', () => {
const transaction = {
status: TX_SUBMITTED,
txParams: {
@@ -640,6 +666,25 @@ describe('Activity utils :: filterByAddress', () => {
const tokens = [] as Token[];
+ const result = filterByAddress(transaction, tokens, TEST_ADDRESS_ONE);
+ expect(result).toEqual(true);
+ });
+
+ it('returns false for incoming transfer when token is not in list', () => {
+ const transaction = {
+ status: TX_SUBMITTED,
+ txParams: {
+ from: TEST_ADDRESS_TWO,
+ to: TEST_ADDRESS_ONE,
+ },
+ isTransfer: true,
+ transferInformation: {
+ contractAddress: TEST_ADDRESS_THREE,
+ },
+ } as DeepPartial as TransactionMeta;
+
+ const tokens = [] as Token[];
+
const result = filterByAddress(transaction, tokens, TEST_ADDRESS_ONE);
expect(result).toEqual(false);
});
diff --git a/app/util/activity/index.ts b/app/util/activity/index.ts
index cfb7271fe11..c28291be12c 100644
--- a/app/util/activity/index.ts
+++ b/app/util/activity/index.ts
@@ -116,14 +116,16 @@ export const filterByAddressAndNetwork = (
condition &&
tx.status !== TX_UNAPPROVED
) {
- return isTransfer
+ const result = isTransfer
? !!tokens.find(({ address }) =>
areAddressesEqual(
address,
transferInformation?.contractAddress ?? '',
),
- )
+ ) || areAddressesEqual(from, selectedAddress) // Allow if sender is current address
: true;
+
+ return result;
}
return false;
@@ -150,14 +152,16 @@ export const filterByAddress = (
isFromOrToSelectedAddress(from, to ?? '', selectedAddress) &&
tx.status !== TX_UNAPPROVED
) {
- return isTransfer
+ const result = isTransfer
? !!tokens.find(({ address }) =>
areAddressesEqual(
address,
transferInformation?.contractAddress ?? '',
),
- )
+ ) || areAddressesEqual(from, selectedAddress) // Allow if sender is current address
: true;
+
+ return result;
}
return false;
diff --git a/app/util/transactions/index.js b/app/util/transactions/index.js
index 58d200ac8df..139297e5a4a 100644
--- a/app/util/transactions/index.js
+++ b/app/util/transactions/index.js
@@ -76,6 +76,7 @@ export const TOKEN_METHOD_TRANSFER = 'transfer';
export const TOKEN_METHOD_APPROVE = 'approve';
export const TOKEN_METHOD_TRANSFER_FROM = 'transferfrom';
export const TOKEN_METHOD_INCREASE_ALLOWANCE = 'increaseAllowance';
+export const TOKEN_METHOD_MINT = 'mint';
export const CONTRACT_METHOD_DEPLOY = 'deploy';
export const CONNEXT_METHOD_DEPOSIT = 'connextdeposit';
export const TOKEN_METHOD_SET_APPROVAL_FOR_ALL = 'setapprovalforall';
@@ -102,6 +103,12 @@ export const CONTRACT_CREATION_SIGNATURE = '0x60a060405260046060527f48302e31';
export const INCREASE_ALLOWANCE_SIGNATURE = '0x39509351';
export const SET_APPROVAL_FOR_ALL_SIGNATURE = '0xa22cb465';
+// Common NFT method signatures
+export const SAFE_MINT_SIGNATURE = '0x40c10f19'; // safeMint(address,uint256)
+export const MINT_SIGNATURE = '0xa0712d68'; // mint(uint256)
+export const MINT_TO_SIGNATURE = '0x3b4b1381'; // mintTo(address) - common in many NFT contracts
+export const SAFE_MINT_WITH_DATA = '0x8832e6e3'; // safeMint(address,uint256,bytes)
+
export const TRANSACTION_TYPES = {
APPROVE: 'transaction_approve',
INCREASE_ALLOWANCE: 'transaction_increase_allowance',
@@ -191,6 +198,7 @@ const actionKeys = {
[DOWNGRADE_SMART_ACCOUNT_ACTION_KEY]: strings(
'transactions.smart_account_downgrade',
),
+ [TOKEN_METHOD_MINT]: strings('transactions.mint'),
[TransactionType.stakingClaim]: strings(
'transactions.tx_review_staking_claim',
),
@@ -450,6 +458,15 @@ export async function getMethodData(data, networkClientId) {
) {
return { name: CONTRACT_METHOD_DEPLOY };
}
+ // Common NFT mint methods
+ else if (
+ fourByteSignature === normalizeHex(SAFE_MINT_SIGNATURE) ||
+ fourByteSignature === normalizeHex(MINT_SIGNATURE) ||
+ fourByteSignature === normalizeHex(MINT_TO_SIGNATURE) ||
+ fourByteSignature === normalizeHex(SAFE_MINT_WITH_DATA)
+ ) {
+ return { name: TOKEN_METHOD_MINT };
+ }
// If it's a new method, use on-chain method registry
try {
@@ -556,6 +573,22 @@ export async function getTransactionActionKey(transaction, chainId) {
return type;
}
+ // Handle deployContract type explicitly
+ if (type === TransactionType.deployContract) {
+ return CONTRACT_METHOD_DEPLOY;
+ }
+
+ // Handle NFT/collectible transfers - ERC721 and ERC1155
+ // tokenMethodTransferFrom is used for ERC721
+ // tokenMethodSafeTransferFrom is used for ERC1155
+ if (
+ type === TransactionType.tokenMethodTransferFrom ||
+ type === TransactionType.tokenMethodSafeTransferFrom ||
+ type === 'transferfrom' // Legacy/fallback check
+ ) {
+ return TRANSFER_FROM_ACTION_KEY;
+ }
+
if (hasTransactionType(transaction, [TransactionType.predictDeposit])) {
return TransactionType.predictDeposit;
}
@@ -592,6 +625,10 @@ export async function getTransactionActionKey(transaction, chainId) {
if (name) return name;
}
+ if (type === TransactionType.contractInteraction) {
+ return SMART_CONTRACT_INTERACTION_ACTION_KEY;
+ }
+
const toSmartContract =
transaction.toSmartContract !== undefined
? transaction.toSmartContract
@@ -651,6 +688,55 @@ export function isTransactionIncomplete(status) {
export async function getActionKey(tx, selectedAddress, ticker, chainId) {
const actionKey = await getTransactionActionKey(tx, chainId);
+ // Handle transferFrom - need to distinguish between NFT and ERC20
+ // Both return 'transferfrom' but have different transaction types
+ if (actionKey === TRANSFER_FROM_ACTION_KEY) {
+ const fromAddress = safeToChecksumAddress(tx.txParams.from)?.toLowerCase();
+ const selectedAddr = selectedAddress?.toLowerCase();
+ const sentByUser = fromAddress === selectedAddr;
+
+ // Check if it's an NFT/collectible transfer (ERC721/ERC1155)
+ const isNFTTransfer =
+ tx.type === TransactionType.tokenMethodTransferFrom ||
+ tx.type === TransactionType.tokenMethodSafeTransferFrom;
+
+ if (isNFTTransfer) {
+ // NFT transfers - show collectible messages
+ if (sentByUser) {
+ return strings('transactions.sent_collectible');
+ }
+ return strings('transactions.received_collectible');
+ }
+
+ // ERC20 transferFrom - decode actual recipient from transaction data
+ // tx.txParams.to is the token contract, not the recipient
+ let toAddress;
+ try {
+ // transferFrom has 3 parameters (from, to, amount): 0x + 8 (sig) + 64*3 (params) = 202 chars
+ if (tx.txParams.data && tx.txParams.data.length >= 202) {
+ // Decode recipient from transferFrom(from, to, amount) calldata
+ const [, decodedToAddress] = decodeTransferData(
+ 'transferFrom',
+ tx.txParams.data,
+ );
+ toAddress = decodedToAddress?.toLowerCase();
+ }
+ } catch (error) {
+ // If decoding fails, fall back to transferInformation if available
+ if (tx.transferInformation?.recipient) {
+ toAddress = tx.transferInformation.recipient?.toLowerCase();
+ }
+ }
+
+ // Determine direction based on whether user is the recipient
+ const isRecipient = toAddress && toAddress === selectedAddr;
+
+ if (isRecipient) {
+ return strings('transactions.received_tokens');
+ }
+ return strings('transactions.sent_tokens');
+ }
+
// Handle token transfers with direction logic (similar to ETH transfers)
if (actionKey === SEND_TOKEN_ACTION_KEY) {
const fromAddress = safeToChecksumAddress(tx.txParams.from)?.toLowerCase();
diff --git a/app/util/transactions/index.test.ts b/app/util/transactions/index.test.ts
index 04c0bfeb6e3..ae6b293c4fe 100644
--- a/app/util/transactions/index.test.ts
+++ b/app/util/transactions/index.test.ts
@@ -22,6 +22,12 @@ import {
TOKEN_METHOD_TRANSFER,
CONTRACT_METHOD_DEPLOY,
TOKEN_METHOD_TRANSFER_FROM,
+ TOKEN_METHOD_MINT,
+ TRANSFER_FROM_ACTION_KEY,
+ SAFE_MINT_SIGNATURE,
+ MINT_SIGNATURE,
+ MINT_TO_SIGNATURE,
+ SAFE_MINT_WITH_DATA,
calculateEIP1559Times,
parseTransactionLegacy,
getIsNativeTokenTransferred,
@@ -515,6 +521,41 @@ describe('Transactions utils :: getMethodData', () => {
);
});
+ it('returns mint for safeMint signature', async () => {
+ const safeMintData = `${SAFE_MINT_SIGNATURE}0000000000000000000000000000000000000000000000000000000000000001`;
+
+ const result = await getMethodData(safeMintData, MOCK_NETWORK_CLIENT_ID);
+
+ expect(result.name).toEqual(TOKEN_METHOD_MINT);
+ });
+
+ it('returns mint for mint signature', async () => {
+ const mintData = `${MINT_SIGNATURE}0000000000000000000000000000000000000000000000000000000000000001`;
+
+ const result = await getMethodData(mintData, MOCK_NETWORK_CLIENT_ID);
+
+ expect(result.name).toEqual(TOKEN_METHOD_MINT);
+ });
+
+ it('returns mint for mintTo signature', async () => {
+ const mintToData = `${MINT_TO_SIGNATURE}000000000000000000000000abcdef1234567890abcdef1234567890abcdef12`;
+
+ const result = await getMethodData(mintToData, MOCK_NETWORK_CLIENT_ID);
+
+ expect(result.name).toEqual(TOKEN_METHOD_MINT);
+ });
+
+ it('returns mint for safeMintWithData signature', async () => {
+ const safeMintWithDataData = `${SAFE_MINT_WITH_DATA}0000000000000000000000000000000000000000000000000000000000000001`;
+
+ const result = await getMethodData(
+ safeMintWithDataData,
+ MOCK_NETWORK_CLIENT_ID,
+ );
+
+ expect(result.name).toEqual(TOKEN_METHOD_MINT);
+ });
+
it('calls handleMethodData with the correct data', async () => {
(handleMethodData as jest.Mock).mockResolvedValue({
parsedRegistryMethod: { name: TOKEN_METHOD_TRANSFER },
@@ -846,6 +887,143 @@ describe('Transactions utils :: getActionKey', () => {
expect(result).toBe(strings('transactions.sent_ether'));
});
+
+ it('returns "Sent Collectible" for tokenMethodTransferFrom type when user is sender', async () => {
+ spyOnQueryMethod(undefined);
+ const tx = {
+ type: TransactionType.tokenMethodTransferFrom,
+ txParams: {
+ from: MOCK_ADDRESS1,
+ to: MOCK_ADDRESS2,
+ },
+ };
+
+ const result = await getActionKey(
+ tx,
+ MOCK_ADDRESS1,
+ undefined,
+ MOCK_CHAIN_ID,
+ );
+
+ expect(result).toBe(strings('transactions.sent_collectible'));
+ });
+
+ it('returns "Received Collectible" for tokenMethodTransferFrom type when user is receiver', async () => {
+ spyOnQueryMethod(undefined);
+ const tx = {
+ type: TransactionType.tokenMethodTransferFrom,
+ txParams: {
+ from: MOCK_ADDRESS2,
+ to: MOCK_ADDRESS1,
+ },
+ };
+
+ const result = await getActionKey(
+ tx,
+ MOCK_ADDRESS1,
+ undefined,
+ MOCK_CHAIN_ID,
+ );
+
+ expect(result).toBe(strings('transactions.received_collectible'));
+ });
+
+ it('returns "Sent Collectible" for tokenMethodSafeTransferFrom type when user is sender', async () => {
+ spyOnQueryMethod(undefined);
+ const tx = {
+ type: TransactionType.tokenMethodSafeTransferFrom,
+ txParams: {
+ from: MOCK_ADDRESS1,
+ to: MOCK_ADDRESS2,
+ },
+ };
+
+ const result = await getActionKey(
+ tx,
+ MOCK_ADDRESS1,
+ undefined,
+ MOCK_CHAIN_ID,
+ );
+
+ expect(result).toBe(strings('transactions.sent_collectible'));
+ });
+
+ it('returns "Received Collectible" for tokenMethodSafeTransferFrom type when user is receiver', async () => {
+ spyOnQueryMethod(undefined);
+ const tx = {
+ type: TransactionType.tokenMethodSafeTransferFrom,
+ txParams: {
+ from: MOCK_ADDRESS2,
+ to: MOCK_ADDRESS1,
+ },
+ };
+
+ const result = await getActionKey(
+ tx,
+ MOCK_ADDRESS1,
+ undefined,
+ MOCK_CHAIN_ID,
+ );
+
+ expect(result).toBe(strings('transactions.received_collectible'));
+ });
+
+ it('decodes recipient from ERC20 transferFrom transaction data', async () => {
+ spyOnQueryMethod(undefined);
+ const sender = '0x1440ec793ae50fa046b95bfeca5af475b6003f9e';
+ const recipient = '0x77648f1407986479fb1fa5cc3597084b5dbdb057';
+ const tokenContract = '0x6b175474e89094c44da98b954eedeac495271d0f';
+
+ // transferFrom(from, to, amount) calldata
+ const transferFromData =
+ '0x23b872dd' + // transferFrom signature
+ '000000000000000000000000' +
+ sender.slice(2).toLowerCase() + // from
+ '000000000000000000000000' +
+ recipient.slice(2).toLowerCase() + // to (recipient - NOT txParams.to which is the contract)
+ '0000000000000000000000000000000000000000000000000de0b6b3a7640000'; // amount
+
+ const tx = {
+ txParams: {
+ from: sender,
+ to: tokenContract, // This is the token contract, not the recipient
+ data: transferFromData,
+ },
+ };
+
+ // User is the recipient - should show received
+ const result = await getActionKey(tx, recipient, undefined, MOCK_CHAIN_ID);
+
+ expect(result).toBe(strings('transactions.received_tokens'));
+ });
+
+ it('returns sent for ERC20 transferFrom when user is sender', async () => {
+ spyOnQueryMethod(undefined);
+ const sender = '0x1440ec793ae50fa046b95bfeca5af475b6003f9e';
+ const recipient = '0x77648f1407986479fb1fa5cc3597084b5dbdb057';
+ const tokenContract = '0x6b175474e89094c44da98b954eedeac495271d0f';
+
+ const transferFromData =
+ '0x23b872dd' +
+ '000000000000000000000000' +
+ sender.slice(2).toLowerCase() +
+ '000000000000000000000000' +
+ recipient.slice(2).toLowerCase() +
+ '0000000000000000000000000000000000000000000000000de0b6b3a7640000';
+
+ const tx = {
+ txParams: {
+ from: sender,
+ to: tokenContract,
+ data: transferFromData,
+ },
+ };
+
+ // User is the sender - should show sent
+ const result = await getActionKey(tx, sender, undefined, MOCK_CHAIN_ID);
+
+ expect(result).toBe(strings('transactions.sent_tokens'));
+ });
});
describe('Transactions utils :: generateTxWithNewTokenAllowance', () => {
@@ -1445,6 +1623,71 @@ describe('Transactions utils :: getTransactionActionKey', () => {
expect(actionKey).toBe(type);
});
+
+ it('returns TRANSFER_FROM_ACTION_KEY for tokenMethodTransferFrom type', async () => {
+ const transaction = {
+ type: TransactionType.tokenMethodTransferFrom,
+ txParams: {
+ to: '0x123',
+ from: '0x456',
+ },
+ };
+
+ const actionKey = await getTransactionActionKey(transaction, '0x1');
+
+ expect(actionKey).toBe(TRANSFER_FROM_ACTION_KEY);
+ });
+
+ it('returns TRANSFER_FROM_ACTION_KEY for tokenMethodSafeTransferFrom type', async () => {
+ const transaction = {
+ type: TransactionType.tokenMethodSafeTransferFrom,
+ txParams: {
+ to: '0x123',
+ from: '0x456',
+ },
+ };
+
+ const actionKey = await getTransactionActionKey(transaction, '0x1');
+
+ expect(actionKey).toBe(TRANSFER_FROM_ACTION_KEY);
+ });
+
+ it('returns TRANSFER_FROM_ACTION_KEY for legacy transferfrom type', async () => {
+ const transaction = {
+ type: 'transferfrom',
+ txParams: {
+ to: '0x123',
+ from: '0x456',
+ },
+ };
+
+ const actionKey = await getTransactionActionKey(transaction, '0x1');
+
+ expect(actionKey).toBe(TRANSFER_FROM_ACTION_KEY);
+ });
+
+ it('returns mint for NFT mint method signatures', async () => {
+ const mintSignatures = [
+ SAFE_MINT_SIGNATURE,
+ MINT_SIGNATURE,
+ MINT_TO_SIGNATURE,
+ SAFE_MINT_WITH_DATA,
+ ];
+
+ for (const signature of mintSignatures) {
+ const transaction = {
+ txParams: {
+ to: '0x123',
+ from: '0x456',
+ data: `${signature}0000000000000000000000000000000000000000000000000000000000000001`,
+ },
+ };
+
+ const actionKey = await getTransactionActionKey(transaction, '0x1');
+
+ expect(actionKey).toBe(TOKEN_METHOD_MINT);
+ }
+ });
});
describe('Transactions utils :: getFourByteSignature', () => {
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 6139503ee14..e601473c47f 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -3520,6 +3520,7 @@
"interaction": "Interaction",
"contract_deploy": "Contract Deployment",
"to_contract": "New Contract",
+ "mint": "Mint",
"tx_details_free": "Free",
"tx_details_not_available": "Not available",
"smart_contract_interaction": "Smart contract interaction",
From 8ac94d5b153f37035a25d479fbdf2faf3a2297b6 Mon Sep 17 00:00:00 2001
From: sahar-fehri
Date: Tue, 25 Nov 2025 12:37:03 +0100
Subject: [PATCH 2/9] feat: add trending tokens search (#23036)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
PR to add search functionality in trending tokens page and pull down to
refresh logic.
## **Changelog**
CHANGELOG entry: Adds search functionality for trending and pull down to
refresh. This is still under FF.
## **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 search and pull-to-refresh to Trending Tokens, introduces a
reusable header with search used by Trending and Perps, refines network
filtering, and updates hooks/tests.
>
> - **Trending Tokens UX**:
> - Adds in‑header search to `TrendingTokensFullView` via new
`TrendingListHeader` using reusable `shared/ListHeaderWithSearch`.
> - Implements pull‑to‑refresh (`RefreshControl`) in
`TrendingTokensList` and wires refresh in full view.
> - Hides control bar when searching; filters section data client‑side;
displays skeletons appropriately.
> - **Sections/Data Flow**:
> - Extends `SECTIONS_CONFIG.tokens.useSectionData` to accept `{
searchQuery, sortBy, chainIds }`, merge search + trending results, and
expose `refetch`.
> - Updates `useTrendingRequest`: simplifies (removes cache), debounced
fetch with explicit loading; defaults to popular networks when
`chainIds` empty.
> - **Networks**:
> - Refactors `usePopularNetworks` (path changes) to filter testnets
(EVM/Bitcoin/Solana) and exclude EVM custom RPCs; sorts Ethereum and
Linea first.
> - `TrendingTokenNetworkBottomSheet`: switches to local hook path and
excludes additional networks by CAIP (incl. BTC mainnet) from picker.
> - **Reusable Header**:
> - New `shared/ListHeaderWithSearch` component; refactors
`PerpsMarketListHeader` to use it.
> - **Tests & i18n**:
> - Adds comprehensive tests for new headers, bottom sheets, list,
hooks, and full view; updates expectations to `toBeOnTheScreen`.
> - Adds `trending.cancel` string; minor style import path fixes.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
78bcccdc0005c2f02a1fd5288280d15de8f1c06e. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../PerpsMarketListHeader.tsx | 142 +---------
.../TrendingListHeader.test.tsx | 123 ++++++++
.../TrendingListHeader/TrendingListHeader.tsx | 45 +++
.../TrendingListHeader.types.ts | 47 ++++
.../components/TrendingListHeader/index.ts | 2 +
.../TrendingTokenNetworkBottomSheet.test.tsx | 28 +-
.../TrendingTokenNetworkBottomSheet.tsx | 3 +-
...endingTokenPriceChangeBottomSheet.test.tsx | 34 +--
.../TrendingTokenTimeBottomSheet.test.tsx | 22 +-
.../TrendingTokensList.test.tsx | 4 +-
.../TrendingTokensList/TrendingTokensList.tsx | 8 +-
.../hooks/usePopularNetworks/index.ts} | 40 ++-
.../usePopularNetworks.test.ts | 119 +++++++-
.../hooks/useTrendingRequest/index.ts | 154 +---------
.../useTrendingRequest.test.ts | 7 +-
.../ListHeaderWithSearch.styles.ts} | 4 +-
.../ListHeaderWithSearch.tsx | 176 ++++++++++++
.../ListHeaderWithSearch.types.ts | 61 ++++
.../UI/shared/ListHeaderWithSearch/index.ts | 2 +
.../TrendingTokensFullView.test.tsx | 264 ++++++++++--------
.../TrendingTokensFullView.tsx | 237 +++++++++-------
.../TrendingView/config/sections.config.tsx | 50 +++-
locales/languages/en.json | 1 +
23 files changed, 987 insertions(+), 586 deletions(-)
create mode 100644 app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.test.tsx
create mode 100644 app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.tsx
create mode 100644 app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.types.ts
create mode 100644 app/components/UI/Trending/components/TrendingListHeader/index.ts
rename app/components/{hooks/usePopularNetworks.ts => UI/Trending/hooks/usePopularNetworks/index.ts} (74%)
rename app/components/{hooks => UI/Trending/hooks/usePopularNetworks}/usePopularNetworks.test.ts (62%)
rename app/components/UI/{Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.styles.ts => shared/ListHeaderWithSearch/ListHeaderWithSearch.styles.ts} (89%)
create mode 100644 app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.tsx
create mode 100644 app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.types.ts
create mode 100644 app/components/UI/shared/ListHeaderWithSearch/index.ts
diff --git a/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.tsx b/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.tsx
index fd9451c3956..70cd654ef49 100644
--- a/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.tsx
@@ -1,33 +1,7 @@
-import React, { useCallback } from 'react';
-import {
- View,
- TouchableOpacity,
- Pressable,
- Keyboard,
- TextInput,
- Platform,
-} from 'react-native';
-import { useNavigation } from '@react-navigation/native';
-import { useStyles } from '../../../../../component-library/hooks';
-import {
- Box,
- BoxFlexDirection,
- BoxAlignItems,
-} from '@metamask/design-system-react-native';
-import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import Icon, {
- IconName,
- IconSize,
- IconColor,
-} from '../../../../../component-library/components/Icons/Icon';
-import Text, {
- TextVariant,
- TextColor,
-} from '../../../../../component-library/components/Texts/Text';
+import React from 'react';
import { strings } from '../../../../../../locales/i18n';
-import { useTheme } from '../../../../../util/theme';
+import ListHeaderWithSearch from '../../../shared/ListHeaderWithSearch';
import type { PerpsMarketListHeaderProps } from './PerpsMarketListHeader.types';
-import styleSheet from './PerpsMarketListHeader.styles';
/**
* PerpsMarketListHeader Component
@@ -59,109 +33,13 @@ import styleSheet from './PerpsMarketListHeader.styles';
* />
* ```
*/
-const PerpsMarketListHeader: React.FC = ({
- title,
- isSearchVisible = false,
- searchQuery = '',
- onSearchQueryChange,
- onSearchClear: _onSearchClear, // Not used - clear icon removed
- onBack,
- onSearchToggle,
- testID,
-}) => {
- const { styles } = useStyles(styleSheet, {});
- const tw = useTailwind();
- const { colors } = useTheme();
- const navigation = useNavigation();
-
- // Default back handler
- const defaultHandleBack = useCallback(() => {
- if (navigation.canGoBack()) {
- navigation.goBack();
- }
- }, [navigation]);
-
- // Use custom handler if provided, otherwise use default
- const handleBack = onBack || defaultHandleBack;
-
- return (
- Keyboard.dismiss()}
- testID={testID}
- >
- {isSearchVisible ? (
-
- {/* Search Bar - Replaces back button and title */}
-
-
-
-
- {/* Cancel Button */}
-
-
- {strings('perps.cancel')}
-
-
-
- ) : (
-
- {/* Back Button */}
-
-
-
-
- {/* Title */}
-
-
- {title || strings('perps.title')}
-
-
-
- {/* Search Toggle Button */}
-
-
-
-
-
-
- )}
-
- );
-};
+const PerpsMarketListHeader: React.FC = (props) => (
+
+);
export default PerpsMarketListHeader;
diff --git a/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.test.tsx b/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.test.tsx
new file mode 100644
index 00000000000..a7265641f71
--- /dev/null
+++ b/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.test.tsx
@@ -0,0 +1,123 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react-native';
+import TrendingListHeader from './TrendingListHeader';
+
+// Mock navigation
+const mockGoBack = jest.fn();
+const mockCanGoBack = jest.fn().mockReturnValue(true);
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({
+ goBack: mockGoBack,
+ canGoBack: mockCanGoBack,
+ }),
+}));
+
+// Mock tailwind hook
+jest.mock('@metamask/design-system-twrnc-preset', () => {
+ const tw = Object.assign((..._args: unknown[]) => ({}), {
+ style: (..._args: unknown[]) => ({}),
+ });
+
+ return {
+ useTailwind: () => tw,
+ };
+});
+describe('TrendingListHeader', () => {
+ const defaultProps = {
+ title: 'Trending Tokens',
+ isSearchVisible: false,
+ searchQuery: '',
+ onSearchQueryChange: jest.fn(),
+ onBack: jest.fn(),
+ onSearchToggle: jest.fn(),
+ testID: 'trending-list-header',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders back button and title when search is not visible', () => {
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+
+ const backButton = getByTestId('trending-list-header-back-button');
+ const searchToggle = getByTestId('trending-list-header-search-toggle');
+
+ expect(backButton).toBeOnTheScreen();
+ expect(searchToggle).toBeOnTheScreen();
+ expect(queryByTestId('trending-list-header-search-bar')).toBeNull();
+ });
+
+ it('calls onBack handler when back button is pressed', () => {
+ const onBack = jest.fn();
+ const { getByTestId } = render(
+ ,
+ );
+
+ const backButton = getByTestId('trending-list-header-back-button');
+
+ fireEvent.press(backButton);
+
+ expect(onBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('navigates back with default handler when onBack is not provided', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ const backButton = getByTestId('trending-list-header-back-button');
+
+ fireEvent.press(backButton);
+
+ expect(mockCanGoBack).toHaveBeenCalledTimes(1);
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders search bar when search is visible', () => {
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+
+ const searchBar = getByTestId('trending-list-header-search-bar');
+ const searchClose = getByTestId('trending-list-header-search-close');
+
+ expect(searchBar).toBeOnTheScreen();
+ expect(searchClose).toBeOnTheScreen();
+ expect(queryByTestId('trending-list-header-back-button')).toBeNull();
+ });
+
+ it('calls onSearchQueryChange when search text changes', () => {
+ const onSearchQueryChange = jest.fn();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const searchBar = getByTestId('trending-list-header-search-bar');
+
+ fireEvent.changeText(searchBar, 'eth');
+
+ expect(onSearchQueryChange).toHaveBeenCalledWith('eth');
+ });
+
+ it('calls onSearchToggle when search toggle button is pressed', () => {
+ const onSearchToggle = jest.fn();
+ const { getByTestId } = render(
+ ,
+ );
+
+ const searchToggle = getByTestId('trending-list-header-search-toggle');
+
+ fireEvent.press(searchToggle);
+
+ expect(onSearchToggle).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.tsx b/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.tsx
new file mode 100644
index 00000000000..e22e08d985c
--- /dev/null
+++ b/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { strings } from '../../../../../../locales/i18n';
+import ListHeaderWithSearch from '../../../shared/ListHeaderWithSearch';
+import type { TrendingListHeaderProps } from './TrendingListHeader.types';
+
+/**
+ * TrendingListHeader Component
+ *
+ * Header component for Trending Tokens List view with back button,
+ * title, and search toggle functionality
+ *
+ * Features:
+ * - Back button with default or custom navigation handler
+ * - Centered title with custom text support
+ * - Search toggle button that changes icon based on visibility
+ * - Keyboard dismiss on header press
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ *
+ * @example Custom back handler
+ * ```tsx
+ *
+ * ```
+ */
+const TrendingListHeader: React.FC = (props) => (
+
+);
+
+export default TrendingListHeader;
diff --git a/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.types.ts b/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.types.ts
new file mode 100644
index 00000000000..1c6fa29105d
--- /dev/null
+++ b/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.types.ts
@@ -0,0 +1,47 @@
+/**
+ * Props for TrendingListHeader component
+ */
+export interface TrendingListHeaderProps {
+ /**
+ * Header title text
+ * @default strings('trending.trending_tokens')
+ */
+ title?: string;
+
+ /**
+ * Whether search bar is currently visible
+ * @default false
+ */
+ isSearchVisible?: boolean;
+
+ /**
+ * Search query value (required when isSearchVisible is true)
+ */
+ searchQuery?: string;
+
+ /**
+ * Callback when search query changes
+ */
+ onSearchQueryChange?: (query: string) => void;
+
+ /**
+ * Callback when search clear button is pressed
+ */
+ onSearchClear?: () => void;
+
+ /**
+ * Callback when back button is pressed
+ * If not provided, uses default navigation.goBack()
+ */
+ onBack?: () => void;
+
+ /**
+ * Callback when search toggle button is pressed
+ */
+ onSearchToggle?: () => void;
+
+ /**
+ * Test ID for the header container
+ */
+ testID?: string;
+}
diff --git a/app/components/UI/Trending/components/TrendingListHeader/index.ts b/app/components/UI/Trending/components/TrendingListHeader/index.ts
new file mode 100644
index 00000000000..ecab283840b
--- /dev/null
+++ b/app/components/UI/Trending/components/TrendingListHeader/index.ts
@@ -0,0 +1,2 @@
+export { default as TrendingListHeader } from './TrendingListHeader';
+export type { TrendingListHeaderProps } from './TrendingListHeader.types';
diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx
index 92457a3a02e..7c3e6d69701 100644
--- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx
+++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx
@@ -38,7 +38,7 @@ const mockNetworks: ProcessedNetwork[] = [
const mockUsePopularNetworks = jest.fn(() => mockNetworks);
-jest.mock('../../../../hooks/usePopularNetworks', () => ({
+jest.mock('../../hooks/usePopularNetworks', () => ({
usePopularNetworks: () => mockUsePopularNetworks(),
}));
@@ -227,9 +227,9 @@ describe('TrendingTokenNetworkBottomSheet', () => {
false,
);
- expect(getByText('Networks')).toBeTruthy();
- expect(getByText('All networks')).toBeTruthy();
- expect(getByTestId('icon-Check')).toBeTruthy();
+ expect(getByText('Networks')).toBeOnTheScreen();
+ expect(getByText('All networks')).toBeOnTheScreen();
+ expect(getByTestId('icon-Check')).toBeOnTheScreen();
});
it('renders all network options', () => {
@@ -239,9 +239,9 @@ describe('TrendingTokenNetworkBottomSheet', () => {
false,
);
- expect(getByText('All networks')).toBeTruthy();
- expect(getByText('Ethereum Mainnet')).toBeTruthy();
- expect(getByText('Polygon')).toBeTruthy();
+ expect(getByText('All networks')).toBeOnTheScreen();
+ expect(getByText('Ethereum Mainnet')).toBeOnTheScreen();
+ expect(getByText('Polygon')).toBeOnTheScreen();
});
it('calls onNetworkSelect with null when "All networks" is pressed', () => {
@@ -347,8 +347,8 @@ describe('TrendingTokenNetworkBottomSheet', () => {
false,
);
- expect(getByText('Ethereum Mainnet')).toBeTruthy();
- expect(getByTestId('icon-Check')).toBeTruthy();
+ expect(getByText('Ethereum Mainnet')).toBeOnTheScreen();
+ expect(getByTestId('icon-Check')).toBeOnTheScreen();
});
it('displays check icon for "All networks" when selected', () => {
@@ -358,8 +358,8 @@ describe('TrendingTokenNetworkBottomSheet', () => {
false,
);
- expect(getByText('All networks')).toBeTruthy();
- expect(getByTestId('icon-Check')).toBeTruthy();
+ expect(getByText('All networks')).toBeOnTheScreen();
+ expect(getByTestId('icon-Check')).toBeOnTheScreen();
});
it('renders network avatars with correct props', () => {
@@ -370,13 +370,13 @@ describe('TrendingTokenNetworkBottomSheet', () => {
);
const ethereumAvatar = getByTestId('avatar-Ethereum Mainnet');
- expect(ethereumAvatar).toBeTruthy();
+ expect(ethereumAvatar).toBeOnTheScreen();
expect(ethereumAvatar.props['data-image-source']).toEqual({
uri: 'https://example.com/ethereum.png',
});
const polygonAvatar = getByTestId('avatar-Polygon');
- expect(polygonAvatar).toBeTruthy();
+ expect(polygonAvatar).toBeOnTheScreen();
expect(polygonAvatar.props['data-image-source']).toEqual({
uri: 'https://example.com/polygon.png',
});
@@ -389,7 +389,7 @@ describe('TrendingTokenNetworkBottomSheet', () => {
false,
);
- expect(getByTestId('icon-Global')).toBeTruthy();
+ expect(getByTestId('icon-Global')).toBeOnTheScreen();
});
it('does not render when isVisible is false', () => {
diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx
index 02e620544e0..d2cb9cece47 100644
--- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx
+++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx
@@ -19,7 +19,7 @@ import Avatar, {
import { strings } from '../../../../../../locales/i18n';
import { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace';
import { CaipChainId } from '@metamask/utils';
-import { usePopularNetworks } from '../../../../hooks/usePopularNetworks';
+import { usePopularNetworks } from '../../hooks/usePopularNetworks';
export enum NetworkOption {
AllNetworks = 'all',
@@ -32,6 +32,7 @@ const EXCLUDED_NETWORKS: CaipChainId[] = [
'eip155:11297108109', // Palm
'eip155:999', // Hyper EVM
'eip155:143', // Monad
+ 'bip122:000000000019d6689c085ae165831e93', // btc mainnet
];
export interface TrendingTokenNetworkBottomSheetProps {
diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.test.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.test.tsx
index a1aafc4cfa2..22597b83e72 100644
--- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.test.tsx
+++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.test.tsx
@@ -111,9 +111,9 @@ describe('TrendingTokenPriceChangeBottomSheet', () => {
,
);
- expect(getByText('Sort by')).toBeTruthy();
- expect(getByText('Price change')).toBeTruthy();
- expect(getByText('High to low')).toBeTruthy();
+ expect(getByText('Sort by')).toBeOnTheScreen();
+ expect(getByText('Price change')).toBeOnTheScreen();
+ expect(getByText('High to low')).toBeOnTheScreen();
});
it('renders all sort options', () => {
@@ -121,9 +121,9 @@ describe('TrendingTokenPriceChangeBottomSheet', () => {
,
);
- expect(getByText('Price change')).toBeTruthy();
- expect(getByText('Volume')).toBeTruthy();
- expect(getByText('Market cap')).toBeTruthy();
+ expect(getByText('Price change')).toBeOnTheScreen();
+ expect(getByText('Volume')).toBeOnTheScreen();
+ expect(getByText('Market cap')).toBeOnTheScreen();
});
it('renders Apply button', () => {
@@ -131,8 +131,8 @@ describe('TrendingTokenPriceChangeBottomSheet', () => {
,
);
- expect(getByTestId('apply-button')).toBeTruthy();
- expect(getByText('Apply')).toBeTruthy();
+ expect(getByTestId('apply-button')).toBeOnTheScreen();
+ expect(getByText('Apply')).toBeOnTheScreen();
});
it('displays "High to low" and down arrow for descending sort', () => {
@@ -140,7 +140,7 @@ describe('TrendingTokenPriceChangeBottomSheet', () => {
,
);
- expect(getByText('High to low')).toBeTruthy();
+ expect(getByText('High to low')).toBeOnTheScreen();
});
it('toggles sort direction when same option is pressed', () => {
@@ -149,13 +149,13 @@ describe('TrendingTokenPriceChangeBottomSheet', () => {
);
const priceChangeOption = getByText('Price change');
- expect(getByText('High to low')).toBeTruthy();
+ expect(getByText('High to low')).toBeOnTheScreen();
const parent = priceChangeOption.parent;
if (!parent) throw new Error('Parent element not found');
fireEvent.press(parent);
- expect(getByText('Low to high')).toBeTruthy();
+ expect(getByText('Low to high')).toBeOnTheScreen();
expect(queryByText('High to low')).toBeNull();
});
@@ -169,8 +169,8 @@ describe('TrendingTokenPriceChangeBottomSheet', () => {
if (!parent) throw new Error('Parent element not found');
fireEvent.press(parent);
- expect(getByText('Volume')).toBeTruthy();
- expect(getByText('High to low')).toBeTruthy();
+ expect(getByText('Volume')).toBeOnTheScreen();
+ expect(getByText('High to low')).toBeOnTheScreen();
});
it('calls onPriceChangeSelect with correct values when Apply is pressed', () => {
@@ -281,8 +281,8 @@ describe('TrendingTokenPriceChangeBottomSheet', () => {
/>,
);
- expect(getByText('Volume')).toBeTruthy();
- expect(getByText('Low to high')).toBeTruthy();
+ expect(getByText('Volume')).toBeOnTheScreen();
+ expect(getByText('Low to high')).toBeOnTheScreen();
});
it('calls onOpenBottomSheet when isVisible becomes true', () => {
@@ -309,7 +309,7 @@ describe('TrendingTokenPriceChangeBottomSheet', () => {
const parent = marketCapOption.parent;
if (!parent) throw new Error('Parent element not found');
fireEvent.press(parent);
- expect(getByText('Market cap')).toBeTruthy();
- expect(getByText('High to low')).toBeTruthy();
+ expect(getByText('Market cap')).toBeOnTheScreen();
+ expect(getByText('High to low')).toBeOnTheScreen();
});
});
diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.test.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.test.tsx
index b60bd5866df..2316f3c830c 100644
--- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.test.tsx
+++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.test.tsx
@@ -123,9 +123,9 @@ describe('TrendingTokenTimeBottomSheet', () => {
,
);
- expect(getByText('Time')).toBeTruthy();
- expect(getByText('24 hours')).toBeTruthy();
- expect(getByTestId('icon-Check')).toBeTruthy();
+ expect(getByText('Time')).toBeOnTheScreen();
+ expect(getByText('24 hours')).toBeOnTheScreen();
+ expect(getByTestId('icon-Check')).toBeOnTheScreen();
});
it('renders all time options', () => {
@@ -133,10 +133,10 @@ describe('TrendingTokenTimeBottomSheet', () => {
,
);
- expect(getByText('24 hours')).toBeTruthy();
- expect(getByText('6 hours')).toBeTruthy();
- expect(getByText('1 hour')).toBeTruthy();
- expect(getByText('5 minutes')).toBeTruthy();
+ expect(getByText('24 hours')).toBeOnTheScreen();
+ expect(getByText('6 hours')).toBeOnTheScreen();
+ expect(getByText('1 hour')).toBeOnTheScreen();
+ expect(getByText('5 minutes')).toBeOnTheScreen();
});
it('calls onTimeSelect with correct sortBy when 24 hours is pressed', () => {
@@ -274,8 +274,8 @@ describe('TrendingTokenTimeBottomSheet', () => {
,
);
- expect(getByText('24 hours')).toBeTruthy();
- expect(getByTestId('icon-Check')).toBeTruthy();
+ expect(getByText('24 hours')).toBeOnTheScreen();
+ expect(getByTestId('icon-Check')).toBeOnTheScreen();
});
it('does not render when isVisible is false', () => {
@@ -295,8 +295,8 @@ describe('TrendingTokenTimeBottomSheet', () => {
/>,
);
- expect(getByText('6 hours')).toBeTruthy();
- expect(getByTestId('icon-Check')).toBeTruthy();
+ expect(getByText('6 hours')).toBeOnTheScreen();
+ expect(getByTestId('icon-Check')).toBeOnTheScreen();
});
it('calls onOpenBottomSheet when isVisible becomes true', () => {
diff --git a/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.test.tsx b/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.test.tsx
index b8c534d296d..2f6fdaf98a1 100644
--- a/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.test.tsx
+++ b/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.test.tsx
@@ -72,7 +72,7 @@ describe('TrendingTokensList', () => {
/>,
);
- expect(getByTestId('trending-tokens-list')).toBeTruthy();
+ expect(getByTestId('trending-tokens-list')).toBeOnTheScreen();
});
it('renders multiple tokens', () => {
@@ -101,7 +101,7 @@ describe('TrendingTokensList', () => {
/>,
);
- expect(getByTestId('trending-tokens-list')).toBeTruthy();
+ expect(getByTestId('trending-tokens-list')).toBeOnTheScreen();
expect(getAllByTestId(/trending-token-row-item-/)).toHaveLength(3);
});
});
diff --git a/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx b/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx
index 4147dcac707..ae890d714da 100644
--- a/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx
+++ b/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx
@@ -1,4 +1,5 @@
import React, { useCallback } from 'react';
+import { RefreshControl } from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { TrendingAsset } from '@metamask/assets-controllers';
import TrendingTokenRowItem from '../TrendingTokenRowItem/TrendingTokenRowItem';
@@ -13,6 +14,10 @@ export interface TrendingTokensListProps {
* Selected time option to determine which price change field to display
*/
selectedTimeOption: TimeOption;
+ /**
+ * Refresh control for pull-to-refresh functionality
+ */
+ refreshControl?: React.ReactElement;
}
/**
@@ -22,7 +27,7 @@ export interface TrendingTokensListProps {
* (renderItem and keyExtractor) to avoid recreating them on every render
*/
const TrendingTokensList: React.FC = React.memo(
- ({ trendingTokens, selectedTimeOption }) => {
+ ({ trendingTokens, selectedTimeOption, refreshControl }) => {
const renderItem = useCallback(
({ item }: { item: TrendingAsset }) => (
= React.memo(
renderItem={renderItem}
keyExtractor={keyExtractor}
keyboardShouldPersistTaps="handled"
+ refreshControl={refreshControl as React.ReactElement}
testID="trending-tokens-list"
/>
);
diff --git a/app/components/hooks/usePopularNetworks.ts b/app/components/UI/Trending/hooks/usePopularNetworks/index.ts
similarity index 74%
rename from app/components/hooks/usePopularNetworks.ts
rename to app/components/UI/Trending/hooks/usePopularNetworks/index.ts
index 0a8e2c487cf..77033408252 100644
--- a/app/components/hooks/usePopularNetworks.ts
+++ b/app/components/UI/Trending/hooks/usePopularNetworks/index.ts
@@ -2,11 +2,15 @@ import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { CaipChainId, Hex, parseCaipChainId } from '@metamask/utils';
import { toEvmCaipChainId } from '@metamask/multichain-network-controller';
-import { getNetworkImageSource, isTestNet } from '../../util/networks';
-import { PopularList } from '../../util/networks/customNetworks';
import { BtcScope, SolScope } from '@metamask/keyring-api';
-import { selectNetworkConfigurationsByCaipChainId } from '../../selectors/networkController';
-import { ProcessedNetwork } from './useNetworksByNamespace/useNetworksByNamespace';
+import {
+ NetworkConfiguration,
+ RpcEndpointType,
+} from '@metamask/network-controller';
+import { getNetworkImageSource, isTestNet } from '../../../../../util/networks';
+import { selectNetworkConfigurationsByCaipChainId } from '../../../../../selectors/networkController';
+import { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace';
+import { PopularList } from '../../../../../util/networks/customNetworks';
/**
* Hook to get popular networks, combining networks from Redux state and PopularList.
@@ -20,7 +24,6 @@ export const usePopularNetworks = (): ProcessedNetwork[] => {
const networkConfigurations = useSelector(
selectNetworkConfigurationsByCaipChainId,
);
-
return useMemo(() => {
const processedNetworks: ProcessedNetwork[] = [];
const addedCaipChainIds = new Set();
@@ -35,19 +38,19 @@ export const usePopularNetworks = (): ProcessedNetwork[] => {
return isTestNet(hexChainId);
}
- // Check Bitcoin testnets
+ // Check Bitcoin testnets using full CAIP IDs from BtcScope
if (namespace === 'bip122') {
return (
- reference === BtcScope.Testnet ||
- reference === BtcScope.Testnet4 ||
- reference === BtcScope.Regtest ||
- reference === BtcScope.Signet
+ caipChainId === BtcScope.Testnet ||
+ caipChainId === BtcScope.Testnet4 ||
+ caipChainId === BtcScope.Regtest ||
+ caipChainId === BtcScope.Signet
);
}
- // Check Solana testnets
+ // Check Solana testnets using full CAIP IDs from SolScope
if (namespace === 'solana') {
- return reference === SolScope.Devnet;
+ return caipChainId === SolScope.Devnet;
}
// For other namespaces, assume mainnet if not explicitly a testnet
@@ -56,8 +59,17 @@ export const usePopularNetworks = (): ProcessedNetwork[] => {
// First, add all networks from networkConfigurations (excluding testnets)
for (const [caipChainId, config] of Object.entries(networkConfigurations)) {
- // Skip testnets using isTestnet helper
- if (isTestnetCaipChainId(caipChainId as CaipChainId)) {
+ // Skip testnets using isTestnet helper and custom networks based of rpcEndpoints[defaultRpcEndpointIndex].type
+ const isEvmCustomChain =
+ config.caipChainId.startsWith('eip155') &&
+ (config as NetworkConfiguration).rpcEndpoints?.[
+ (config as NetworkConfiguration).defaultRpcEndpointIndex
+ ]?.type === RpcEndpointType.Custom;
+
+ if (
+ isTestnetCaipChainId(caipChainId as CaipChainId) ||
+ isEvmCustomChain
+ ) {
continue;
}
diff --git a/app/components/hooks/usePopularNetworks.test.ts b/app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.test.ts
similarity index 62%
rename from app/components/hooks/usePopularNetworks.test.ts
rename to app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.test.ts
index 5eee737dee4..f609370f645 100644
--- a/app/components/hooks/usePopularNetworks.test.ts
+++ b/app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.test.ts
@@ -1,19 +1,20 @@
import { renderHook } from '@testing-library/react-native';
import { useSelector } from 'react-redux';
import { CaipChainId } from '@metamask/utils';
-import { isTestNet } from '../../util/networks';
-import { usePopularNetworks } from './usePopularNetworks';
+import { BtcScope, SolScope } from '@metamask/keyring-api';
+import { isTestNet } from '../../../../../util/networks';
+import { usePopularNetworks } from '.';
jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
-jest.mock('../../util/networks', () => ({
+jest.mock('../../../../../util/networks', () => ({
getNetworkImageSource: jest.fn(),
isTestNet: jest.fn(),
}));
-jest.mock('../../util/networks/customNetworks', () => ({
+jest.mock('../../../../../util/networks/customNetworks', () => ({
PopularList: [
{
chainId: '0xa86a',
@@ -140,6 +141,116 @@ describe('usePopularNetworks', () => {
expect(result.current.some((n) => n.name === 'Arbitrum')).toBe(true);
expect(result.current.some((n) => n.name === 'BNB Chain')).toBe(true);
});
+
+ it('filters out Bitcoin testnets from networkConfigurations', () => {
+ const mockNetworkConfigurations = {
+ // Bitcoin mainnet example
+ [BtcScope.Mainnet]: {
+ caipChainId: BtcScope.Mainnet as CaipChainId,
+ name: 'Bitcoin',
+ },
+ // Bitcoin testnet variants using full CAIP IDs from BtcScope
+ [BtcScope.Testnet]: {
+ caipChainId: BtcScope.Testnet as CaipChainId,
+ name: 'Bitcoin Testnet',
+ },
+ [BtcScope.Testnet4]: {
+ caipChainId: BtcScope.Testnet4 as CaipChainId,
+ name: 'Bitcoin Testnet4',
+ },
+ [BtcScope.Regtest]: {
+ caipChainId: BtcScope.Regtest as CaipChainId,
+ name: 'Bitcoin Regtest',
+ },
+ [BtcScope.Signet]: {
+ caipChainId: BtcScope.Signet as CaipChainId,
+ name: 'Bitcoin Signet',
+ },
+ };
+
+ mockUseSelector.mockReturnValue(mockNetworkConfigurations);
+
+ const { result } = renderHook(() => usePopularNetworks());
+
+ expect(result.current.some((n) => n.name === 'Bitcoin')).toBe(true);
+ expect(result.current.some((n) => n.name === 'Bitcoin Testnet')).toBe(
+ false,
+ );
+ expect(result.current.some((n) => n.name === 'Bitcoin Testnet4')).toBe(
+ false,
+ );
+ expect(result.current.some((n) => n.name === 'Bitcoin Regtest')).toBe(
+ false,
+ );
+ expect(result.current.some((n) => n.name === 'Bitcoin Signet')).toBe(
+ false,
+ );
+ });
+
+ it('filters out Solana Devnet from networkConfigurations', () => {
+ const mockNetworkConfigurations = {
+ [SolScope.Mainnet]: {
+ caipChainId: SolScope.Mainnet as CaipChainId,
+ name: 'Solana Mainnet',
+ },
+ [SolScope.Devnet]: {
+ caipChainId: SolScope.Devnet as CaipChainId,
+ name: 'Solana Devnet',
+ },
+ };
+
+ mockUseSelector.mockReturnValue(mockNetworkConfigurations);
+
+ const { result } = renderHook(() => usePopularNetworks());
+
+ expect(result.current.some((n) => n.name === 'Solana Mainnet')).toBe(
+ true,
+ );
+ expect(result.current.some((n) => n.name === 'Solana Devnet')).toBe(
+ false,
+ );
+ });
+ });
+
+ describe('custom network filtering', () => {
+ it('filters EVM custom networks from networkConfigurations', () => {
+ const mockNetworkConfigurations = {
+ 'eip155:1': {
+ caipChainId: 'eip155:1' as CaipChainId,
+ name: 'Ethereum Mainnet',
+ },
+ 'eip155:81457': {
+ caipChainId: 'eip155:81457' as CaipChainId,
+ chainId: '0x13e31',
+ name: 'blast',
+ rpcEndpoints: [
+ {
+ url: 'https://blast-rpc.publicnode.com',
+ name: '',
+ // Match RpcEndpointType.Custom value used in the hook
+ type: 'custom',
+ networkClientId: '0c8dd6d9-a167-4656-9057-b5daf33dbbde',
+ },
+ ],
+ nativeCurrency: 'ETH',
+ defaultRpcEndpointIndex: 0,
+ lastUpdatedAt: 1763644775633,
+ },
+ };
+
+ mockUseSelector.mockReturnValue(mockNetworkConfigurations);
+
+ const { result } = renderHook(() => usePopularNetworks());
+
+ expect(
+ result.current.some(
+ (network) => network.caipChainId === 'eip155:81457',
+ ),
+ ).toBe(false);
+ expect(
+ result.current.some((network) => network.caipChainId === 'eip155:1'),
+ ).toBe(true);
+ });
});
describe('sorting', () => {
diff --git a/app/components/UI/Trending/hooks/useTrendingRequest/index.ts b/app/components/UI/Trending/hooks/useTrendingRequest/index.ts
index 18345c6a95f..da61d8ed85a 100644
--- a/app/components/UI/Trending/hooks/useTrendingRequest/index.ts
+++ b/app/components/UI/Trending/hooks/useTrendingRequest/index.ts
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useEffect, useState, useRef } from 'react';
import { debounce } from 'lodash';
-import { CaipChainId, parseCaipChainId } from '@metamask/utils';
+import type { CaipChainId } from '@metamask/utils';
import {
getTrendingTokens,
SortTrendingBy,
@@ -15,108 +15,6 @@ import { useNetworksToUse } from '../../../../hooks/useNetworksToUse/useNetworks
export const DEBOUNCE_WAIT = 500;
-/**
- * Performance Optimization: Simple cache with TTL (30 seconds)
- *
- * The key optimization that makes navigation snappy is using lazy initialization
- * in useState to check the cache synchronously during component mount. This allows:
- * 1. Immediate render with cached data (no async state updates blocking navigation)
- * 2. Avoids unnecessary API calls when navigating back and forth
- * 3. Component renders instantly if cache exists, fetch happens in background if needed
- *
- * Without this pattern, async state updates in useEffect would block navigation,
- * causing the "view all" button to require multiple clicks and feel laggy.
- */
-const CACHE_DURATION_MS = 30 * 1000;
-const cache = new Map<
- string,
- { data: Awaited>; timestamp: number }
->();
-
-/**
- * Compare function for CAIP chain IDs to ensure consistent sorting
- * First compares by namespace (alphabetically), then by reference
- * (numerically if both are numbers, otherwise alphabetically)
- */
-const compareCaipChainIds = (a: CaipChainId, b: CaipChainId): number => {
- try {
- const { namespace: namespaceA, reference: refA } = parseCaipChainId(a);
- const { namespace: namespaceB, reference: refB } = parseCaipChainId(b);
-
- // First compare namespaces
- if (namespaceA !== namespaceB) {
- return namespaceA.localeCompare(namespaceB);
- }
-
- // Then compare references - try numeric comparison first
- const numA = Number(refA);
- const numB = Number(refB);
- if (!isNaN(numA) && !isNaN(numB)) {
- return numA - numB;
- }
-
- // Fallback to alphabetical comparison for non-numeric references
- return refA.localeCompare(refB);
- } catch {
- // If parsing fails, fall back to string comparison
- return a.localeCompare(b);
- }
-};
-
-// Generate cache key from options
-const getCacheKey = (options: {
- chainIds: CaipChainId[];
- sortBy?: SortTrendingBy;
- minLiquidity?: number;
- minVolume24hUsd?: number;
- maxVolume24hUsd?: number;
- minMarketCap?: number;
- maxMarketCap?: number;
-}): string => {
- // Sort chain IDs using compare function to ensure consistent cache keys
- // regardless of input order
- const sortedChainIds = [...options.chainIds].sort(compareCaipChainIds);
- return JSON.stringify({
- chainIds: sortedChainIds,
- sortBy: options.sortBy,
- minLiquidity: options.minLiquidity,
- minVolume24hUsd: options.minVolume24hUsd,
- maxVolume24hUsd: options.maxVolume24hUsd,
- minMarketCap: options.minMarketCap,
- maxMarketCap: options.maxMarketCap,
- });
-};
-
-// Check if cache entry is valid
-const isCacheValid = (
- entry:
- | { data: Awaited>; timestamp: number }
- | undefined,
-): boolean => {
- if (!entry) return false;
- return Date.now() - entry.timestamp < CACHE_DURATION_MS;
-};
-
-/**
- * Simple cleanup: Remove expired entries from cache
- * Only called when storing new entries (non-blocking, doesn't affect navigation)
- */
-const cleanupExpiredEntries = (): void => {
- const now = Date.now();
- for (const [key, entry] of cache.entries()) {
- if (now - entry.timestamp >= CACHE_DURATION_MS) {
- cache.delete(key);
- }
- }
-};
-
-/**
- * Clear all cache entries - useful for testing
- */
-export const clearCache = (): void => {
- cache.clear();
-};
-
/**
* Hook for handling trending tokens request
* @returns {Object} An object containing the trending tokens results, loading state, error, and a function to trigger fetch
@@ -188,37 +86,11 @@ export const useTrendingRequest = (options: {
],
);
- /**
- * Performance Optimization: Lazy initialization in useState
- *
- * This is the critical fix that makes navigation snappy. By checking the cache
- * synchronously in the useState initializer function, we can:
- * - Render immediately with cached data (no loading state delay)
- * - Avoid blocking navigation with async state updates
- * - Ensure the component is ready to render as soon as it mounts
- *
- * If we used useEffect to check cache, it would run after render, causing:
- * - Initial render with loading state
- * - Async state update that could block navigation
- * - Multiple clicks needed on "view all" button
- */
const [results, setResults] = useState
- > | null>(() => {
- if (!stableChainIds.length) return null;
- const cacheKey = getCacheKey(memoizedOptions);
- const cached = cache.get(cacheKey);
- if (cached && isCacheValid(cached)) {
- return cached.data;
- }
- return null;
- });
+ > | null>(null);
- const [isLoading, setIsLoading] = useState(() => {
- if (!stableChainIds.length) return false;
- const cacheKey = getCacheKey(memoizedOptions);
- return !isCacheValid(cache.get(cacheKey));
- });
+ const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
@@ -230,16 +102,6 @@ export const useTrendingRequest = (options: {
return;
}
- // Check cache first
- const cacheKey = getCacheKey(memoizedOptions);
- const cached = cache.get(cacheKey);
- if (cached && isCacheValid(cached)) {
- setResults(cached.data);
- setIsLoading(false);
- setError(null);
- return;
- }
-
// Increment request ID to mark this as the current request
const currentRequestId = ++requestIdRef.current;
setIsLoading(true);
@@ -258,13 +120,6 @@ export const useTrendingRequest = (options: {
// Only update state if this is still the current request
if (currentRequestId === requestIdRef.current) {
setResults(resultsToStore);
- // Store in cache and cleanup expired entries (non-blocking)
- cache.set(cacheKey, {
- data: resultsToStore,
- timestamp: Date.now(),
- });
- // Cleanup expired entries when storing new data (doesn't block navigation)
- cleanupExpiredEntries();
}
} catch (err) {
// Only update state if this is still the current request
@@ -298,6 +153,9 @@ export const useTrendingRequest = (options: {
return;
}
+ // Immediately show loading state so UI can render skeleton right away
+ setIsLoading(true);
+
// Fetch new data
debouncedFetchTrendingTokens();
diff --git a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts
index 39d242c53a7..1a0974a43aa 100644
--- a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts
+++ b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts
@@ -1,4 +1,4 @@
-import { DEBOUNCE_WAIT, useTrendingRequest, clearCache } from '.';
+import { DEBOUNCE_WAIT, useTrendingRequest } from '.';
import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider';
import { act } from '@testing-library/react-native';
// eslint-disable-next-line import/no-namespace
@@ -53,8 +53,6 @@ describe('useTrendingRequest', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
- // Clear cache between tests to ensure test isolation
- clearCache();
// Set up default mocks for network hooks
mockUseNetworksByNamespace.mockReturnValue({
networks: mockDefaultNetworks,
@@ -79,7 +77,6 @@ describe('useTrendingRequest', () => {
afterEach(() => {
jest.useRealTimers();
- clearCache();
});
it('returns an object with results, isLoading, error, and fetch function', () => {
@@ -392,8 +389,6 @@ describe('useTrendingRequest', () => {
await Promise.resolve();
});
- // Clear cache so subsequent fetch calls will actually trigger API calls
- clearCache();
spyGetTrendingTokens.mockClear();
await act(async () => {
diff --git a/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.styles.ts b/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.styles.ts
similarity index 89%
rename from app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.styles.ts
rename to app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.styles.ts
index a91800c34e4..75b53fae73f 100644
--- a/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.styles.ts
+++ b/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.styles.ts
@@ -1,8 +1,8 @@
import { StyleSheet } from 'react-native';
-import type { Theme } from '../../../../../util/theme/models';
+import type { Theme } from '../../../../util/theme/models';
/**
- * Styles for PerpsMarketListHeader component
+ * Styles for ListHeaderWithSearch component
*/
const styleSheet = (params: { theme: Theme }) => {
const { theme } = params;
diff --git a/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.tsx b/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.tsx
new file mode 100644
index 00000000000..b242d24e72e
--- /dev/null
+++ b/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.tsx
@@ -0,0 +1,176 @@
+import React, { useCallback } from 'react';
+import {
+ View,
+ TouchableOpacity,
+ Pressable,
+ Keyboard,
+ TextInput,
+ Platform,
+} from 'react-native';
+import { useNavigation } from '@react-navigation/native';
+import { useStyles } from '../../../../component-library/hooks';
+import {
+ Box,
+ BoxFlexDirection,
+ BoxAlignItems,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import Icon, {
+ IconName,
+ IconSize,
+ IconColor,
+} from '../../../../component-library/components/Icons/Icon';
+import Text, {
+ TextVariant,
+ TextColor,
+} from '../../../../component-library/components/Texts/Text';
+import { useTheme } from '../../../../util/theme';
+import type { ListHeaderWithSearchProps } from './ListHeaderWithSearch.types';
+import styleSheet from './ListHeaderWithSearch.styles';
+
+/**
+ * ListHeaderWithSearch Component
+ *
+ * Reusable header component for list views with back button,
+ * title, and search toggle functionality
+ *
+ * Features:
+ * - Back button with default or custom navigation handler
+ * - Centered title with custom text support
+ * - Search toggle button that changes icon based on visibility
+ * - Keyboard dismiss on header press
+ * - Configurable search placeholder and cancel text
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ *
+ * @example Custom back handler
+ * ```tsx
+ *
+ * ```
+ */
+const ListHeaderWithSearch: React.FC = ({
+ title,
+ defaultTitle,
+ isSearchVisible = false,
+ searchQuery = '',
+ searchPlaceholder,
+ cancelText,
+ onSearchQueryChange,
+ onSearchClear: _onSearchClear, // Not used - clear icon removed
+ onBack,
+ onSearchToggle,
+ testID,
+}) => {
+ const { styles } = useStyles(styleSheet, {});
+ const tw = useTailwind();
+ const { colors } = useTheme();
+ const navigation = useNavigation();
+
+ // Default back handler
+ const defaultHandleBack = useCallback(() => {
+ if (navigation.canGoBack()) {
+ navigation.goBack();
+ }
+ }, [navigation]);
+
+ // Use custom handler if provided, otherwise use default
+ const handleBack = onBack || defaultHandleBack;
+
+ return (
+ Keyboard.dismiss()}
+ testID={testID}
+ >
+ {isSearchVisible ? (
+
+ {/* Search Bar - Replaces back button and title */}
+
+
+
+
+ {/* Cancel Button */}
+
+
+ {cancelText}
+
+
+
+ ) : (
+
+ {/* Back Button */}
+
+
+
+
+ {/* Title */}
+
+
+ {title || defaultTitle}
+
+
+
+ {/* Search Toggle Button */}
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default ListHeaderWithSearch;
diff --git a/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.types.ts b/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.types.ts
new file mode 100644
index 00000000000..376b72584c6
--- /dev/null
+++ b/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.types.ts
@@ -0,0 +1,61 @@
+/**
+ * Props for ListHeaderWithSearch component
+ */
+export interface ListHeaderWithSearchProps {
+ /**
+ * Header title text
+ */
+ title?: string;
+
+ /**
+ * Default title to use if title prop is not provided
+ */
+ defaultTitle: string;
+
+ /**
+ * Whether search bar is currently visible
+ * @default false
+ */
+ isSearchVisible?: boolean;
+
+ /**
+ * Search query value (required when isSearchVisible is true)
+ */
+ searchQuery?: string;
+
+ /**
+ * Search placeholder text
+ */
+ searchPlaceholder: string;
+
+ /**
+ * Cancel button text
+ */
+ cancelText: string;
+
+ /**
+ * Callback when search query changes
+ */
+ onSearchQueryChange?: (query: string) => void;
+
+ /**
+ * Callback when search clear button is pressed
+ */
+ onSearchClear?: () => void;
+
+ /**
+ * Callback when back button is pressed
+ * If not provided, uses default navigation.goBack()
+ */
+ onBack?: () => void;
+
+ /**
+ * Callback when search toggle button is pressed
+ */
+ onSearchToggle?: () => void;
+
+ /**
+ * Test ID for the header container
+ */
+ testID?: string;
+}
diff --git a/app/components/UI/shared/ListHeaderWithSearch/index.ts b/app/components/UI/shared/ListHeaderWithSearch/index.ts
new file mode 100644
index 00000000000..59a9bc70bbf
--- /dev/null
+++ b/app/components/UI/shared/ListHeaderWithSearch/index.ts
@@ -0,0 +1,2 @@
+export { default } from './ListHeaderWithSearch';
+export type { ListHeaderWithSearchProps } from './ListHeaderWithSearch.types';
diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx
index d494b136775..008b80dec83 100644
--- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx
+++ b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx
@@ -15,11 +15,32 @@ jest.mock('@react-navigation/native', () => ({
createNavigatorFactory: () => ({}),
}));
-const mockUseTrendingRequest = jest.fn();
+const mockFetchTrendingTokens = jest.fn();
+const mockUseTrendingRequest = jest.fn().mockReturnValue({
+ results: [],
+ isLoading: false,
+ error: null,
+ fetch: mockFetchTrendingTokens,
+});
jest.mock('../../../UI/Trending/hooks/useTrendingRequest', () => ({
useTrendingRequest: (options: unknown) => mockUseTrendingRequest(options),
}));
+const mockUseSectionData = jest.fn();
+
+// Mock sections.config to avoid complex Perps dependencies
+// Make useSectionData return the same data as useTrendingRequest
+jest.mock('../../TrendingView/config/sections.config', () => ({
+ SECTIONS_CONFIG: {
+ tokens: {
+ useSectionData: (params?: { searchQuery?: string }) =>
+ mockUseSectionData(params),
+ getSearchableText: (item: { name?: string; symbol?: string }) =>
+ `${item.name || ''} ${item.symbol || ''}`.toLowerCase(),
+ },
+ },
+}));
+
jest.mock(
'../../../UI/Trending/components/TrendingTokensList/TrendingTokensList',
() => {
@@ -27,11 +48,12 @@ jest.mock(
return ({
trendingTokens,
onTokenPress,
+ ...rest
}: {
trendingTokens: TrendingAsset[];
onTokenPress: (token: TrendingAsset) => void;
}) => (
-
+
{trendingTokens.map((token, index) => (
{
};
});
-jest.mock('../../../../component-library/components/HeaderBase', () => {
- const { View } = jest.requireActual('react-native');
- const MockHeaderBase = ({
- children,
- startAccessory,
- endAccessory,
- }: {
- children: React.ReactNode;
- startAccessory?: React.ReactNode;
- endAccessory?: React.ReactNode;
- }) => (
-
- {startAccessory}
- {children}
- {endAccessory}
-
- );
- return {
- __esModule: true,
- default: MockHeaderBase,
- HeaderBaseVariant: {
- Display: 'display',
- Compact: 'compact',
- },
- };
-});
-
-jest.mock('../../../../component-library/components/Buttons/ButtonIcon', () => {
- const { TouchableOpacity } = jest.requireActual('react-native');
- const MockButtonIcon = ({
- onPress,
- testID,
- }: {
- onPress?: () => void;
- testID?: string;
- }) => (
-
- ButtonIcon
-
- );
- return {
- __esModule: true,
- default: MockButtonIcon,
- ButtonIconSizes: {
- Sm: '24',
- Md: '28',
- Lg: '32',
- },
- };
-});
-
-jest.mock('../../../../component-library/components/Texts/Text', () => {
- const { Text } = jest.requireActual('react-native');
- return {
- __esModule: true,
- default: Text,
- TextVariant: {
- HeadingMD: 'HeadingMD',
- },
- TextColor: {
- Default: 'Default',
- },
- };
-});
-
-jest.mock('../../../../component-library/components/Icons/Icon', () => {
- const { View } = jest.requireActual('react-native');
- return {
- __esModule: true,
- default: function MockIcon({ name }: { name: string }) {
- return {name};
- },
- IconName: {
- ArrowLeft: 'ArrowLeft',
- Search: 'Search',
- ArrowDown: 'ArrowDown',
- },
- IconColor: {
- Alternative: 'Alternative',
- },
- IconSize: {
- Xs: 'Xs',
- },
- };
-});
-
-jest.mock('../../../../../locales/i18n', () => ({
- strings: (key: string) => {
- const translations: Record = {
- 'trending.trending_tokens': 'Trending Tokens',
- 'trending.price_change': 'Price change',
- 'trending.all_networks': 'All networks',
- 'trending.24h': '24h',
- };
- return translations[key] || key;
- },
-}));
-
const createMockToken = (
overrides: Partial = {},
): TrendingAsset => ({
@@ -292,6 +216,11 @@ describe('TrendingTokensFullView', () => {
error: null,
fetch: jest.fn(),
});
+ mockUseSectionData.mockReturnValue({
+ data: [],
+ isLoading: false,
+ refetch: jest.fn(),
+ });
});
it('renders header with title and buttons', () => {
@@ -301,9 +230,8 @@ describe('TrendingTokensFullView', () => {
false, // Exclude NavigationContainer since we're mocking navigation
);
- expect(getByText('Trending Tokens')).toBeTruthy();
- expect(getByTestId('back-button')).toBeTruthy();
- expect(getByTestId('search-button')).toBeTruthy();
+ expect(getByText('Trending Tokens')).toBeOnTheScreen();
+ expect(getByTestId('trending-tokens-header-back-button')).toBeOnTheScreen();
});
it('renders control buttons', () => {
@@ -313,12 +241,12 @@ describe('TrendingTokensFullView', () => {
false,
);
- expect(getByTestId('price-change-button')).toBeTruthy();
- expect(getByTestId('all-networks-button')).toBeTruthy();
- expect(getByTestId('24h-button')).toBeTruthy();
- expect(getByText('Price change')).toBeTruthy();
- expect(getByText('All networks')).toBeTruthy();
- expect(getByText('24h')).toBeTruthy();
+ expect(getByTestId('price-change-button')).toBeOnTheScreen();
+ expect(getByTestId('all-networks-button')).toBeOnTheScreen();
+ expect(getByTestId('24h-button')).toBeOnTheScreen();
+ expect(getByText('Price change')).toBeOnTheScreen();
+ expect(getByText('All networks')).toBeOnTheScreen();
+ expect(getByText('24h')).toBeOnTheScreen();
});
it('navigates back when back button is pressed', () => {
@@ -328,7 +256,7 @@ describe('TrendingTokensFullView', () => {
false,
);
- const backButton = getByTestId('back-button');
+ const backButton = getByTestId('trending-tokens-header-back-button');
fireEvent.press(backButton);
expect(mockGoBack).toHaveBeenCalled();
@@ -344,7 +272,7 @@ describe('TrendingTokensFullView', () => {
const button24h = getByTestId('24h-button');
fireEvent.press(button24h);
- expect(getByTestId('trending-token-time-bottom-sheet')).toBeTruthy();
+ expect(getByTestId('trending-token-time-bottom-sheet')).toBeOnTheScreen();
});
it('opens network bottom sheet when all networks button is pressed', () => {
@@ -357,7 +285,9 @@ describe('TrendingTokensFullView', () => {
const allNetworksButton = getByTestId('all-networks-button');
fireEvent.press(allNetworksButton);
- expect(getByTestId('trending-token-network-bottom-sheet')).toBeTruthy();
+ expect(
+ getByTestId('trending-token-network-bottom-sheet'),
+ ).toBeOnTheScreen();
});
it('opens price change bottom sheet when price change button is pressed', () => {
@@ -389,7 +319,7 @@ describe('TrendingTokensFullView', () => {
false,
);
- expect(getByTestId('trending-tokens-skeleton')).toBeTruthy();
+ expect(getByTestId('trending-tokens-skeleton')).toBeOnTheScreen();
});
it('displays skeleton loader when results are empty', () => {
@@ -406,7 +336,7 @@ describe('TrendingTokensFullView', () => {
false,
);
- expect(getByTestId('trending-tokens-skeleton')).toBeTruthy();
+ expect(getByTestId('trending-tokens-skeleton')).toBeOnTheScreen();
});
it('displays trending tokens list when data is loaded', () => {
@@ -422,23 +352,30 @@ describe('TrendingTokensFullView', () => {
fetch: jest.fn(),
});
+ mockUseSectionData.mockReturnValue({
+ data: mockTokens,
+ isLoading: false,
+ refetch: jest.fn(),
+ });
+
const { getByTestId, getByText } = renderWithProvider(
,
{ state: mockState },
false,
);
- expect(getByTestId('trending-tokens-list')).toBeTruthy();
- expect(getByText('Token 1')).toBeTruthy();
- expect(getByText('Token 2')).toBeTruthy();
+ expect(getByTestId('trending-tokens-list')).toBeOnTheScreen();
+ expect(getByText('Token 1')).toBeOnTheScreen();
+ expect(getByText('Token 2')).toBeOnTheScreen();
});
- it('calls useTrendingRequest with correct initial parameters', () => {
+ it('calls useSectionData with correct initial parameters', () => {
renderWithProvider(, { state: mockState }, false);
- expect(mockUseTrendingRequest).toHaveBeenCalledWith({
+ expect(mockUseSectionData).toHaveBeenCalledWith({
sortBy: undefined,
- chainIds: undefined,
+ chainIds: null,
+ searchQuery: undefined,
});
});
@@ -458,9 +395,10 @@ describe('TrendingTokensFullView', () => {
});
await waitFor(() => {
- expect(mockUseTrendingRequest).toHaveBeenLastCalledWith({
+ expect(mockUseSectionData).toHaveBeenLastCalledWith({
sortBy: 'h6_trending',
- chainIds: undefined,
+ chainIds: null,
+ searchQuery: undefined,
});
});
});
@@ -481,10 +419,90 @@ describe('TrendingTokensFullView', () => {
});
await waitFor(() => {
- expect(mockUseTrendingRequest).toHaveBeenLastCalledWith({
+ expect(mockUseSectionData).toHaveBeenLastCalledWith({
sortBy: undefined,
chainIds: ['eip155:1'],
+ searchQuery: undefined,
});
});
});
+
+ it('updates price change filter when option is selected', async () => {
+ const mockTokens = [
+ createMockToken({ name: 'Token 1', assetId: 'eip155:1/erc20:0x123' }),
+ createMockToken({ name: 'Token 2', assetId: 'eip155:1/erc20:0x456' }),
+ ];
+
+ mockUseTrendingRequest.mockReturnValue({
+ results: mockTokens,
+ isLoading: false,
+ error: null,
+ fetch: jest.fn(),
+ });
+
+ mockUseSectionData.mockReturnValue({
+ data: mockTokens,
+ isLoading: false,
+ refetch: jest.fn(),
+ });
+
+ const { getByTestId, getByText } = renderWithProvider(
+ ,
+ { state: mockState },
+ false,
+ );
+
+ // Open price change bottom sheet
+ const priceChangeButton = getByTestId('price-change-button');
+ fireEvent.press(priceChangeButton);
+
+ // Select Volume option (which maps to PriceChangeOption.Volume and ascending sort)
+ const volumeOption = getByTestId('price-change-select-volume');
+ await act(async () => {
+ fireEvent(volumeOption, 'touchEnd');
+ });
+
+ // Price change button label should update to "Volume"
+ expect(getByText('Volume')).toBeOnTheScreen();
+ });
+
+ it('triggers section refetch on pull-to-refresh', async () => {
+ const mockTokens = [
+ createMockToken({
+ assetId: 'eip155:1/erc20:0xabc',
+ name: 'Token 1',
+ symbol: 'TKN1',
+ }),
+ ];
+
+ mockUseTrendingRequest.mockReturnValueOnce({
+ results: mockTokens,
+ isLoading: false,
+ error: null,
+ fetch: mockFetchTrendingTokens,
+ });
+
+ mockUseSectionData.mockReturnValue({
+ data: mockTokens,
+ isLoading: false,
+ refetch: mockFetchTrendingTokens,
+ });
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ { state: mockState },
+ false,
+ );
+
+ const list = getByTestId('trending-tokens-list');
+
+ // Simulate pull-to-refresh via RefreshControl's onRefresh
+ const refreshControl = list.props.refreshControl;
+
+ await act(async () => {
+ await refreshControl.props.onRefresh();
+ });
+
+ expect(mockFetchTrendingTokens).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx
index 0dd37e6eeae..81a52016284 100644
--- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx
+++ b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx
@@ -5,33 +5,32 @@ import {
SafeAreaView,
useSafeAreaInsets,
} from 'react-native-safe-area-context';
-import { StyleSheet, View, TouchableOpacity } from 'react-native';
+import {
+ StyleSheet,
+ View,
+ TouchableOpacity,
+ RefreshControl,
+} from 'react-native';
import { useSelector } from 'react-redux';
import { useAppThemeFromContext } from '../../../../util/theme';
import { Theme } from '../../../../util/theme/models';
import { selectNetworkConfigurationsByCaipChainId } from '../../../../selectors/networkController';
-import HeaderBase, {
- HeaderBaseVariant,
-} from '../../../../component-library/components/HeaderBase';
-import ButtonIcon, {
- ButtonIconSizes,
-} from '../../../../component-library/components/Buttons/ButtonIcon';
import Icon, {
IconName,
IconColor,
IconSize,
} from '../../../../component-library/components/Icons/Icon';
import { strings } from '../../../../../locales/i18n';
+import { TrendingListHeader } from '../../../UI/Trending/components/TrendingListHeader';
import TrendingTokensList from '../../../UI/Trending/components/TrendingTokensList/TrendingTokensList';
import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton';
-import { useTrendingRequest } from '../../../UI/Trending/hooks/useTrendingRequest';
-import { SortTrendingBy } from '@metamask/assets-controllers';
+import {
+ SortTrendingBy,
+ type TrendingAsset,
+} from '@metamask/assets-controllers';
import { CaipChainId, Hex, parseCaipChainId } from '@metamask/utils';
import { PopularList } from '../../../../util/networks/customNetworks';
-import Text, {
- TextColor,
- TextVariant,
-} from '../../../../component-library/components/Texts/Text';
+import Text from '../../../../component-library/components/Texts/Text';
import {
TrendingTokenTimeBottomSheet,
TrendingTokenNetworkBottomSheet,
@@ -41,6 +40,7 @@ import {
TimeOption,
} from '../../../UI/Trending/components/TrendingTokensBottomSheet';
import { sortTrendingTokens } from '../../../UI/Trending/utils/sortTrendingTokens';
+import { SECTIONS_CONFIG } from '../../TrendingView/config/sections.config';
interface TrendingTokensNavigationParamList {
[key: string]: undefined | object;
@@ -56,14 +56,6 @@ const createStyles = (theme: Theme) =>
headerContainer: {
backgroundColor: theme.colors.background.default,
},
- header: {
- paddingTop: 16,
- paddingBottom: 0,
- paddingHorizontal: 16,
- alignItems: 'center',
- gap: 8,
- alignSelf: 'stretch',
- },
cardContainer: {
margin: 16,
borderRadius: 16,
@@ -147,11 +139,25 @@ const TrendingTokensFullView = () => {
const [showNetworkBottomSheet, setShowNetworkBottomSheet] = useState(false);
const [showPriceChangeBottomSheet, setShowPriceChangeBottomSheet] =
useState(false);
+ const [isSearchVisible, setIsSearchVisible] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [refreshing, setRefreshing] = useState(false);
const handleBackPress = useCallback(() => {
navigation.goBack();
}, [navigation]);
+ const handleSearchToggle = useCallback(() => {
+ setIsSearchVisible((prev) => !prev);
+ if (isSearchVisible) {
+ setSearchQuery('');
+ }
+ }, [isSearchVisible]);
+
+ const handleSearchQueryChange = useCallback((query: string) => {
+ setSearchQuery(query);
+ }, []);
+
const networkConfigurations = useSelector(
selectNetworkConfigurationsByCaipChainId,
);
@@ -188,26 +194,57 @@ const TrendingTokensFullView = () => {
return strings('trending.all_networks');
}, [selectedNetwork, networkConfigurations]);
- const { results: trendingTokensResults, isLoading } = useTrendingRequest({
+ // Use tokens section data as the single source of truth:
+ // - When no search query: returns trending results from useTrendingRequest
+ // - When search query exists: returns merged trending + search results
+ const {
+ data: tokensSectionData,
+ isLoading,
+ refetch: refetchTokensSection,
+ } = SECTIONS_CONFIG.tokens.useSectionData({
+ searchQuery: searchQuery || undefined,
sortBy,
- chainIds: selectedNetwork ?? undefined,
+ chainIds: selectedNetwork,
});
+ const searchResults = useMemo(() => {
+ // When search is not active, use the full section data
+ if (!isSearchVisible) {
+ return tokensSectionData as TrendingAsset[];
+ }
+
+ const searchTerm = searchQuery.toLowerCase().trim();
+
+ // If search box is empty, still use full section data
+ if (!searchTerm) {
+ return tokensSectionData as TrendingAsset[];
+ }
+
+ const tokensSectionConfig = SECTIONS_CONFIG.tokens;
+
+ // Filter section data based on searchable text (symbol + name)
+ return (tokensSectionData as unknown[]).filter((item) =>
+ tokensSectionConfig.getSearchableText(item).includes(searchTerm),
+ ) as TrendingAsset[];
+ }, [isSearchVisible, searchQuery, tokensSectionData]);
+
// Sort and display tokens based on selected option and direction
const trendingTokens = useMemo(() => {
// Early return if no results
- if (trendingTokensResults.length === 0) {
+ if (searchResults.length === 0) {
return [];
}
- // If no sort option selected, return results as-is (already sorted by API)
+ const filteredResults = searchResults;
+
+ // If no sort option selected, return filtered results as-is (already sorted by API)
if (!selectedPriceChangeOption) {
- return trendingTokensResults.slice(0, MAX_TOKENS);
+ return filteredResults.slice(0, MAX_TOKENS);
}
// Sort using the shared utility function
const sorted = sortTrendingTokens(
- trendingTokensResults,
+ filteredResults,
selectedPriceChangeOption,
priceChangeSortDirection,
selectedTimeOption,
@@ -215,7 +252,7 @@ const TrendingTokensFullView = () => {
return sorted.slice(0, MAX_TOKENS);
}, [
- trendingTokensResults,
+ searchResults,
selectedPriceChangeOption,
priceChangeSortDirection,
selectedTimeOption,
@@ -253,6 +290,18 @@ const TrendingTokensFullView = () => {
setShowTimeBottomSheet(true);
}, []);
+ // Handle pull-to-refresh
+ const handleRefresh = useCallback(async () => {
+ setRefreshing(true);
+ try {
+ refetchTokensSection?.();
+ } catch (error) {
+ console.warn('Failed to refresh trending tokens:', error);
+ } finally {
+ setRefreshing(false);
+ }
+ }, [refetchTokensSection]);
+
// Get the button text based on selected price change option
const priceChangeButtonText = useMemo(() => {
switch (selectedPriceChangeOption) {
@@ -276,79 +325,28 @@ const TrendingTokensFullView = () => {
},
]}
>
-
- }
- endAccessory={
- {
- // TODO: Implement search functionality
- }}
- iconName={IconName.Search}
- testID="search-button"
- />
- }
- style={styles.header}
- >
-
- {strings('trending.trending_tokens')}
-
-
+
-
-
-
-
-
- {priceChangeButtonText}
-
-
-
-
-
-
-
-
- {selectedNetworkName}
-
-
-
-
+ {!isSearchVisible ? (
+
+
- {selectedTimeOption}
+ {priceChangeButtonText}
{
/>
+
+
+
+
+ {selectedNetworkName}
+
+
+
+
+
+
+
+ {selectedTimeOption}
+
+
+
+
+
-
- {isLoading || trendingTokensResults.length === 0 ? (
+ ) : null}
+
+ {isLoading || (searchResults as TrendingAsset[]).length === 0 ? (
@@ -369,6 +404,14 @@ const TrendingTokensFullView = () => {
+ }
/>
)}
diff --git a/app/components/Views/TrendingView/config/sections.config.tsx b/app/components/Views/TrendingView/config/sections.config.tsx
index 54e27b0ab7f..0506d56ef74 100644
--- a/app/components/Views/TrendingView/config/sections.config.tsx
+++ b/app/components/Views/TrendingView/config/sections.config.tsx
@@ -1,6 +1,10 @@
import React from 'react';
import type { NavigationProp, ParamListBase } from '@react-navigation/native';
-import type { TrendingAsset } from '@metamask/assets-controllers';
+import type {
+ TrendingAsset,
+ SortTrendingBy,
+} from '@metamask/assets-controllers';
+import Routes from '../../../../constants/navigation/Routes';
import { strings } from '../../../../../locales/i18n';
import TrendingTokenRowItem from '../../../UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem';
import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton';
@@ -15,10 +19,7 @@ import SectionCard from '../components/SectionCard/SectionCard';
import SectionCarrousel from '../components/SectionCarrousel/SectionCarrousel';
import { useTrendingRequest } from '../../../UI/Trending/hooks/useTrendingRequest';
import { sortTrendingTokens } from '../../../UI/Trending/utils/sortTrendingTokens';
-import {
- PriceChangeOption,
- SortDirection,
-} from '../../../UI/Trending/components/TrendingTokensBottomSheet';
+import { PriceChangeOption } from '../../../UI/Trending/components/TrendingTokensBottomSheet';
import { usePredictMarketData } from '../../../UI/Predict/hooks/usePredictMarketData';
import { usePerpsMarkets } from '../../../UI/Perps/hooks';
import { PerpsConnectionProvider } from '../../../UI/Perps/providers/PerpsConnectionProvider';
@@ -29,13 +30,20 @@ import type { SiteData } from '../SectionSites/SiteRowItem/SiteRowItem';
import SiteRowItemWrapper from '../SectionSites/SiteRowItemWrapper';
import SiteSkeleton from '../SectionSites/SiteSkeleton/SiteSkeleton';
import { useSitesData } from '../SectionSites/hooks/useSitesData';
-import Routes from '../../../../constants/navigation/Routes';
+import { CaipChainId } from '@metamask/utils';
export type SectionId = 'predictions' | 'tokens' | 'perps' | 'sites';
interface SectionData {
data: unknown[];
isLoading: boolean;
+ refetch?: () => void;
+}
+
+interface SectionParams {
+ searchQuery?: string;
+ sortBy?: SortTrendingBy;
+ chainIds?: CaipChainId[] | null;
}
interface SectionConfig {
@@ -51,9 +59,10 @@ interface SectionConfig {
getSearchableText: (item: unknown) => string;
keyExtractor: (item: unknown) => string;
Section: React.ComponentType;
- useSectionData: (searchQuery?: string) => {
+ useSectionData: (params?: SectionParams) => {
data: unknown[];
isLoading: boolean;
+ refetch?: () => void;
};
}
@@ -89,7 +98,8 @@ export const SECTIONS_CONFIG: Record = {
`${(item as TrendingAsset).symbol} ${(item as TrendingAsset).name}`.toLowerCase(),
keyExtractor: (item) => `token-${(item as TrendingAsset).assetId}`,
Section: () => ,
- useSectionData: (searchQuery?: string) => {
+ useSectionData: (params?: SectionParams) => {
+ const { searchQuery, sortBy, chainIds } = params ?? {};
// Trending will return tokens that have just been created which wont be picked up by search API
// so if you see a token on trending and search on omnisearch which uses the search endpoint...
// There is a chance you will get 0 results
@@ -100,18 +110,26 @@ export const SECTIONS_CONFIG: Record = {
chainIds: [],
});
- const { results: trendingResults, isLoading: isTrendingLoading } =
- useTrendingRequest({});
+ const {
+ results: trendingResults,
+ isLoading: isTrendingLoading,
+ fetch: fetchTrendingTokens,
+ } = useTrendingRequest({
+ sortBy,
+ chainIds: chainIds ?? undefined,
+ });
if (!searchQuery) {
const sortedResults = sortTrendingTokens(
trendingResults,
PriceChangeOption.PriceChange,
- SortDirection.Descending,
);
return {
data: sortedResults,
isLoading: isTrendingLoading,
+ refetch: () => {
+ fetchTrendingTokens();
+ },
};
}
@@ -129,6 +147,9 @@ export const SECTIONS_CONFIG: Record = {
return {
data: Array.from(resultMap.values()),
isLoading: isSearchLoading,
+ refetch: () => {
+ fetchTrendingTokens();
+ },
};
},
},
@@ -195,7 +216,8 @@ export const SECTIONS_CONFIG: Record = {
(item as PredictMarketType).title.toLowerCase(),
keyExtractor: (item) => `prediction-${(item as PredictMarketType).id}`,
Section: () => ,
- useSectionData: (searchQuery?: string) => {
+ useSectionData: (params?: SectionParams) => {
+ const { searchQuery } = params ?? {};
const { marketData, isFetching } = usePredictMarketData({
category: 'trending',
pageSize: searchQuery ? 20 : 6,
@@ -255,11 +277,11 @@ export const useSectionsData = (
searchQuery?: string,
): Record => {
const { data: trendingTokens, isLoading: isTokensLoading } =
- SECTIONS_CONFIG.tokens.useSectionData(searchQuery);
+ SECTIONS_CONFIG.tokens.useSectionData({ searchQuery });
const { data: perpsMarkets, isLoading: isPerpsLoading } =
SECTIONS_CONFIG.perps.useSectionData();
const { data: predictionMarkets, isLoading: isPredictionsLoading } =
- SECTIONS_CONFIG.predictions.useSectionData(searchQuery);
+ SECTIONS_CONFIG.predictions.useSectionData({ searchQuery });
const { data: sites, isLoading: isSitesLoading } =
SECTIONS_CONFIG.sites.useSectionData();
diff --git a/locales/languages/en.json b/locales/languages/en.json
index e601473c47f..7f8e5894e2d 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -6997,6 +6997,7 @@
"low_to_high": "Low to high",
"apply": "Apply",
"search_placeholder": "Search tokens, sites, URLs",
+ "cancel": "Cancel",
"perps": "Perps",
"predictions": "Predictions",
"no_results": "No results found",
From ebe3705e9f21012341ee31919973b911336468c0 Mon Sep 17 00:00:00 2001
From: Matthew Walsh
Date: Tue, 25 Nov 2025 11:48:22 +0000
Subject: [PATCH 3/9] fix: cp-7.60.0 alerts persisting in metamask pay (#23240)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Prevent quote specific alerts persisting when keyboard is visible in
MetaMask Pay.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: #23134
## **Manual testing steps**
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Prevents MetaMask Pay alerts from persisting while editing amounts by
gating fee/network alerts during input, adds a `ConfirmationAlerts`
wrapper, and updates tests.
>
> - **Alerts handling**
> - Suppress fee/native-balance alerts while a pending amount is being
edited via new `pendingAmountUsd` option in
`useInsufficientPayTokenBalanceAlert` and `isPendingAlert` guards.
> - Improve target amount calculation to clamp within valid bounds and
handle negative shortfalls.
> - Update tests to cover pending-amount behavior and edge cases
(negative shortfall, no-target messages).
> - **UI/Provider refactor**
> - Replace inline `AlertsContextProvider` usage with new
`ConfirmationAlerts` wrapper in `confirm-component.tsx` that sources
alerts via `useConfirmationAlerts`.
> - **Custom amount flow**
> - On Done, call `updateTokenAmount()` and `EngineService.flushState()`
to finalize state before hiding keyboard.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
66d25494ad99e203d77e6a16f3efc9ad49aba8b6. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../components/confirm/confirm-component.tsx | 13 +++-
.../custom-amount-info/custom-amount-info.tsx | 6 +-
...seInsufficientPayTokenBalanceAlert.test.ts | 70 +++++++++++++++++--
.../useInsufficientPayTokenBalanceAlert.ts | 20 +++++-
4 files changed, 96 insertions(+), 13 deletions(-)
diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.tsx
index 30878c0e0db..db913563213 100755
--- a/app/components/Views/confirmations/components/confirm/confirm-component.tsx
+++ b/app/components/Views/confirmations/components/confirm/confirm-component.tsx
@@ -59,13 +59,12 @@ const ConfirmWrapped = ({
styles: ReturnType;
route?: UnstakeConfirmationViewProps['route'];
}) => {
- const alerts = useConfirmationAlerts();
const isScrollDisabled = useDisableScroll();
return (
-
+
@@ -88,7 +87,7 @@ const ConfirmWrapped = ({
-
+
);
@@ -167,6 +166,14 @@ export const Confirm = ({ route }: ConfirmProps) => {
);
};
+function ConfirmationAlerts({ children }: { children: ReactNode }) {
+ const alerts = useConfirmationAlerts();
+
+ return (
+ {children}
+ );
+}
+
function Loader() {
const { styles } = useStyles(styleSheet, { isFullScreenConfirmation: true });
const params = useParams();
diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx
index 572648507f0..50e8d5c37d0 100644
--- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx
+++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx
@@ -48,6 +48,7 @@ import Button, {
} from '../../../../../../component-library/components/Buttons/Button';
import { useAlerts } from '../../../context/alert-system-context';
import { useTransactionConfirm } from '../../../hooks/transactions/useTransactionConfirm';
+import EngineService from '../../../../../../core/EngineService';
export interface CustomAmountInfoProps {
children?: ReactNode;
@@ -87,8 +88,9 @@ export const CustomAmountInfo: React.FC = memo(
pendingTokenAmount: amountHumanDebounced,
});
- const handleDone = useCallback(async () => {
- await updateTokenAmount();
+ const handleDone = useCallback(() => {
+ updateTokenAmount();
+ EngineService.flushState();
setIsKeyboardVisible(false);
}, [updateTokenAmount]);
diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.test.ts
index 320ddc6b500..99b703a7093 100644
--- a/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.test.ts
+++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.test.ts
@@ -52,10 +52,15 @@ const NATIVE_TOKEN_MOCK = {
balanceRaw: '100',
} as NonNullable>;
-function runHook() {
- return renderHookWithProvider(() => useInsufficientPayTokenBalanceAlert(), {
- state: merge({}, otherControllersMock),
- });
+function runHook(
+ props: Parameters[0] = {},
+) {
+ return renderHookWithProvider(
+ () => useInsufficientPayTokenBalanceAlert(props),
+ {
+ state: merge({}, otherControllersMock),
+ },
+ );
}
describe('useInsufficientPayTokenBalanceAlert', () => {
@@ -185,7 +190,37 @@ describe('useInsufficientPayTokenBalanceAlert', () => {
title: strings('alert_system.insufficient_pay_token_balance.message'),
message: strings(
'alert_system.insufficient_pay_token_balance_fees_no_target.message',
- { amount: '$1.21' },
+ ),
+ severity: Severity.Danger,
+ },
+ ]);
+ });
+
+ it('returns alert if pay token balance shortfall is negative due to bad exchange rates', () => {
+ useTransactionPayTokenMock.mockReturnValue({
+ payToken: {
+ ...PAY_TOKEN_MOCK,
+ balanceRaw: '999',
+ },
+ setPayToken: jest.fn(),
+ });
+
+ useTransactionPayTotalsMock.mockReturnValue({
+ ...TOTALS_MOCK,
+ sourceAmount: { ...TOTALS_MOCK.sourceAmount, usd: '1.19' },
+ });
+
+ const { result } = runHook();
+
+ expect(result.current).toStrictEqual([
+ {
+ key: AlertKeys.InsufficientPayTokenFees,
+ field: RowAlertKey.Amount,
+ isBlocking: true,
+ title: strings('alert_system.insufficient_pay_token_balance.message'),
+ message: strings(
+ 'alert_system.insufficient_pay_token_balance_fees.message',
+ { amount: '$1.23' },
),
severity: Severity.Danger,
},
@@ -252,6 +287,20 @@ describe('useInsufficientPayTokenBalanceAlert', () => {
},
]);
});
+
+ it('returns no alert if pending amount provided', () => {
+ useTransactionPayTokenMock.mockReturnValue({
+ payToken: {
+ ...PAY_TOKEN_MOCK,
+ balanceRaw: '999',
+ },
+ setPayToken: jest.fn(),
+ });
+
+ const { result } = runHook({ pendingAmountUsd: '1.23' });
+
+ expect(result.current).toStrictEqual([]);
+ });
});
describe('for source network fee', () => {
@@ -325,5 +374,16 @@ describe('useInsufficientPayTokenBalanceAlert', () => {
expect(result.current).toStrictEqual([]);
});
+
+ it('returns no alert if pending amount provided', () => {
+ useTokenWithBalanceMock.mockReturnValue({
+ ...NATIVE_TOKEN_MOCK,
+ balanceRaw: '99',
+ } as ReturnType);
+
+ const { result } = runHook({ pendingAmountUsd: '1.23' });
+
+ expect(result.current).toStrictEqual([]);
+ });
});
});
diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.ts
index e700dc4ae46..753d9c79462 100644
--- a/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.ts
+++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.ts
@@ -28,6 +28,7 @@ export function useInsufficientPayTokenBalanceAlert({
const formatFiat = useFiatFormatter({ currency: 'usd' });
const isLoading = useIsTransactionPayLoading();
const isSourceGasFeeToken = totals?.fees.isSourceGasFeeToken ?? false;
+ const isPendingAlert = Boolean(pendingAmountUsd !== undefined);
const sourceChainId = payToken?.chainId ?? '0x0';
@@ -86,7 +87,15 @@ export function useInsufficientPayTokenBalanceAlert({
const targetUsdValue = totalAmountUsd.minus(shortfall);
const targetUsd = formatFiat(targetUsdValue);
- return targetUsdValue.isLessThanOrEqualTo(0) ? undefined : targetUsd;
+ if (targetUsdValue.isLessThanOrEqualTo(0)) {
+ return undefined;
+ }
+
+ if (targetUsdValue.isGreaterThan(totalAmountUsd)) {
+ return formatFiat(totalAmountUsd);
+ }
+
+ return targetUsd;
}, [balanceUsd, formatFiat, totalAmountUsd, totalSourceAmountUsd]);
const totalSourceNetworkFeeRaw = useMemo(
@@ -100,18 +109,23 @@ export function useInsufficientPayTokenBalanceAlert({
);
const isInsufficientForFees = useMemo(
- () => payToken && totalSourceAmountRaw.isGreaterThan(balanceRaw ?? '0'),
- [balanceRaw, payToken, totalSourceAmountRaw],
+ () =>
+ !isPendingAlert &&
+ payToken &&
+ totalSourceAmountRaw.isGreaterThan(balanceRaw ?? '0'),
+ [balanceRaw, isPendingAlert, payToken, totalSourceAmountRaw],
);
const isInsufficientForSourceNetwork = useMemo(
() =>
payToken &&
!isPayTokenNative &&
+ !isPendingAlert &&
!isSourceGasFeeToken &&
totalSourceNetworkFeeRaw.isGreaterThan(nativeToken?.balanceRaw ?? '0'),
[
isPayTokenNative,
+ isPendingAlert,
isSourceGasFeeToken,
nativeToken?.balanceRaw,
payToken,
From 861b876cb2925c42e480305df52016e388d2d89f Mon Sep 17 00:00:00 2001
From: OGPoyraz
Date: Tue, 25 Nov 2025 13:13:37 +0100
Subject: [PATCH 4/9] feat: Add predefined recipient support to send flow
(#23087)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR aims to add a support for predefined recipient into send flow
navigation. It mainly aims to support opening send flow through QR
scanner.
In order to send predefined recipient flows should call
`navigateToSendPage` callback from
`app/components/Views/confirmations/hooks/useSendNavigation.ts` hook.
Let's assume we already validated the QR address is Solana address. In
that scenario a call like
```
navigateToSendPage({
location: "QRScanner",
predefinedRecipient: {
address: '7W54AwGDYRF7Xmoi6phjTnrQhruYtoUdCKJMYAXP7VWC',
chainType: "solana",
},
})
```
will open assets page in the send flow and it will skip recipient page
as user already wants to send into that address.
All the navigations that will come from QR scanner have to fill
validated recipient address and the flag for the type. For more
information please see `handleSendPageNavigation` jsdoc or
`SendNavigationParams.PredefinedRecipient`
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: https://github.com/MetaMask/metamask-mobile/issues/21758
## **Manual testing steps**
Adds predefined recipient navigation to send flow - so this is currently
a technical feature which doesn't have any usage for now.
## **Screenshots/Recordings**
### **Before**
### **After**
https://github.com/user-attachments/assets/f7327bf1-ec0a-4255-a441-b98e2cf20aa2
## **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]
> Adds predefined recipient handling to the new send flow, refactors
navigation to a param object API, updates amount/asset selection logic,
and expands tests.
>
> - **Send flow & navigation**:
> - Introduce `ChainType`, `PredefinedRecipient`, and
`SendNavigationParams`; refactor `handleSendPageNavigation` to accept a
params object and pass `predefinedRecipient` to routes.
> - Update `useSendNavigation` to accept params and propagate
`isSendRedesignEnabled` internally; migrate callers (`AssetOverview`,
`CollectibleModal`, `NftDetails`, `Wallet`).
> - Amount screen (`AmountKeyboard`) now reads `predefinedRecipient`
from route params and skips the recipient step, calling
`updateTo`/`handleSubmitPress` directly.
> - **Asset selection & tokens**:
> - Add `useSendTokens` to filter tokens by chain type based on
`useSendType`; switch `Asset` component to use it.
> - Simplify `useAccountTokens` (remove scope filtering) and keep
balance-based filtering/formatting.
> - **Utilities**:
> - Add `derivePredefinedRecipientParams` to infer chain type from an
address.
> - **Tests**:
> - Add/adjust tests for navigation, amount flow, send type detection,
token filtering, gas/amount validation, and address utils; update mocks
to new APIs.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
acda5985857eb144e111b0498cd1af273c895d37. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../UI/AssetOverview/AssetOverview.tsx | 2 +-
.../UI/CollectibleModal/CollectibleModal.tsx | 5 +-
.../Views/NftDetails/NftDetails.tsx | 5 +-
app/components/Views/Wallet/index.tsx | 4 +-
.../confirmations/__mocks__/send.mock.ts | 2 +
.../amount-keyboard/amount-keyboard.test.tsx | 47 +++
.../amount-keyboard/amount-keyboard.tsx | 19 +-
.../components/send/amount/amount.test.tsx | 4 +-
.../components/send/asset/asset.test.tsx | 10 +-
.../components/send/asset/asset.tsx | 4 +-
.../hooks/send/useAccountTokens.test.ts | 66 +---
.../hooks/send/useAccountTokens.ts | 28 +-
.../hooks/send/useAmountValidation.test.ts | 4 +
.../send/useGasFeeEstimatesForSend.test.ts | 7 +-
.../hooks/send/usePercentageAmount.test.ts | 12 +
.../hooks/send/useSendTokens.test.ts | 262 ++++++++++++++
.../confirmations/hooks/send/useSendTokens.ts | 41 +++
.../hooks/send/useSendType.test.ts | 340 +++++++++++++++++-
.../confirmations/hooks/send/useSendType.ts | 50 ++-
.../hooks/send/useToAddressValidation.test.ts | 12 +
.../hooks/useSendNavigation.test.ts | 14 +-
.../confirmations/hooks/useSendNavigation.ts | 14 +-
.../Views/confirmations/utils/address.test.ts | 130 +++++++
.../Views/confirmations/utils/address.ts | 39 ++
.../Views/confirmations/utils/send.test.ts | 22 +-
.../Views/confirmations/utils/send.ts | 62 +++-
app/components/hooks/useSendNonEvmAsset.ts | 9 +-
27 files changed, 1068 insertions(+), 146 deletions(-)
create mode 100644 app/components/Views/confirmations/hooks/send/useSendTokens.test.ts
create mode 100644 app/components/Views/confirmations/hooks/send/useSendTokens.ts
create mode 100644 app/components/Views/confirmations/utils/address.test.ts
create mode 100644 app/components/Views/confirmations/utils/address.ts
diff --git a/app/components/UI/AssetOverview/AssetOverview.tsx b/app/components/UI/AssetOverview/AssetOverview.tsx
index 09a4894375d..278278601a0 100644
--- a/app/components/UI/AssetOverview/AssetOverview.tsx
+++ b/app/components/UI/AssetOverview/AssetOverview.tsx
@@ -314,7 +314,7 @@ const AssetOverview: React.FC = ({
dispatch(newAssetTransaction(asset));
}
- navigateToSendPage(InitSendLocation.AssetOverview, asset);
+ navigateToSendPage({ location: InitSendLocation.AssetOverview, asset });
};
const onBuy = () => {
diff --git a/app/components/UI/CollectibleModal/CollectibleModal.tsx b/app/components/UI/CollectibleModal/CollectibleModal.tsx
index e2a8f33f482..c03db824998 100644
--- a/app/components/UI/CollectibleModal/CollectibleModal.tsx
+++ b/app/components/UI/CollectibleModal/CollectibleModal.tsx
@@ -87,7 +87,10 @@ const CollectibleModal = () => {
const onSend = useCallback(async () => {
dispatch(newAssetTransaction({ contractName, ...collectible }));
- navigateToSendPage(InitSendLocation.CollectibleModal, collectible);
+ navigateToSendPage({
+ location: InitSendLocation.CollectibleModal,
+ asset: collectible,
+ });
}, [contractName, collectible, dispatch, navigateToSendPage]);
const isTradable = useCallback(
diff --git a/app/components/Views/NftDetails/NftDetails.tsx b/app/components/Views/NftDetails/NftDetails.tsx
index ea9a3b130f0..d3d1ab101da 100644
--- a/app/components/Views/NftDetails/NftDetails.tsx
+++ b/app/components/Views/NftDetails/NftDetails.tsx
@@ -190,7 +190,10 @@ const NftDetails = () => {
dispatch(
newAssetTransaction({ contractName: collectible.name, ...collectible }),
);
- navigateToSendPage(InitSendLocation.NftDetails, collectible);
+ navigateToSendPage({
+ location: InitSendLocation.NftDetails,
+ asset: collectible,
+ });
}, [collectible, chainId, dispatch, navigateToSendPage]);
const isTradable = useCallback(
diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx
index cc8596b7070..d06969bb48e 100644
--- a/app/components/Views/Wallet/index.tsx
+++ b/app/components/Views/Wallet/index.tsx
@@ -695,14 +695,14 @@ const Wallet = ({
}
// Navigate to send flow after successful transaction initialization
- navigateToSendPage(InitSendLocation.HomePage);
+ navigateToSendPage({ location: InitSendLocation.HomePage });
} catch (error) {
// Handle any errors that occur during the send flow initiation
console.error('Error initiating send flow:', error);
// Still attempt to navigate to maintain user flow, but without transaction initialization
// The SendFlow view should handle the lack of initialized transaction gracefully
- navigateToSendPage(InitSendLocation.HomePage);
+ navigateToSendPage({ location: InitSendLocation.HomePage });
}
}, [
trackEvent,
diff --git a/app/components/Views/confirmations/__mocks__/send.mock.ts b/app/components/Views/confirmations/__mocks__/send.mock.ts
index c1858a7e81b..117086004ab 100644
--- a/app/components/Views/confirmations/__mocks__/send.mock.ts
+++ b/app/components/Views/confirmations/__mocks__/send.mock.ts
@@ -34,6 +34,8 @@ export const EVM_NATIVE_ASSET = {
decimals: 18,
isNative: true,
isETH: true,
+ image:
+ 'https://upload.wikimedia.org/wikipedia/commons/0/05/Ethereum_logo_2014.svg',
logo: 'https://upload.wikimedia.org/wikipedia/commons/0/05/Ethereum_logo_2014.svg',
name: 'Ethereum',
symbol: 'ETH',
diff --git a/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.test.tsx b/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.test.tsx
index 07c3aa67ba8..b8a38bf5863 100644
--- a/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.test.tsx
+++ b/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.test.tsx
@@ -16,6 +16,8 @@ import { usePercentageAmount } from '../../../../hooks/send/usePercentageAmount'
import { useSendContext } from '../../../../context/send-context';
import { useRouteParams } from '../../../../hooks/send/useRouteParams';
import { useSendType } from '../../../../hooks/send/useSendType';
+import { useParams } from '../../../../../../../util/navigation/navUtils';
+import { useSendActions } from '../../../../hooks/send/useSendActions';
// eslint-disable-next-line import/no-namespace
import * as AmountValidation from '../../../../hooks/send/useAmountValidation';
import { getBackgroundColor } from './amount-keyboard.styles';
@@ -53,6 +55,14 @@ jest.mock('../../../../hooks/send/useSendType', () => ({
useSendType: jest.fn(),
}));
+jest.mock('../../../../../../../util/navigation/navUtils', () => ({
+ useParams: jest.fn(),
+}));
+
+jest.mock('../../../../hooks/send/useSendActions', () => ({
+ useSendActions: jest.fn(),
+}));
+
const mockGoBack = jest.fn();
const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
@@ -87,6 +97,9 @@ const mockUsePercentageAmount = usePercentageAmount as jest.MockedFunction<
typeof usePercentageAmount
>;
+const mockUseParams = jest.mocked(useParams);
+const mockUseSendActions = jest.mocked(useSendActions);
+
const renderComponent = (
mockState?: ProviderValues['state'],
amount = '100',
@@ -113,7 +126,10 @@ const renderComponent = (
describe('Amount', () => {
const mockUseSendType = jest.mocked(useSendType);
+ const mockHandleSubmitPress = jest.fn();
+
beforeEach(() => {
+ jest.clearAllMocks();
mockUseSendType.mockReturnValue({
isNonEvmSendType: false,
} as unknown as ReturnType);
@@ -121,6 +137,10 @@ describe('Amount', () => {
getPercentageAmount: () => 10,
isMaxAmountSupported: true,
} as unknown as ReturnType);
+ mockUseParams.mockReturnValue({});
+ mockUseSendActions.mockReturnValue({
+ handleSubmitPress: mockHandleSubmitPress,
+ } as unknown as ReturnType);
});
it('renders correctly', () => {
@@ -174,6 +194,33 @@ describe('Amount', () => {
fireEvent.press(getByText('Continue'));
expect(mockValidateNonEvmAmountAsync).toHaveBeenCalled();
});
+
+ it('calls updateTo and handleSubmitPress when predefinedRecipient is provided', () => {
+ const mockUpdateTo = jest.fn();
+ const predefinedRecipientAddress =
+ '0x1234567890123456789012345678901234567890';
+
+ mockUseParams.mockReturnValue({
+ predefinedRecipient: {
+ address: predefinedRecipientAddress,
+ chainType: 'evm',
+ },
+ });
+ mockUseSendContext.mockReturnValue({
+ asset: MOCK_EVM_ASSET,
+ updateAsset: jest.fn(),
+ updateTo: mockUpdateTo,
+ } as unknown as ReturnType);
+
+ const { getByText } = renderComponent();
+ fireEvent.press(getByText('Continue'));
+
+ expect(mockUpdateTo).toHaveBeenCalledWith(predefinedRecipientAddress);
+ expect(mockHandleSubmitPress).toHaveBeenCalledWith(
+ predefinedRecipientAddress,
+ );
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
});
describe('getBackgroundColor', () => {
diff --git a/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.tsx b/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.tsx
index 9ac70d810e2..6993109ba82 100644
--- a/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.tsx
+++ b/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.tsx
@@ -7,6 +7,7 @@ import Button, {
ButtonVariants,
ButtonWidthTypes,
} from '../../../../../../../component-library/components/Buttons/Button';
+import { useParams } from '../../../../../../../util/navigation/navUtils.ts';
import { useStyles } from '../../../../../../hooks/useStyles';
import { AssetType, TokenStandard } from '../../../../types/token';
import { getFractionLength } from '../../../../utils/send.ts';
@@ -16,7 +17,9 @@ import { useCurrencyConversions } from '../../../../hooks/send/useCurrencyConver
import { usePercentageAmount } from '../../../../hooks/send/usePercentageAmount';
import { useSendType } from '../../../../hooks/send/useSendType';
import { useSendContext } from '../../../../context/send-context';
+import { type PredefinedRecipient } from '../../../../utils/send';
import { useSendScreenNavigation } from '../../../../hooks/send/useSendScreenNavigation';
+import { useSendActions } from '../../../../hooks/send/useSendActions';
import { EditAmountKeyboard } from '../../../edit-amount-keyboard';
import { styleSheet } from './amount-keyboard.styles';
@@ -44,7 +47,8 @@ export const AmountKeyboard = ({
const { gotToSendScreen } = useSendScreenNavigation();
const { isMaxAmountSupported, getPercentageAmount } = usePercentageAmount();
const { amountError, validateNonEvmAmountAsync } = useAmountValidation();
- const { asset, updateValue } = useSendContext();
+ const { asset, updateValue, updateTo } = useSendContext();
+ const { handleSubmitPress } = useSendActions();
const { isNonEvmSendType } = useSendType();
const isNFT = asset?.standard === TokenStandard.ERC1155;
const { styles } = useStyles(styleSheet, {
@@ -54,6 +58,10 @@ export const AmountKeyboard = ({
const { captureAmountSelected, setAmountInputMethodPressedMax } =
useAmountSelectionMetrics();
+ const { predefinedRecipient } = useParams<{
+ predefinedRecipient: PredefinedRecipient;
+ }>();
+
const updateToPercentageAmount = useCallback(
(percentage: number) => {
const percentageAmount = getPercentageAmount(percentage) ?? '0';
@@ -100,12 +108,21 @@ export const AmountKeyboard = ({
}
}
captureAmountSelected();
+ // Skip the recipient screen if a predefined recipient is provided
+ if (predefinedRecipient) {
+ updateTo(predefinedRecipient.address);
+ handleSubmitPress(predefinedRecipient.address);
+ return;
+ }
gotToSendScreen(Routes.SEND.RECIPIENT);
}, [
captureAmountSelected,
gotToSendScreen,
isNonEvmSendType,
validateNonEvmAmountAsync,
+ handleSubmitPress,
+ updateTo,
+ predefinedRecipient,
]);
return (
diff --git a/app/components/Views/confirmations/components/send/amount/amount.test.tsx b/app/components/Views/confirmations/components/send/amount/amount.test.tsx
index fd3356cde1e..1fb5b8f072c 100644
--- a/app/components/Views/confirmations/components/send/amount/amount.test.tsx
+++ b/app/components/Views/confirmations/components/send/amount/amount.test.tsx
@@ -76,7 +76,9 @@ jest.mock('@react-navigation/native', () => ({
goBack: jest.fn(),
navigate: jest.fn(),
}),
- useRoute: jest.fn(),
+ useRoute: () => ({
+ params: {},
+ }),
}));
const mockedUseAmountSelectionMetrics = jest.mocked(useAmountSelectionMetrics);
diff --git a/app/components/Views/confirmations/components/send/asset/asset.test.tsx b/app/components/Views/confirmations/components/send/asset/asset.test.tsx
index f70462e7eec..96df77829dc 100644
--- a/app/components/Views/confirmations/components/send/asset/asset.test.tsx
+++ b/app/components/Views/confirmations/components/send/asset/asset.test.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react-native';
import { AssetType, Nft } from '../../../types/token';
-import { useAccountTokens } from '../../../hooks/send/useAccountTokens';
+import { useSendTokens } from '../../../hooks/send/useSendTokens';
import { useTokenSearch } from '../../../hooks/send/useTokenSearch';
import { useEVMNfts } from '../../../hooks/send/useNfts';
import { useAssetSelectionMetrics } from '../../../hooks/send/metrics/useAssetSelectionMetrics';
@@ -66,8 +66,8 @@ const mockNfts: Nft[] = [
},
];
-jest.mock('../../../hooks/send/useAccountTokens', () => ({
- useAccountTokens: jest.fn(),
+jest.mock('../../../hooks/send/useSendTokens', () => ({
+ useSendTokens: jest.fn(),
}));
jest.mock('../../../hooks/send/useTokenSearch', () => ({
@@ -198,7 +198,7 @@ jest.mock('../../../../../../../locales/i18n', () => ({
}),
}));
-const mockUseAccountTokens = jest.mocked(useAccountTokens);
+const mockUseSendTokens = jest.mocked(useSendTokens);
const mockUseTokenSearch = jest.mocked(useTokenSearch);
const mockUseEVMNfts = jest.mocked(useEVMNfts);
const mockUseAssetSelectionMetrics = jest.mocked(useAssetSelectionMetrics);
@@ -212,7 +212,7 @@ describe('Asset', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockUseAccountTokens.mockReturnValue(mockTokens);
+ mockUseSendTokens.mockReturnValue(mockTokens);
mockUseEVMNfts.mockReturnValue(mockNfts);
mockUseTokenSearch.mockReturnValue({
diff --git a/app/components/Views/confirmations/components/send/asset/asset.tsx b/app/components/Views/confirmations/components/send/asset/asset.tsx
index 0df5fbe4d42..3a9ce0fc5ce 100644
--- a/app/components/Views/confirmations/components/send/asset/asset.tsx
+++ b/app/components/Views/confirmations/components/send/asset/asset.tsx
@@ -21,7 +21,7 @@ import { NftList } from '../../nft-list';
import { AssetType } from '../../../types/token';
import { NetworkFilter } from '../../network-filter';
import { useEVMNfts } from '../../../hooks/send/useNfts';
-import { useAccountTokens } from '../../../hooks/send/useAccountTokens';
+import { useSendTokens } from '../../../hooks/send/useSendTokens';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ScrollView } from 'react-native-gesture-handler';
@@ -40,7 +40,7 @@ export const Asset: React.FC = (props = {}) => {
tokenFilter,
} = props;
- const originalTokens = useAccountTokens({ includeNoBalance });
+ const originalTokens = useSendTokens({ includeNoBalance });
const tokens = useMemo(
() => (tokenFilter ? tokenFilter(originalTokens) : originalTokens),
diff --git a/app/components/Views/confirmations/hooks/send/useAccountTokens.test.ts b/app/components/Views/confirmations/hooks/send/useAccountTokens.test.ts
index 9c10d6fcdc8..698389fa312 100644
--- a/app/components/Views/confirmations/hooks/send/useAccountTokens.test.ts
+++ b/app/components/Views/confirmations/hooks/send/useAccountTokens.test.ts
@@ -2,7 +2,6 @@ import { renderHook } from '@testing-library/react-hooks';
import { useSelector } from 'react-redux';
import { useAccountTokens } from './useAccountTokens';
-import { useSendScope } from './useSendScope';
import { getNetworkBadgeSource } from '../../utils/network';
import { getIntlNumberFormatter } from '../../../../../util/intl';
import { TokenStandard } from '../../types/token';
@@ -14,10 +13,6 @@ jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
-jest.mock('./useSendScope', () => ({
- useSendScope: jest.fn(),
-}));
-
jest.mock('../../utils/network', () => ({
getNetworkBadgeSource: jest.fn(),
}));
@@ -44,7 +39,6 @@ jest.mock('../../../../../selectors/currencyRateController', () => ({
}));
const mockUseSelector = jest.mocked(useSelector);
-const mockUseSendScope = jest.mocked(useSendScope);
const mockGetNetworkBadgeSource = jest.mocked(getNetworkBadgeSource);
const mockGetIntlNumberFormatter = jest.mocked(getIntlNumberFormatter);
const mockSelectAssetsBySelectedAccountGroup = jest.mocked(
@@ -103,10 +97,6 @@ describe('useAccountTokens', () => {
return undefined;
});
- mockUseSendScope.mockReturnValue({
- isEvmOnly: false,
- isSolanaOnly: false,
- });
mockGetNetworkBadgeSource.mockReturnValue('network-badge-source');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGetIntlNumberFormatter.mockReturnValue(mockFormatter as any);
@@ -114,55 +104,27 @@ describe('useAccountTokens', () => {
mockIsTestNet.mockReturnValue(false);
});
- describe('when no scope filter is applied', () => {
- it('returns all assets with balance', () => {
- const { result } = renderHook(() => useAccountTokens());
-
- expect(result.current).toHaveLength(2);
- expect(result.current[0].symbol).toBe('TOKEN1');
- expect(result.current[1].symbol).toBe('SOLTOKEN1');
- });
-
- it('filters out assets with zero balance', () => {
- const { result } = renderHook(() => useAccountTokens());
+ it('returns all assets with balance', () => {
+ const { result } = renderHook(() => useAccountTokens());
- const symbols = result.current.map((asset) => asset.symbol);
- expect(symbols).not.toContain('TOKEN2');
- });
+ expect(result.current).toHaveLength(2);
+ expect(result.current[0].symbol).toBe('TOKEN1');
+ expect(result.current[1].symbol).toBe('SOLTOKEN1');
});
- describe('when EVM only scope is applied', () => {
- beforeEach(() => {
- mockUseSendScope.mockReturnValue({
- isEvmOnly: true,
- isSolanaOnly: false,
- });
- });
-
- it('returns only EVM assets', () => {
- const { result } = renderHook(() => useAccountTokens());
+ it('filters out assets with zero balance', () => {
+ const { result } = renderHook(() => useAccountTokens());
- expect(result.current).toHaveLength(1);
- expect(result.current[0].symbol).toBe('TOKEN1');
- expect(result.current[0].accountType).toContain('eip155');
- });
+ const symbols = result.current.map((asset) => asset.symbol);
+ expect(symbols).not.toContain('TOKEN2');
});
- describe('when Solana only scope is applied', () => {
- beforeEach(() => {
- mockUseSendScope.mockReturnValue({
- isEvmOnly: false,
- isSolanaOnly: true,
- });
- });
-
- it('returns only Solana assets', () => {
- const { result } = renderHook(() => useAccountTokens());
+ it('returns all asset types without filtering by account type', () => {
+ const { result } = renderHook(() => useAccountTokens());
- expect(result.current).toHaveLength(1);
- expect(result.current[0].symbol).toBe('SOLTOKEN1');
- expect(result.current[0].accountType).toContain('solana');
- });
+ const accountTypes = result.current.map((asset) => asset.accountType);
+ expect(accountTypes).toContain('eip155:1/erc20:0xtoken1');
+ expect(accountTypes).toContain('solana:mainnet/spl:0xsoltoken1');
});
describe('asset processing', () => {
diff --git a/app/components/Views/confirmations/hooks/send/useAccountTokens.ts b/app/components/Views/confirmations/hooks/send/useAccountTokens.ts
index 4f4d2de8ecb..611faf062ee 100644
--- a/app/components/Views/confirmations/hooks/send/useAccountTokens.ts
+++ b/app/components/Views/confirmations/hooks/send/useAccountTokens.ts
@@ -11,33 +11,19 @@ import I18n from '../../../../../../locales/i18n';
import { getIntlNumberFormatter } from '../../../../../util/intl';
import { getNetworkBadgeSource } from '../../utils/network';
import { AssetType, TokenStandard } from '../../types/token';
-import { useSendScope } from './useSendScope';
export function useAccountTokens({
includeNoBalance = false,
+}: {
+ includeNoBalance?: boolean;
} = {}): AssetType[] {
const assets = useSelector(selectFilteredAssetsBySelectedAccountGroup);
- const { isEvmOnly, isSolanaOnly } = useSendScope();
const fiatCurrency = useSelector(selectCurrentCurrency);
return useMemo(() => {
const flatAssets = Object.values(assets).flat();
- let filteredAssets;
-
- if (isEvmOnly) {
- filteredAssets = flatAssets.filter((asset) =>
- asset.accountType.includes('eip155'),
- );
- } else if (isSolanaOnly) {
- filteredAssets = flatAssets.filter((asset) =>
- asset.accountType.includes('solana'),
- );
- } else {
- filteredAssets = flatAssets;
- }
-
- const assetsWithBalance = filteredAssets.filter((asset) => {
+ const assetsWithBalance = flatAssets.filter((asset) => {
if (includeNoBalance) {
return true;
}
@@ -85,11 +71,5 @@ export function useAccountTokens({
new BigNumber(a.fiat?.balance || 0),
) || 0,
);
- }, [
- assets,
- includeNoBalance,
- isEvmOnly,
- isSolanaOnly,
- fiatCurrency,
- ]) as unknown as AssetType[];
+ }, [assets, includeNoBalance, fiatCurrency]) as unknown as AssetType[];
}
diff --git a/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts b/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts
index 69514428dad..10dff45ada7 100644
--- a/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts
+++ b/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts
@@ -19,6 +19,10 @@ import { AssetType, TokenStandard } from '../../types/token';
import * as SendContext from '../../context/send-context/send-context';
const MOCK_ADDRESS_1 = '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc';
+jest.mock('../../../../../util/navigation/navUtils', () => ({
+ useParams: () => ({}),
+}));
+
describe('validateERC1155Balance', () => {
it('return error if amount is greater than balance and not otherwise', () => {
expect(
diff --git a/app/components/Views/confirmations/hooks/send/useGasFeeEstimatesForSend.test.ts b/app/components/Views/confirmations/hooks/send/useGasFeeEstimatesForSend.test.ts
index c66b2ede1ef..b94feb25d03 100644
--- a/app/components/Views/confirmations/hooks/send/useGasFeeEstimatesForSend.test.ts
+++ b/app/components/Views/confirmations/hooks/send/useGasFeeEstimatesForSend.test.ts
@@ -8,16 +8,21 @@ jest.mock('../gas/useGasFeeEstimates', () => ({
}),
}));
+jest.mock('../../../../../util/navigation/navUtils', () => ({
+ useParams: () => ({}),
+}));
+
const mockState = {
state: evmSendStateMock,
};
describe('useGasFeeEstimatesForSend', () => {
- it('return gas estimates', () => {
+ it('returns gas estimates', () => {
const { result } = renderHookWithProvider(
() => useGasFeeEstimatesForSend(),
mockState,
);
+
expect(result.current.gasFeeEstimates).toBeDefined();
});
});
diff --git a/app/components/Views/confirmations/hooks/send/usePercentageAmount.test.ts b/app/components/Views/confirmations/hooks/send/usePercentageAmount.test.ts
index 6d9a9f077ea..71169713adc 100644
--- a/app/components/Views/confirmations/hooks/send/usePercentageAmount.test.ts
+++ b/app/components/Views/confirmations/hooks/send/usePercentageAmount.test.ts
@@ -12,6 +12,7 @@ import { useSendContext } from '../../context/send-context';
import * as SendUtils from '../../utils/send';
import { usePercentageAmount } from './usePercentageAmount';
import { useBalance } from './useBalance';
+import { useParams } from '../../../../../util/navigation/navUtils';
jest.mock('@metamask/assets-controllers', () => ({
getNativeTokenAddress: () => '0xeDd1935e28b253C7905Cf5a944f0B5830FFA916a',
@@ -31,6 +32,10 @@ jest.mock('./useBalance', () => ({
useBalance: jest.fn(),
}));
+jest.mock('../../../../../util/navigation/navUtils', () => ({
+ useParams: jest.fn(),
+}));
+
const mockState = {
state: evmSendStateMock,
};
@@ -41,7 +46,14 @@ const mockUseSendContext = useSendContext as jest.MockedFunction<
const mockUseBalance = useBalance as jest.MockedFunction;
+const mockUseParams = useParams as jest.MockedFunction;
+
describe('usePercentageAmount', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseParams.mockReturnValue(undefined);
+ });
+
it('return required fields', () => {
mockUseSendContext.mockReturnValue({
asset: {},
diff --git a/app/components/Views/confirmations/hooks/send/useSendTokens.test.ts b/app/components/Views/confirmations/hooks/send/useSendTokens.test.ts
new file mode 100644
index 00000000000..40b5feb62cd
--- /dev/null
+++ b/app/components/Views/confirmations/hooks/send/useSendTokens.test.ts
@@ -0,0 +1,262 @@
+import { renderHook } from '@testing-library/react-hooks';
+
+import { useSendTokens } from './useSendTokens';
+import { useAccountTokens } from './useAccountTokens';
+import { useSendType } from './useSendType';
+import { AssetType, TokenStandard } from '../../types/token';
+
+jest.mock('./useAccountTokens');
+jest.mock('./useSendType');
+
+const mockUseAccountTokens = jest.mocked(useAccountTokens);
+const mockUseSendType = jest.mocked(useSendType);
+
+const mockEvmToken = {
+ address: '0x1234567890123456789012345678901234567890',
+ chainId: '0x1',
+ symbol: 'ETH',
+ ticker: 'ETH',
+ decimals: 18,
+ balance: '1.5',
+ balanceFiat: '$3000.00',
+ image: 'https://example.com/eth.png',
+ aggregators: [],
+ logo: 'https://example.com/eth.png',
+ isETH: true,
+ isNative: true,
+ accountType: 'eip155:1/erc20:0xtoken1',
+ networkBadgeSource: 'network-badge-source',
+ balanceInSelectedCurrency: '$3000.00',
+ standard: TokenStandard.ERC20,
+ fiat: { balance: '3000' },
+ rawBalance: '0x1234',
+} as unknown as AssetType;
+
+const mockSolanaToken: AssetType = {
+ address: '0xsolanatoken',
+ chainId: 'solana:mainnet',
+ symbol: 'SOL',
+ ticker: 'SOL',
+ decimals: 9,
+ balance: '10.5',
+ balanceFiat: '$500.00',
+ image: 'https://example.com/sol.png',
+ aggregators: [],
+ logo: 'https://example.com/sol.png',
+ isNative: true,
+ accountType: 'solana:mainnet/spl:0xsoltoken1',
+ networkBadgeSource: 'network-badge-source',
+ balanceInSelectedCurrency: '$500.00',
+ standard: TokenStandard.ERC20,
+ fiat: { balance: '500' },
+ rawBalance: '0x5678',
+} as unknown as AssetType;
+
+const mockTronToken: AssetType = {
+ address: '0xtrontoken',
+ chainId: 'tron:mainnet',
+ symbol: 'TRX',
+ ticker: 'TRX',
+ decimals: 6,
+ balance: '100',
+ balanceFiat: '$200.00',
+ image: 'https://example.com/trx.png',
+ aggregators: [],
+ logo: 'https://example.com/trx.png',
+ isNative: true,
+ accountType: 'tron:mainnet/trc20:0xtrontoken1',
+ networkBadgeSource: 'network-badge-source',
+ balanceInSelectedCurrency: '$200.00',
+ standard: TokenStandard.ERC20,
+ fiat: { balance: '200' },
+ rawBalance: '0x9abc',
+} as unknown as AssetType;
+
+const mockBitcoinToken: AssetType = {
+ address: '0xbtctoken',
+ chainId: 'bip122:000000000019d6689c085ae165831e93',
+ symbol: 'BTC',
+ ticker: 'BTC',
+ decimals: 8,
+ balance: '0.5',
+ balanceFiat: '$25000.00',
+ image: 'https://example.com/btc.png',
+ aggregators: [],
+ logo: 'https://example.com/btc.png',
+ isNative: true,
+ accountType: 'bip122:000000000019d6689c085ae165831e93/slip44:0',
+ networkBadgeSource: 'network-badge-source',
+ balanceInSelectedCurrency: '$25000.00',
+ standard: TokenStandard.ERC20,
+ fiat: { balance: '25000' },
+ rawBalance: '0xdef0',
+} as unknown as AssetType;
+
+describe('useSendTokens', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUseSendType.mockReturnValue({
+ isEvmSendType: undefined,
+ isEvmNativeSendType: undefined,
+ isNonEvmSendType: undefined,
+ isNonEvmNativeSendType: undefined,
+ isSolanaSendType: undefined,
+ isBitcoinSendType: undefined,
+ isTronSendType: undefined,
+ });
+ });
+
+ it('returns all tokens when no send type is set', () => {
+ mockUseAccountTokens.mockReturnValue([
+ mockEvmToken,
+ mockSolanaToken,
+ mockTronToken,
+ mockBitcoinToken,
+ ]);
+
+ const { result } = renderHook(() => useSendTokens());
+
+ expect(mockUseAccountTokens).toHaveBeenCalledWith({
+ includeNoBalance: false,
+ });
+ expect(result.current).toHaveLength(4);
+ });
+
+ it('filters to EVM tokens when isEvmSendType is true', () => {
+ mockUseSendType.mockReturnValue({
+ isEvmSendType: true,
+ isEvmNativeSendType: undefined,
+ isNonEvmSendType: undefined,
+ isNonEvmNativeSendType: undefined,
+ isSolanaSendType: undefined,
+ isBitcoinSendType: undefined,
+ isTronSendType: undefined,
+ });
+ mockUseAccountTokens.mockReturnValue([
+ mockEvmToken,
+ mockSolanaToken,
+ mockTronToken,
+ mockBitcoinToken,
+ ]);
+
+ const { result } = renderHook(() => useSendTokens());
+
+ expect(mockUseAccountTokens).toHaveBeenCalledWith({
+ includeNoBalance: false,
+ });
+ expect(result.current).toHaveLength(1);
+ expect(result.current[0]).toEqual(mockEvmToken);
+ });
+
+ it('filters to Solana tokens when isSolanaSendType is true', () => {
+ mockUseSendType.mockReturnValue({
+ isEvmSendType: undefined,
+ isEvmNativeSendType: undefined,
+ isNonEvmSendType: true,
+ isNonEvmNativeSendType: undefined,
+ isSolanaSendType: true,
+ isBitcoinSendType: undefined,
+ isTronSendType: undefined,
+ });
+ mockUseAccountTokens.mockReturnValue([
+ mockEvmToken,
+ mockSolanaToken,
+ mockTronToken,
+ mockBitcoinToken,
+ ]);
+
+ const { result } = renderHook(() => useSendTokens());
+
+ expect(mockUseAccountTokens).toHaveBeenCalledWith({
+ includeNoBalance: false,
+ });
+ expect(result.current).toHaveLength(1);
+ expect(result.current[0]).toEqual(mockSolanaToken);
+ });
+
+ it('filters to Tron tokens when isTronSendType is true', () => {
+ mockUseSendType.mockReturnValue({
+ isEvmSendType: undefined,
+ isEvmNativeSendType: undefined,
+ isNonEvmSendType: true,
+ isNonEvmNativeSendType: undefined,
+ isSolanaSendType: undefined,
+ isBitcoinSendType: undefined,
+ isTronSendType: true,
+ });
+ mockUseAccountTokens.mockReturnValue([
+ mockEvmToken,
+ mockSolanaToken,
+ mockTronToken,
+ mockBitcoinToken,
+ ]);
+
+ const { result } = renderHook(() => useSendTokens());
+
+ expect(mockUseAccountTokens).toHaveBeenCalledWith({
+ includeNoBalance: false,
+ });
+ expect(result.current).toHaveLength(1);
+ expect(result.current[0]).toEqual(mockTronToken);
+ });
+
+ it('filters to Bitcoin tokens when isBitcoinSendType is true', () => {
+ mockUseSendType.mockReturnValue({
+ isEvmSendType: undefined,
+ isEvmNativeSendType: undefined,
+ isNonEvmSendType: true,
+ isNonEvmNativeSendType: undefined,
+ isSolanaSendType: undefined,
+ isBitcoinSendType: true,
+ isTronSendType: undefined,
+ });
+ mockUseAccountTokens.mockReturnValue([
+ mockEvmToken,
+ mockSolanaToken,
+ mockTronToken,
+ mockBitcoinToken,
+ ]);
+
+ const { result } = renderHook(() => useSendTokens());
+
+ expect(mockUseAccountTokens).toHaveBeenCalledWith({
+ includeNoBalance: false,
+ });
+ expect(result.current).toHaveLength(1);
+ expect(result.current[0]).toEqual(mockBitcoinToken);
+ });
+
+ it('passes includeNoBalance option to useAccountTokens', () => {
+ mockUseAccountTokens.mockReturnValue([mockEvmToken]);
+
+ renderHook(() => useSendTokens({ includeNoBalance: true }));
+
+ expect(mockUseAccountTokens).toHaveBeenCalledWith({
+ includeNoBalance: true,
+ });
+ });
+
+ it('prioritizes first matching account type when multiple are true', () => {
+ mockUseSendType.mockReturnValue({
+ isEvmSendType: true,
+ isEvmNativeSendType: undefined,
+ isNonEvmSendType: true,
+ isNonEvmNativeSendType: undefined,
+ isSolanaSendType: true,
+ isBitcoinSendType: undefined,
+ isTronSendType: undefined,
+ });
+ mockUseAccountTokens.mockReturnValue([
+ mockEvmToken,
+ mockSolanaToken,
+ mockTronToken,
+ mockBitcoinToken,
+ ]);
+
+ const { result } = renderHook(() => useSendTokens());
+
+ expect(result.current).toHaveLength(1);
+ expect(result.current[0]).toEqual(mockEvmToken);
+ });
+});
diff --git a/app/components/Views/confirmations/hooks/send/useSendTokens.ts b/app/components/Views/confirmations/hooks/send/useSendTokens.ts
new file mode 100644
index 00000000000..8ca012ef860
--- /dev/null
+++ b/app/components/Views/confirmations/hooks/send/useSendTokens.ts
@@ -0,0 +1,41 @@
+import { useMemo } from 'react';
+import { AssetType } from '../../types/token';
+import { useAccountTokens } from './useAccountTokens';
+import { useSendType } from './useSendType';
+
+export function useSendTokens({
+ includeNoBalance = false,
+}: {
+ includeNoBalance?: boolean;
+} = {}): AssetType[] {
+ const { isEvmSendType, isSolanaSendType, isTronSendType, isBitcoinSendType } =
+ useSendType();
+ const allTokens = useAccountTokens({ includeNoBalance });
+
+ return useMemo(() => {
+ const accountTypeMap: Record = {
+ eip155: !!isEvmSendType,
+ solana: !!isSolanaSendType,
+ tron: !!isTronSendType,
+ bip122: !!isBitcoinSendType,
+ };
+
+ const matchedAccountType = Object.entries(accountTypeMap).find(
+ ([, isType]) => isType,
+ )?.[0];
+
+ if (!matchedAccountType) {
+ return allTokens;
+ }
+
+ return allTokens.filter((token) =>
+ token.accountType?.includes(matchedAccountType),
+ );
+ }, [
+ allTokens,
+ isEvmSendType,
+ isSolanaSendType,
+ isTronSendType,
+ isBitcoinSendType,
+ ]);
+}
diff --git a/app/components/Views/confirmations/hooks/send/useSendType.test.ts b/app/components/Views/confirmations/hooks/send/useSendType.test.ts
index 2032a0d6a5b..5f9de050566 100644
--- a/app/components/Views/confirmations/hooks/send/useSendType.test.ts
+++ b/app/components/Views/confirmations/hooks/send/useSendType.test.ts
@@ -1,20 +1,342 @@
import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider';
-import { evmSendStateMock } from '../../__mocks__/send.mock';
+import {
+ evmSendStateMock,
+ EVM_NATIVE_ASSET,
+ SOLANA_ASSET,
+} from '../../__mocks__/send.mock';
import { useSendType } from './useSendType';
+import { useParams } from '../../../../../util/navigation/navUtils';
+import { useSendContext } from '../../context/send-context';
+import { AssetType } from '../../types/token';
const mockState = {
state: evmSendStateMock,
};
+jest.mock('../../../../../util/navigation/navUtils', () => ({
+ useParams: jest.fn(),
+}));
+
+jest.mock('../../context/send-context', () => ({
+ useSendContext: jest.fn(),
+}));
+
describe('useSendType', () => {
- it('return types of send', () => {
- const { result } = renderHookWithProvider(() => useSendType(), mockState);
- expect(result.current).toEqual({
- isEvmNativeSendType: undefined,
- isEvmSendType: undefined,
- isNonEvmNativeSendType: undefined,
- isNonEvmSendType: undefined,
- isSolanaSendType: undefined,
+ const mockUseParams = jest.mocked(useParams);
+ const mockUseSendContext = jest.mocked(useSendContext);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseParams.mockReturnValue(undefined);
+ mockUseSendContext.mockReturnValue({
+ asset: undefined,
+ chainId: undefined,
+ fromAccount: undefined,
+ from: '',
+ maxValueMode: false,
+ to: undefined,
+ updateAsset: jest.fn(),
+ updateTo: jest.fn(),
+ updateValue: jest.fn(),
+ value: undefined,
+ });
+ });
+
+ describe('with no asset or predefined recipient', () => {
+ it('returns undefined for all send types', () => {
+ const { result } = renderHookWithProvider(() => useSendType(), mockState);
+
+ expect(result.current).toEqual({
+ isEvmNativeSendType: undefined,
+ isEvmSendType: undefined,
+ isNonEvmNativeSendType: undefined,
+ isNonEvmSendType: undefined,
+ isSolanaSendType: undefined,
+ });
+ });
+ });
+
+ describe('EVM assets', () => {
+ it('identifies EVM address as EVM send type', () => {
+ mockUseSendContext.mockReturnValue({
+ asset: {
+ ...EVM_NATIVE_ASSET,
+ address: '0x1234567890123456789012345678901234567890',
+ } as unknown as AssetType,
+ chainId: undefined,
+ fromAccount: undefined,
+ from: '',
+ maxValueMode: false,
+ to: undefined,
+ updateAsset: jest.fn(),
+ updateTo: jest.fn(),
+ updateValue: jest.fn(),
+ value: undefined,
+ });
+
+ const { result } = renderHookWithProvider(() => useSendType(), mockState);
+
+ expect(result.current.isEvmSendType).toBe(true);
+ });
+
+ it('identifies EVM native asset as EVM native send type', () => {
+ mockUseSendContext.mockReturnValue({
+ asset: EVM_NATIVE_ASSET as unknown as AssetType,
+ chainId: undefined,
+ fromAccount: undefined,
+ from: '',
+ maxValueMode: false,
+ to: undefined,
+ updateAsset: jest.fn(),
+ updateTo: jest.fn(),
+ updateValue: jest.fn(),
+ value: undefined,
+ });
+
+ const { result } = renderHookWithProvider(() => useSendType(), mockState);
+
+ expect(result.current.isEvmSendType).toBe(true);
+ expect(result.current.isEvmNativeSendType).toBe(true);
+ });
+
+ it('identifies EVM token as EVM send type but not native', () => {
+ mockUseSendContext.mockReturnValue({
+ asset: {
+ ...EVM_NATIVE_ASSET,
+ image: '',
+ isNative: false,
+ },
+ chainId: undefined,
+ fromAccount: undefined,
+ from: '',
+ maxValueMode: false,
+ to: undefined,
+ updateAsset: jest.fn(),
+ updateTo: jest.fn(),
+ updateValue: jest.fn(),
+ value: undefined,
+ });
+
+ const { result } = renderHookWithProvider(() => useSendType(), mockState);
+
+ expect(result.current.isEvmSendType).toBe(true);
+ expect(result.current.isEvmNativeSendType).toBe(false);
+ });
+ });
+
+ describe('Solana assets', () => {
+ it('identifies Solana chain ID as Solana send type', () => {
+ mockUseSendContext.mockReturnValue({
+ asset: SOLANA_ASSET,
+ chainId: undefined,
+ fromAccount: undefined,
+ from: '',
+ maxValueMode: false,
+ to: undefined,
+ updateAsset: jest.fn(),
+ updateTo: jest.fn(),
+ updateValue: jest.fn(),
+ value: undefined,
+ });
+
+ const { result } = renderHookWithProvider(() => useSendType(), mockState);
+
+ expect(result.current.isSolanaSendType).toBe(true);
+ expect(result.current.isNonEvmSendType).toBe(true);
+ });
+
+ it('identifies Solana native asset as non-EVM native send type', () => {
+ mockUseSendContext.mockReturnValue({
+ asset: SOLANA_ASSET,
+ chainId: undefined,
+ fromAccount: undefined,
+ from: '',
+ maxValueMode: false,
+ to: undefined,
+ updateAsset: jest.fn(),
+ updateTo: jest.fn(),
+ updateValue: jest.fn(),
+ value: undefined,
+ });
+
+ const { result } = renderHookWithProvider(() => useSendType(), mockState);
+
+ expect(result.current.isNonEvmNativeSendType).toBe(true);
+ expect(result.current.isSolanaSendType).toBe(true);
+ });
+ });
+
+ describe('predefined recipients', () => {
+ it('identifies predefined EVM recipient as EVM send type', () => {
+ mockUseParams.mockReturnValue({
+ predefinedRecipient: {
+ address: '0x1234567890123456789012345678901234567890',
+ chainType: 'evm',
+ },
+ });
+
+ const { result } = renderHookWithProvider(() => useSendType(), mockState);
+
+ expect(result.current.isEvmSendType).toBe(true);
+ });
+
+ it('identifies predefined Solana recipient as Solana send type', () => {
+ mockUseParams.mockReturnValue({
+ predefinedRecipient: {
+ address: '7W54AwGDYRF7Xmoi6phjTnrQhruYtoUdCKJMYAXP7VWC',
+ chainType: 'solana',
+ },
+ });
+
+ const { result } = renderHookWithProvider(() => useSendType(), mockState);
+
+ expect(result.current.isSolanaSendType).toBe(true);
+ expect(result.current.isNonEvmSendType).toBe(true);
+ });
+ });
+
+ describe('EVM vs non-EVM detection', () => {
+ it('returns undefined for EVM when asset has no address', () => {
+ mockUseSendContext.mockReturnValue({
+ asset: {
+ ...EVM_NATIVE_ASSET,
+ address: undefined as unknown as string,
+ },
+ chainId: undefined,
+ fromAccount: undefined,
+ from: '',
+ maxValueMode: false,
+ to: undefined,
+ updateAsset: jest.fn(),
+ updateTo: jest.fn(),
+ updateValue: jest.fn(),
+ value: undefined,
+ });
+
+ const { result } = renderHookWithProvider(() => useSendType(), mockState);
+
+ expect(result.current.isEvmSendType).toBeUndefined();
+ });
+
+ it('returns undefined for non-EVM when asset has no chainId', () => {
+ mockUseSendContext.mockReturnValue({
+ asset: {
+ ...SOLANA_ASSET,
+ chainId: undefined as unknown as string,
+ },
+ chainId: undefined,
+ fromAccount: undefined,
+ from: '',
+ maxValueMode: false,
+ to: undefined,
+ updateAsset: jest.fn(),
+ updateTo: jest.fn(),
+ updateValue: jest.fn(),
+ value: undefined,
+ });
+
+ const { result } = renderHookWithProvider(() => useSendType(), mockState);
+
+ expect(result.current.isNonEvmSendType).toBeUndefined();
+ });
+ });
+
+ describe('native asset detection', () => {
+ it('returns undefined for native status when asset has no isNative property', () => {
+ mockUseSendContext.mockReturnValue({
+ asset: {
+ address: '0x1234567890123456789012345678901234567890',
+ } as unknown as typeof EVM_NATIVE_ASSET,
+ chainId: undefined,
+ fromAccount: undefined,
+ from: '',
+ maxValueMode: false,
+ to: undefined,
+ updateAsset: jest.fn(),
+ updateTo: jest.fn(),
+ updateValue: jest.fn(),
+ value: undefined,
+ });
+
+ const { result } = renderHookWithProvider(() => useSendType(), mockState);
+
+ expect(result.current.isEvmNativeSendType).toBeUndefined();
+ expect(result.current.isNonEvmNativeSendType).toBeUndefined();
+ });
+
+ it('handles false native status correctly', () => {
+ mockUseSendContext.mockReturnValue({
+ asset: {
+ ...EVM_NATIVE_ASSET,
+ isNative: false,
+ image: '',
+ },
+ chainId: undefined,
+ fromAccount: undefined,
+ from: '',
+ maxValueMode: false,
+ to: undefined,
+ updateAsset: jest.fn(),
+ updateTo: jest.fn(),
+ updateValue: jest.fn(),
+ value: undefined,
+ });
+
+ const { result } = renderHookWithProvider(() => useSendType(), mockState);
+
+ expect(result.current.isEvmNativeSendType).toBe(false);
+ });
+ });
+
+ describe('predefined recipient priority', () => {
+ it('prioritizes predefined EVM over asset address', () => {
+ mockUseParams.mockReturnValue({
+ predefinedRecipient: {
+ address: '0x1234567890123456789012345678901234567890',
+ chainType: 'evm',
+ },
+ });
+ mockUseSendContext.mockReturnValue({
+ asset: SOLANA_ASSET,
+ chainId: undefined,
+ fromAccount: undefined,
+ from: '',
+ maxValueMode: false,
+ to: undefined,
+ updateAsset: jest.fn(),
+ updateTo: jest.fn(),
+ updateValue: jest.fn(),
+ value: undefined,
+ });
+
+ const { result } = renderHookWithProvider(() => useSendType(), mockState);
+
+ expect(result.current.isEvmSendType).toBe(true);
+ });
+
+ it('prioritizes predefined Solana over asset chainId', () => {
+ mockUseParams.mockReturnValue({
+ predefinedRecipient: {
+ address: '7W54AwGDYRF7Xmoi6phjTnrQhruYtoUdCKJMYAXP7VWC',
+ chainType: 'solana',
+ },
+ });
+ mockUseSendContext.mockReturnValue({
+ asset: EVM_NATIVE_ASSET,
+ chainId: undefined,
+ fromAccount: undefined,
+ from: '',
+ maxValueMode: false,
+ to: undefined,
+ updateAsset: jest.fn(),
+ updateTo: jest.fn(),
+ updateValue: jest.fn(),
+ value: undefined,
+ });
+
+ const { result } = renderHookWithProvider(() => useSendType(), mockState);
+
+ expect(result.current.isSolanaSendType).toBe(true);
});
});
});
diff --git a/app/components/Views/confirmations/hooks/send/useSendType.ts b/app/components/Views/confirmations/hooks/send/useSendType.ts
index 5a574ba7657..b4cb0cbf51f 100644
--- a/app/components/Views/confirmations/hooks/send/useSendType.ts
+++ b/app/components/Views/confirmations/hooks/send/useSendType.ts
@@ -5,7 +5,7 @@ import {
/// END:ONLY_INCLUDE_IF
isSolanaChainId,
} from '@metamask/bridge-controller';
-import { useMemo } from 'react';
+import { useCallback, useMemo } from 'react';
import { useSendContext } from '../../context/send-context';
import {
@@ -14,34 +14,64 @@ import {
isTronChainId,
/// END:ONLY_INCLUDE_IF
} from '../../../../../core/Multichain/utils';
+import { useParams } from '../../../../../util/navigation/navUtils';
+import { PredefinedRecipient } from '../../utils/send';
export const useSendType = () => {
const { asset } = useSendContext();
+ const { predefinedRecipient } =
+ useParams<{
+ predefinedRecipient: PredefinedRecipient;
+ }>() || {};
+
+ const isPredefinedEvm = predefinedRecipient?.chainType === 'evm';
+ const isPredefinedBitcoin = predefinedRecipient?.chainType === 'bitcoin';
+ const isPredefinedSolana = predefinedRecipient?.chainType === 'solana';
+ const isPredefinedTron = predefinedRecipient?.chainType === 'tron';
+
+ const isPredefinedNonEvm =
+ predefinedRecipient?.chainType && predefinedRecipient.chainType !== 'evm';
+
const isEvmSendType = useMemo(
- () => (asset?.address ? isEvmAddress(asset.address) : undefined),
- [asset?.address],
+ () =>
+ isPredefinedEvm ||
+ (asset?.address ? isEvmAddress(asset.address) : undefined),
+ [asset?.address, isPredefinedEvm],
);
+
const isNonEvmSendType = useMemo(
- () => (asset?.chainId ? isNonEvmChainId(asset.chainId) : undefined),
+ () =>
+ isPredefinedNonEvm ||
+ (asset?.chainId ? isNonEvmChainId(asset.chainId) : undefined),
+ [asset?.chainId, isPredefinedNonEvm],
+ );
+
+ const createChainTypeCheck = useCallback(
+ (
+ isPredefined: boolean | undefined,
+ chainChecker: (chainId: string) => boolean,
+ ) =>
+ isPredefined ||
+ (asset?.chainId ? chainChecker(asset.chainId) : undefined),
[asset?.chainId],
);
const isSolanaSendType = useMemo(
- () => (asset?.chainId ? isSolanaChainId(asset.chainId) : undefined),
- [asset?.chainId],
+ () => createChainTypeCheck(isPredefinedSolana, isSolanaChainId),
+ [createChainTypeCheck, isPredefinedSolana],
);
/// BEGIN:ONLY_INCLUDE_IF(bitcoin)
const isBitcoinSendType = useMemo(
- () => (asset?.chainId ? isBitcoinChainId(asset.chainId) : undefined),
- [asset?.chainId],
+ () => createChainTypeCheck(isPredefinedBitcoin, isBitcoinChainId),
+ [createChainTypeCheck, isPredefinedBitcoin],
);
/// END:ONLY_INCLUDE_IF
/// BEGIN:ONLY_INCLUDE_IF(tron)
const isTronSendType = useMemo(
- () => (asset?.chainId ? isTronChainId(asset.chainId) : undefined),
- [asset?.chainId],
+ () => createChainTypeCheck(isPredefinedTron, isTronChainId),
+ [createChainTypeCheck, isPredefinedTron],
);
/// END:ONLY_INCLUDE_IF
diff --git a/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts b/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts
index f459b0cc8fa..fb79267c211 100644
--- a/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts
+++ b/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts
@@ -8,11 +8,16 @@ import {
} from '../../__mocks__/send.mock';
import { useSendContext } from '../../context/send-context';
import { useToAddressValidation } from './useToAddressValidation';
+import { useParams } from '../../../../../util/navigation/navUtils';
jest.mock('../../context/send-context', () => ({
useSendContext: jest.fn(),
}));
+jest.mock('../../../../../util/navigation/navUtils', () => ({
+ useParams: jest.fn(),
+}));
+
const mockState = {
state: evmSendStateMock,
};
@@ -21,7 +26,14 @@ const mockUseSendContext = useSendContext as jest.MockedFunction<
typeof useSendContext
>;
+const mockUseParams = jest.mocked(useParams);
+
describe('useToAddressValidation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseParams.mockReturnValue(undefined);
+ });
+
it('return fields for to address error and warning', () => {
mockUseSendContext.mockReturnValue(
{} as unknown as ReturnType,
diff --git a/app/components/Views/confirmations/hooks/useSendNavigation.test.ts b/app/components/Views/confirmations/hooks/useSendNavigation.test.ts
index 8f3767ba5bd..24eae996af8 100644
--- a/app/components/Views/confirmations/hooks/useSendNavigation.test.ts
+++ b/app/components/Views/confirmations/hooks/useSendNavigation.test.ts
@@ -35,9 +35,10 @@ describe('useSendNavigation', () => {
const { result } = renderHookWithProvider(() => useSendNavigation(), {
state: mockState,
});
- result.current.navigateToSendPage(InitSendLocation.AssetOverview, {
- name: 'ETHEREUM',
- } as AssetType);
+ result.current.navigateToSendPage({
+ location: InitSendLocation.AssetOverview,
+ asset: { name: 'ETHEREUM' } as AssetType,
+ });
expect(mockNavigate).toHaveBeenCalledWith('SendFlowView');
});
@@ -45,9 +46,10 @@ describe('useSendNavigation', () => {
const { result } = renderHookWithProvider(() => useSendNavigation(), {
state: rffSendRedesignEnabledMock,
});
- result.current.navigateToSendPage(InitSendLocation.AssetOverview, {
- name: 'ETHEREUM',
- } as AssetType);
+ result.current.navigateToSendPage({
+ location: InitSendLocation.AssetOverview,
+ asset: { name: 'ETHEREUM' } as AssetType,
+ });
expect(mockNavigate.mock.calls[0][0]).toEqual('Send');
});
});
diff --git a/app/components/Views/confirmations/hooks/useSendNavigation.ts b/app/components/Views/confirmations/hooks/useSendNavigation.ts
index 5062deb3249..9569e4da533 100644
--- a/app/components/Views/confirmations/hooks/useSendNavigation.ts
+++ b/app/components/Views/confirmations/hooks/useSendNavigation.ts
@@ -1,11 +1,9 @@
-import { Nft } from '@metamask/assets-controllers';
import { useCallback } from 'react';
import { useNavigation } from '@react-navigation/native';
import { useSelector } from 'react-redux';
import { selectSendRedesignFlags } from '../../../../selectors/featureFlagController/confirmations';
-import { handleSendPageNavigation } from '../utils/send';
-import { AssetType } from '../types/token';
+import { handleSendPageNavigation, SendNavigationParams } from '../utils/send';
export const useSendNavigation = () => {
const { navigate } = useNavigation();
@@ -14,13 +12,11 @@ export const useSendNavigation = () => {
);
const navigateToSendPage = useCallback(
- (location: string, asset?: AssetType | Nft) => {
- handleSendPageNavigation(
- navigate,
- location,
+ (params: Omit) => {
+ handleSendPageNavigation(navigate, {
+ ...params,
isSendRedesignEnabled,
- asset,
- );
+ });
},
[navigate, isSendRedesignEnabled],
);
diff --git a/app/components/Views/confirmations/utils/address.test.ts b/app/components/Views/confirmations/utils/address.test.ts
new file mode 100644
index 00000000000..7e6d38570cd
--- /dev/null
+++ b/app/components/Views/confirmations/utils/address.test.ts
@@ -0,0 +1,130 @@
+import { derivePredefinedRecipientParams } from './address';
+import { ChainType } from './send';
+
+describe('derivePredefinedRecipientParams', () => {
+ describe('EVM addresses', () => {
+ it('returns EVM chain type for valid EVM address', () => {
+ const address = '0xC4955C0d639D99699Bfd7Ec54d9FaFEe40e4D272';
+
+ const result = derivePredefinedRecipientParams(address);
+
+ expect(result).toEqual({
+ address,
+ chainType: ChainType.EVM,
+ });
+ });
+ });
+
+ describe('Solana addresses', () => {
+ it('returns Solana chain type for valid Solana address', () => {
+ const address = '7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV';
+
+ const result = derivePredefinedRecipientParams(address);
+
+ expect(result).toEqual({
+ address,
+ chainType: ChainType.SOLANA,
+ });
+ });
+ });
+
+ describe('Bitcoin addresses', () => {
+ it('returns Bitcoin chain type for valid P2WPKH mainnet address', () => {
+ const address = 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k';
+
+ const result = derivePredefinedRecipientParams(address);
+
+ expect(result).toEqual({
+ address,
+ chainType: ChainType.BITCOIN,
+ });
+ });
+
+ it('returns Bitcoin chain type for valid P2PKH mainnet address', () => {
+ const address = '1P5ZEDWTKTFGxQjZphgWPQUpe554WKDfHQ';
+
+ const result = derivePredefinedRecipientParams(address);
+
+ expect(result).toEqual({
+ address,
+ chainType: ChainType.BITCOIN,
+ });
+ });
+ });
+
+ describe('Tron addresses', () => {
+ it('returns Tron chain type for valid Tron address', () => {
+ const address = 'TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7';
+
+ const result = derivePredefinedRecipientParams(address);
+
+ expect(result).toEqual({
+ address,
+ chainType: ChainType.TRON,
+ });
+ });
+
+ it('returns Tron chain type for another valid Tron address', () => {
+ const address = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
+
+ const result = derivePredefinedRecipientParams(address);
+
+ expect(result).toEqual({
+ address,
+ chainType: ChainType.TRON,
+ });
+ });
+ });
+
+ describe('Invalid addresses', () => {
+ it('returns undefined for invalid address', () => {
+ const address = 'invalid-address';
+
+ const result = derivePredefinedRecipientParams(address);
+
+ expect(result).toBeUndefined();
+ });
+
+ it('returns undefined for empty string', () => {
+ const address = '';
+
+ const result = derivePredefinedRecipientParams(address);
+
+ expect(result).toBeUndefined();
+ });
+
+ it('returns undefined for address with wrong prefix', () => {
+ const address = 'ANPeeaaFhwdYaBjwE6tz8N6Vp1y66i5NjE';
+
+ const result = derivePredefinedRecipientParams(address);
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('Priority order', () => {
+ it('checks EVM address first', () => {
+ const evmAddress = '0xC4955C0d639D99699Bfd7Ec54d9FaFEe40e4D272';
+ const result = derivePredefinedRecipientParams(evmAddress);
+ expect(result?.chainType).toBe(ChainType.EVM);
+ });
+
+ it('checks Solana address after EVM', () => {
+ const solanaAddress = '7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV';
+ const result = derivePredefinedRecipientParams(solanaAddress);
+ expect(result?.chainType).toBe(ChainType.SOLANA);
+ });
+
+ it('checks Bitcoin address after Solana', () => {
+ const btcAddress = 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k';
+ const result = derivePredefinedRecipientParams(btcAddress);
+ expect(result?.chainType).toBe(ChainType.BITCOIN);
+ });
+
+ it('checks Tron address last', () => {
+ const tronAddress = 'TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7';
+ const result = derivePredefinedRecipientParams(tronAddress);
+ expect(result?.chainType).toBe(ChainType.TRON);
+ });
+ });
+});
diff --git a/app/components/Views/confirmations/utils/address.ts b/app/components/Views/confirmations/utils/address.ts
new file mode 100644
index 00000000000..833f3ef660a
--- /dev/null
+++ b/app/components/Views/confirmations/utils/address.ts
@@ -0,0 +1,39 @@
+import { ChainType } from './send';
+import { isAddress as isSolanaAddress } from '@solana/addresses';
+import { isAddress as isEvmAddress } from 'ethers/lib/utils';
+import {
+ isBtcMainnetAddress,
+ isTronAddress,
+} from '../../../../core/Multichain/utils';
+
+export const derivePredefinedRecipientParams = (address: string) => {
+ if (isEvmAddress(address)) {
+ return {
+ address,
+ chainType: ChainType.EVM,
+ };
+ }
+
+ if (isSolanaAddress(address)) {
+ return {
+ address,
+ chainType: ChainType.SOLANA,
+ };
+ }
+
+ if (isBtcMainnetAddress(address)) {
+ return {
+ address,
+ chainType: ChainType.BITCOIN,
+ };
+ }
+
+ if (isTronAddress(address)) {
+ return {
+ address,
+ chainType: ChainType.TRON,
+ };
+ }
+
+ return undefined;
+};
diff --git a/app/components/Views/confirmations/utils/send.test.ts b/app/components/Views/confirmations/utils/send.test.ts
index 07dfa9a464d..d8c32158d17 100644
--- a/app/components/Views/confirmations/utils/send.test.ts
+++ b/app/components/Views/confirmations/utils/send.test.ts
@@ -44,26 +44,24 @@ jest.mock('../../../../lib/ppom/ppom-util', () => ({
describe('handleSendPageNavigation', () => {
it('navigates to legacy send page', () => {
const mockNavigate = jest.fn();
- handleSendPageNavigation(
- mockNavigate,
- InitSendLocation.WalletActions,
- false,
- {
+ handleSendPageNavigation(mockNavigate, {
+ location: InitSendLocation.WalletActions,
+ isSendRedesignEnabled: false,
+ asset: {
name: 'ETHEREUM',
} as AssetType,
- );
+ });
expect(mockNavigate).toHaveBeenCalledWith('SendFlowView');
});
it('navigates to send redesign page', () => {
const mockNavigate = jest.fn();
- handleSendPageNavigation(
- mockNavigate,
- InitSendLocation.WalletActions,
- true,
- {
+ handleSendPageNavigation(mockNavigate, {
+ location: InitSendLocation.WalletActions,
+ isSendRedesignEnabled: true,
+ asset: {
name: 'ETHEREUM',
} as AssetType,
- );
+ });
expect(mockNavigate.mock.calls[0][0]).toEqual('Send');
});
});
diff --git a/app/components/Views/confirmations/utils/send.ts b/app/components/Views/confirmations/utils/send.ts
index 669ecf5822e..9753fb5450e 100644
--- a/app/components/Views/confirmations/utils/send.ts
+++ b/app/components/Views/confirmations/utils/send.ts
@@ -30,6 +30,25 @@ import { AssetType, TokenStandard } from '../types/token';
import { MMM_ORIGIN } from '../constants/confirmations';
import { isNativeToken } from '../utils/generic';
+export enum ChainType {
+ EVM = 'evm',
+ SOLANA = 'solana',
+ BITCOIN = 'bitcoin',
+ TRON = 'tron',
+}
+
+export interface PredefinedRecipient {
+ address: string;
+ chainType: ChainType;
+}
+
+export interface SendNavigationParams {
+ location: string;
+ isSendRedesignEnabled: boolean;
+ asset?: AssetType | Nft;
+ predefinedRecipient?: PredefinedRecipient;
+}
+
const captureSendStartedEvent = (location: string) => {
const { trackEvent } = MetaMetrics.getInstance();
trackEvent(
@@ -51,16 +70,49 @@ export function isValidPositiveNumericString(str: string) {
return false;
}
}
-
+/**
+ * Navigates to the appropriate send flow screen based on the redesign flag and asset type.
+ *
+ * This function handles navigation for both the legacy and redesigned send flows. In the redesigned flow,
+ * it intelligently determines the starting screen based on whether an asset is provided:
+ * - No asset: starts at asset selection screen
+ * - ERC721 NFT: starts at recipient screen (since NFTs are non-divisible)
+ * - Other assets: starts at amount screen
+ *
+ * @param navigate - Navigation function that accepts a screen name and optional params object
+ * @param params - Object containing the navigation parameters
+ * @param params.location - Analytics identifier for where the send flow was initiated (e.g., 'wallet', 'token_details')
+ * @param params.isSendRedesignEnabled - Feature flag indicating whether to use the new send flow or legacy SendFlowView
+ * @param params.asset - Optional preselected asset (token or NFT) to send. When provided, skips the asset selection screen.
+ * @param params.predefinedRecipient - Optional recipient with chain information. Should be an object containing:
+ * - `address`: The recipient's address string
+ * - `chainType`: One of 'evm', 'solana', 'bitcoin', or 'tron'
+ *
+ * @remarks
+ * The predefinedRecipient is passed through navigation params and can be used by downstream screens
+ * to pre-populate the recipient field and determine the appropriate chain context for the transaction.
+ *
+ * @example
+ * ```typescript
+ * handleSendPageNavigation(navigation.navigate, {
+ * location: 'QRCode',
+ * isSendRedesignEnabled: true,
+ * predefinedRecipient: {
+ * address: '7W54AwGDYRF7X...',
+ * chainType: 'solana'
+ * }
+ * });
+ * ```
+ */
export const handleSendPageNavigation = (
navigate: (
screenName: RouteName,
params?: object,
) => void,
- location: string,
- isSendRedesignEnabled: boolean,
- asset?: AssetType | Nft,
+ params: SendNavigationParams,
) => {
+ const { location, isSendRedesignEnabled, asset, predefinedRecipient } =
+ params;
if (isSendRedesignEnabled) {
captureSendStartedEvent(location);
let screen = Routes.SEND.ASSET;
@@ -71,10 +123,12 @@ export const handleSendPageNavigation = (
screen = Routes.SEND.AMOUNT;
}
}
+
navigate(Routes.SEND.DEFAULT, {
screen,
params: {
asset,
+ predefinedRecipient,
},
});
} else {
diff --git a/app/components/hooks/useSendNonEvmAsset.ts b/app/components/hooks/useSendNonEvmAsset.ts
index 380f4b57324..e39f3cd047f 100644
--- a/app/components/hooks/useSendNonEvmAsset.ts
+++ b/app/components/hooks/useSendNonEvmAsset.ts
@@ -40,12 +40,11 @@ export function useSendNonEvmAsset({
const sendNonEvmAsset = useCallback(
async (location: string): Promise => {
if (isSendRedesignEnabled) {
- handleSendPageNavigation(
- navigation.navigate,
+ handleSendPageNavigation(navigation.navigate, {
location,
- true,
- asset.address ? (asset as TokenI) : undefined,
- );
+ isSendRedesignEnabled: true,
+ asset: asset.address ? (asset as TokenI) : undefined,
+ });
return true;
}
From 7b165c4651ca9733b626f65e101b8c9eee58ca9f Mon Sep 17 00:00:00 2001
From: Pedro Pablo Aste Kompen
Date: Tue, 25 Nov 2025 12:05:11 -0300
Subject: [PATCH 5/9] refactor(ramp): add horizontal padding to ScreenLayout
content (#23254)
## **Description**
Added horizontal padding (16px) to the `ScreenLayout` component's
content section to improve UI consistency across Ramp/Deposit screens.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/TRAM-2866
## **Manual testing steps**
```gherkin
Feature: ScreenLayout padding
Scenario: user views any Ramp screen
Given user opens any Ramp/Deposit flow screen
When user views the content area
Then content should have consistent 16px horizontal padding
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
.../__snapshots__/BuildQuote.test.tsx.snap | 24 +++++++++++++
.../__snapshots__/OrderDetails.test.tsx.snap | 18 ++++++++++
.../Quotes/__snapshots__/Quotes.test.tsx.snap | 4 +++
.../SendTransaction.test.tsx.snap | 6 ++++
.../ActivationKeyForm.test.tsx.snap | 1 +
.../__snapshots__/Settings.test.tsx.snap | 6 ++++
.../Aggregator/components/ScreenLayout.tsx | 1 +
.../AdditionalVerification.test.tsx.snap | 2 ++
.../__snapshots__/BankDetails.test.tsx.snap | 4 +++
.../__snapshots__/BasicInfo.test.tsx.snap | 14 ++++++++
.../__snapshots__/BuildQuote.test.tsx.snap | 34 +++++++++++++++++++
.../DepositOrderDetails.test.tsx.snap | 5 +++
.../__snapshots__/EnterAddress.test.tsx.snap | 8 +++++
.../__snapshots__/EnterEmail.test.tsx.snap | 8 +++++
.../__snapshots__/KycProcessing.test.tsx.snap | 12 +++++++
.../__snapshots__/WebviewModal.test.tsx.snap | 1 +
.../OrderProcessing.test.tsx.snap | 8 +++++
.../__snapshots__/OtpCode.test.tsx.snap | 10 ++++++
.../VerifyIdentity.test.tsx.snap | 2 ++
.../__snapshots__/ErrorView.test.tsx.snap | 2 ++
20 files changed, 170 insertions(+)
diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap
index bdbe4ab2c19..89da8e65ebc 100644
--- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap
+++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap
@@ -594,6 +594,7 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -1772,6 +1773,7 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -2786,6 +2788,7 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -5636,6 +5639,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -6518,6 +6522,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -7532,6 +7537,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -8959,6 +8965,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -9750,6 +9757,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -10764,6 +10772,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -12191,6 +12200,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -13326,6 +13336,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -14340,6 +14351,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -15007,6 +15019,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -16064,6 +16077,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -17078,6 +17092,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -18505,6 +18520,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -19260,6 +19276,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -20274,6 +20291,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -20941,6 +20959,7 @@ exports[`BuildQuote View renders correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -22119,6 +22138,7 @@ exports[`BuildQuote View renders correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -23133,6 +23153,7 @@ exports[`BuildQuote View renders correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -23703,6 +23724,7 @@ exports[`BuildQuote View renders correctly 2`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -24797,6 +24819,7 @@ exports[`BuildQuote View renders correctly 2`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -25875,6 +25898,7 @@ exports[`BuildQuote View renders correctly 2`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
diff --git a/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap
index 59e706e47db..68922e4a7b5 100644
--- a/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap
+++ b/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap
@@ -482,6 +482,7 @@ exports[`OrderDetails renders a cancelled order 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -1418,6 +1419,7 @@ exports[`OrderDetails renders a cancelled order 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -1975,6 +1977,7 @@ exports[`OrderDetails renders a completed order 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -2929,6 +2932,7 @@ exports[`OrderDetails renders a completed order 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -3486,6 +3490,7 @@ exports[`OrderDetails renders a created order 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -4455,6 +4460,7 @@ exports[`OrderDetails renders a created order 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -4960,6 +4966,7 @@ exports[`OrderDetails renders a failed order 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -5896,6 +5903,7 @@ exports[`OrderDetails renders a failed order 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -6453,6 +6461,7 @@ exports[`OrderDetails renders a pending order 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -7422,6 +7431,7 @@ exports[`OrderDetails renders a pending order 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -9049,6 +9059,7 @@ exports[`OrderDetails renders non-transacted orders 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -10033,6 +10044,7 @@ exports[`OrderDetails renders non-transacted orders 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -10603,6 +10615,7 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -11603,6 +11616,7 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -12160,6 +12174,7 @@ exports[`OrderDetails renders transacted orders that do not have timeDescription
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -13129,6 +13144,7 @@ exports[`OrderDetails renders transacted orders that do not have timeDescription
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -13634,6 +13650,7 @@ exports[`OrderDetails renders transacted orders that have timeDescriptionPending
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -14618,6 +14635,7 @@ exports[`OrderDetails renders transacted orders that have timeDescriptionPending
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap
index 0ed4a61df6e..1436c28f2f0 100644
--- a/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap
+++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap
@@ -1365,6 +1365,7 @@ exports[`Quotes custom action renders correctly after animation with the recomme
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -3697,6 +3698,7 @@ exports[`Quotes renders correctly after animation with expanded quotes 2`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -3785,6 +3787,7 @@ exports[`Quotes renders correctly after animation with expanded quotes 2`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -5585,6 +5588,7 @@ exports[`Quotes renders correctly after animation with the recommended quote 1`]
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
diff --git a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/__snapshots__/SendTransaction.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/__snapshots__/SendTransaction.test.tsx.snap
index 622768e4403..05edc904e23 100644
--- a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/__snapshots__/SendTransaction.test.tsx.snap
+++ b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/__snapshots__/SendTransaction.test.tsx.snap
@@ -461,6 +461,7 @@ exports[`SendTransaction View renders correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -715,6 +716,7 @@ exports[`SendTransaction View renders correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -1278,6 +1280,7 @@ exports[`SendTransaction View renders correctly for custom action payment method
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -1450,6 +1453,7 @@ exports[`SendTransaction View renders correctly for custom action payment method
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -2013,6 +2017,7 @@ exports[`SendTransaction View renders correctly for token 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -2271,6 +2276,7 @@ exports[`SendTransaction View renders correctly for token 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
diff --git a/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/ActivationKeyForm.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/ActivationKeyForm.test.tsx.snap
index 08989b72bb8..5c0ee199447 100644
--- a/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/ActivationKeyForm.test.tsx.snap
+++ b/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/ActivationKeyForm.test.tsx.snap
@@ -464,6 +464,7 @@ exports[`AddActivationKey renders correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
diff --git a/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/Settings.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/Settings.test.tsx.snap
index 9611833b41a..7d0c073b2f6 100644
--- a/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/Settings.test.tsx.snap
+++ b/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/Settings.test.tsx.snap
@@ -478,6 +478,7 @@ exports[`Settings Activation Keys renders correctly when is loading 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -1696,6 +1697,7 @@ exports[`Settings Activation Keys renders correctly when there are no keys 1`] =
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -2472,6 +2474,7 @@ exports[`Settings Region renders correctly when region is not set 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -3077,6 +3080,7 @@ exports[`Settings Region renders correctly when region is set 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -3720,6 +3724,7 @@ exports[`Settings renders correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -4363,6 +4368,7 @@ exports[`Settings renders correctly for internal builds 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
diff --git a/app/components/UI/Ramp/Aggregator/components/ScreenLayout.tsx b/app/components/UI/Ramp/Aggregator/components/ScreenLayout.tsx
index 8c68890df23..0ead307f810 100644
--- a/app/components/UI/Ramp/Aggregator/components/ScreenLayout.tsx
+++ b/app/components/UI/Ramp/Aggregator/components/ScreenLayout.tsx
@@ -23,6 +23,7 @@ const createStyles = (colors: Colors) =>
},
content: {
padding: 15,
+ paddingHorizontal: 16,
},
grow: {
flex: 1,
diff --git a/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap
index fe54a783c20..1c5aa3d7a5b 100644
--- a/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap
+++ b/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap
@@ -558,6 +558,7 @@ exports[`AdditionalVerification Component render matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -631,6 +632,7 @@ exports[`AdditionalVerification Component render matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
diff --git a/app/components/UI/Ramp/Deposit/Views/BankDetails/__snapshots__/BankDetails.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/BankDetails/__snapshots__/BankDetails.test.tsx.snap
index 61f845f6f05..54252d68ec3 100644
--- a/app/components/UI/Ramp/Deposit/Views/BankDetails/__snapshots__/BankDetails.test.tsx.snap
+++ b/app/components/UI/Ramp/Deposit/Views/BankDetails/__snapshots__/BankDetails.test.tsx.snap
@@ -580,6 +580,7 @@ exports[`BankDetails Component render matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -913,6 +914,7 @@ exports[`BankDetails Component render matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -1690,6 +1692,7 @@ exports[`BankDetails Component render matches snapshot with bank info shown 1`]
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -2236,6 +2239,7 @@ exports[`BankDetails Component render matches snapshot with bank info shown 1`]
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
diff --git a/app/components/UI/Ramp/Deposit/Views/BasicInfo/__snapshots__/BasicInfo.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/BasicInfo/__snapshots__/BasicInfo.test.tsx.snap
index 159a08e0341..0ecf3cbe46b 100644
--- a/app/components/UI/Ramp/Deposit/Views/BasicInfo/__snapshots__/BasicInfo.test.tsx.snap
+++ b/app/components/UI/Ramp/Deposit/Views/BasicInfo/__snapshots__/BasicInfo.test.tsx.snap
@@ -589,6 +589,7 @@ exports[`BasicInfo Component navigates to address page when form is valid and co
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -1422,6 +1423,7 @@ exports[`BasicInfo Component navigates to address page when form is valid and co
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -2175,6 +2177,7 @@ exports[`BasicInfo Component passes regions to DepositPhoneField component 1`] =
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -3008,6 +3011,7 @@ exports[`BasicInfo Component passes regions to DepositPhoneField component 1`] =
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -3761,6 +3765,7 @@ exports[`BasicInfo Component prefills form data when previousFormData is provide
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -4594,6 +4599,7 @@ exports[`BasicInfo Component prefills form data when previousFormData is provide
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -5347,6 +5353,7 @@ exports[`BasicInfo Component render matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -6180,6 +6187,7 @@ exports[`BasicInfo Component render matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -6933,6 +6941,7 @@ exports[`BasicInfo Component snapshot matches validation errors when continue is
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -7826,6 +7835,7 @@ exports[`BasicInfo Component snapshot matches validation errors when continue is
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -8579,6 +8589,7 @@ exports[`BasicInfo Component snapshot matches validation errors when continue is
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -9457,6 +9468,7 @@ exports[`BasicInfo Component snapshot matches validation errors when continue is
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -10210,6 +10222,7 @@ exports[`BasicInfo Component snapshot matches validation errors when continue is
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -10921,6 +10934,7 @@ exports[`BasicInfo Component snapshot matches validation errors when continue is
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
diff --git a/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap
index 519e4f78cc5..5f5a98b57b9 100644
--- a/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap
+++ b/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap
@@ -558,6 +558,7 @@ exports[`BuildQuote Component Continue button functionality displays error when
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -1813,6 +1814,7 @@ exports[`BuildQuote Component Continue button functionality displays error when
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -2432,6 +2434,7 @@ exports[`BuildQuote Component Continue button functionality displays error when
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -3687,6 +3690,7 @@ exports[`BuildQuote Component Continue button functionality displays error when
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -4306,6 +4310,7 @@ exports[`BuildQuote Component Continue button functionality displays error when
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -5561,6 +5566,7 @@ exports[`BuildQuote Component Continue button functionality displays error when
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -6180,6 +6186,7 @@ exports[`BuildQuote Component Keypad Functionality displays converted token amou
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -7374,6 +7381,7 @@ exports[`BuildQuote Component Keypad Functionality displays converted token amou
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -7993,6 +8001,7 @@ exports[`BuildQuote Component Keypad Functionality updates amount when keypad is
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -9187,6 +9196,7 @@ exports[`BuildQuote Component Keypad Functionality updates amount when keypad is
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -9805,6 +9815,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -10999,6 +11010,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -11618,6 +11630,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -12905,6 +12918,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -13524,6 +13538,7 @@ exports[`BuildQuote Component Payment Method Selection does not show the duratio
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -14673,6 +14688,7 @@ exports[`BuildQuote Component Payment Method Selection does not show the duratio
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -15292,6 +15308,7 @@ exports[`BuildQuote Component Payment Method Selection shows the right duration
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -16486,6 +16503,7 @@ exports[`BuildQuote Component Payment Method Selection shows the right duration
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -17105,6 +17123,7 @@ exports[`BuildQuote Component Region Selection displays EUR currency when select
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -18299,6 +18318,7 @@ exports[`BuildQuote Component Region Selection displays EUR currency when select
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -18918,6 +18938,7 @@ exports[`BuildQuote Component Region Selection displays default US region on ini
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -20112,6 +20133,7 @@ exports[`BuildQuote Component Region Selection displays default US region on ini
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -20731,6 +20753,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -21925,6 +21948,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -22544,6 +22568,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -23831,6 +23856,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -24450,6 +24476,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -25642,6 +25669,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -26261,6 +26289,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -27546,6 +27575,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -28165,6 +28195,7 @@ exports[`BuildQuote Component User Details Error displays user details error ale
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -29452,6 +29483,7 @@ exports[`BuildQuote Component User Details Error displays user details error ale
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -30071,6 +30103,7 @@ exports[`BuildQuote Component render matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -31265,6 +31298,7 @@ exports[`BuildQuote Component render matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
diff --git a/app/components/UI/Ramp/Deposit/Views/DepositOrderDetails/__snapshots__/DepositOrderDetails.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/DepositOrderDetails/__snapshots__/DepositOrderDetails.test.tsx.snap
index 24547746e00..26110bd3729 100644
--- a/app/components/UI/Ramp/Deposit/Views/DepositOrderDetails/__snapshots__/DepositOrderDetails.test.tsx.snap
+++ b/app/components/UI/Ramp/Deposit/Views/DepositOrderDetails/__snapshots__/DepositOrderDetails.test.tsx.snap
@@ -68,6 +68,7 @@ exports[`DepositOrderDetails Component renders an error screen if a CREATED orde
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -242,6 +243,7 @@ exports[`DepositOrderDetails Component renders error state correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -761,6 +763,7 @@ exports[`DepositOrderDetails Component renders loading state correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -867,6 +870,7 @@ exports[`DepositOrderDetails Component renders processing state correctly 1`] =
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -1411,6 +1415,7 @@ exports[`DepositOrderDetails Component renders success state correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
diff --git a/app/components/UI/Ramp/Deposit/Views/EnterAddress/__snapshots__/EnterAddress.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/EnterAddress/__snapshots__/EnterAddress.test.tsx.snap
index 2537a317f36..10553582273 100644
--- a/app/components/UI/Ramp/Deposit/Views/EnterAddress/__snapshots__/EnterAddress.test.tsx.snap
+++ b/app/components/UI/Ramp/Deposit/Views/EnterAddress/__snapshots__/EnterAddress.test.tsx.snap
@@ -589,6 +589,7 @@ exports[`EnterAddress Component displays form validation errors when continue is
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -1514,6 +1515,7 @@ exports[`EnterAddress Component displays form validation errors when continue is
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -2268,6 +2270,7 @@ exports[`EnterAddress Component prefills form data when previousFormData is prov
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -3125,6 +3128,7 @@ exports[`EnterAddress Component prefills form data when previousFormData is prov
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -3879,6 +3883,7 @@ exports[`EnterAddress Component render matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -4744,6 +4749,7 @@ exports[`EnterAddress Component render matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -5498,6 +5504,7 @@ exports[`EnterAddress Component shows text input for state when region is not US
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -6384,6 +6391,7 @@ exports[`EnterAddress Component shows text input for state when region is not US
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
diff --git a/app/components/UI/Ramp/Deposit/Views/EnterEmail/__snapshots__/EnterEmail.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/EnterEmail/__snapshots__/EnterEmail.test.tsx.snap
index 47d2c1851a4..a3d14868660 100644
--- a/app/components/UI/Ramp/Deposit/Views/EnterEmail/__snapshots__/EnterEmail.test.tsx.snap
+++ b/app/components/UI/Ramp/Deposit/Views/EnterEmail/__snapshots__/EnterEmail.test.tsx.snap
@@ -558,6 +558,7 @@ exports[`EnterEmail Component render matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -790,6 +791,7 @@ exports[`EnterEmail Component render matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -1420,6 +1422,7 @@ exports[`EnterEmail Component renders error message snapshot when API call fails
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -1666,6 +1669,7 @@ exports[`EnterEmail Component renders error message snapshot when API call fails
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -2296,6 +2300,7 @@ exports[`EnterEmail Component renders loading state snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -2528,6 +2533,7 @@ exports[`EnterEmail Component renders loading state snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -3158,6 +3164,7 @@ exports[`EnterEmail Component renders validation error snapshot invalid email 1`
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -3404,6 +3411,7 @@ exports[`EnterEmail Component renders validation error snapshot invalid email 1`
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
diff --git a/app/components/UI/Ramp/Deposit/Views/KycProcessing/__snapshots__/KycProcessing.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/KycProcessing/__snapshots__/KycProcessing.test.tsx.snap
index ae04ee2cc23..fd94bce5143 100644
--- a/app/components/UI/Ramp/Deposit/Views/KycProcessing/__snapshots__/KycProcessing.test.tsx.snap
+++ b/app/components/UI/Ramp/Deposit/Views/KycProcessing/__snapshots__/KycProcessing.test.tsx.snap
@@ -558,6 +558,7 @@ exports[`KycProcessing Component render matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -714,6 +715,7 @@ exports[`KycProcessing Component render matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -1306,6 +1308,7 @@ exports[`KycProcessing Component renders approved state snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -1484,6 +1487,7 @@ exports[`KycProcessing Component renders approved state snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -2112,6 +2116,7 @@ exports[`KycProcessing Component renders error state snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -2276,6 +2281,7 @@ exports[`KycProcessing Component renders error state snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -2904,6 +2910,7 @@ exports[`KycProcessing Component renders loading state snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -3060,6 +3067,7 @@ exports[`KycProcessing Component renders loading state snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -3652,6 +3660,7 @@ exports[`KycProcessing Component renders pending forms state snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -3816,6 +3825,7 @@ exports[`KycProcessing Component renders pending forms state snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -4444,6 +4454,7 @@ exports[`KycProcessing Component renders rejected state snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -4608,6 +4619,7 @@ exports[`KycProcessing Component renders rejected state snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/__snapshots__/WebviewModal.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/__snapshots__/WebviewModal.test.tsx.snap
index e11294acbfe..f7e9984c555 100644
--- a/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/__snapshots__/WebviewModal.test.tsx.snap
+++ b/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/__snapshots__/WebviewModal.test.tsx.snap
@@ -1142,6 +1142,7 @@ exports[`WebviewModal Component should display error view when webview HTTP erro
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
diff --git a/app/components/UI/Ramp/Deposit/Views/OrderProcessing/__snapshots__/OrderProcessing.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/OrderProcessing/__snapshots__/OrderProcessing.test.tsx.snap
index 019912ec739..3f999874f4a 100644
--- a/app/components/UI/Ramp/Deposit/Views/OrderProcessing/__snapshots__/OrderProcessing.test.tsx.snap
+++ b/app/components/UI/Ramp/Deposit/Views/OrderProcessing/__snapshots__/OrderProcessing.test.tsx.snap
@@ -42,6 +42,7 @@ exports[`OrderProcessing Component renders created state correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -526,6 +527,7 @@ exports[`OrderProcessing Component renders created state correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -635,6 +637,7 @@ exports[`OrderProcessing Component renders error state correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -1115,6 +1118,7 @@ exports[`OrderProcessing Component renders error state correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -1313,6 +1317,7 @@ exports[`OrderProcessing Component renders processing state correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -1797,6 +1802,7 @@ exports[`OrderProcessing Component renders processing state correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
@@ -1906,6 +1912,7 @@ exports[`OrderProcessing Component renders success state correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -2386,6 +2393,7 @@ exports[`OrderProcessing Component renders success state correctly 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
undefined,
diff --git a/app/components/UI/Ramp/Deposit/Views/OtpCode/__snapshots__/OtpCode.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/OtpCode/__snapshots__/OtpCode.test.tsx.snap
index 35c685a6e42..159b1b8af96 100644
--- a/app/components/UI/Ramp/Deposit/Views/OtpCode/__snapshots__/OtpCode.test.tsx.snap
+++ b/app/components/UI/Ramp/Deposit/Views/OtpCode/__snapshots__/OtpCode.test.tsx.snap
@@ -353,6 +353,7 @@ exports[`OtpCode Screen calls resendOtp when resend link is clicked and properly
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -766,6 +767,7 @@ exports[`OtpCode Screen calls resendOtp when resend link is clicked and properly
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -1193,6 +1195,7 @@ exports[`OtpCode Screen render matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -1626,6 +1629,7 @@ exports[`OtpCode Screen render matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -2053,6 +2057,7 @@ exports[`OtpCode Screen renders cooldown timer snapshot after resending OTP 1`]
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -2466,6 +2471,7 @@ exports[`OtpCode Screen renders cooldown timer snapshot after resending OTP 1`]
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -2893,6 +2899,7 @@ exports[`OtpCode Screen renders error snapshot when API call fails 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -3352,6 +3359,7 @@ exports[`OtpCode Screen renders error snapshot when API call fails 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -3778,6 +3786,7 @@ exports[`OtpCode Screen renders resend error snapshot when resend fails 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -4191,6 +4200,7 @@ exports[`OtpCode Screen renders resend error snapshot when resend fails 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
diff --git a/app/components/UI/Ramp/Deposit/Views/VerifyIdentity/__snapshots__/VerifyIdentity.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/VerifyIdentity/__snapshots__/VerifyIdentity.test.tsx.snap
index bc6dd6baba8..7d3f1ae59f9 100644
--- a/app/components/UI/Ramp/Deposit/Views/VerifyIdentity/__snapshots__/VerifyIdentity.test.tsx.snap
+++ b/app/components/UI/Ramp/Deposit/Views/VerifyIdentity/__snapshots__/VerifyIdentity.test.tsx.snap
@@ -361,6 +361,7 @@ exports[`VerifyIdentity Component renders verify identity screen with all conten
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
{
"flex": 1,
@@ -486,6 +487,7 @@ exports[`VerifyIdentity Component renders verify identity screen with all conten
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
diff --git a/app/components/UI/Ramp/Deposit/components/ErrorView/__snapshots__/ErrorView.test.tsx.snap b/app/components/UI/Ramp/Deposit/components/ErrorView/__snapshots__/ErrorView.test.tsx.snap
index 64ab68cc54f..ca454810f97 100644
--- a/app/components/UI/Ramp/Deposit/components/ErrorView/__snapshots__/ErrorView.test.tsx.snap
+++ b/app/components/UI/Ramp/Deposit/components/ErrorView/__snapshots__/ErrorView.test.tsx.snap
@@ -343,6 +343,7 @@ exports[`ErrorView Component renders with all props and matches snapshot 1`] = `
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
@@ -804,6 +805,7 @@ exports[`ErrorView Component renders with default props and matches snapshot 1`]
[
{
"padding": 15,
+ "paddingHorizontal": 16,
},
undefined,
{
From 0ab429d80e69892548d1d36c525c7a494952db4c Mon Sep 17 00:00:00 2001
From: Aslau Mario-Daniel
Date: Tue, 25 Nov 2025 15:24:40 +0000
Subject: [PATCH 6/9] feat: Throw error in Segment instead of Sentry Stop
throwing error in Sentry when user tries to add a chain with unrecognized rpc
url (#23075)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Sentry Issue:
[METAMASK-MOBILE-2DVH](https://metamask.sentry.io/issues/5351012899/?referrer=github_integration)
```
Error: {"code":4100,"message":"Request blocked due to spam filter."}
at ?anon_0_ (app/util/Logger/index.ts:83:32)
...
(11 additional frame(s) were not displayed)
```
Steps to reproduce:
- Go to https://bridge.coredao.org/coreBTC
- Click on "Add coreBTC to your wallet"
- MM will throw "Unrecognized chain ID "0x45c". Try adding the chain
using wallet_addEthereumChain first."
Here we throw a Sentry error while we'd like to send and Error event in
Segment instead.
## **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**
- [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
- [ ] 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]
> Routes RPC errors from `createLoggerMiddleware` to analytics (with
better message selection) instead of Sentry, and adds comprehensive unit
tests for middlewares.
>
> - **Utilities — `app/util/middlewares.js`**:
> - **RPC error handling**: Replace Sentry logging with analytics
tracking via `trackErrorAsAnalytics`.
> - User rejections tracked as `Error in RPC response: User rejected`.
> - Non-user errors tracked as `Error in RPC response` using
`error.data?.message || error.message || 'Unknown RPC error'`.
> - Removes `Logger.error` and related error param construction.
> - **Logging**: Preserve `Logger.log` of RPC activity; skip when
`req.isMetamaskInternal`.
> - **Tests — `app/util/middlewares.test.js`**:
> - Add tests for `createOriginMiddleware`, `containsUserRejectedError`,
and `createLoggerMiddleware` covering success, user rejection, non-user
errors, internal requests, and edge cases.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
434135e0516cf5ebe2a9962bd1d6674745dca2f5. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---------
Co-authored-by: Nico MASSART
---
app/util/middlewares.js | 20 +-
app/util/middlewares.test.js | 416 +++++++++++++++++++++++++++++++++++
2 files changed, 421 insertions(+), 15 deletions(-)
create mode 100644 app/util/middlewares.test.js
diff --git a/app/util/middlewares.js b/app/util/middlewares.js
index 0fbf6cb4d21..42ca2bdbda9 100644
--- a/app/util/middlewares.js
+++ b/app/util/middlewares.js
@@ -75,7 +75,7 @@ export function createLoggerMiddleware(opts) {
) {
next((/** @type {Function} */ cb) => {
if (res.error) {
- const { error, ...resWithoutError } = res;
+ const { error } = res;
if (error) {
if (containsUserRejectedError(error.message, error.code)) {
trackErrorAsAnalytics(
@@ -89,21 +89,11 @@ export function createLoggerMiddleware(opts) {
* "message":"Internal JSON-RPC error.",
* "data":{"code":-32000,"message":"gas required exceeds allowance (59956966) or always failing transaction"}
* }
- * This will make the error log to sentry with the title "gas required exceeds allowance (59956966) or always failing transaction"
- * making it easier to differentiate each error.
+ * This will track the error to analytics with the error message for better differentiation.
*/
- const errorParams = {
- message: 'Error in RPC response',
- orginalError: error,
- res: resWithoutError,
- req,
- };
-
- if (error.data) {
- errorParams.data = error.data;
- }
-
- Logger.error(error, errorParams);
+ const errorMessage =
+ error.data?.message || error.message || 'Unknown RPC error';
+ trackErrorAsAnalytics('Error in RPC response', errorMessage);
}
}
}
diff --git a/app/util/middlewares.test.js b/app/util/middlewares.test.js
new file mode 100644
index 00000000000..09d921e99eb
--- /dev/null
+++ b/app/util/middlewares.test.js
@@ -0,0 +1,416 @@
+import Logger from './Logger';
+import trackErrorAsAnalytics from './metrics/TrackError/trackErrorAsAnalytics';
+import {
+ createOriginMiddleware,
+ containsUserRejectedError,
+ createLoggerMiddleware,
+} from './middlewares';
+
+// Mock dependencies
+jest.mock('./Logger');
+jest.mock('./metrics/TrackError/trackErrorAsAnalytics');
+
+describe('middlewares', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('createOriginMiddleware', () => {
+ it('appends origin to request', () => {
+ const origin = 'https://example.com';
+ const middleware = createOriginMiddleware({ origin });
+ const req = {};
+ const res = {};
+ const next = jest.fn();
+
+ middleware(req, res, next);
+
+ expect(req.origin).toBe(origin);
+ expect(next).toHaveBeenCalledTimes(1);
+ });
+
+ it('initializes params as empty array if not present', () => {
+ const middleware = createOriginMiddleware({ origin: 'https://test.com' });
+ const req = {};
+ const next = jest.fn();
+
+ middleware(req, {}, next);
+
+ expect(req.params).toEqual([]);
+ });
+
+ it('does not override existing params', () => {
+ const existingParams = [1, 2, 3];
+ const middleware = createOriginMiddleware({ origin: 'https://test.com' });
+ const req = { params: existingParams };
+ const next = jest.fn();
+
+ middleware(req, {}, next);
+
+ expect(req.params).toBe(existingParams);
+ });
+ });
+
+ describe('containsUserRejectedError', () => {
+ it('returns true for error message containing "user rejected"', () => {
+ const result = containsUserRejectedError('User rejected the transaction');
+
+ expect(result).toBe(true);
+ });
+
+ it('returns true for error message containing "user denied"', () => {
+ const result = containsUserRejectedError(
+ 'MetaMask Message Signature: User denied message signature.',
+ );
+
+ expect(result).toBe(true);
+ });
+
+ it('returns true for error message containing "user cancelled"', () => {
+ const result = containsUserRejectedError(
+ 'User cancelled the transaction',
+ );
+
+ expect(result).toBe(true);
+ });
+
+ it('returns true for case insensitive user rejection messages', () => {
+ const upperCaseResult = containsUserRejectedError('USER REJECTED');
+ const mixedCaseResult = containsUserRejectedError('UsEr DeNiEd');
+
+ expect(upperCaseResult).toBe(true);
+ expect(mixedCaseResult).toBe(true);
+ });
+
+ it('returns true for error code 4001', () => {
+ const result = containsUserRejectedError('Some error', 4001);
+
+ expect(result).toBe(true);
+ });
+
+ it('returns true when both message and code indicate user rejection', () => {
+ const result = containsUserRejectedError('User rejected', 4001);
+
+ expect(result).toBe(true);
+ });
+
+ it('returns false for null error message', () => {
+ const result = containsUserRejectedError(null);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false for undefined error message', () => {
+ const result = containsUserRejectedError(undefined);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false for non-string error message', () => {
+ const numberResult = containsUserRejectedError(123);
+ const objectResult = containsUserRejectedError({});
+ const arrayResult = containsUserRejectedError([]);
+
+ expect(numberResult).toBe(false);
+ expect(objectResult).toBe(false);
+ expect(arrayResult).toBe(false);
+ });
+
+ it('returns false for error message without user rejection phrases', () => {
+ const result = containsUserRejectedError('Internal JSON-RPC error');
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false for error codes other than 4001', () => {
+ const result4000 = containsUserRejectedError('Some error', 4000);
+ const result4002 = containsUserRejectedError('Some error', 4002);
+
+ expect(result4000).toBe(false);
+ expect(result4002).toBe(false);
+ });
+
+ it('returns false when exception occurs during checking', () => {
+ const errorMessage = {
+ toLowerCase: () => {
+ throw new Error('Test error');
+ },
+ };
+
+ const result = containsUserRejectedError(errorMessage);
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('createLoggerMiddleware', () => {
+ let middleware;
+ const origin = 'https://example.com';
+ let req;
+ let res;
+ let next;
+ let callback;
+
+ beforeEach(() => {
+ middleware = createLoggerMiddleware({ origin });
+ req = { method: 'eth_sendTransaction' };
+ res = {};
+ next = jest.fn((cb) => {
+ callback = cb;
+ });
+ });
+
+ describe('when response has no error', () => {
+ it('logs RPC activity', () => {
+ res = { result: 'success' };
+
+ middleware(req, res, next);
+ callback(jest.fn());
+
+ expect(Logger.log).toHaveBeenCalledWith(
+ `RPC (${origin}):`,
+ req,
+ '->',
+ res,
+ );
+ });
+
+ it('does not log when request is internal', () => {
+ req.isMetamaskInternal = true;
+ res = { result: 'success' };
+
+ middleware(req, res, next);
+ callback(jest.fn());
+
+ expect(Logger.log).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when response has user rejection error', () => {
+ it('tracks user rejection to analytics', () => {
+ const errorMessage = 'User rejected the transaction';
+ res = {
+ error: {
+ message: errorMessage,
+ code: 4001,
+ },
+ };
+
+ middleware(req, res, next);
+ callback(jest.fn());
+
+ expect(trackErrorAsAnalytics).toHaveBeenCalledWith(
+ 'Error in RPC response: User rejected',
+ errorMessage,
+ );
+ expect(Logger.log).toHaveBeenCalledWith(
+ `RPC (${origin}):`,
+ req,
+ '->',
+ res,
+ );
+ });
+
+ it('does not log RPC activity for user rejection with isMetamaskInternal', () => {
+ req.isMetamaskInternal = true;
+ res = {
+ error: {
+ message: 'User denied',
+ code: 4001,
+ },
+ };
+
+ middleware(req, res, next);
+ callback(jest.fn());
+
+ expect(trackErrorAsAnalytics).toHaveBeenCalled();
+ expect(Logger.log).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when response has non-user-rejection error', () => {
+ it('tracks error with nested data.message to analytics', () => {
+ const nestedMessage = 'gas required exceeds allowance (59956966)';
+ res = {
+ error: {
+ code: -32603,
+ message: 'Internal JSON-RPC error.',
+ data: {
+ code: -32000,
+ message: nestedMessage,
+ },
+ },
+ };
+
+ middleware(req, res, next);
+ callback(jest.fn());
+
+ expect(trackErrorAsAnalytics).toHaveBeenCalledWith(
+ 'Error in RPC response',
+ nestedMessage,
+ );
+ });
+
+ it('tracks error with top-level message when data.message is missing', () => {
+ const errorMessage = 'Unrecognized chain ID "0x999"';
+ res = {
+ error: {
+ code: 4902,
+ message: errorMessage,
+ },
+ };
+
+ middleware(req, res, next);
+ callback(jest.fn());
+
+ expect(trackErrorAsAnalytics).toHaveBeenCalledWith(
+ 'Error in RPC response',
+ errorMessage,
+ );
+ });
+
+ it('tracks error with fallback message when both are missing', () => {
+ res = {
+ error: {
+ code: -32603,
+ },
+ };
+
+ middleware(req, res, next);
+ callback(jest.fn());
+
+ expect(trackErrorAsAnalytics).toHaveBeenCalledWith(
+ 'Error in RPC response',
+ 'Unknown RPC error',
+ );
+ });
+
+ it('prioritizes data.message over message', () => {
+ const nestedMessage = 'Specific nested error';
+ res = {
+ error: {
+ message: 'Generic error',
+ data: {
+ message: nestedMessage,
+ },
+ },
+ };
+
+ middleware(req, res, next);
+ callback(jest.fn());
+
+ expect(trackErrorAsAnalytics).toHaveBeenCalledWith(
+ 'Error in RPC response',
+ nestedMessage,
+ );
+ });
+
+ it('logs RPC activity after tracking error', () => {
+ res = {
+ error: {
+ message: 'Some error',
+ },
+ };
+
+ middleware(req, res, next);
+ callback(jest.fn());
+
+ expect(trackErrorAsAnalytics).toHaveBeenCalled();
+ expect(Logger.log).toHaveBeenCalledWith(
+ `RPC (${origin}):`,
+ req,
+ '->',
+ res,
+ );
+ });
+
+ it('does not log RPC activity when request is internal', () => {
+ req.isMetamaskInternal = true;
+ res = {
+ error: {
+ message: 'Some error',
+ },
+ };
+
+ middleware(req, res, next);
+ callback(jest.fn());
+
+ expect(trackErrorAsAnalytics).toHaveBeenCalled();
+ expect(Logger.log).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles error with empty string message', () => {
+ res = {
+ error: {
+ message: '',
+ },
+ };
+
+ middleware(req, res, next);
+ callback(jest.fn());
+
+ expect(trackErrorAsAnalytics).toHaveBeenCalledWith(
+ 'Error in RPC response',
+ 'Unknown RPC error',
+ );
+ });
+
+ it('handles error with null message', () => {
+ res = {
+ error: {
+ message: null,
+ },
+ };
+
+ middleware(req, res, next);
+ callback(jest.fn());
+
+ expect(trackErrorAsAnalytics).toHaveBeenCalledWith(
+ 'Error in RPC response',
+ 'Unknown RPC error',
+ );
+ });
+
+ it('handles error with data but no data.message', () => {
+ res = {
+ error: {
+ message: 'Top level message',
+ data: {
+ code: -32000,
+ },
+ },
+ };
+
+ middleware(req, res, next);
+ callback(jest.fn());
+
+ expect(trackErrorAsAnalytics).toHaveBeenCalledWith(
+ 'Error in RPC response',
+ 'Top level message',
+ );
+ });
+ });
+
+ it('calls next with callback', () => {
+ middleware(req, res, next);
+
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(typeof next.mock.calls[0][0]).toBe('function');
+ });
+
+ it('invokes callback passed to middleware callback', () => {
+ const cb = jest.fn();
+
+ middleware(req, res, next);
+ callback(cb);
+
+ expect(cb).toHaveBeenCalledTimes(1);
+ });
+ });
+});
From 66ce0f2e2f0fe972c8bec9188a097b7ac93ca6b0 Mon Sep 17 00:00:00 2001
From: Pedro Pablo Aste Kompen
Date: Tue, 25 Nov 2025 12:35:22 -0300
Subject: [PATCH 7/9] refactor(ramp): change network filter to single-select in
token selection (#23256)
## **Description**
Changed the network filter in the TokenSelection screen from
checkbox-style (multi-select) to radio button-style (single-select)
behavior. Users can now select only one network at a time, or view all
networks.
The implementation simplifies the `TokenNetworkFilterBar` component by
replacing the toggle logic with a straightforward single-selection
mechanism. All existing filtering logic in parent components remains
compatible.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/TRAM-2867
## **Manual testing steps**
```gherkin
Feature: Token selection network filter
Scenario: user selects a single network filter
Given the user is on the Token Selection screen
And "All Networks" is selected
When user taps on "Ethereum" network button
Then only "Ethereum" button should be highlighted
And only Ethereum network tokens should be displayed
Scenario: user switches between network filters
Given the user has "Ethereum" network selected
And only Ethereum tokens are displayed
When user taps on "Polygon" network button
Then "Ethereum" button should no longer be highlighted
And only "Polygon" button should be highlighted
And only Polygon network tokens should be displayed
Scenario: user returns to all networks view
Given the user has "Polygon" network selected
When user taps on "All Networks" button
Then "All Networks" button should be highlighted
And "Polygon" button should no longer be highlighted
And tokens from all networks should be displayed
```
## **Screenshots/Recordings**
### **Before**
https://github.com/user-attachments/assets/cbf54072-4a0d-416c-9da1-5af76212d68d
### **After**
https://github.com/user-attachments/assets/83f662a5-00bf-4133-89fd-accd259208e3
## **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 the Ramp token network filter to single-select with
simplified handlers and updates tests/snapshots accordingly.
>
> - **UI/Logic**:
> - Refactor `TokenNetworkFilterBar` to single-select behavior
(radio-style): `All` sets `networkFilter` to `null`; pressing a network
always sets `networkFilter` to `[chainId]`.
> - Simplify handlers using `useCallback`; remove multi-select/toggle
logic and dependency on `excludeFromArray`.
> - **Tests**:
> - Update tests to reflect single-select behavior: add `handleAllPress`
test; adjust `handleNetworkPress` expectations (replace/add/remove
scenarios).
> - Refresh snapshots; remove snapshot for partial multi-select state.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
ee39c8b7e4dd4783561d546abbf412b97cf0fc93. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../TokenNetworkFilterBar.test.tsx | 59 ++--
.../TokenNetworkFilterBar.tsx | 29 +-
.../TokenNetworkFilterBar.test.tsx.snap | 251 ------------------
3 files changed, 25 insertions(+), 314 deletions(-)
diff --git a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.test.tsx b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.test.tsx
index a5e80abaa32..1eed5f7fc3e 100644
--- a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.test.tsx
+++ b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.test.tsx
@@ -60,62 +60,38 @@ describe('TokenNetworkFilterBar', () => {
expect(toJSON()).toMatchSnapshot();
});
- it('renders correctly with partial networks selected', () => {
- const { toJSON } = render(
- ,
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- describe('handleNetworkPress', () => {
- it('sets single network when all networks are selected', () => {
- const { getByText } = render(
- ,
- );
-
- fireEvent.press(getByText('Ethereum'));
-
- expect(mockSetNetworkFilter).toHaveBeenCalledWith(['eip155:1']);
- });
-
- it('removes network from filter when network is currently selected', () => {
+ describe('handleAllPress', () => {
+ it('sets filter to null when clicking "All" button', () => {
const { getByText } = render(
,
);
- fireEvent.press(getByText('Ethereum'));
+ fireEvent.press(getByText('All'));
- expect(mockSetNetworkFilter).toHaveBeenCalledWith(['eip155:10']);
+ expect(mockSetNetworkFilter).toHaveBeenCalledWith(null);
});
+ });
- it('sets filter to empty array when deselecting last selected network', () => {
+ describe('handleNetworkPress', () => {
+ it('sets single network when all networks are selected', () => {
const { getByText } = render(
,
);
fireEvent.press(getByText('Ethereum'));
- expect(mockSetNetworkFilter).toHaveBeenCalledWith([]);
+ expect(mockSetNetworkFilter).toHaveBeenCalledWith(['eip155:1']);
});
- it('adds network to filter when network is not currently selected', () => {
+ it('replaces selected network when clicking different network', () => {
const { getByText } = render(
{
fireEvent.press(getByText('Optimism'));
- expect(mockSetNetworkFilter).toHaveBeenCalledWith([
- 'eip155:1',
- 'eip155:10',
- ]);
+ expect(mockSetNetworkFilter).toHaveBeenCalledWith(['eip155:10']);
});
- it('sets filter to null when adding network results in all networks selected', () => {
+ it('sets same network when clicking already selected network', () => {
const { getByText } = render(
,
);
- fireEvent.press(getByText('Polygon'));
+ fireEvent.press(getByText('Ethereum'));
- expect(mockSetNetworkFilter).toHaveBeenCalledWith(null);
+ expect(mockSetNetworkFilter).toHaveBeenCalledWith(['eip155:1']);
});
});
});
diff --git a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx
index 5c04a6f53c4..b5203cd7e61 100644
--- a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx
+++ b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useCallback } from 'react';
import { CaipChainId } from '@metamask/utils';
import { ScrollView } from 'react-native-gesture-handler';
@@ -16,7 +16,6 @@ import Text, {
import styleSheet from './TokenNetworkFilterBar.styles';
import { useStyles } from '../../../../hooks/useStyles';
-import { excludeFromArray } from '../../Deposit/utils';
import { useTokenNetworkInfo } from '../../hooks/useTokenNetworkInfo';
import { strings } from '../../../../../../locales/i18n';
@@ -39,27 +38,17 @@ function TokenNetworkFilterBar({
networkFilter.length === 0 ||
networkFilter.length === networks.length;
- const handleAllPress = () => {
+ const handleAllPress = useCallback(() => {
setNetworkFilter(null);
- };
+ }, [setNetworkFilter]);
- const handleNetworkPress = (chainId: CaipChainId) => {
- if (isAllSelected) {
+ const handleNetworkPress = useCallback(
+ (chainId: CaipChainId) => {
+ // Radio button behavior: always set to single selection
setNetworkFilter([chainId]);
- return;
- }
-
- const currentFilter = networkFilter || [];
- const isSelected = currentFilter.includes(chainId);
-
- if (isSelected) {
- const newFilter = excludeFromArray(currentFilter, chainId);
- setNetworkFilter(newFilter.length === networks.length ? null : newFilter);
- } else {
- const newFilter = [...currentFilter, chainId];
- setNetworkFilter(newFilter.length === networks.length ? null : newFilter);
- }
- };
+ },
+ [setNetworkFilter],
+ );
return (
`;
-exports[`TokenNetworkFilterBar renders correctly with partial networks selected 1`] = `
-
-
-
-
- All
-
-
-
-
-
-
-
- Ethereum
-
-
-
-
-
-
-
- Optimism
-
-
-
-
-
-
-
- Polygon
-
-
-
-
-`;
-
exports[`TokenNetworkFilterBar renders correctly with single network selected 1`] = `
Date: Tue, 25 Nov 2025 08:46:22 -0800
Subject: [PATCH 8/9] chore: Update 'Daily resource' to 'Daily resources'
(#22679)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Resource (singular) is awkward and should be resources (plural)
## **Description**
## **Changelog**
CHANGELOG entry: Updated copy "Daily resource" to "Daily resources"
## **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]
> Updates `asset_overview.tron.daily_resource` copy from “Daily
resource” to “Daily resources”.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
1010630479126a513cfabb60e7474d66aebde752. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
locales/languages/en.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 7f8e5894e2d..e2958f9be35 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -3062,7 +3062,7 @@
"earn": "Earn",
"convert": "Convert",
"tron": {
- "daily_resource": "Daily resource",
+ "daily_resource": "Daily resources",
"bandwidth": "Bandwidth",
"energy": "Energy",
"daily_resource_description": "This is your daily allowance based on your staked TRX. You get 600 bandwidth for free daily.",
From 69d1ae2bfeb571766a3985688807787c72e88b50 Mon Sep 17 00:00:00 2001
From: Matthew Walsh
Date: Tue, 25 Nov 2025 16:50:26 +0000
Subject: [PATCH 9/9] fix: cp-7.60.0 predict withdraw using gas station
(#23255)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Fix Predict withdraw when using gas station with insufficient existing
token balance.
Bump `transaction-controller` and `transaction-pay-controller`.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: #23137 #23126
## **Manual testing steps**
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Prevents gas-fee token injection/external sign when
`txMeta.isGasFeeTokenIgnoredIfBalance` is set; wires Remote Feature Flag
messenger action; bumps transaction/transaction-pay controllers.
>
> - **Confirmations**:
> - Update `useTransactionConfirm` to skip adding `batchTransactions`
and `isExternalSign` when `txMeta.isGasFeeTokenIgnoredIfBalance` is
true.
> - Include `isGasFeeTokenIgnoredIfBalance` in metadata handling for
smart transactions and 7702 flows.
> - **Tests**:
> - Add tests ensuring no gas-fee token batching or external signing
when `isGasFeeTokenIgnoredIfBalance` is set.
> - **Messaging**:
> - Allow `RemoteFeatureFlagController:getState` in
`transaction-controller` init messenger.
> - **Dependencies**:
> - Bump `@metamask/transaction-controller` to `62.3.0` and
`@metamask/transaction-pay-controller` to `10.1.0` (with corresponding
lockfile updates).
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
ccbc4d8b69106e4f8ad9c85a83db6e26af4560ff. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../useTransactionConfirm.test.ts | 43 +++++
.../transactions/useTransactionConfirm.ts | 9 +-
.../transaction-controller-messenger.ts | 1 +
package.json | 6 +-
yarn.lock | 169 +++++++++++++++---
5 files changed, 200 insertions(+), 28 deletions(-)
diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts
index 4c47a4d62f6..11dd534d402 100644
--- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts
+++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts
@@ -342,6 +342,7 @@ describe('useTransactionConfirm', () => {
maxPriorityFeePerGas: '0x2',
} as unknown as ReturnType);
});
+
it('adds batchTransactions and gas properties when smart transaction is enabled', async () => {
const { result } = renderHook();
@@ -380,6 +381,25 @@ describe('useTransactionConfirm', () => {
}),
});
});
+
+ it('does nothing if isGasFeeTokenIgnoredIfBalance', async () => {
+ useTransactionMetadataRequestMock.mockReturnValue({
+ id: transactionIdMock,
+ isGasFeeTokenIgnoredIfBalance: true,
+ } as unknown as TransactionMeta);
+
+ const { result } = renderHook();
+
+ await act(async () => {
+ await result.current.onConfirm();
+ });
+
+ expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), {
+ txMeta: expect.not.objectContaining({
+ batchTransactions: expect.any(Array),
+ }),
+ });
+ });
});
describe('handleGasless7702', () => {
@@ -438,5 +458,28 @@ describe('useTransactionConfirm', () => {
txMeta: expect.not.objectContaining({ isExternalSign: true }),
});
});
+
+ it('does nothing if isGasFeeTokenIgnoredIfBalance', async () => {
+ isSendBundleSupportedMock.mockReturnValue(Promise.resolve(false));
+
+ useSelectedGasFeeTokenMock.mockReturnValue({
+ transferTransaction: { data: '0xabc' },
+ } as unknown as ReturnType);
+
+ useTransactionMetadataRequestMock.mockReturnValue({
+ id: transactionIdMock,
+ isGasFeeTokenIgnoredIfBalance: true,
+ } as unknown as TransactionMeta);
+
+ const { result } = renderHook();
+
+ await act(async () => {
+ await result.current.onConfirm();
+ });
+
+ expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), {
+ txMeta: expect.not.objectContaining({ isExternalSign: true }),
+ });
+ });
});
});
diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts
index 974530bcbd5..e34b7291f9b 100644
--- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts
+++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts
@@ -33,7 +33,8 @@ export function useTransactionConfirm() {
const navigation = useNavigation();
const transactionMetadata = useTransactionMetadataRequest();
const selectedGasFeeToken = useSelectedGasFeeToken();
- const { chainId, type } = transactionMetadata ?? {};
+ const { chainId, isGasFeeTokenIgnoredIfBalance, type } =
+ transactionMetadata ?? {};
const { isFullScreenConfirmation } = useFullScreenConfirmation();
const quotes = useTransactionPayQuotes();
@@ -49,7 +50,7 @@ export function useTransactionConfirm() {
const handleSmartTransaction = useCallback(
(updatedMetadata: TransactionMeta) => {
- if (!selectedGasFeeToken) {
+ if (!selectedGasFeeToken || isGasFeeTokenIgnoredIfBalance) {
return;
}
@@ -76,6 +77,7 @@ export function useTransactionConfirm() {
},
[
selectedGasFeeToken,
+ isGasFeeTokenIgnoredIfBalance,
isGaslessSupported,
transactionMetadata?.isGasFeeSponsored,
],
@@ -83,7 +85,7 @@ export function useTransactionConfirm() {
const handleGasless7702 = useCallback(
(updatedMetadata: TransactionMeta) => {
- if (!selectedGasFeeToken) {
+ if (!selectedGasFeeToken || isGasFeeTokenIgnoredIfBalance) {
return;
}
@@ -92,6 +94,7 @@ export function useTransactionConfirm() {
isGaslessSupported && transactionMetadata?.isGasFeeSponsored;
},
[
+ isGasFeeTokenIgnoredIfBalance,
isGaslessSupported,
selectedGasFeeToken,
transactionMetadata?.isGasFeeSponsored,
diff --git a/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts b/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts
index f792253ca13..86f4721428e 100644
--- a/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts
+++ b/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts
@@ -148,6 +148,7 @@ export function getTransactionControllerInitMessenger(
'NetworkController:getEIP1559Compatibility',
'KeyringController:signEip7702Authorization',
'KeyringController:signTypedMessage',
+ 'RemoteFeatureFlagController:getState',
'TransactionController:addTransaction',
'TransactionController:addTransactionBatch',
'TransactionController:getState',
diff --git a/package.json b/package.json
index 0203511799f..8d6868a8b57 100644
--- a/package.json
+++ b/package.json
@@ -176,7 +176,7 @@
"@scure/bip32": "1.7.0",
"@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",
- "@metamask/transaction-controller@npm:^62.2.0": "patch:@metamask/transaction-controller@npm%3A62.2.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch"
+ "@metamask/transaction-controller@npm:^62.3.0": "patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch"
},
"dependencies": {
"@config-plugins/detox": "^9.0.0",
@@ -286,8 +286,8 @@
"@metamask/swappable-obj-proxy": "^2.1.0",
"@metamask/swaps-controller": "^15.0.0",
"@metamask/token-search-discovery-controller": "^4.0.0",
- "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.2.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch",
- "@metamask/transaction-pay-controller": "^10.0.0",
+ "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch",
+ "@metamask/transaction-pay-controller": "^10.1.0",
"@metamask/tron-wallet-snap": "^1.10.0",
"@metamask/utils": "^11.8.1",
"@ngraveio/bc-ur": "^1.1.6",
diff --git a/yarn.lock b/yarn.lock
index d7ac51a651b..2cfdd0a5b63 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7408,6 +7408,58 @@ __metadata:
languageName: node
linkType: hard
+"@metamask/assets-controllers@npm:^91.0.0":
+ version: 91.0.0
+ resolution: "@metamask/assets-controllers@npm:91.0.0"
+ dependencies:
+ "@ethereumjs/util": "npm:^9.1.0"
+ "@ethersproject/abi": "npm:^5.7.0"
+ "@ethersproject/address": "npm:^5.7.0"
+ "@ethersproject/bignumber": "npm:^5.7.0"
+ "@ethersproject/contracts": "npm:^5.7.0"
+ "@ethersproject/providers": "npm:^5.7.0"
+ "@metamask/abi-utils": "npm:^2.0.3"
+ "@metamask/base-controller": "npm:^9.0.0"
+ "@metamask/contract-metadata": "npm:^2.4.0"
+ "@metamask/controller-utils": "npm:^11.16.0"
+ "@metamask/eth-query": "npm:^4.0.0"
+ "@metamask/keyring-api": "npm:^21.0.0"
+ "@metamask/messenger": "npm:^0.3.0"
+ "@metamask/metamask-eth-abis": "npm:^3.1.1"
+ "@metamask/polling-controller": "npm:^16.0.0"
+ "@metamask/rpc-errors": "npm:^7.0.2"
+ "@metamask/snaps-sdk": "npm:^9.0.0"
+ "@metamask/snaps-utils": "npm:^11.0.0"
+ "@metamask/utils": "npm:^11.8.1"
+ "@types/bn.js": "npm:^5.1.5"
+ "@types/uuid": "npm:^8.3.0"
+ async-mutex: "npm:^0.5.0"
+ bitcoin-address-validation: "npm:^2.2.3"
+ bn.js: "npm:^5.2.1"
+ immer: "npm:^9.0.6"
+ lodash: "npm:^4.17.21"
+ multiformats: "npm:^9.9.0"
+ reselect: "npm:^5.1.1"
+ single-call-balance-checker-abi: "npm:^1.0.0"
+ uuid: "npm:^8.3.2"
+ peerDependencies:
+ "@metamask/account-tree-controller": ^4.0.0
+ "@metamask/accounts-controller": ^35.0.0
+ "@metamask/approval-controller": ^8.0.0
+ "@metamask/core-backend": ^5.0.0
+ "@metamask/keyring-controller": ^25.0.0
+ "@metamask/network-controller": ^26.0.0
+ "@metamask/permission-controller": ^12.0.0
+ "@metamask/phishing-controller": ^16.0.0
+ "@metamask/preferences-controller": ^22.0.0
+ "@metamask/providers": ^22.0.0
+ "@metamask/snaps-controllers": ^14.0.0
+ "@metamask/transaction-controller": ^62.0.0
+ webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0
+ checksum: 10/8e43d631a5ae86fc4801912e79d944ad087a605bb7a5e2813de64b6e068dc26482d25d37c3e2272e435a71ee8a7dafe875edb46cbfbcd150fb474588b45e6ff4
+ languageName: node
+ linkType: hard
+
"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A89.0.1#~/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch":
version: 89.0.1
resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A89.0.1#~/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch::version=89.0.1&hash=6be0d3"
@@ -7561,6 +7613,38 @@ __metadata:
languageName: node
linkType: hard
+"@metamask/bridge-controller@npm:^63.0.0":
+ version: 63.0.0
+ resolution: "@metamask/bridge-controller@npm:63.0.0"
+ dependencies:
+ "@ethersproject/address": "npm:^5.7.0"
+ "@ethersproject/bignumber": "npm:^5.7.0"
+ "@ethersproject/constants": "npm:^5.7.0"
+ "@ethersproject/contracts": "npm:^5.7.0"
+ "@ethersproject/providers": "npm:^5.7.0"
+ "@metamask/base-controller": "npm:^9.0.0"
+ "@metamask/controller-utils": "npm:^11.16.0"
+ "@metamask/gas-fee-controller": "npm:^26.0.0"
+ "@metamask/keyring-api": "npm:^21.0.0"
+ "@metamask/messenger": "npm:^0.3.0"
+ "@metamask/metamask-eth-abis": "npm:^3.1.1"
+ "@metamask/multichain-network-controller": "npm:^3.0.0"
+ "@metamask/polling-controller": "npm:^16.0.0"
+ "@metamask/utils": "npm:^11.8.1"
+ bignumber.js: "npm:^9.1.2"
+ reselect: "npm:^5.1.1"
+ uuid: "npm:^8.3.2"
+ peerDependencies:
+ "@metamask/accounts-controller": ^35.0.0
+ "@metamask/assets-controllers": ^91.0.0
+ "@metamask/network-controller": ^26.0.0
+ "@metamask/remote-feature-flag-controller": ^2.0.0
+ "@metamask/snaps-controllers": ^14.0.0
+ "@metamask/transaction-controller": ^62.0.0
+ checksum: 10/53125ecf3938d8ec7c5b33b6ee7302b475616f73c24e2303a702fc31b446f9b7335adaf9451edc7070989542595dc6ac4199fd09d94f614e196e8066edc2855e
+ languageName: node
+ linkType: hard
+
"@metamask/bridge-status-controller@npm:^61.0.0":
version: 61.0.0
resolution: "@metamask/bridge-status-controller@npm:61.0.0"
@@ -7583,6 +7667,28 @@ __metadata:
languageName: node
linkType: hard
+"@metamask/bridge-status-controller@npm:^63.0.0":
+ version: 63.0.0
+ resolution: "@metamask/bridge-status-controller@npm:63.0.0"
+ dependencies:
+ "@metamask/base-controller": "npm:^9.0.0"
+ "@metamask/controller-utils": "npm:^11.16.0"
+ "@metamask/polling-controller": "npm:^16.0.0"
+ "@metamask/superstruct": "npm:^3.1.0"
+ "@metamask/utils": "npm:^11.8.1"
+ bignumber.js: "npm:^9.1.2"
+ uuid: "npm:^8.3.2"
+ peerDependencies:
+ "@metamask/accounts-controller": ^35.0.0
+ "@metamask/bridge-controller": ^63.0.0
+ "@metamask/gas-fee-controller": ^26.0.0
+ "@metamask/network-controller": ^26.0.0
+ "@metamask/snaps-controllers": ^14.0.0
+ "@metamask/transaction-controller": ^62.0.0
+ checksum: 10/04a814032f57d988f8b753c789ebd9293ee6205825d92b15d41c9fd9f11e39835d5920186e5a4bede8231d0af26b391e699def6a95be21e04c231d1761c821b6
+ languageName: node
+ linkType: hard
+
"@metamask/browser-passworder@npm:^4.3.0":
version: 4.3.0
resolution: "@metamask/browser-passworder@npm:4.3.0"
@@ -8553,6 +8659,26 @@ __metadata:
languageName: node
linkType: hard
+"@metamask/multichain-network-controller@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "@metamask/multichain-network-controller@npm:3.0.0"
+ dependencies:
+ "@metamask/base-controller": "npm:^9.0.0"
+ "@metamask/controller-utils": "npm:^11.16.0"
+ "@metamask/keyring-api": "npm:^21.0.0"
+ "@metamask/keyring-internal-api": "npm:^9.0.0"
+ "@metamask/messenger": "npm:^0.3.0"
+ "@metamask/superstruct": "npm:^3.1.0"
+ "@metamask/utils": "npm:^11.8.1"
+ "@solana/addresses": "npm:^2.0.0"
+ lodash: "npm:^4.17.21"
+ peerDependencies:
+ "@metamask/accounts-controller": ^35.0.0
+ "@metamask/network-controller": ^26.0.0
+ checksum: 10/b167cd4bed12285c1e37f74a681371c453936e1aaa7e1207fb98cd97cbfa8831ca9e96a569a8787caac3f5a831627435e001dc8febaad2e61a142e4298f57d2f
+ languageName: node
+ linkType: hard
+
"@metamask/multichain-transactions-controller@npm:^6.0.0":
version: 6.0.0
resolution: "@metamask/multichain-transactions-controller@npm:6.0.0"
@@ -9437,9 +9563,9 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/transaction-controller@npm:62.2.0":
- version: 62.2.0
- resolution: "@metamask/transaction-controller@npm:62.2.0"
+"@metamask/transaction-controller@npm:62.3.0, @metamask/transaction-controller@npm:^62.2.0":
+ version: 62.3.0
+ resolution: "@metamask/transaction-controller@npm:62.3.0"
dependencies:
"@ethereumjs/common": "npm:^4.4.0"
"@ethereumjs/tx": "npm:^5.4.0"
@@ -9471,7 +9597,7 @@ __metadata:
peerDependencies:
"@babel/runtime": ^7.0.0
"@metamask/eth-block-tracker": ">=9"
- checksum: 10/978884a159300253960443c331422da9355d26f118b6caa59a4924a99ccadae677a41330bf94497f1cb686cc68bea30431220621e4a9d1576652cb5a3489977a
+ checksum: 10/c6e4024359567692b8d2f29ccca678124692d28dca547a0bdec829d81aeda381f51840d981a047d69ae60fb24033b6a45d22bb1e8751f4de46a4f26733c30278
languageName: node
linkType: hard
@@ -9513,9 +9639,9 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.2.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch":
- version: 62.2.0
- resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.2.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch::version=62.2.0&hash=1a3342"
+"@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch":
+ version: 62.3.0
+ resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch::version=62.3.0&hash=1a3342"
dependencies:
"@ethereumjs/common": "npm:^4.4.0"
"@ethereumjs/tx": "npm:^5.4.0"
@@ -9547,34 +9673,33 @@ __metadata:
peerDependencies:
"@babel/runtime": ^7.0.0
"@metamask/eth-block-tracker": ">=9"
- checksum: 10/aeac45f777786d040d4860cd49fc2665a632cd834eef3f5ef73e1a7fad95ead6bfb02a597a0b60f9bcc7036c0a88fa0affe27a5d9f11f595cb3cffdc13a446d4
+ checksum: 10/a8d7360285485c1d81b1eed2e7b744d82941450fe0825d195bc69d830576a5587624841a7e9f4e75169be2e77db4cde714ca4b35fc04ef6039625de1250849d6
languageName: node
linkType: hard
-"@metamask/transaction-pay-controller@npm:^10.0.0":
- version: 10.0.0
- resolution: "@metamask/transaction-pay-controller@npm:10.0.0"
+"@metamask/transaction-pay-controller@npm:^10.1.0":
+ version: 10.1.0
+ resolution: "@metamask/transaction-pay-controller@npm:10.1.0"
dependencies:
"@ethersproject/abi": "npm:^5.7.0"
"@ethersproject/contracts": "npm:^5.7.0"
+ "@metamask/assets-controllers": "npm:^91.0.0"
"@metamask/base-controller": "npm:^9.0.0"
+ "@metamask/bridge-controller": "npm:^63.0.0"
+ "@metamask/bridge-status-controller": "npm:^63.0.0"
"@metamask/controller-utils": "npm:^11.16.0"
+ "@metamask/gas-fee-controller": "npm:^26.0.0"
"@metamask/messenger": "npm:^0.3.0"
"@metamask/metamask-eth-abis": "npm:^3.1.1"
+ "@metamask/network-controller": "npm:^26.0.0"
+ "@metamask/remote-feature-flag-controller": "npm:^2.0.1"
+ "@metamask/transaction-controller": "npm:^62.2.0"
"@metamask/utils": "npm:^11.8.1"
bignumber.js: "npm:^9.1.2"
bn.js: "npm:^5.2.1"
immer: "npm:^9.0.6"
lodash: "npm:^4.17.21"
- peerDependencies:
- "@metamask/assets-controllers": ^91.0.0
- "@metamask/bridge-controller": ^63.0.0
- "@metamask/bridge-status-controller": ^63.0.0
- "@metamask/gas-fee-controller": ^26.0.0
- "@metamask/network-controller": ^26.0.0
- "@metamask/remote-feature-flag-controller": ^2.0.0
- "@metamask/transaction-controller": ^62.0.0
- checksum: 10/596b50c04ee658bd16aefc8000d8cdbe2ac04e82636e9029b828c352377ca1e1af6b3c33f129ea28ba966553f513f42988e6ad50bc4edb99c59a68467fe6a1f6
+ checksum: 10/59b7b07879b7ea36871906efd5b8c3328c16e72780a02b29a814544b2f970d9e625f286cd43f9e4b08cb8e9bffab66e12b296bfc36161c28b033b3b69093bd91
languageName: node
linkType: hard
@@ -35612,8 +35737,8 @@ __metadata:
"@metamask/test-dapp-multichain": "npm:^0.17.1"
"@metamask/test-dapp-solana": "npm:^0.3.0"
"@metamask/token-search-discovery-controller": "npm:^4.0.0"
- "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.2.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch"
- "@metamask/transaction-pay-controller": "npm:^10.0.0"
+ "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch"
+ "@metamask/transaction-pay-controller": "npm:^10.1.0"
"@metamask/tron-wallet-snap": "npm:^1.10.0"
"@metamask/utils": "npm:^11.8.1"
"@ngraveio/bc-ur": "npm:^1.1.6"