diff --git a/src/components/NetworkModal/index.tsx b/src/components/NetworkModal/index.tsx index 92b98b5ac..56b80583a 100644 --- a/src/components/NetworkModal/index.tsx +++ b/src/components/NetworkModal/index.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' import { SupportedChains, useSwitchNetwork } from '@gooddollar/web3sdk-v2' -import { Text, Link } from 'native-base' +import { Text, Link, Spinner, HStack } from 'native-base' import { SwitchChainModal } from '@gooddollar/good-design' import { ChainId } from '@sushiswap/sdk' import { UnsupportedChainId } from '@gooddollar/web3sdk' @@ -39,11 +39,11 @@ const TextWrapper = styled.div` } ` -const ChainOption = ({ chainId, chain, toggleNetworkModal, switchChain, labels, icons, error }: any) => { +const ChainOption = ({ chainId, chain, switchChain, labels, icons, error, disabled }: any) => { const onOptionClick = useCallback(() => { - toggleNetworkModal() + if (disabled) return switchChain(chain) - }, [switchChain, toggleNetworkModal, chain]) + }, [disabled, switchChain, chain]) const isUnsupported = error instanceof UnsupportedChainId @@ -56,6 +56,7 @@ const ChainOption = ({ chainId, chain, toggleNetworkModal, switchChain, labels, icon={icons[chain]} id={String(chain)} onClick={onOptionClick} + disabled={disabled} /> ) } @@ -72,6 +73,7 @@ export default function NetworkModal(): JSX.Element | null { const networkModalOpen = useModalOpen(ApplicationModal.NETWORK) const toggleNetworkModal = useNetworkModalToggle() const [toAddNetwork, setToAddNetwork] = useState() + const [switchingChain, setSwitchingChain] = useState(false) const networkLabel: string | null = error ? null : (NETWORK_LABEL as any)[+(chainId ?? 42220)] const network = getEnv() @@ -102,17 +104,19 @@ export default function NetworkModal(): JSX.Element | null { const switchChain = useCallback( async (chain: SupportedChains) => { + setSwitchingChain(true) try { + await new Promise((resolve) => setTimeout(resolve, 250)) await switchNetwork(chain) sendData({ event: 'network_switch', action: 'network_switch_success', network: ChainId[chain], }) + toggleNetworkModal() } catch (e: any) { if (e?.code === 4902) { setToAddNetwork(chain) - toggleNetworkModal() return } @@ -124,6 +128,7 @@ export default function NetworkModal(): JSX.Element | null { network: ChainId[chain], }) console.warn('Wallet not initialized. Network preference saved.') + toggleNetworkModal() return } @@ -135,6 +140,8 @@ export default function NetworkModal(): JSX.Element | null { error: e?.message || 'Unknown error', }) + } finally { + setSwitchingChain(false) } }, @@ -174,6 +181,13 @@ export default function NetworkModal(): JSX.Element | null { )} + {switchingChain && ( + + + {i18n._(t`Switching network...`)} + + )} +
{allowedNetworks.map((chain: SupportedChains) => ( ))}
diff --git a/src/components/WalletModal/Option.tsx b/src/components/WalletModal/Option.tsx index 04589a48c..68b79514c 100644 --- a/src/components/WalletModal/Option.tsx +++ b/src/components/WalletModal/Option.tsx @@ -72,6 +72,7 @@ export default function Option({ icon, active = false, id, + disabled = false, }: { clickable?: boolean size?: number | null @@ -81,15 +82,17 @@ export default function Option({ icon: string active?: boolean id: string + disabled?: boolean /** @deprecated */ color?: string }) { const content = (
diff --git a/src/components/Web3Status/index.tsx b/src/components/Web3Status/index.tsx index 8d28fb158..34383d9ad 100644 --- a/src/components/Web3Status/index.tsx +++ b/src/components/Web3Status/index.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import useENSName from '../../hooks/useENSName' import { isTransactionRecent, useAllTransactions } from '../../state/transactions/hooks' import { TransactionDetails } from '../../state/transactions/reducer' @@ -12,6 +12,8 @@ import { Text, HStack } from 'native-base' import { useNativeBalance } from '@gooddollar/web3sdk-v2' import { Currency } from '@sushiswap/sdk' import { useAppKitAccount, useAppKitNetwork } from '@reown/appkit/react' +import { useEthers } from '@usedapp/core' +import { Spinner } from 'native-base' // we want the latest one to come first, so return negative if a is after b function newTransactionsFirst(a: TransactionDetails, b: TransactionDetails) { @@ -23,11 +25,54 @@ function Web3StatusInner() { const sendData = useSendAnalyticsData() const { address } = useAppKitAccount() const { chainId } = useAppKitNetwork() + const { library } = useEthers() as any const { ENSName } = useENSName(address ?? undefined) const allTransactions = useAllTransactions() const nativeBalance = useNativeBalance() + const [directNativeBalance, setDirectNativeBalance] = useState() + const [showBalance, setShowBalance] = useState(false) + + // added a timed-out fallback for when native-balance does not seem to complete (happening on mainnet) + // it uses a timeout to avoid mis-formatted amounts based on old-chain decimals. + useEffect(() => { + let cancelled = false + setShowBalance(false) + setDirectNativeBalance(undefined) + const timeoutId = window.setTimeout(() => { + if (!cancelled) { + setShowBalance(true) + } + }, 500) + + async function readBalance() { + if (!address || !library?.getBalance) { + return + } + + try { + const balance = await library.getBalance(address) + if (!cancelled) { + setDirectNativeBalance(balance.toString()) + } + } catch { + if (!cancelled) { + setDirectNativeBalance(undefined) + } + } + } + + void readBalance() + + return () => { + cancelled = true + window.clearTimeout(timeoutId) + } + }, [address, library, /*used*/ chainId]) + + const displayNativeBalance = nativeBalance ?? directNativeBalance + const shouldShowBalance = showBalance && !!displayNativeBalance const sortedRecentTransactions = useMemo(() => { const txs = Object.values(allTransactions) @@ -46,10 +91,13 @@ function Web3StatusInner() { {address && (
- {nativeBalance && ( + {shouldShowBalance ? ( - {parseFloat(nativeBalance).toFixed(4)} {Currency.getNativeCurrencySymbol(+(chainId ?? 1))} + {parseFloat(displayNativeBalance!).toFixed(4)}{' '} + {Currency.getNativeCurrencySymbol(+(chainId ?? 1))} + ) : ( + )} {hasPendingTransactions ? (
diff --git a/src/hooks/rpcParsing.test.ts b/src/hooks/rpcParsing.test.ts new file mode 100644 index 000000000..b6e922bb9 --- /dev/null +++ b/src/hooks/rpcParsing.test.ts @@ -0,0 +1,24 @@ +/* eslint-env jest */ +import { fetchRpcsFromChainlist, FALLBACK_RPCS_BY_CHAIN } from './rpcParsing' + +describe('rpcParsing', () => { + it('fetches and parses HTTP(S) RPCs for all required chains from chainlist', async () => { + const extraRpcs = await fetchRpcsFromChainlist() + + expect(extraRpcs).toBeDefined() + ;[1, 122, 42220, 50].forEach((chainId) => { + const key = String(chainId) + expect(extraRpcs[key]).toBeDefined() + expect(extraRpcs[key].length).toBeGreaterThan(0) + expect(extraRpcs[key].every((url) => /^https?:\/\//.test(url))).toBe(true) + }) + }, 30000) + + it('FALLBACK_RPCS_BY_CHAIN covers all required chains with HTTP(S) URLs', () => { + ;['1', '122', '42220', '50'].forEach((chainId) => { + expect(FALLBACK_RPCS_BY_CHAIN[chainId]).toBeDefined() + expect(FALLBACK_RPCS_BY_CHAIN[chainId].length).toBeGreaterThan(0) + expect(FALLBACK_RPCS_BY_CHAIN[chainId].every((url) => /^https?:\/\//.test(url))).toBe(true) + }) + }) +}) diff --git a/src/hooks/rpcParsing.ts b/src/hooks/rpcParsing.ts new file mode 100644 index 000000000..7aab9dc89 --- /dev/null +++ b/src/hooks/rpcParsing.ts @@ -0,0 +1,26 @@ +export const FALLBACK_RPCS_BY_CHAIN: Record = { + '1': ['https://eth.llamarpc.com', 'https://1rpc.io/eth'], + '122': ['https://rpc.fuse.io'], + '42220': ['https://forno.celo.org'], + '50': ['https://rpc.xinfin.network'], +} + +const CHAINLIST_JSON_URL = 'https://chainid.network/chains.json' +const TARGET_CHAIN_IDS = new Set([1, 122, 42220, 50]) + +export async function fetchRpcsFromChainlist(): Promise> { + const response = await fetch(CHAINLIST_JSON_URL) + if (!response.ok) throw new Error('Failed to fetch chainlist') + + const chains: Array<{ chainId: number; rpc: string[] }> = await response.json() + + const result: Record = {} + for (const chain of chains) { + if (TARGET_CHAIN_IDS.has(chain.chainId)) { + result[String(chain.chainId)] = chain.rpc.filter( + (url) => url.startsWith('http://') || url.startsWith('https://') + ) + } + } + return result +} diff --git a/src/hooks/useWeb3.tsx b/src/hooks/useWeb3.tsx index aa31c7ca0..3bea4da45 100644 --- a/src/hooks/useWeb3.tsx +++ b/src/hooks/useWeb3.tsx @@ -10,6 +10,7 @@ import { useAppKitNetwork, useAppKitProvider } from '@reown/appkit/react' import type { Provider } from '@reown/appkit/react' import { useAccount } from 'wagmi' +import { FALLBACK_RPCS_BY_CHAIN, fetchRpcsFromChainlist } from './rpcParsing' import { getEnv } from 'utils/env' import { isMiniPay, getMiniPayProvider } from 'utils/minipay' @@ -34,7 +35,6 @@ const gasSettings = { // 50: { maxFeePerGas: BigNumber.from(12.5e9).toHexString() }, // eip-1559 is only supported on XDC testnet. Last checked 15 november 2025. } -const CHAINLIST_URL = 'https://raw.githubusercontent.com/DefiLlama/chainlist/refs/heads/main/constants/extraRpcs.js' const RPC_CACHE_KEY = 'GD_RPC_CACHE' const CACHE_DURATION_MS = 24 * 60 * 60 * 1000 // 24 hours const RPC_TEST_TIMEOUT_MS = 5000 @@ -73,85 +73,23 @@ async function fetchAndTestRpcs(): Promise> { const rpcsByChain: Record = {} try { - console.log('[fetchAndTestRpcs] Starting RPC fetch and test...') - const response = await fetch(CHAINLIST_URL) - if (!response.ok) throw new Error('Failed to fetch chainlist') - - const text = await response.text() - console.log('[fetchAndTestRpcs] Chainlist fetched, parsing extraRpcs...') - // Parse "export const extraRpcs" from the JS file - const match = text.match(/export\s+const\s+extraRpcs\s*=\s*(\{[\s\S]*?\n\})/m) - if (!match) throw new Error('Could not parse extraRpcs from chainlist') - - // Create a mock privacyStatement object for eval context - const privacyStatement = {} - - // Safe evaluation of the RPC object with privacyStatement in scope - const extraRpcs = eval( - `(function() { const privacyStatement = ${JSON.stringify(privacyStatement)}; return ${match[1]}; })()` - ) - console.log('[fetchAndTestRpcs] Successfully parsed extraRpcs', extraRpcs) - - // Map chainlist chain IDs to our RPC keys - const chainMapping: Record = { - 1: 'MAINNET_RPC', - 122: 'FUSE_RPC', - 42220: 'CELO_RPC', - 50: 'XDC_RPC', - } - - // Test RPCs for each chain - for (const [chainId] of Object.entries(chainMapping)) { - const chainIdNum = Number(chainId) - console.log(`[fetchAndTestRpcs] Processing chain ${chainIdNum}...`) - - const chainRpcsData = extraRpcs[chainIdNum] || { rpcs: [] } - - // Handle both old format (array) and new format (object with rpcs property) - const chainRpcs = Array.isArray(chainRpcsData) ? chainRpcsData : chainRpcsData.rpcs || [] - console.log(`[fetchAndTestRpcs] Found ${chainRpcs.length} RPC entries for ${chainId}`) - - if (Array.isArray(chainRpcs)) { - // Extract URLs and filter out WebSocket protocols - const rpcUrlsToTest = chainRpcs - .map((rpcEntry) => { - if (typeof rpcEntry === 'string') { - return rpcEntry - } - if (typeof rpcEntry === 'object' && rpcEntry !== null && 'url' in rpcEntry) { - return rpcEntry.url - } - return null - }) - .filter((url): url is string => url !== null && !url.startsWith('wss://')) - - console.log( - `[fetchAndTestRpcs] Testing ${rpcUrlsToTest.length} HTTP(S) RPCs for ${chainId}:`, - rpcUrlsToTest - ) - - // Test all RPCs in parallel - const testResults = await Promise.all( - rpcUrlsToTest.slice(0, 10).map(async (rpcUrl) => ({ - rpcUrl, - isValid: await testRpc(rpcUrl), - })) - ) - - // Log individual test results - testResults.forEach((result) => { - console.log(`[fetchAndTestRpcs] ${result.rpcUrl}: ${result.isValid ? '✓ VALID' : '✗ INVALID'}`) - }) - - // Collect valid RPCs - const validRpcs = testResults.filter((result) => result.isValid).map((result) => result.rpcUrl) - rpcsByChain[chainId] = validRpcs - console.log(`[fetchAndTestRpcs] ${chainId} has ${validRpcs.length} valid RPCs`) - } + const extraRpcs = await fetchRpcsFromChainlist() + + for (const [chainId] of Object.entries(FALLBACK_RPCS_BY_CHAIN)) { + const chainRpcs = extraRpcs[chainId] || [] + const testResults = await Promise.all( + chainRpcs.slice(0, 10).map(async (rpcUrl) => ({ + rpcUrl, + isValid: await testRpc(rpcUrl), + })) + ) + const validRpcs = testResults.filter((r) => r.isValid).map((r) => r.rpcUrl) + rpcsByChain[chainId] = validRpcs.length ? validRpcs : FALLBACK_RPCS_BY_CHAIN[chainId] } - console.log('[fetchAndTestRpcs] RPC testing complete:', rpcsByChain) } catch (error) { console.warn('[fetchAndTestRpcs] Error during RPC fetch/test:', error) + rpcInitializationPromise = null + return FALLBACK_RPCS_BY_CHAIN } return rpcsByChain @@ -236,10 +174,10 @@ export function useNetwork(): NetworkSettings { ]) ) - const celoRpcList = sample(process.env.REACT_APP_CELO_RPC?.split(',')) ?? 'https://forno.celo.org' - const fuseRpcList = sample(process.env.REACT_APP_FUSE_RPC?.split(',')) ?? 'https://rpc.fuse.io' - const xdcRpcList = sample(process.env.REACT_APP_XDC_RPC?.split(',')) ?? 'https://rpc.xinfin.network' - const mainnetList = sample(['https://eth.llamarpc.com', 'https://1rpc.io/eth']) ?? 'https://eth.llamarpc.com' + const celoRpcList = sample(process.env.REACT_APP_CELO_RPC?.split(',')) ?? FALLBACK_RPCS_BY_CHAIN['42220'][0] + const fuseRpcList = sample(process.env.REACT_APP_FUSE_RPC?.split(',')) ?? FALLBACK_RPCS_BY_CHAIN['122'][0] + const xdcRpcList = sample(process.env.REACT_APP_XDC_RPC?.split(',')) ?? FALLBACK_RPCS_BY_CHAIN['50'][0] + const mainnetList = sample(FALLBACK_RPCS_BY_CHAIN['1']) ?? FALLBACK_RPCS_BY_CHAIN['1'][0] const [currentNetwork, rpcs] = useMemo(() => { const selectedRpcs = {