diff --git a/eslint.config.mjs b/eslint.config.mjs index c20fb1371c6..582cc76c648 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -202,7 +202,6 @@ export default [ 'src/components/modals/SwapVerifyTermsModal.tsx', 'src/components/modals/TextInputModal.tsx', 'src/components/modals/TransferModal.tsx', - 'src/components/modals/WalletListMenuModal.tsx', 'src/components/modals/WalletListSortModal.tsx', 'src/components/modals/WcSmartContractModal.tsx', @@ -252,7 +251,6 @@ export default [ 'src/components/scenes/DuressModeHowToScene.tsx', 'src/components/scenes/DuressModeSettingScene.tsx', - 'src/components/scenes/EditTokenScene.tsx', 'src/components/scenes/ExtraTabScene.tsx', 'src/components/scenes/Fio/FioAddressListScene.tsx', @@ -275,13 +273,11 @@ export default [ 'src/components/scenes/LoadingScene.tsx', 'src/components/scenes/Loans/LoanCloseScene.tsx', - 'src/components/scenes/Loans/LoanCreateScene.tsx', 'src/components/scenes/Loans/LoanDashboardScene.tsx', 'src/components/scenes/Loans/LoanDetailsScene.tsx', 'src/components/scenes/Loans/LoanManageScene.tsx', 'src/components/scenes/Loans/LoanStatusScene.tsx', - 'src/components/scenes/ManageTokensScene.tsx', 'src/components/scenes/MigrateWalletCalculateFeeScene.tsx', 'src/components/scenes/MigrateWalletCompletionScene.tsx', @@ -478,7 +474,6 @@ export default [ 'src/reducers/ExchangeInfoReducer.ts', 'src/reducers/NetworkReducer.ts', - 'src/selectors/getCreateWalletList.ts', 'src/selectors/SettingsSelectors.ts', 'src/state/createStateProvider.tsx', 'src/state/SceneFooterState.tsx', @@ -492,8 +487,6 @@ export default [ 'src/util/crypto.ts', 'src/util/CryptoAmount.ts', 'src/util/cryptoTextUtils.ts', - 'src/util/CurrencyInfoHelpers.ts', - 'src/util/CurrencyWalletHelpers.ts', 'src/util/exchangeRates.ts', @@ -510,7 +503,6 @@ export default [ 'src/util/otpReminder.tsx', 'src/util/scaling.ts', 'src/util/show-confetti.ts', - 'src/util/stakeUtils.ts', 'src/util/ukComplianceUtils.ts', 'src/util/utils.ts', diff --git a/package.json b/package.json index 19d0f60f868..a8ba6514583 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "deprecated-react-native-prop-types": "^5.0.0", "detect-bundler": "^1.1.0", "disklet": "^0.5.2", - "edge-core-js": "^2.43.1", + "edge-core-js": "file:../edge-core-js", "edge-currency-accountbased": "^4.75.2", "edge-currency-monero": "^2.2.0", "edge-currency-plugins": "^3.8.11", diff --git a/src/__tests__/edgeProvider.test.ts b/src/__tests__/edgeProvider.test.ts index 4175b8278bc..8b3dd64ef8e 100644 --- a/src/__tests__/edgeProvider.test.ts +++ b/src/__tests__/edgeProvider.test.ts @@ -246,7 +246,6 @@ const currencyConfig: Record = { metaTokens: [] }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, telos: { @@ -282,7 +281,6 @@ const currencyConfig: Record = { metaTokens: [] }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, wax: { @@ -318,7 +316,6 @@ const currencyConfig: Record = { metaTokens: [] }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, binancesmartchain: { @@ -390,7 +387,6 @@ const currencyConfig: Record = { metaTokens: [] }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, ethereum: { @@ -2888,7 +2884,7 @@ const currencyConfig: Record = { } } }, - builtinTokens: { + customTokens: { '1985365e9f78359a9b6ad760e32412f4a445e862': { currencyCode: 'REP', denominations: [ @@ -4176,8 +4172,7 @@ const currencyConfig: Record = { contractAddress: '0x111111111117dc0aa78b770fa6a738034120c302' } } - }, - customTokens: {} + } }, ethereumclassic: { otherMethods: {}, @@ -4272,7 +4267,6 @@ const currencyConfig: Record = { metaTokens: [] }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, fantom: { @@ -4611,7 +4605,7 @@ const currencyConfig: Record = { } } }, - builtinTokens: { + customTokens: { '049d68029688eabf473097a2fc38ef61633a3c7a': { currencyCode: 'FUSDT', denominations: [ @@ -4755,8 +4749,7 @@ const currencyConfig: Record = { contractAddress: '0x4cdf39285d7ca8eb3f090fda0c069ba5f4145b37' } } - }, - customTokens: {} + } }, rsk: { otherMethods: {}, @@ -4851,7 +4844,7 @@ const currencyConfig: Record = { } } }, - builtinTokens: { + customTokens: { '2acc95758f8b5f583470ba265eb685a8f45fc9d5': { currencyCode: 'RIF', denominations: [ @@ -4865,8 +4858,7 @@ const currencyConfig: Record = { contractAddress: '0x2acc95758f8b5f583470ba265eb685a8f45fc9d5' } } - }, - customTokens: {} + } }, polygon: { otherMethods: {}, @@ -5307,7 +5299,7 @@ const currencyConfig: Record = { } } }, - builtinTokens: { + customTokens: { '2791bca1f2de4661ed88a30c99a7a9449aa84174': { currencyCode: 'USDC', denominations: [ @@ -5503,8 +5495,7 @@ const currencyConfig: Record = { contractAddress: '0x53e0bca35ec356bd5dddfebbd1fc0fd03fabad39' } } - }, - customTokens: {} + } }, celo: { otherMethods: {}, @@ -5628,7 +5619,7 @@ const currencyConfig: Record = { } } }, - builtinTokens: { + customTokens: { '765de816845861e75a25fca122bb6898b8b1282a': { currencyCode: 'CUSD', denominations: [ @@ -5655,8 +5646,7 @@ const currencyConfig: Record = { contractAddress: '0xD8763CBa276a3738E6DE85b4b3bF5FDed6D6cA73' } } - }, - customTokens: {} + } }, avalanche: { otherMethods: {}, @@ -6093,7 +6083,7 @@ const currencyConfig: Record = { } } }, - builtinTokens: { + customTokens: { '60781c2586d68229fde47564546784ab3faca982': { currencyCode: 'PNG', denominations: [ @@ -6289,8 +6279,7 @@ const currencyConfig: Record = { contractAddress: '0x50b7545627a5162F82A992c33b87aDc75187B218' } } - }, - customTokens: {} + } }, fio: { otherMethods: {}, @@ -6361,7 +6350,6 @@ const currencyConfig: Record = { metaTokens: [] }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, zcash: { @@ -6398,7 +6386,6 @@ const currencyConfig: Record = { metaTokens: [] }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, ripple: { @@ -6434,7 +6421,6 @@ const currencyConfig: Record = { metaTokens: [] }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, stellar: { @@ -6462,7 +6448,6 @@ const currencyConfig: Record = { metaTokens: [] }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, tezos: { @@ -6502,7 +6487,6 @@ const currencyConfig: Record = { metaTokens: [] }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, binance: { @@ -6536,7 +6520,6 @@ const currencyConfig: Record = { metaTokens: [] }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, hedera: { @@ -6573,7 +6556,6 @@ const currencyConfig: Record = { metaTokens: [] }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, solana: { @@ -6608,7 +6590,6 @@ const currencyConfig: Record = { metaTokens: [] }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, monero: { @@ -6640,7 +6621,6 @@ const currencyConfig: Record = { metaTokens: [] }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, bitcoin: { @@ -6699,7 +6679,6 @@ const currencyConfig: Record = { symbolImageDarkMono: 'https://content.edge.app/bitcoin-logo-solo-64.png' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, bitcoincash: { @@ -6755,7 +6734,6 @@ const currencyConfig: Record = { 'https://content.edge.app/bitcoincash-logo-solo-64.png' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, bitcoingold: { @@ -6807,7 +6785,6 @@ const currencyConfig: Record = { transactionExplorer: 'https://explorer.bitcoingold.org/insight/tx/%s' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, bitcoinsv: { @@ -6858,7 +6835,6 @@ const currencyConfig: Record = { symbolImageDarkMono: 'https://content.edge.app/bitcoinsv-logo-solo-64.png' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, bitcointestnet: { @@ -6912,7 +6888,6 @@ const currencyConfig: Record = { symbolImageDarkMono: 'https://content.edge.app/bitcoin-logo-solo-64.png' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, dash: { @@ -6960,7 +6935,6 @@ const currencyConfig: Record = { 'https://blockchair.com/dash/transaction/%s?from=edgeapp' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, digibyte: { @@ -7001,7 +6975,6 @@ const currencyConfig: Record = { transactionExplorer: 'https://digiexplorer.info/tx/%s' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, dogecoin: { @@ -7045,7 +7018,6 @@ const currencyConfig: Record = { 'https://blockchair.com/dogecoin/transaction/%s?from=edgeapp' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, eboost: { @@ -7086,7 +7058,6 @@ const currencyConfig: Record = { transactionExplorer: 'https://www.blockexperts.com/ebst/tx/%s' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, feathercoin: { @@ -7130,7 +7101,6 @@ const currencyConfig: Record = { 'https://content.edge.app/feathercoin-logo-solo-64.png' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, groestlcoin: { @@ -7173,7 +7143,6 @@ const currencyConfig: Record = { 'https://blockchair.com/groestlcoin/transaction/%s?from=edgeapp' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, litecoin: { @@ -7222,7 +7191,6 @@ const currencyConfig: Record = { symbolImageDarkMono: 'https://content.edge.app/litecoin-logo-solo-64.png' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, qtum: { @@ -7258,7 +7226,6 @@ const currencyConfig: Record = { transactionExplorer: 'https://explorer.qtum.org/tx/%s' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, ravencoin: { @@ -7297,7 +7264,6 @@ const currencyConfig: Record = { transactionExplorer: 'https://ravencoin.network/tx/%s' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, smartcash: { @@ -7338,7 +7304,6 @@ const currencyConfig: Record = { transactionExplorer: 'https://insight.smartcash.cc/tx/%s' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, ufo: { @@ -7379,7 +7344,6 @@ const currencyConfig: Record = { transactionExplorer: 'https://explorer.ufobject.com/tx/%s' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, vertcoin: { @@ -7426,7 +7390,6 @@ const currencyConfig: Record = { transactionExplorer: 'https://insight.vertcoin.org/tx/%s' }, allTokens: {}, - builtinTokens: {}, customTokens: {} }, zcoin: { @@ -7469,7 +7432,6 @@ const currencyConfig: Record = { symbolImageDarkMono: 'https://content.edge.app/zcoin-logo-solo-64.png' }, allTokens: {}, - builtinTokens: {}, customTokens: {} } } diff --git a/src/__tests__/modals/__snapshots__/WalletListModal.test.tsx.snap b/src/__tests__/modals/__snapshots__/WalletListModal.test.tsx.snap index 25d2c135ddc..69a3118c002 100644 --- a/src/__tests__/modals/__snapshots__/WalletListModal.test.tsx.snap +++ b/src/__tests__/modals/__snapshots__/WalletListModal.test.tsx.snap @@ -781,6 +781,7 @@ exports[`WalletListModal should render with loading props 1`] = ` - - - - - - - - - - - - - REP (Ethereum) - - - Augur - - - - - - - - - - - - - - - - - - - - REPV2 (Ethereum) - - - Augur v2 - - - - - - - - - - - - - - - - - - - - HERC (Ethereum) - - - Hercules - - - - - - - - - - - - - - - - - - - - DAI (Ethereum) - - - Dai Stablecoin - - - - - - - { + console.warn('Token migration failed:', error) + }) + const referralPromise = dispatch(loadAccountReferral(account)) // Navigate immediately - all settings are now in Redux @@ -345,6 +353,7 @@ export function logoutRequest( resetLocalAccountSettingsCache() dispatch({ type: 'LOGOUT' }) + clearTokenCache() if (typeof account.logout === 'function') await account.logout() const rootNavigation = getRootNavigation(navigation) rootNavigation.replace('login', { diff --git a/src/actions/TokenMigrationActions.tsx b/src/actions/TokenMigrationActions.tsx new file mode 100644 index 00000000000..bab637047a1 --- /dev/null +++ b/src/actions/TokenMigrationActions.tsx @@ -0,0 +1,96 @@ +import type { EdgeAccount, EdgeCurrencyWallet, EdgeToken } from 'edge-core-js' + +import { fetchToken, serverTokenToEdgeToken } from '../util/tokenService' + +/** + * Migrates enabled tokens from builtin tokens to custom tokens by fetching + * them from the rates server. This runs after account login to ensure users + * upgrading to the new codebase have their enabled tokens available as custom tokens. + * + * @param account - The logged-in account + * @returns Promise that resolves when migration is complete + */ +export async function migrateEnabledTokensFromServer( + account: EdgeAccount +): Promise { + const { currencyWallets } = account + const tokensToMigrate = new Map>() // pluginId -> Set + + // Collect all enabled tokenIds grouped by pluginId from all wallets + for (const walletId in currencyWallets) { + const wallet: EdgeCurrencyWallet = currencyWallets[walletId] + const { currencyInfo, currencyConfig, enabledTokenIds } = wallet + const { pluginId } = currencyInfo + const { customTokens } = currencyConfig + + // Get or create the set for this pluginId + let tokenSet = tokensToMigrate.get(pluginId) + if (tokenSet == null) { + tokenSet = new Set() + tokensToMigrate.set(pluginId, tokenSet) + } + + // Add enabled tokenIds that aren't already in customTokens + for (const tokenId of enabledTokenIds) { + if (customTokens[tokenId] == null) { + tokenSet.add(tokenId) + } + } + } + + // If no migration needed, return early + if (tokensToMigrate.size === 0) { + return + } + + // Fetch missing tokens from the server and add them to customTokens + const currencyConfigs = account.currencyConfig + let migratedCount = 0 + + for (const [pluginId, tokenIds] of tokensToMigrate) { + const currencyConfig = currencyConfigs[pluginId] + if (currencyConfig == null) continue + + const results = await Promise.allSettled( + Array.from(tokenIds).map( + async tokenId => await fetchToken({ tokenId, pluginId }) + ) + ) + + const tokensToAdd: EdgeToken[] = [] + const tokenIdArray = Array.from(tokenIds) + for (let i = 0; i < results.length; i++) { + const result = results[i] + const tokenId = tokenIdArray[i] + if (result.status === 'rejected') { + console.warn( + `Token migration: Failed to migrate ${pluginId}:${tokenId}:`, + result.reason + ) + continue + } + if (result.value == null) { + console.warn( + `Token migration: Could not fetch token ${pluginId}:${tokenId} from server` + ) + continue + } + const edgeToken = serverTokenToEdgeToken(result.value) + tokensToAdd.push(edgeToken) + console.log( + `Token migration: Migrated ${pluginId}:${tokenId} (${edgeToken.currencyCode})` + ) + } + + if (tokensToAdd.length > 0) { + await currencyConfig.addCustomTokens(tokensToAdd) + } + migratedCount += tokensToAdd.length + } + + if (migratedCount > 0) { + console.log( + `Token migration: Successfully migrated ${migratedCount} tokens` + ) + } +} diff --git a/src/components/modals/WalletListMenuModal.tsx b/src/components/modals/WalletListMenuModal.tsx index e755b5b6e74..6c0698f42cd 100644 --- a/src/components/modals/WalletListMenuModal.tsx +++ b/src/components/modals/WalletListMenuModal.tsx @@ -142,7 +142,7 @@ export const WALLET_LIST_MENU: Array<{ } ] -export function WalletListMenuModal(props: Props) { +export const WalletListMenuModal: React.FC = props => { const { bridge, tokenId, navigation, walletId } = props const [options, setOptions] = React.useState([]) @@ -161,7 +161,7 @@ export function WalletListMenuModal(props: Props) { const theme = useTheme() const styles = getStyles(theme) - const handleCancel = () => { + const handleCancel = (): void => { props.bridge.resolve() } @@ -274,8 +274,10 @@ export function WalletListMenuModal(props: Props) { // Special case for `manageTokens`. Only allow pluginsIds that have metatokens if (value === 'manageTokens') { if ( - Object.keys(account.currencyConfig[pluginId].builtinTokens) - .length === 0 + ( + account.currencyConfig[pluginId].currencyInfo + .customTokenTemplate ?? [] + ).length === 0 ) continue } diff --git a/src/components/scenes/CreateWalletSelectCryptoScene.tsx b/src/components/scenes/CreateWalletSelectCryptoScene.tsx index 4be70f15b71..748a9be047d 100644 --- a/src/components/scenes/CreateWalletSelectCryptoScene.tsx +++ b/src/components/scenes/CreateWalletSelectCryptoScene.tsx @@ -308,12 +308,14 @@ const CreateWalletSelectCryptoComponent: React.FC = (props: Props) => { const handleAddCustomTokenPress = useHandler(async () => { const allowedCreateAssets = createList - .filter( - createItem => - createItem.tokenId === null && - Object.keys(account.currencyConfig[createItem.pluginId].builtinTokens) - .length > 0 - ) + .filter(createItem => { + if (createItem.tokenId !== null) return false + const config = account.currencyConfig[createItem.pluginId] + return ( + config?.currencyInfo?.customTokenTemplate?.length != null && + config.currencyInfo.customTokenTemplate.length > 0 + ) + }) .map(filteredCreateItem => ({ pluginId: filteredCreateItem.pluginId, tokenId: null diff --git a/src/components/scenes/EditTokenScene.tsx b/src/components/scenes/EditTokenScene.tsx index 80f59be8984..f7380cd125c 100644 --- a/src/components/scenes/EditTokenScene.tsx +++ b/src/components/scenes/EditTokenScene.tsx @@ -15,6 +15,7 @@ import { useSelector } from '../../types/reactRedux' import type { EdgeAppSceneProps } from '../../types/routerTypes' import { getWalletName } from '../../util/CurrencyWalletHelpers' import { logActivity } from '../../util/logger' +import { searchTokens, serverTokenToEdgeToken } from '../../util/tokenService' import { ButtonsView } from '../buttons/ButtonsView' import { SceneWrapper } from '../common/SceneWrapper' import { withWallet } from '../hoc/withWallet' @@ -40,7 +41,7 @@ interface Props extends EdgeAppSceneProps<'editToken'> { wallet: EdgeCurrencyWallet } -function EditTokenSceneComponent(props: Props) { +const EditTokenSceneComponent: React.FC = props => { const { navigation, route, wallet } = props const { tokenId } = route.params @@ -61,7 +62,7 @@ function EditTokenSceneComponent(props: Props) { return (multiplier.length - 1).toString() }) - const emptyNetworkLocation = () => { + const emptyNetworkLocation = (): Map => { const out = new Map() for (const item of customTokenTemplate) { const value = route.params.networkLocation?.[item.key] @@ -90,7 +91,7 @@ function EditTokenSceneComponent(props: Props) { if (tokenId == null) return await Airship.show<'ok' | 'cancel' | undefined>(bridge => ( builtinTokenId === newTokenId ) if (matchingBuiltinTokenId != null) { await showMessage( sprintf( lstrings.warning_token_exists_1s, - builtinTokens[matchingBuiltinTokenId].currencyCode + customTokens[matchingBuiltinTokenId].currencyCode ) ) return @@ -220,7 +222,7 @@ function EditTokenSceneComponent(props: Props) { } }) - const autoCompleteToken = async (searchString: string) => { + const autoCompleteToken = async (searchString: string): Promise => { if ( // Ignore autocomplete if it's already loading isAutoCompleteTokenLoading.current || @@ -234,12 +236,17 @@ function EditTokenSceneComponent(props: Props) { } isAutoCompleteTokenLoading.current = true - const [token] = await wallet.currencyConfig - .getTokenDetails({ contractAddress: searchString }) - .catch(() => []) + + const pluginId = wallet.currencyInfo.pluginId + const results = await searchTokens({ + searchTerm: searchString, + pluginIds: [pluginId] + }).catch(() => []) isAutoCompleteTokenLoading.current = false - if (token != null) { + const serverResult = results[0] + if (serverResult != null) { + const token = serverTokenToEdgeToken(serverResult) setCurrencyCode(token.currencyCode) setDisplayName(token.displayName) setDecimalPlaces( @@ -255,18 +262,18 @@ function EditTokenSceneComponent(props: Props) { return out }) setDidAutoCompleteToken(true) - } else if (token == null && didAutoCompleteToken) { + } else if (serverResult == null && didAutoCompleteToken) { setCurrencyCode('') setDisplayName('') setDecimalPlaces('18') - setLocation(location => { + setLocation(() => { return emptyNetworkLocation() }) setDidAutoCompleteToken(false) } } - const renderCustomTokenTemplateRows = () => { + const renderCustomTokenTemplateRows = (): React.ReactNode[] => { return customTokenTemplate .sort((a, b) => (a.key === 'contractAddress' ? -1 : 1)) .map(item => { diff --git a/src/components/scenes/Loans/LoanCreateScene.tsx b/src/components/scenes/Loans/LoanCreateScene.tsx index 44398d20eee..9dbe5f14ae7 100644 --- a/src/components/scenes/Loans/LoanCreateScene.tsx +++ b/src/components/scenes/Loans/LoanCreateScene.tsx @@ -37,7 +37,7 @@ import type { import { getWalletPickerExcludeWalletIds } from '../../../util/borrowUtils' import { getBorrowPluginIconUri } from '../../../util/CdnUris' import { getCurrencyCode } from '../../../util/CurrencyInfoHelpers' -import { enableTokens } from '../../../util/CurrencyWalletHelpers' +import { enableTokensWithSpinner } from '../../../util/CurrencyWalletHelpers' import { DECIMAL_PRECISION, removeIsoPrefix, @@ -71,7 +71,7 @@ export interface LoanCreateParams { interface Props extends EdgeAppSceneProps<'loanCreate'> {} -export const LoanCreateScene = (props: Props) => { +export const LoanCreateScene: React.FC = (props: Props) => { const { navigation, route } = props const { borrowEngine, borrowPlugin } = route.params @@ -84,11 +84,11 @@ export const LoanCreateScene = (props: Props) => { // Force enable tokens required for loan useAsyncEffect( async () => { - await enableTokens( + await enableTokensWithSpinner( [LOAN_TOKEN_IDS[borrowEngineWallet.currencyInfo.pluginId].WBTC], borrowEngineWallet ) - await enableTokens( + await enableTokensWithSpinner( [LOAN_TOKEN_IDS[borrowEngineWallet.currencyInfo.pluginId].USDC], borrowEngineWallet ) @@ -456,8 +456,8 @@ export const LoanCreateScene = (props: Props) => { } } }) - .catch(e => { - showError(e.message) + .catch((error: unknown) => { + showError(error) }) } diff --git a/src/components/scenes/ManageTokensScene.tsx b/src/components/scenes/ManageTokensScene.tsx index 02512622e7e..3a91507e202 100644 --- a/src/components/scenes/ManageTokensScene.tsx +++ b/src/components/scenes/ManageTokensScene.tsx @@ -1,17 +1,25 @@ -import type { EdgeCurrencyWallet } from 'edge-core-js' +import type { EdgeCurrencyWallet, EdgeToken } from 'edge-core-js' import * as React from 'react' -import { SectionList, View } from 'react-native' +import { ActivityIndicator, SectionList, View } from 'react-native' import { FlatList } from 'react-native-gesture-handler' import { useHandler } from '../../hooks/useHandler' import { useRowLayout } from '../../hooks/useRowLayout' +import { + useServerTokens, + useServerTokenSearch +} from '../../hooks/useServerTokens' import { useWalletName } from '../../hooks/useWalletName' import { useWatch } from '../../hooks/useWatch' import { lstrings } from '../../locales/strings' import type { EdgeAppSceneProps } from '../../types/routerTypes' import type { FlatListItem } from '../../types/types' -import { getWalletName } from '../../util/CurrencyWalletHelpers' +import { + changeEnabledTokenIds, + getWalletName +} from '../../util/CurrencyWalletHelpers' import { logActivity } from '../../util/logger' +import { serverTokenToEdgeToken } from '../../util/tokenService' import { normalizeForSearch } from '../../util/utils' import { ButtonsView } from '../buttons/ButtonsView' import { SceneWrapper } from '../common/SceneWrapper' @@ -58,11 +66,29 @@ const ManageTokensSceneComponent: React.FC = props => { const [searchValue, setSearchValue] = React.useState('') + const pluginId = wallet.currencyInfo.pluginId + + // Memoize pluginIds array to prevent infinite loops + const pluginIdsArray = React.useMemo(() => [pluginId], [pluginId]) + // Subscribe to the account's token lists: const { currencyConfig } = wallet - const allTokens = useWatch(currencyConfig, 'allTokens') const customTokens = useWatch(currencyConfig, 'customTokens') + // Fetch server tokens for this chain + const { + tokens: serverTokens, + loading: serverLoading, + loadMore: loadMoreServerTokens, + hasMore: hasMoreServerTokens + } = useServerTokens({ pluginIds: pluginIdsArray }) + + // Search server tokens when user types + const { tokens: searchResults } = useServerTokenSearch({ + searchTerm: searchValue, + pluginIds: pluginIdsArray + }) + // Subscribe to the wallet's enabled tokens: const enabledTokenIds = useWatch(wallet, 'enabledTokenIds') @@ -71,6 +97,23 @@ const ManageTokensSceneComponent: React.FC = props => { () => new Set(enabledTokenIds) ) + // Merge token sources: customTokens + serverTokens + searchResults (avoiding duplicates) + const mergedTokens = React.useMemo(() => { + const tokens: Record = { ...customTokens } + + for (const serverToken of [...serverTokens, ...searchResults]) { + if (tokens[serverToken.tokenId] == null) { + try { + tokens[serverToken.tokenId] = serverTokenToEdgeToken(serverToken) + } catch (error) { + // Silently skip invalid tokens + } + } + } + + return tokens + }, [customTokens, serverTokens, searchResults]) + // Baseline for change detection (updated for external additions): const [baselineSet, setBaselineSet] = React.useState( () => new Set(enabledTokenIds) @@ -126,11 +169,11 @@ const ManageTokensSceneComponent: React.FC = props => { } }, [enabledTokenIds, baselineSet]) - // Sort the token list (only re-sort when allTokens changes, not on toggle): + // Sort the token list (only re-sort when mergedTokens changes, not on toggle): const sortedTokenIds = React.useMemo(() => { - return Object.keys(allTokens).sort((id1, id2) => { - const token1 = allTokens[id1] - const token2 = allTokens[id2] + return Object.keys(mergedTokens).sort((id1, id2) => { + const token1 = mergedTokens[id1] + const token2 = mergedTokens[id2] // Use sorting baseline for stable ordering during session const isToken1Enabled = sortingBaselineSet.has(id1) @@ -145,18 +188,28 @@ const ManageTokensSceneComponent: React.FC = props => { if (token1.currencyCode > token2.currencyCode) return 1 return 0 }) - }, [allTokens, sortingBaselineSet]) + }, [mergedTokens, sortingBaselineSet]) // Filter the list of tokens based on the search term: const filteredTokenIds = React.useMemo(() => { + if (searchValue.length === 0) { + return sortedTokenIds + } + + // Build set of matching tokenIds from search results + const searchResultIds = new Set(searchResults.map(t => t.tokenId)) + + // Filter to tokens that match search (either in merged or search results) const target = normalizeForSearch(searchValue) - return sortedTokenIds.filter(tokenId => { - const token = allTokens[tokenId] + const filtered = sortedTokenIds.filter(tokenId => { + if (searchResultIds.has(tokenId)) return true + const token = mergedTokens[tokenId] const currencyCode = normalizeForSearch(token.currencyCode) const displayName = normalizeForSearch(token.displayName) return currencyCode.includes(target) || displayName.includes(target) }) - }, [allTokens, searchValue, sortedTokenIds]) + return filtered + }, [mergedTokens, searchValue, sortedTokenIds, searchResults]) // Split the list of tokens based on if there were auto-detected tokens given const autoDetectedTokenIds = React.useMemo( @@ -170,8 +223,8 @@ const ManageTokensSceneComponent: React.FC = props => { ) const extraData = React.useMemo( - () => ({ allTokens, pendingEnabledTokenIds, customTokens }), - [allTokens, pendingEnabledTokenIds, customTokens] + () => ({ mergedTokens, pendingEnabledTokenIds, customTokens }), + [mergedTokens, pendingEnabledTokenIds, customTokens] ) const sectionList = React.useMemo(() => { @@ -197,6 +250,13 @@ const ManageTokensSceneComponent: React.FC = props => { const handleItemLayout = useRowLayout() + // Load more server tokens when reaching the end of the list + const handleEndReached = useHandler(() => { + if (!serverLoading && hasMoreServerTokens) { + loadMoreServerTokens() + } + }) + // Goes to the add token scene: const handleAdd = useHandler(() => { navigation.navigate('editToken', { @@ -246,7 +306,7 @@ const ManageTokensSceneComponent: React.FC = props => { ) } - await wallet.changeEnabledTokenIds([...pendingEnabledTokenIds]) + await changeEnabledTokenIds(Array.from(pendingEnabledTokenIds), wallet) }) // Save and navigate back: @@ -313,9 +373,9 @@ const ManageTokensSceneComponent: React.FC = props => { navigation={navigation} wallet={wallet} // Token stuff: - isCustom={customTokens[tokenId] != null} + isCustom={customTokens[tokenId]?.isUserCreated === true} isEnabled={pendingEnabledTokenIds.has(tokenId)} - token={allTokens[tokenId]} + token={mergedTokens[tokenId]} tokenId={tokenId} // Callbacks: onToggle={handleToggle} @@ -360,6 +420,11 @@ const ManageTokensSceneComponent: React.FC = props => { keyExtractor={keyExtractor} renderItem={renderRow} style={styles.list} + onEndReached={handleEndReached} + onEndReachedThreshold={0.5} + ListFooterComponent={ + serverLoading ? : null + } /> ) : ( = props => { renderSectionHeader={renderSectionHeader} sections={sectionList} style={styles.sectionList} + onEndReached={handleEndReached} + onEndReachedThreshold={0.5} + ListFooterComponent={ + serverLoading ? : null + } /> )} <> @@ -414,6 +484,9 @@ const getStyles = cacheStyles((theme: Theme) => ({ sectionList: { marginTop: theme.rem(1), marginHorizontal: theme.rem(0.5) + }, + loader: { + marginVertical: theme.rem(1) } })) diff --git a/src/components/scenes/SwapConfirmationScene.tsx b/src/components/scenes/SwapConfirmationScene.tsx index 04eed5b9982..9580e85d40b 100644 --- a/src/components/scenes/SwapConfirmationScene.tsx +++ b/src/components/scenes/SwapConfirmationScene.tsx @@ -318,11 +318,7 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { tokenId: fromTokenId, currencyConfig: fromWallet.currencyConfig }), - isBuiltInAsset: - (toTokenId == null || - toWallet.currencyConfig.builtinTokens[toTokenId] != null) && - (fromTokenId == null || - fromWallet.currencyConfig.builtinTokens[fromTokenId] != null), + isBuiltInAsset: false, orderId: result.orderId, swapProviderId: pluginId } diff --git a/src/components/scenes/WalletListScene.tsx b/src/components/scenes/WalletListScene.tsx index 1ba9fb175b5..cee9e4c0545 100644 --- a/src/components/scenes/WalletListScene.tsx +++ b/src/components/scenes/WalletListScene.tsx @@ -110,7 +110,10 @@ export const WalletListScene: React.FC = props => { const tokenSupportingWalletIds = React.useMemo(() => { const walletIds: string[] = [] for (const wallet of Object.values(currencyWallets)) { - if (Object.keys(wallet.currencyConfig.builtinTokens).length > 0) { + if ( + (wallet.currencyConfig.currencyInfo.customTokenTemplate ?? []).length > + 0 + ) { walletIds.push(wallet.id) } } diff --git a/src/components/themed/WalletList.tsx b/src/components/themed/WalletList.tsx index 37185d120ee..1eff6c57379 100644 --- a/src/components/themed/WalletList.tsx +++ b/src/components/themed/WalletList.tsx @@ -1,20 +1,26 @@ import type { EdgeTokenId } from 'edge-core-js' import * as React from 'react' -import type { ViewStyle } from 'react-native' +import { ActivityIndicator, type ViewStyle } from 'react-native' import { FlatList } from 'react-native-gesture-handler' import { selectWalletToken } from '../../actions/WalletActions' import { useHandler } from '../../hooks/useHandler' +import { + useServerTokens, + useServerTokenSearch +} from '../../hooks/useServerTokens' import { lstrings } from '../../locales/strings' import { filterWalletCreateItemListBySearchText, getCreateWalletList, + type TokenWalletCreateItem, type WalletCreateItem } from '../../selectors/getCreateWalletList' import { useDispatch, useSelector } from '../../types/reactRedux' import type { NavigationBase } from '../../types/routerTypes' import type { EdgeAsset, FlatListItem, WalletListItem } from '../../types/types' import { checkAssetFilter } from '../../util/CurrencyInfoHelpers' +import { serverTokenToEdgeToken } from '../../util/tokenService' import { searchWalletList } from '../services/SortedWalletList' import { useTheme } from '../services/ThemeContext' import { ModalFooter } from './ModalParts' @@ -78,6 +84,34 @@ export const WalletList: React.FC = (props: Props) => { ) const sortedWalletList = useSelector(state => state.sortedWalletList) + // Get all unique pluginIds from filtered wallets for server token fetching + const pluginIds = React.useMemo(() => { + const pluginIdSet = new Set() + for (const item of sortedWalletList) { + if (item.type === 'asset') { + pluginIdSet.add(item.wallet.currencyInfo.pluginId) + } + } + return Array.from(pluginIdSet) + }, [sortedWalletList]) + + // Fetch server tokens for all relevant chains + const { + tokens: serverTokens, + loading: serverLoading, + loadMore: loadMoreServerTokens, + hasMore: hasMoreServerTokens + } = useServerTokens({ + pluginIds: pluginIds.length > 0 ? pluginIds : undefined, + enabled: showCreateWallet + }) + + // Search server tokens when user types + const { tokens: searchResults } = useServerTokenSearch({ + searchTerm: searchText, + pluginIds: pluginIds.length > 0 ? pluginIds : undefined + }) + const handlePress = useHandler( async (walletId: string, tokenId: EdgeTokenId) => { if (onPress != null) { @@ -173,19 +207,88 @@ export const WalletList: React.FC = (props: Props) => { return out }, [filteredWalletList, mostRecentWallets]) + // Convert server tokens to WalletCreateItem format + const serverTokenCreateItems = React.useMemo(() => { + if (!showCreateWallet) return [] + + const existingTokenKeys = new Set() + // Track existing tokens from custom tokens and filtered wallet list + for (const pluginId of Object.keys(account.currencyConfig)) { + const { customTokens } = account.currencyConfig[pluginId] + for (const tokenId of Object.keys(customTokens)) { + existingTokenKeys.add(`${pluginId}-${tokenId}`) + } + } + for (const item of filteredWalletList) { + if (item.type === 'asset' && item.tokenId != null) { + existingTokenKeys.add( + `${item.wallet.currencyInfo.pluginId}-${item.tokenId}` + ) + } + } + + // When searching, use search results; otherwise use paginated server tokens + const tokensToUse = + searchText.length > 0 && searchResults.length > 0 + ? searchResults + : serverTokens + + // Track processed tokens to avoid duplicates + const processedTokenKeys = new Set() + const items: TokenWalletCreateItem[] = [] + + for (const serverToken of tokensToUse) { + const key = `${serverToken.chainPluginId}-${serverToken.tokenId}` + // Skip if already exists as custom token, enabled token, or already processed + if (existingTokenKeys.has(key) || processedTokenKeys.has(key)) continue + processedTokenKeys.add(key) + + // Find wallets that could add this token + const createWalletIds = Object.keys(account.currencyWallets).filter( + walletId => + account.currencyWallets[walletId].currencyInfo.pluginId === + serverToken.chainPluginId + ) + + const edgeToken = serverTokenToEdgeToken(serverToken) + items.push({ + type: 'create', + key: `create-server-${serverToken.chainPluginId}-${serverToken.tokenId}`, + currencyCode: serverToken.currencyCode, + displayName: serverToken.displayName, + pluginId: serverToken.chainPluginId, + tokenId: serverToken.tokenId, + createWalletIds, + networkLocation: edgeToken.networkLocation + }) + } + + return items + }, [ + account, + filteredWalletList, + searchResults, + searchText, + serverTokens, + showCreateWallet + ]) + // Assemble create-wallet rows: const createWalletList: WalletCreateItem[] = React.useMemo(() => { if (!showCreateWallet) return [] - return filterWalletCreateItemListBySearchText( - getCreateWalletList(account, { - allowedAssets, - excludeAssets, - filteredWalletList, - filterActivation - }), - searchText - ) + const baseList = getCreateWalletList(account, { + allowedAssets, + excludeAssets, + filteredWalletList, + filterActivation + }) + + // Merge with server tokens + const mergedList = [...baseList, ...serverTokenCreateItems] + + // Apply search filter + return filterWalletCreateItemListBySearchText(mergedList, searchText) }, [ account, allowedAssets, @@ -193,6 +296,7 @@ export const WalletList: React.FC = (props: Props) => { filterActivation, filteredWalletList, searchText, + serverTokenCreateItems, showCreateWallet ]) @@ -277,6 +381,13 @@ export const WalletList: React.FC = (props: Props) => { [createWalletId, handlePress] ) + // Load more server tokens when reaching the end of the list + const handleEndReached = useHandler(() => { + if (showCreateWallet && !serverLoading && hasMoreServerTokens) { + loadMoreServerTokens() + } + }) + const scrollPadding = React.useMemo(() => { return { paddingBottom: theme.rem(ModalFooter.bottomRem) } }, [theme]) @@ -289,6 +400,13 @@ export const WalletList: React.FC = (props: Props) => { keyboardShouldPersistTaps="handled" keyExtractor={keyExtractor} renderItem={renderRow} + onEndReached={handleEndReached} + onEndReachedThreshold={0.5} + ListFooterComponent={ + showCreateWallet && serverLoading ? ( + + ) : null + } /> ) } diff --git a/src/hooks/useServerTokens.ts b/src/hooks/useServerTokens.ts new file mode 100644 index 00000000000..6c45725a3e4 --- /dev/null +++ b/src/hooks/useServerTokens.ts @@ -0,0 +1,256 @@ +import * as React from 'react' + +import { + type EdgeTokenInfo, + listTokens, + type ListTokensParams, + searchTokens, + type SearchTokensParams +} from '../util/tokenService' +import { useHandler } from './useHandler' + +// --------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------- + +export interface UseServerTokensResult { + /** The fetched tokens */ + tokens: EdgeTokenInfo[] + /** True while fetching data */ + loading: boolean + /** Error message if fetch failed */ + error: string | undefined + /** Load more tokens (for pagination) */ + loadMore: () => void + /** True if there are more tokens to load */ + hasMore: boolean + /** Refresh the token list */ + refresh: () => void +} + +export interface UseServerTokensParams { + /** Filter by specific plugin IDs */ + pluginIds?: string[] + /** Page size for pagination */ + pageSize?: number + /** Enable the hook (defaults to true) */ + enabled?: boolean +} + +export interface UseServerTokenSearchResult { + /** The search results */ + tokens: EdgeTokenInfo[] + /** True while searching */ + loading: boolean + /** Error message if search failed */ + error: string | undefined +} + +export interface UseServerTokenSearchParams { + /** The search term */ + searchTerm: string + /** Filter by specific plugin IDs */ + pluginIds?: string[] + /** Debounce delay in ms (default 300) */ + debounceMs?: number +} + +// --------------------------------------------------------------------- +// useServerTokens - List tokens with pagination +// --------------------------------------------------------------------- + +/** + * Hook to fetch server tokens with pagination support. + * Tokens are fetched from the rates server /v1/listTokens endpoint. + */ +export function useServerTokens( + params: UseServerTokensParams = {} +): UseServerTokensResult { + const { pluginIds, pageSize = 100, enabled = true } = params + + const [tokens, setTokens] = React.useState([]) + const [loading, setLoading] = React.useState(false) + const [error, setError] = React.useState() + const [page, setPage] = React.useState(0) + const [hasMore, setHasMore] = React.useState(true) + + // Track if we've done the initial fetch + const initialFetchDone = React.useRef(false) + // Track if a fetch is currently in progress (ref for synchronous check) + const fetchInProgress = React.useRef(false) + + // Create a stable key for the query params to detect changes + const queryKey = React.useMemo(() => { + const pluginIdStr = pluginIds != null ? pluginIds.sort().join(',') : 'all' + return `${pluginIdStr}:${pageSize}` + }, [pluginIds, pageSize]) + + // Reset state when query params change + React.useEffect(() => { + setTokens([]) + setPage(0) + setHasMore(true) + initialFetchDone.current = false + fetchInProgress.current = false + }, [queryKey]) + + // Fetch tokens + const fetchTokens = useHandler( + async (pageToFetch: number, append: boolean) => { + if (!enabled || fetchInProgress.current) { + return + } + + fetchInProgress.current = true + setLoading(true) + setError(undefined) + + try { + const fetchParams: ListTokensParams = { + page: pageToFetch, + pageSize, + pluginIds + } + + const result = await listTokens(fetchParams) + + if (append) { + setTokens(prev => [...prev, ...result]) + } else { + setTokens(result) + } + + // If we got fewer results than pageSize, there are no more + setHasMore(result.length >= pageSize) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + fetchInProgress.current = false + setLoading(false) + } + } + ) + + // Initial fetch + React.useEffect(() => { + if (!enabled || initialFetchDone.current) return + initialFetchDone.current = true + fetchTokens(0, false).catch(() => {}) + }, [enabled, fetchTokens, queryKey]) + + // Load more handler + const loadMore = useHandler(() => { + if (fetchInProgress.current || !hasMore) return + const nextPage = page + 1 + setPage(nextPage) + fetchTokens(nextPage, true).catch(() => {}) + }) + + // Refresh handler + const refresh = useHandler(() => { + setTokens([]) + setPage(0) + setHasMore(true) + fetchTokens(0, false).catch(() => {}) + }) + + return { + tokens, + loading, + error, + loadMore, + hasMore, + refresh + } +} + +// --------------------------------------------------------------------- +// useServerTokenSearch - Search tokens with debounce +// --------------------------------------------------------------------- + +/** + * Hook to search server tokens with debouncing. + * Uses the rates server /v1/findTokens endpoint. + */ +export function useServerTokenSearch( + params: UseServerTokenSearchParams +): UseServerTokenSearchResult { + const { searchTerm, pluginIds, debounceMs = 300 } = params + + const [tokens, setTokens] = React.useState([]) + const [loading, setLoading] = React.useState(false) + const [error, setError] = React.useState() + + // Stable key for pluginIds to prevent effect re-fires on reference changes + const pluginIdsKey = React.useMemo( + () => (pluginIds != null ? [...pluginIds].sort().join(',') : ''), + [pluginIds] + ) + + // Keep a ref to pluginIds so the effect can read the current value + const pluginIdsRef = React.useRef(pluginIds) + pluginIdsRef.current = pluginIds + + // Debounced search term + const [debouncedTerm, setDebouncedTerm] = React.useState(searchTerm) + + // Debounce the search term + React.useEffect(() => { + const timer = setTimeout(() => { + setDebouncedTerm(searchTerm) + }, debounceMs) + + return () => { + clearTimeout(timer) + } + }, [searchTerm, debounceMs]) + + // Perform search when debounced term changes + React.useEffect(() => { + if (debouncedTerm.length === 0) { + setTokens([]) + setLoading(false) + return + } + + let cancelled = false + + const doSearch = async (): Promise => { + setLoading(true) + setError(undefined) + + try { + const searchParams: SearchTokensParams = { + searchTerm: debouncedTerm, + pluginIds: pluginIdsRef.current + } + + const result = await searchTokens(searchParams) + + if (!cancelled) { + setTokens(result) + } + } catch (e) { + if (!cancelled) { + setError(e instanceof Error ? e.message : String(e)) + } + } finally { + if (!cancelled) { + setLoading(false) + } + } + } + + doSearch().catch(() => {}) + + return () => { + cancelled = true + } + }, [debouncedTerm, pluginIdsKey]) + + return { + tokens, + loading, + error + } +} diff --git a/src/selectors/getCreateWalletList.ts b/src/selectors/getCreateWalletList.ts index 59a60a967ec..810a66fa015 100644 --- a/src/selectors/getCreateWalletList.ts +++ b/src/selectors/getCreateWalletList.ts @@ -106,15 +106,15 @@ export const getCreateWalletList = ( const excludedAssetsMap = createAssetMap(excludeAssets) const allowedAssetsMap = createAssetMap(allowedAssets) - const isAllowed = (pluginId: string, tokenId: EdgeTokenId) => + const isAllowed = (pluginId: string, tokenId: EdgeTokenId): boolean => // if the wallet already exists, then it is not allowed - !existingWalletsMap.get(pluginId)?.has(tokenId) && + !(existingWalletsMap.get(pluginId)?.has(tokenId) ?? false) && // if allowedAssets is empty, then all assets are allowed (allowedAssetsMap.size === 0 || - allowedAssetsMap.get(pluginId)?.has(tokenId)) && + (allowedAssetsMap.get(pluginId)?.has(tokenId) ?? false)) && // if excludedAssets is not empty, then the asset must not be in the excluded list (excludedAssetsMap.size === 0 || - !excludedAssetsMap.get(pluginId)?.has(tokenId)) + !(excludedAssetsMap.get(pluginId)?.has(tokenId) ?? false)) // Add top-level wallet types: const newWallets: MainWalletCreateItem[] = [] @@ -125,7 +125,7 @@ export const getCreateWalletList = ( // Prevent plugins that are "watch only" from being allowed to create new wallets if (isKeysOnlyPlugin(pluginId)) continue // Prevent currencies that needs activation from being created from a modal - if (filterActivation && requiresActivation(pluginId)) continue + if (filterActivation === true && requiresActivation(pluginId)) continue const currencyConfig = account.currencyConfig[pluginId] const { assetDisplayName, currencyCode, displayName, walletType } = @@ -169,8 +169,8 @@ export const getCreateWalletList = ( }) } - const { builtinTokens, currencyInfo } = currencyConfig - const tokenIds = Object.keys(builtinTokens) + const { customTokens, currencyInfo } = currencyConfig + const tokenIds = Object.keys(customTokens) if (tokenIds.length === 0) continue // Identify which wallets could add the token @@ -181,7 +181,7 @@ export const getCreateWalletList = ( for (const tokenId of tokenIds) { const { currencyCode, displayName, networkLocation } = - builtinTokens[tokenId] + customTokens[tokenId] // Fix for when the token code and chain code are the same (like EOS/TLOS) if (currencyCode === currencyInfo.currencyCode) continue @@ -287,7 +287,7 @@ export const filterWalletCreateItemListBySearchText = ( return out } -function requiresActivation(pluginId: string) { +function requiresActivation(pluginId: string): boolean { const { isAccountActivationRequired = false } = SPECIAL_CURRENCY_INFO[pluginId] ?? {} return isAccountActivationRequired diff --git a/src/util/ActionProgramUtils.ts b/src/util/ActionProgramUtils.ts index d64fc34e23a..61fbb0d2ae6 100644 --- a/src/util/ActionProgramUtils.ts +++ b/src/util/ActionProgramUtils.ts @@ -20,7 +20,7 @@ import type { import { convertCurrency, getExchangeRate } from '../selectors/WalletSelectors' import { config } from '../theme/appConfig' import { getToken } from './CurrencyInfoHelpers' -import { enableTokens } from './CurrencyWalletHelpers' +import { enableTokensWithSpinner } from './CurrencyWalletHelpers' import { convertNativeToExchange, DECIMAL_PRECISION, @@ -76,7 +76,7 @@ export const makeAaveCreateActionProgram = async ( // Swap and Deposit steps // - await enableTokens([source.tokenId], borrowEngineWallet) + await enableTokensWithSpinner([source.tokenId], borrowEngineWallet) const toTokenId = source.tokenId ?? @@ -174,7 +174,7 @@ export const makeAaveBorrowAction = async ( // If no borrow token specified (withdraw to bank), default to USDC for intermediate borrow step prior to withdrawing to bank const borrowTokenCc = borrowToken == null ? 'USDC' : borrowToken.currencyCode - await enableTokens([destination.tokenId], borrowEngineWallet) + await enableTokensWithSpinner([destination.tokenId], borrowEngineWallet) // TODO: ASSUMPTION: The only borrow destinations are: // 1. USDC @@ -231,7 +231,7 @@ export const makeAaveDepositAction = async ({ // TODO: Handle buy from fiat onramp in a separate method // If no deposit token provided (i.e. buy from exchange provider), default to WBTC - await enableTokens([depositTokenId], borrowEngineWallet) + await enableTokensWithSpinner([depositTokenId], borrowEngineWallet) const allTokens = borrowEngineWallet.currencyConfig.allTokens const tokenId = depositTokenId ?? @@ -313,7 +313,7 @@ export const makeAaveCloseAction = async ({ // We must ensure the token is enabled to get the user's token balance and // calculate exchange rates - await enableTokens([collateralTokenId], wallet) + await enableTokensWithSpinner([collateralTokenId], wallet) if (debt != null) { const debtTokenId = debt.tokenId @@ -325,7 +325,7 @@ export const makeAaveCloseAction = async ({ // We must ensure the token is enabled to get the user's token balance // and calculate exchange rates - await enableTokens([debtTokenId], wallet) + await enableTokensWithSpinner([debtTokenId], wallet) // #region Swap Validation diff --git a/src/util/CurrencyInfoHelpers.ts b/src/util/CurrencyInfoHelpers.ts index 24e8eb13d9b..3e9eac92b0f 100644 --- a/src/util/CurrencyInfoHelpers.ts +++ b/src/util/CurrencyInfoHelpers.ts @@ -284,8 +284,8 @@ export const currencyCodesToEdgeAssets = ( // Add tokens if (child != null) { - const tokenId = Object.keys(currencyConfig.builtinTokens).find( - tokenId => currencyConfig.builtinTokens[tokenId].currencyCode === child + const tokenId = Object.keys(currencyConfig.customTokens).find( + tokenId => currencyConfig.customTokens[tokenId].currencyCode === child ) if (tokenId != null) edgeTokenIds.push({ pluginId, tokenId }) } diff --git a/src/util/CurrencyWalletHelpers.ts b/src/util/CurrencyWalletHelpers.ts index d76adcbaf20..a353d51155d 100644 --- a/src/util/CurrencyWalletHelpers.ts +++ b/src/util/CurrencyWalletHelpers.ts @@ -1,11 +1,12 @@ import { sub } from 'biggystring' -import type { EdgeCurrencyWallet, EdgeTokenId } from 'edge-core-js' +import type { EdgeCurrencyWallet, EdgeToken, EdgeTokenId } from 'edge-core-js' import { sprintf } from 'sprintf-js' import { showFullScreenSpinner } from '../components/modals/AirshipFullScreenSpinner' import { SPECIAL_CURRENCY_INFO } from '../constants/WalletAndCurrencyConstants' import { lstrings } from '../locales/strings' import { getFioStakingBalances } from './stakeUtils' +import { fetchToken, serverTokenToEdgeToken } from './tokenService' import { removeIsoPrefix } from './utils' /** @@ -47,7 +48,10 @@ export const getAvailableBalance = ( const { pluginId } = wallet.currencyInfo let balance = wallet.balanceMap.get(tokenId) ?? '0' - if (SPECIAL_CURRENCY_INFO[pluginId]?.isStakingSupported && tokenId == null) { + if ( + SPECIAL_CURRENCY_INFO[pluginId]?.isStakingSupported === true && + tokenId == null + ) { // Special case for FIO mainnet (no token) const { locked } = getFioStakingBalances(wallet.stakingStatus) balance = sub(balance, locked) @@ -61,10 +65,10 @@ export const getAvailableBalance = ( * get enabled. * - If the tokens are all already enabled, this function call is a noop. */ -export const enableTokens = async ( +export const enableTokensWithSpinner = async ( newTokenIds: EdgeTokenId[], wallet: EdgeCurrencyWallet -) => { +): Promise => { const { enabledTokenIds, currencyConfig } = wallet const { allTokens } = currencyConfig @@ -77,6 +81,45 @@ export const enableTokens = async ( if (tokensToEnable.length > 0) await showFullScreenSpinner( lstrings.wallet_list_modal_enabling_token, - wallet.changeEnabledTokenIds([...enabledTokenIds, ...tokensToEnable]) + changeEnabledTokenIds([...enabledTokenIds, ...tokensToEnable], wallet) ) } + +/** + * Changes the enabled token ids in a wallet. + * - Adds any new tokens to the wallet's custom tokens. + */ +export const changeEnabledTokenIds = async ( + tokenIds: string[], + wallet: EdgeCurrencyWallet +): Promise => { + const knownTokenIds = tokenIds.filter( + tokenId => wallet.currencyConfig.customTokens[tokenId] != null + ) + const unknownTokenIds = tokenIds.filter( + tokenId => wallet.currencyConfig.customTokens[tokenId] == null + ) + + const results = await Promise.all( + unknownTokenIds.map( + async tokenId => + await fetchToken({ + tokenId, + pluginId: wallet.currencyInfo.pluginId + }).catch(() => undefined) + ) + ) + + const tokensToAdd: EdgeToken[] = [] + const tokenIdsToEnable = [...knownTokenIds] + for (let i = 0; i < unknownTokenIds.length; i++) { + const result = results[i] + if (result != null) { + tokensToAdd.push(serverTokenToEdgeToken(result)) + tokenIdsToEnable.push(unknownTokenIds[i]) + } + } + + await wallet.currencyConfig.addCustomTokens(tokensToAdd) + await wallet.changeEnabledTokenIds(tokenIdsToEnable) +} diff --git a/src/util/fake/fakeCurrencyConfig.ts b/src/util/fake/fakeCurrencyConfig.ts index 703b8d70b3c..c0a526b61b2 100644 --- a/src/util/fake/fakeCurrencyConfig.ts +++ b/src/util/fake/fakeCurrencyConfig.ts @@ -24,12 +24,12 @@ export function makeFakeCurrencyConfig( allTokens: tokens, alwaysEnabledTokenIds: [], - builtinTokens: tokens, customTokens: {}, otherMethods: {}, userSettings: {}, addCustomToken: async () => 'token-xyz', + addCustomTokens: async () => [], changeAlwaysEnabledTokenIds: async () => {}, changeCustomToken: async () => {}, changeUserSettings: async () => {}, diff --git a/src/util/network.ts b/src/util/network.ts index 9feee0fb785..10f84920454 100644 --- a/src/util/network.ts +++ b/src/util/network.ts @@ -13,7 +13,10 @@ import { runOnce } from './runOnce' import { asyncWaterfall, getOsVersion, shuffleArray } from './utils' import { checkAppVersion } from './versionCheck' const INFO_SERVERS = ['https://info1.edge.app', 'https://info2.edge.app'] -const RATES_SERVERS = ['https://rates3.edge.app', 'https://rates4.edge.app'] +export const RATES_SERVERS = [ + 'https://rates3.edge.app', + 'https://rates4.edge.app' +] const RATES_SERVER_V2 = ['https://rates1.edge.app', 'https://rates2.edge.app'] const INFO_FETCH_INTERVAL = 5 * 60 * 1000 // 5 minutes diff --git a/src/util/stakeUtils.ts b/src/util/stakeUtils.ts index bfad81934d2..7ce5c81abb1 100644 --- a/src/util/stakeUtils.ts +++ b/src/util/stakeUtils.ts @@ -21,7 +21,7 @@ import type { import type { StakePositionMap } from '../reducers/StakingReducer' import type { EdgeAsset } from '../types/types' import { getCurrencyIconUris } from './CdnUris' -import { enableTokens } from './CurrencyWalletHelpers' +import { enableTokensWithSpinner } from './CurrencyWalletHelpers' import { getUkCompliantString } from './ukComplianceUtils' /** @@ -30,13 +30,19 @@ import { getUkCompliantString } from './ukComplianceUtils' const getAssetDisplayName = ( stakePolicy: StakePolicy, assetType: 'stakeAssets' | 'rewardAssets' -) => +): string[] => stakePolicy[assetType].map(asset => asset.displayName ?? asset.currencyCode) /** * Returns staked and earned allocations in a shape that makes sense for the GUI */ -export const getPositionAllocations = (stakePosition: StakePosition) => { +export const getPositionAllocations = ( + stakePosition: StakePosition +): { + staked: PositionAllocation[] + earned: PositionAllocation[] + unstaked: PositionAllocation[] +} => { return { staked: stakePosition.allocations.filter( positionAllocation => positionAllocation.allocationType === 'staked' @@ -71,7 +77,7 @@ export const getPolicyAssetName = ( export const getPolicyTitleName = ( stakePolicy: StakePolicy, countryCode?: string -) => { +): string => { const stakeCurrencyCodes = getAssetDisplayName(stakePolicy, 'stakeAssets') const rewardCurrencyCodes = getAssetDisplayName(stakePolicy, 'rewardAssets') @@ -106,7 +112,7 @@ export const getPolicyTitleName = ( */ export const getAllocationLocktimeMessage = ( allocation: PositionAllocation -) => { +): string => { return allocation.locktime != null && new Date(allocation.locktime) > new Date() ? ` (${sprintf( @@ -123,7 +129,9 @@ export const getPolicyIconUris = ( currencyConfigs: EdgeAccount['currencyConfig'], stakePolicy: StakePolicy ): { stakeAssetUris: string[]; rewardAssetUris: string[] } => { - const assetInfosToEdgeTokens = (assetInfos: StakeAssetInfo[]) => { + const assetInfosToEdgeTokens = ( + assetInfos: StakeAssetInfo[] + ): EdgeAsset[] => { const edgeAssets: EdgeAsset[] = [] for (const stakeAsset of assetInfos) { @@ -162,10 +170,11 @@ export const getPluginFromPolicyId = ( stakePolicyId: string, filter?: StakePolicyFilter ): StakePlugin | undefined => { - return stakePlugins.find(plugin => - plugin - .getPolicies(filter) - .find(policy => policy.stakePolicyId === stakePolicyId) + return stakePlugins.find( + plugin => + plugin + .getPolicies(filter) + .find(policy => policy.stakePolicyId === stakePolicyId) != null ) } @@ -211,7 +220,7 @@ export const getFioStakingBalances = ( export const enableStakeTokens = async ( wallet: EdgeCurrencyWallet, stakePolicy: StakePolicy -) => { +): Promise => { const requiredTokenIds: EdgeTokenId[] = [] for (const stakeAssetInfo of [ ...stakePolicy.stakeAssets, @@ -220,10 +229,10 @@ export const enableStakeTokens = async ( requiredTokenIds.push(stakeAssetInfo.tokenId) } - await enableTokens(requiredTokenIds, wallet) + await enableTokensWithSpinner(requiredTokenIds, wallet) } -export const getBestApy = (stakePolicies: StakePolicy[]) => { +export const getBestApy = (stakePolicies: StakePolicy[]): number => { return stakePolicies.reduce((prev, curr) => Math.max(prev, curr.apy ?? 0), 0) } @@ -270,21 +279,22 @@ export const getPoliciesFromPlugins = ( pluginId: wallet.currencyInfo.pluginId, tokenId }) - .filter( - stakePolicy => - (!stakePolicy.deprecated && - stakePolicy.stakeAssets.some( - asset => - asset.pluginId === wallet.currencyInfo.pluginId && - asset.tokenId === tokenId - )) || - (stakePolicy.deprecated && + .filter((stakePolicy): boolean => { + if (stakePolicy.deprecated === true) { + return ( stakePositionMap[stakePolicy.stakePolicyId]?.allocations.some( allocation => allocation.pluginId === wallet.currencyInfo.pluginId && allocation.tokenId === tokenId && gt(allocation.nativeAmount, '0') - )) - ) + ) ?? false + ) + } + return stakePolicy.stakeAssets.some( + asset => + asset.pluginId === wallet.currencyInfo.pluginId && + asset.tokenId === tokenId + ) + }) ) } diff --git a/src/util/tokenService.ts b/src/util/tokenService.ts new file mode 100644 index 00000000000..c9962685dcc --- /dev/null +++ b/src/util/tokenService.ts @@ -0,0 +1,305 @@ +import { asArray, asNumber, asObject, asOptional, asString } from 'cleaners' +import type { EdgeToken, JsonObject } from 'edge-core-js' + +import { cleanMultiFetch, RATES_SERVERS } from './network' + +// --------------------------------------------------------------------- +// Cleaners for rates server response +// --------------------------------------------------------------------- + +const asJsonObject = (raw: unknown): JsonObject => { + if (raw == null || typeof raw !== 'object') { + throw new TypeError('Expected a JSON object') + } + return raw as JsonObject +} + +export const asEdgeTokenInfo = asObject({ + rank: asNumber, + contractAddress: asString, + currencyCode: asString, + displayName: asString, + decimals: asNumber, + chainPluginId: asString, + networkLocation: asOptional(asJsonObject), + tokenId: asString +}) +export type EdgeTokenInfo = ReturnType + +const asEdgeTokenInfoArray = asArray(asEdgeTokenInfo) + +// --------------------------------------------------------------------- +// Cache configuration +// --------------------------------------------------------------------- + +const LIST_CACHE_TTL = 5 * 60 * 1000 // 5 minutes +const SEARCH_CACHE_TTL = 1 * 60 * 1000 // 1 minute +const TOKEN_CACHE_TTL = 5 * 60 * 1000 // 5 minutes + +interface CacheEntry { + data: T + timestamp: number +} + +interface TokenCache { + lists: Map> + searches: Map> + tokens: Map> +} + +const cache: TokenCache = { + lists: new Map(), + searches: new Map(), + tokens: new Map() +} + +function isCacheValid( + entry: CacheEntry | undefined, + ttl: number +): entry is CacheEntry { + if (entry == null) return false + return Date.now() - entry.timestamp < ttl +} + +// --------------------------------------------------------------------- +// Conversion utilities +// --------------------------------------------------------------------- + +/** + * Convert server EdgeTokenInfo to edge-core-js EdgeToken format. + */ +export function serverTokenToEdgeToken(info: EdgeTokenInfo): EdgeToken { + return { + currencyCode: info.currencyCode, + displayName: info.displayName, + denominations: [ + { + name: info.currencyCode, + multiplier: '1' + '0'.repeat(info.decimals) + } + ], + networkLocation: info.networkLocation ?? { + contractAddress: info.contractAddress + } + } +} + +/** + * Create a unique cache key for a token. + */ +function makeTokenCacheKey(pluginId: string, tokenId: string): string { + return `${pluginId}:${tokenId}` +} + +/** + * Create a cache key for list queries. + */ +function makeListCacheKey( + pluginIds: string[] | undefined, + page: number, + pageSize: number +): string { + const pluginIdStr = pluginIds != null ? pluginIds.sort().join(',') : 'all' + return `${pluginIdStr}:${page}:${pageSize}` +} + +/** + * Create a cache key for search queries. + */ +function makeSearchCacheKey( + searchTerm: string, + pluginIds: string[] | undefined +): string { + const pluginIdStr = pluginIds != null ? pluginIds.sort().join(',') : 'all' + return `${searchTerm.toLowerCase()}:${pluginIdStr}` +} + +// --------------------------------------------------------------------- +// API Functions +// --------------------------------------------------------------------- + +export interface GetTokenParams { + tokenId: string + pluginId: string +} + +/** + * Fetch a single token by tokenId and pluginId. + * Uses the /v1/getToken endpoint. + */ +export async function fetchToken( + params: GetTokenParams +): Promise { + const { tokenId, pluginId } = params + const cacheKey = makeTokenCacheKey(pluginId, tokenId) + + // Check cache first + const cached = cache.tokens.get(cacheKey) + if (isCacheValid(cached, TOKEN_CACHE_TTL)) { + return cached.data + } + + try { + const queryParams = new URLSearchParams({ tokenId, pluginId }) + const path = `v1/getToken?${queryParams.toString()}` + + const token = await cleanMultiFetch(asEdgeTokenInfo, RATES_SERVERS, path) + + cache.tokens.set(cacheKey, { data: token, timestamp: Date.now() }) + return token + } catch (error) { + console.warn(`fetchToken error for ${pluginId}:${tokenId}:`, error) + return undefined + } +} + +export interface SearchTokensParams { + searchTerm: string + pluginIds?: string[] +} + +/** + * Search tokens by a search term (matches currency code, display name, token ID, contract address). + * Uses the /v1/findTokens endpoint. + */ +export async function searchTokens( + params: SearchTokensParams +): Promise { + const { searchTerm, pluginIds } = params + + if (searchTerm.length === 0) { + return [] + } + + const cacheKey = makeSearchCacheKey(searchTerm, pluginIds) + + // Check cache first + const cached = cache.searches.get(cacheKey) + if (isCacheValid(cached, SEARCH_CACHE_TTL)) { + return cached.data + } + + try { + const queryParams = new URLSearchParams({ searchTerm }) + if (pluginIds != null && pluginIds.length > 0) { + queryParams.set('pluginIds', pluginIds.join(',')) + } + const path = `v1/findTokens?${queryParams.toString()}` + + const result = await cleanMultiFetch( + asEdgeTokenInfoArray, + RATES_SERVERS, + path + ) + + cache.searches.set(cacheKey, { data: result, timestamp: Date.now() }) + + // Also populate individual token cache + for (const token of result) { + const tokenCacheKey = makeTokenCacheKey( + token.chainPluginId, + token.tokenId + ) + cache.tokens.set(tokenCacheKey, { data: token, timestamp: Date.now() }) + } + return result + } catch (error) { + console.warn('searchTokens error:', error) + return [] + } +} + +export interface ListTokensParams { + page?: number + pageSize?: number + pluginIds?: string[] +} + +/** + * List tokens with pagination, optionally filtered by plugin IDs. + * Uses the /v1/listTokens endpoint. + */ +export async function listTokens( + params: ListTokensParams = {} +): Promise { + const { page = 0, pageSize = 100, pluginIds } = params + const cacheKey = makeListCacheKey(pluginIds, page, pageSize) + + // Check cache first + const cached = cache.lists.get(cacheKey) + if (isCacheValid(cached, LIST_CACHE_TTL)) { + return cached.data + } + + try { + const queryParams = new URLSearchParams({ + page: String(page), + pageSize: String(pageSize) + }) + if (pluginIds != null && pluginIds.length > 0) { + queryParams.set('pluginIds', pluginIds.join(',')) + } + const path = `v1/listTokens?${queryParams.toString()}` + + const result = await cleanMultiFetch( + asEdgeTokenInfoArray, + RATES_SERVERS, + path + ) + + cache.lists.set(cacheKey, { data: result, timestamp: Date.now() }) + + // Also populate individual token cache + for (const token of result) { + const tokenCacheKey = makeTokenCacheKey( + token.chainPluginId, + token.tokenId + ) + cache.tokens.set(tokenCacheKey, { data: token, timestamp: Date.now() }) + } + + return result + } catch (error) { + console.warn('listTokens error:', error) + return [] + } +} + +// --------------------------------------------------------------------- +// Cache management +// --------------------------------------------------------------------- + +/** + * Clear all cached token data. + * Call this on app background or memory pressure. + */ +export function clearTokenCache(): void { + cache.lists.clear() + cache.searches.clear() + cache.tokens.clear() +} + +/** + * Clear expired cache entries. + */ +export function pruneTokenCache(): void { + const now = Date.now() + + for (const [key, entry] of cache.lists) { + if (now - entry.timestamp >= LIST_CACHE_TTL) { + cache.lists.delete(key) + } + } + + for (const [key, entry] of cache.searches) { + if (now - entry.timestamp >= SEARCH_CACHE_TTL) { + cache.searches.delete(key) + } + } + + for (const [key, entry] of cache.tokens) { + if (now - entry.timestamp >= TOKEN_CACHE_TTL) { + cache.tokens.delete(key) + } + } +} diff --git a/yarn.lock b/yarn.lock index e51c3e047af..ca8113c93b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9353,10 +9353,8 @@ ed25519@0.0.4: bindings "^1.2.1" nan "^2.0.9" -edge-core-js@^2.43.1: - version "2.43.1" - resolved "https://registry.yarnpkg.com/edge-core-js/-/edge-core-js-2.43.1.tgz#6014cd9004dfc5df043e15d033674a5b365fc4e1" - integrity sha512-+OD+BXPU8t2UXHLrAUZ9tnn2Gq4zjLkCMOWzSbxvGAVR3aawknE9NLsq8FeomK0QIF6iebSRplK9YjVhjqFUsQ== +"edge-core-js@file:../edge-core-js": + version "2.42.0" dependencies: "@nymproject/mix-fetch" "^1.4.2" aes-js "^3.1.0"