diff --git a/app/access-check.tsx b/app/access-check.tsx
index 63f6981..18c5e85 100644
--- a/app/access-check.tsx
+++ b/app/access-check.tsx
@@ -42,10 +42,14 @@ function PerChainEligibilityList({
perChainRoleEligibility,
isResolvingRoleEligibility,
roleEligibilityError,
+ retryingChainIds = [],
+ onRetryChain,
}: {
perChainRoleEligibility: PerChainRoleEligibilityResolution[];
isResolvingRoleEligibility: boolean;
roleEligibilityError?: string;
+ retryingChainIds?: number[];
+ onRetryChain?: (chainId: number) => void;
}) {
if (
perChainRoleEligibility.length === 0 &&
@@ -78,32 +82,51 @@ function PerChainEligibilityList({
) : null}
- {perChainRoleEligibility.map((chain) => (
-
-
-
- Chain {chain.chainId}
-
-
- {statusCopy[chain.status]}
-
+ {perChainRoleEligibility.map((chain) => {
+ const isRetrying = retryingChainIds.includes(chain.chainId);
+ const canRetry = chain.chainId > 0 && chain.status !== "resolved" && onRetryChain;
+
+ return (
+
+
+
+ Chain {chain.chainId}
+
+
+ {statusCopy[chain.status]}
+
+
+ {chain.resolvedRoles && chain.resolvedRoles.length > 0 ? (
+
+ Roles: {chain.resolvedRoles.join(", ")}
+
+ ) : null}
+ {chain.errorMessage ? (
+
+ {chain.errorMessage}
+
+ ) : null}
+ {canRetry ? (
+
- {chain.resolvedRoles && chain.resolvedRoles.length > 0 ? (
-
- Roles: {chain.resolvedRoles.join(", ")}
-
- ) : null}
- {chain.errorMessage ? (
- {chain.errorMessage}
- ) : null}
-
- ))}
+ );
+ })}
);
}
@@ -149,6 +172,8 @@ export default function AccessCheck() {
reset: resetAccessCheck,
perChainRoleEligibility,
isResolvingRoleEligibility,
+ resolvingRoleEligibilityChainIds,
+ retryRoleEligibilityChain,
roleEligibilityError,
} = accessCheck;
const recordCheck = useAccessHistoryStore((state) => state.recordCheck);
@@ -419,8 +444,7 @@ export default function AccessCheck() {
!!addressError ||
!!guildIdError ||
!!resourceIdError ||
- countdown.isExpired ||
- isOffline
+ countdown.isExpired
}
/>
{isOffline ? (
@@ -565,6 +589,8 @@ export default function AccessCheck() {
@@ -584,6 +610,8 @@ export default function AccessCheck() {
diff --git a/app/guilds/[guildId].tsx b/app/guilds/[guildId].tsx
index 26fc1c2..80def25 100644
--- a/app/guilds/[guildId].tsx
+++ b/app/guilds/[guildId].tsx
@@ -4,11 +4,11 @@ import { useWallet } from "../../src/features/wallet/useWallet";
import { useGuilds, GuildNotFoundError } from "../../src/features/guilds/useGuilds";
import { useMembership } from "../../src/features/membership/useMembership";
import { AppHeader } from "../../src/components/AppHeader";
-import { LoadingState } from "../../src/components/LoadingState";
import { GuildDetailSkeleton } from "../../src/components/GuildDetailSkeleton";
import { ErrorState } from "../../src/components/ErrorState";
import { GuildNotFoundState } from "../../src/components/GuildNotFoundState";
import { Card } from "../../src/components/Card";
+import { Button } from "../../src/components/Button";
import { RoleBadge } from "../../src/components/RoleBadge";
import { WalletAddress } from "../../src/components/WalletAddress";
import {
@@ -19,9 +19,73 @@ import {
import { StaleDataBanner } from "../../src/components/StaleDataBanner";
import { WalletRequired } from "../../src/components/WalletRequired";
import { useCombinedStaleState } from "../../src/features/offline/useStaleQuery";
-import { groupRoleRequirementsByChain } from "../../src/features/guilds/roleRequirements";
+import {
+ groupRoleRequirementsByChain,
+ normalizeRoleRequirements,
+} from "../../src/features/guilds/roleRequirements";
+import { useGuildChainAvailability } from "../../src/features/guilds/useGuildChainAvailability";
+import type {
+ AccessRequirement,
+ PerChainRoleEligibilityResolution,
+} from "../../src/features/access/roleEligibilityResolver";
import React from "react";
+type GuildDetailRole = {
+ id: string;
+ name: string;
+ chainId?: number;
+ requirements?: AccessRequirement[];
+};
+
+function ChainUnavailableState({
+ chainId,
+ label,
+ status,
+ errorMessage,
+ isRetrying,
+ onRetry,
+}: {
+ chainId: number;
+ label: string;
+ status: PerChainRoleEligibilityResolution["status"];
+ errorMessage?: string;
+ isRetrying: boolean;
+ onRetry: () => void;
+}) {
+ const isTimeout = status === "timed-out";
+
+ return (
+
+
+ {isTimeout ? "Network check timed out" : "Network unavailable"}
+
+
+ {isTimeout
+ ? `${label} did not respond before the timeout. Other networks can still be explored.`
+ : `${label} is unavailable right now. Other networks can still be explored.`}
+
+ {errorMessage ? (
+ {errorMessage}
+ ) : null}
+
+
+ );
+}
+
export default function GuildDetail() {
const { guildId } = useLocalSearchParams<{ guildId: string }>();
const { walletAddress } = useWallet();
@@ -34,41 +98,38 @@ export default function GuildDetail() {
const membershipQuery = useMembershipQuery(validGuildId);
const rolesQuery = useRoles(validGuildId);
- const {
- data: guild,
- isLoading: guildLoading,
- error: guildError,
- isPending: guildPending,
- } = guildQuery;
- const { isLoading: memLoading, isPending: memPending, data: membership } = membershipQuery;
- const { data: roles, isLoading: rolesLoading, isPending: rolesPending } = rolesQuery;
+ const { data: guild, isLoading: guildLoading, error: guildError } = guildQuery;
+ const { isLoading: memLoading, data: membership } = membershipQuery;
+ const { data: roles, isLoading: rolesLoading } = rolesQuery;
const { data: guildConfig } = guildConfigQuery;
const staleState = useCombinedStaleState([guildQuery, membershipQuery, rolesQuery]);
- const groupedRequirements = groupRoleRequirementsByChain(
- (roles as Array<{ id: string; name: string; chainId?: number }> | undefined) &&
- guildConfig?.requirements
- ? (roles as Array<{ id: string; name: string; chainId?: number }> | undefined)?.map(
- (role) => {
- const requirement = guildConfig.requirements?.find(
- (item: { id: string; name?: string; chainId: number }) =>
- item.id === role.id || item.name === role.name,
- );
- return {
- id: role.id,
- name: role.name,
- chainId: role.chainId ?? requirement?.chainId ?? guild?.chainId ?? 1,
- };
- },
- )
- : ((roles as Array<{ id: string; name: string; chainId?: number }> | undefined) ?? []).map(
- (role) => ({
- id: role.id,
- name: role.name,
- chainId: role.chainId ?? guild?.chainId ?? 1,
- }),
- ),
- guild?.chainId ?? 1,
+ const fallbackChainId = guild?.chainId ?? 1;
+ const detailRoles = roles as GuildDetailRole[] | undefined;
+ const normalizedRequirements = normalizeRoleRequirements(
+ detailRoles,
+ guildConfig?.requirements as
+ | {
+ id: string;
+ name?: string;
+ chainId: number;
+ }[]
+ | undefined,
+ fallbackChainId,
+ );
+ const groupedRequirements = groupRoleRequirementsByChain(normalizedRequirements, fallbackChainId);
+ const chainAvailability = useGuildChainAvailability({
+ guildId: validGuildId,
+ walletAddress,
+ roles: detailRoles,
+ enabled: !!validGuildId && !!walletAddress && !!detailRoles,
+ });
+ const availabilityByChain = React.useMemo(
+ () =>
+ new Map(
+ chainAvailability.perChain.map((chain) => [chain.chainId, chain] as const),
+ ),
+ [chainAvailability.perChain],
);
const guildChainLabel =
groupedRequirements.length === 0
@@ -152,7 +213,6 @@ export default function GuildDetail() {
Owner
-
Chain ID
@@ -188,27 +248,58 @@ export default function GuildDetail() {
Available Roles
{groupedRequirements.length > 0 ? (
- groupedRequirements.map((group) => (
-
-
- {group.label}
-
-
- {group.requirements.map((role) => (
- {
+ const availability = availabilityByChain.get(group.chainId);
+ const isUnavailable =
+ availability?.status === "timed-out" || availability?.status === "error";
+ const isChecking = chainAvailability.checkingChainIds.includes(group.chainId);
+
+ return (
+
+
+
+ {group.label}
+
+ {isChecking ? (
+
+ Checking network
+
+ ) : null}
+
+
+ {isUnavailable ? (
+ {
+ void chainAvailability.retryChain(group.chainId);
+ }}
+ />
+ ) : (
+
-
-
- ))}
+ {group.requirements.map((role) => (
+
+
+
+ ))}
+
+ )}
-
- ))
+ );
+ })
) : (
No roles defined for this guild.
)}
diff --git a/src/features/access/roleEligibilityResolver.ts b/src/features/access/roleEligibilityResolver.ts
index a73a54f..5fd18be 100644
--- a/src/features/access/roleEligibilityResolver.ts
+++ b/src/features/access/roleEligibilityResolver.ts
@@ -32,7 +32,15 @@ export type ResolveRoleEligibilityInput = {
timeouts?: RpcConfig["timeouts"];
};
-type EthCallFn = (rpcUrl: string, payload: unknown) => Promise;
+export type ResolveRoleEligibilityChainInput = {
+ walletAddress: string;
+ chainId: number;
+ requirements: AccessRequirement[];
+ /** Optional override to bypass rpcConfig (useful in tests). */
+ rpcs?: string[];
+ /** Optional override to bypass default timeouts/backoff (useful in tests). */
+ timeouts?: RpcConfig["timeouts"];
+};
type JsonRpcSuccess = { result?: unknown };
type JsonRpcError = { error?: { message?: string } };
@@ -57,7 +65,20 @@ function withTimeout(promise: Promise, ms: number): Promise {
});
}
-function buildRoleRequirementCallData(requirement: AccessRequirement): {
+function toChainErrorResolution(
+ chainId: number,
+ error: unknown,
+): PerChainRoleEligibilityResolution {
+ const msg = error instanceof Error ? error.message : String(error);
+ const isTimeout = /timed out/i.test(msg) || /Timeout/i.test(msg);
+ return {
+ chainId,
+ status: isTimeout ? "timed-out" : "error",
+ errorMessage: msg,
+ };
+}
+
+function buildRoleRequirementCallData(requirement: AccessRequirement, walletAddress: string): {
to: string;
data: string;
} {
@@ -96,15 +117,18 @@ function buildRoleRequirementCallData(requirement: AccessRequirement): {
throw new Error(`Unsupported ROLE id encoding for role requirement: ${roleId}`);
})();
- const addr = (() => {
- if (!/^0x[a-fA-F0-9]{40}$/.test(requirement.address ?? "")) {
- // best-effort: allow lowercasing without strict validation
- throw new Error(`Invalid ROLE contract address: ${requirement.address}`);
+ if (!/^0x[a-fA-F0-9]{40}$/.test(requirement.address ?? "")) {
+ throw new Error(`Invalid ROLE contract address: ${requirement.address}`);
+ }
+
+ const account = (() => {
+ if (!/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) {
+ throw new Error(`Invalid wallet address: ${walletAddress}`);
}
- return requirement.address!.slice(2).toLowerCase().padStart(64, "0");
+ return walletAddress.slice(2).toLowerCase().padStart(64, "0");
})();
- const data = `${HAS_ROLE_SELECTOR}${bytes32}${addr}`;
+ const data = `${HAS_ROLE_SELECTOR}${bytes32}${account}`;
return { to, data };
}
@@ -165,7 +189,7 @@ async function resolveChainRoleEligibility(params: {
try {
const roleChecks = supportedRequirements.map(async (req) => {
- const { to, data } = buildRoleRequirementCallData(req);
+ const { to, data } = buildRoleRequirementCallData(req, walletAddress);
const hasRole = await withTimeout(
rpcEthCall(rpcUrl, to, data),
timeouts.roleResolverRpcAttemptTimeoutMs,
@@ -208,6 +232,26 @@ async function resolveChainRoleEligibility(params: {
return { chainId, status: "error", errorMessage: "No RPC endpoints configured" };
}
+export async function resolveRoleEligibilityForChain(
+ input: ResolveRoleEligibilityChainInput,
+): Promise {
+ const {
+ walletAddress,
+ chainId,
+ requirements,
+ rpcs = getRpcsForChain(chainId),
+ timeouts = rpcConfig.timeouts,
+ } = input;
+
+ return resolveChainRoleEligibility({
+ walletAddress,
+ chainId,
+ roleRequirements: requirements,
+ rpcs: rpcs.filter(Boolean),
+ timeouts,
+ }).catch((e) => toChainErrorResolution(chainId, e));
+}
+
export async function resolveRoleEligibilityForChains(
input: ResolveRoleEligibilityInput,
): Promise {
@@ -220,25 +264,15 @@ export async function resolveRoleEligibilityForChains(
byChain.set(chainId, arr);
}
- const perChainTasks: Array> = [];
+ const perChainTasks: Promise[] = [];
for (const [chainId, roleRequirements] of byChain.entries()) {
- const rpcs = (rpcsByChain[chainId] ?? getRpcsForChain(chainId)).filter(Boolean);
-
perChainTasks.push(
- resolveChainRoleEligibility({
+ resolveRoleEligibilityForChain({
walletAddress,
chainId,
- roleRequirements,
- rpcs,
+ requirements: roleRequirements,
+ rpcs: rpcsByChain[chainId],
timeouts,
- }).catch((e) => {
- const msg = e instanceof Error ? e.message : String(e);
- const isTimeout = /timed out/i.test(msg) || /Timeout/i.test(msg);
- return {
- chainId,
- status: isTimeout ? "timed-out" : "error",
- errorMessage: msg,
- };
}),
);
}
diff --git a/src/features/access/useAccessCheck.ts b/src/features/access/useAccessCheck.ts
index 99cf56a..346a3b7 100644
--- a/src/features/access/useAccessCheck.ts
+++ b/src/features/access/useAccessCheck.ts
@@ -227,6 +227,8 @@ export const useAccessCheck = () => {
mutateAsync,
perChainRoleEligibility: multiChain.perChain as PerChainRoleEligibilityResolution[],
isResolvingRoleEligibility: multiChain.isResolving,
+ resolvingRoleEligibilityChainIds: multiChain.resolvingChainIds,
+ retryRoleEligibilityChain: multiChain.retryChain,
roleEligibilityError: multiChain.error,
};
};
diff --git a/src/features/access/useMultiChainRoleEligibility.ts b/src/features/access/useMultiChainRoleEligibility.ts
index c48fc18..1ff06d4 100644
--- a/src/features/access/useMultiChainRoleEligibility.ts
+++ b/src/features/access/useMultiChainRoleEligibility.ts
@@ -1,8 +1,8 @@
-import { useCallback, useMemo, useState } from "react";
+import { useCallback, useMemo, useRef, useState } from "react";
import { guildPassClient } from "../../lib/guildpassClient";
import { getRpcsForChain, rpcConfig } from "../../config/rpcConfig";
-import { resolveRoleEligibilityForChains } from "./roleEligibilityResolver";
+import { resolveRoleEligibilityForChain } from "./roleEligibilityResolver";
import type {
AccessRequirement,
PerChainRoleEligibilityResolution,
@@ -11,6 +11,7 @@ import type {
export type MultiChainRoleEligibilityStatusState = {
isResolving: boolean;
+ resolvingChainIds: number[];
perChain: PerChainRoleEligibilityResolution[];
error?: string;
};
@@ -22,11 +23,49 @@ type GuildRoleWithRequirements = {
requirements?: AccessRequirement[];
};
+type LastResolutionContext = {
+ guildId: string;
+ walletAddress: string;
+ requirementsByChain: Map;
+ rpcsByChain: Record;
+};
+
export type RoleEligibilityResolutionPlan = {
requirements: RoleRequirementOnChain[];
configurationErrors: PerChainRoleEligibilityResolution[];
};
+function upsertPerChainResolution(
+ current: PerChainRoleEligibilityResolution[],
+ next: PerChainRoleEligibilityResolution,
+): PerChainRoleEligibilityResolution[] {
+ return [...current.filter((item) => item.chainId !== next.chainId), next].sort(
+ (left, right) => left.chainId - right.chainId,
+ );
+}
+
+function addResolvingChain(current: number[], chainId: number): number[] {
+ return current.includes(chainId) ? current : [...current, chainId].sort((a, b) => a - b);
+}
+
+function removeResolvingChain(current: number[], chainId: number): number[] {
+ return current.filter((id) => id !== chainId);
+}
+
+function groupRequirementsByChain(
+ requirements: RoleRequirementOnChain[],
+): Map {
+ const byChain = new Map();
+
+ for (const { chainId, requirement } of requirements) {
+ const chainRequirements = byChain.get(chainId) ?? [];
+ chainRequirements.push(requirement);
+ byChain.set(chainId, chainRequirements);
+ }
+
+ return byChain;
+}
+
function describeRole(role: GuildRoleWithRequirements): string {
const name = role.name?.trim();
const id = role.id?.trim();
@@ -68,11 +107,17 @@ export function buildRoleEligibilityResolutionPlan(
export const useMultiChainRoleEligibility = () => {
const [state, setState] = useState({
isResolving: false,
+ resolvingChainIds: [],
perChain: [],
});
+ const requestIdRef = useRef(0);
+ const lastResolutionRef = useRef(null);
const resolve = useCallback(async (guildId: string, walletAddress: string) => {
- setState({ isResolving: true, perChain: [] });
+ const requestId = requestIdRef.current + 1;
+ requestIdRef.current = requestId;
+ lastResolutionRef.current = null;
+ setState({ isResolving: true, resolvingChainIds: [], perChain: [] });
try {
// Fetch roles including their on-chain requirements.
@@ -82,8 +127,10 @@ export const useMultiChainRoleEligibility = () => {
const plan = buildRoleEligibilityResolutionPlan(roles);
if (plan.requirements.length === 0) {
+ if (requestIdRef.current !== requestId) return;
setState({
isResolving: false,
+ resolvingChainIds: [],
perChain: plan.configurationErrors,
});
return;
@@ -94,28 +141,96 @@ export const useMultiChainRoleEligibility = () => {
rpcsByChain[chainId] = getRpcsForChain(chainId);
}
- const perChain = await resolveRoleEligibilityForChains({
+ const requirementsByChain = groupRequirementsByChain(plan.requirements);
+ const chainIds = Array.from(requirementsByChain.keys()).sort((a, b) => a - b);
+ lastResolutionRef.current = {
+ guildId,
walletAddress,
- requirements: plan.requirements,
+ requirementsByChain,
rpcsByChain,
- timeouts: rpcConfig.timeouts,
- });
+ };
+ if (requestIdRef.current !== requestId) return;
setState({
- isResolving: false,
- perChain: [...plan.configurationErrors, ...perChain],
+ isResolving: true,
+ resolvingChainIds: chainIds,
+ perChain: plan.configurationErrors,
});
+
+ await Promise.all(
+ chainIds.map(async (chainId) => {
+ const result = await resolveRoleEligibilityForChain({
+ walletAddress,
+ chainId,
+ requirements: requirementsByChain.get(chainId) ?? [],
+ rpcs: rpcsByChain[chainId],
+ timeouts: rpcConfig.timeouts,
+ });
+
+ if (requestIdRef.current !== requestId) return;
+
+ setState((current) => {
+ const resolvingChainIds = removeResolvingChain(current.resolvingChainIds, chainId);
+ return {
+ ...current,
+ isResolving: resolvingChainIds.length > 0,
+ resolvingChainIds,
+ perChain: upsertPerChainResolution(current.perChain, result),
+ };
+ });
+ }),
+ );
} catch (e: any) {
+ if (requestIdRef.current !== requestId) return;
setState({
isResolving: false,
+ resolvingChainIds: [],
perChain: [],
error: e instanceof Error ? e.message : String(e),
});
}
}, []);
+ const retryChain = useCallback(async (chainId: number) => {
+ const context = lastResolutionRef.current;
+ const requirements = context?.requirementsByChain.get(chainId);
+
+ if (!context || !requirements) {
+ return;
+ }
+
+ const requestId = requestIdRef.current;
+
+ setState((current) => ({
+ ...current,
+ isResolving: true,
+ resolvingChainIds: addResolvingChain(current.resolvingChainIds, chainId),
+ error: undefined,
+ }));
+
+ const result = await resolveRoleEligibilityForChain({
+ walletAddress: context.walletAddress,
+ chainId,
+ requirements,
+ rpcs: context.rpcsByChain[chainId],
+ timeouts: rpcConfig.timeouts,
+ });
+
+ if (requestIdRef.current !== requestId) return;
+
+ setState((current) => {
+ const resolvingChainIds = removeResolvingChain(current.resolvingChainIds, chainId);
+ return {
+ ...current,
+ isResolving: resolvingChainIds.length > 0,
+ resolvingChainIds,
+ perChain: upsertPerChainResolution(current.perChain, result),
+ };
+ });
+ }, []);
+
return useMemo(
- () => ({ ...state, resolve }),
- [resolve, state.isResolving, state.perChain, state.error],
+ () => ({ ...state, resolve, retryChain }),
+ [resolve, retryChain, state],
);
};
diff --git a/src/features/guilds/useGuildChainAvailability.ts b/src/features/guilds/useGuildChainAvailability.ts
new file mode 100644
index 0000000..1c9e59c
--- /dev/null
+++ b/src/features/guilds/useGuildChainAvailability.ts
@@ -0,0 +1,182 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { getRpcsForChain, rpcConfig } from "../../config/rpcConfig";
+import { resolveRoleEligibilityForChain } from "../access/roleEligibilityResolver";
+import type {
+ AccessRequirement,
+ PerChainRoleEligibilityResolution,
+ RoleRequirementOnChain,
+} from "../access/roleEligibilityResolver";
+import { buildRoleEligibilityResolutionPlan } from "../access/useMultiChainRoleEligibility";
+
+type GuildRoleWithRequirements = {
+ id?: string;
+ name?: string;
+ chainId?: number | null;
+ requirements?: AccessRequirement[];
+};
+
+type LastAvailabilityContext = {
+ walletAddress: string;
+ requirementsByChain: Map;
+ rpcsByChain: Record;
+};
+
+export type GuildChainAvailabilityState = {
+ isChecking: boolean;
+ checkingChainIds: number[];
+ perChain: PerChainRoleEligibilityResolution[];
+ retryChain: (chainId: number) => Promise;
+};
+
+function groupRequirementsByChain(
+ requirements: RoleRequirementOnChain[],
+): Map {
+ const byChain = new Map();
+
+ for (const { chainId, requirement } of requirements) {
+ const chainRequirements = byChain.get(chainId) ?? [];
+ chainRequirements.push(requirement);
+ byChain.set(chainId, chainRequirements);
+ }
+
+ return byChain;
+}
+
+function upsertPerChainResolution(
+ current: PerChainRoleEligibilityResolution[],
+ next: PerChainRoleEligibilityResolution,
+): PerChainRoleEligibilityResolution[] {
+ return [...current.filter((item) => item.chainId !== next.chainId), next].sort(
+ (left, right) => left.chainId - right.chainId,
+ );
+}
+
+function addCheckingChain(current: number[], chainId: number): number[] {
+ return current.includes(chainId) ? current : [...current, chainId].sort((a, b) => a - b);
+}
+
+function removeCheckingChain(current: number[], chainId: number): number[] {
+ return current.filter((id) => id !== chainId);
+}
+
+export function useGuildChainAvailability({
+ guildId,
+ walletAddress,
+ roles,
+ enabled = true,
+}: {
+ guildId: string;
+ walletAddress?: string | null;
+ roles?: GuildRoleWithRequirements[];
+ enabled?: boolean;
+}): GuildChainAvailabilityState {
+ const [state, setState] = useState>({
+ isChecking: false,
+ checkingChainIds: [],
+ perChain: [],
+ });
+ const requestIdRef = useRef(0);
+ const lastAvailabilityRef = useRef(null);
+
+ useEffect(() => {
+ const requestId = requestIdRef.current + 1;
+ requestIdRef.current = requestId;
+ lastAvailabilityRef.current = null;
+
+ if (!enabled || !walletAddress || !roles) {
+ setState({ isChecking: false, checkingChainIds: [], perChain: [] });
+ return;
+ }
+
+ const plan = buildRoleEligibilityResolutionPlan(roles);
+ if (plan.requirements.length === 0) {
+ setState({
+ isChecking: false,
+ checkingChainIds: [],
+ perChain: plan.configurationErrors,
+ });
+ return;
+ }
+
+ const requirementsByChain = groupRequirementsByChain(plan.requirements);
+ const chainIds = Array.from(requirementsByChain.keys()).sort((a, b) => a - b);
+ const rpcsByChain: Record = {};
+ for (const chainId of chainIds) {
+ rpcsByChain[chainId] = getRpcsForChain(chainId);
+ }
+
+ lastAvailabilityRef.current = {
+ walletAddress,
+ requirementsByChain,
+ rpcsByChain,
+ };
+
+ setState({
+ isChecking: true,
+ checkingChainIds: chainIds,
+ perChain: plan.configurationErrors,
+ });
+
+ for (const chainId of chainIds) {
+ void resolveRoleEligibilityForChain({
+ walletAddress,
+ chainId,
+ requirements: requirementsByChain.get(chainId) ?? [],
+ rpcs: rpcsByChain[chainId],
+ timeouts: rpcConfig.timeouts,
+ }).then((result) => {
+ if (requestIdRef.current !== requestId) return;
+
+ setState((current) => {
+ const checkingChainIds = removeCheckingChain(current.checkingChainIds, chainId);
+ return {
+ isChecking: checkingChainIds.length > 0,
+ checkingChainIds,
+ perChain: upsertPerChainResolution(current.perChain, result),
+ };
+ });
+ });
+ }
+ }, [enabled, guildId, roles, walletAddress]);
+
+ const retryChain = useCallback(async (chainId: number) => {
+ const context = lastAvailabilityRef.current;
+ const requirements = context?.requirementsByChain.get(chainId);
+
+ if (!context || !requirements) {
+ return;
+ }
+
+ const requestId = requestIdRef.current;
+
+ setState((current) => ({
+ ...current,
+ isChecking: true,
+ checkingChainIds: addCheckingChain(current.checkingChainIds, chainId),
+ }));
+
+ const result = await resolveRoleEligibilityForChain({
+ walletAddress: context.walletAddress,
+ chainId,
+ requirements,
+ rpcs: context.rpcsByChain[chainId],
+ timeouts: rpcConfig.timeouts,
+ });
+
+ if (requestIdRef.current !== requestId) return;
+
+ setState((current) => {
+ const checkingChainIds = removeCheckingChain(current.checkingChainIds, chainId);
+ return {
+ isChecking: checkingChainIds.length > 0,
+ checkingChainIds,
+ perChain: upsertPerChainResolution(current.perChain, result),
+ };
+ });
+ }, []);
+
+ return useMemo(
+ () => ({ ...state, retryChain }),
+ [retryChain, state],
+ );
+}
diff --git a/tests/accessCheckScreen.test.tsx b/tests/accessCheckScreen.test.tsx
index e4b191e..e86b4d5 100644
--- a/tests/accessCheckScreen.test.tsx
+++ b/tests/accessCheckScreen.test.tsx
@@ -33,15 +33,17 @@ const guildPassClientMock = vi.hoisted(() => ({
}));
const multiChainState = vi.hoisted(() => ({
- perChain: [] as Array<{
+ perChain: [] as {
chainId: number;
status: "resolved" | "timed-out" | "error";
resolvedRoles?: string[];
errorMessage?: string;
- }>,
+ }[],
isResolving: false,
+ resolvingChainIds: [] as number[],
error: undefined as string | undefined,
resolve: vi.fn(async () => undefined),
+ retryChain: vi.fn(async () => undefined),
}));
const guildQueryMock = vi.hoisted(() => ({
@@ -171,8 +173,10 @@ describe("AccessCheck screen", () => {
guildPassClientMock.checkAccess.mockReset().mockResolvedValue(ACCESS_GRANTED_FIXTURE);
multiChainState.perChain = [];
multiChainState.isResolving = false;
+ multiChainState.resolvingChainIds = [];
multiChainState.error = undefined;
multiChainState.resolve.mockReset().mockResolvedValue(undefined);
+ multiChainState.retryChain.mockReset().mockResolvedValue(undefined);
guildQueryMock.data = { name: "Guild Alpha" };
useAccessHistoryStore.setState({ entries: [] });
useNetworkStore.setState({ isOnline: true, isOffline: false });
@@ -424,10 +428,51 @@ describe("AccessCheck screen", () => {
expect(screen!.root.findByProps({ testID: "per-chain-eligibility-row-137" })).toBeDefined();
expect(screenText).toContain("Error");
expect(screenText).toContain("RPC provider error");
+ expect(
+ screen!.root.findByProps({ testID: "per-chain-eligibility-retry-10" }),
+ ).toBeDefined();
+ expect(
+ screen!.root.findByProps({ testID: "per-chain-eligibility-retry-137" }),
+ ).toBeDefined();
expect(screenText).toContain("Resolving");
expect(screenText).toContain("Some chains could not be fully resolved.");
});
+ it("retries only the selected per-chain role eligibility failure", async () => {
+ multiChainState.perChain = [
+ { chainId: 1, status: "resolved", resolvedRoles: ["member"] },
+ { chainId: 10, status: "timed-out", errorMessage: "RPC attempt timed out after 500ms" },
+ ];
+
+ let screen: ReactTestRenderer;
+
+ await act(async () => {
+ screen = renderScreen();
+ });
+
+ await act(async () => {
+ screen.root
+ .findByProps({ testID: "access-check-guild-id-input" })
+ .props.onChangeText("guild-alpha");
+ screen.root
+ .findByProps({ testID: "access-check-resource-id-input" })
+ .props.onChangeText("vip-door");
+ await flush();
+ });
+
+ await act(async () => {
+ screen.root.findByProps({ accessibilityLabel: "Check Access" }).props.onPress();
+ await flush();
+ });
+
+ await act(async () => {
+ screen.root.findByProps({ testID: "per-chain-eligibility-retry-10" }).props.onPress();
+ });
+
+ expect(multiChainState.retryChain).toHaveBeenCalledTimes(1);
+ expect(multiChainState.retryChain).toHaveBeenCalledWith(10);
+ });
+
it("renders the offline banner and allows a local verification attempt when offline", async () => {
useNetworkStore.setState({ isOnline: false, isOffline: true });
diff --git a/tests/guildDetailCrossChainFailures.test.tsx b/tests/guildDetailCrossChainFailures.test.tsx
new file mode 100644
index 0000000..f6c673c
--- /dev/null
+++ b/tests/guildDetailCrossChainFailures.test.tsx
@@ -0,0 +1,239 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import React from "react";
+import TestRenderer, { act, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import GuildDetail from "../app/guilds/[guildId]";
+
+const routerMocks = vi.hoisted(() => ({
+ back: vi.fn(),
+ push: vi.fn(),
+ replace: vi.fn(),
+}));
+
+const searchParams = vi.hoisted(() => ({
+ guildId: "guild-alpha",
+}));
+
+const walletState = vi.hoisted(() => ({
+ walletAddress: "0x1234567890123456789012345678901234567890",
+ isConnected: true,
+ isHydrated: true,
+}));
+
+const screenData = vi.hoisted(() => ({
+ guild: {
+ id: "guild-alpha",
+ name: "Guild Alpha",
+ description: "Cross-chain guild",
+ ownerAddress: "0xOwnerAddress1234567890123456789012345678",
+ chainId: 1,
+ isActive: true,
+ },
+ guildConfig: {
+ guildId: "guild-alpha",
+ requiredRoles: ["member", "optimism"],
+ accessPolicy: "any" as const,
+ requirements: [
+ { id: "role-eth", name: "Ethereum Role", chainId: 1 },
+ { id: "role-optimism", name: "Optimism Role", chainId: 10 },
+ ],
+ },
+ membership: {
+ guildId: "guild-alpha",
+ isActive: true,
+ roles: ["member"],
+ },
+ roles: [
+ {
+ id: "role-eth",
+ name: "Ethereum Role",
+ guildId: "guild-alpha",
+ chainId: 1,
+ requirements: [
+ {
+ type: "ROLE" as const,
+ address: "0x1234567890123456789012345678901234567890",
+ id: "1",
+ },
+ ],
+ },
+ {
+ id: "role-optimism",
+ name: "Optimism Role",
+ guildId: "guild-alpha",
+ chainId: 10,
+ requirements: [
+ {
+ type: "ROLE" as const,
+ address: "0x1234567890123456789012345678901234567890",
+ id: "2",
+ },
+ ],
+ },
+ ],
+}));
+
+const availabilityState = vi.hoisted(() => ({
+ isChecking: false,
+ checkingChainIds: [] as number[],
+ perChain: [] as {
+ chainId: number;
+ status: "resolved" | "timed-out" | "error";
+ resolvedRoles?: string[];
+ errorMessage?: string;
+ }[],
+ retryChain: vi.fn(async () => undefined),
+}));
+
+const queryHelpers = vi.hoisted(() => ({
+ makeQuery: (data: unknown) => ({
+ data,
+ isLoading: false,
+ isPending: false,
+ isFetching: false,
+ isStale: false,
+ dataUpdatedAt: 1,
+ error: null,
+ refetch: vi.fn(),
+ }),
+}));
+
+vi.mock("expo-router", () => ({
+ useLocalSearchParams: () => searchParams,
+ useRouter: () => routerMocks,
+}));
+
+vi.mock("../src/features/wallet/useWallet", () => ({
+ useWallet: () => walletState,
+}));
+
+vi.mock("../src/features/guilds/useGuilds", () => ({
+ GuildNotFoundError: class GuildNotFoundError extends Error {},
+ useGuilds: () => ({
+ useGuild: () => queryHelpers.makeQuery(screenData.guild),
+ useGuildConfig: () => queryHelpers.makeQuery(screenData.guildConfig),
+ useRoles: () => queryHelpers.makeQuery(screenData.roles),
+ }),
+}));
+
+vi.mock("../src/features/membership/useMembership", () => ({
+ useMembership: () => ({
+ useMembershipQuery: () => queryHelpers.makeQuery(screenData.membership),
+ }),
+}));
+
+vi.mock("../src/features/offline/useStaleQuery", () => ({
+ useCombinedStaleState: () => ({
+ isOffline: false,
+ isStale: false,
+ reason: null,
+ lastSyncedAt: null,
+ }),
+}));
+
+vi.mock("../src/features/guilds/useGuildChainAvailability", () => ({
+ useGuildChainAvailability: () => availabilityState,
+}));
+
+vi.mock("../src/components/WalletAddress", () => ({
+ WalletAddress: () => null,
+}));
+
+let renderedScreens: ReactTestRenderer[] = [];
+let queryClients: QueryClient[] = [];
+
+const renderScreen = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: 0 },
+ mutations: { retry: false, gcTime: 0 },
+ },
+ });
+ queryClients.push(queryClient);
+
+ const screen = TestRenderer.create(
+ React.createElement(
+ QueryClientProvider,
+ { client: queryClient },
+ React.createElement(GuildDetail),
+ ),
+ );
+ renderedScreens.push(screen);
+ return screen;
+};
+
+const outputText = (renderer: ReactTestRenderer) => JSON.stringify(renderer.toJSON());
+
+describe("GuildDetail cross-chain availability", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ availabilityState.isChecking = false;
+ availabilityState.checkingChainIds = [];
+ availabilityState.perChain = [];
+ availabilityState.retryChain.mockReset().mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ for (const screen of renderedScreens) {
+ screen.unmount();
+ }
+ for (const queryClient of queryClients) {
+ queryClient.clear();
+ }
+ renderedScreens = [];
+ queryClients = [];
+ });
+
+ it("renders healthy chain roles while a sibling chain shows a timed-out retry state", async () => {
+ availabilityState.perChain = [
+ { chainId: 1, status: "resolved", resolvedRoles: ["1"] },
+ {
+ chainId: 10,
+ status: "timed-out",
+ errorMessage: "RPC attempt timed out after 50ms",
+ },
+ ];
+
+ let screen!: ReactTestRenderer;
+
+ await act(async () => {
+ screen = renderScreen();
+ });
+
+ const screenText = outputText(screen!);
+ expect(screen.root.findByProps({ testID: "guild-roles-list-1" })).toBeDefined();
+ expect(screenText).toContain("Ethereum Role");
+ expect(screen.root.findByProps({ testID: "guild-chain-unavailable-10" })).toBeDefined();
+ expect(screenText).toContain("Network check timed out");
+ expect(screenText).toContain("RPC attempt timed out after 50ms");
+ expect(screen.root.findByProps({ testID: "guild-chain-retry-10" })).toBeDefined();
+ });
+
+ it("renders a retryable unavailable state for a failed chain without hiding other chains", async () => {
+ availabilityState.perChain = [
+ { chainId: 1, status: "resolved", resolvedRoles: ["1"] },
+ {
+ chainId: 10,
+ status: "error",
+ errorMessage: "Optimism RPC provider error",
+ },
+ ];
+
+ let screen!: ReactTestRenderer;
+
+ await act(async () => {
+ screen = renderScreen();
+ });
+
+ expect(screen.root.findByProps({ testID: "guild-roles-list-1" })).toBeDefined();
+ expect(screen.root.findByProps({ testID: "guild-chain-unavailable-10" })).toBeDefined();
+ expect(outputText(screen!)).toContain("Network unavailable");
+
+ await act(async () => {
+ screen.root.findByProps({ testID: "guild-chain-retry-10" }).props.onPress();
+ });
+
+ expect(availabilityState.retryChain).toHaveBeenCalledTimes(1);
+ expect(availabilityState.retryChain).toHaveBeenCalledWith(10);
+ });
+});
diff --git a/tests/hooks/useGuildChainAvailability.test.ts b/tests/hooks/useGuildChainAvailability.test.ts
new file mode 100644
index 0000000..1757d17
--- /dev/null
+++ b/tests/hooks/useGuildChainAvailability.test.ts
@@ -0,0 +1,165 @@
+import React from "react";
+import TestRenderer, { act, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useGuildChainAvailability } from "../../src/features/guilds/useGuildChainAvailability";
+
+const rpcConfigMock = vi.hoisted(() => {
+ const defaultTimeouts = {
+ roleResolverPerChainTimeoutMs: 75,
+ roleResolverRpcAttemptTimeoutMs: 50,
+ roleResolverBackoffBaseDelayMs: 0,
+ roleResolverBackoffMaxDelayMs: 0,
+ roleResolverMaxAttemptsPerEndpoint: 0,
+ };
+
+ return {
+ defaultTimeouts,
+ getRpcsForChain: vi.fn((_chainId: number) => [] as string[]),
+ timeouts: { ...defaultTimeouts },
+ };
+});
+
+vi.mock("../../src/lib/guildpassClient", () => ({
+ guildPassClient: {
+ roles: {
+ getRoles: vi.fn(),
+ },
+ },
+}));
+
+vi.mock("../../src/config/rpcConfig", () => ({
+ getRpcsForChain: rpcConfigMock.getRpcsForChain,
+ rpcConfig: {
+ timeouts: rpcConfigMock.timeouts,
+ },
+}));
+
+const roles = [
+ {
+ id: "role-eth",
+ name: "Ethereum Role",
+ chainId: 1,
+ requirements: [
+ {
+ type: "ROLE" as const,
+ address: "0x1234567890123456789012345678901234567890",
+ id: "1",
+ },
+ ],
+ },
+ {
+ id: "role-optimism",
+ name: "Optimism Role",
+ chainId: 10,
+ requirements: [
+ {
+ type: "ROLE" as const,
+ address: "0x1234567890123456789012345678901234567890",
+ id: "2",
+ },
+ ],
+ },
+];
+
+const WALLET_ADDRESS = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+
+type HookResult = ReturnType;
+
+let hookResult: HookResult | undefined;
+
+function HookHarness() {
+ hookResult = useGuildChainAvailability({
+ guildId: "guild-1",
+ walletAddress: WALLET_ADDRESS,
+ roles,
+ });
+ return null;
+}
+
+async function renderHook() {
+ let renderer: ReactTestRenderer;
+
+ await act(async () => {
+ renderer = TestRenderer.create(React.createElement(HookHarness));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ return {
+ get current() {
+ if (!hookResult) throw new Error("Hook did not render");
+ return hookResult;
+ },
+ unmount() {
+ renderer.unmount();
+ },
+ };
+}
+
+describe("useGuildChainAvailability", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ hookResult = undefined;
+ rpcConfigMock.getRpcsForChain.mockReset();
+ Object.assign(rpcConfigMock.timeouts, rpcConfigMock.defaultTimeouts);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it("publishes healthy chains while a sibling chain is still waiting on its timeout", async () => {
+ rpcConfigMock.getRpcsForChain.mockImplementation((chainId: number) =>
+ chainId === 1 ? ["https://rpc.ethereum.test"] : ["https://rpc.optimism.test"],
+ );
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation((rpcUrl) => {
+ if (String(rpcUrl).includes("ethereum")) {
+ return Promise.resolve({
+ json: async () => ({ result: "0x1" }),
+ } as Response);
+ }
+
+ return new Promise(() => {});
+ });
+ const hook = await renderHook();
+
+ expect(hook.current.perChain).toContainEqual({
+ chainId: 1,
+ status: "resolved",
+ resolvedRoles: ["1"],
+ });
+ expect(hook.current.perChain.find((chain) => chain.chainId === 10)).toBeUndefined();
+ expect(hook.current.isChecking).toBe(true);
+ expect(hook.current.checkingChainIds).toContain(10);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(80);
+ });
+
+ expect(hook.current.perChain).toEqual([
+ {
+ chainId: 1,
+ status: "resolved",
+ resolvedRoles: ["1"],
+ },
+ {
+ chainId: 10,
+ status: "timed-out",
+ errorMessage: "RPC attempt timed out after 50ms",
+ },
+ ]);
+ expect(hook.current.isChecking).toBe(false);
+ expect(hook.current.checkingChainIds).toEqual([]);
+ const ethereumCall = fetchSpy.mock.calls.find(([rpcUrl]) =>
+ String(rpcUrl).includes("ethereum"),
+ );
+ const ethereumPayload = JSON.parse(String((ethereumCall?.[1] as RequestInit).body));
+ expect(ethereumPayload.params[0].data).toContain(
+ WALLET_ADDRESS.slice(2).toLowerCase().padStart(64, "0"),
+ );
+
+ fetchSpy.mockRestore();
+ hook.unmount();
+ });
+});
diff --git a/tests/hooks/useMultiChainRoleEligibility.test.ts b/tests/hooks/useMultiChainRoleEligibility.test.ts
index ec0985d..c02e4ef 100644
--- a/tests/hooks/useMultiChainRoleEligibility.test.ts
+++ b/tests/hooks/useMultiChainRoleEligibility.test.ts
@@ -1,6 +1,6 @@
import React from "react";
import TestRenderer, { act, type ReactTestRenderer } from "react-test-renderer";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildRoleEligibilityResolutionPlan,
type RoleEligibilityResolutionPlan,
@@ -11,6 +11,22 @@ const guildPassClientMock = vi.hoisted(() => ({
getRoles: vi.fn(),
}));
+const rpcConfigMock = vi.hoisted(() => {
+ const defaultTimeouts = {
+ roleResolverPerChainTimeoutMs: 75,
+ roleResolverRpcAttemptTimeoutMs: 50,
+ roleResolverBackoffBaseDelayMs: 0,
+ roleResolverBackoffMaxDelayMs: 0,
+ roleResolverMaxAttemptsPerEndpoint: 0,
+ };
+
+ return {
+ defaultTimeouts,
+ getRpcsForChain: vi.fn((_chainId: number) => [] as string[]),
+ timeouts: { ...defaultTimeouts },
+ };
+});
+
vi.mock("../../src/lib/guildpassClient", () => ({
guildPassClient: {
roles: {
@@ -20,9 +36,9 @@ vi.mock("../../src/lib/guildpassClient", () => ({
}));
vi.mock("../../src/config/rpcConfig", () => ({
- getRpcsForChain: vi.fn(() => []),
+ getRpcsForChain: rpcConfigMock.getRpcsForChain,
rpcConfig: {
- timeouts: {},
+ timeouts: rpcConfigMock.timeouts,
},
}));
@@ -32,6 +48,13 @@ const ROLE_REQUIREMENT = {
id: "1",
};
+const ROLE_REQUIREMENT_2 = {
+ ...ROLE_REQUIREMENT,
+ id: "2",
+};
+
+const WALLET_ADDRESS = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+
type HookResult = ReturnType;
let hookResult: HookResult | undefined;
@@ -59,6 +82,11 @@ async function renderHook() {
};
}
+const flushMicrotasks = async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+};
+
describe("buildRoleEligibilityResolutionPlan", () => {
it("reports a required role with no chainId instead of silently dropping it", () => {
const plan = buildRoleEligibilityResolutionPlan([
@@ -144,6 +172,13 @@ describe("useMultiChainRoleEligibility", () => {
beforeEach(() => {
hookResult = undefined;
guildPassClientMock.getRoles.mockReset();
+ rpcConfigMock.getRpcsForChain.mockReset().mockReturnValue([]);
+ Object.assign(rpcConfigMock.timeouts, rpcConfigMock.defaultTimeouts);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
});
it("publishes a missing-chain configuration error through perChain", async () => {
@@ -218,4 +253,159 @@ describe("useMultiChainRoleEligibility", () => {
fetchSpy.mockRestore();
hook.unmount();
});
+
+ it("publishes a successful chain while another chain is still waiting on its timeout", async () => {
+ vi.useFakeTimers();
+ guildPassClientMock.getRoles.mockResolvedValue([
+ {
+ id: "role-eth",
+ name: "Ethereum Role",
+ chainId: 1,
+ requirements: [ROLE_REQUIREMENT],
+ },
+ {
+ id: "role-optimism",
+ name: "Optimism Role",
+ chainId: 10,
+ requirements: [ROLE_REQUIREMENT_2],
+ },
+ ]);
+ rpcConfigMock.getRpcsForChain.mockImplementation((chainId: number) =>
+ chainId === 1 ? ["https://rpc.ethereum.test"] : ["https://rpc.optimism.test"],
+ );
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation((rpcUrl) => {
+ if (String(rpcUrl).includes("ethereum")) {
+ return Promise.resolve({
+ json: async () => ({ result: "0x1" }),
+ } as Response);
+ }
+
+ return new Promise(() => {});
+ });
+ const hook = await renderHook();
+ let resolution: Promise | undefined;
+
+ await act(async () => {
+ resolution = hook.current.resolve(
+ "guild-1",
+ WALLET_ADDRESS,
+ );
+ await flushMicrotasks();
+ });
+
+ expect(hook.current.perChain).toContainEqual({
+ chainId: 1,
+ status: "resolved",
+ resolvedRoles: ["1"],
+ });
+ expect(hook.current.perChain.find((chain) => chain.chainId === 10)).toBeUndefined();
+ expect(hook.current.isResolving).toBe(true);
+ expect(hook.current.resolvingChainIds).toContain(10);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(80);
+ await resolution;
+ });
+
+ expect(hook.current.perChain).toEqual([
+ {
+ chainId: 1,
+ status: "resolved",
+ resolvedRoles: ["1"],
+ },
+ {
+ chainId: 10,
+ status: "timed-out",
+ errorMessage: "RPC attempt timed out after 50ms",
+ },
+ ]);
+ expect(hook.current.isResolving).toBe(false);
+ expect(hook.current.resolvingChainIds).toEqual([]);
+ const ethereumCall = fetchSpy.mock.calls.find(([rpcUrl]) =>
+ String(rpcUrl).includes("ethereum"),
+ );
+ const ethereumPayload = JSON.parse(String((ethereumCall?.[1] as RequestInit).body));
+ expect(ethereumPayload.params[0].to).toBe(ROLE_REQUIREMENT.address);
+ expect(ethereumPayload.params[0].data).toContain(
+ WALLET_ADDRESS.slice(2).toLowerCase().padStart(64, "0"),
+ );
+
+ fetchSpy.mockRestore();
+ hook.unmount();
+ });
+
+ it("retries one failed chain without replacing successful sibling results", async () => {
+ guildPassClientMock.getRoles.mockResolvedValue([
+ {
+ id: "role-eth",
+ name: "Ethereum Role",
+ chainId: 1,
+ requirements: [ROLE_REQUIREMENT],
+ },
+ {
+ id: "role-optimism",
+ name: "Optimism Role",
+ chainId: 10,
+ requirements: [ROLE_REQUIREMENT_2],
+ },
+ ]);
+ rpcConfigMock.getRpcsForChain.mockImplementation((chainId: number) =>
+ chainId === 1 ? ["https://rpc.ethereum.test"] : ["https://rpc.optimism.test"],
+ );
+ let optimismAttempts = 0;
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation((rpcUrl) => {
+ if (String(rpcUrl).includes("ethereum")) {
+ return Promise.resolve({
+ json: async () => ({ result: "0x1" }),
+ } as Response);
+ }
+
+ optimismAttempts += 1;
+ if (optimismAttempts === 1) {
+ return Promise.reject(new Error("Optimism RPC provider error"));
+ }
+
+ return Promise.resolve({
+ json: async () => ({ result: "0x1" }),
+ } as Response);
+ });
+ const hook = await renderHook();
+
+ await act(async () => {
+ await hook.current.resolve("guild-1", WALLET_ADDRESS);
+ });
+
+ expect(hook.current.perChain).toEqual([
+ {
+ chainId: 1,
+ status: "resolved",
+ resolvedRoles: ["1"],
+ },
+ {
+ chainId: 10,
+ status: "error",
+ errorMessage: "Optimism RPC provider error",
+ },
+ ]);
+
+ await act(async () => {
+ await hook.current.retryChain(10);
+ });
+
+ expect(hook.current.perChain).toEqual([
+ {
+ chainId: 1,
+ status: "resolved",
+ resolvedRoles: ["1"],
+ },
+ {
+ chainId: 10,
+ status: "resolved",
+ resolvedRoles: ["2"],
+ },
+ ]);
+
+ fetchSpy.mockRestore();
+ hook.unmount();
+ });
});