From 8b9ab1ef3474413d160a1d7cf873b5427168009f Mon Sep 17 00:00:00 2001 From: Fielding Johnston Date: Fri, 24 Apr 2026 21:21:39 -0500 Subject: [PATCH 1/2] feat(chains): registry-driven wagmi config + wrong-chain guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wagmi.ts now derives its chains array from the registry, filtered by the deployment's testnet flag. Adding a chain to chains.ts (with a matching entry in the chainIdToWagmiChain map) auto-expands the wallet picker — no more manual sync between two sources of truth. Today the wallet picker still shows only Base on the mainnet site (Base Sepolia on testnet) because those are the only registry entries. RG-644a80 will populate Ethereum, Arbitrum, Optimism, Polygon, Avalanche, and BNB Chain entries with verified Sablier v2.0 + USDC addresses; once those land in chains.ts, this PR's wagmi derivation will pick them up automatically. WrongChainPanel: a new client component that swaps the page content for a "switch your wallet" prompt when useChainId() returns a chainId not in our registry. Used to guard /create (block tx flow) and /vaults (block stream queries). The landing falls back to the default chain's contract link so the page still renders normally. Defensive fallback in /create, /vaults, and /. Before this PR, a user manually switching their wallet to an unsupported chain (e.g. mainnet Ethereum on the Base deployment) would crash the page on getChainConfig(chainId) throwing. Now those pages fall back to DEFAULT_CHAIN_ID for display values; WrongChainPanel handles the UX. Refs: RG-b53952 Co-Authored-By: Claude Opus 4.7 (1M context) --- .tix/issues.jsonl | 1 + packages/app/src/app/create/page.tsx | 13 ++++- packages/app/src/app/page.tsx | 8 ++- packages/app/src/app/vaults/page.tsx | 14 ++++- .../app/src/components/WrongChainPanel.tsx | 57 +++++++++++++++++++ packages/app/src/config/wagmi.ts | 28 ++++++++- 6 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 packages/app/src/components/WrongChainPanel.tsx diff --git a/.tix/issues.jsonl b/.tix/issues.jsonl index 8748960..00282b9 100644 --- a/.tix/issues.jsonl +++ b/.tix/issues.jsonl @@ -40,3 +40,4 @@ {"type":"comment","id":"01KQ00SHT9A0933RQ0AN9ZBVGQ","issue_id":"RG-bd4d82","author":"claude","body":"PR: https://github.com/fielding/ripguard/pull/8 — behavior-preserving registry introduction. Call-site refactor lands in RG-9430aa.","created_at":1777043556169} {"type":"issue","id":"RG-9430aa","title":"Refactor create + vaults pages to useChainId() lookups","body":"","status":"in_progress","priority":2,"assignee":"","created_at":1777031317780,"updated_at":1777083286869,"tags":["multichain","evm"]} {"type":"issue","id":"RG-42e939","title":"Per-chain USDC decimals (BNB=18, others=6)","body":"","status":"in_progress","priority":2,"assignee":"","created_at":1777031317799,"updated_at":1777083286894,"tags":["multichain","evm"]} +{"type":"issue","id":"RG-b53952","title":"Update wagmi config for multi-chain array + wrong-chain guard on /create","body":"","status":"in_progress","priority":2,"assignee":"","created_at":1777031317760,"updated_at":1777083687610,"tags":["multichain","evm"]} diff --git a/packages/app/src/app/create/page.tsx b/packages/app/src/app/create/page.tsx index fb6f24d..28ad943 100644 --- a/packages/app/src/app/create/page.tsx +++ b/packages/app/src/app/create/page.tsx @@ -18,10 +18,11 @@ import { brokerFeeForTreasury, brokerFeePctString, } from "@/config/contracts"; -import { getChainConfig } from "@/config/chains"; +import { getChainConfig, isSupportedChain, DEFAULT_CHAIN_ID } from "@/config/chains"; import { erc20Abi, sablierLockupAbi, testUsdcAbi } from "@/config/abis"; import { ShareCard } from "@/components/ShareCard"; import { ErrorBoundary } from "@/components/ErrorBoundary"; +import { WrongChainPanel } from "@/components/WrongChainPanel"; import { useToast } from "@/components/Toast"; import { trackLockCreated, trackLockApproved, trackContractError } from "@/lib/analytics"; import { isUserRejection, extractErrorReason } from "@/lib/errors"; @@ -112,13 +113,19 @@ function CreateLockInner() { const { address, isConnected } = useAccount(); const chainId = useChainId(); + // Defensive lookup: if the wallet is on a chain we don't support, fall + // back to the deployment's default chain so the page can still render. + // The wrong-chain panel below blocks any tx attempt on the unsupported chain. const { sablierLockup, usdc: usdcAddress, usdcDecimals, treasury, explorerUrl, - } = useMemo(() => getChainConfig(chainId), [chainId]); + } = useMemo( + () => getChainConfig(isSupportedChain(chainId) ? chainId : DEFAULT_CHAIN_ID), + [chainId] + ); const brokerFee = brokerFeeForTreasury(treasury); const brokerFeePct = brokerFeePctString(brokerFee); // 1 USDC minimum, scaled to the chain's decimals (6 on most, 18 on BNB). @@ -596,6 +603,7 @@ function CreateLockInner() {
+ {step === "success" ? ( )} +
diff --git a/packages/app/src/app/page.tsx b/packages/app/src/app/page.tsx index f100b52..1517d89 100644 --- a/packages/app/src/app/page.tsx +++ b/packages/app/src/app/page.tsx @@ -1,7 +1,7 @@ "use client"; import { PRESETS, IS_TESTNET } from "@/config/contracts"; -import { getChainConfig } from "@/config/chains"; +import { getChainConfig, isSupportedChain, DEFAULT_CHAIN_ID } from "@/config/chains"; import { useChainId } from "wagmi"; import Link from "next/link"; import { Header } from "@/components/Header"; @@ -120,7 +120,11 @@ function FAQItem({ export default function Home() { const chainId = useChainId(); - const { sablierLockup, explorerUrl } = getChainConfig(chainId); + // Unconnected users + users on unsupported chains see the default chain's + // contract link so the landing always has somewhere real to point at. + const { sablierLockup, explorerUrl } = getChainConfig( + isSupportedChain(chainId) ? chainId : DEFAULT_CHAIN_ID + ); const sablierExplorerUrl = `${explorerUrl}/address/${sablierLockup}`; const githubUrl = "https://github.com/fielding/ripguard"; diff --git a/packages/app/src/app/vaults/page.tsx b/packages/app/src/app/vaults/page.tsx index 0de1eb9..68a0f28 100644 --- a/packages/app/src/app/vaults/page.tsx +++ b/packages/app/src/app/vaults/page.tsx @@ -13,8 +13,9 @@ import { useWriteContract, useWaitForTransactionReceipt, } from "wagmi"; -import { getChainConfig, type ChainConfig } from "@/config/chains"; +import { getChainConfig, isSupportedChain, DEFAULT_CHAIN_ID, type ChainConfig } from "@/config/chains"; import { sablierLockupAbi } from "@/config/abis"; +import { WrongChainPanel } from "@/components/WrongChainPanel"; import { ShareCard } from "@/components/ShareCard"; import { ErrorBoundary, CardErrorBoundary } from "@/components/ErrorBoundary"; import { useToast } from "@/components/Toast"; @@ -420,7 +421,12 @@ async function fetchFromChain( function VaultDashboard() { const { address, isConnected } = useAccount(); const chainId = useChainId(); - const chainConfig = getChainConfig(chainId); + // Defensive lookup: unsupported chains fall back to the deployment's + // default chain so the page renders. The wrong-chain panel below blocks + // any tx on the unsupported chain. + const chainConfig = getChainConfig( + isSupportedChain(chainId) ? chainId : DEFAULT_CHAIN_ID + ); const { sablierLockup, usdcDecimals, explorerUrl } = chainConfig; const publicClient = usePublicClient(); const { toast } = useToast(); @@ -813,7 +819,9 @@ export default function Vaults() {
- + + +
diff --git a/packages/app/src/components/WrongChainPanel.tsx b/packages/app/src/components/WrongChainPanel.tsx new file mode 100644 index 0000000..42f034a --- /dev/null +++ b/packages/app/src/components/WrongChainPanel.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useChainId, useSwitchChain } from "wagmi"; +import { CHAINS, isSupportedChain } from "@/config/chains"; +import { IS_TESTNET } from "@/config/contracts"; + +/** Renders a "switch chain" prompt when the wallet is on a chain RipGuard + * doesn't support yet. Returns null when the chain is supported, so the + * caller can wrap their page content like: + * + * ```tsx + * {normalContent} + * ``` + */ +export function WrongChainPanel({ children }: { children: React.ReactNode }) { + const chainId = useChainId(); + const { switchChain, isPending } = useSwitchChain(); + + if (isSupportedChain(chainId)) { + return <>{children}; + } + + const supportedForDeployment = Object.values(CHAINS).filter( + (c) => c.isTestnet === IS_TESTNET + ); + + return ( +
+
Wrong network
+

+ Your wallet is on a chain we don't support yet. +

+

+ RipGuard runs on{" "} + {supportedForDeployment.map((c, i) => ( + + {i > 0 && i === supportedForDeployment.length - 1 ? " and " : i > 0 ? ", " : ""} + {c.name} + + ))} + . Switch your wallet to lock USDC. +

+
+ {supportedForDeployment.map((c) => ( + + ))} +
+
+ ); +} diff --git a/packages/app/src/config/wagmi.ts b/packages/app/src/config/wagmi.ts index 3a2c24f..f5297e7 100644 --- a/packages/app/src/config/wagmi.ts +++ b/packages/app/src/config/wagmi.ts @@ -1,8 +1,32 @@ import { getDefaultConfig } from "@rainbow-me/rainbowkit"; -import { base, baseSepolia } from "wagmi/chains"; +import * as wagmiChains from "wagmi/chains"; +import { type Chain } from "wagmi/chains"; +import { CHAINS } from "./chains"; + +// Map a registry chainId to its wagmi chain definition. Add an entry here +// when adding a chain to the registry so wagmi can pick it up. +const chainIdToWagmiChain: Record = { + [wagmiChains.base.id]: wagmiChains.base, + [wagmiChains.baseSepolia.id]: wagmiChains.baseSepolia, +}; const isTestnet = process.env.NEXT_PUBLIC_CHAIN === "base-sepolia"; +// Derive wagmi's chain list from the registry, filtered to chains matching +// the deployment's testnet flag. Registry ⇒ wagmi is one-way: adding a +// chain to chains.ts (with a corresponding wagmi mapping above) auto-expands +// the wallet picker. +const supportedChains = Object.values(CHAINS) + .filter((c) => c.isTestnet === isTestnet) + .map((c) => chainIdToWagmiChain[c.chainId]) + .filter((c): c is Chain => c !== undefined); + +if (supportedChains.length === 0) { + throw new Error( + `No wagmi-mapped chains for ${isTestnet ? "testnet" : "mainnet"} deployment. Check chains.ts and the chainIdToWagmiChain map in wagmi.ts.` + ); +} + const wcProjectId = process.env.NEXT_PUBLIC_WC_PROJECT_ID; if (!wcProjectId && process.env.NODE_ENV === "development") { console.warn( @@ -13,6 +37,6 @@ if (!wcProjectId && process.env.NODE_ENV === "development") { export const config = getDefaultConfig({ appName: "RipGuard", projectId: wcProjectId || "YOUR_WC_PROJECT_ID", - chains: [isTestnet ? baseSepolia : base], + chains: supportedChains as [Chain, ...Chain[]], ssr: true, }); From f9db0a046e0be5aea1ad5ead6f8e63440d24038d Mon Sep 17 00:00:00 2001 From: Fielding Johnston Date: Sat, 25 Apr 2026 16:53:31 -0500 Subject: [PATCH 2/2] fix(chains): make wrong-chain guard deployment-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #12. isSupportedChain returns true for any chain in the registry — which includes both mainnets and Base Sepolia. That made the wrong-chain guard wrong twice over: a wallet on mainnet visiting the testnet deployment was treated as supported (and got resolved to a real mainnet chain config), and a wallet on Base Sepolia visiting the mainnet deployment got the testnet config back. New helper isSupportedDeploymentChain(chainId, isTestnet) checks both registry membership AND that the chain's testnet flag matches the deployment's. Used everywhere we previously called isSupportedChain to gate fallback behavior: - WrongChainPanel: now shows the panel for cross-deployment wallets - /create defensive lookup: falls back to deployment default - /vaults defensive lookup: same - landing defensive lookup: same isSupportedChain stays exported for any caller that genuinely wants "is this in the registry at all" (e.g., share-card path verification). Test covers all four matrix cases: mainnet chain on mainnet ✓, mainnet on testnet ✗, testnet on mainnet ✗, testnet on testnet ✓. Refs: RG-b53952 (review feedback) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/app/src/app/create/page.tsx | 9 ++++++--- packages/app/src/app/page.tsx | 4 ++-- packages/app/src/app/vaults/page.tsx | 5 +++-- packages/app/src/components/WrongChainPanel.tsx | 7 +++++-- packages/app/src/config/chains.test.ts | 14 ++++++++++++++ packages/app/src/config/chains.ts | 17 +++++++++++++++++ 6 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/app/src/app/create/page.tsx b/packages/app/src/app/create/page.tsx index 28ad943..3561ba1 100644 --- a/packages/app/src/app/create/page.tsx +++ b/packages/app/src/app/create/page.tsx @@ -18,7 +18,7 @@ import { brokerFeeForTreasury, brokerFeePctString, } from "@/config/contracts"; -import { getChainConfig, isSupportedChain, DEFAULT_CHAIN_ID } from "@/config/chains"; +import { getChainConfig, isSupportedDeploymentChain, DEFAULT_CHAIN_ID } from "@/config/chains"; import { erc20Abi, sablierLockupAbi, testUsdcAbi } from "@/config/abis"; import { ShareCard } from "@/components/ShareCard"; import { ErrorBoundary } from "@/components/ErrorBoundary"; @@ -123,8 +123,11 @@ function CreateLockInner() { treasury, explorerUrl, } = useMemo( - () => getChainConfig(isSupportedChain(chainId) ? chainId : DEFAULT_CHAIN_ID), - [chainId] + () => + getChainConfig( + isSupportedDeploymentChain(chainId, IS_TESTNET) ? chainId : DEFAULT_CHAIN_ID, + ), + [chainId], ); const brokerFee = brokerFeeForTreasury(treasury); const brokerFeePct = brokerFeePctString(brokerFee); diff --git a/packages/app/src/app/page.tsx b/packages/app/src/app/page.tsx index 1517d89..066b9b5 100644 --- a/packages/app/src/app/page.tsx +++ b/packages/app/src/app/page.tsx @@ -1,7 +1,7 @@ "use client"; import { PRESETS, IS_TESTNET } from "@/config/contracts"; -import { getChainConfig, isSupportedChain, DEFAULT_CHAIN_ID } from "@/config/chains"; +import { getChainConfig, isSupportedDeploymentChain, DEFAULT_CHAIN_ID } from "@/config/chains"; import { useChainId } from "wagmi"; import Link from "next/link"; import { Header } from "@/components/Header"; @@ -123,7 +123,7 @@ export default function Home() { // Unconnected users + users on unsupported chains see the default chain's // contract link so the landing always has somewhere real to point at. const { sablierLockup, explorerUrl } = getChainConfig( - isSupportedChain(chainId) ? chainId : DEFAULT_CHAIN_ID + isSupportedDeploymentChain(chainId, IS_TESTNET) ? chainId : DEFAULT_CHAIN_ID, ); const sablierExplorerUrl = `${explorerUrl}/address/${sablierLockup}`; const githubUrl = "https://github.com/fielding/ripguard"; diff --git a/packages/app/src/app/vaults/page.tsx b/packages/app/src/app/vaults/page.tsx index 68a0f28..33ba86f 100644 --- a/packages/app/src/app/vaults/page.tsx +++ b/packages/app/src/app/vaults/page.tsx @@ -13,7 +13,8 @@ import { useWriteContract, useWaitForTransactionReceipt, } from "wagmi"; -import { getChainConfig, isSupportedChain, DEFAULT_CHAIN_ID, type ChainConfig } from "@/config/chains"; +import { getChainConfig, isSupportedDeploymentChain, DEFAULT_CHAIN_ID, type ChainConfig } from "@/config/chains"; +import { IS_TESTNET } from "@/config/contracts"; import { sablierLockupAbi } from "@/config/abis"; import { WrongChainPanel } from "@/components/WrongChainPanel"; import { ShareCard } from "@/components/ShareCard"; @@ -425,7 +426,7 @@ function VaultDashboard() { // default chain so the page renders. The wrong-chain panel below blocks // any tx on the unsupported chain. const chainConfig = getChainConfig( - isSupportedChain(chainId) ? chainId : DEFAULT_CHAIN_ID + isSupportedDeploymentChain(chainId, IS_TESTNET) ? chainId : DEFAULT_CHAIN_ID, ); const { sablierLockup, usdcDecimals, explorerUrl } = chainConfig; const publicClient = usePublicClient(); diff --git a/packages/app/src/components/WrongChainPanel.tsx b/packages/app/src/components/WrongChainPanel.tsx index 42f034a..73794d6 100644 --- a/packages/app/src/components/WrongChainPanel.tsx +++ b/packages/app/src/components/WrongChainPanel.tsx @@ -1,7 +1,7 @@ "use client"; import { useChainId, useSwitchChain } from "wagmi"; -import { CHAINS, isSupportedChain } from "@/config/chains"; +import { CHAINS, isSupportedDeploymentChain } from "@/config/chains"; import { IS_TESTNET } from "@/config/contracts"; /** Renders a "switch chain" prompt when the wallet is on a chain RipGuard @@ -16,7 +16,10 @@ export function WrongChainPanel({ children }: { children: React.ReactNode }) { const chainId = useChainId(); const { switchChain, isPending } = useSwitchChain(); - if (isSupportedChain(chainId)) { + // Check against the active deployment specifically, not just registry + // membership — Base Sepolia is in the registry, but on the mainnet + // deployment it's still "wrong chain" for our purposes. + if (isSupportedDeploymentChain(chainId, IS_TESTNET)) { return <>{children}; } diff --git a/packages/app/src/config/chains.test.ts b/packages/app/src/config/chains.test.ts index e57366b..23563ae 100644 --- a/packages/app/src/config/chains.test.ts +++ b/packages/app/src/config/chains.test.ts @@ -5,6 +5,7 @@ import { SUPPORTED_CHAIN_IDS, getChainConfig, isSupportedChain, + isSupportedDeploymentChain, } from "./chains"; describe("chain registry", () => { @@ -57,4 +58,17 @@ describe("chain registry", () => { expect(isSupportedChain(1)).toBe(false); expect(isSupportedChain(undefined)).toBe(false); }); + + it("isSupportedDeploymentChain filters by deployment's testnet flag", () => { + // Mainnet deployment: Base mainnet ✓, Base Sepolia ✗ + expect(isSupportedDeploymentChain(8453, false)).toBe(true); + expect(isSupportedDeploymentChain(84532, false)).toBe(false); + // Testnet deployment: Base Sepolia ✓, Base mainnet ✗ + expect(isSupportedDeploymentChain(84532, true)).toBe(true); + expect(isSupportedDeploymentChain(8453, true)).toBe(false); + // Unregistered chain: false either way + expect(isSupportedDeploymentChain(999_999, false)).toBe(false); + expect(isSupportedDeploymentChain(999_999, true)).toBe(false); + expect(isSupportedDeploymentChain(undefined, false)).toBe(false); + }); }); diff --git a/packages/app/src/config/chains.ts b/packages/app/src/config/chains.ts index c62fe7f..1753947 100644 --- a/packages/app/src/config/chains.ts +++ b/packages/app/src/config/chains.ts @@ -81,3 +81,20 @@ export function getChainConfig(chainId: number): ChainConfig { export function isSupportedChain(chainId: number | undefined): chainId is number { return chainId !== undefined && chainId in CHAINS; } + +// "Supported by THIS deployment" — registry membership AND testnet flag matches. +// Use this (not isSupportedChain) for any logic that gates on whether the +// current deployment can actually transact on a chain. The registry holds +// both mainnets and testnets; isSupportedChain returns true for any of them, +// which would let a mainnet wallet through the wrong-chain guard on the +// testnet deployment (and vice-versa). +export function isSupportedDeploymentChain( + chainId: number | undefined, + isTestnetDeployment: boolean, +): chainId is number { + return ( + chainId !== undefined && + isSupportedChain(chainId) && + getChainConfig(chainId).isTestnet === isTestnetDeployment + ); +}