diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 546b7cc0..52d87386 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,9 @@ name: Frontend CI - on: push: branches: [main] pull_request: branches: [main] - jobs: build: runs-on: ubuntu-latest @@ -16,13 +14,13 @@ jobs: NEXT_PUBLIC_APP_URL: http://localhost:3000 NEXT_PUBLIC_API_URL: http://localhost:3000/api steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v5 with: version: 10.18.3 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: - node-version: 20 + node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - name: Build production bundle diff --git a/app/(dashboard)/claims/page.tsx b/app/(dashboard)/claims/page.tsx index e612006a..6e6eff8b 100644 --- a/app/(dashboard)/claims/page.tsx +++ b/app/(dashboard)/claims/page.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +// Handle wallet-network mismatch before signing/claiming. +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CheckCircle, Clock, @@ -19,7 +20,6 @@ import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; import { useReducedMotion } from "@/hooks/useReducedMotion"; import { useWalletContext } from "@/context/WalletContext"; import { cn } from "@/lib/utils"; -import { ClaimEligibilityStatus } from "@/components/claims/ClaimEligibilityStatus"; import { ClaimEligibilityClientError } from "@/lib/claim-eligibility-client"; import type { ClaimEvidence, ClaimStatus } from "@/types/claim-eligibility"; @@ -108,6 +108,30 @@ const STATUS_CONFIG: Record< }, }; +/** + * Network on which claim settlements are signed. + * Uses an env override so the same bundle can target testnet or mainnet. + * Exported for tests. + */ +export const REQUIRED_CLAIM_NETWORK = + process.env.NEXT_PUBLIC_CLAIM_NETWORK ?? "testnet"; + +/** + * Returns true when the connected wallet's network does not match the + * network required for claim settlements. A missing wallet network is not + * treated as a mismatch because the action is already disabled when the + * wallet is not connected. + */ +export const isClaimNetworkMismatch = ( + walletNetwork?: string | null, +): boolean => { + if (!REQUIRED_CLAIM_NETWORK) return false; + if (!walletNetwork) return false; + // Network IDs are normalized to lowercase because wallet providers may + // return different casing for the same network (e.g. "mainnet" vs "Mainnet"). + return walletNetwork.toLowerCase() !== REQUIRED_CLAIM_NETWORK.toLowerCase(); +}; + // ── Mock Data ──────────────────────────────────────────────────────────────── export const MOCK_CLAIMS: Claim[] = [ @@ -284,6 +308,8 @@ export interface ClaimCardProps { ) => Promise; /** Connected account used to scope eligibility (drives the permission state). */ account?: string; + /** Disables the claim action when the wallet is missing or on the wrong network. */ + disabled?: boolean; } /** @@ -300,8 +326,7 @@ export const ClaimCard: React.FC = ({ onClaim, isClaiming = false, reducedMotion = false, - eligibilityFetcher, - account, + disabled = false, }) => { const { marketTitle, @@ -314,7 +339,11 @@ export const ClaimCard: React.FC = ({ status, } = claim; - const isActionable = status === "available"; + const { network: walletNetwork } = useWalletContext(); + const isWrongNetwork = isClaimNetworkMismatch(walletNetwork); + // Handle wallet-network mismatch before signing/claiming: do not allow + // claiming unless the wallet is on the required claim settlement network. + const isActionable = status === "available" && !isWrongNetwork; return ( = ({ + {error &&
{error}
} + + ); +} diff --git a/components/WalletReconnectBanner.tsx b/components/WalletReconnectBanner.tsx index 5233309c..b3055d17 100644 --- a/components/WalletReconnectBanner.tsx +++ b/components/WalletReconnectBanner.tsx @@ -20,49 +20,67 @@ const HAS_CONNECTED_KEY = "predictify_has_connected"; export interface WalletReconnectBannerProps { className?: string; onReconnect?: () => void; + supportedChainIds?: (number | string)[]; } export function WalletReconnectBanner({ className, onReconnect, + supportedChainIds, }: WalletReconnectBannerProps) { - const { isConnected } = useWallet(); + const { isConnected, chainId } = useWallet(); const [dismissed, setDismissed] = useState(false); const [show, setShow] = useState(false); const wasConnectedRef = useRef(isConnected); const initialCheckDone = useRef(false); + const previousChainIdRef = useRef(chainId); + const wapMismatchRef = useRef(false); + + const isNetworkMismatch = Boolean( + isConnected && + supportedChainIds && + !(chainId !== undefined && supportedChainIds.some((id) => String(id) === String(chainId))) + ); useEffect(() => { + const wasConnected = wasConnectedRef.current; + const wasMismatch = wapMismatchRef.current; + if (!initialCheckDone.current) { initialCheckDone.current = true; let hasConnectedBefore = false; try { - hasConnectedBefore = - localStorage.getItem(HAS_CONNECTED_KEY) === "true"; + hasConnectedBefore = localStorage.getItem(HAS_CONNECTED_KEY) === "true"; } catch { /* localStorage unavailable */ } - if (hasConnectedBefore && !isConnected) { - setShow(true); - } - if (isConnected) { try { localStorage.setItem(HAS_CONNECTED_KEY, "true"); } catch { /* localStorage unavailable */ } + + if (isNetworkMismatch) { + setShow(true); + setDismissed(false); + } + } else if (hasConnectedBefore) { + setShow(true); } wasConnectedRef.current = isConnected; + wapMismatchRef.current = isNetworkMismatch; + previousChainIdRef.current = chainId; return; } - if (wasConnectedRef.current && !isConnected) { + // Transition: disconnected -> connected + if (!wasConnected && isConnected) { try { - localStorage.removeItem(HAS_CONNECTED_KEY); + localStorage.setItem(HAS_CONNECTED_KEY, "true"); } catch { /* localStorage unavailable */ } @@ -70,18 +88,43 @@ export function WalletReconnectBanner({ setDismissed(false); } - if (!wasConnectedRef.current && isConnected) { + // Transition: connected -> disconnected + if (wasConnected && !isConnected) { try { - localStorage.setItem(HAS_CONNECTED_KEY, "true"); + localStorage.removeItem(HAS_CONNECTED_KEY); } catch { /* localStorage unavailable */ } + setShow(true); + setDismissed(false); + } + + // Network changed while connected + if (isConnected && chainId !== previousChainIdRef.current) { + setDismissed(false); + if (isNetworkMismatch) { + setShow(true); + } else { + setShow(false); + } + } + + // Network mismatch appeared (e.g., supportedChainIds prop changed) + if (isConnected && isNetworkMismatch && !wasMismatch) { + setShow(true); + setDismissed(false); + } + + // Network mismatch resolved + if (isConnected && !isNetworkMismatch && wasMismatch) { setShow(false); setDismissed(false); } wasConnectedRef.current = isConnected; - }, [isConnected]); + wapMismatchRef.current = isNetworkMismatch; + previousChainIdRef.current = chainId; + }, [isConnected, chainId, isNetworkMismatch]); const handleReconnect = useCallback(() => { onReconnect?.(); @@ -92,43 +135,12 @@ export function WalletReconnectBanner({ setShow(false); }, []); + const actionLabel = isNetworkMismatch ? "Switch network" : reconnectButtonLabel; + const actionAriaLabel = isNetworkMismatch ? "Switch network" : reconnectAriaLabel; + if (!show || dismissed) return null; return ( -
+
- -
- ); -} + role=\"alert\"\n aria-live=\"polite\"\n className=\"border-amber-500/50 bg-amber-50 text-amber-900 dark:bg-amber-950/20 dark:text-amber-400 [svg]:text-amber-500\"\n >\n \n {isNetworkMismatch ? \"Unsupported network\" : reconnectBannerTitle}\n \n
\n

\n {isNetworkMismatch\n ? \"Please switch to a supported network to continue.\"\n : reconnectBannerDescription}\n

\n
\n \n \n {dismissButtonLabel}\n \n \n \n {actionLabel}\n \n
\n
\n
\n \n
\n );\n}\n \ No newline at end of file diff --git a/components/connect-wallet-modal.tsx b/components/connect-wallet-modal.tsx index 292eed40..337cd98d 100644 --- a/components/connect-wallet-modal.tsx +++ b/components/connect-wallet-modal.tsx @@ -1,6 +1,68 @@ "use client"; -import { WalletModal, WalletModalProps } from "@/src/legacy-pages/WalletModal"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { WalletModal, WalletModalProps } from "/src/legacy-pages/WalletModal"; -export { WalletModal as ConnectWalletModal }; -export type { WalletModalProps as ConnectWalletModalProps }; +const DEFAULT_CHAIN_ID = 1; +const envChainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || DEFAULT_CHAIN_ID); +const SUPPORTED_CHAIN_ID = Number.isInteger(envChainId) && envChainId > 0 ? envChainId : DEFAULT_CHAIN_ID; + +function getEthereumProvider(): any | null { + if (typeof window !== "undefined" && (window as any).ethereum) { + return (window as any).ethereum; + } + return null; +} + +function getCurrentChainId(): number | undefined { + const provider = getEthereumProvider(); + if (!provider?.chainId) return undefined; + const chainId = Number(provider.chainId); + return Number.isFinite(chainId) ? chainId : undefined; +} + +function isSupportedChainId(chainId: number | undefined): boolean { + return chainId === SUPPORTED_CHAIN_ID; +} + +async function switchToSupportedChain(): Promise { + const provider = getEthereumProvider(); + if (!provider) { + throw new Error("Ethereum provider not available"); + } + try { + await provider.request({ + method: "wallet_switchEthereumChain", + params: [{ chainId: `0x${SUPPORTED_CHAIN_ID.toString(16)}` }], + }); + } catch (error) { + console.error("Failed to switch network:", error); + throw error; + } +} + +export function ConnectWalletModal(props: WalletModalProps) { + const [chainId, setChainId] = useState(getCurrentChainId); + const [hasProvider, setHasProvider] = useState(false); + const [isSwitching, setIsSwitching] = useState(false); + const [switchError, setSwitchError] = useState(null); + const [loadError, setLoadError] = useState(null); + const mounted = useRef(true); + const chainIdRequestId = useRef(0); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const detectChainId = useCallback(async () => { + const provider = getEthereumProvider(); + if (!provider) { + if (mounted.current) setHasProvider(false); + return; + } + if (mounted.current) setHasProvider(true); + const requestId = ++chainIdRequestId.current; + }, []); \ No newline at end of file diff --git a/components/navbar/NetworkSwitcher.tsx b/components/navbar/NetworkSwitcher.tsx index 635ca13c..fe2dcd10 100644 --- a/components/navbar/NetworkSwitcher.tsx +++ b/components/navbar/NetworkSwitcher.tsx @@ -1,71 +1,96 @@ -"use client"; - -import React from "react"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { StellarIcon, ArrowDown } from "../icons"; -import { getNetworkTint } from "@/lib/network-tint"; - -interface NetworkSwitcherProps { - network: string; - onChange?: (next: string) => void; - className?: string; -} - -const NETWORKS = ["Mainnet", "Testnet", "Futurenet"]; - -export function NetworkSwitcher({ network, onChange, className }: NetworkSwitcherProps) { - const activeTint = getNetworkTint(network); - - return ( - - - - - - Network - - {NETWORKS.map((n) => { - const t = getNetworkTint(n); - return ( - onChange?.(n)} - className="cursor-pointer flex items-center gap-2" - role="menuitemradio" - aria-checked={n === network} - > -
- {n} - - ); - })} - - - ); -} - - +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { StellarIcon, ArrowDown } from "../icons"; +import { getNetworkTint } from "@/lib/network-tint"; + +interface NetworkSwitcherProps { + network: string; + onChange?: (next: string) => void; + className?: string; + /** The network currently connected in the user's wallet, if known. */ + walletNetwork?: string; + /** Called when the user selects a network that differs from `walletNetwork`. */ + onMismatch?: (next: string) => void; +} + +const NETWORKS = ["Mainnet", "Testnet", "Futurenet"] as const; +type Network = (typeof NETWORKS)[number]; + +function isNetwork(value: string): value is Network { + return (NETWORKS as readonly string[]).includes(value); +} + +export function NetworkSwitcher({ network, onChange, className, walletNetwork, onMismatch }: NetworkSwitcherProps) { + const safeNetwork: string = isNetwork(network) ? network : NETWORKS[0]; + const activeTint = getNetworkTint(safeNetwork); + const hasSwitchMatch = walletNetwork != null && walletNetwork !== safeNetwork; + + const handleSelect = (next: string) => { + if (!isNetwork(next)) return; + if (next === safeNetwork) return; + if (walletNetwork != null && next !== walletNetwork && onMismatch) { + onMismatch(next); + } else { + onChange?(next); + } + }; + + return ( + + + + + + Network + + {NETWORKS.map((n) => { + const t = getNetworkTint(n); + const isSelected = n === safeNetwork; + const isMismatched = walletNetwork != null && n !== walletNetwork; + return ( + handleSelect(n)} + className="cursor-pointer flex items-center gap-2" + role="menuitemradio" + aria-checked={isSelected} + > +
+ {n + {isMismatched && ( + + ! + + )} + + ); + })} + + + ); +}