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
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 (
+
- 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(),
+ },
});
}