diff --git a/frontend/.env.example b/frontend/.env.example index 1e9dde60..8390fd43 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,5 +1,7 @@ # Provider RPC and Alchemy key NEXT_PUBLIC_PROVIDER_RPC= +# Comma separated RPC endpoints used when NEXT_PUBLIC_PROVIDER_RPC is unreachable +NEXT_PUBLIC_PROVIDER_RPC_FALLBACKS= NEXT_PUBLIC_ALCHEMY_KEY= # Ethers.js's Provider Alchemy key for ENS NEXT_PUBLIC_ALCHEMY_KEY_FOR_ENS= diff --git a/frontend/src/hooks/useEvent.ts b/frontend/src/hooks/useEvent.ts index bb01c522..1c3a44ed 100644 --- a/frontend/src/hooks/useEvent.ts +++ b/frontend/src/hooks/useEvent.ts @@ -284,19 +284,26 @@ export const useEvents = (option?: UseEventOption) => { // read record data useEffect(() => { if (eventManagerContract === undefined || currentCursor === null) return; + + let isCurrentRequest = true; + setError(null); setIsLoading(true); eventManagerContract - ?.call("getEventRecords", [COUNT_PER_PAGE, currentCursor]) + .call("getEventRecords", [COUNT_PER_PAGE, currentCursor]) .then((res: any) => { - setEventData(res); + if (isCurrentRequest) setEventData(res); }) .catch((err: any) => { - setError(err); + if (isCurrentRequest) setError(err); }) .finally(() => { - setIsLoading(false); + if (isCurrentRequest) setIsLoading(false); }); - }, [currentCursor, eventManagerContract]); + + return () => { + isCurrentRequest = false; + }; + }, [currentCursor, eventManagerContract, COUNT_PER_PAGE]); if (option?.initialCursor !== undefined && currentCursor === null) { setCurrentCursor(option?.initialCursor); } @@ -327,7 +334,9 @@ export const useEvents = (option?: UseEventOption) => { return { events, isLoading, - error, + // A failing record count leaves the list empty just as a failing record + // fetch does, so both errors have to reach the caller. + error: error ?? countError, countData, nextCursor, prevCursor, diff --git a/frontend/src/libs/contractMethods.ts b/frontend/src/libs/contractMethods.ts index 3419ed07..17c3c17e 100644 --- a/frontend/src/libs/contractMethods.ts +++ b/frontend/src/libs/contractMethods.ts @@ -5,25 +5,23 @@ import { ipfs2http } from "../../utils/ipfs2http"; import axios from "axios"; import { NFT } from "types/NFT"; import { Event } from "types/Event"; -const provierRpc = process.env.NEXT_PUBLIC_PROVIDER_RPC!; +import { getReadonlyProvider } from "./rpcProviders"; const contractAddress = process.env.NEXT_PUBLIC_CONTRACT_MINT_NFT_MANAGER!; const eventContractAddress = process.env.NEXT_PUBLIC_CONTRACT_EVENT_MANAGER!; const getMintNFTManagerContract = () => { - const provider = new ethers.providers.JsonRpcProvider(provierRpc); const _contract = new ethers.Contract( contractAddress, contract.abi, - provider + getReadonlyProvider() ); return _contract; } const getEventManagerContract = () => { - const provider = new ethers.providers.JsonRpcProvider(provierRpc); const _contract = new ethers.Contract( eventContractAddress, eventContract.abi, - provider + getReadonlyProvider() ); return _contract; } diff --git a/frontend/src/libs/rpcProviders.ts b/frontend/src/libs/rpcProviders.ts new file mode 100644 index 00000000..78695ac4 --- /dev/null +++ b/frontend/src/libs/rpcProviders.ts @@ -0,0 +1,207 @@ +// ABOUTME: Resolves the read-only RPC endpoints used for on-chain reads. +// ABOUTME: Fails over only for endpoint availability errors and validates each endpoint's chain. + +import { ethers } from "ethers"; + +// Chains for which a hosted RPC fallback makes sense. Local chains are excluded +// because no public endpoint can serve them. +const REMOTE_CHAIN_IDS = ["137", "80001"]; + +const envChainId = process.env.NEXT_PUBLIC_CHAIN_ID; + +const thirdwebRpcUrl = () => { + const clientId = process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID; + if (!clientId || !envChainId || !REMOTE_CHAIN_IDS.includes(envChainId)) + return null; + return `https://${envChainId}.rpc.thirdweb.com/${clientId}`; +}; + +/** + * Read-only RPC endpoints in priority order. + * NEXT_PUBLIC_PROVIDER_RPC comes first, then any comma separated endpoints in + * NEXT_PUBLIC_PROVIDER_RPC_FALLBACKS, then the thirdweb RPC derived from + * NEXT_PUBLIC_THIRDWEB_CLIENT_ID. + */ +export const getReadonlyRpcUrls = (): string[] => { + const configured = [ + process.env.NEXT_PUBLIC_PROVIDER_RPC, + ...(process.env.NEXT_PUBLIC_PROVIDER_RPC_FALLBACKS?.split(",") ?? []), + thirdwebRpcUrl(), + ]; + const urls = configured + .map((url) => url?.trim()) + .filter((url): url is string => !!url); + return Array.from(new Set(urls)); +}; + +type RpcErrorLike = { + code?: string | number; + status?: string | number; + reason?: string; + message?: string; + error?: unknown; + serverError?: unknown; +}; + +const TRANSPORT_ERROR_CODES = new Set([ + "NETWORK_ERROR", + "TIMEOUT", + "ECONNREFUSED", + "ECONNRESET", + "ETIMEDOUT", + "ENOTFOUND", + "EAI_AGAIN", +]); + +const SEMANTIC_ERROR_PATTERN = + /execution reverted|revert(?:ed| reason)?|invalid (?:argument|params)|insufficient funds|nonce (?:is )?too low/i; +const AVAILABILITY_ERROR_PATTERN = + /app is inactive|rate limit|too many requests|request limit|quota|temporar(?:y|ily) unavailable|service unavailable|gateway timeout|unauthori[sz]ed|forbidden|(?:invalid|missing) api key|project .*?(?:disabled|inactive)|missing response|failed response|failed to fetch|invalid json|socket hang up/i; + +const getErrorChain = (error: unknown): RpcErrorLike[] => { + const chain: RpcErrorLike[] = []; + const queue: unknown[] = [error]; + const seen = new Set(); + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || typeof current !== "object" || seen.has(current)) continue; + seen.add(current); + + const rpcError = current as RpcErrorLike; + chain.push(rpcError); + queue.push(rpcError.error, rpcError.serverError); + } + + return chain; +}; + +/** + * Retry transport and endpoint-availability failures only. Semantic JSON-RPC + * errors must reach the caller so a fallback cannot turn a genuine revert into + * a stale or otherwise different successful result. + */ +const isRetryableRpcError = (error: unknown): boolean => { + const chain = getErrorChain(error); + const message = chain + .map((item) => `${item.reason ?? ""} ${item.message ?? ""}`) + .join(" "); + + if (SEMANTIC_ERROR_PATTERN.test(message)) return false; + + if (chain.some((item) => TRANSPORT_ERROR_CODES.has(String(item.code ?? "")))) + return true; + + if (chain.some((item) => item.serverError !== undefined)) return true; + + if ( + chain.some((item) => { + const status = Number(item.status); + return ( + [401, 403, 404, 405, 408, 425, 429].includes(status) || status >= 500 + ); + }) + ) + return true; + + if (chain.some((item) => ["-32002", "-32005"].includes(String(item.code)))) + return true; + + return AVAILABILITY_ERROR_PATTERN.test(message); +}; + +class FailoverJsonRpcProvider extends ethers.providers.StaticJsonRpcProvider { + private readonly endpointProviders: ethers.providers.StaticJsonRpcProvider[]; + private readonly expectedChainId: number; + private readonly networkChecks = new WeakMap< + ethers.providers.StaticJsonRpcProvider, + Promise + >(); + + constructor(urls: string[], network: number) { + super(urls[0], network); + this.expectedChainId = network; + this.endpointProviders = urls.map( + (url) => new ethers.providers.StaticJsonRpcProvider(url, network) + ); + } + + private verifyNetwork( + provider: ethers.providers.StaticJsonRpcProvider + ): Promise { + const existingCheck = this.networkChecks.get(provider); + if (existingCheck) return existingCheck; + + const check = provider + .send("eth_chainId", []) + .then((rawChainId) => { + let actualChainId: number; + try { + actualChainId = ethers.BigNumber.from(rawChainId).toNumber(); + } catch { + const error: any = new Error( + "RPC endpoint returned an invalid chain ID" + ); + error.code = "NETWORK_ERROR"; + throw error; + } + + if (actualChainId !== this.expectedChainId) { + const error: any = new Error( + `RPC endpoint chain ID ${actualChainId} does not match expected chain ID ${this.expectedChainId}` + ); + error.code = "NETWORK_ERROR"; + error.expectedChainId = this.expectedChainId; + error.actualChainId = actualChainId; + throw error; + } + + return rawChainId; + }) + .catch((error) => { + this.networkChecks.delete(provider); + throw error; + }); + + this.networkChecks.set(provider, check); + return check; + } + + async send(method: string, params: Array): Promise { + let firstError: unknown; + + for (const provider of this.endpointProviders) { + try { + const chainId = await this.verifyNetwork(provider); + if (method === "eth_chainId") return chainId; + return await provider.send(method, params); + } catch (error) { + firstError ??= error; + if (!isRetryableRpcError(error)) throw error; + } + } + + throw firstError ?? new Error("No RPC endpoint is available."); + } +} + +let readonlyProvider: ethers.providers.BaseProvider | null = null; + +export const getReadonlyProvider = () => { + if (readonlyProvider) return readonlyProvider; + + const urls = getReadonlyRpcUrls(); + if (urls.length === 0) + throw new Error( + "No RPC endpoint is configured. Set NEXT_PUBLIC_PROVIDER_RPC." + ); + + const network = Number(envChainId); + if (!Number.isSafeInteger(network) || network <= 0) + throw new Error( + "No valid chain ID is configured. Set NEXT_PUBLIC_CHAIN_ID." + ); + + readonlyProvider = new FailoverJsonRpcProvider(urls, network); + return readonlyProvider; +}; diff --git a/frontend/src/libs/web3Config.ts b/frontend/src/libs/web3Config.ts index 22869e00..d42f404c 100644 --- a/frontend/src/libs/web3Config.ts +++ b/frontend/src/libs/web3Config.ts @@ -7,15 +7,19 @@ import { } from "@thirdweb-dev/react"; import { useLocale } from "src/hooks/useLocale"; import { useMemo } from "react"; +import { getReadonlyRpcUrls } from "./rpcProviders"; export const chainId = process.env.NEXT_PUBLIC_CHAIN_ID!; +// The thirdweb SDK reads contracts through the first entry of this list only, +// so the entries behind it cover a missing NEXT_PUBLIC_PROVIDER_RPC rather than +// acting as a runtime failover. export const activeChain = chainId === "80001" - ? { ...Mumbai, rpc: [process.env.NEXT_PUBLIC_PROVIDER_RPC!, ...Mumbai.rpc] } + ? { ...Mumbai, rpc: [...getReadonlyRpcUrls(), ...Mumbai.rpc] } : chainId === "137" ? { ...Polygon, - rpc: [process.env.NEXT_PUBLIC_PROVIDER_RPC!, ...Polygon.rpc], + rpc: [...getReadonlyRpcUrls(), ...Polygon.rpc], } : { ...Localhost, rpc: ["http://localhost:8545"], chainId: 31337 }; diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index 0a025962..c830bec9 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -194,6 +194,8 @@ export default { ERROR_CREATING_EVENT_GROUP: "An error occurred when creating your new event group", ERROR_MINTING_PARTICIPATION_NFT: "An error occurred when minting your NFT", + ERROR_LOADING_EVENTS: + "Could not load events. The blockchain node may be unreachable. Please try again later.", OWNER: "Owner", MINTGUIDE: guide, diff --git a/frontend/src/locales/ja.ts b/frontend/src/locales/ja.ts index 6c6942dc..e472797d 100644 --- a/frontend/src/locales/ja.ts +++ b/frontend/src/locales/ja.ts @@ -194,6 +194,8 @@ export default { ERROR_CREATING_EVENT: "イベントを作成中にエラーが発生しました", ERROR_CREATING_EVENT_GROUP: "イベントグループを作成中にエラーが発生しました", ERROR_MINTING_PARTICIPATION_NFT: "NFTをミント中にエラーが発生しました", + ERROR_LOADING_EVENTS: + "イベントを読み込めませんでした。ブロックチェーンノードに接続できていない可能性があります。しばらくしてから再度お試しください。", OWNER: "所有者", MINTGUIDE: guide, diff --git a/frontend/src/pages/events/index.tsx b/frontend/src/pages/events/index.tsx index 9197fd66..9f2f700a 100644 --- a/frontend/src/pages/events/index.tsx +++ b/frontend/src/pages/events/index.tsx @@ -12,6 +12,7 @@ import { VStack, } from "@chakra-ui/react"; +import AlertMessage from "src/components/atoms/form/AlertMessage"; import EventCard from "../../components/atoms/events/EventCard"; import { Event } from "types/Event"; import { useLocale } from "../../hooks/useLocale"; @@ -23,8 +24,14 @@ import Paginate from "src/components/atoms/events/Paginate"; const Events: NextPage = () => { const router = useRouter(); const { t } = useLocale(); - const { events, isLoading, countData, setCurrentCursor, COUNT_PER_PAGE } = - useEvents(); + const { + events, + isLoading, + error, + countData, + setCurrentCursor, + COUNT_PER_PAGE, + } = useEvents(); const [currentPage, setCurrentPage] = useState(null); const pageChanged = useCallback( @@ -73,6 +80,8 @@ const Events: NextPage = () => { {isLoading ? ( + ) : error ? ( + ) : ( <>