diff --git a/components/Layout.tsx b/components/Layout.tsx index 88d937c..a873ee8 100644 --- a/components/Layout.tsx +++ b/components/Layout.tsx @@ -1,5 +1,6 @@ import { useRouter } from "next/router"; import { WalletComponent } from "./Wallet"; +import { NetworkSwitcher } from "./NetworkSwitcher"; import { useToast } from "./ToastProvider"; import { verifySignatureCache } from "../lib/signatureCache"; import { useState } from "react"; @@ -65,6 +66,7 @@ export function Layout({ children, title = "Base Verify Demo" }: LayoutProps) {
+
diff --git a/components/NetworkSwitcher.tsx b/components/NetworkSwitcher.tsx new file mode 100644 index 0000000..6f2afb4 --- /dev/null +++ b/components/NetworkSwitcher.tsx @@ -0,0 +1,54 @@ +'use client' + +import { useChainId, useSwitchChain } from 'wagmi' +import { base, baseSepolia } from 'wagmi/chains' +import { useToast } from './ToastProvider' + +const NETWORKS = [ + { id: base.id, label: 'Base' }, + { id: baseSepolia.id, label: 'Base Sepolia' }, +] + +export function NetworkSwitcher() { + const chainId = useChainId() + const { switchChainAsync, isPending } = useSwitchChain() + const { showToast } = useToast() + + const switchTo = async (id: number, label: string) => { + try { + await switchChainAsync({ chainId: id }) + showToast(`Switched to ${label}`, 'success') + } catch { + showToast(`Could not switch to ${label}`, 'error') + } + } + + return ( +
+ {NETWORKS.map(({ id, label }) => { + const active = chainId === id + return ( + + ) + })} +
+ ) +} diff --git a/components/Wallet.tsx b/components/Wallet.tsx index 6357e61..2844922 100644 --- a/components/Wallet.tsx +++ b/components/Wallet.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useAccount, useConnect, useDisconnect, useSwitchChain } from 'wagmi' import { config, baseSepolia } from '../lib/wagmi' import { useToast } from './ToastProvider' @@ -47,9 +47,16 @@ export function WalletComponent() { } }, [connector]) - // Base Account often stays on mainnet (8453) after connect; nudge to Sepolia. + // Base Account often stays on mainnet (8453) after connect; nudge to Sepolia once. + // Only on the initial connect — don't fight manual network switches afterwards. + const nudgedToSepolia = useRef(false) useEffect(() => { - if (!isConnected || walletChainId === undefined || walletChainId === baseSepolia.id) return + if (!isConnected) { + nudgedToSepolia.current = false + return + } + if (nudgedToSepolia.current || walletChainId === undefined || walletChainId === baseSepolia.id) return + nudgedToSepolia.current = true void switchChainAsync({ chainId: baseSepolia.id }).catch(() => {}) }, [isConnected, walletChainId, switchChainAsync]) diff --git a/pages/coinbase.tsx b/pages/coinbase.tsx index 2cfd7c6..97dd051 100644 --- a/pages/coinbase.tsx +++ b/pages/coinbase.tsx @@ -34,8 +34,12 @@ export default function CoinbasePage({ initialUsers, error }: Props) { const [isDeleting, setIsDeleting] = useState(false) const [deleteError, setDeleteError] = useState('') const [showVerifyModal, setShowVerifyModal] = useState(false) + const [tamperStripTrait, setTamperStripTrait] = useState(false) + const [tamperChangeAction, setTamperChangeAction] = useState(false) const { showToast } = useToast(); + const isDev = process.env.NODE_ENV !== 'production' + // Clear cache when address changes or if cached signature is for wrong provider useEffect(() => { if (address) { @@ -232,20 +236,29 @@ export default function CoinbasePage({ initialUsers, error }: Props) { try { let signature; - // Check for cached signature first + // Dev-only tamper toggles simulate a malicious client editing the message + // before signing. The server-side pin in /api/coinbase/verify-token should + // reject either case with a 400. + const isTampering = isDev && (tamperStripTrait || tamperChangeAction) + const action = isDev && tamperChangeAction + ? 'claim_demo_coinbase_airdrop_tampered' + : 'claim_demo_coinbase_airdrop' + const traits = isDev && tamperStripTrait + ? {} + : { 'coinbase_one_active': 'true' } + + // Check for cached signature first (skipped while tampering so each attempt re-signs) const cachedSignature = verifySignatureCache.get(); - if (cachedSignature && verifySignatureCache.isValidForAddress(address, 'claim_demo_coinbase_airdrop', 'coinbase')) { + if (!isTampering && cachedSignature && verifySignatureCache.isValidForAddress(address, 'claim_demo_coinbase_airdrop', 'coinbase')) { console.log('Using cached signature'); signature = cachedSignature; } else { - console.log('Generating new signature'); + console.log('Generating new signature', isTampering ? '(tampered)' : ''); // Generate SIWE signature for base_verify_token signature = await generateSignature({ - action: 'claim_demo_coinbase_airdrop', + action, provider: 'coinbase', - traits: { - 'coinbase_one_active': 'true', - }, + traits, signMessageFunction: async (message: string) => { return new Promise((resolve, reject) => { signMessage( @@ -260,8 +273,10 @@ export default function CoinbasePage({ initialUsers, error }: Props) { address: address, }); - // Cache the newly generated signature - verifySignatureCache.set(signature); + // Cache the newly generated signature (never cache a tampered one) + if (!isTampering) { + verifySignatureCache.set(signature); + } } const { code, state } = router.query; @@ -499,6 +514,32 @@ export default function CoinbasePage({ initialUsers, error }: Props) { + {/* Dev-only tamper controls — simulate a malicious client editing the + signed message. The server pin should reject these with a 400. */} + {isDev && ( +
+
+ Dev: tamper with signed message +
+ + +
+ )} + {/* Claim Section */}