diff --git a/README.md b/README.md index 5f15997..bbce28d 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,39 @@ function App() { } ``` +### Web3 Chain Configuration + +`BreadUIKitProvider` now supports explicit chain configuration. + +```tsx +import { BreadUIKitProvider } from "@breadcoop/ui"; +import { erc20Abi } from "viem"; + + + {children} +; +``` + +`chainId` is the primary target network used by login/switch-chain and reads. +`supportedChainIds` controls which chains are treated as connected vs unsupported. + +### Migration Notes + +- Preferred: pass `chainId` (and optionally `supportedChainIds`) explicitly. +- Legacy fallback: `isProd` is still accepted for backward compatibility (`true -> 100`, `false -> 31337`). +- For Sepolia, set `chainId={11155111}` to avoid automatic switching to Gnosis. + ## Components ### Typography diff --git a/src/components/auth/login-button-privy.tsx b/src/components/auth/login-button-privy.tsx index 09c91ff..19d1df5 100644 --- a/src/components/auth/login-button-privy.tsx +++ b/src/components/auth/login-button-privy.tsx @@ -5,14 +5,13 @@ import { ReactNode } from "react"; import LiftedButton from "../LiftedButton/LiftedButton"; import { ButtonShell } from "./button-shell"; import { App } from "../../interface/app"; -import { gnosis } from "viem/chains"; +import { useBreadUIKitContext } from "../../context/lib"; export interface LoginButtonPrivyProps { app: App; status: "CONNECTED" | "LOADING" | "UNSUPPORTED_CHAIN" | "NOT_CONNECTED"; label?: string; rightIcon?: ReactNode; - isProd?: boolean; } export const LoginButtonPrivy = ({ @@ -20,8 +19,8 @@ export const LoginButtonPrivy = ({ status, label = "Sign In", rightIcon, - isProd = true, }: LoginButtonPrivyProps) => { + const { chainId } = useBreadUIKitContext(); const className = app === "fund" ? "bg-primary-orange" @@ -42,7 +41,7 @@ export const LoginButtonPrivy = ({ return ( ); @@ -63,11 +62,11 @@ export const LoginButtonPrivy = ({ function SwitchNetwork({ activeWallet, - isProd, + chainId, className, }: { activeWallet: ConnectedWallet; - isProd: boolean; + chainId: number; className?: string; }) { return ( @@ -77,8 +76,7 @@ function SwitchNetwork({ if (!activeWallet) return; try { - const targetChainId = isProd ? gnosis.id : 31337; - await activeWallet.switchChain(targetChainId); + await activeWallet.switchChain(chainId); } catch (error) { console.error("Failed to switch chain:", error); } diff --git a/src/components/auth/login-button.tsx b/src/components/auth/login-button.tsx index 8e4c437..8c6f8cb 100644 --- a/src/components/auth/login-button.tsx +++ b/src/components/auth/login-button.tsx @@ -7,18 +7,22 @@ import { import { LoginButtonGeneral } from "./login-button-general"; import { useAuthProvider } from "../../context/lib"; +export interface LoginButtonProps extends LoginButtonPrivyProps { + /** @deprecated Chain behavior is controlled via BreadUIKitProvider config. */ + isProd?: boolean; +} + export const LoginButton = ({ label = "Sign In", - isProd, + isProd: _isProd, ...props -}: LoginButtonPrivyProps) => { +}: LoginButtonProps) => { const authProvider = useAuthProvider(); if (authProvider === "privy") { return ( ); diff --git a/src/components/connected-user/privy-provider.tsx b/src/components/connected-user/privy-provider.tsx index 0787711..9ce2031 100644 --- a/src/components/connected-user/privy-provider.tsx +++ b/src/components/connected-user/privy-provider.tsx @@ -1,23 +1,23 @@ "use client"; import { ReactNode, useMemo } from "react"; -import { anvil, gnosis } from "viem/chains"; import { type Hex } from "viem"; import { usePrivy, useWallets } from "@privy-io/react-auth"; import { TConnectedUserState, TUserConnected } from "."; import { ConnectedUserContext } from "./context"; +import { useBreadUIKitContext } from "../../context/lib"; +import { resolveChain } from "./resolve-chain"; interface IConnectedUserProviderPrivyProps { children: ReactNode; - isProd: boolean; } export function ConnectedUserProviderPrivy({ - isProd, children, }: IConnectedUserProviderPrivyProps) { const { ready, authenticated } = usePrivy(); const { wallets } = useWallets(); + const { chainId: configuredChainId, supportedChainIds } = useBreadUIKitContext(); const embeddedWallet = useMemo(() => { return wallets.find( @@ -36,25 +36,29 @@ export function ConnectedUserProviderPrivy({ } const address = embeddedWallet.address as Hex; - const chainId = embeddedWallet.chainId; + const walletChainId = embeddedWallet.chainId; + const parsedChainId = walletChainId + ? parseInt(walletChainId.split(":")[1], 10) + : undefined; + const currentChainId = + parsedChainId !== undefined && Number.isFinite(parsedChainId) + ? parsedChainId + : undefined; - const parsedChainId = chainId ? parseInt(chainId.split(":")[1]) : undefined; + const isSupportedChain = + currentChainId !== undefined && supportedChainIds.includes(currentChainId); + const _status: TUserConnected["status"] = isSupportedChain + ? "CONNECTED" + : "UNSUPPORTED_CHAIN"; - let _status: TUserConnected["status"] = "CONNECTED"; - if (isProd) { - _status = parsedChainId === gnosis.id ? "CONNECTED" : "UNSUPPORTED_CHAIN"; - } else { - _status = parsedChainId === anvil.id ? "CONNECTED" : "UNSUPPORTED_CHAIN"; - } - - const chain = isProd ? gnosis : anvil; + const chain = resolveChain(currentChainId ?? configuredChainId); return { status: _status, address, chain, }; - }, [ready, authenticated, embeddedWallet, isProd]); + }, [ready, authenticated, embeddedWallet, configuredChainId, supportedChainIds]); // Embedded wallets are never Safe wallets const isSafe = useMemo(() => { diff --git a/src/components/connected-user/provider-general.tsx b/src/components/connected-user/provider-general.tsx index 2728bdc..9c35860 100644 --- a/src/components/connected-user/provider-general.tsx +++ b/src/components/connected-user/provider-general.tsx @@ -4,17 +4,18 @@ import { ReactNode, useMemo } from "react"; import { TConnectedUserState, TUserConnected } from "./interface"; import { useAccount } from "wagmi"; import { useAutoConnect } from "../../hooks/use-auto-connect"; -import { anvil, gnosis } from "viem/chains"; import { ConnectedUserContext } from "./context"; +import { useBreadUIKitContext } from "../../context/lib"; +import { resolveChain } from "./resolve-chain"; interface IConnectedUserProviderGeneralProps { children: ReactNode; - isProd: boolean; } -export function ConnectedUserProviderGeneral({ isProd, children }: IConnectedUserProviderGeneralProps) { +export function ConnectedUserProviderGeneral({ children }: IConnectedUserProviderGeneralProps) { const { isConnected, connector, address, status, chain } = useAccount(); const { isSafe } = useAutoConnect(connector); + const { chainId, supportedChainIds } = useBreadUIKitContext(); const user = useMemo(() => { if (status === "connecting" && !address) { @@ -25,20 +26,19 @@ export function ConnectedUserProviderGeneral({ isProd, children }: IConnectedUse return { status: "NOT_CONNECTED" }; } - let _staus: TUserConnected["status"] = "CONNECTED"; - if (isProd) { - _staus = - chain?.id === gnosis.id ? "CONNECTED" : "UNSUPPORTED_CHAIN"; - } else { - _staus = chain?.id === anvil.id ? "CONNECTED" : "UNSUPPORTED_CHAIN"; - } + const currentChainId = chain?.id; + const isSupportedChain = + currentChainId !== undefined && supportedChainIds.includes(currentChainId); + const userStatus: TUserConnected["status"] = isSupportedChain + ? "CONNECTED" + : "UNSUPPORTED_CHAIN"; return { - status: _staus, + status: userStatus, address, - chain: chain || (isProd ? gnosis : anvil), + chain: chain || resolveChain(chainId), }; - }, [isConnected, address, chain, status]); + }, [isConnected, address, chain, status, chainId, supportedChainIds]); const value = useMemo(() => ({ user, isSafe }), [user, isSafe]); diff --git a/src/components/connected-user/provider.tsx b/src/components/connected-user/provider.tsx index 316a2b2..d01ce32 100644 --- a/src/components/connected-user/provider.tsx +++ b/src/components/connected-user/provider.tsx @@ -1,19 +1,16 @@ "use client"; -import { ReactNode } from "react"; import { useAuthProvider } from "../../context/lib"; import { ConnectedUserProviderPrivy } from "./privy-provider"; import { ConnectedUserProviderGeneral } from "./provider-general"; -interface IConnectedUserProviderProps { - children: ReactNode; - isProd: boolean; +interface ConnectedUserProviderProps { + children: React.ReactNode; + /** @deprecated Chain behavior is controlled via BreadUIKitProvider config. */ + isProd?: boolean; } -export function ConnectedUserProvider({ - isProd, - children, -}: IConnectedUserProviderProps) { +export function ConnectedUserProvider({ children, isProd: _isProd }: ConnectedUserProviderProps) { const authProvider = useAuthProvider(); const Provider = @@ -21,5 +18,5 @@ export function ConnectedUserProvider({ ? ConnectedUserProviderPrivy : ConnectedUserProviderGeneral; - return {children}; + return {children}; } diff --git a/src/components/connected-user/resolve-chain.ts b/src/components/connected-user/resolve-chain.ts new file mode 100644 index 0000000..beee72e --- /dev/null +++ b/src/components/connected-user/resolve-chain.ts @@ -0,0 +1,29 @@ +import { type Chain } from "viem"; +import { anvil, gnosis, sepolia } from "viem/chains"; + +const KNOWN_CHAINS: Record = { + [gnosis.id]: gnosis, + [anvil.id]: anvil, + [sepolia.id]: sepolia, +}; + +function createFallbackChain(chainId: number): Chain { + return { + id: chainId, + name: `Chain ${chainId}`, + nativeCurrency: { + name: "Ether", + symbol: "ETH", + decimals: 18, + }, + rpcUrls: { + default: { + http: [], + }, + }, + }; +} + +export function resolveChain(chainId: number): Chain { + return KNOWN_CHAINS[chainId] || createFallbackChain(chainId); +} diff --git a/src/components/navbar/account-menu.tsx b/src/components/navbar/account-menu.tsx index 55364ea..25bcc36 100644 --- a/src/components/navbar/account-menu.tsx +++ b/src/components/navbar/account-menu.tsx @@ -1,7 +1,7 @@ "use client"; import * as NavigationMenu from "@radix-ui/react-navigation-menu"; -import { type Address } from "viem"; +import { type Address, type Chain } from "viem"; import { Body } from "../typography/Typography"; import { truncateAddress } from "../../utils/truncate-address"; import { CaretDownIcon } from "@phosphor-icons/react"; @@ -12,11 +12,13 @@ import { appsConfig } from "../../utils/app"; export interface AccountMenuProps extends Pick { userAddress: Address; + chain: Chain; app: App; } const AccountMenu = ({ userAddress, + chain, ensNameResult, app, widgetItems, @@ -44,6 +46,7 @@ const AccountMenu = ({ diff --git a/src/components/navbar/account-widget.tsx b/src/components/navbar/account-widget.tsx index c787a56..659a7ae 100644 --- a/src/components/navbar/account-widget.tsx +++ b/src/components/navbar/account-widget.tsx @@ -2,7 +2,6 @@ import { ArrowUpRightIcon, - CopyIcon, GraphIcon, UserCircleIcon, WalletIcon, @@ -13,21 +12,19 @@ import { UseEnsNameReturnType } from "wagmi"; import { GetEnsNameReturnType } from "@wagmi/core"; import { Body } from "../typography/Typography"; import { truncateAddress } from "../../utils/truncate-address"; -import { copyToClipboard } from "../../utils/copy-to-clipboard"; import { Logo } from "../Logo"; import LogoutButton from "./log-out"; import { App } from "../../interface/app"; import { appsConfig } from "../../utils/app"; import { useBreadBalance } from "../../hooks/use-bread-balance"; -import { Address } from "viem"; +import { Address, type Chain } from "viem"; import NavAccountWidgetItem from "./account-widget-item"; import { FormattedDecimalNumber } from "../typography/formatted-dec-num"; import { CopyButtonIcon } from "../buttons"; -const GNOSIS_LINK = "https://gnosisscan.io/address/"; - export interface NavAccountDetailsProps { userAddress: Address; + chain: Chain; ensNameResult: UseEnsNameReturnType | { data: string | undefined; isLoading: boolean; @@ -42,6 +39,7 @@ export interface NavAccountDetailsProps { const NavAccountDetails = ({ className, userAddress, + chain, ensNameResult, app, widgetItems, @@ -50,6 +48,8 @@ const NavAccountDetails = ({ const { BREAD } = useBreadBalance({ address: userAddress }); const appIconColor = appsConfig[app].text; + const explorerUrl = chain.blockExplorers?.default.url; + const accountUrl = explorerUrl ? `${explorerUrl}/address/${userAddress || ""}` : undefined; return ( - - - + {accountUrl ? ( + + + + ) : null} - - Gnosis chain + {chain.name} {actionItems} diff --git a/src/context/lib.tsx b/src/context/lib.tsx index b5f9e03..f86c951 100644 --- a/src/context/lib.tsx +++ b/src/context/lib.tsx @@ -26,6 +26,8 @@ type TokenConfig = { type BreadUIKitContextType = { isProd: boolean; + chainId: number; + supportedChainIds: number[]; tokenConfig: TokenConfig; app: App; authProvider: AuthProvider; @@ -37,24 +39,56 @@ export const BreadUIKitContext = createContext< export const BreadUIKitProvider = ({ isProd, + chainId, + supportedChainIds, tokenConfig, children, app, authProvider, }: { - isProd: boolean; + isProd?: boolean; + chainId?: number; + supportedChainIds?: number[]; tokenConfig: TokenConfig; app: App; authProvider: AuthProvider; children: React.ReactNode; }) => { - if (isProd) { - tokenConfig.BREAD.address = - "0xa555d5344f6FB6c65da19e403Cb4c1eC4a1a5Ee3"; - } + const legacyIsProd = isProd ?? true; + const effectiveChainId = chainId ?? (legacyIsProd ? 100 : 31337); + const effectiveSupportedChainIds = Array.from( + new Set([ + ...(supportedChainIds && supportedChainIds.length > 0 + ? supportedChainIds + : [effectiveChainId]), + effectiveChainId, + ]), + ); + const effectiveIsProd = isProd ?? effectiveChainId === 100; + + const resolvedTokenConfig = { + ...tokenConfig, + BREAD: { + ...tokenConfig.BREAD, + // Keep legacy behavior only when the chain is still derived from isProd. + address: + chainId === undefined && effectiveIsProd + ? "0xa555d5344f6FB6c65da19e403Cb4c1eC4a1a5Ee3" + : tokenConfig.BREAD.address, + }, + }; return ( - + {children} ); diff --git a/src/hooks/use-bread-balance.ts b/src/hooks/use-bread-balance.ts index 5576dc1..cfc7f95 100644 --- a/src/hooks/use-bread-balance.ts +++ b/src/hooks/use-bread-balance.ts @@ -1,12 +1,10 @@ import { Address, erc20Abi, formatUnits } from "viem"; import { useBlock, useReadContract } from "wagmi"; import { useBreadUIKitContext } from "../context/lib"; -import { getActiveChainId } from "../utils/active-chain"; import { useEffect, useMemo } from "react"; export const useBreadBalance = ({ address }: { address: Address }) => { - const { tokenConfig, isProd } = useBreadUIKitContext(); - const chainId = getActiveChainId(isProd); + const { tokenConfig, chainId } = useBreadUIKitContext(); const { data: blockNumber } = useBlock({ watch: true, chainId }); const { diff --git a/src/utils/active-chain.ts b/src/utils/active-chain.ts index 66df5b2..5f71035 100644 --- a/src/utils/active-chain.ts +++ b/src/utils/active-chain.ts @@ -1 +1,6 @@ -export const getActiveChainId = (isProd: boolean) => isProd ? 100 : 31337; +export const getActiveChainId = (chainIdOrIsProd: number | boolean) => { + if (typeof chainIdOrIsProd === "number") return chainIdOrIsProd; + + // Deprecated fallback for legacy callers. + return chainIdOrIsProd ? 100 : 31337; +};