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 && (
+
+ )}
+
{/* Claim Section */}