Skip to content

Commit 710d7c3

Browse files
feat: bridge solana pumpfun fetch (#14699)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> This PR will fetch asset metadata from the Token API when the user searches for a token's address but it isn't in the current token list. Works for both Solana and EVM. ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to Solana bridge 2. Open source token picker 3. Paste in a token from https://pump.fun/board?coins_sort=created_timestamp 4. Observe fetching ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> https://github.com/user-attachments/assets/8d914d06-6f43-49c1-aa59-cb308471eb6f ## **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.
1 parent 8bcaa40 commit 710d7c3

File tree

12 files changed

+753
-22
lines changed

12 files changed

+753
-22
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- feat: add AppMetadataController controller ([#14513](https://github.com/MetaMask/metamask-mobile/pull/14513))
2222
- feat(bridge): implement bridge quote fetching ([#14413](https://github.com/MetaMask/metamask-mobile/pull/14413))
2323
- feat(multi-srp): add e2e tests ([#14583](https://github.com/MetaMask/metamask-mobile/pull/14583))
24-
24+
- feat(bridge): fetch token metadata for Bridge token pickers if not already available ([#14699](https://github.com/MetaMask/metamask-mobile/pull/14699))
2525

2626
### Changed
2727

app/components/UI/Bridge/components/BridgeDestTokenSelector/BridgeDestTokenSelector.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ describe('BridgeDestTokenSelector', () => {
230230
});
231231

232232
it('displays empty state when no tokens match search', async () => {
233+
jest.useFakeTimers();
233234
const { getByTestId, getByText } = renderScreen(
234235
BridgeDestTokenSelector,
235236
{

app/components/UI/Bridge/components/BridgeDestTokenSelector/index.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ export const BridgeDestTokenSelector: React.FC = () => {
3636
const selectedDestChainId = useSelector(selectSelectedDestChainId);
3737
const selectedSourceToken = useSelector(selectSourceToken);
3838
const { tokens: tokensList, pending } = useTokens({
39-
topTokensChainId: selectedDestChainId as Hex,
40-
balanceChainIds: [selectedDestChainId as Hex],
39+
topTokensChainId: selectedDestChainId,
40+
balanceChainIds: selectedDestChainId ? [selectedDestChainId] : [],
4141
tokensToExclude: selectedSourceToken ? [selectedSourceToken] : [],
4242
});
4343
const handleTokenPress = useCallback(
@@ -92,6 +92,7 @@ export const BridgeDestTokenSelector: React.FC = () => {
9292
renderTokenItem={renderToken}
9393
tokensList={tokensList}
9494
pending={pending}
95+
chainIdToFetchMetadata={selectedDestChainId}
9596
/>
9697
);
9798
};

app/components/UI/Bridge/components/BridgeSourceTokenSelector/BridgeSourceTokenSelector.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ describe('BridgeSourceTokenSelector', () => {
193193
});
194194

195195
it('displays empty state when no tokens match search', async () => {
196+
jest.useFakeTimers();
196197
const { getByTestId, getByText } = renderScreen(
197198
BridgeSourceTokenSelector,
198199
{

app/components/UI/Bridge/components/BridgeSourceTokenSelector/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
///: END:ONLY_INCLUDE_IF
99
} from '@metamask/utils';
1010
import {
11+
selectChainId,
1112
selectEvmNetworkConfigurationsByChainId,
1213
selectNetworkConfigurations,
1314
} from '../../../../../selectors/networkController';
@@ -53,6 +54,7 @@ export const BridgeSourceTokenSelector: React.FC = () => {
5354
const { sortedSourceNetworks } = useSortedSourceNetworks();
5455
const selectedSourceToken = useSelector(selectSourceToken);
5556
const selectedDestToken = useSelector(selectDestToken);
57+
const selectedChainId = useSelector(selectChainId);
5658

5759
const {
5860
chainId: selectedEvmChainId, // Will be the most recently selected EVM chain if you are on Solana
@@ -158,6 +160,7 @@ export const BridgeSourceTokenSelector: React.FC = () => {
158160
renderTokenItem={renderItem}
159161
tokensList={tokensList}
160162
pending={pending}
163+
chainIdToFetchMetadata={selectedChainId}
161164
/>
162165
);
163166
};

app/components/UI/Bridge/components/BridgeTokenSelectorBase.tsx

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { useTokenSearch } from '../hooks/useTokenSearch';
2121
import TextFieldSearch from '../../../../component-library/components/Form/TextFieldSearch';
2222
import { BridgeToken } from '../types';
2323
import { Skeleton } from '../../../../component-library/components/Skeleton';
24+
import { useAssetMetadata } from '../hooks/useAssetMetadata';
25+
import { CaipChainId, Hex } from '@metamask/utils';
2426

2527
const createStyles = (params: { theme: Theme }) => {
2628
const { theme } = params;
@@ -60,7 +62,7 @@ const createStyles = (params: { theme: Theme }) => {
6062
// Need the flex 1 to make sure this doesn't disappear when FlexDirection.Row is used
6163
skeletonItem: {
6264
flex: 1,
63-
}
65+
},
6466
});
6567
};
6668

@@ -75,13 +77,13 @@ const SkeletonItem = () => {
7577
>
7678
<Skeleton height={30} width={30} style={styles.skeletonCircle} />
7779

78-
<Box gap={4} style={styles.skeletonItem}>
79-
<Skeleton height={24} width={'96%'} />
80-
<Skeleton height={24} width={'37%'} />
81-
</Box>
80+
<Box gap={4} style={styles.skeletonItem}>
81+
<Skeleton height={24} width={'96%'} />
82+
<Skeleton height={24} width={'37%'} />
83+
</Box>
8284

83-
<Icon name={IconName.Info} />
84-
</Box>
85+
<Icon name={IconName.Info} />
86+
</Box>
8587
);
8688
};
8789

@@ -109,20 +111,49 @@ interface BridgeTokenSelectorBaseProps {
109111
renderTokenItem: ({ item }: { item: BridgeToken }) => React.JSX.Element;
110112
tokensList: BridgeToken[];
111113
pending?: boolean;
114+
chainIdToFetchMetadata?: Hex | CaipChainId;
112115
}
113116

114117
export const BridgeTokenSelectorBase: React.FC<
115118
BridgeTokenSelectorBaseProps
116-
> = ({ networksBar, renderTokenItem, tokensList, pending }) => {
119+
> = ({
120+
networksBar,
121+
renderTokenItem,
122+
tokensList,
123+
pending,
124+
chainIdToFetchMetadata: chainId,
125+
}) => {
117126
const { styles, theme } = useStyles(createStyles, {});
118-
const { searchString, setSearchString, searchResults } = useTokenSearch({
127+
const {
128+
searchString,
129+
setSearchString,
130+
searchResults,
131+
debouncedSearchString,
132+
} = useTokenSearch({
119133
tokens: tokensList || [],
120134
});
121-
const tokensToRender = useMemo(
122-
() => (searchString ? searchResults : tokensList),
123-
[searchString, searchResults, tokensList],
135+
136+
const {
137+
assetMetadata: unlistedAssetMetadata,
138+
pending: unlistedAssetMetadataPending,
139+
} = useAssetMetadata(
140+
debouncedSearchString,
141+
Boolean(debouncedSearchString && searchResults.length === 0),
142+
chainId,
124143
);
125144

145+
const tokensToRender = useMemo(() => {
146+
if (!searchString) {
147+
return tokensList;
148+
}
149+
150+
if (searchResults.length > 0) {
151+
return searchResults;
152+
}
153+
154+
return unlistedAssetMetadata ? [unlistedAssetMetadata] : [];
155+
}, [searchString, searchResults, tokensList, unlistedAssetMetadata]);
156+
126157
const keyExtractor = useCallback(
127158
(token: BridgeToken) => `${token.chainId}-${token.address}`,
128159
[],
@@ -139,18 +170,23 @@ export const BridgeTokenSelectorBase: React.FC<
139170
() => (
140171
<Box style={styles.emptyList}>
141172
<Text color={TextColor.Alternative}>
142-
{strings('swaps.no_tokens_result', { searchString })}
173+
{strings('swaps.no_tokens_result', { searchString: debouncedSearchString })}
143174
</Text>
144175
</Box>
145176
),
146-
[searchString, styles],
177+
[debouncedSearchString, styles],
147178
);
148179

149180
const modalRef = useRef<BottomSheetRef>(null);
150181
const dismissModal = (): void => {
151182
modalRef.current?.onCloseBottomSheet();
152183
};
153184

185+
const shouldRenderLoading = useMemo(
186+
() => pending || unlistedAssetMetadataPending,
187+
[pending, unlistedAssetMetadataPending],
188+
);
189+
154190
return (
155191
<BottomSheet isFullscreen ref={modalRef}>
156192
<Box style={styles.content}>
@@ -192,10 +228,14 @@ export const BridgeTokenSelectorBase: React.FC<
192228
</Box>
193229

194230
<FlatList
195-
data={pending ? [] : tokensToRender}
231+
data={shouldRenderLoading ? [] : tokensToRender}
196232
renderItem={renderTokenItem}
197233
keyExtractor={keyExtractor}
198-
ListEmptyComponent={searchString ? renderEmptyList : LoadingSkeleton}
234+
ListEmptyComponent={
235+
debouncedSearchString && !shouldRenderLoading
236+
? renderEmptyList
237+
: LoadingSkeleton
238+
}
199239
/>
200240
</Box>
201241
</BottomSheet>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { CaipAssetType, CaipChainId, Hex } from '@metamask/utils';
2+
import { useSelector } from 'react-redux';
3+
import { fetchAssetMetadata, getAssetImageUrl } from './utils';
4+
import { useAsyncResult } from '../../../../hooks/useAsyncResult';
5+
import { selectBasicFunctionalityEnabled } from '../../../../../selectors/settings';
6+
import { isAddress as isSolanaAddress } from '@solana/addresses';
7+
import { isAddress as isEvmAddress } from 'ethers/lib/utils';
8+
9+
export enum AssetType {
10+
/** The native asset for the current network, such as ETH */
11+
native = 'NATIVE',
12+
/** An ERC20 token */
13+
token = 'TOKEN',
14+
/** An ERC721 or ERC1155 token. */
15+
NFT = 'NFT',
16+
/**
17+
* A transaction interacting with a contract that isn't a token method
18+
* interaction will be marked as dealing with an unknown asset type.
19+
*/
20+
unknown = 'UNKNOWN',
21+
}
22+
23+
/**
24+
* Fetches token metadata for a single token if searchQuery is defined but filteredTokenList is empty
25+
* There is no minimum age of token that can be queried for. The Token API has a fallback mechanism that will look up tokens it does not have saved.
26+
*
27+
* @param searchQuery - The search query to fetch metadata for
28+
* @param shouldFetchMetadata - Whether to fetch metadata
29+
* @param chainId - The chain id to fetch metadata for
30+
* @returns The asset metadata
31+
*/
32+
export const useAssetMetadata = (
33+
searchQuery: string,
34+
shouldFetchMetadata: boolean,
35+
chainId?: Hex | CaipChainId,
36+
) => {
37+
const isBasicFunctionalityEnabled = useSelector(
38+
selectBasicFunctionalityEnabled,
39+
);
40+
41+
const { value: assetMetadata, pending } = useAsyncResult<
42+
| {
43+
address: Hex | CaipAssetType | string;
44+
symbol: string;
45+
decimals: number;
46+
image: string;
47+
chainId: Hex | CaipChainId;
48+
isNative: boolean;
49+
type: AssetType.token;
50+
balance: string;
51+
string: string;
52+
}
53+
| undefined
54+
>(async () => {
55+
if (!chainId || !searchQuery) {
56+
return undefined;
57+
}
58+
59+
const trimmedSearchQuery = searchQuery.trim();
60+
const isAddress =
61+
isSolanaAddress(trimmedSearchQuery) || isEvmAddress(trimmedSearchQuery);
62+
63+
if (isBasicFunctionalityEnabled && shouldFetchMetadata && isAddress) {
64+
const metadata = await fetchAssetMetadata(trimmedSearchQuery, chainId);
65+
66+
if (metadata) {
67+
return {
68+
...metadata,
69+
chainId,
70+
isNative: false,
71+
type: AssetType.token,
72+
image: getAssetImageUrl(metadata.assetId, chainId) ?? '',
73+
balance: '',
74+
string: '',
75+
} as const;
76+
}
77+
return undefined;
78+
}
79+
return undefined;
80+
}, [shouldFetchMetadata, searchQuery]);
81+
82+
return { assetMetadata, pending };
83+
};

0 commit comments

Comments
 (0)