From 68407478707acfcfa426805f50743d4a4d876c28 Mon Sep 17 00:00:00 2001 From: Yu-Gi-Oh! <54774811+imyugioh@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:51:42 +0000 Subject: [PATCH 1/2] chore(runway): cherry-pick fix(ramps): filter activity tab's transfer details for selected account -> cp-7.71.0 (#27830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ### Activity — on-ramp orders scoped to the selected account The Activity **Orders** tab merges legacy fiat orders with V2 `RampsController` orders. Legacy rows were already limited to the **selected account group** via `getOrders`. V2 orders were not filtered, so purchase history from other wallets could appear. This change adds `selectRampsOrdersForSelectedAccountGroup`, which keeps only orders whose `walletAddress` matches any formatted address in the selected account group (same semantics as legacy, using `areAddressesEqual` for EVM vs non-EVM). Hook and modal consumers that should reflect “current wallet context” now use this selector instead of the raw controller list. ### Transak — preserve user-entered fiat amount on Build Quote (in-app) After **additional verification**, opening the KYC/payment webview called `navigateToKycWebview` with **`quote.fiatAmount`**, and the stack reset rewrote **RampAmountInput** params with that value. The quote total can differ from what the user typed (e.g. fees), so Build Quote could show **27.37** after the user entered **25**, and that value persisted after closing the sheet or going back. **Change:** `routeAfterAuthentication(..., amount)` already carried the typed fiat; `navigateToAdditionalVerificationCallback` now puts the same `amount` on **both** `RampAmountInput` and `RampAdditionalVerification` route params. **V2 Additional Verification** reads that param and passes **only** it into `navigateToKycWebview` (no `quote.fiatAmount` for this purpose). **Out of scope:** Order detail screens and stored order payloads are unchanged. The **Transak payment webview** URL is unchanged (no `fiatAmount` override in widget params); only in-app Build Quote / stack state matches the user’s input. ## **Changelog** CHANGELOG entry: Fixed Activity on-ramp (Orders) list showing V2 purchases from wallets other than the selected account group; fixed Transak unified buy flow so the fiat amount on Build Quote after additional verification matches the user-entered amount on the amount screen (in-app stack), without changing Transak’s payment widget totals. ### Tests - `selectRampsOrdersForSelectedAccountGroup` and ramp hook consumers (selector + hook unit tests). - `useTransakRouting`: IDPROOF / additional verification navigation with user `amount` set and omitted. - V2 `AdditionalVerification`: continue passes route `amount` into `navigateToKycWebview`. ## **Related issues** Refs: [TRAM-3361](https://consensyssoftware.atlassian.net/browse/TRAM-3361) ## **Manual testing steps** ```gherkin Feature: Activity on-ramp orders and Transak amount (TRAM-3361) Scenario: Orders tab shows only selected account group’s V2 on-ramp orders Given the user has two wallets (or account groups) with separate on-ramp purchase history And unified ramps V2 orders exist for more than one wallet address When the user selects account group A and opens Activity → Orders Then only on-ramp orders whose destination wallet belongs to account group A are listed When the user switches to account group B Then the Orders tab lists only orders for account group B Scenario: Custom fiat amount survives Transak additional verification on Build Quote Given the user is in unified Buy with Transak (native) as provider And the user enters a custom fiat amount (e.g. 25) on the amount screen When the user continues through flows that require additional verification And the user taps Continue on the additional verification screen Then Build Quote under the KYC/payment sheet still shows the entered amount (e.g. 25), not only the quote total When the user closes the webview or goes back from the flow Then the amount screen still shows the same entered amount (e.g. 25) ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/b26bb3cb-8219-48a3-8128-7c79026fdd18 ### **After** https://github.com/user-attachments/assets/3e1929e3-7e1a-42c9-98bd-ea8f7bc9b1eb https://github.com/user-attachments/assets/ef6d14ed-6533-4e04-a5a4-8cbd44477170 ## **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 - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Moderate UX/data-scoping change: filters which on-ramp orders appear based on multichain account selection and adjusts Transak navigation params, which could hide expected history or affect flow state if account/address resolution is off. > > **Overview** > Fixes unified ramp UI to **scope V2 `RampsController` orders to the selected account group**, preventing purchases from other wallets from showing up in Activity-derived surfaces. This introduces `selectRampsOrdersForSelectedAccountGroup` (address-matched via `areAddressesEqual`) and switches key consumers (`useRampsOrders`, `useRampsProviders`, `useRampsButtonClickData`, `ProviderSelectionModal`) from the unfiltered selector. > > Updates the Transak additional-verification flow to **preserve the user-entered fiat amount** through stack resets: `useTransakRouting` now carries `amount` into `RampAdditionalVerification` params, and `AdditionalVerification` uses that param (not `quote.fiatAmount`) when opening the KYC webview. Selector and routing behavior are covered with expanded unit tests. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 39d5861d79d45c9f742c809c6a95aa1ef2aff902. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../ProviderSelectionModal.tsx | 6 +- .../AdditionalVerification.test.tsx | 5 +- .../NativeFlow/AdditionalVerification.tsx | 9 +- .../UI/Ramp/hooks/useRampsButtonClickData.ts | 6 +- .../UI/Ramp/hooks/useRampsOrders.test.ts | 70 +++++++++- .../UI/Ramp/hooks/useRampsOrders.ts | 4 +- .../UI/Ramp/hooks/useRampsProviders.ts | 6 +- .../UI/Ramp/hooks/useTransakRouting.test.ts | 59 +++++++- .../UI/Ramp/hooks/useTransakRouting.ts | 4 +- app/selectors/rampsController/index.test.ts | 127 ++++++++++++++++++ app/selectors/rampsController/index.ts | 34 ++++- 11 files changed, 307 insertions(+), 23 deletions(-) diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx index b6ef67590ed..8a1d0b81bb7 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx @@ -17,7 +17,7 @@ import { useRampsController } from '../../../hooks/useRampsController'; import { useRampsQuotes } from '../../../hooks/useRampsQuotes'; import useRampAccountAddress from '../../../hooks/useRampAccountAddress'; import { getOrdersProviders } from '../../../../../../reducers/fiatOrders'; -import { selectRampsOrders } from '../../../../../../selectors/rampsController'; +import { selectRampsOrdersForSelectedAccountGroup } from '../../../../../../selectors/rampsController'; import { completedOrdersFromRampsOrders } from '../../../utils/determinePreferredProvider'; import { useStyles } from '../../../../../hooks/useStyles'; import styleSheet from './ProviderSelectionModal.styles'; @@ -59,7 +59,9 @@ function ProviderSelectionModal() { } = useRampsController(); const legacyOrdersProviders = useSelector(getOrdersProviders); - const controllerOrders = useSelector(selectRampsOrders); + const controllerOrders = useSelector( + selectRampsOrdersForSelectedAccountGroup, + ); const ordersProviders = useMemo(() => { const v2ProviderIds = completedOrdersFromRampsOrders(controllerOrders).map( diff --git a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx index 6375f3e7a3e..481c0cfa3c3 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx @@ -36,9 +36,10 @@ jest.mock('../../../../../util/navigation/navUtils', () => ({ (..._args: unknown[]) => (params: unknown) => ['MockRoute', params], useParams: () => ({ - quote: { quoteId: 'test-quote-id', fiatAmount: 100 }, + quote: { quoteId: 'test-quote-id', fiatAmount: 127.37 }, kycUrl: 'https://kyc.example.com', workFlowRunId: 'wf-123', + amount: 25, }), })); @@ -71,7 +72,7 @@ describe('V2AdditionalVerification', () => { expect(mockNavigateToKycWebview).toHaveBeenCalledWith({ kycUrl: 'https://kyc.example.com', - amount: 100, + amount: 25, }); }); diff --git a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx index 190666ee128..00a4377856f 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx @@ -23,11 +23,14 @@ interface V2AdditionalVerificationParams { quote: TransakBuyQuote; kycUrl: string; workFlowRunId: string; + /** From BuildQuote route; keeps stack amount in sync when opening KYC webview. */ + amount?: number; } const V2AdditionalVerification = () => { const navigation = useNavigation(); - const { kycUrl, quote } = useParams(); + const { kycUrl, amount: userEnteredAmount } = + useParams(); const { styles, theme } = useStyles(styleSheet, {}); @@ -46,8 +49,8 @@ const V2AdditionalVerification = () => { }, [navigation, theme]); const handleContinuePress = useCallback(() => { - navigateToKycWebview({ kycUrl, amount: quote?.fiatAmount }); - }, [navigateToKycWebview, kycUrl, quote?.fiatAmount]); + navigateToKycWebview({ kycUrl, amount: userEnteredAmount }); + }, [navigateToKycWebview, kycUrl, userEnteredAmount]); return ( diff --git a/app/components/UI/Ramp/hooks/useRampsButtonClickData.ts b/app/components/UI/Ramp/hooks/useRampsButtonClickData.ts index 1868700aa6c..4b73ecb6ddf 100644 --- a/app/components/UI/Ramp/hooks/useRampsButtonClickData.ts +++ b/app/components/UI/Ramp/hooks/useRampsButtonClickData.ts @@ -5,7 +5,7 @@ import { getRampRoutingDecision, UnifiedRampRoutingType, } from '../../../../reducers/fiatOrders'; -import { selectRampsOrders } from '../../../../selectors/rampsController'; +import { selectRampsOrdersForSelectedAccountGroup } from '../../../../selectors/rampsController'; import { getProviderToken } from '../Deposit/utils/ProviderTokenVault'; import { completedOrdersFromFiatOrders, @@ -21,7 +21,9 @@ export interface RampsButtonClickData { export function useRampsButtonClickData(): RampsButtonClickData { const orders = useSelector(getOrders); - const controllerOrders = useSelector(selectRampsOrders); + const controllerOrders = useSelector( + selectRampsOrdersForSelectedAccountGroup, + ); const rampRoutingDecision = useSelector(getRampRoutingDecision); const [isAuthenticated, setIsAuthenticated] = useState(false); diff --git a/app/components/UI/Ramp/hooks/useRampsOrders.test.ts b/app/components/UI/Ramp/hooks/useRampsOrders.test.ts index 0c5048f6ba5..6f1b6557191 100644 --- a/app/components/UI/Ramp/hooks/useRampsOrders.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsOrders.test.ts @@ -2,9 +2,23 @@ import { renderHook, act } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import React from 'react'; +import { AccountGroupType } from '@metamask/account-api'; import { RampsOrderStatus, type RampsOrder } from '@metamask/ramps-controller'; +import { createMockInternalAccount } from '../../../../util/test/accountsControllerTestUtils'; import { useRampsOrders } from './useRampsOrders'; +const RAMP_HOOKS_TEST_WALLET_ID = 'keyring:use-ramps-orders-test' as const; +const RAMP_HOOKS_TEST_GROUP_ID = + `${RAMP_HOOKS_TEST_WALLET_ID}/ethereum` as const; +const RAMP_HOOKS_TEST_ACCOUNT_ID = 'account-rh-1'; +/** Must be a valid EVM address (20 bytes) so `areAddressesEqual` treats it as EVM. */ +const RAMP_HOOKS_TEST_ADDRESS = '0x2990079bcdee240329a520d2444386fc119da21a'; + +const rampHooksTestInternalAccount = { + ...createMockInternalAccount(RAMP_HOOKS_TEST_ADDRESS, 'Test'), + id: RAMP_HOOKS_TEST_ACCOUNT_ID, +}; + const mockAddOrder = jest.fn(); const mockAddPrecreatedOrder = jest.fn(); const mockRemoveOrder = jest.fn(); @@ -35,7 +49,7 @@ const createMockOrder = (overrides: Partial = {}): RampsOrder => ({ createdAt: Date.now(), totalFeesFiat: 5, txHash: '0xabc', - walletAddress: '0x123', + walletAddress: RAMP_HOOKS_TEST_ADDRESS, status: RampsOrderStatus.Completed, network: { name: 'Ethereum', chainId: 'eip155:1' }, canBeUpdated: false, @@ -54,6 +68,45 @@ const createMockStore = (orders: RampsOrder[] = []) => RampsController: { orders, }, + AccountTreeController: { + accountTree: { + wallets: { + [RAMP_HOOKS_TEST_WALLET_ID]: { + id: RAMP_HOOKS_TEST_WALLET_ID, + metadata: { name: 'Test wallet' }, + groups: { + [RAMP_HOOKS_TEST_GROUP_ID]: { + id: RAMP_HOOKS_TEST_GROUP_ID, + type: AccountGroupType.SingleAccount, + accounts: [RAMP_HOOKS_TEST_ACCOUNT_ID], + metadata: { name: 'Test Group' }, + }, + }, + }, + }, + selectedAccountGroup: RAMP_HOOKS_TEST_GROUP_ID, + }, + }, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + enableMultichainAccounts: { + enabled: true, + featureVersion: '1', + minimumVersion: '1.0.0', + }, + }, + }, + AccountsController: { + internalAccounts: { + accounts: { + [RAMP_HOOKS_TEST_ACCOUNT_ID]: rampHooksTestInternalAccount, + }, + selectedAccount: RAMP_HOOKS_TEST_ACCOUNT_ID, + }, + }, + KeyringController: { + keyrings: [], + }, }, }), }, @@ -78,7 +131,7 @@ describe('useRampsOrders', () => { expect(result.current.orders).toEqual([]); }); - it('returns orders from the store', () => { + it('returns orders from the store when walletAddress matches the selected account group', () => { const order = createMockOrder(); const store = createMockStore([order]); const { result } = renderHook(() => useRampsOrders(), { @@ -88,6 +141,19 @@ describe('useRampsOrders', () => { expect(result.current.orders).toEqual([order]); }); + it('excludes orders whose walletAddress is not in the selected account group', () => { + const foreignOrder = createMockOrder({ + providerOrderId: 'foreign-order', + walletAddress: '0x0000000000000000000000000000000000000001', + }); + const store = createMockStore([foreignOrder]); + const { result } = renderHook(() => useRampsOrders(), { + wrapper: wrapper(store), + }); + + expect(result.current.orders).toEqual([]); + }); + it('finds an order by providerOrderId', () => { const order1 = createMockOrder({ providerOrderId: 'order-1' }); const order2 = createMockOrder({ providerOrderId: 'order-2' }); diff --git a/app/components/UI/Ramp/hooks/useRampsOrders.ts b/app/components/UI/Ramp/hooks/useRampsOrders.ts index 72f58dd8198..48a78cb0480 100644 --- a/app/components/UI/Ramp/hooks/useRampsOrders.ts +++ b/app/components/UI/Ramp/hooks/useRampsOrders.ts @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'; import type { RampsOrder } from '@metamask/ramps-controller'; import { extractOrderCode } from '../utils/extractOrderCode'; import Engine from '../../../../core/Engine'; -import { selectRampsOrders } from '../../../../selectors/rampsController'; +import { selectRampsOrdersForSelectedAccountGroup } from '../../../../selectors/rampsController'; export interface AddPrecreatedOrderParams { orderId: string; @@ -31,7 +31,7 @@ export interface UseRampsOrdersResult { } export function useRampsOrders(): UseRampsOrdersResult { - const orders = useSelector(selectRampsOrders); + const orders = useSelector(selectRampsOrdersForSelectedAccountGroup); const getOrderById = useCallback( (providerOrderId: string) => { diff --git a/app/components/UI/Ramp/hooks/useRampsProviders.ts b/app/components/UI/Ramp/hooks/useRampsProviders.ts index 255aeff8af3..017e6f626ec 100644 --- a/app/components/UI/Ramp/hooks/useRampsProviders.ts +++ b/app/components/UI/Ramp/hooks/useRampsProviders.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { selectProviders, - selectRampsOrders, + selectRampsOrdersForSelectedAccountGroup, } from '../../../../selectors/rampsController'; import { type Provider } from '@metamask/ramps-controller'; import Engine from '../../../../core/Engine'; @@ -55,7 +55,9 @@ export function useRampsProviders(): UseRampsProvidersResult { } = useSelector(selectProviders); const legacyOrders = useSelector(getOrders); - const controllerOrders = useSelector(selectRampsOrders); + const controllerOrders = useSelector( + selectRampsOrdersForSelectedAccountGroup, + ); const completedOrders = useMemo( () => [ diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts index bde067dce25..708fcd90549 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts @@ -284,7 +284,7 @@ describe('useTransakRouting', () => { 'test-ott', mockQuote, MOCK_WALLET_ADDRESS, - expect.any(Object), + { theme: 'light' }, ); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ @@ -482,10 +482,7 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication( - mockQuote as never, - mockQuote.fiatAmount, - ); + await result.current.routeAfterAuthentication(mockQuote as never, 25); }); expect(mockReset).toHaveBeenCalledWith( @@ -494,7 +491,56 @@ describe('useTransakRouting', () => { routes: [ expect.objectContaining({ name: 'RampAmountInput', - params: { amount: mockQuote.fiatAmount }, + params: { amount: 25 }, + }), + expect.objectContaining({ + name: 'RampAdditionalVerification', + params: expect.objectContaining({ + quote: mockQuote, + kycUrl: 'https://kyc.example.com', + workFlowRunId: 'wf-123', + amount: 25, + }), + }), + ], + }), + ); + }); + + it('handles ADDITIONAL_FORMS_REQUIRED with IDPROOF when user amount is omitted', async () => { + mockGetUserDetails.mockResolvedValue({ + firstName: 'John', + address: {}, + }); + mockGetKycRequirement.mockResolvedValue({ + status: 'ADDITIONAL_FORMS_REQUIRED', + kycType: 'STANDARD', + }); + mockGetAdditionalRequirements.mockResolvedValue({ + formsRequired: [ + { + type: 'IDPROOF', + metadata: { + kycUrl: 'https://kyc.example.com', + workFlowRunId: 'wf-123', + }, + }, + ], + }); + + const { result } = renderHook(() => useTransakRouting()); + + await act(async () => { + await result.current.routeAfterAuthentication(mockQuote as never); + }); + + expect(mockReset).toHaveBeenCalledWith( + expect.objectContaining({ + index: 1, + routes: [ + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: undefined }, }), expect.objectContaining({ name: 'RampAdditionalVerification', @@ -502,6 +548,7 @@ describe('useTransakRouting', () => { quote: mockQuote, kycUrl: 'https://kyc.example.com', workFlowRunId: 'wf-123', + amount: undefined, }), }), ], diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.ts b/app/components/UI/Ramp/hooks/useTransakRouting.ts index 661863ddf24..342e15b0441 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.ts @@ -40,6 +40,8 @@ interface RampStackParamList { quote: TransakBuyQuote; kycUrl: string; workFlowRunId: string; + /** User-entered fiat from BuildQuote; used when resetting stack so amount screen keeps the typed value. */ + amount?: number; }; RampKycProcessing: { quote: TransakBuyQuote }; RampEnterEmail: undefined; @@ -258,7 +260,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { }, { name: Routes.RAMP.ADDITIONAL_VERIFICATION, - params: { quote, kycUrl, workFlowRunId }, + params: { quote, kycUrl, workFlowRunId, amount }, }, ], }); diff --git a/app/selectors/rampsController/index.test.ts b/app/selectors/rampsController/index.test.ts index dd17c1ed4ca..ebee366ee51 100644 --- a/app/selectors/rampsController/index.test.ts +++ b/app/selectors/rampsController/index.test.ts @@ -6,6 +6,9 @@ import { type Country, type PaymentMethod, } from '@metamask/ramps-controller'; +import { AccountGroupType } from '@metamask/account-api'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import { createMockInternalAccount } from '../../util/test/accountsControllerTestUtils'; import { selectUserRegion, selectProviders, @@ -14,6 +17,7 @@ import { selectPaymentMethods, selectRampsControllerState, selectRampsOrders, + selectRampsOrdersForSelectedAccountGroup, selectTransak, } from './index'; @@ -31,6 +35,7 @@ type RampsControllerStateOverride = Partial; const createMockState = ( rampsController: RampsControllerStateOverride = {}, + extraBackgroundState: Record = {}, ): RootState => ({ engine: { @@ -58,10 +63,65 @@ const createMockState = ( }, ...rampsController, }, + KeyringController: { + keyrings: [], + }, + ...extraBackgroundState, }, }, }) as unknown as RootState; +const WALLET_ID = 'keyring:ramps-selector-test' as const; +const GROUP_ID = `${WALLET_ID}/ethereum` as const; + +function createStateWithSelectedAccountGroup( + rampsController: RampsControllerStateOverride, + internalAccount: InternalAccount, + accountId: string, +): RootState { + return createMockState(rampsController, { + AccountTreeController: { + accountTree: { + wallets: { + [WALLET_ID]: { + id: WALLET_ID, + metadata: { name: 'Test wallet' }, + groups: { + [GROUP_ID]: { + id: GROUP_ID, + type: AccountGroupType.SingleAccount, + accounts: [accountId], + metadata: { name: 'Test Group' }, + }, + }, + }, + }, + selectedAccountGroup: GROUP_ID, + }, + }, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + enableMultichainAccounts: { + enabled: true, + featureVersion: '1', + minimumVersion: '1.0.0', + }, + }, + }, + AccountsController: { + internalAccounts: { + accounts: { + [accountId]: internalAccount, + }, + selectedAccount: accountId, + }, + }, + KeyringController: { + keyrings: [], + }, + }); +} + const mockUserRegion: UserRegion = { country: { isoCode: 'US', @@ -314,6 +374,73 @@ describe('RampsController Selectors', () => { }); }); + describe('selectRampsOrdersForSelectedAccountGroup', () => { + const accountId = 'account-ramps-1'; + const walletAddrLower = '0x2990079bcdee240329a520d2444386fc119da21a'; + const internalAccount = { + ...createMockInternalAccount(walletAddrLower, 'Account 1'), + id: accountId, + }; + + it('returns empty array when no selected account group addresses', () => { + const mockOrders = [ + { + providerOrderId: 'order-1', + walletAddress: walletAddrLower, + status: 'COMPLETED', + createdAt: 1000, + }, + ]; + const state = createMockState({ + orders: mockOrders, + } as never); + + expect(selectRampsOrdersForSelectedAccountGroup(state)).toEqual([]); + }); + + it('keeps orders whose walletAddress matches a selected group address (case-insensitive for EVM)', () => { + const mockOrders = [ + { + providerOrderId: 'order-match', + walletAddress: '0x2990079BCDEE240329A520D2444386FC119DA21A', + status: 'COMPLETED', + createdAt: 1000, + }, + { + providerOrderId: 'order-other', + walletAddress: '0x0000000000000000000000000000000000000001', + status: 'COMPLETED', + createdAt: 2000, + }, + ]; + const state = createStateWithSelectedAccountGroup( + { orders: mockOrders } as never, + internalAccount, + accountId, + ); + + const result = selectRampsOrdersForSelectedAccountGroup(state); + expect(result).toEqual([mockOrders[0]]); + }); + + it('excludes orders with missing walletAddress', () => { + const mockOrders = [ + { + providerOrderId: 'order-no-wallet', + status: 'COMPLETED', + createdAt: 1000, + }, + ]; + const state = createStateWithSelectedAccountGroup( + { orders: mockOrders } as never, + internalAccount, + accountId, + ); + + expect(selectRampsOrdersForSelectedAccountGroup(state)).toEqual([]); + }); + }); + describe('selectTransak', () => { it('returns transak state when nativeProviders.transak is set', () => { const mockTransakState = { diff --git a/app/selectors/rampsController/index.ts b/app/selectors/rampsController/index.ts index aa30fd4b8aa..1c86fea1d88 100644 --- a/app/selectors/rampsController/index.ts +++ b/app/selectors/rampsController/index.ts @@ -11,6 +11,9 @@ import { type RampsOrder, } from '@metamask/ramps-controller'; import { RootState } from '../../reducers'; +import { areAddressesEqual } from '../../util/address'; +import { createDeepEqualSelector } from '../util'; +import { selectSelectedAccountGroupWithInternalAccountsAddresses } from '../multichainAccounts/accountTreeController'; /** * Selects the RampsController state from Redux. @@ -90,13 +93,42 @@ export const selectPaymentMethods = createSelector( ); /** - * Selects V2 orders from RampsController state. + * Selects all V2 orders from RampsController state (unfiltered). + * For UI scoped to the selected account group, use + * `selectRampsOrdersForSelectedAccountGroup` instead. */ export const selectRampsOrders = createSelector( selectRampsControllerState, (rampsControllerState): RampsOrder[] => rampsControllerState?.orders ?? [], ); +/** + * V2 on-ramp orders whose `walletAddress` belongs to the selected account group. + * Matches legacy `getOrders` scoping for fiat orders. + */ +export const selectRampsOrdersForSelectedAccountGroup = createDeepEqualSelector( + [selectRampsOrders, selectSelectedAccountGroupWithInternalAccountsAddresses], + (orders, addresses): RampsOrder[] => { + if (addresses.length === 0) { + return []; + } + return orders.filter((order) => { + const walletAddress = order.walletAddress; + if (!walletAddress) { + return false; + } + return addresses.some( + (addr) => addr != null && areAddressesEqual(walletAddress, addr), + ); + }); + }, + { + devModeChecks: { + identityFunctionCheck: 'never', + }, + }, +); + /** * Selects the transak native provider state (isAuthenticated, userDetails, buyQuote, kycRequirement). */ From e31075cc2948e5992addc1cac50f9491216d6f1d Mon Sep 17 00:00:00 2001 From: imyugioh <54774811+imyugioh@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:36:26 -0500 Subject: [PATCH 2/2] chore(rampsController): add test case for solana, bitcoin, tron --- app/selectors/rampsController/index.test.ts | 129 ++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/app/selectors/rampsController/index.test.ts b/app/selectors/rampsController/index.test.ts index ebee366ee51..4d179e49b44 100644 --- a/app/selectors/rampsController/index.test.ts +++ b/app/selectors/rampsController/index.test.ts @@ -7,8 +7,12 @@ import { type PaymentMethod, } from '@metamask/ramps-controller'; import { AccountGroupType } from '@metamask/account-api'; +import { AccountId } from '@metamask/accounts-controller'; +import { TrxAccountType } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; import { InternalAccount } from '@metamask/keyring-internal-api'; import { createMockInternalAccount } from '../../util/test/accountsControllerTestUtils'; +import { mockSolanaAddress } from '../../util/test/keyringControllerTestUtils'; import { selectUserRegion, selectProviders, @@ -439,6 +443,131 @@ describe('RampsController Selectors', () => { expect(selectRampsOrdersForSelectedAccountGroup(state)).toEqual([]); }); + + it('keeps orders whose walletAddress matches a Solana account in the selected group', () => { + const solanaAccountId = 'account-ramps-solana' as AccountId; + const otherSolanaAddress = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'; + const solanaInternalAccount: InternalAccount = { + id: solanaAccountId, + address: mockSolanaAddress, + type: 'solana:dataAccount' as InternalAccount['type'], + options: {}, + methods: [], + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + metadata: { + name: 'Solana Account', + importTime: Date.now(), + keyring: { + type: 'Snap Keyring', + }, + }, + }; + const mockOrders = [ + { + providerOrderId: 'order-sol-match', + walletAddress: mockSolanaAddress, + status: 'COMPLETED', + createdAt: 1000, + }, + { + providerOrderId: 'order-sol-other', + walletAddress: otherSolanaAddress, + status: 'COMPLETED', + createdAt: 2000, + }, + ]; + const state = createStateWithSelectedAccountGroup( + { orders: mockOrders } as never, + solanaInternalAccount, + solanaAccountId, + ); + + expect(selectRampsOrdersForSelectedAccountGroup(state)).toEqual([ + mockOrders[0], + ]); + }); + + it('keeps orders whose walletAddress matches a Bitcoin account in the selected group', () => { + const bitcoinAccountId = 'account-ramps-bitcoin' as AccountId; + const bitcoinAddress = 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh'; + const otherBitcoinAddress = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'; + const bitcoinInternalAccount: InternalAccount = { + id: bitcoinAccountId, + address: bitcoinAddress, + type: 'bip122:p2wpkh' as InternalAccount['type'], + options: {}, + methods: [], + scopes: ['bip122:000000000019d6689c085ae165831e93'], + metadata: { + name: 'Bitcoin Account', + importTime: Date.now(), + keyring: { + type: 'Snap Keyring', + }, + }, + }; + const mockOrders = [ + { + providerOrderId: 'order-btc-match', + walletAddress: bitcoinAddress, + status: 'COMPLETED', + createdAt: 1000, + }, + { + providerOrderId: 'order-btc-other', + walletAddress: otherBitcoinAddress, + status: 'COMPLETED', + createdAt: 2000, + }, + ]; + const state = createStateWithSelectedAccountGroup( + { orders: mockOrders } as never, + bitcoinInternalAccount, + bitcoinAccountId, + ); + + expect(selectRampsOrdersForSelectedAccountGroup(state)).toEqual([ + mockOrders[0], + ]); + }); + + it('keeps orders whose walletAddress matches a Tron account in the selected group', () => { + const tronAccountId = 'account-ramps-tron' as AccountId; + const tronAddress = 'TXYZopYRdj2D9XRtbPoJZ1CuXLNaoEBgD'; + const otherTronAddress = 'TN3W4H6rK2ce4vX9YnFQHw8ENXNA9s8rPH'; + const tronInternalAccount: InternalAccount = { + ...createMockInternalAccount( + tronAddress, + 'Tron Account', + KeyringTypes.snap, + TrxAccountType.Eoa, + ), + id: tronAccountId, + }; + const mockOrders = [ + { + providerOrderId: 'order-tron-match', + walletAddress: tronAddress, + status: 'COMPLETED', + createdAt: 1000, + }, + { + providerOrderId: 'order-tron-other', + walletAddress: otherTronAddress, + status: 'COMPLETED', + createdAt: 2000, + }, + ]; + const state = createStateWithSelectedAccountGroup( + { orders: mockOrders } as never, + tronInternalAccount, + tronAccountId, + ); + + expect(selectRampsOrdersForSelectedAccountGroup(state)).toEqual([ + mockOrders[0], + ]); + }); }); describe('selectTransak', () => {