Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
21 changes: 15 additions & 6 deletions frontend/src/hooks/useEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 3 additions & 5 deletions frontend/src/libs/contractMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
207 changes: 207 additions & 0 deletions frontend/src/libs/rpcProviders.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>();

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<string>
>();

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<string> {
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<any>): Promise<any> {
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;
};
8 changes: 6 additions & 2 deletions frontend/src/libs/web3Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ export default {
ERROR_CREATING_EVENT: "イベントを作成中にエラーが発生しました",
ERROR_CREATING_EVENT_GROUP: "イベントグループを作成中にエラーが発生しました",
ERROR_MINTING_PARTICIPATION_NFT: "NFTをミント中にエラーが発生しました",
ERROR_LOADING_EVENTS:
"イベントを読み込めませんでした。ブロックチェーンノードに接続できていない可能性があります。しばらくしてから再度お試しください。",
OWNER: "所有者",
MINTGUIDE: guide,

Expand Down
13 changes: 11 additions & 2 deletions frontend/src/pages/events/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<number | null>(null);

const pageChanged = useCallback(
Expand Down Expand Up @@ -73,6 +80,8 @@ const Events: NextPage = () => {
</Flex>
{isLoading ? (
<Spinner></Spinner>
) : error ? (
<AlertMessage status="error" title={t.ERROR_LOADING_EVENTS} />
) : (
<VStack spacing={5} align="stretch">
<>
Expand Down