Skip to content

Commit af8fe0d

Browse files
fix: fix on-ramp buy navigation on token details (#25709)
## **Description** PR to fix navigation to buy crypto with cash on the sticky buy button by passing the assetId ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Pass assetID to the on ramp buy screen. ## **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** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Small navigation change limited to the token-details Buy fallback path; risk is mainly incorrect `assetId` formatting causing the on-ramp screen to open without preselecting the intended asset. > > **Overview** > Fixes the token-details sticky **Buy** flow so that when no eligible swap/bridge source token exists, the app navigates to on-ramp via `goToBuy({ assetId })` instead of calling `goToBuy()` with no context. > > `assetId` is derived by using the token’s CAIP address directly for non-EVM assets, or generating an EVM CAIP asset id via `formatAddressToAssetId(address, chainId)` (with a safe fallback to `undefined` on errors). Tests are expanded to cover EVM vs non-EVM/trending tokens and error cases for `assetId` generation. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ce98ac3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Curtis David <Curtis.David7@gmail.com>
1 parent fa7d510 commit af8fe0d

2 files changed

Lines changed: 253 additions & 2 deletions

File tree

app/components/UI/TokenDetails/hooks/useTokenActions.test.ts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
ActionLocation,
1616
} from '../../../../util/analytics/actionButtonTracking';
1717
import Routes from '../../../../constants/navigation/Routes';
18+
import { isCaipAssetType } from '@metamask/utils';
19+
import { formatAddressToAssetId } from '@metamask/bridge-controller';
1820

