diff --git a/src/components/UnsupportedChain.tsx b/src/components/UnsupportedChain.tsx index 396a5dd1..b0eedede 100644 --- a/src/components/UnsupportedChain.tsx +++ b/src/components/UnsupportedChain.tsx @@ -1,17 +1,16 @@ import { SUPPORTED_CHAINS } from '@/config'; import { AlertOctagon } from 'lucide-react'; -import useUserStore from '@/stores/useUser.store'; +import { useAccount } from 'wagmi'; import { Alert, AlertDescription, AlertTitle } from './ui/alert'; const SUPPORTED_CHAIN_IDS = SUPPORTED_CHAINS.map((chain) => chain.id); export function UnsupportedChain() { - const { isConnected, chainId } = useUserStore(); - + const { isConnected, chainId } = useAccount(); const isChainSupported = chainId !== undefined && SUPPORTED_CHAIN_IDS.includes(chainId); - if (isChainSupported || !isConnected) { + if (!isConnected || isChainSupported) { return null; } diff --git a/src/components/navbar/ChainSelector.tsx b/src/components/navbar/ChainSelector.tsx index 7608b067..3e5baecd 100644 --- a/src/components/navbar/ChainSelector.tsx +++ b/src/components/navbar/ChainSelector.tsx @@ -1,5 +1,5 @@ import { SUPPORTED_CHAINS } from '@/config.ts'; -import { useRouter } from '@tanstack/react-router'; +import { useChainSwitch } from '@/hooks/useChainSwitch.ts'; import useUserStore from '@/stores/useUser.store.ts'; import { Select, @@ -11,23 +11,17 @@ import { export function ChainSelector() { const { chainId } = useUserStore(); - const { navigate } = useRouter(); - + const { requestChainChange } = useChainSwitch(); const handleChainChange = async (value: string) => { - const newChainSlug = SUPPORTED_CHAINS.find( - (chain) => chain.id === Number(value) - )?.slug; - const pathParts = location.pathname.split('/').filter(Boolean); - const newPath = - pathParts.length > 1 - ? `/${newChainSlug}/${pathParts.slice(1).join('/')}` - : `/${newChainSlug}`; - - navigate({ to: newPath }); + requestChainChange(Number(value)); }; return ( - diff --git a/src/config.ts b/src/config.ts index b34127af..75e7d6e6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ import arbitrumSepoliaIcon from './assets/chain-icons/arbitrum-sepolia.svg'; import iexecLogo from './assets/iexec-logo.svg'; +import { bellecour, arbitrumSepolia } from './utils/wagmiNetworks'; export const PREVIEW_TABLE_LENGTH = 5; export const TABLE_LENGTH = 16; @@ -14,6 +15,7 @@ export const SUPPORTED_CHAINS = [ icon: iexecLogo, blockExplorerUrl: 'https://blockscout-bellecour.iex.ec', subgraphUrl: 'https://thegraph.iex.ec/subgraphs/name/bellecour/poco-v5', + wagmiNetwork: bellecour, }, { id: 421614, @@ -24,5 +26,6 @@ export const SUPPORTED_CHAINS = [ blockExplorerUrl: 'https://sepolia.arbiscan.io/', subgraphUrl: 'http://localhost:8080/subgraphs/id/2GCj8gzLCihsiEDq8cYvC5nUgK6VfwZ6hm3Wj8A3kcxz', + wagmiNetwork: arbitrumSepolia, }, ]; diff --git a/src/graphql/execute.ts b/src/graphql/execute.ts index 9778cb9a..fe979c44 100644 --- a/src/graphql/execute.ts +++ b/src/graphql/execute.ts @@ -3,9 +3,12 @@ import type { TypedDocumentString } from './graphql' export async function execute( query: TypedDocumentString, - chainId: number, + chainId?: number, ...[variables]: TVariables extends Record ? [] : [TVariables] ) { + if (!chainId) { + throw Error('Missing chainId') + } const subgraphUrl = getSubgraphUrl(chainId); const response = await fetch(subgraphUrl, { method: 'POST', diff --git a/src/hooks/ChainSyncManger.ts b/src/hooks/ChainSyncManger.ts new file mode 100644 index 00000000..76e5d806 --- /dev/null +++ b/src/hooks/ChainSyncManger.ts @@ -0,0 +1,100 @@ +import { useParams, useRouter } from '@tanstack/react-router'; +import { switchChain } from '@wagmi/core'; +import { useEffect, useRef } from 'react'; +import { useAccount } from 'wagmi'; +import useUserStore from '@/stores/useUser.store'; +import { getChainFromId, INITIAL_CHAIN } from '@/utils/chain.utils'; +import { wagmiAdapter } from '@/utils/wagmiConfig'; + +/** + * Synchronize URL, account and app state + * + * - keep the global store up to date with user's account state + * - keep the URL up to date with the chain + * - request chain changes when user's account connects to the wrong chain + */ +export function ChainSyncManager() { + const { chainSlug } = useParams({ from: '/$chainSlug' }); + const { navigate, history } = useRouter(); + const { pathname } = history.location; + const { + chain: accountChain, + address: accountAddress, + isConnected: accountIsConnected, + status: accountStatus, + } = useAccount(); + const { chainId, setChainId, setIsConnected, setAddress } = useUserStore(); + + const isNavigating = useRef(false); + const previousAccountStatus = useRef(undefined); + + // init store's chain from location (once at mount time) + useEffect(() => { + setChainId(INITIAL_CHAIN.id); + }, []); + + // update store with user account's state + useEffect(() => { + setIsConnected(accountIsConnected); + setAddress(accountAddress); + if (accountChain?.id && chainId !== accountChain?.id) { + setChainId(accountChain?.id); + } + }, [ + accountAddress, + accountIsConnected, + setIsConnected, + setAddress, + chainId, + accountChain?.id, + setChainId, + ]); + + // request chain change if the user connects on chain different from the active chain + useEffect(() => { + // auto reconnection case connect to the initial chain + if ( + (previousAccountStatus.current === undefined || + previousAccountStatus.current === 'reconnecting') && + accountChain?.id && + INITIAL_CHAIN.id !== accountChain?.id + ) { + switchChain(wagmiAdapter.wagmiConfig, { chainId: INITIAL_CHAIN.id }); + } + // connection case connect to the selected chain + if ( + previousAccountStatus.current === 'connecting' && + chainId && + accountChain?.id && + chainId !== accountChain?.id + ) { + switchChain(wagmiAdapter.wagmiConfig, { chainId }); + } + + previousAccountStatus.current = accountStatus; + }, [accountChain?.id, chainId, accountStatus]); + + // Sync URL with store's chain + useEffect(() => { + if (!chainId) { + return; + } + const slug = getChainFromId(chainId)?.slug; + + if (slug !== chainSlug && !isNavigating.current) { + const [, ...rest] = pathname.split('/').filter(Boolean); + const newPath = `/${slug}/${rest.join('/')}`; + isNavigating.current = true; + const navigationResult = navigate({ to: newPath, replace: true }); + if (navigationResult instanceof Promise) { + navigationResult.finally(() => { + isNavigating.current = false; + }); + } else { + isNavigating.current = false; + } + } + }, [chainId, chainSlug, navigate, pathname]); + + return null; +} diff --git a/src/hooks/useChainSwitch.ts b/src/hooks/useChainSwitch.ts new file mode 100644 index 00000000..f7ff97b7 --- /dev/null +++ b/src/hooks/useChainSwitch.ts @@ -0,0 +1,24 @@ +import { switchChain } from '@wagmi/core'; +import { useAccount } from 'wagmi'; +import useUserStore from '@/stores/useUser.store'; +import { wagmiAdapter } from '@/utils/wagmiConfig'; + +export function useChainSwitch() { + const { isConnected } = useAccount(); + const { setChainId } = useUserStore(); + /** + * request a chain change + * + * the change is either: + * - immediately effective if the user is not connected + * - delegated to the user's account provider if the user is connected + */ + async function requestChainChange(chainId: number) { + if (isConnected) { + switchChain(wagmiAdapter.wagmiConfig, { chainId }); + } else { + setChainId(chainId); + } + } + return { requestChainChange }; +} diff --git a/src/hooks/useSyncAccountWithUserStore.ts b/src/hooks/useSyncAccountWithUserStore.ts deleted file mode 100644 index 0fc1f582..00000000 --- a/src/hooks/useSyncAccountWithUserStore.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useEffect } from 'react'; -import { useAccount } from 'wagmi'; -import useUserStore from '@/stores/useUser.store'; - -export function useSyncAccountWithUserStore() { - const { connector, address, isConnected } = useAccount(); - const { setConnector, setIsConnected, setAddress } = useUserStore(); - - useEffect(() => { - setConnector(connector); - setIsConnected(isConnected); - setAddress(address); - }, [ - connector, - address, - isConnected, - setConnector, - setIsConnected, - setAddress, - ]); -} diff --git a/src/hooks/useSyncChain.ts b/src/hooks/useSyncChain.ts deleted file mode 100644 index 841d3d24..00000000 --- a/src/hooks/useSyncChain.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useParams, useRouter } from '@tanstack/react-router'; -import { switchChain } from '@wagmi/core'; -import { useEffect, useRef } from 'react'; -import { useAccount } from 'wagmi'; -import useUserStore from '@/stores/useUser.store'; -import { getChainFromId, getChainFromSlug } from '@/utils/chain.utils'; -import { wagmiAdapter } from '@/utils/wagmiConfig'; - -export function useSyncChain() { - const { chainSlug } = useParams({ from: '/$chainSlug' }); - const { pathname } = useRouter().history.location; - const { navigate } = useRouter(); - const { chain, isConnected } = useAccount(); - const { chainId, setChainId } = useUserStore(); - - const isSyncing = useRef(false); - - // Sync store and wallet with the chain from the URL - useEffect(() => { - const targetChain = getChainFromSlug(chainSlug); - if (!targetChain) return; - - if (chainId !== targetChain.id) setChainId(targetChain.id); - - const needsSwitch = isConnected && chain?.id !== targetChain.id; - if (needsSwitch && !isSyncing.current) { - isSyncing.current = true; - switchChain(wagmiAdapter.wagmiConfig, { - chainId: targetChain.id, - }).finally(() => { - isSyncing.current = false; - }); - } - }, [chainSlug]); - - // Sync URL if wallet chain changes - useEffect(() => { - const walletChain = getChainFromId(chain?.id); - if (!walletChain) return; - - if (chainId !== walletChain.id) setChainId(walletChain.id); - - if (walletChain.slug !== chainSlug && !isSyncing.current) { - const [, ...rest] = pathname.split('/').filter(Boolean); - const newPath = `/${walletChain.slug}/${rest.join('/')}`; - isSyncing.current = true; - const navigationResult = navigate({ to: newPath, replace: true }); - if (navigationResult instanceof Promise) { - navigationResult.finally(() => { - isSyncing.current = false; - }); - } else { - isSyncing.current = false; - } - } - }, [chain?.id]); -} diff --git a/src/routes/$chainSlug/_layout.tsx b/src/routes/$chainSlug/_layout.tsx index 969e1572..ed02a23d 100644 --- a/src/routes/$chainSlug/_layout.tsx +++ b/src/routes/$chainSlug/_layout.tsx @@ -1,12 +1,9 @@ import { createFileRoute, Outlet } from '@tanstack/react-router'; -import { useSyncChain } from '@/hooks/useSyncChain'; export const Route = createFileRoute('/$chainSlug/_layout')({ component: RouteComponent, }); function RouteComponent() { - useSyncChain(); - return ; } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 7546ad92..4cdc6574 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -3,17 +3,16 @@ import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'; import { Footer } from '@/components/Footer'; import { UnsupportedChain } from '@/components/UnsupportedChain'; import { Navbar } from '@/components/navbar/NavBar'; -import { useSyncAccountWithUserStore } from '@/hooks/useSyncAccountWithUserStore'; +import { ChainSyncManager } from '@/hooks/ChainSyncManger'; export const Route = createRootRoute({ component: Root, }); function Root() { - useSyncAccountWithUserStore(); - return (
+ diff --git a/src/stores/useUser.store.ts b/src/stores/useUser.store.ts index b52036e2..ee84b606 100644 --- a/src/stores/useUser.store.ts +++ b/src/stores/useUser.store.ts @@ -1,24 +1,18 @@ -import { SUPPORTED_CHAINS } from '@/config'; import { Address } from '@/types'; -import type { Connector } from 'wagmi'; import { create } from 'zustand'; type UserState = { - connector: Connector | undefined; - setConnector: (param: Connector | undefined) => void; isConnected: boolean; setIsConnected: (param: boolean) => void; isInitialized: boolean; setInitialized: (isInitialized: boolean) => void; address: Address | undefined; setAddress: (param: Address | undefined) => void; - chainId: number; + chainId: number | undefined; setChainId: (param: number) => void; }; const useUserStore = create((set) => ({ - connector: undefined, - setConnector: (connector: Connector | undefined) => set({ connector }), isConnected: false, setIsConnected: (isConnected: boolean) => set({ isConnected }), isInitialized: false, @@ -27,8 +21,8 @@ const useUserStore = create((set) => ({ setAddress: (address: Address | undefined) => { set({ address: address?.toLowerCase() as Address }); }, - chainId: SUPPORTED_CHAINS[0].id, - setChainId: (chainId: number | undefined) => { + chainId: undefined, + setChainId: (chainId: number) => { set({ chainId: chainId }); }, })); diff --git a/src/utils/chain.utils.ts b/src/utils/chain.utils.ts index c7d06b7d..2c0fbb6c 100644 --- a/src/utils/chain.utils.ts +++ b/src/utils/chain.utils.ts @@ -22,3 +22,10 @@ export function getBlockExplorerUrl(chainId: number) { const chain = getChainFromId(chainId); return chain?.blockExplorerUrl ?? 'https://blockscout.com/'; } + +/** + * initial chain evaluated once against the current location when the app loads + */ +export const INITIAL_CHAIN = + getChainFromSlug(new URL(window.location.href).pathname.split('/')[1]) || + SUPPORTED_CHAINS[0]; diff --git a/src/utils/wagmiConfig.ts b/src/utils/wagmiConfig.ts index 644236d3..ba50be07 100644 --- a/src/utils/wagmiConfig.ts +++ b/src/utils/wagmiConfig.ts @@ -1,11 +1,11 @@ import { WagmiAdapter } from '@reown/appkit-adapter-wagmi'; -import { AppKitNetwork, arbitrumSepolia } from '@reown/appkit/networks'; +import { AppKitNetwork } from '@reown/appkit/networks'; import { createAppKit } from '@reown/appkit/react'; import { http, CreateConnectorFn } from 'wagmi'; import { injected, walletConnect } from 'wagmi/connectors'; -import { bellecour } from './bellecourChainConfig.ts'; import { InjectedWalletProvider } from './injected-wallet-provider/injected-wallet-provider.ts'; import { EIP6963ProviderDetail } from './injected-wallet-provider/types.ts'; +import wagmiNetworks from './wagmiNetworks.ts'; // Wagmi Client initialization if (!import.meta.env.VITE_REOWN_PROJECT_ID) { @@ -69,18 +69,17 @@ preservedAvailableProviderDetails.forEach((providerDetails) => { ); }); -const networks: [AppKitNetwork, ...AppKitNetwork[]] = [ - bellecour, - arbitrumSepolia, +const networks = Object.values(wagmiNetworks) as [ + AppKitNetwork, + ...AppKitNetwork[], ]; export const wagmiAdapter = new WagmiAdapter({ networks: networks, multiInjectedProviderDiscovery: false, - transports: { - [bellecour.id]: http(), - [arbitrumSepolia.id]: http(), - }, + transports: Object.fromEntries( + Object.values(wagmiNetworks).map((network) => [network.id, http()]) + ), projectId, connectors, }); @@ -94,12 +93,10 @@ const featuredWalletIds = [ 'ecc4036f814562b41a5268adc86270fba1365471402006302e70169465b7ac18', // Zerion ]; -// Create modal createAppKit({ adapters: [wagmiAdapter], networks: networks, projectId, - defaultNetwork: bellecour, featuredWalletIds, features: { email: false, diff --git a/src/utils/bellecourChainConfig.ts b/src/utils/wagmiNetworks.ts similarity index 62% rename from src/utils/bellecourChainConfig.ts rename to src/utils/wagmiNetworks.ts index 99a221bb..2be3b395 100644 --- a/src/utils/bellecourChainConfig.ts +++ b/src/utils/wagmiNetworks.ts @@ -1,7 +1,10 @@ -export const bellecour = { +import { AppKitNetwork, arbitrumSepolia } from '@reown/appkit/networks'; + +export { arbitrumSepolia } from '@reown/appkit/networks'; + +export const bellecour: AppKitNetwork = { id: 0x86, name: 'iExec Sidechain', - network: 'bellecour', nativeCurrency: { decimals: 18, name: 'xRLC', @@ -19,3 +22,10 @@ export const bellecour = { default: { name: 'Blockscout', url: 'https://blockscout-bellecour.iex.ec' }, }, }; + +const wagmiNetworks = { + bellecour, + arbitrumSepolia, +}; + +export default wagmiNetworks;