Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .tix/issues.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}
16 changes: 14 additions & 2 deletions packages/app/src/app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ import {
brokerFeeForTreasury,
brokerFeePctString,
} from "@/config/contracts";
import { getChainConfig } 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";
import { WrongChainPanel } from "@/components/WrongChainPanel";
import { useToast } from "@/components/Toast";
import { trackLockCreated, trackLockApproved, trackContractError } from "@/lib/analytics";
import { isUserRejection, extractErrorReason } from "@/lib/errors";
Expand Down Expand Up @@ -112,13 +113,22 @@ 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(
isSupportedDeploymentChain(chainId, IS_TESTNET) ? 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).
Expand Down Expand Up @@ -596,6 +606,7 @@ function CreateLockInner() {
</div>

<div className="relative mx-auto max-w-2xl w-full">
<WrongChainPanel>
{step === "success" ? (
<SuccessView
txHash={lockTxHash!}
Expand Down Expand Up @@ -1045,6 +1056,7 @@ function CreateLockInner() {
)}
</>
)}
</WrongChainPanel>
</div>
</main>
</div>
Expand Down
8 changes: 6 additions & 2 deletions packages/app/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { PRESETS, IS_TESTNET } from "@/config/contracts";
import { getChainConfig } 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";
Expand Down Expand Up @@ -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(
isSupportedDeploymentChain(chainId, IS_TESTNET) ? chainId : DEFAULT_CHAIN_ID,
);
const sablierExplorerUrl = `${explorerUrl}/address/${sablierLockup}`;
const githubUrl = "https://github.com/fielding/ripguard";

Expand Down
15 changes: 12 additions & 3 deletions packages/app/src/app/vaults/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import {
useWriteContract,
useWaitForTransactionReceipt,
} from "wagmi";
import { getChainConfig, 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";
import { ErrorBoundary, CardErrorBoundary } from "@/components/ErrorBoundary";
import { useToast } from "@/components/Toast";
Expand Down Expand Up @@ -420,7 +422,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(
isSupportedDeploymentChain(chainId, IS_TESTNET) ? chainId : DEFAULT_CHAIN_ID,
);
const { sablierLockup, usdcDecimals, explorerUrl } = chainConfig;
const publicClient = usePublicClient();
const { toast } = useToast();
Expand Down Expand Up @@ -813,7 +820,9 @@ export default function Vaults() {

<div className="relative flex-1 flex flex-col">
<ErrorBoundary fallbackTitle="Failed to load vaults">
<VaultDashboard />
<WrongChainPanel>
<VaultDashboard />
</WrongChainPanel>
</ErrorBoundary>
</div>
</main>
Expand Down
60 changes: 60 additions & 0 deletions packages/app/src/components/WrongChainPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"use client";

import { useChainId, useSwitchChain } from "wagmi";
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
* doesn't support yet. Returns null when the chain is supported, so the
* caller can wrap their page content like:
*
* ```tsx
* <WrongChainPanel>{normalContent}</WrongChainPanel>
* ```
*/
export function WrongChainPanel({ children }: { children: React.ReactNode }) {
const chainId = useChainId();
const { switchChain, isPending } = useSwitchChain();

// 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}</>;
}

const supportedForDeployment = Object.values(CHAINS).filter(
(c) => c.isTestnet === IS_TESTNET
);

return (
<div className="flex flex-col items-center justify-center gap-7 py-16 px-6 text-center max-w-md mx-auto">
<div className="eyebrow text-warning/90">Wrong network</div>
<h3 className="font-display text-3xl tracking-tight max-w-sm">
Your wallet is on a chain we don&apos;t support yet.
</h3>
<p className="text-muted text-sm leading-relaxed max-w-sm">
RipGuard runs on{" "}
{supportedForDeployment.map((c, i) => (
<span key={c.chainId}>
{i > 0 && i === supportedForDeployment.length - 1 ? " and " : i > 0 ? ", " : ""}
<span className="text-foreground">{c.name}</span>
</span>
))}
. Switch your wallet to lock USDC.
</p>
<div className="flex flex-wrap gap-3 justify-center">
{supportedForDeployment.map((c) => (
<button
key={c.chainId}
onClick={() => switchChain({ chainId: c.chainId })}
disabled={isPending}
className="btn-primary disabled:opacity-50"
>
{isPending ? "Confirm in wallet…" : `Switch to ${c.shortName}`}
</button>
))}
</div>
</div>
);
}
14 changes: 14 additions & 0 deletions packages/app/src/config/chains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
SUPPORTED_CHAIN_IDS,
getChainConfig,
isSupportedChain,
isSupportedDeploymentChain,
} from "./chains";

describe("chain registry", () => {
Expand Down Expand Up @@ -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);
});
});
17 changes: 17 additions & 0 deletions packages/app/src/config/chains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
28 changes: 26 additions & 2 deletions packages/app/src/config/wagmi.ts
Original file line number Diff line number Diff line change
@@ -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<number, Chain> = {
[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(
Expand All @@ -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,
});