From a8fae1e9af387464199570296b756daa0151016b Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Mon, 3 Nov 2025 13:33:47 +0000 Subject: [PATCH 1/5] feat: qr code reader, single amount --- wagmi-disperse/package.json | 4 +- wagmi-disperse/pnpm-lock.yaml | 8 ++ wagmi-disperse/src/App.tsx | 50 +--------- wagmi-disperse/src/css/disperse.css | 143 ++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 46 deletions(-) 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..3fb9cc5 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,7 +6,7 @@ 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 QRRecipientInput from "./components/QRRecipientInput"; import TokenLoader from "./components/TokenLoader"; import TransactionSection from "./components/TransactionSection"; const DebugPanel = lazy(() => import("./components/debug/DebugPanel")); @@ -26,7 +26,6 @@ import { getTotalAmount, } from "./utils/balanceCalculations"; import { canDeployToNetwork } from "./utils/contractVerify"; -import { parseRecipients } from "./utils/parseRecipients"; function App() { const config = useConfig(); @@ -58,7 +57,6 @@ function App() { const [recipients, setRecipients] = useState([]); const walletStatus = status === "connected" ? `logged in as ${address}` : "please unlock wallet"; - const textareaRef = useRef(null); const { sending, token, setSending, setToken } = useCurrencySelection(); @@ -74,23 +72,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); @@ -116,25 +97,15 @@ function App() { 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(); } } }, - [setSending, setAppState, token, parseAmounts, resetToken], + [setSending, setAppState, token, resetToken], ); const selectToken = useCallback( @@ -142,19 +113,8 @@ function App() { 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], + [setToken, setSending, setAppState], ); // Use reactive allowance hook @@ -270,7 +230,7 @@ function App() { ((appState >= AppState.CONNECTED_TO_WALLET && sending === "ether") || appState >= AppState.SELECTED_CURRENCY || (sending === "token" && !!token.symbol)) && ( - + )} {appState >= AppState.ENTERED_AMOUNTS && ( diff --git a/wagmi-disperse/src/css/disperse.css b/wagmi-disperse/src/css/disperse.css index 50f65b2..f9f6401 100644 --- a/wagmi-disperse/src/css/disperse.css +++ b/wagmi-disperse/src/css/disperse.css @@ -475,3 +475,146 @@ h1 sup .chain-selector-dropdown { z-index: 1000; min-width: 250px; } + +/* QR Scanner Styles */ +.qr-scanner-container { + margin-bottom: 1.4rem; +} + +#qr-reader { + width: 100%; + max-width: 500px; + margin: 0 auto 1rem; + border: 2px solid #111111; + background: #f5f5f5; + min-height: 50px; +} + +#qr-reader.scanning { + border-color: aquamarine; + background: transparent; +} + +#qr-reader > div { + border: none !important; +} + +.qr-scan-button { + border: none; + font-style: italic; + padding: .7rem; + background: aquamarine; + box-shadow: 6px 6px crimson; + cursor: pointer; + font-size: 1.4rem; + margin-bottom: 1rem; +} + +.qr-scan-button:focus { + outline: none; +} + +.qr-scan-button:hover { + background: #7fffd4cc; +} + +.error-message { + color: crimson; + 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: aquamarine; + 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: crimson; + color: white; + border-color: crimson; +} + +.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: aquamarine; + padding: .7rem; + font-size: 1.4rem; + width: 100%; + margin-bottom: 1.4rem; +} + +.amount-input:focus { + outline: none; +} From 74e3a8e7fd91d43903c29a5589885e6ac54bba99 Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Mon, 3 Nov 2025 14:36:54 +0000 Subject: [PATCH 2/5] redesign --- wagmi-disperse/src/App.tsx | 86 ++++--- .../src/components/CurrencySelector.tsx | 10 +- wagmi-disperse/src/components/Header.tsx | 5 +- .../src/components/QRRecipientInput.tsx | 213 ++++++++++++++++++ wagmi-disperse/src/components/TokenLoader.tsx | 2 +- wagmi-disperse/src/css/disperse.css | 47 ++-- wagmi-disperse/src/wagmi.ts | 10 +- 7 files changed, 297 insertions(+), 76 deletions(-) create mode 100644 wagmi-disperse/src/components/QRRecipientInput.tsx diff --git a/wagmi-disperse/src/App.tsx b/wagmi-disperse/src/App.tsx index 3fb9cc5..5807844 100644 --- a/wagmi-disperse/src/App.tsx +++ b/wagmi-disperse/src/App.tsx @@ -7,7 +7,6 @@ import CurrencySelector from "./components/CurrencySelector"; import Header from "./components/Header"; import NetworkStatus from "./components/NetworkStatus"; import QRRecipientInput from "./components/QRRecipientInput"; -import TokenLoader from "./components/TokenLoader"; import TransactionSection from "./components/TransactionSection"; const DebugPanel = lazy(() => import("./components/debug/DebugPanel")); import { AppState } from "./constants"; @@ -15,7 +14,7 @@ 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, @@ -27,6 +26,14 @@ import { } from "./utils/balanceCalculations"; import { canDeployToNetwork } from "./utils/contractVerify"; +// 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(); const chainId = useChainId(); @@ -35,6 +42,14 @@ function App() { address, chainId: chainId, }); + + // 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; @@ -56,6 +71,7 @@ function App() { }, []); const [recipients, setRecipients] = useState([]); + const [amount, setAmount] = useState(""); const walletStatus = status === "connected" ? `logged in as ${address}` : "please unlock wallet"; const { sending, token, setSending, setToken } = useCurrencySelection(); @@ -86,11 +102,6 @@ function App() { [sending, token, setAppState], ); - const resetToken = useCallback(() => { - setToken({}); - setAppState(AppState.CONNECTED_TO_WALLET); - }, [setToken, setAppState]); - const selectCurrency = useCallback( (type: "ether" | "token") => { setSending(type); @@ -98,24 +109,17 @@ function App() { if (type === "ether") { setAppState(AppState.SELECTED_CURRENCY); } else if (type === "token") { - if (token.address && token.decimals !== undefined && token.symbol) { - setAppState(AppState.SELECTED_CURRENCY); - } else { - resetToken(); - } + // Auto-populate PNK token + setToken(PNK_TOKEN); + setAppState(AppState.SELECTED_CURRENCY); } }, - [setSending, setAppState, token, resetToken], + [setSending, setAppState, setToken], ); - const selectToken = useCallback( - (tokenInfo: TokenInfo) => { - setToken(tokenInfo); - setSending("token"); - setAppState(AppState.SELECTED_CURRENCY); - }, - [setToken, setSending, setAppState], - ); + const handleAmountChange = useCallback((e: React.ChangeEvent) => { + setAmount(e.target.value); + }, []); // Use reactive allowance hook const { allowance: currentAllowance } = useTokenAllowance({ @@ -202,19 +206,30 @@ function App() { )} - {appState >= AppState.CONNECTED_TO_WALLET && sending === "token" && ( + {appState >= AppState.SELECTED_CURRENCY && (
- - {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)}

)}
@@ -230,7 +245,12 @@ function App() { ((appState >= AppState.CONNECTED_TO_WALLET && sending === "ether") || appState >= AppState.SELECTED_CURRENCY || (sending === "token" && !!token.symbol)) && ( - + )} {appState >= AppState.ENTERED_AMOUNTS && ( 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/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/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/css/disperse.css b/wagmi-disperse/src/css/disperse.css index f9f6401..009ccfe 100644 --- a/wagmi-disperse/src/css/disperse.css +++ b/wagmi-disperse/src/css/disperse.css @@ -81,7 +81,7 @@ textarea { display: block; border: none; border-bottom: 2px #111111 solid; - background: aquamarine; + background: #9013fe; padding: .7rem; font-size: 1.4rem; width: 100%; @@ -99,8 +99,8 @@ input[type="submit"] { border: none; font-style: italic; padding: .7rem; - background: aquamarine; - box-shadow: 6px 6px crimson; + background: #9013fe; + box-shadow: 6px 6px #009aff; } input[type="submit"]:focus { @@ -108,7 +108,7 @@ input[type="submit"]:focus { } .red { - color: crimson; + color: #009aff; } .info { @@ -127,7 +127,7 @@ input[type="submit"]:focus { .error { font-style: italic; - color: crimson; + color: #009aff; } .pending { @@ -148,7 +148,7 @@ input[type="submit"]:focus { } .negative { - color: crimson; + color: #009aff; } .warning { @@ -159,7 +159,7 @@ input[type="submit"]:focus { } .checking { - color: crimson; + color: #009aff; font-style: italic; } @@ -217,7 +217,7 @@ input[type="submit"]:disabled { .secondary input { background: none; - border: 1px crimson solid; + border: 1px #009aff solid; } @keyframes pulse { @@ -263,7 +263,7 @@ input[type="submit"]:disabled { .chooser input[type="radio"]:checked + label { color: #111111; border-bottom: 2px #111111 solid; - background: aquamarine; + background: #9013fe; } /* Logo styles from disperse-logo.tag */ @@ -301,11 +301,11 @@ header a { } .active svg path { - fill: aquamarine !important; + fill: #4D00B4 !important; } .inactive svg path { - fill: crimson !important; + fill: #009aff !important; } /* Chain Selector Styles */ @@ -319,7 +319,7 @@ header a { align-items: center; gap: 0.5rem; padding: 0.5rem 0.8rem; - background-color: aquamarine; + background-color: #9013fe; border: 1px solid #111; border-radius: 4px; font-size: 1rem; @@ -328,7 +328,7 @@ header a { } .chain-selector-button:hover { - background-color: #7fffd4cc; + background-color: #f5f5f5; } .chain-selector-dropdown { @@ -370,7 +370,7 @@ header a { } .chain-selector-option.active { - background-color: aquamarine; + background-color: #9013fe; } /* Chain selector search input */ @@ -491,7 +491,7 @@ h1 sup .chain-selector-dropdown { } #qr-reader.scanning { - border-color: aquamarine; + border-color: #9013fe; background: transparent; } @@ -503,8 +503,8 @@ h1 sup .chain-selector-dropdown { border: none; font-style: italic; padding: .7rem; - background: aquamarine; - box-shadow: 6px 6px crimson; + background: #9013fe; + box-shadow: 6px 6px #009aff; cursor: pointer; font-size: 1.4rem; margin-bottom: 1rem; @@ -515,11 +515,12 @@ h1 sup .chain-selector-dropdown { } .qr-scan-button:hover { - background: #7fffd4cc; + color: #9013fe; + background: #f5f5f5; } .error-message { - color: crimson; + color: #009aff; font-style: italic; margin-top: 0.5rem; } @@ -551,7 +552,7 @@ h1 sup .chain-selector-dropdown { justify-content: space-between; align-items: center; padding: 0.7rem; - background: aquamarine; + background: #9013fe; border-bottom: 2px solid #111111; margin-bottom: 0.5rem; font-family: monospace; @@ -585,9 +586,9 @@ h1 sup .chain-selector-dropdown { } .remove-button { - background: crimson; + background: #009aff; color: white; - border-color: crimson; + border-color: #009aff; } .remove-button:hover { @@ -608,7 +609,7 @@ h1 sup .chain-selector-dropdown { display: block; border: none; border-bottom: 2px #111111 solid; - background: aquamarine; + background: #f5f5f5; padding: .7rem; font-size: 1.4rem; width: 100%; diff --git a/wagmi-disperse/src/wagmi.ts b/wagmi-disperse/src/wagmi.ts index e0b4358..0e1fd69 100644 --- a/wagmi-disperse/src/wagmi.ts +++ b/wagmi-disperse/src/wagmi.ts @@ -1,14 +1,10 @@ import { http, createConfig } from "wagmi"; -import * as chains from "wagmi/chains"; +import { arbitrumSepolia } 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[]]); +// Use only Arbitrum Sepolia network +const validChains = [arbitrumSepolia] as [Chain, ...Chain[]]; export const config = createConfig({ chains: validChains, From de1e6158ba5dea486233aa759fa120b9ec74f03e Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Mon, 3 Nov 2025 14:51:03 +0000 Subject: [PATCH 3/5] chore: credits --- wagmi-disperse/src/App.tsx | 13 +++++++++++++ wagmi-disperse/src/css/disperse.css | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/wagmi-disperse/src/App.tsx b/wagmi-disperse/src/App.tsx index 5807844..4f56003 100644 --- a/wagmi-disperse/src/App.tsx +++ b/wagmi-disperse/src/App.tsx @@ -292,6 +292,19 @@ function App() { recipientsCount={recipients.length} /> + + ); } diff --git a/wagmi-disperse/src/css/disperse.css b/wagmi-disperse/src/css/disperse.css index 009ccfe..8e04d41 100644 --- a/wagmi-disperse/src/css/disperse.css +++ b/wagmi-disperse/src/css/disperse.css @@ -619,3 +619,22 @@ h1 sup .chain-selector-dropdown { .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; +} From 14423d41a0365268adac764f5e8ae7794dbcfbe5 Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Mon, 3 Nov 2025 17:03:37 +0000 Subject: [PATCH 4/5] fix: aggressive network switching to arbitrum sepolia --- wagmi-disperse/src/App.tsx | 23 ++- .../src/components/DeployContract.tsx | 11 +- .../src/components/NetworkSwitcher.tsx | 146 ++++++++++++++++++ .../src/components/TransactionButton.tsx | 10 +- .../src/components/TransactionSection.tsx | 11 +- wagmi-disperse/src/constants.ts | 3 + wagmi-disperse/src/wagmi.ts | 19 ++- 7 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 wagmi-disperse/src/components/NetworkSwitcher.tsx diff --git a/wagmi-disperse/src/App.tsx b/wagmi-disperse/src/App.tsx index 4f56003..fc1208d 100644 --- a/wagmi-disperse/src/App.tsx +++ b/wagmi-disperse/src/App.tsx @@ -6,10 +6,11 @@ import { Suspense, lazy } from "react"; import CurrencySelector from "./components/CurrencySelector"; import Header from "./components/Header"; import NetworkStatus from "./components/NetworkStatus"; +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"; @@ -43,6 +44,9 @@ function App() { 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, @@ -146,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 = () => { @@ -194,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" && ( @@ -206,7 +217,7 @@ function App() {
)} - {appState >= AppState.SELECTED_CURRENCY && ( + {appState >= AppState.SELECTED_CURRENCY && !isWrongNetwork && (

amount to send

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

@@ -239,9 +250,10 @@ 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)) && ( @@ -269,6 +281,7 @@ function App() { account={address} nativeCurrencyName={nativeCurrencyName} effectiveAllowance={effectiveAllowance} + isWrongNetwork={isWrongNetwork} /> )} 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/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/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} /> [chain.id, http()])), + transports: Object.fromEntries(supportedChains.map((chain) => [chain.id, http()])), }); declare module "wagmi" { From 514449082dd2675b6fa7e82f26759349de923192 Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Mon, 3 Nov 2025 17:23:38 +0000 Subject: [PATCH 5/5] fix: don't show the camera container when not scanning --- wagmi-disperse/src/css/disperse.css | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/wagmi-disperse/src/css/disperse.css b/wagmi-disperse/src/css/disperse.css index 8e04d41..521bf75 100644 --- a/wagmi-disperse/src/css/disperse.css +++ b/wagmi-disperse/src/css/disperse.css @@ -487,12 +487,19 @@ h1 sup .chain-selector-dropdown { margin: 0 auto 1rem; border: 2px solid #111111; background: #f5f5f5; - min-height: 50px; + height: 0; + min-height: 0; + overflow: hidden; + border: none; + margin: 0; } #qr-reader.scanning { - border-color: #9013fe; + height: auto; + min-height: 50px; + border: 2px solid #9013fe; background: transparent; + margin: 0 auto 1rem; } #qr-reader > div {