1921
jest.mock('react-redux', () => ({
2022
...jest.requireActual('react-redux'),
@@ -123,6 +125,19 @@ jest.mock('../../Bridge/utils/tokenUtils', () => ({
123125
getNativeSourceToken: jest.fn(),
124126
}));
125127

128+
jest.mock('@metamask/utils', () => ({
129+
...jest.requireActual('@metamask/utils'),
130+
isCaipAssetType: jest.fn(),
131+
}));
132+
133+
jest.mock('@metamask/bridge-controller', () => ({
134+
...jest.requireActual('@metamask/bridge-controller'),
135+
formatAddressToAssetId: jest.fn(),
136+
}));
137+
138+
const mockIsCaipAssetType = jest.mocked(isCaipAssetType);
139+
const mockFormatAddressToAssetId = jest.mocked(formatAddressToAssetId);
140+
126141
jest.mock('../../../../core/Engine', () => ({
127142
context: {
128143
NetworkController: {
@@ -373,6 +388,21 @@ describe('useTokenActions', () => {
373388
});
374389

375390
describe('handleBuyPress', () => {
391+
beforeEach(() => {
392+
// Default mock behavior for assetId generation
393+
mockIsCaipAssetType.mockReturnValue(false);
394+
mockFormatAddressToAssetId.mockImplementation(
395+
(address: string, chainId: string | number) => {
396+
// Simulate the real behavior for EVM tokens
397+
const numericChainId =
398+
typeof chainId === 'string' ? parseInt(chainId, 16) : chainId;
399+
const checksumAddress =
400+
address.slice(0, 2) + address.slice(2).toUpperCase();
401+
return `eip155:${numericChainId}/erc20:${checksumAddress}`;
402+
},
403+
);
404+
});
405+
376406
it('routes to on-ramp when no eligible tokens exist', () => {
377407
// Empty user assets (no tokens with balance) - uses default from setupDefaultMocks
378408
const { result } = renderHook(() =>
@@ -388,6 +418,206 @@ describe('useTokenActions', () => {
388418
expect(mockGoToSwaps).not.toHaveBeenCalled();
389419
});
390420

421+
describe('assetId generation for on-ramp', () => {
422+
it('uses token.address directly for non-EVM tokens with CAIP address (Solana)', () => {
423+
// Real Solana token structure - address is already a CAIP asset type
424+
const solanaToken = {
425+
address:
426+
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:AUSD1jCcCyPLybk1YnvPWsHQSrZ46dxwoMniN4N2UEB9',
427+
aggregators: [],
428+
decimals: 6,
429+
image:
430+
'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/AUSD1jCcCyPLybk1YnvPWsHQSrZ46dxwoMniN4N2UEB9.png',
431+
name: 'AUSD',
432+
symbol: 'AUSD',
433+
balance: '0',
434+
balanceFiat: '$0.00',
435+
isETH: false,
436+
isStaked: false,
437+
chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
438+
isNative: false,
439+
ticker: 'AUSD',
440+
accountType: 'solana:data-account',
441+
} as unknown as TokenI;
442+
443+
mockIsCaipAssetType.mockReturnValue(true);
444+
445+
const { result } = renderHook(() =>
446+
useTokenActions({
447+
token: solanaToken,
448+
networkName: 'Solana',
449+
}),
450+
);
451+
452+
result.current.handleBuyPress();
453+
454+
expect(mockIsCaipAssetType).toHaveBeenCalledWith(solanaToken.address);
455+
expect(mockFormatAddressToAssetId).not.toHaveBeenCalled();
456+
expect(mockGoToBuy).toHaveBeenCalledWith({
457+
assetId: solanaToken.address,
458+
});
459+
});
460+
461+
it('uses token.address directly for trending non-EVM tokens with CAIP address', () => {
462+
// Real trending Solana token structure
463+
const trendingSolanaToken = {
464+
chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
465+
address:
466+
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:4j1B6dZn9s4nmf8yZhResvSrTA3nmMhDnfNYY2Q5N7c1',
467+
decimals: 6,
468+
image:
469+
'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/4j1B6dZn9s4nmf8yZhResvSrTA3nmMhDnfNYY2Q5N7c1.png',
470+
pricePercentChange1d: 358.639,
471+
isNative: false,
472+
isETH: false,
473+
isFromTrending: true,
474+
} as unknown as TokenI;
475+
476+
mockIsCaipAssetType.mockReturnValue(true);
477+
478+
const { result } = renderHook(() =>
479+
useTokenActions({
480+
token: trendingSolanaToken,
481+
networkName: 'Solana',
482+
}),
483+
);
484+
485+
result.current.handleBuyPress();
486+
487+
expect(mockIsCaipAssetType).toHaveBeenCalledWith(
488+
trendingSolanaToken.address,
489+
);
490+
expect(mockFormatAddressToAssetId).not.toHaveBeenCalled();
491+
expect(mockGoToBuy).toHaveBeenCalledWith({
492+
assetId: trendingSolanaToken.address,
493+
});
494+
});
495+
496+
it('uses formatAddressToAssetId for EVM tokens with hex address', () => {
497+
// Real EVM token structure - address is hex, chainId is hex
498+
const evmToken = {
499+
address: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
500+
aggregators: [],
501+
decimals: 18,
502+
image:
503+
'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x6b175474e89094c44da98b954eedeac495271d0f.png',
504+
name: 'Dai Stablecoin',
505+
symbol: 'DAI',
506+
balance: '0',
507+
balanceFiat: '$0.00',
508+
isETH: false,
509+
isStaked: false,
510+
chainId: '0x1',
511+
isNative: false,
512+
ticker: 'DAI',
513+
accountType: 'eip155:eoa',
514+
} as unknown as TokenI;
515+
516+
const expectedAssetId =
517+
'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F';
518+
mockIsCaipAssetType.mockReturnValue(false);
519+
mockFormatAddressToAssetId.mockReturnValue(expectedAssetId);
520+
521+
const { result } = renderHook(() =>
522+
useTokenActions({
523+
token: evmToken,
524+
networkName: 'Ethereum Mainnet',
525+
}),
526+
);
527+
528+
result.current.handleBuyPress();
529+
530+
expect(mockIsCaipAssetType).toHaveBeenCalledWith(evmToken.address);
531+
expect(mockFormatAddressToAssetId).toHaveBeenCalledWith(
532+
evmToken.address,
533+
evmToken.chainId,
534+
);
535+
expect(mockGoToBuy).toHaveBeenCalledWith({
536+
assetId: expectedAssetId,
537+
});
538+
});
539+
540+
it('uses formatAddressToAssetId for trending EVM tokens', () => {
541+
// Real trending EVM token structure
542+
const trendingEvmToken = {
543+
chainId: '0x2105',
544+
address: '0x852df602530532fb356adf25fbf0f6511b764b07',
545+
symbol: 'Dave',
546+
name: 'Dave the Minion',
547+
decimals: 18,
548+
image:
549+
'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0x852df602530532fb356adf25fbf0f6511b764b07.png',
550+
pricePercentChange1d: 3203.891,
551+
isNative: false,
552+
isETH: false,
553+
isFromTrending: true,
554+
} as unknown as TokenI;
555+
556+
const expectedAssetId =
557+
'eip155:8453/erc20:0x852df602530532fb356adf25fbf0f6511b764b07';
558+
mockIsCaipAssetType.mockReturnValue(false);
559+
mockFormatAddressToAssetId.mockReturnValue(expectedAssetId);
560+
561+
const { result } = renderHook(() =>
562+
useTokenActions({
563+
token: trendingEvmToken,
564+
networkName: 'Base',
565+
}),
566+
);
567+
568+
result.current.handleBuyPress();
569+
570+
expect(mockIsCaipAssetType).toHaveBeenCalledWith(
571+
trendingEvmToken.address,
572+
);
573+
expect(mockFormatAddressToAssetId).toHaveBeenCalledWith(
574+
trendingEvmToken.address,
575+
trendingEvmToken.chainId,
576+
);
577+
expect(mockGoToBuy).toHaveBeenCalledWith({
578+
assetId: expectedAssetId,
579+
});
580+
});
581+
582+
it('passes undefined assetId when formatAddressToAssetId throws an error', () => {
583+
mockIsCaipAssetType.mockReturnValue(false);
584+
mockFormatAddressToAssetId.mockImplementation(() => {
585+
throw new Error('Invalid address format');
586+
});
587+
588+
const { result } = renderHook(() =>
589+
useTokenActions({
590+
token: defaultToken,
591+
networkName: 'Ethereum Mainnet',
592+
}),
593+
);
594+
595+
result.current.handleBuyPress();
596+
597+
expect(mockGoToBuy).toHaveBeenCalledWith({
598+
assetId: undefined,
599+
});
600+
});
601+
602+
it('passes undefined assetId when formatAddressToAssetId returns null', () => {
603+
mockIsCaipAssetType.mockReturnValue(false);
604+
mockFormatAddressToAssetId.mockReturnValue(undefined);
605+
606+
const { result } = renderHook(() =>
607+
useTokenActions({
608+
token: defaultToken,
609+
networkName: 'Ethereum Mainnet',
610+
}),
611+
);
612+
613+
result.current.handleBuyPress();
614+
615+
expect(mockGoToBuy).toHaveBeenCalledWith({
616+
assetId: undefined,
617+
});
618+
});
619+
});
620+
391621
it('calls goToSwaps with source and dest tokens when user has eligible tokens on same chain', () => {
392622
// Override selectAssetsBySelectedAccountGroup with tokens that have balance
393623
selectorMocks.mockSelectAssetsBySelectedAccountGroup.mockReturnValue({

app/components/UI/TokenDetails/hooks/useTokenActions.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from '../../Bridge/utils/tokenUtils';
3636
import { useSendNonEvmAsset } from '../../../hooks/useSendNonEvmAsset';
3737
import {
38+
formatAddressToAssetId,
3839
formatChainIdToCaip,
3940
isNativeAddress,
4041
} from '@metamask/bridge-controller';
@@ -391,13 +392,33 @@ export const useTokenActions = ({
391392
const handleBuyPress = useCallback(() => {
392393
// If user has no eligible tokens to swap with, route to on-ramp
393394
if (!buySourceToken) {
394-
goToBuy();
395+
let assetId: string | undefined;
396+
397+
try {
398+
if (isCaipAssetType(token.address)) {
399+
assetId = token.address;
400+
} else if (token.chainId) {
401+
assetId =
402+
formatAddressToAssetId(token.address, token.chainId) ?? undefined;
403+
}
404+
} catch {
405+
assetId = undefined;
406+
}
407+
408+
goToBuy({ assetId });
395409
return;
396410
}
397411

398412
if (!goToSwaps) return;
399413
goToSwaps(buySourceToken, currentTokenAsBridgeToken);
400-
}, [goToSwaps, goToBuy, buySourceToken, currentTokenAsBridgeToken]);
414+
}, [
415+
goToSwaps,
416+
goToBuy,
417+
buySourceToken,
418+
currentTokenAsBridgeToken,
419+
token.address,
420+
token.chainId,
421+
]);
401422

402423
// Sell: current token as source, let swap UI compute default dest
403424
const handleSellPress = useCallback(() => {

0 commit comments

Comments
 (0)