From 38872d8743acc5b3a4171c2e0ba5269109ce94cf Mon Sep 17 00:00:00 2001 From: keem-hyun Date: Wed, 10 Sep 2025 00:46:38 +0900 Subject: [PATCH] feat: support monad testnet --- app/agreement/[id]/page.tsx | 29 +++--- app/components/AgreementList.tsx | 9 +- app/components/CreateAgreement.tsx | 109 ++++++++++++++++--- app/page.tsx | 10 +- app/providers.tsx | 3 +- components/BetModal.tsx | 34 ++++-- components/DebateCard.tsx | 10 +- components/Header.tsx | 48 +++++---- components/LiveDebateBottomSheet.tsx | 10 +- components/NetworkSelector.tsx | 150 +++++++++++++++++++++++++++ components/NetworkSwitchModal.tsx | 31 +++--- lib/agreementFactoryABI.ts | 20 ++++ lib/hooks/useEnsureChain.ts | 32 ++++-- lib/hooks/useMultiChainSwitch.ts | 86 +++++++++++++++ lib/utils/customChains.ts | 108 +++++++++++++++++++ lib/utils/network.ts | 39 +++++-- public/assets/icons/monad_logo.svg | 3 + types/ethereum.d.ts | 13 +++ 18 files changed, 649 insertions(+), 95 deletions(-) create mode 100644 components/NetworkSelector.tsx create mode 100644 lib/hooks/useMultiChainSwitch.ts create mode 100644 lib/utils/customChains.ts create mode 100644 public/assets/icons/monad_logo.svg create mode 100644 types/ethereum.d.ts diff --git a/app/agreement/[id]/page.tsx b/app/agreement/[id]/page.tsx index bb7f29e..0e982f1 100644 --- a/app/agreement/[id]/page.tsx +++ b/app/agreement/[id]/page.tsx @@ -2,11 +2,11 @@ import { useParams, useRouter } from "next/navigation"; import { useState, useEffect, useRef } from "react"; -import { useAccount, useReadContract, useWriteContract, useWaitForTransactionReceipt, useConfig, useBalance } from "wagmi"; +import { useAccount, useReadContract, useWriteContract, useWaitForTransactionReceipt, useConfig, useBalance, useChainId } from "wagmi"; import { baseSepolia } from "wagmi/chains"; import { parseEther, formatEther } from "viem"; import { readContract } from "@wagmi/core"; -import { AGREEMENT_FACTORY_ADDRESS, AGREEMENT_FACTORY_ABI } from "@/lib/agreementFactoryABI"; +import { getAgreementFactoryAddress, AGREEMENT_FACTORY_ABI } from "@/lib/agreementFactoryABI"; import { BettingOptions } from "@/components/BettingOptions"; import { CountdownTimer } from "@/components/CountdownTimer"; import { ResultSection } from "@/components/ResultSection"; @@ -26,7 +26,8 @@ export default function AgreementDetailPage() { const contractId = parseInt(params.id as string); const { isConnected, address } = useAccount(); - const { data: balanceData } = useBalance({ address, chainId: baseSepolia.id, query: { enabled: !!address } }); + const chainId = useChainId(); + const { data: balanceData } = useBalance({ address, chainId, query: { enabled: !!address } }); const { isCorrectChain, isSwitching, needsSwitch, ensureCorrectChain } = useEnsureChain(); // Auto switch to Base Sepolia // Toasts removed const [betError, setBetError] = useState<{ title: string; details: string[] } | null>(null); @@ -58,7 +59,7 @@ export default function AgreementDetailPage() { // Contract data const { data: contractData, refetch: refetchContract } = useReadContract({ - address: AGREEMENT_FACTORY_ADDRESS as `0x${string}`, + address: getAgreementFactoryAddress(chainId) as `0x${string}`, abi: AGREEMENT_FACTORY_ABI, functionName: "getContract", args: [BigInt(contractId)], @@ -66,7 +67,7 @@ export default function AgreementDetailPage() { // Comments data const { data: commentsData, refetch: refetchComments } = useReadContract({ - address: AGREEMENT_FACTORY_ADDRESS as `0x${string}`, + address: getAgreementFactoryAddress(chainId) as `0x${string}`, abi: AGREEMENT_FACTORY_ABI, functionName: "getComments", args: [BigInt(contractId), BigInt(0), BigInt(50)], @@ -74,7 +75,7 @@ export default function AgreementDetailPage() { // Check if user has bet const { data: hasUserBet, refetch: refetchUserBet } = useReadContract({ - address: AGREEMENT_FACTORY_ADDRESS as `0x${string}`, + address: getAgreementFactoryAddress(chainId) as `0x${string}`, abi: AGREEMENT_FACTORY_ABI, functionName: "hasUserBet", args: address ? [BigInt(contractId), address] : undefined, @@ -111,7 +112,7 @@ export default function AgreementDetailPage() { for (const commenter of uniqueCommenters) { try { const result = await readContract(config, { - address: AGREEMENT_FACTORY_ADDRESS as `0x${string}`, + address: getAgreementFactoryAddress(chainId) as `0x${string}`, abi: AGREEMENT_FACTORY_ABI, functionName: "getUserBetsPaginated", args: [BigInt(contractId), commenter as `0x${string}`, BigInt(0), BigInt(1)], @@ -183,7 +184,7 @@ export default function AgreementDetailPage() { try { if (!address) return; const result = await readContract(config, { - address: AGREEMENT_FACTORY_ADDRESS as `0x${string}`, + address: getAgreementFactoryAddress(chainId) as `0x${string}`, abi: AGREEMENT_FACTORY_ABI, functionName: "getUserBetsPaginated", args: [BigInt(contractId), address as `0x${string}`, BigInt(0), BigInt(1)], @@ -355,12 +356,12 @@ export default function AgreementDetailPage() { }); await writeContract({ - address: AGREEMENT_FACTORY_ADDRESS as `0x${string}`, + address: getAgreementFactoryAddress(chainId) as `0x${string}`, abi: AGREEMENT_FACTORY_ABI, functionName: "simpleBet", args: [BigInt(contractId), selectedSide], value: amount, - chainId: baseSepolia.id, + chainId, }); } catch (err) { @@ -431,11 +432,11 @@ export default function AgreementDetailPage() { status: (contract?.status === 0 ? "open" : contract?.status === 1 ? "closed" : "resolved"), }); await writeContract({ - address: AGREEMENT_FACTORY_ADDRESS as `0x${string}`, + address: getAgreementFactoryAddress(chainId) as `0x${string}`, abi: AGREEMENT_FACTORY_ABI, functionName: "addComment", args: [BigInt(contractId), comment.trim()], - chainId: baseSepolia.id, + chainId, }); } catch (err) { console.error("Error adding comment:", err); @@ -559,7 +560,7 @@ export default function AgreementDetailPage() { contractId, endedAt: new Date().toISOString(), bettingEndTime: Number(contract.bettingEndTime ?? 0), - chainId: baseSepolia.id, + chainId, }), cache: 'no-store', keepalive: true, @@ -727,6 +728,7 @@ export default function AgreementDetailPage() { comment={comment} setComment={setComment} onSubmitComment={handleComment} + chainId={chainId} onBetClick={() => { trackBetEvent(EVENTS.TRANSACTION_INITIATED, { debate_id: String(contractId), @@ -765,6 +767,7 @@ export default function AgreementDetailPage() { onBet={handleBet} isPending={isPending} isConfirming={isConfirming} + chainId={chainId} isCorrectChain={isCorrectChain} isSwitching={isSwitching} needsSwitch={needsSwitch} diff --git a/app/components/AgreementList.tsx b/app/components/AgreementList.tsx index 5bab88c..2676bd9 100644 --- a/app/components/AgreementList.tsx +++ b/app/components/AgreementList.tsx @@ -1,8 +1,8 @@ "use client"; import { useRouter } from "next/navigation"; -import { useReadContract, useReadContracts } from "wagmi"; -import { AGREEMENT_FACTORY_ADDRESS, AGREEMENT_FACTORY_ABI } from "@/lib/agreementFactoryABI"; +import { useReadContract, useReadContracts, useChainId } from "wagmi"; +import { getAgreementFactoryAddress, AGREEMENT_FACTORY_ABI } from "@/lib/agreementFactoryABI"; interface AgreementContract { id: number; @@ -145,9 +145,10 @@ function AgreementCard({ contract, onSelect }: AgreementCardProps) { export function AgreementList() { const router = useRouter(); + const chainId = useChainId(); const { data: contractCounter } = useReadContract({ - address: AGREEMENT_FACTORY_ADDRESS as `0x${string}`, + address: getAgreementFactoryAddress(chainId) as `0x${string}`, abi: AGREEMENT_FACTORY_ABI, functionName: "contractCounter", }); @@ -159,7 +160,7 @@ export function AgreementList() { const contractReads = []; for (let i = startIndex; i < count; i++) { contractReads.push({ - address: AGREEMENT_FACTORY_ADDRESS as `0x${string}`, + address: getAgreementFactoryAddress(chainId) as `0x${string}`, abi: AGREEMENT_FACTORY_ABI, functionName: 'getContract' as const, args: [BigInt(i)], diff --git a/app/components/CreateAgreement.tsx b/app/components/CreateAgreement.tsx index 4bde03d..44fc94f 100644 --- a/app/components/CreateAgreement.tsx +++ b/app/components/CreateAgreement.tsx @@ -1,19 +1,46 @@ "use client"; import { useState, useCallback, useEffect } from "react"; -import { useAccount, useWriteContract, useWaitForTransactionReceipt } from "wagmi"; +import { useAccount, useWriteContract, useWaitForTransactionReceipt, useChainId, useConfig, useReconnect } from "wagmi"; +import { getChainId } from "wagmi/actions"; import { baseSepolia } from "wagmi/chains"; import { parseEther } from "viem"; -import { AGREEMENT_FACTORY_ADDRESS, AGREEMENT_FACTORY_ABI } from "@/lib/agreementFactoryABI"; +import { getAgreementFactoryAddress, AGREEMENT_FACTORY_ABI } from "@/lib/agreementFactoryABI"; import { useRouter } from "next/navigation"; import { useAnalytics } from "@/lib/hooks/useAnalytics"; import { EVENTS } from "@/lib/analytics"; import { useEnsureChain } from "@/lib/hooks/useEnsureChain"; +import { monadTestnet } from "@/lib/utils/customChains"; // Toasts removed export function CreateAgreement() { - const { address, isConnected } = useAccount(); + const { address, isConnected, chain } = useAccount(); + const chainId = useChainId(); + const config = useConfig(); + const { reconnect } = useReconnect(); const { isCorrectChain, isSwitching, needsSwitch, ensureCorrectChain } = useEnsureChain(); // Auto switch to Base Sepolia + + // Debug logging + console.log("useAccount chain:", chain?.id); + console.log("useChainId:", chainId); + + // Check for potential state mismatch early + let configChainId; + try { + configChainId = getChainId(config); + console.log("Early config chainId check:", configChainId); + } catch (error) { + console.log("Early config chainId error:", error); + configChainId = chainId; + } + + // Use the actual connected chain ID from the account, fallback to useChainId() + const actualChainId = chain?.id ?? chainId; + + // Detect mismatch early and show warning + const hasChainMismatch = configChainId !== actualChainId; + console.log("Chain mismatch detected:", hasChainMismatch, "Config:", configChainId, "Hook:", actualChainId); + // Toasts removed const router = useRouter(); const [topic, setTopic] = useState(""); @@ -25,6 +52,9 @@ export function CreateAgreement() { const [minBet, setMinBet] = useState("0.0002"); const [maxBet, setMaxBet] = useState("0.1"); // Betting end time input removed; default duration is fixed (24h) + + // Determine currency symbol based on chain + const currencySymbol = actualChainId === monadTestnet.id ? "MON" : "ETH"; useEffect(() => { // Short delay to ensure smooth transition @@ -144,7 +174,7 @@ export function CreateAgreement() { return; } if (minWei < MIN_ALLOWED) { - alert(`Minimum bet must be at least 0.0002 ETH`); + alert(`Minimum bet must be at least 0.0002 ${currencySymbol}`); return; } if (maxWei <= BigInt(0)) { @@ -152,7 +182,7 @@ export function CreateAgreement() { return; } if (maxWei > MAX_ALLOWED) { - alert(`Maximum bet cannot exceed 100 ETH`); + alert(`Maximum bet cannot exceed 100 ${currencySymbol}`); return; } if (minWei > maxWei) { @@ -160,7 +190,7 @@ export function CreateAgreement() { return; } } catch { - alert("Please enter valid ETH amounts"); + alert(`Please enter valid ${currencySymbol} amounts`); return; } @@ -172,10 +202,42 @@ export function CreateAgreement() { const ok = await ensureCorrectChain(); if (!ok) { return; } + console.log("Current chainId from useChainId:", chainId); + console.log("Current chainId from useAccount:", chain?.id); + console.log("Using actualChainId:", actualChainId); + console.log("Contract address:", getAgreementFactoryAddress(actualChainId)); + + // Double-check the actual chain from config + let configChainId; + try { + configChainId = getChainId(config); + console.log("Config chainId:", configChainId); + } catch (error) { + console.error("Failed to get config chain ID:", error); + configChainId = actualChainId; + } + + // If there's a mismatch between wagmi state and config, show error and ask user to refresh + if (configChainId && configChainId !== actualChainId) { + console.log("Chain mismatch detected! Config:", configChainId, "vs Hook:", actualChainId); + + alert(`Network sync issue detected.\n\nWallet connector: Chain ${configChainId}\nApp state: Chain ${actualChainId}\n\nPlease refresh the page to sync your wallet properly.`); + return; + } + + // Use the actual chain ID from the wallet for consistency + const transactionChainId = actualChainId; + // Track create button click (page view again optional) trackPageView('create_submit'); + + console.log("Transaction details:", { + address: getAgreementFactoryAddress(transactionChainId), + chainId: transactionChainId + }); + await writeContract({ - address: AGREEMENT_FACTORY_ADDRESS as `0x${string}`, + address: getAgreementFactoryAddress(transactionChainId) as `0x${string}`, abi: AGREEMENT_FACTORY_ABI, functionName: "createContract", args: [ @@ -187,7 +249,7 @@ export function CreateAgreement() { minWei, // User-defined min bet (validated) maxWei // User-defined max bet (validated) ], - chainId: baseSepolia.id, + chainId: transactionChainId, }); } catch (err) { // Check if user rejected the transaction @@ -333,7 +395,7 @@ export function CreateAgreement() {
- +
- + Min bet must be at least 0.0002 ETH.

; + if (minV < MIN_ALLOWED) return

Min bet must be at least 0.0002 {currencySymbol}.

; if (maxV <= BigInt(0)) return

Max bet must be greater than 0.

; - if (maxV > MAX_ALLOWED) return

Max bet cannot exceed 100 ETH.

; - if (maxV < minV) return

Max bet must be at least {minBet} ETH.

; + if (maxV > MAX_ALLOWED) return

Max bet cannot exceed 100 {currencySymbol}.

; + if (maxV < minV) return

Max bet must be at least {minBet} {currencySymbol}.

; return null; } catch { - return

Enter valid ETH amounts (e.g., 0.001).

; + return

Enter valid {currencySymbol} amounts (e.g., 0.001).

; } })()}
@@ -400,7 +462,7 @@ export function CreateAgreement() {
)} diff --git a/app/page.tsx b/app/page.tsx index 94c5880..212bf12 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,10 +3,10 @@ import { useMiniKit } from "@coinbase/onchainkit/minikit"; import { useEffect, useMemo } from "react"; import { useRouter } from "next/navigation"; -import { useReadContract, useReadContracts } from "wagmi"; +import { useReadContract, useReadContracts, useChainId } from "wagmi"; import { Header } from "@/components/Header"; import { DebateCard } from "@/components/DebateCard"; -import { AGREEMENT_FACTORY_ADDRESS, AGREEMENT_FACTORY_ABI } from "@/lib/agreementFactoryABI"; +import { getAgreementFactoryAddress, AGREEMENT_FACTORY_ABI } from "@/lib/agreementFactoryABI"; import { useAnalytics } from "@/lib/hooks/useAnalytics"; import { EVENTS } from "@/lib/analytics"; @@ -32,11 +32,12 @@ interface AgreementContract { export default function App() { const { setFrameReady, isFrameReady } = useMiniKit(); const router = useRouter(); + const chainId = useChainId(); const { trackPageView, trackDebateEvent } = useAnalytics(); // Read contract count const { data: agreementCountData } = useReadContract({ - address: AGREEMENT_FACTORY_ADDRESS as `0x${string}`, + address: getAgreementFactoryAddress(chainId) as `0x${string}`, abi: AGREEMENT_FACTORY_ABI, functionName: "contractCounter", }); @@ -55,7 +56,7 @@ export default function App() { // Read all agreements const { data: agreementsData, isLoading } = useReadContracts({ contracts: agreementIds.map((id) => ({ - address: AGREEMENT_FACTORY_ADDRESS as `0x${string}`, + address: getAgreementFactoryAddress(chainId) as `0x${string}`, abi: AGREEMENT_FACTORY_ABI, functionName: "contracts", args: [BigInt(id)], @@ -207,6 +208,7 @@ export default function App() { creator={agreement.creator} totalVolume={agreement.totalPoolA + agreement.totalPoolB} timeRemaining={formatTimeRemaining(agreement.bettingEndTime)} + chainId={chainId} onClick={() => { trackDebateEvent(EVENTS.DEBATE_CARD_CLICKED, { debate_id: agreement.id.toString(), diff --git a/app/providers.tsx b/app/providers.tsx index ed8c55a..f40290a 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -13,6 +13,7 @@ import { } from "@rainbow-me/rainbowkit"; import "@rainbow-me/rainbowkit/styles.css"; import { AnalyticsProvider } from "@/components/AnalyticsProvider"; +import { monadTestnet } from "@/lib/utils/customChains"; // Create query client const queryClient = new QueryClient(); @@ -25,7 +26,7 @@ const { wallets } = getDefaultWallets(); const wagmiConfig = getDefaultConfig({ appName: process.env.NEXT_PUBLIC_ONCHAINKIT_PROJECT_NAME || "Agora", projectId: process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID || "YOUR_PROJECT_ID", // Required for WalletConnect - chains: [baseSepolia], // Only Base Sepolia supported + chains: [baseSepolia, monadTestnet], // Base Sepolia and Monad testnet supported wallets, ssr: false, // Disable SSR for client-side wallet connections }); diff --git a/components/BetModal.tsx b/components/BetModal.tsx index 764082a..da40b9a 100644 --- a/components/BetModal.tsx +++ b/components/BetModal.tsx @@ -26,6 +26,7 @@ interface BetModalProps { insufficientBalance?: boolean; minBet?: string; maxBet?: string; + chainId?: number; } export function BetModal({ @@ -52,8 +53,21 @@ export function BetModal({ insufficientBalance = false, minBet = "0.0002", maxBet = "100", + chainId, }: BetModalProps) { const [showAmountStep, setShowAmountStep] = useState(false); + + const getCurrencySymbol = () => { + if (chainId === 84532) return "ETH"; + if (chainId === 10143) return "MON"; + return "ETH"; + }; + + const getNetworkName = () => { + if (chainId === 84532) return "Base Sepolia"; + if (chainId === 10143) return "Monad Testnet"; + return "Base Sepolia"; + }; if (!isOpen) return null; @@ -181,7 +195,7 @@ export function BetModal({ {/* To Win Display */}
To Win : - {calculateWinAmount()} ETH + {calculateWinAmount()} {getCurrencySymbol()} {!hasOppositeBets() && (
Actual winnings may vary based on final odds @@ -211,9 +225,9 @@ export function BetModal({ min={minBet} max={maxBet} /> -
ETH
+
{getCurrencySymbol()}
- Min: {minBet} ETH | Max: {maxBet} ETH + Min: {minBet} {getCurrencySymbol()} | Max: {maxBet} {getCurrencySymbol()}
@@ -231,7 +245,7 @@ export function BetModal({ {needsSwitch && (

- Please switch to Base Sepolia network to place bets + Please switch to {getNetworkName()} network to place bets

)} @@ -246,7 +260,7 @@ export function BetModal({ return (

- Minimum bet is {minBet} ETH + Minimum bet is {minBet} {getCurrencySymbol()}

); @@ -256,7 +270,7 @@ export function BetModal({ return (

- Maximum bet is {maxBet} ETH + Maximum bet is {maxBet} {getCurrencySymbol()}

); @@ -266,7 +280,7 @@ export function BetModal({ return (

- Insufficient ETH balance (need {betAmount} ETH + gas fees) + Insufficient {getCurrencySymbol()} balance (need {betAmount} {getCurrencySymbol()} + gas fees)

); @@ -299,14 +313,14 @@ export function BetModal({ if (isSwitching) return "Switching Network..."; if (isPending || isConfirming) return "Processing..."; if (needsSwitch) return "Wrong Network"; - if (insufficientBalance) return "Insufficient ETH"; + if (insufficientBalance) return `Insufficient ${getCurrencySymbol()}`; const amount = parseFloat(betAmount) || 0; const min = parseFloat(minBet); const max = parseFloat(maxBet); - if (amount < min && betAmount !== '') return `Min ${minBet} ETH`; - if (amount > max) return `Max ${maxBet} ETH`; + if (amount < min && betAmount !== '') return `Min ${minBet} ${getCurrencySymbol()}`; + if (amount > max) return `Max ${maxBet} ${getCurrencySymbol()}`; if (!isValidAmount()) return "Invalid Amount"; return "Bet"; diff --git a/components/DebateCard.tsx b/components/DebateCard.tsx index a05cbaf..e697c50 100644 --- a/components/DebateCard.tsx +++ b/components/DebateCard.tsx @@ -31,6 +31,7 @@ interface DebateCardProps { }; image?: string; onClick?: () => void; + chainId?: number; } export function DebateCard({ @@ -44,10 +45,17 @@ export function DebateCard({ totalVolume, image, onClick, + chainId, }: DebateCardProps) { const totalVotes = (option1.votes || 0) + (option2.votes || 0); const option1Percentage = totalVotes > 0 ? ((option1.votes || 0) / totalVotes) * 100 : 50; const option2Percentage = totalVotes > 0 ? ((option2.votes || 0) / totalVotes) * 100 : 50; + + const getCurrencySymbol = (chainId?: number) => { + if (chainId === 84532) return "ETH"; + if (chainId === 10143) return "MON"; + return "ETH"; + }; return (
- Vol. {parseFloat(formatEther(totalVolume)).toFixed(3)} ETH + Vol. {parseFloat(formatEther(totalVolume)).toFixed(3)} {getCurrencySymbol(chainId)} )} diff --git a/components/Header.tsx b/components/Header.tsx index ddf0630..881d10b 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -5,6 +5,7 @@ import { ConnectButton } from "@rainbow-me/rainbowkit"; import Link from "next/link"; import { useEnsureChain } from "@/lib/hooks/useEnsureChain"; import { NetworkSwitchModal } from "./NetworkSwitchModal"; +import { monadTestnet } from "@/lib/utils/customChains"; export function Header() { const { @@ -96,25 +97,34 @@ export function Header() { type="button" className="px-2 py-1 text-xs sm:text-sm bg-transparent border border-primary text-primary hover:bg-primary hover:text-gray-1000 rounded-lg transition-colors" > - {chain.hasIcon && ( -
- {chain.iconUrl && ( - {chain.name - )} -
+ {/* Show Monad logo for Monad testnet */} + {chain.id === monadTestnet.id ? ( + Monad logo + ) : ( + chain.hasIcon && ( +
+ {chain.iconUrl && ( + {chain.name + )} +
+ ) )} {chain.name} diff --git a/components/LiveDebateBottomSheet.tsx b/components/LiveDebateBottomSheet.tsx index 22ac489..77e204b 100644 --- a/components/LiveDebateBottomSheet.tsx +++ b/components/LiveDebateBottomSheet.tsx @@ -21,6 +21,7 @@ interface LiveDebateBottomSheetProps { isCorrectChain: boolean; isSwitching: boolean; needsSwitch: boolean; + chainId?: number; } export function LiveDebateBottomSheet({ @@ -40,9 +41,16 @@ export function LiveDebateBottomSheet({ isCorrectChain, isSwitching, needsSwitch, + chainId, }: LiveDebateBottomSheetProps) { if (!isOpen) return null; + const getNetworkName = (chainId?: number) => { + if (chainId === 84532) return "Base Sepolia"; + if (chainId === 10143) return "Monad Testnet"; + return "Base Sepolia"; + }; + return (

- Switch to Base Sepolia network to comment + Switch to {getNetworkName(chainId)} network to comment

)} diff --git a/components/NetworkSelector.tsx b/components/NetworkSelector.tsx new file mode 100644 index 0000000..77eb331 --- /dev/null +++ b/components/NetworkSelector.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useState } from "react"; +import { useChainId } from "wagmi"; +import { baseSepolia } from "wagmi/chains"; +import { monadTestnet } from "@/lib/utils/customChains"; +import { useMultiChainSwitch } from "@/lib/hooks/useMultiChainSwitch"; +import { getNetworkShortName } from "@/lib/utils/network"; + +const supportedChains = [ + { id: monadTestnet.id, name: "Monad Testnet", icon: null, logo: "/assets/icons/monad_logo.svg" }, +]; + +export function NetworkSelector() { + const chainId = useChainId(); + const [isOpen, setIsOpen] = useState(false); + const { switchToChain, isAddingChain, error, clearError } = useMultiChainSwitch(); + + const currentChain = supportedChains.find(c => c.id === chainId); + const isSupported = !!currentChain; + + const handleChainSelect = async (targetChainId: number) => { + if (targetChainId === chainId) { + setIsOpen(false); + return; + } + + const success = await switchToChain(targetChainId); + if (success) { + setIsOpen(false); + } + }; + + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+
+
Select Network
+ {supportedChains.map((chain) => ( + + ))} +
+ + {error && ( +
+
+ + + +
+

{error}

+ +
+
+
+ )} + + {isAddingChain && ( +
+
+ + + + + Adding network to wallet... +
+
+ )} +
+ + )} +
+ ); +} \ No newline at end of file diff --git a/components/NetworkSwitchModal.tsx b/components/NetworkSwitchModal.tsx index c6d4bf0..c41364b 100644 --- a/components/NetworkSwitchModal.tsx +++ b/components/NetworkSwitchModal.tsx @@ -61,23 +61,26 @@ export function NetworkSwitchModal({ {/* Content */}

- Please switch to Base Sepolia network to continue using this application. + Please switch to a supported network to continue using this application.

{/* Network Details */}
-
-
- Network Name: - Base Sepolia -
-
- Chain ID: - 84532 (0x14a34) -
-
- RPC URL: - https://sepolia.base.org +
+

Supported Networks:

+ +
+
+
Base Sepolia
+
Chain ID: 84532 (0x14a34)
+
✓ Natively supported by wallet
+
+ +
+
Monad Testnet
+
Chain ID: 10143 (0x279f)
+
Custom network (will be added automatically)
+
@@ -125,7 +128,7 @@ export function NetworkSwitchModal({ Switching... ) : ( - "Switch to Base Sepolia" + "Switch Network" )} )} diff --git a/lib/agreementFactoryABI.ts b/lib/agreementFactoryABI.ts index 8df9c87..7f2e66b 100644 --- a/lib/agreementFactoryABI.ts +++ b/lib/agreementFactoryABI.ts @@ -1,3 +1,23 @@ +import { baseSepolia } from "wagmi/chains"; +import { monadTestnet } from "./utils/customChains"; + +// Chain-specific contract addresses +export const getAgreementFactoryAddress = (chainId?: number): string => { + if (!chainId) { + throw new Error("Chain ID is required to get contract address"); + } + + switch (chainId) { + case baseSepolia.id: + return process.env.NEXT_PUBLIC_AGREEMENT_FACTORY_ADDRESS as string; + case monadTestnet.id: + return process.env.NEXT_PUBLIC_AGREEMENT_FACTORY_ADDRESS_MONAD as string; + default: + throw new Error(`Unsupported chain ID: ${chainId}`); + } +}; + +// Legacy export for backward compatibility export const AGREEMENT_FACTORY_ADDRESS = process.env.NEXT_PUBLIC_AGREEMENT_FACTORY_ADDRESS as string; export const AGREEMENT_FACTORY_ABI = [ diff --git a/lib/hooks/useEnsureChain.ts b/lib/hooks/useEnsureChain.ts index 711a31b..95b8c98 100644 --- a/lib/hooks/useEnsureChain.ts +++ b/lib/hooks/useEnsureChain.ts @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from 'react'; import { useChainId, useSwitchChain, useConfig } from 'wagmi'; import { getChainId } from 'wagmi/actions'; import { baseSepolia } from 'wagmi/chains'; +import { monadTestnet } from '@/lib/utils/customChains'; interface EnsureChainState { isCorrectChain: boolean; @@ -26,8 +27,16 @@ export function useEnsureChain(): EnsureChainState { const [autoSwitchEnabled, setAutoSwitchEnabledState] = useState(true); const PREF_KEY = 'agora:autoSwitchToSepolia'; - const isCorrectChain = chainId === baseSepolia.id; + // Allow both Base Sepolia and Monad testnet + const isCorrectChain = chainId === baseSepolia.id || chainId === monadTestnet.id; const needsSwitch = chainId !== undefined && !isCorrectChain; + + // Debug logging + console.log("useEnsureChain - chainId:", chainId); + console.log("useEnsureChain - baseSepolia.id:", baseSepolia.id); + console.log("useEnsureChain - monadTestnet.id:", monadTestnet.id); + console.log("useEnsureChain - isCorrectChain:", isCorrectChain); + console.log("useEnsureChain - needsSwitch:", needsSwitch); const switchNetwork = useCallback(async () => { if (!switchChain || !chainId) return; @@ -35,14 +44,16 @@ export function useEnsureChain(): EnsureChainState { try { setIsSwitching(true); setError(null); - console.log(`Switching from chain ${chainId} to Base Sepolia (${baseSepolia.id})`); - await switchChain({ chainId: baseSepolia.id }); + // Default to Base Sepolia when switching networks + const targetChain = baseSepolia.id; + console.log(`Switching from chain ${chainId} to Base Sepolia (${targetChain})`); + await switchChain({ chainId: targetChain }); // Wait until the client actually reports the new chain id const start = Date.now(); while (Date.now() - start < 7000) { try { const current = getChainId(config); - if (current === baseSepolia.id) break; + if (current === targetChain) break; } catch {} await new Promise((r) => setTimeout(r, 150)); } @@ -67,10 +78,10 @@ export function useEnsureChain(): EnsureChainState { const ensureCorrectChain = useCallback(async (): Promise => { if (isCorrectChain) return true; await switchNetwork(); - // Double check via config + // Double check via config - accept both supported chains try { const current = getChainId(config); - return current === baseSepolia.id; + return current === baseSepolia.id || current === monadTestnet.id; } catch { return false; } @@ -88,7 +99,16 @@ export function useEnsureChain(): EnsureChainState { // Auto-switch only once when component mounts and chain is detected (if enabled) useEffect(() => { + console.log("useEnsureChain auto-switch check:", { + needsSwitch, + autoSwitchEnabled, + autoSwitchAttempted, + isSwitching, + willSwitch: needsSwitch && autoSwitchEnabled && !autoSwitchAttempted && !isSwitching + }); + if (needsSwitch && autoSwitchEnabled && !autoSwitchAttempted && !isSwitching) { + console.log("useEnsureChain: Triggering auto-switch from", chainId, "to Base Sepolia"); setAutoSwitchAttempted(true); switchNetwork(); } diff --git a/lib/hooks/useMultiChainSwitch.ts b/lib/hooks/useMultiChainSwitch.ts new file mode 100644 index 0000000..eabdafa --- /dev/null +++ b/lib/hooks/useMultiChainSwitch.ts @@ -0,0 +1,86 @@ +import { useCallback, useState } from 'react'; +import { useChainId, useSwitchChain } from 'wagmi'; +import { baseSepolia } from 'wagmi/chains'; +import { monadTestnet, addChainToWallet } from '@/lib/utils/customChains'; + +export function useMultiChainSwitch() { + const chainId = useChainId(); + const { switchChain } = useSwitchChain(); + const [isAddingChain, setIsAddingChain] = useState(false); + const [error, setError] = useState(null); + + const switchToChain = useCallback(async (targetChainId: number) => { + if (!switchChain) { + setError('Wallet not connected'); + return false; + } + + setError(null); + + try { + // First, try to switch using wagmi + await switchChain({ chainId: targetChainId }); + return true; + } catch (wagmiError: any) { + console.log('Wagmi switch failed, trying direct MetaMask approach:', wagmiError); + + // If wagmi fails, try adding the chain to MetaMask first + if (targetChainId === monadTestnet.id && window.ethereum) { + try { + setIsAddingChain(true); + + // Try to switch first + const chainIdHex = `0x${targetChainId.toString(16)}`; + try { + await window.ethereum.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: chainIdHex }], + }); + return true; + } catch (switchError: any) { + // If chain doesn't exist (error code 4902), add it + if (switchError.code === 4902) { + await addChainToWallet(monadTestnet); + + // Try switching again after adding + await window.ethereum.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: chainIdHex }], + }); + return true; + } + throw switchError; + } + } catch (addError: any) { + console.error('Failed to add/switch to Monad testnet:', addError); + setError(addError.message || 'Failed to add Monad testnet to wallet'); + return false; + } finally { + setIsAddingChain(false); + } + } + + // For other chains or if no ethereum object + setError(wagmiError.message || 'Failed to switch network'); + return false; + } + }, [switchChain]); + + const switchToBaseSepolia = useCallback(() => { + return switchToChain(baseSepolia.id); + }, [switchToChain]); + + const switchToMonadTestnet = useCallback(() => { + return switchToChain(monadTestnet.id); + }, [switchToChain]); + + return { + currentChainId: chainId, + switchToChain, + switchToBaseSepolia, + switchToMonadTestnet, + isAddingChain, + error, + clearError: () => setError(null), + }; +} \ No newline at end of file diff --git a/lib/utils/customChains.ts b/lib/utils/customChains.ts new file mode 100644 index 0000000..14c148a --- /dev/null +++ b/lib/utils/customChains.ts @@ -0,0 +1,108 @@ +import { type Chain } from 'wagmi/chains'; + +// Define Monad testnet chain +export const monadTestnet: Chain = { + id: 10143, + name: "Monad Testnet", + nativeCurrency: { + decimals: 18, + name: "Monad", + symbol: "MON", + }, + rpcUrls: { + default: { + http: ["https://testnet-rpc.monad.xyz"], + webSocket: ["wss://testnet-rpc.monad.xyz"], + }, + public: { + http: ["https://testnet-rpc.monad.xyz"], + webSocket: ["wss://testnet-rpc.monad.xyz"], + }, + }, + blockExplorers: { + default: { + name: "Monad Explorer", + url: "https://explorer.testnet.monad.xyz", + }, + }, + testnet: true, +}; + +/** + * Add a custom chain to MetaMask + */ +export async function addChainToWallet(chain: Chain) { + if (!window.ethereum) { + throw new Error('MetaMask is not installed'); + } + + try { + // Prepare chain parameters for MetaMask + const params = { + chainId: `0x${chain.id.toString(16)}`, + chainName: chain.name, + nativeCurrency: { + name: chain.nativeCurrency.name, + symbol: chain.nativeCurrency.symbol, + decimals: chain.nativeCurrency.decimals, + }, + rpcUrls: chain.rpcUrls.default.http, + blockExplorerUrls: chain.blockExplorers ? [chain.blockExplorers.default.url] : [], + }; + + console.log('Adding chain to wallet with params:', params); + + await window.ethereum.request({ + method: 'wallet_addEthereumChain', + params: [params], + }); + } catch (error: any) { + console.error('Error adding chain to wallet:', error); + // Error code 4001 means user rejected the request + if (error.code === 4001) { + throw new Error('User rejected the request to add the network'); + } + // Error code -32602 means invalid parameters + if (error.code === -32602) { + throw new Error('Invalid network parameters. Please check the RPC URL and try again.'); + } + throw error; + } +} + +/** + * Switch to a chain, adding it first if necessary + */ +export async function switchToChain(chainId: number) { + if (!window.ethereum) { + throw new Error('MetaMask is not installed'); + } + + const chainIdHex = `0x${chainId.toString(16)}`; + + try { + // Try to switch to the chain + await window.ethereum.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: chainIdHex }], + }); + } catch (error: any) { + // Error code 4902 means chain is not added to MetaMask + if (error.code === 4902) { + // Find the chain configuration + const chain = chainId === monadTestnet.id ? monadTestnet : null; + if (chain) { + await addChainToWallet(chain); + // Try switching again after adding + await window.ethereum.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: chainIdHex }], + }); + } else { + throw new Error(`Unknown chain ID: ${chainId}`); + } + } else { + throw error; + } + } +} \ No newline at end of file diff --git a/lib/utils/network.ts b/lib/utils/network.ts index 85c3be6..38f3b75 100644 --- a/lib/utils/network.ts +++ b/lib/utils/network.ts @@ -8,6 +8,9 @@ export interface NetworkInfo { blockExplorer: string; } +// Monad testnet chain ID +const MONAD_TESTNET_ID = 10143; + /** * Convert chain ID to hexadecimal format */ @@ -28,6 +31,19 @@ export function getBaseSepolia(): NetworkInfo { }; } +/** + * Get Monad testnet network information + */ +export function getMonadTestnet(): NetworkInfo { + return { + id: MONAD_TESTNET_ID, + name: 'Monad Testnet', + hexId: chainIdToHex(MONAD_TESTNET_ID), + rpcUrl: 'https://testnet-rpc.monad.xyz', + blockExplorer: 'https://explorer.testnet.monad.xyz' + }; +} + /** * Check if the given chain ID is Base Sepolia */ @@ -35,9 +51,16 @@ export function isBaseSepolia(chainId: number | undefined): boolean { return chainId === baseSepolia.id; } +/** + * Check if the given chain ID is Monad testnet + */ +export function isMonadTestnet(chainId: number | undefined): boolean { + return chainId === MONAD_TESTNET_ID; +} + /** * Get network name by chain ID - * This app only supports Base Sepolia + * This app supports Base Sepolia and Monad testnet */ export function getNetworkName(chainId: number | undefined): string { if (!chainId) return 'Unknown Network'; @@ -45,6 +68,8 @@ export function getNetworkName(chainId: number | undefined): string { switch (chainId) { case 84532: return 'Base Sepolia'; + case MONAD_TESTNET_ID: + return 'Monad Testnet'; // Legacy support for display purposes only - app doesn't support these networks case 1: return 'Ethereum Mainnet (Unsupported)'; @@ -57,13 +82,13 @@ export function getNetworkName(chainId: number | undefined): string { /** * Check if a network is a testnet - * This app only supports Base Sepolia (testnet) + * This app supports Base Sepolia and Monad testnets */ export function isTestnet(chainId: number | undefined): boolean { if (!chainId) return false; - // Only Base Sepolia is supported as the testnet - return chainId === 84532; + // Base Sepolia and Monad testnet are supported + return chainId === 84532 || chainId === MONAD_TESTNET_ID; } /** @@ -72,7 +97,7 @@ export function isTestnet(chainId: number | undefined): boolean { export function getNetworkStatusColor(chainId: number | undefined): string { if (!chainId) return 'text-gray-400'; - if (isBaseSepolia(chainId)) { + if (isBaseSepolia(chainId) || isMonadTestnet(chainId)) { return 'text-green-400'; } @@ -85,7 +110,7 @@ export function getNetworkStatusColor(chainId: number | undefined): string { /** * Get short network name for mobile display - * This app only supports Base Sepolia + * This app supports Base Sepolia and Monad testnet */ export function getNetworkShortName(chainId: number | undefined): string { if (!chainId) return 'Unknown'; @@ -93,6 +118,8 @@ export function getNetworkShortName(chainId: number | undefined): string { switch (chainId) { case 84532: return 'Sepolia'; // Base Sepolia + case MONAD_TESTNET_ID: + return 'Monad'; // Monad testnet // Legacy support for display only - not supported case 8453: return 'Base (Unsupported)'; diff --git a/public/assets/icons/monad_logo.svg b/public/assets/icons/monad_logo.svg new file mode 100644 index 0000000..3cf4b36 --- /dev/null +++ b/public/assets/icons/monad_logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/types/ethereum.d.ts b/types/ethereum.d.ts new file mode 100644 index 0000000..a1338a8 --- /dev/null +++ b/types/ethereum.d.ts @@ -0,0 +1,13 @@ +interface EthereumProvider { + request: (args: { method: string; params?: any[] }) => Promise; + on: (event: string, handler: (...args: any[]) => void) => void; + removeListener: (event: string, handler: (...args: any[]) => void) => void; +} + +declare global { + interface Window { + ethereum?: EthereumProvider; + } +} + +export {}; \ No newline at end of file