From 60058b28d2d3a6e9f04e5a7f9425caa122745f23 Mon Sep 17 00:00:00 2001 From: omer Date: Sun, 14 Jun 2026 19:00:37 +0000 Subject: [PATCH] Add multi-chain support: Celo Mainnet (42220) alongside Base (8453) - Per-chain config map (contract, mint price, treasury, deploy block, native symbol) keyed by chainId; useActiveChainConfig resolves the active chain - wagmi: add Celo chain + transport (Base behaviour untouched) - All reads/writes (hooks, BuyModal, PlotModal, ProfileDrawer, Canvas) resolve the active chain's contract dynamically; writes pin to + auto-switch the active chain via wallet_switchEthereumChain/addEthereumChain - Canvas state isolation: clear plot/minted/image caches + scan cursor and optimistic overrides on chainId switch, refetch per network - Header NetworkSwitcher dropdown (Base square mark / Celo circle mark) - Coinbase Smart Wallet hidden on Celo; guide to MetaMask/Rabby/WalletConnect - NetworkGuard now warns only on unsupported networks - Celo mint fee = CELO equivalent of ~0.001 USD (configurable) Shipped optimizations untouched: bbox compression ladder, 9,500-block log scan chunk, optimistic canvas updates. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .env.example | 12 +++ src/app/page.tsx | 6 +- src/components/BaseBoardCanvas.tsx | 49 +++++++---- src/components/BuyModal.tsx | 28 +++--- src/components/ChainLogos.tsx | 82 ++++++++++++++++++ src/components/HangingHeader.tsx | 5 +- src/components/NetworkGuard.tsx | 29 +++++-- src/components/NetworkSwitcher.tsx | 125 +++++++++++++++++++++++++++ src/components/PlotModal.tsx | 10 ++- src/components/ProfileDrawer.tsx | 37 ++++---- src/components/WalletConnect.tsx | 125 ++++++++++++++++++--------- src/hooks/useActiveContract.ts | 19 +++++ src/hooks/useBaseBoard.ts | 103 +++++++++++++++------- src/lib/constants.ts | 133 ++++++++++++++++++++++++++++- src/lib/wagmi.ts | 15 ++-- 15 files changed, 640 insertions(+), 138 deletions(-) create mode 100644 src/components/ChainLogos.tsx create mode 100644 src/components/NetworkSwitcher.tsx create mode 100644 src/hooks/useActiveContract.ts diff --git a/.env.example b/.env.example index 16023b8..48b5c6d 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,18 @@ # Deployed BaseBoard.sol address on Base Mainnet (8453). NEXT_PUBLIC_BASEBOARD_CONTRACT_ADDRESS= +# Block Base contract was deployed at (fromBlock for log scans). +NEXT_PUBLIC_BASEBOARD_DEPLOY_BLOCK= + +# ---- Celo Mainnet (42220) ---- +# Deployed BaseBoard.sol address on Celo (placeholder until deployed). +NEXT_PUBLIC_CELO_CONTRACT_ADDRESS= +# Treasury that receives Celo mint fees (must match the on-chain TREASURY). +NEXT_PUBLIC_CELO_TREASURY_ADDRESS= +# Flat plot price on Celo, in CELO (≈ $0.001 equivalent). +NEXT_PUBLIC_CELO_PLOT_PRICE= +# Block the Celo contract was deployed at (fromBlock for log scans). +NEXT_PUBLIC_CELO_DEPLOY_BLOCK= # Coinbase Developer Platform / OnchainKit client API key. # https://portal.cdp.coinbase.com/ diff --git a/src/app/page.tsx b/src/app/page.tsx index 0bc6674..df56cc6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,6 +4,7 @@ import { BaseBoardCanvas } from "@/components/BaseBoardCanvas"; import { HangingHeader } from "@/components/HangingHeader"; import { StatsDashboard } from "@/components/StatsDashboard"; import { WalletConnect } from "@/components/WalletConnect"; +import { NetworkSwitcher } from "@/components/NetworkSwitcher"; import { NetworkGuard } from "@/components/NetworkGuard"; import { ProfileDrawer } from "@/components/ProfileDrawer"; import { PlotModal } from "@/components/PlotModal"; @@ -11,9 +12,11 @@ import { BuyModal } from "@/components/BuyModal"; import { Legend } from "@/components/Legend"; import { Toaster } from "@/components/Toaster"; import { useBoardStore } from "@/store/useBoardStore"; +import { useActiveChainConfig } from "@/hooks/useActiveContract"; export default function Home() { const toggleProfile = useBoardStore((s) => s.toggleProfile); + const cfg = useActiveChainConfig(); return (
@@ -33,6 +36,7 @@ export default function Home() { > My Profile +
@@ -51,7 +55,7 @@ export default function Home() {

- BaseBoard · 3162 × 3162 grid · Base Mainnet (8453) + BaseBoard · 3162 × 3162 grid · {cfg.name} ({cfg.chainId})

diff --git a/src/components/BaseBoardCanvas.tsx b/src/components/BaseBoardCanvas.tsx index 84d8639..65b9753 100644 --- a/src/components/BaseBoardCanvas.tsx +++ b/src/components/BaseBoardCanvas.tsx @@ -7,14 +7,10 @@ import { useRef, useState, } from "react"; -import { usePublicClient, useAccount } from "wagmi"; -import { baseBoardAbi, baseBoardAddress } from "@/lib/contract"; -import { - BASEBOARD_DEPLOY_BLOCK, - GRID_SIZE, - IS_CONTRACT_CONFIGURED, - ZERO_ADDRESS, -} from "@/lib/constants"; +import { usePublicClient, useAccount, useChainId } from "wagmi"; +import { baseBoardAbi } from "@/lib/contract"; +import { GRID_SIZE, ZERO_ADDRESS } from "@/lib/constants"; +import { useActiveChainConfig } from "@/hooks/useActiveContract"; import { clamp, plotIdFromXY, xyFromPlotId } from "@/lib/coords"; import { parseZone, stripZone } from "@/lib/image"; import type { Plot } from "@/lib/types"; @@ -46,6 +42,8 @@ export function BaseBoardCanvas() { const containerRef = useRef(null); const publicClient = usePublicClient(); const { address } = useAccount(); + const chainId = useChainId(); + const cfg = useActiveChainConfig(); const openPlot = useBoardStore((s) => s.openPlot); const setBuySelection = useBoardStore((s) => s.setBuySelection); @@ -59,6 +57,7 @@ export function BaseBoardCanvas() { const clearBasket = useBoardStore((s) => s.clearBasket); const setDirectBuyIds = useBoardStore((s) => s.setDirectBuyIds); const optimisticPlots = useBoardStore((s) => s.optimisticPlots); + const clearOptimisticPlots = useBoardStore((s) => s.clearOptimisticPlots); const [tool, setTool] = useState("pan"); const [zoomLabel, setZoomLabel] = useState(1); @@ -545,7 +544,7 @@ export function BaseBoardCanvas() { // Data loading for the visible viewport (debounced on camera settle) // ------------------------------------------------------------------- const loadViewport = useCallback(async () => { - if (!IS_CONTRACT_CONFIGURED || !publicClient) return; + if (!cfg.isConfigured || !publicClient) return; const cam = cameraRef.current; const { width, height } = sizeRef.current; const startX = Math.max(0, Math.floor(cam.camX)); @@ -567,7 +566,7 @@ export function BaseBoardCanvas() { try { const result = (await publicClient.readContract({ - address: baseBoardAddress, + address: cfg.contract, abi: baseBoardAbi, functionName: "getPlotsBatch", args: [ids], @@ -587,7 +586,7 @@ export function BaseBoardCanvas() { } catch { /* read failed (rpc / not deployed) — keep prior data */ } - }, [publicClient]); + }, [publicClient, cfg.isConfigured, cfg.contract]); // Debounce viewport loads. const loadTimerRef = useRef | null>(null); @@ -608,7 +607,7 @@ export function BaseBoardCanvas() { const globalLoadingRef = useRef(false); const loadAllMinted = useCallback(async () => { - if (!IS_CONTRACT_CONFIGURED || !publicClient) return; + if (!cfg.isConfigured || !publicClient) return; if (globalLoadingRef.current) return; globalLoadingRef.current = true; try { @@ -616,7 +615,7 @@ export function BaseBoardCanvas() { const from = lastScanBlockRef.current > 0 ? lastScanBlockRef.current + 1 - : BASEBOARD_DEPLOY_BLOCK; + : cfg.deployBlock; // 1) Discover newly-minted plot ids from PlotsPurchased logs (chunked to // respect RPC block-range limits). Base's public RPC caps eth_getLogs @@ -627,7 +626,7 @@ export function BaseBoardCanvas() { const end = Math.min(start + LOG_CHUNK, latest); try { const logs = await publicClient.getContractEvents({ - address: baseBoardAddress, + address: cfg.contract, abi: baseBoardAbi, eventName: "PlotsPurchased", fromBlock: BigInt(start), @@ -651,7 +650,7 @@ export function BaseBoardCanvas() { const slice = all.slice(i, i + READ_CHUNK); try { const res = (await publicClient.readContract({ - address: baseBoardAddress, + address: cfg.contract, abi: baseBoardAbi, functionName: "getPlotsBatch", args: [slice.map((n) => BigInt(n))], @@ -676,7 +675,25 @@ export function BaseBoardCanvas() { } finally { globalLoadingRef.current = false; } - }, [publicClient]); + }, [publicClient, cfg.isConfigured, cfg.contract, cfg.deployBlock]); + + // State isolation: when the active chain changes (Base <-> Celo), wipe every + // cached plot, the minted-id set, the log-scan cursor, decoded images and any + // optimistic overrides so we never show one network's board on another. The + // load effects below re-run automatically (their callbacks are rebuilt from + // the new chain's contract) and repopulate from the active network. + const prevChainRef = useRef(chainId); + useEffect(() => { + if (prevChainRef.current === chainId) return; + prevChainRef.current = chainId; + plotMapRef.current.clear(); + allMintedIdsRef.current.clear(); + lastScanBlockRef.current = 0; + imageCacheRef.current.clear(); + clearOptimisticPlots(); + dirtyRef.current = true; + forceTick((t) => t + 1); + }, [chainId, clearOptimisticPlots]); // Initial global load + light polling so other users' buys/listings appear // without a manual refresh, at every zoom level. diff --git a/src/components/BuyModal.tsx b/src/components/BuyModal.tsx index 017debc..f50baf1 100644 --- a/src/components/BuyModal.tsx +++ b/src/components/BuyModal.tsx @@ -7,19 +7,15 @@ import { Spinner } from "./Spinner"; import { WalletConnect } from "./WalletConnect"; import { useBoardStore } from "@/store/useBoardStore"; import { useBaseBoardWrite } from "@/hooks/useBaseBoard"; -import { baseBoardAbi, baseBoardAddress } from "@/lib/contract"; -import { - IS_CONTRACT_CONFIGURED, - PLOT_PRICE_ETH, - ZERO_ADDRESS, -} from "@/lib/constants"; +import { useActiveChainConfig } from "@/hooks/useActiveContract"; +import { baseBoardAbi } from "@/lib/contract"; +import { ZERO_ADDRESS } from "@/lib/constants"; import { bboxToPlotIds, normalizeBBox, - totalPriceEth, - totalPriceWei, xyFromPlotId, } from "@/lib/coords"; +import { formatEther } from "viem"; import type { Plot } from "@/lib/types"; const MAX_BUY = 400; @@ -34,6 +30,7 @@ export function BuyModal() { const clearOptimisticPlots = useBoardStore((s) => s.clearOptimisticPlots); const { address, isConnected } = useAccount(); const publicClient = usePublicClient(); + const cfg = useActiveChainConfig(); const { writeContractAsync, status, error, reset } = useBaseBoardWrite(); const [buyableIds, setBuyableIds] = useState(null); @@ -66,7 +63,7 @@ export function BuyModal() { if (!open || selectedIds.length === 0 || tooLarge) return; const ids = selectedIds; - if (!IS_CONTRACT_CONFIGURED || !publicClient) { + if (!cfg.isConfigured || !publicClient) { setBuyableIds(ids); // demo mode: treat all as buyable return; } @@ -76,7 +73,7 @@ export function BuyModal() { (async () => { try { const result = (await publicClient.readContract({ - address: baseBoardAddress, + address: cfg.contract, abi: baseBoardAbi, functionName: "getPlotsBatch", args: [ids.map((i) => BigInt(i))], @@ -105,11 +102,11 @@ export function BuyModal() { setTxError(null); try { await writeContractAsync({ - address: baseBoardAddress, + address: cfg.contract, abi: baseBoardAbi, functionName: "buyPlots", args: [buyableIds.map((i) => BigInt(i))], - value: totalPriceWei(buyableIds.length), + value: cfg.plotPriceWei * BigInt(buyableIds.length), }); } catch (e) { setTxError(e instanceof Error ? e.message : "Transaction failed"); @@ -169,8 +166,8 @@ export function BuyModal() {

)}

- {totalCount.toLocaleString()} plots selected · {PLOT_PRICE_ETH} ETH - each + {totalCount.toLocaleString()} plots selected ·{" "} + {cfg.plotPriceLabel} {cfg.nativeSymbol} each

@@ -203,7 +200,8 @@ export function BuyModal() { Total

- {totalPriceEth(buyCount)} ETH + {formatEther(cfg.plotPriceWei * BigInt(buyCount))}{" "} + {cfg.nativeSymbol}

diff --git a/src/components/ChainLogos.tsx b/src/components/ChainLogos.tsx new file mode 100644 index 0000000..c445200 --- /dev/null +++ b/src/components/ChainLogos.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { BASE_CHAIN_ID, CELO_CHAIN_ID } from "@/lib/constants"; + +interface LogoProps { + size?: number; + className?: string; +} + +/** + * Modern square "Basemark" — the white Base disc (flat right edge) on the + * #0052FF rounded square. The flat edge is produced with a clip so it renders + * crisply at any size. + */ +export function BaseLogo({ size = 20, className }: LogoProps) { + const clipId = "baseflat"; + return ( + + ); +} + +/** + * Circular Celo mark — Celo-yellow disc with the concentric-ring motif. + */ +export function CeloLogo({ size = 20, className }: LogoProps) { + return ( + + ); +} + +/** Pick the right logo for a chain id (defaults to the Base mark). */ +export function ChainLogo({ + chainId, + size, + className, +}: { + chainId: number; +} & LogoProps) { + if (chainId === CELO_CHAIN_ID) + return ; + // Base + local default to the Base mark. + void BASE_CHAIN_ID; + return ; +} diff --git a/src/components/HangingHeader.tsx b/src/components/HangingHeader.tsx index 7626208..ef8644c 100644 --- a/src/components/HangingHeader.tsx +++ b/src/components/HangingHeader.tsx @@ -1,10 +1,13 @@ "use client"; +import { useActiveChainConfig } from "@/hooks/useActiveContract"; + /** * The "BaseBoard" title plaque, styled to look like a framed sign hanging from * a nail in the wall by two taut ropes. Pure SVG + CSS, no images. */ export function HangingHeader() { + const cfg = useActiveChainConfig(); return (
{/* Nail driven into the wall */} @@ -52,7 +55,7 @@ export function HangingHeader() { BaseBoard

- 10,000,000 plots · Base Mainnet + 10,000,000 plots · {cfg.name}

diff --git a/src/components/NetworkGuard.tsx b/src/components/NetworkGuard.tsx index c72c3ed..b4957ea 100644 --- a/src/components/NetworkGuard.tsx +++ b/src/components/NetworkGuard.tsx @@ -1,35 +1,48 @@ "use client"; import { useAccount, useChainId, useSwitchChain } from "wagmi"; -import { ACTIVE_CHAIN_ID, DEV_LOCAL } from "@/lib/constants"; +import { + DEFAULT_CHAIN_CONFIG, + DEV_LOCAL, + SUPPORTED_CHAIN_IDS, +} from "@/lib/constants"; import { Spinner } from "./Spinner"; /** - * Renders a sticky warning banner whenever a connected wallet is on the wrong - * network, with a one-click switch to Base Mainnet (8453). + * Sticky warning shown only when a connected wallet is on a network BaseBoard + * doesn't support (anything other than Base 8453 or Celo 42220). Offers a + * one-click switch to the default chain. Supported networks are switched + * between via the header NetworkSwitcher instead. */ export function NetworkGuard() { const { isConnected } = useAccount(); const chainId = useChainId(); const { switchChain, isPending } = useSwitchChain(); - if (!isConnected || chainId === ACTIVE_CHAIN_ID || DEV_LOCAL) return null; + const supported = SUPPORTED_CHAIN_IDS.includes(chainId); + if (!isConnected || supported || DEV_LOCAL) return null; return (
- Wrong network detected. BaseBoard runs on{" "} - Base Mainnet (8453). + Unsupported network. BaseBoard runs on{" "} + Base and Celo.
diff --git a/src/components/NetworkSwitcher.tsx b/src/components/NetworkSwitcher.tsx new file mode 100644 index 0000000..aa60dd4 --- /dev/null +++ b/src/components/NetworkSwitcher.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useChainId, useSwitchChain } from "wagmi"; +import { + CHAIN_CONFIGS, + DEV_LOCAL, + getChainConfig, + type ChainConfig, +} from "@/lib/constants"; +import { ChainLogo } from "./ChainLogos"; +import { Spinner } from "./Spinner"; + +/** + * Header network switcher. Shows the active chain's logo + name and, on click, + * a dropdown of every supported chain (Base square mark / Celo circle mark). + * Selecting one fires the wallet's `wallet_switchEthereumChain` prompt via + * wagmi's `switchChain`, which also isolates the board to that network. + */ +export function NetworkSwitcher() { + const chainId = useChainId(); + const { switchChain, isPending } = useSwitchChain(); + const [open, setOpen] = useState(false); + const [pendingId, setPendingId] = useState(null); + const ref = useRef(null); + + // Hidden in dev-local mode (single local chain) — nothing to switch between. + const chains = CHAIN_CONFIGS; + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + + if (DEV_LOCAL || chains.length < 2) return null; + + const active: ChainConfig | undefined = getChainConfig(chainId); + const activeChainId = active?.chainId ?? chains[0].chainId; + const activeLabel = active?.shortName ?? "Network"; + + const select = (target: number) => { + setOpen(false); + if (target === chainId) return; + setPendingId(target); + switchChain( + { + chainId: target as Parameters[0]["chainId"], + }, + { onSettled: () => setPendingId(null) }, + ); + }; + + return ( +
+ + + {open && ( +
+ {chains.map((c) => { + const isActive = c.chainId === chainId; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/PlotModal.tsx b/src/components/PlotModal.tsx index be164d4..7993cfe 100644 --- a/src/components/PlotModal.tsx +++ b/src/components/PlotModal.tsx @@ -8,7 +8,8 @@ import { Spinner } from "./Spinner"; import { WalletConnect } from "./WalletConnect"; import { useBoardStore } from "@/store/useBoardStore"; import { usePlot, useOffer, useBaseBoardWrite } from "@/hooks/useBaseBoard"; -import { baseBoardAbi, baseBoardAddress } from "@/lib/contract"; +import { useActiveChainConfig } from "@/hooks/useActiveContract"; +import { baseBoardAbi } from "@/lib/contract"; import { shortAddress, xyFromPlotId } from "@/lib/coords"; export function PlotModal() { @@ -17,6 +18,7 @@ export function PlotModal() { const setProfileOpen = useBoardStore((s) => s.setProfileOpen); const { address, isConnected } = useAccount(); + const cfg = useActiveChainConfig(); const { plot, isOwned, isLoading } = usePlot(activePlotId); const { data: myOfferRaw } = useOffer(activePlotId, address); const { writeContractAsync, status, error, reset } = useBaseBoardWrite(); @@ -43,7 +45,7 @@ export function PlotModal() { const onBuyNow = () => run(() => writeContractAsync({ - address: baseBoardAddress, + address: cfg.contract, abi: baseBoardAbi, functionName: "buyListedPlot", args: [BigInt(activePlotId!)], @@ -65,7 +67,7 @@ export function PlotModal() { } return run(() => writeContractAsync({ - address: baseBoardAddress, + address: cfg.contract, abi: baseBoardAbi, functionName: "placeOffer", args: [BigInt(activePlotId!)], @@ -77,7 +79,7 @@ export function PlotModal() { const onCancelOffer = () => run(() => writeContractAsync({ - address: baseBoardAddress, + address: cfg.contract, abi: baseBoardAbi, functionName: "cancelOffer", args: [BigInt(activePlotId!)], diff --git a/src/components/ProfileDrawer.tsx b/src/components/ProfileDrawer.tsx index 23ee0fb..991d283 100644 --- a/src/components/ProfileDrawer.tsx +++ b/src/components/ProfileDrawer.tsx @@ -7,8 +7,9 @@ import { Spinner } from "./Spinner"; import { WalletConnect } from "./WalletConnect"; import { useBoardStore } from "@/store/useBoardStore"; import { usePlotsByOwner, useBaseBoardWrite } from "@/hooks/useBaseBoard"; -import { baseBoardAbi, baseBoardAddress } from "@/lib/contract"; -import { IS_CONTRACT_CONFIGURED, ZERO_ADDRESS } from "@/lib/constants"; +import { useActiveChainConfig } from "@/hooks/useActiveContract"; +import { baseBoardAbi } from "@/lib/contract"; +import { ZERO_ADDRESS } from "@/lib/constants"; import { shortAddress, xyFromPlotId } from "@/lib/coords"; import { MAX_ONCHAIN_IMAGE_BYTES, @@ -69,6 +70,7 @@ type MinimalPublicClient = { */ async function preflightImageUpdate( publicClient: MinimalPublicClient | null | undefined, + contract: `0x${string}`, account: `0x${string}` | undefined, plotId: number, uri: string, @@ -81,7 +83,7 @@ async function preflightImageUpdate( if (!publicClient) return null; try { const plot = (await publicClient.readContract({ - address: baseBoardAddress, + address: contract, abi: baseBoardAbi, functionName: "getPlot", args: [BigInt(plotId)], @@ -101,6 +103,7 @@ export function ProfileDrawer() { const refreshNonce = useBoardStore((s) => s.refreshNonce); const { address, isConnected } = useAccount(); const publicClient = usePublicClient(); + const cfg = useActiveChainConfig(); const { ids, isLoading } = usePlotsByOwner(address); const [details, setDetails] = useState>({}); @@ -118,7 +121,7 @@ export function ProfileDrawer() { }, [profileOpen]); useEffect(() => { - if (!IS_CONTRACT_CONFIGURED || !publicClient || ids.length === 0) { + if (!cfg.isConfigured || !publicClient || ids.length === 0) { setDetails({}); return; } @@ -126,7 +129,7 @@ export function ProfileDrawer() { (async () => { try { const result = (await publicClient.readContract({ - address: baseBoardAddress, + address: cfg.contract, abi: baseBoardAbi, functionName: "getPlotsBatch", args: [ids.map((i) => BigInt(i))], @@ -144,7 +147,8 @@ export function ProfileDrawer() { return () => { cancelled = true; }; - }, [ids.join(","), refreshNonce]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ids.join(","), refreshNonce, cfg.contract, cfg.isConfigured]); return ( <> @@ -213,11 +217,10 @@ export function ProfileDrawer() {

- ) : !IS_CONTRACT_CONFIGURED ? ( + ) : !cfg.isConfigured ? (

- Contract not configured. Deploy BaseBoard.sol and set{" "} - NEXT_PUBLIC_BASEBOARD_CONTRACT_ADDRESS to manage your - plots. + No BaseBoard contract is configured on {cfg.name}. Deploy + BaseBoard.sol on this network to manage your plots here.

) : isLoading ? (
@@ -305,6 +308,7 @@ function OwnedPlotRow({ const { x, y } = xyFromPlotId(plotId); const { address } = useAccount(); const publicClient = usePublicClient(); + const cfg = useActiveChainConfig(); const setFocusPlotId = useBoardStore((s) => s.setFocusPlotId); const setProfileOpen = useBoardStore((s) => s.setProfileOpen); const pushToast = useBoardStore((s) => s.pushToast); @@ -375,7 +379,7 @@ function OwnedPlotRow({ pendingOverrideRef.current = override({ isForSale: true, price: v }); void submit("Listing", () => writeContractAsync({ - address: baseBoardAddress, + address: cfg.contract, abi: baseBoardAbi, functionName: "listPlot", args: [BigInt(plotId), v], @@ -389,7 +393,7 @@ function OwnedPlotRow({ pendingOverrideRef.current = override({ isForSale: true, price: v }); void submit("Price update", () => writeContractAsync({ - address: baseBoardAddress, + address: cfg.contract, abi: baseBoardAbi, functionName: "updatePlotPrice", args: [BigInt(plotId), v], @@ -401,7 +405,7 @@ function OwnedPlotRow({ pendingOverrideRef.current = override({ isForSale: false, price: 0n }); void submit("Cancel listing", () => writeContractAsync({ - address: baseBoardAddress, + address: cfg.contract, abi: baseBoardAbi, functionName: "cancelListing", args: [BigInt(plotId)], @@ -412,6 +416,7 @@ function OwnedPlotRow({ const onSaveImage = async (uri: string) => { const problem = await preflightImageUpdate( publicClient as MinimalPublicClient | undefined, + cfg.contract, address, plotId, uri, @@ -426,7 +431,7 @@ function OwnedPlotRow({ pendingOverrideRef.current = override({ imageUri: uri.trim() }); await submit("Image update", () => writeContractAsync({ - address: baseBoardAddress, + address: cfg.contract, abi: baseBoardAbi, functionName: "updatePlotImage", args: [BigInt(plotId), uri.trim()], @@ -605,6 +610,7 @@ function MultiImagePanel({ const applyOptimisticPlots = useBoardStore((s) => s.applyOptimisticPlots); const { address } = useAccount(); const publicClient = usePublicClient(); + const cfg = useActiveChainConfig(); const { writeContractAsync, status, isSuccess } = useBaseBoardWrite(); const [pending, setPending] = useState(false); const pendingOverrideRef = useRef | null>(null); @@ -643,6 +649,7 @@ function MultiImagePanel({ const finalUri = withZone(uri, zone); const problem = await preflightImageUpdate( publicClient as MinimalPublicClient | undefined, + cfg.contract, address, anchorId, finalUri, @@ -664,7 +671,7 @@ function MultiImagePanel({ setPending(true); try { await writeContractAsync({ - address: baseBoardAddress, + address: cfg.contract, abi: baseBoardAbi, functionName: "updatePlotImage", args: [BigInt(anchorId), finalUri], diff --git a/src/components/WalletConnect.tsx b/src/components/WalletConnect.tsx index 772c321..9fb5514 100644 --- a/src/components/WalletConnect.tsx +++ b/src/components/WalletConnect.tsx @@ -18,65 +18,112 @@ import { useChainId, useConnect, useDisconnect, - useSwitchChain, } from "wagmi"; -import { ACTIVE_CHAIN_ID, DEV_LOCAL } from "@/lib/constants"; +import { CELO_CHAIN_ID, DEV_LOCAL } from "@/lib/constants"; import { shortAddress } from "@/lib/coords"; +/** Whether a connector is Coinbase Wallet / Smart Wallet. */ +function isCoinbaseConnector(id: string, name: string): boolean { + return /coinbase/i.test(id) || /coinbase/i.test(name); +} + /** - * Connect / account button powered by OnchainKit. Supports Coinbase Wallet, - * MetaMask / injected wallets, and (when configured) WalletConnect. When the - * wallet is connected to the wrong chain, a prominent "Switch to Base" button - * is shown instead so the app only ever transacts on Base Mainnet (8453). + * Connect / account button. On Base it uses OnchainKit's Coinbase-first flow. + * On Celo — which Coinbase Smart Wallet does not support — it falls back to a + * connector list that hides Coinbase and steers users to MetaMask / Rabby / + * WalletConnect instead. */ export function WalletConnect() { // Dev-only: connect straight to the injected local provider so the full flow // can be tested against a Hardhat node without a real wallet popup. if (DEV_LOCAL) return ; + return ; +} + +function ChainAwareWalletConnect() { + const chainId = useChainId(); + const isCelo = chainId === CELO_CHAIN_ID; + + if (isCelo) return ; + return ( -
- - - - + + + + + + + + - - - - - -
- - - - - -
+
+ + + + + ); } /** - * Prominent "Wrong Network" pill + one-tap switch to Base Mainnet. Renders - * nothing when disconnected or already on the correct chain. + * Celo connect UI: Coinbase Smart Wallet is hidden because it cannot transact + * on Celo Mainnet. Offers MetaMask / Rabby (injected) and WalletConnect. */ -function NetworkSwitchButton() { - const { isConnected } = useAccount(); - const chainId = useChainId(); - const { switchChain, isPending } = useSwitchChain(); +function CeloWalletConnect() { + const { isConnected, address } = useAccount(); + const { connect, connectors, isPending } = useConnect(); + const { disconnect } = useDisconnect(); + + if (isConnected) { + return ( + + ); + } - if (!isConnected || chainId === ACTIVE_CHAIN_ID) return null; + const usable = connectors.filter( + (c) => !isCoinbaseConnector(c.id, c.name), + ); + // De-dupe by id (some setups register multiple injected entries). + const seen = new Set(); + const list = usable.filter((c) => { + if (seen.has(c.id)) return false; + seen.add(c.id); + return true; + }); + + const label = (id: string, name: string): string => { + if (id === "injected") return "MetaMask / Rabby / Browser Wallet"; + if (/walletconnect/i.test(id)) return "WalletConnect"; + return name; + }; return ( - +
+
+ {list.map((c) => ( + + ))} +
+

+ Coinbase Smart Wallet isn't supported on Celo — connect with + MetaMask, Rabby or WalletConnect. +

+
); } diff --git a/src/hooks/useActiveContract.ts b/src/hooks/useActiveContract.ts new file mode 100644 index 0000000..a60f542 --- /dev/null +++ b/src/hooks/useActiveContract.ts @@ -0,0 +1,19 @@ +"use client"; + +import { useChainId } from "wagmi"; +import { + DEFAULT_CHAIN_CONFIG, + getChainConfig, + type ChainConfig, +} from "@/lib/constants"; + +/** + * Resolve the BaseBoard config (contract address, mint price, treasury, deploy + * block, native symbol …) for the wallet's currently-active chain. Falls back + * to the default chain (Base in production) when disconnected or on an + * unsupported network, so reads/writes always target a coherent contract. + */ +export function useActiveChainConfig(): ChainConfig { + const chainId = useChainId(); + return getChainConfig(chainId) ?? DEFAULT_CHAIN_CONFIG; +} diff --git a/src/hooks/useBaseBoard.ts b/src/hooks/useBaseBoard.ts index 2b09ee2..fbbfc4f 100644 --- a/src/hooks/useBaseBoard.ts +++ b/src/hooks/useBaseBoard.ts @@ -3,34 +3,46 @@ import { useCallback, useEffect } from "react"; import { useAccount, + useChainId, useReadContract, - useSwitchChain, useWatchContractEvent, useWriteContract, useWaitForTransactionReceipt, } from "wagmi"; -import { base } from "viem/chains"; -import { baseBoardAbi, baseBoardAddress } from "@/lib/contract"; +import { base, celo, hardhat } from "viem/chains"; +import type { Chain } from "viem"; +import { baseBoardAbi } from "@/lib/contract"; import { - ACTIVE_CHAIN_ID, + DEFAULT_CHAIN_CONFIG, DISPLAY_MAX_PLOTS, - IS_CONTRACT_CONFIGURED, + getChainConfig, ZERO_ADDRESS, } from "@/lib/constants"; +import { useActiveChainConfig } from "./useActiveContract"; import type { Plot } from "@/lib/types"; import { useBoardStore } from "@/store/useBoardStore"; -const sharedReadConfig = { - address: baseBoardAddress, - abi: baseBoardAbi, -} as const; +/** viem chain object for a given chain id (for pinning write transactions). */ +function viemChainFor(chainId: number): Chain { + switch (chainId) { + case celo.id: + return celo; + case hardhat.id: + return hardhat; + default: + return base; + } +} export function useBoardStats() { + const cfg = useActiveChainConfig(); + const sharedReadConfig = { address: cfg.contract, abi: baseBoardAbi } as const; + const { data, refetch, isLoading } = useReadContract({ ...sharedReadConfig, functionName: "totalPlotsSold", query: { - enabled: IS_CONTRACT_CONFIGURED, + enabled: cfg.isConfigured, refetchInterval: 15_000, }, }); @@ -38,13 +50,13 @@ export function useBoardStats() { useWatchContractEvent({ ...sharedReadConfig, eventName: "PlotsPurchased", - enabled: IS_CONTRACT_CONFIGURED, + enabled: cfg.isConfigured, onLogs: () => { void refetch(); }, }); - const sold = IS_CONTRACT_CONFIGURED && data != null ? Number(data) : 0; + const sold = cfg.isConfigured && data != null ? Number(data) : 0; const remaining = Math.max(0, DISPLAY_MAX_PLOTS - sold); return { sold, remaining, isLoading, refetch }; @@ -52,7 +64,9 @@ export function useBoardStats() { export function usePlot(plotId: number | null) { const { refreshNonce } = useBoardStore(); - const enabled = IS_CONTRACT_CONFIGURED && plotId != null; + const cfg = useActiveChainConfig(); + const sharedReadConfig = { address: cfg.contract, abi: baseBoardAbi } as const; + const enabled = cfg.isConfigured && plotId != null; const query = useReadContract({ ...sharedReadConfig, @@ -73,7 +87,9 @@ export function usePlot(plotId: number | null) { } export function useOffer(plotId: number | null, bidder?: `0x${string}`) { - const enabled = IS_CONTRACT_CONFIGURED && plotId != null && !!bidder; + const cfg = useActiveChainConfig(); + const sharedReadConfig = { address: cfg.contract, abi: baseBoardAbi } as const; + const enabled = cfg.isConfigured && plotId != null && !!bidder; return useReadContract({ ...sharedReadConfig, functionName: "offers", @@ -84,7 +100,9 @@ export function useOffer(plotId: number | null, bidder?: `0x${string}`) { export function usePlotsByOwner(account?: `0x${string}`) { const { refreshNonce } = useBoardStore(); - const enabled = IS_CONTRACT_CONFIGURED && !!account; + const cfg = useActiveChainConfig(); + const sharedReadConfig = { address: cfg.contract, abi: baseBoardAbi } as const; + const enabled = cfg.isConfigured && !!account; const query = useReadContract({ ...sharedReadConfig, @@ -108,6 +126,7 @@ export function usePlotsByOwner(account?: `0x${string}`) { export function useBaseBoardWrite() { const bumpRefresh = useBoardStore((s) => s.bumpRefresh); const { connector } = useAccount(); + const chainId = useChainId(); const { writeContract, writeContractAsync, @@ -116,7 +135,6 @@ export function useBaseBoardWrite() { error, reset, } = useWriteContract(); - const { switchChainAsync } = useSwitchChain(); const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash, @@ -129,21 +147,32 @@ export function useBaseBoardWrite() { type WriteAsync = typeof writeContractAsync; const writeContractAsyncGuarded = useCallback( ((variables, options) => { + // Pin every write to the *active* chain (Base or Celo) so transactions + // always land on the network whose contract the UI is reading from. + const cfg = getChainConfig(chainId) ?? DEFAULT_CHAIN_CONFIG; + const targetChainId = cfg.chainId; + const targetChain = viemChainFor(targetChainId); + const doWrite = () => writeContractAsync( - { ...variables, chainId: ACTIVE_CHAIN_ID, chain: base }, + { ...variables, chainId: targetChainId, chain: targetChain }, options, ); - const ensureBaseAndWrite = async () => { + const ensureChainAndWrite = async () => { if (connector) { - const provider = await connector.getProvider() as { - request: (args: { method: string; params?: unknown[] }) => Promise; + const provider = (await connector.getProvider()) as { + request: (args: { + method: string; + params?: unknown[]; + }) => Promise; }; - const rawId = await provider.request({ method: "eth_chainId" }) as string; + const rawId = (await provider.request({ + method: "eth_chainId", + })) as string; const realChainId = parseInt(rawId, 16); - if (realChainId !== ACTIVE_CHAIN_ID) { - const hexChainId = `0x${ACTIVE_CHAIN_ID.toString(16)}`; + if (realChainId !== targetChainId) { + const hexChainId = `0x${targetChainId.toString(16)}`; try { await provider.request({ method: "wallet_switchEthereumChain", @@ -154,16 +183,24 @@ export function useBaseBoardWrite() { if (code === 4902 || code === -32603) { await provider.request({ method: "wallet_addEthereumChain", - params: [{ - chainId: hexChainId, - chainName: "Base", - nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, - rpcUrls: ["https://mainnet.base.org"], - blockExplorerUrls: ["https://basescan.org"], - }], + params: [ + { + chainId: hexChainId, + chainName: cfg.name, + nativeCurrency: { + name: cfg.nativeSymbol, + symbol: cfg.nativeSymbol, + decimals: 18, + }, + rpcUrls: [cfg.rpcUrl], + blockExplorerUrls: cfg.explorer ? [cfg.explorer] : [], + }, + ], }); } else { - throw new Error("Please switch to Base Mainnet in your wallet to continue."); + throw new Error( + `Please switch to ${cfg.name} in your wallet to continue.`, + ); } } } @@ -171,9 +208,9 @@ export function useBaseBoardWrite() { return doWrite(); }; - return ensureBaseAndWrite(); + return ensureChainAndWrite(); }) as WriteAsync, - [writeContractAsync, switchChainAsync, connector], + [writeContractAsync, connector, chainId], ); const status = isPending diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 275fa70..908ff84 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,5 +1,5 @@ import { parseEther } from "viem"; -import { base } from "wagmi/chains"; +import { base, celo } from "wagmi/chains"; /** Width / height of the square grid (3162 x 3162 = 9,998,244 plots). */ export const GRID_SIZE = 3162; @@ -67,3 +67,134 @@ export const ONCHAINKIT_API_KEY = /** WalletConnect project id (optional). */ export const WALLETCONNECT_PROJECT_ID = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || ""; + +// --------------------------------------------------------------------------- +// Multi-chain configuration (Base + Celo) +// --------------------------------------------------------------------------- +// BaseBoard runs concurrently on Base Mainnet (8453) and Celo Mainnet (42220). +// Every read/write resolves its contract address, mint price, treasury and +// log-scan deploy block from the *active* chain via `getChainConfig(chainId)`, +// so switching networks transparently isolates state to that chain. Base's +// shipped behaviour is untouched — its config below mirrors the original +// constants exactly. + +export const BASE_CHAIN_ID = base.id; // 8453 +export const CELO_CHAIN_ID = celo.id; // 42220 + +/** Celo Mainnet contract — placeholder until a BaseBoard is deployed there. */ +export const CELO_CONTRACT_ADDRESS = (process.env + .NEXT_PUBLIC_CELO_CONTRACT_ADDRESS || + "0x71aad1110dfd8f60249cd45ce4fb05163b6f812b") as `0x${string}`; + +/** + * Treasury that receives Celo mint fees. The real routing lives *inside* the + * deployed contract; this constant is for the frontend (display / parity) and + * should match the `TREASURY` baked into the Celo deployment. + */ +export const CELO_TREASURY_ADDRESS = (process.env + .NEXT_PUBLIC_CELO_TREASURY_ADDRESS || + "0x71aad1110dfd8f60249cd45ce4fb05163b6f812b") as `0x${string}`; + +/** + * Flat primary price per plot on Celo, as a decimal-CELO string. Mirrors the + * 0.1¢ ($0.001) target of the Base fee, expressed in CELO. Placeholder — + * override per CELO/USD via `NEXT_PUBLIC_CELO_PLOT_PRICE`. + */ +export const CELO_PLOT_PRICE = process.env.NEXT_PUBLIC_CELO_PLOT_PRICE || "0.002"; +export const CELO_PLOT_PRICE_WEI = parseEther(CELO_PLOT_PRICE); + +/** Block the Celo BaseBoard was deployed at (0 until deployed). */ +export const CELO_DEPLOY_BLOCK = Number( + process.env.NEXT_PUBLIC_CELO_DEPLOY_BLOCK || "0", +); + +/** Per-chain configuration consumed everywhere reads/writes happen. */ +export interface ChainConfig { + chainId: number; + /** Display name (e.g. "Base Mainnet"). */ + name: string; + /** Short label shown in the switcher (e.g. "Base"). */ + shortName: string; + /** Deployed BaseBoard contract for this chain. */ + contract: `0x${string}`; + /** Whether a non-zero contract address is configured. */ + isConfigured: boolean; + /** Flat primary price per plot, in wei (native units). */ + plotPriceWei: bigint; + /** Decimal price string for display. */ + plotPriceLabel: string; + /** Native currency symbol (ETH / CELO). */ + nativeSymbol: string; + /** Treasury receiving mint fees (on-chain authoritative). */ + treasury: `0x${string}`; + /** Block to start `PlotsPurchased` log scans from. */ + deployBlock: number; + /** Default RPC url (for wallet_addEthereumChain). */ + rpcUrl: string; + /** Block explorer base url. */ + explorer: string; +} + +const isNonZero = (addr: string) => + addr.toLowerCase() !== ZERO_ADDRESS.toLowerCase(); + +const BASE_CONFIG: ChainConfig = { + chainId: BASE_CHAIN_ID, + name: "Base Mainnet", + shortName: "Base", + contract: CONTRACT_ADDRESS, + isConfigured: isNonZero(CONTRACT_ADDRESS), + plotPriceWei: PLOT_PRICE_WEI, + plotPriceLabel: PLOT_PRICE_ETH, + nativeSymbol: "ETH", + treasury: TREASURY_ADDRESS, + deployBlock: BASEBOARD_DEPLOY_BLOCK, + rpcUrl: "https://mainnet.base.org", + explorer: "https://basescan.org", +}; + +const CELO_CONFIG: ChainConfig = { + chainId: CELO_CHAIN_ID, + name: "Celo Mainnet", + shortName: "Celo", + contract: CELO_CONTRACT_ADDRESS, + isConfigured: isNonZero(CELO_CONTRACT_ADDRESS), + plotPriceWei: CELO_PLOT_PRICE_WEI, + plotPriceLabel: CELO_PLOT_PRICE, + nativeSymbol: "CELO", + treasury: CELO_TREASURY_ADDRESS, + deployBlock: CELO_DEPLOY_BLOCK, + rpcUrl: "https://forno.celo.org", + explorer: "https://celoscan.io", +}; + +const LOCAL_CONFIG: ChainConfig = { + chainId: LOCAL_CHAIN_ID, + name: "Local Hardhat", + shortName: "Local", + contract: CONTRACT_ADDRESS, + isConfigured: isNonZero(CONTRACT_ADDRESS), + plotPriceWei: PLOT_PRICE_WEI, + plotPriceLabel: PLOT_PRICE_ETH, + nativeSymbol: "ETH", + treasury: TREASURY_ADDRESS, + deployBlock: 0, + rpcUrl: "http://127.0.0.1:8545", + explorer: "", +}; + +/** All chains the app supports in the current mode. */ +export const CHAIN_CONFIGS: ChainConfig[] = DEV_LOCAL + ? [LOCAL_CONFIG] + : [BASE_CONFIG, CELO_CONFIG]; + +/** Chain shown / targeted when disconnected or on an unsupported network. */ +export const DEFAULT_CHAIN_CONFIG: ChainConfig = CHAIN_CONFIGS[0]; + +export const SUPPORTED_CHAIN_IDS = CHAIN_CONFIGS.map((c) => c.chainId); + +/** Resolve the config for a chain id, or `undefined` if unsupported. */ +export function getChainConfig(chainId?: number): ChainConfig | undefined { + if (chainId == null) return undefined; + return CHAIN_CONFIGS.find((c) => c.chainId === chainId); +} diff --git a/src/lib/wagmi.ts b/src/lib/wagmi.ts index a15209b..207bccb 100644 --- a/src/lib/wagmi.ts +++ b/src/lib/wagmi.ts @@ -1,5 +1,5 @@ import { http, createConfig, createStorage, cookieStorage } from "wagmi"; -import { base, hardhat } from "wagmi/chains"; +import { base, celo, hardhat } from "wagmi/chains"; import { coinbaseWallet, injected, walletConnect } from "wagmi/connectors"; import { DEV_LOCAL, WALLETCONNECT_PROJECT_ID } from "./constants"; @@ -72,8 +72,10 @@ function installLocalProvider() { } /** - * wagmi config restricted to Base Mainnet (8453). Supports Coinbase Wallet, - * MetaMask / injected wallets, and (optionally) WalletConnect. + * wagmi config for Base Mainnet (8453) + Celo Mainnet (42220), running + * concurrently. Supports Coinbase Wallet, MetaMask / injected wallets, and + * (optionally) WalletConnect. Note: Coinbase Smart Wallet does not support + * Celo — the connect UI hides it while Celo is the active chain. */ export function getWagmiConfig() { installLocalProvider(); @@ -110,8 +112,11 @@ export function getWagmiConfig() { return createConfig({ ...shared, - chains: [base], - transports: { [base.id]: http() }, + chains: [base, celo], + transports: { + [base.id]: http(), + [celo.id]: http(), + }, }); }