diff --git a/wagmi-disperse/package.json b/wagmi-disperse/package.json index a2745f3..28aa754 100644 --- a/wagmi-disperse/package.json +++ b/wagmi-disperse/package.json @@ -16,6 +16,7 @@ "dependencies": { "@tanstack/react-query": "5.80.0", "fuse.js": "^7.1.0", + "html5-qrcode": "^2.3.8", "react": "^18.3.1", "react-dom": "^18.3.1", "viem": "^2.31.6", @@ -38,5 +39,6 @@ "typescript": "^5.8.3", "vite": "^6.3.5", "vitest": "^3.2.0" - } + }, + "packageManager": "pnpm@10.6.3+sha512.bb45e34d50a9a76e858a95837301bfb6bd6d35aea2c5d52094fa497a467c43f5c440103ce2511e9e0a2f89c3d6071baac3358fc68ac6fb75e2ceb3d2736065e6" } diff --git a/wagmi-disperse/pnpm-lock.yaml b/wagmi-disperse/pnpm-lock.yaml index 1e0e2b7..049e968 100644 --- a/wagmi-disperse/pnpm-lock.yaml +++ b/wagmi-disperse/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: fuse.js: specifier: ^7.1.0 version: 7.1.0 + html5-qrcode: + specifier: ^2.3.8 + version: 2.3.8 react: specifier: ^18.3.1 version: 18.3.1 @@ -1524,6 +1527,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html5-qrcode@2.3.8: + resolution: {integrity: sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -4822,6 +4828,8 @@ snapshots: html-escaper@2.0.2: {} + html5-qrcode@2.3.8: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 diff --git a/wagmi-disperse/src/App.tsx b/wagmi-disperse/src/App.tsx index e5e48bc..fc1208d 100644 --- a/wagmi-disperse/src/App.tsx +++ b/wagmi-disperse/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { formatUnits } from "viem"; import { useAccount, useBalance, useChainId, useConfig, useConnect } from "wagmi"; @@ -6,16 +6,16 @@ import { Suspense, lazy } from "react"; import CurrencySelector from "./components/CurrencySelector"; import Header from "./components/Header"; import NetworkStatus from "./components/NetworkStatus"; -import RecipientInput from "./components/RecipientInput"; -import TokenLoader from "./components/TokenLoader"; +import NetworkSwitcher from "./components/NetworkSwitcher"; +import QRRecipientInput from "./components/QRRecipientInput"; import TransactionSection from "./components/TransactionSection"; const DebugPanel = lazy(() => import("./components/debug/DebugPanel")); -import { AppState } from "./constants"; +import { AppState, EXPECTED_CHAIN_ID } from "./constants"; import { useAppState } from "./hooks/useAppState"; import { useContractVerification } from "./hooks/useContractVerification"; import { useCurrencySelection } from "./hooks/useCurrencySelection"; import { useTokenAllowance } from "./hooks/useTokenAllowance"; -import type { Recipient, TokenInfo } from "./types"; +import type { Recipient } from "./types"; import { getBalance, getDecimals, @@ -26,7 +26,14 @@ import { getTotalAmount, } from "./utils/balanceCalculations"; import { canDeployToNetwork } from "./utils/contractVerify"; -import { parseRecipients } from "./utils/parseRecipients"; + +// PNK token constant for Arbitrum Sepolia +const PNK_TOKEN = { + address: "0xA13c3e5f8F19571859F4Ab1003B960a5DF694C10" as `0x${string}`, + symbol: "PNK", + name: "Kleros", + decimals: 18, +}; function App() { const config = useConfig(); @@ -36,6 +43,17 @@ function App() { address, chainId: chainId, }); + + // Log current state for debugging + console.log("[App] Connection state:", { chainId, isConnected, address, status }); + + // Fetch PNK token balance + const { data: pnkBalanceData } = useBalance({ + address, + token: PNK_TOKEN.address, + chainId: chainId, + }); + const { connectors, connect } = useConnect(); const isChainSupported = chainId ? config.chains.some((chain) => chain.id === chainId) : false; @@ -57,8 +75,8 @@ function App() { }, []); const [recipients, setRecipients] = useState([]); + const [amount, setAmount] = useState(""); const walletStatus = status === "connected" ? `logged in as ${address}` : "please unlock wallet"; - const textareaRef = useRef(null); const { sending, token, setSending, setToken } = useCurrencySelection(); @@ -74,23 +92,6 @@ function App() { token, }); - const parseAmounts = useCallback(() => { - if (!textareaRef.current) return; - - const text = textareaRef.current.value; - const decimals = getDecimals(sending, token); - const newRecipients = parseRecipients(text, decimals); - - setRecipients(newRecipients); - - if ( - newRecipients.length && - (sending === "ether" || (sending === "token" && token.address && token.decimals !== undefined)) - ) { - setAppState(AppState.ENTERED_AMOUNTS); - } - }, [sending, token, setAppState]); - const handleRecipientsChange = useCallback( (newRecipients: Recipient[]) => { setRecipients(newRecipients); @@ -105,57 +106,24 @@ function App() { [sending, token, setAppState], ); - const resetToken = useCallback(() => { - setToken({}); - setAppState(AppState.CONNECTED_TO_WALLET); - }, [setToken, setAppState]); - const selectCurrency = useCallback( (type: "ether" | "token") => { setSending(type); if (type === "ether") { setAppState(AppState.SELECTED_CURRENCY); - requestAnimationFrame(() => { - if (textareaRef.current?.value) { - parseAmounts(); - } - }); } else if (type === "token") { - if (token.address && token.decimals !== undefined && token.symbol) { - setAppState(AppState.SELECTED_CURRENCY); - requestAnimationFrame(() => { - if (textareaRef.current?.value) { - parseAmounts(); - } - }); - } else { - resetToken(); - } + // Auto-populate PNK token + setToken(PNK_TOKEN); + setAppState(AppState.SELECTED_CURRENCY); } }, - [setSending, setAppState, token, parseAmounts, resetToken], + [setSending, setAppState, setToken], ); - const selectToken = useCallback( - (tokenInfo: TokenInfo) => { - setToken(tokenInfo); - setSending("token"); - setAppState(AppState.SELECTED_CURRENCY); - - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.focus(); - if (tokenInfo.decimals !== undefined) { - parseAmounts(); - } - } - }); - }); - }, - [setToken, setSending, setAppState, parseAmounts], - ); + const handleAmountChange = useCallback((e: React.ChangeEvent) => { + setAmount(e.target.value); + }, []); // Use reactive allowance hook const { allowance: currentAllowance } = useTokenAllowance({ @@ -182,6 +150,9 @@ function App() { const symbol = useMemo(() => getSymbol(sending, token, chainId), [sending, token, chainId]); const decimals = useMemo(() => getDecimals(sending, token), [sending, token]); const nativeCurrencyName = useMemo(() => getNativeCurrencyName(chainId), [chainId]); + + // Check if on wrong network + const isWrongNetwork = isConnected && chainId !== EXPECTED_CHAIN_ID; // Display all wallet connectors const renderConnectors = () => { @@ -230,7 +201,11 @@ function App() { )} - {appState >= AppState.CONNECTED_TO_WALLET && ( + {/* Show network switcher PROMINENTLY if connected but on wrong network - AUTO-SWITCHES */} + {isConnected && isWrongNetwork && } + + {/* Only show currency selector and beyond if on CORRECT network */} + {appState >= AppState.CONNECTED_TO_WALLET && !isWrongNetwork && (
{sending === "ether" && ( @@ -242,19 +217,30 @@ function App() {
)} - {appState >= AppState.CONNECTED_TO_WALLET && sending === "token" && ( + {appState >= AppState.SELECTED_CURRENCY && !isWrongNetwork && (
- - {token.symbol && ( +

amount to send

+

Enter the amount in {symbol} to send to each address (up to 18 decimals).

+
+ +
+ {sending === "ether" && ( +

+ you have {formatUnits(balanceData?.value || 0n, 18)} {nativeCurrencyName} + {balanceData?.value === 0n && chainId && (make sure to add funds)} +

+ )} + {sending === "token" && token.symbol && (

- you have {formatUnits(token.balance || 0n, token.decimals || 18)} {token.symbol} + you have {formatUnits(pnkBalanceData?.value || 0n, token.decimals || 18)} {token.symbol} + {pnkBalanceData?.value === 0n && chainId && (make sure to add funds)}

)}
@@ -264,13 +250,19 @@ function App() { 1. Ether is selected and we're connected to a supported wallet/network, or 2. We're in SELECTED_CURRENCY state or higher (any currency), 3. Token is selected and we have a valid token (with symbol) - BUT never show when on an unsupported network (NETWORK_UNAVAILABLE state) + BUT never show when on an unsupported network (NETWORK_UNAVAILABLE state) or WRONG network */} - {appState !== AppState.NETWORK_UNAVAILABLE && + {!isWrongNetwork && + appState !== AppState.NETWORK_UNAVAILABLE && ((appState >= AppState.CONNECTED_TO_WALLET && sending === "ether") || appState >= AppState.SELECTED_CURRENCY || (sending === "token" && !!token.symbol)) && ( - + )} {appState >= AppState.ENTERED_AMOUNTS && ( @@ -289,6 +281,7 @@ function App() { account={address} nativeCurrencyName={nativeCurrencyName} effectiveAllowance={effectiveAllowance} + isWrongNetwork={isWrongNetwork} /> )} @@ -312,6 +305,19 @@ function App() { recipientsCount={recipients.length} /> + + ); } diff --git a/wagmi-disperse/src/components/CurrencySelector.tsx b/wagmi-disperse/src/components/CurrencySelector.tsx index f9be2bd..055a435 100644 --- a/wagmi-disperse/src/components/CurrencySelector.tsx +++ b/wagmi-disperse/src/components/CurrencySelector.tsx @@ -1,6 +1,4 @@ import { type ChangeEvent, useState } from "react"; -import { useChainId } from "wagmi"; -import { nativeCurrencyName } from "../networks"; interface CurrencySelectorProps { onSelect: (type: "ether" | "token") => void; @@ -8,10 +6,6 @@ interface CurrencySelectorProps { const CurrencySelector = ({ onSelect }: CurrencySelectorProps) => { const [selectedCurrency, setSelectedCurrency] = useState<"ether" | "token">("ether"); - const chainId = useChainId(); - - // Get native currency name for display - const nativeCurrency = nativeCurrencyName(chainId); // Don't auto-select ether on mount - this causes issues when switching back from token // The parent component should control the initial state instead @@ -33,7 +27,7 @@ const CurrencySelector = ({ onSelect }: CurrencySelectorProps) => { checked={selectedCurrency === "ether"} onChange={handleChange} /> - + or { checked={selectedCurrency === "token"} onChange={handleChange} /> - + ); }; diff --git a/wagmi-disperse/src/components/DeployContract.tsx b/wagmi-disperse/src/components/DeployContract.tsx index 4af61db..daef193 100644 --- a/wagmi-disperse/src/components/DeployContract.tsx +++ b/wagmi-disperse/src/components/DeployContract.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import type { BaseError } from "viem"; -import { useBytecode, useWaitForTransactionReceipt, useWriteContract } from "wagmi"; +import { useBytecode, useChainId, useWaitForTransactionReceipt, useWriteContract } from "wagmi"; +import { EXPECTED_CHAIN_ID } from "../constants"; import { disperse_createx } from "../deploy"; import { createXAbi } from "../generated"; import { explorerTx, networkName } from "../networks"; @@ -18,6 +19,7 @@ const DeployContract = ({ chainId, onSuccess }: DeployContractProps) => { const [txHash, setTxHash] = useState<`0x${string}` | null>(null); const [errorMessage, setErrorMessage] = useState(""); const [deployedAddress, setDeployedAddress] = useState<`0x${string}` | null>(null); + const currentChainId = useChainId(); // Use CreateX to deploy the contract - use generic writeContract to set address for any chain const { writeContract, isPending, isError, error, data: contractWriteData } = useWriteContract(); @@ -86,6 +88,13 @@ const DeployContract = ({ chainId, onSuccess }: DeployContractProps) => { setIsDeploying(true); setErrorMessage(""); + // CRITICAL: Verify we're on the correct network before deployment + if (currentChainId !== EXPECTED_CHAIN_ID) { + setErrorMessage(`Wrong network! Please switch to Arbitrum Sepolia (chain ID ${EXPECTED_CHAIN_ID}). Currently on chain ${currentChainId}.`); + setIsDeploying(false); + return; + } + // If contract is already deployed at expected address, just notify success if (isAlreadyDeployed) { console.log("Contract already deployed at expected address:", expectedAddress); diff --git a/wagmi-disperse/src/components/Header.tsx b/wagmi-disperse/src/components/Header.tsx index 961e2b0..78a6408 100644 --- a/wagmi-disperse/src/components/Header.tsx +++ b/wagmi-disperse/src/components/Header.tsx @@ -1,6 +1,5 @@ import { useDisconnect, useEnsName } from "wagmi"; import { explorerAddr } from "../networks"; -import ChainSelector from "./ChainSelector"; interface HeaderProps { chainId: number | undefined; @@ -43,9 +42,7 @@ const Header = ({ chainId, address }: HeaderProps) => {

disperse - - - + {/* ChainSelector hidden - using Arbitrum Sepolia only */}

{address && ( diff --git a/wagmi-disperse/src/components/NetworkSwitcher.tsx b/wagmi-disperse/src/components/NetworkSwitcher.tsx new file mode 100644 index 0000000..9929091 --- /dev/null +++ b/wagmi-disperse/src/components/NetworkSwitcher.tsx @@ -0,0 +1,146 @@ +import { useEffect, useRef, useState } from "react"; +import { useSwitchChain } from "wagmi"; +import { EXPECTED_CHAIN_ID } from "../constants"; + +interface NetworkSwitcherProps { + currentChainId?: number; +} + +export default function NetworkSwitcher({ currentChainId }: NetworkSwitcherProps) { + const { chains, switchChain, isPending, error } = useSwitchChain(); + const [hasAttemptedSwitch, setHasAttemptedSwitch] = useState(false); + const switchAttemptRef = useRef(false); + + const isWrongNetwork = currentChainId !== EXPECTED_CHAIN_ID; + + // Debug: Log available chains + console.log("[NetworkSwitcher] Available chains:", chains?.map(c => ({ id: c.id, name: c.name }))); + console.log("[NetworkSwitcher] Current chain ID:", currentChainId, "Expected:", EXPECTED_CHAIN_ID, "Is wrong?", isWrongNetwork); + + // Automatically attempt to switch network when wrong network is detected + useEffect(() => { + if (isWrongNetwork && !switchAttemptRef.current && !isPending && switchChain) { + console.log(`[NetworkSwitcher] Wrong network detected (${currentChainId}). Will auto-switch to Arbitrum Sepolia (${EXPECTED_CHAIN_ID}) in 1 second...`); + + // Add a small delay to ensure wallet is ready + const timeoutId = setTimeout(() => { + if (isWrongNetwork && !switchAttemptRef.current) { + console.log(`[NetworkSwitcher] Attempting auto-switch now...`); + switchAttemptRef.current = true; + setHasAttemptedSwitch(true); + + // Attempt the switch - use proper wagmi syntax with callbacks as second parameter + switchChain( + { chainId: EXPECTED_CHAIN_ID }, + { + onError: (error: Error) => { + console.error("[NetworkSwitcher] Auto-switch failed:", error); + console.error("[NetworkSwitcher] Error details:", error.message, error.name); + // Allow retry by resetting the ref after a delay + setTimeout(() => { + switchAttemptRef.current = false; + }, 3000); + }, + onSuccess: () => { + console.log("[NetworkSwitcher] Successfully switched to Arbitrum Sepolia!"); + }, + onSettled: () => { + console.log("[NetworkSwitcher] Switch operation completed"); + } + } + ); + } + }, 1000); // Wait 1 second before auto-switching + + return () => clearTimeout(timeoutId); + } + }, [isWrongNetwork, currentChainId, switchChain, isPending]); + + // Reset attempt flag when network becomes correct + useEffect(() => { + if (!isWrongNetwork) { + switchAttemptRef.current = false; + setHasAttemptedSwitch(false); + } + }, [isWrongNetwork]); + + if (!isWrongNetwork) return null; + + const handleManualSwitch = () => { + console.log("[NetworkSwitcher] Manual switch requested to chain", EXPECTED_CHAIN_ID); + switchChain( + { chainId: EXPECTED_CHAIN_ID }, + { + onError: (error: Error) => { + console.error("[NetworkSwitcher] Manual switch failed:", error); + }, + onSuccess: () => { + console.log("[NetworkSwitcher] Manual switch successful"); + } + } + ); + }; + + return ( +
+

⚠️ Wrong Network Detected

+

+ This app ONLY works on Arbitrum Sepolia (Chain ID: {EXPECTED_CHAIN_ID}) +

+

+ You are currently on chain ID: {currentChainId} +

+ + {isPending && ( +

+ 🔄 Switching network... Please approve in your wallet +

+ )} + + {error && ( +
+

+ ❌ Failed to switch: {error.message} +

+
+ )} + + + +

+ ⚠️ All transaction buttons are DISABLED until you switch to Arbitrum Sepolia. + {hasAttemptedSwitch && !error && !isPending && " Please check your wallet for the network switch request."} +

+
+ ); +} + diff --git a/wagmi-disperse/src/components/QRRecipientInput.tsx b/wagmi-disperse/src/components/QRRecipientInput.tsx new file mode 100644 index 0000000..6701799 --- /dev/null +++ b/wagmi-disperse/src/components/QRRecipientInput.tsx @@ -0,0 +1,213 @@ +import { Html5Qrcode } from "html5-qrcode"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { isAddress, parseUnits } from "viem"; +import type { Recipient, TokenInfo } from "../types"; +import { getDecimals } from "../utils/balanceCalculations"; + +interface QRRecipientInputProps { + sending: "ether" | "token" | null; + token: TokenInfo; + amount: string; + onRecipientsChange: (recipients: Recipient[]) => void; +} + +const QRRecipientInput = ({ sending, token, amount, onRecipientsChange }: QRRecipientInputProps) => { + const [scannedAddresses, setScannedAddresses] = useState<`0x${string}`[]>(() => { + // Load addresses from localStorage on mount + try { + const stored = localStorage.getItem("disperse_scanned_addresses"); + if (stored) { + const parsed = JSON.parse(stored); + return Array.isArray(parsed) ? parsed : []; + } + } catch (error) { + console.error("Failed to load addresses from localStorage:", error); + } + return []; + }); + const [isScanning, setIsScanning] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); + + const html5QrCodeRef = useRef(null); + const lastScanTimeRef = useRef(0); + const scannedAddressesRef = useRef<`0x${string}`[]>([]); + const qrCodeRegionId = "qr-reader"; + + // Save addresses to localStorage whenever they change + useEffect(() => { + try { + localStorage.setItem("disperse_scanned_addresses", JSON.stringify(scannedAddresses)); + } catch (error) { + console.error("Failed to save addresses to localStorage:", error); + } + }, [scannedAddresses]); + + // Update recipients whenever addresses or amount changes + useEffect(() => { + // Keep ref in sync with state for use in scan callback + scannedAddressesRef.current = scannedAddresses; + + if (scannedAddresses.length === 0 || !amount) { + onRecipientsChange([]); + return; + } + + try { + const decimals = getDecimals(sending, token); + const parsedAmount = parseUnits(amount, decimals); + + const recipients: Recipient[] = scannedAddresses.map((address) => ({ + address, + value: parsedAmount, + })); + + onRecipientsChange(recipients); + } catch (error) { + // Invalid amount format + onRecipientsChange([]); + } + }, [scannedAddresses, amount, sending, token, onRecipientsChange]); + + const startScanning = useCallback(async () => { + try { + setErrorMessage(""); + + if (!html5QrCodeRef.current) { + html5QrCodeRef.current = new Html5Qrcode(qrCodeRegionId); + } + + await html5QrCodeRef.current.start( + { facingMode: "environment" }, + { + fps: 10, + qrbox: { width: 250, height: 250 }, + }, + (decodedText) => { + // Throttle scans to prevent rapid duplicate scans (0.5 second delay) + const now = Date.now(); + if (now - lastScanTimeRef.current < 500) { + return; // Ignore scans within 500ms + } + + // Validate that the scanned text is an Ethereum address + if (isAddress(decodedText)) { + const normalizedAddress = decodedText.toLowerCase() as `0x${string}`; + + // Check for duplicates (case-insensitive) - use ref to get current value + if (scannedAddressesRef.current.some((addr) => addr.toLowerCase() === normalizedAddress)) { + // Silently ignore duplicates - don't show message as scanner continuously reads QR + lastScanTimeRef.current = now; // Update timestamp even for duplicates + return; + } + + setScannedAddresses((prev) => [...prev, normalizedAddress]); + setSuccessMessage(`Address added: ${normalizedAddress.slice(0, 10)}...`); + setTimeout(() => setSuccessMessage(""), 2000); + lastScanTimeRef.current = now; // Update timestamp after successful scan + } else { + setErrorMessage("Invalid Ethereum address in QR code"); + setTimeout(() => setErrorMessage(""), 3000); + lastScanTimeRef.current = now; // Update timestamp even for invalid scans + } + }, + undefined, // onScanError - we can ignore errors as they're frequent during scanning + ); + + setIsScanning(true); + } catch (err) { + const error = err as Error; + setErrorMessage( + error.message.includes("Permission") + ? "Camera permission denied. Please allow camera access." + : `Failed to start camera: ${error.message}`, + ); + setIsScanning(false); + } + }, []); // Empty deps - callback uses refs which are always current + + const stopScanning = useCallback(async () => { + try { + if (html5QrCodeRef.current?.isScanning) { + await html5QrCodeRef.current.stop(); + } + setIsScanning(false); + } catch (err) { + console.error("Error stopping scanner:", err); + setIsScanning(false); + } + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (html5QrCodeRef.current?.isScanning) { + html5QrCodeRef.current.stop().catch(console.error); + } + }; + }, []); + + const handleCopyAddress = useCallback((address: string) => { + navigator.clipboard.writeText(address).then(() => { + setSuccessMessage("Copied to clipboard!"); + setTimeout(() => setSuccessMessage(""), 2000); + }); + }, []); + + const handleRemoveAddress = useCallback((address: string) => { + setScannedAddresses((prev) => prev.filter((addr) => addr !== address)); + }, []); + + return ( +
+

scan recipients

+

scan Ethereum addresses as QR codes to add them to the recipient list.

+ + {/* QR Scanner */} +
+
+ + + + {errorMessage &&

{errorMessage}

} + {successMessage &&

{successMessage}

} +
+ + {/* Scanned Addresses List */} + {scannedAddresses.length > 0 && ( +
+

Scanned Addresses ({scannedAddresses.length})

+
    + {scannedAddresses.map((address) => ( +
  • + {address} +
    + + +
    +
  • + ))} +
+
+ )} +
+ ); +}; + +export default memo(QRRecipientInput); diff --git a/wagmi-disperse/src/components/TokenLoader.tsx b/wagmi-disperse/src/components/TokenLoader.tsx index 83d539b..e0334a8 100644 --- a/wagmi-disperse/src/components/TokenLoader.tsx +++ b/wagmi-disperse/src/components/TokenLoader.tsx @@ -249,7 +249,7 @@ const TokenLoader = ({ onSelect, onError, chainId, account, token, contractAddre border: "none", borderBottom: "2px #111 solid", padding: ".7rem", - background: "aquamarine", + background: "#4D00B4", marginRight: "1.4rem", }} /> diff --git a/wagmi-disperse/src/components/TransactionButton.tsx b/wagmi-disperse/src/components/TransactionButton.tsx index 02659a8..6853c5f 100644 --- a/wagmi-disperse/src/components/TransactionButton.tsx +++ b/wagmi-disperse/src/components/TransactionButton.tsx @@ -1,7 +1,8 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import type { BaseError } from "viem"; -import { useWaitForTransactionReceipt, useWriteContract } from "wagmi"; +import { useChainId, useWaitForTransactionReceipt, useWriteContract } from "wagmi"; +import { EXPECTED_CHAIN_ID } from "../constants"; import { erc20 } from "../contracts"; import { disperse_legacy } from "../deploy"; import { disperseAbi } from "../generated"; @@ -39,6 +40,7 @@ const TransactionButton = ({ const [txHash, setTxHash] = useState<`0x${string}` | null>(null); const [errorMessage, setErrorMessage] = useState(""); const queryClient = useQueryClient(); + const currentChainId = useChainId(); // Use the contract address from props, falling back to legacy address if not provided const contractAddress = customAddress || (disperse_legacy.address as `0x${string}`); @@ -114,6 +116,12 @@ const TransactionButton = ({ const handleClick = async () => { setErrorMessage(""); + // CRITICAL: Verify we're on the correct network before ANY transaction + if (currentChainId !== EXPECTED_CHAIN_ID) { + setErrorMessage(`Wrong network! Please switch to Arbitrum Sepolia (chain ID ${EXPECTED_CHAIN_ID}). Currently on chain ${currentChainId}.`); + return; + } + if (!contractAddress) { setErrorMessage("Disperse contract address not available for this network"); return; diff --git a/wagmi-disperse/src/components/TransactionSection.tsx b/wagmi-disperse/src/components/TransactionSection.tsx index d23c5eb..5fd59c7 100644 --- a/wagmi-disperse/src/components/TransactionSection.tsx +++ b/wagmi-disperse/src/components/TransactionSection.tsx @@ -17,6 +17,7 @@ interface TransactionSectionProps { account?: `0x${string}`; nativeCurrencyName?: string; effectiveAllowance?: bigint; + isWrongNetwork?: boolean; } export default function TransactionSection({ @@ -34,6 +35,7 @@ export default function TransactionSection({ account, nativeCurrencyName = "ETH", effectiveAllowance = 0n, + isWrongNetwork = false, }: TransactionSectionProps) { return ( <> @@ -50,10 +52,10 @@ export default function TransactionSection({ {sending === "ether" && ( = totalAmount ? "secondary" : ""} account={account} + disabled={isWrongNetwork} /> div { + border: none !important; +} + +.qr-scan-button { + border: none; + font-style: italic; + padding: .7rem; + background: #9013fe; + box-shadow: 6px 6px #009aff; + cursor: pointer; + font-size: 1.4rem; + margin-bottom: 1rem; +} + +.qr-scan-button:focus { + outline: none; +} + +.qr-scan-button:hover { + color: #9013fe; + background: #f5f5f5; +} + +.error-message { + color: #009aff; + font-style: italic; + margin-top: 0.5rem; +} + +.success-message { + color: #28bd14; + font-style: italic; + margin-top: 0.5rem; +} + +/* Scanned Addresses List */ +.scanned-addresses { + margin-bottom: 1.4rem; +} + +.scanned-addresses h3 { + font-size: 1.6rem; + margin-bottom: 0.7rem; +} + +.address-list { + list-style: none; + padding: 0; + margin: 0; +} + +.address-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.7rem; + background: #9013fe; + border-bottom: 2px solid #111111; + margin-bottom: 0.5rem; + font-family: monospace; + font-size: 1rem; + word-break: break-all; +} + +.address-text { + flex: 1; + margin-right: 1rem; +} + +.address-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.copy-button, +.remove-button { + border: 1px solid #111111; + background: white; + padding: 0.3rem 0.6rem; + font-size: 0.9rem; + cursor: pointer; + transition: background 0.2s; +} + +.copy-button:hover { + background: #e8e8e8; +} + +.remove-button { + background: #009aff; + color: white; + border-color: #009aff; +} + +.remove-button:hover { + background: #dc143ccc; +} + +/* Amount Input */ +.amount-input-container { + margin-bottom: 1.4rem; +} + +.amount-input-container h3 { + font-size: 1.6rem; + margin-bottom: 0.5rem; +} + +.amount-input { + display: block; + border: none; + border-bottom: 2px #111111 solid; + background: #f5f5f5; + padding: .7rem; + font-size: 1.4rem; + width: 100%; + margin-bottom: 1.4rem; +} + +.amount-input:focus { + outline: none; +} + +/* Footer styles */ +.app-footer { + margin-top: 6rem; + padding-top: 2rem; + border-top: 1px solid #ccc; +} + +.app-footer p { + font-size: 1rem; + color: #666; + text-align: center; +} + +.app-footer a { + text-decoration: none; + background: none; + text-shadow: none; +} diff --git a/wagmi-disperse/src/wagmi.ts b/wagmi-disperse/src/wagmi.ts index e0b4358..2eca9a8 100644 --- a/wagmi-disperse/src/wagmi.ts +++ b/wagmi-disperse/src/wagmi.ts @@ -1,24 +1,29 @@ import { http, createConfig } from "wagmi"; -import * as chains from "wagmi/chains"; +import { arbitrumSepolia, mainnet, arbitrum, sepolia, optimism, gnosis } from "wagmi/chains"; import type { Chain } from "wagmi/chains"; import { coinbaseWallet, injected, metaMask, walletConnect } from "wagmi/connectors"; -import { isValidChain } from "./utils/typeGuards"; -const allChains = Object.values(chains).filter(isValidChain); - -// Ensure we have at least one chain for the type system -const validChains = - allChains.length > 0 ? (allChains as unknown as [Chain, ...Chain[]]) : ([chains.mainnet] as [Chain, ...Chain[]]); +// Include common chains so users can switch FROM them TO Arbitrum Sepolia +// The app will only work on Arbitrum Sepolia, but we need other chains in config +// so wagmi can handle switching from them +const supportedChains = [ + arbitrumSepolia, // The ONLY chain where the app actually works + mainnet, // Common chains users might be on + arbitrum, + optimism, + sepolia, + gnosis, +] as [Chain, ...Chain[]]; export const config = createConfig({ - chains: validChains, + chains: supportedChains, connectors: [ injected(), metaMask(), coinbaseWallet(), walletConnect({ projectId: import.meta.env.VITE_WC_PROJECT_ID || "YOUR_PROJECT_ID" }), ], - transports: Object.fromEntries(validChains.map((chain) => [chain.id, http()])), + transports: Object.fromEntries(supportedChains.map((chain) => [chain.id, http()])), }); declare module "wagmi" {