diff --git a/.env.example b/.env.example index 4cd63f9..366c4b1 100644 --- a/.env.example +++ b/.env.example @@ -68,18 +68,27 @@ NEXT_PUBLIC_SIWE_STATEMENT="Sign in to GuildPass Admin" # Defaults to all supported chains below when unset so mock mode works locally. NEXT_PUBLIC_WALLET_CHAINS=mainnet,base,sepolia -# Optional browser-safe RPC URLs for each enabled chain. Leave blank to use -# wagmi/viem defaults. Do not place private RPC secrets here unless your RPC -# provider explicitly supports public browser keys. -# NEXT_PUBLIC_WALLET_RPC_MAINNET=https://eth-mainnet.g.alchemy.com/v2/public-key -# NEXT_PUBLIC_WALLET_RPC_BASE=https://base-mainnet.g.alchemy.com/v2/public-key -# NEXT_PUBLIC_WALLET_RPC_SEPOLIA=https://eth-sepolia.g.alchemy.com/v2/public-key - -# Comma-separated wallet connectors. Currently supported: injected. -# Unsupported values throw a ConfigError at startup. See the "Wallet -# connectors" section of README.md for how to add a new connector. +# Optional browser-safe RPC URLs for each enabled chain. Supports multiple +# comma-separated URLs per chain for automatic failover — the first reachable +# endpoint is preferred, and consistently failing endpoints are deprioritized +# (with automatic recovery after 60 s). Unset values fall back to the public +# default RPC for each chain. +# Example with multiple RPCs per chain: +# NEXT_PUBLIC_WALLET_RPC_MAINNET=https://eth.llamarpc.com,https://eth-mainnet.g.alchemy.com/v2/key +# NEXT_PUBLIC_WALLET_RPC_MAINNET=https://eth.llamarpc.com +# NEXT_PUBLIC_WALLET_RPC_BASE=https://base.llamarpc.com +# NEXT_PUBLIC_WALLET_RPC_SEPOLIA=https://sepolia.llamarpc.com + +# Comma-separated wallet connectors. Supported values: injected, walletConnect. +# When multiple connectors are listed, the connect-button offers a fallback +# path if no injected wallet is detected (e.g. on mobile). +# See the "Wallet connectors" section of README.md for how to add a new connector. NEXT_PUBLIC_WALLET_CONNECTORS=injected +# Required when walletConnect connector is enabled (NEXT_PUBLIC_WALLET_CONNECTORS +# includes "walletConnect"). Obtain a project ID from https://cloud.walletconnect.com +# NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your-project-id + # ── Feature Flags ───────────────────────────────────────────────────────────── # Each flag controls whether a module is visible in navigation and accessible # via its page route. Omitting a flag falls back to the default shown below. @@ -121,11 +130,12 @@ NEXT_PUBLIC_WALLET_CONNECTORS=injected # NEXT_PUBLIC_SIWE_DOMAIN=app.example.com # ── Wallet RPC Configuration (Fallback-aware) ────────────────────────────────── -# Optional custom RPC URLs per chain. When set, the wallet transport uses wagmi's -# fallback composition: primary URL → public default. If the primary URL is -# unreachable at runtime, traffic transparently falls back to the public default -# and a non-blocking notice appears in the UI. +# Optional custom RPC URLs per chain. Supports multiple comma-separated URLs +# for automatic failover with health-based deprioritization: +# - Primary URL is tried first; on failure the next URL in the list is used. +# - After 3 consecutive failures, an endpoint is deprioritized for 60 s. +# - The public default RPC is always appended as the final fallback. # Unset values fall back to the public default RPC for each chain. -# NEXT_PUBLIC_WALLET_RPC_MAINNET=https://eth.llamarpc.com -# NEXT_PUBLIC_WALLET_RPC_BASE=https://base.llamarpc.com -# NEXT_PUBLIC_WALLET_RPC_SEPOLIA=https://sepolia.llamarpc.com +# NEXT_PUBLIC_WALLET_RPC_MAINNET=https://eth.llamarpc.com,https://eth-mainnet.g.alchemy.com/v2/key +# NEXT_PUBLIC_WALLET_RPC_BASE=https://base.llamarpc.com,https://base-mainnet.g.alchemy.com/v2/key +# NEXT_PUBLIC_WALLET_RPC_SEPOLIA=https://sepolia.llamarpc.com,https://eth-sepolia.g.alchemy.com/v2/key diff --git a/README.md b/README.md index 45e04ae..fc7cfc1 100644 --- a/README.md +++ b/README.md @@ -125,15 +125,20 @@ check automatically via the `predev` script. | `NEXT_PUBLIC_SIWE_DOMAIN` | No | Domain field in the EIP-4361 message (defaults to `localhost:3000`) | | `NEXT_PUBLIC_SIWE_STATEMENT` | No | Human-readable statement shown in the signed message | | `NEXT_PUBLIC_WALLET_CHAINS` | No | Comma-separated supported chains for wagmi; supported values: `mainnet`, `base`, `sepolia`; defaults to all three | -| `NEXT_PUBLIC_WALLET_RPC_MAINNET` | No | Optional browser-safe RPC URL for Ethereum mainnet when enabled | -| `NEXT_PUBLIC_WALLET_RPC_BASE` | No | Optional browser-safe RPC URL for Base when enabled | -| `NEXT_PUBLIC_WALLET_RPC_SEPOLIA` | No | Optional browser-safe RPC URL for Sepolia when enabled | +| `NEXT_PUBLIC_WALLET_RPC_MAINNET` | No | Optional browser-safe RPC URL(s) for Ethereum mainnet; supports comma-separated list for multi-RPC failover | +| `NEXT_PUBLIC_WALLET_RPC_BASE` | No | Optional browser-safe RPC URL(s) for Base; supports comma-separated list for multi-RPC failover | +| `NEXT_PUBLIC_WALLET_RPC_SEPOLIA` | No | Optional browser-safe RPC URL(s) for Sepolia; supports comma-separated list for multi-RPC failover | | `NEXT_PUBLIC_WALLET_CONNECTORS` | No | Comma-separated wallet connectors; see [Wallet connectors](#wallet-connectors) for supported values (defaults to `injected`) | +| `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` | When walletConnect enabled | WalletConnect Cloud project ID (required when `walletConnect` connector is listed in `NEXT_PUBLIC_WALLET_CONNECTORS`) | See [`.env.example`](./.env.example) for a ready-to-copy template. Wallet chain settings are built by [`lib/wallet/config.ts`](./lib/wallet/config.ts). Invalid chain names, empty chain lists, unsupported connectors, or malformed RPC URLs throw a `ConfigError` during development so deployment mistakes are visible before users connect a wallet. In mock mode, leaving these variables unset preserves the local default of `mainnet`, `base`, and `sepolia` with default transports. +**Multi-RPC failover**: Each chain supports multiple comma-separated RPC URLs. The first reachable endpoint is preferred; after 3 consecutive failures an endpoint is deprioritized for 60 seconds (with automatic recovery). The public default RPC is always appended as the final fallback. + +**Wallet connector fallback**: When the `walletConnect` connector is enabled alongside `injected`, the connect button automatically offers a WalletConnect QR path when no injected wallet is detected (e.g. mobile browsers without an extension). + Only expose RPC URLs that are safe to bundle into browser JavaScript. Do not put private RPC credentials in `NEXT_PUBLIC_*` variables unless your provider explicitly documents that the key is public and browser-safe. ### Wallet connectors @@ -145,6 +150,7 @@ Currently supported values: | Value | Connector | | ----- | --------- | | `injected` | Browser-extension / injected wallets (MetaMask, Rabby, etc.) — the default | +| `walletConnect` | [WalletConnect](https://walletconnect.com/) v2 — QR-code based mobile wallet pairing (requires `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`) | Setting any other value (for example `walletconnect`) throws a `ConfigError` at startup that lists the supported values and links back to this section — connectors are compiled into the bundle, so an unrecognized name can never work at runtime and is better caught early. diff --git a/components/wallet/connect-button.tsx b/components/wallet/connect-button.tsx index 2ec5ff9..ce62c58 100644 --- a/components/wallet/connect-button.tsx +++ b/components/wallet/connect-button.tsx @@ -1,11 +1,12 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; import { Copy, Check } from "lucide-react"; -import { useAccount, useConnect, useDisconnect, injected } from "wagmi"; +import { useAccount, useConnect, useDisconnect } from "wagmi"; import { Button } from "@/components/ui/button"; import { useSiweAuth } from "@/lib/wallet/providers"; import { AddressText } from "./address-text"; +import { hasInjectedWallet, walletConfig } from "@/lib/wallet/config"; function CopyAddressButton({ address }: { address?: string }) { const [copied, setCopied] = useState(false); @@ -46,21 +47,90 @@ function CopyAddressButton({ address }: { address?: string }) { export function ConnectButton() { const { isConnected, address } = useAccount(); - const { connect, isPending: isConnecting } = useConnect(); + const { connect, connectors, isPending: isConnecting } = useConnect(); const { disconnect } = useDisconnect(); const { sessionStatus, isSigningIn, signIn, logout, error } = useSiweAuth(); + const [showConnectorPicker, setShowConnectorPicker] = useState(false); + const [injectedAvailable, setInjectedAvailable] = useState(null); + + useEffect(() => { + setInjectedAvailable(hasInjectedWallet()); + }, []); + + const hasMultipleConnectors = walletConfig.connectorNames.length > 1 + const showFallbackPrompt = injectedAvailable === false && hasMultipleConnectors + + function handleConnect(connectorId: string) { + const connector = connectors.find((c) => c.id === connectorId) + if (connector) { + connect({ connector }) + setShowConnectorPicker(false) + } + } + + function handleQuickConnect() { + if (injectedAvailable) { + const injectedConnector = connectors.find((c) => c.id === 'injected') + if (injectedConnector) { + connect({ connector: injectedConnector }) + return + } + } + if (hasMultipleConnectors) { + setShowConnectorPicker(true) + } + } if (!isConnected) { + if (showConnectorPicker) { + return ( +
+
+ {connectors.map((connector) => ( + + ))} + +
+
+ ) + } + return ( - +
+
+ +
+ {showFallbackPrompt && ( +

+ No injected wallet detected — another connector may be used. +

+ )} +
); } diff --git a/lib/wallet/config.ts b/lib/wallet/config.ts index ed32f2e..59d8318 100644 --- a/lib/wallet/config.ts +++ b/lib/wallet/config.ts @@ -1,4 +1,5 @@ import { http, injected, fallback } from 'wagmi' +import { walletConnect } from '@wagmi/connectors' import { mainnet, base, sepolia } from 'wagmi/chains' import type { Chain } from 'viem' import type { CreateConnectorFn } from 'wagmi' @@ -52,18 +53,75 @@ function rpcEnvName(chain: Chain): string { return rpcEnvNameFromChainName(name ?? String(chain.id)) } +// ── RPC Health Tracking ───────────────────────────────────────────────────── + +const FAILURE_THRESHOLD = 3 +const RECOVERY_COOLDOWN_MS = 60_000 + +interface EndpointHealth { + failureCount: number + lastFailureAt: number + deprioritized: boolean +} + +export const rpcHealth = new Map() + +export function recordRpcFailure(url: string): void { + const now = Date.now() + const entry = rpcHealth.get(url) ?? { failureCount: 0, lastFailureAt: 0, deprioritized: false } + entry.failureCount += 1 + entry.lastFailureAt = now + if (entry.failureCount >= FAILURE_THRESHOLD) { + entry.deprioritized = true + } + rpcHealth.set(url, entry) +} + +export function recordRpcSuccess(url: string): void { + rpcHealth.delete(url) +} + +export function isDeprioritized(url: string): boolean { + const entry = rpcHealth.get(url) + if (!entry) return false + if (!entry.deprioritized) return false + if (Date.now() - entry.lastFailureAt > RECOVERY_COOLDOWN_MS) { + rpcHealth.delete(url) + return false + } + return true +} + +function sortByHealth(urls: string[]): string[] { + return [...urls].sort((a, b) => { + const aDp = isDeprioritized(a) + const bDp = isDeprioritized(b) + if (aDp && !bDp) return 1 + if (!aDp && bDp) return -1 + const aF = rpcHealth.get(a)?.failureCount ?? 0 + const bF = rpcHealth.get(b)?.failureCount ?? 0 + return aF - bF + }) +} + +// ── Transport Builder ──────────────────────────────────────────────────────── + function buildTransports(chains: readonly [SupportedWalletChain, ...SupportedWalletChain[]]): WalletRuntimeConfig['transports'] { return chains.reduce((transports, chain) => { const envName = rpcEnvName(chain) - const rpcUrl = env(envName) - const primaryTransport = rpcUrl ? http(validateBrowserUrl(rpcUrl, envName)) : null - transports[chain.id] = primaryTransport - ? fallback([primaryTransport, http()]) + const rawValue = env(envName) + const urls = splitCsv(rawValue).map((u) => validateBrowserUrl(u, envName)) + const sorted = sortByHealth(urls) + const userTransports = sorted.map((url) => http(url, { name: url })) + transports[chain.id] = userTransports.length > 0 + ? fallback([...userTransports, http()]) : http() return transports }, {} as WalletRuntimeConfig['transports']) } +// ── Connector Builder ──────────────────────────────────────────────────────── + function parseConnectorNames(): readonly WalletConnectorName[] { return parseConnectorNamesCsv(env('NEXT_PUBLIC_WALLET_CONNECTORS')) } @@ -73,10 +131,29 @@ function buildConnectors(connectorNames: readonly WalletConnectorName[]): Create switch (name) { case 'injected': return injected({ shimDisconnect: true }) + case 'walletConnect': { + const projectId = env('NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID') + if (!projectId) { + fail( + 'NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID is required when using the walletConnect connector.', + ) + } + return walletConnect({ + projectId, + showQrModal: true, + }) + } } }) } +export function hasInjectedWallet(): boolean { + if (typeof window === 'undefined') return false + return typeof window.ethereum !== 'undefined' +} + +// ── Main Config Builder ────────────────────────────────────────────────────── + function buildWalletConfig(): WalletRuntimeConfig { try { const chains = parseChains() diff --git a/lib/wallet/connectors.ts b/lib/wallet/connectors.ts index d8e23da..58696dd 100644 --- a/lib/wallet/connectors.ts +++ b/lib/wallet/connectors.ts @@ -11,6 +11,49 @@ * 3. Document it in README.md ("Wallet connectors") and .env.example. */ +import { ConfigError } from '../config' + +export const SUPPORTED_CONNECTOR_NAMESinjected', 'walletConnect'] as const + +export type WalletConnectorName = (typeof SUPPORTED_CONNECTOR_NAMES)[number] + +export const CONNECTOR_DOCS_URL = + 'https://github.com/Adamantine-Guild/guildpass-integrations#wallet-connectors' + +export function unsupportedConnectorMessage(name: string): string { + return [ + `NEXT_PUBLIC_WALLET_CONNECTORS contains unsupported connector "${name}".`, + '', + ` Supported values: ${SUPPORTED_CONNECTOR_NAMES.join(', ')}.`, + '', + ' To add support for a new connector, see the "Wallet connectors"', + ` section of the README (${CONNECTOR_DOCS_URL})`, + ' and extend lib/wallet/config.ts.', + ].join('\n') +} + +/** + * Parse the comma-separated NEXT_PUBLIC_WALLET_CONNECTORS value into a list + * of supported connector names. Defaults to ['injected'] when unset/empty; + * throws a ConfigError naming the offending value for anything unsupported. + */ +export function parseConnectorNames( + csv: string | undefined, +): readonly WalletConnectorName[] { + const configuredNames = + csv + ?.split(',') + .map((part) => part.trim()) + .filter(Boolean) ?? [] + const names = configuredNames.length > 0 ? configuredNames : ['injected'] + + return names.map((name) => { + if (!(SUPPORTED_CONNECTOR_NAMES as readonly string[]).includes(name)) { + throw new ConfigError(unsupportedConnectorMessage(name)) + } + return name as WalletConnectorName + }) +} export { CONNECTOR_DOCS_URL, SUPPORTED_CONNECTOR_NAMES, diff --git a/test/rpc-failover.test.ts b/test/rpc-failover.test.ts new file mode 100644 index 0000000..99c44b9 --- /dev/null +++ b/test/rpc-failover.test.ts @@ -0,0 +1,109 @@ +import './setup-env' +import { describe, test, afterEach } from 'node:test' +import * as assert from 'node:assert/strict' +import { + rpcHealth, + recordRpcFailure, + recordRpcSuccess, + isDeprioritized, + hasInjectedWallet, +} from '../lib/wallet/config' + +afterEach(() => { + rpcHealth.clear() +}) + +describe('RPC health tracking', () => { + test('single failure does not deprioritize', () => { + recordRpcFailure('https://rpc-a.example.com') + assert.equal(isDeprioritized('https://rpc-a.example.com'), false) + }) + + test('two failures do not deprioritize', () => { + recordRpcFailure('https://rpc-a.example.com') + recordRpcFailure('https://rpc-a.example.com') + assert.equal(isDeprioritized('https://rpc-a.example.com'), false) + }) + + test('three failures deprioritize the endpoint', () => { + recordRpcFailure('https://rpc-a.example.com') + recordRpcFailure('https://rpc-a.example.com') + recordRpcFailure('https://rpc-a.example.com') + assert.equal(isDeprioritized('https://rpc-a.example.com'), true) + }) + + test('recordRpcSuccess removes health entry', () => { + recordRpcFailure('https://rpc-a.example.com') + recordRpcFailure('https://rpc-a.example.com') + recordRpcFailure('https://rpc-a.example.com') + assert.equal(isDeprioritized('https://rpc-a.example.com'), true) + recordRpcSuccess('https://rpc-a.example.com') + assert.equal(isDeprioritized('https://rpc-a.example.com'), false) + }) + + test('unknown URL is not deprioritized', () => { + assert.equal(isDeprioritized('https://unknown.example.com'), false) + }) + + test('failures on different URLs are tracked independently', () => { + recordRpcFailure('https://rpc-a.example.com') + recordRpcFailure('https://rpc-a.example.com') + recordRpcFailure('https://rpc-a.example.com') + assert.equal(isDeprioritized('https://rpc-a.example.com'), true) + assert.equal(isDeprioritized('https://rpc-b.example.com'), false) + }) + + test('failure count is recorded in rpcHealth map', () => { + recordRpcFailure('https://rpc-a.example.com') + recordRpcFailure('https://rpc-a.example.com') + const entry = rpcHealth.get('https://rpc-a.example.com') + assert.ok(entry) + assert.equal(entry.failureCount, 2) + assert.equal(entry.deprioritized, false) + }) +}) + +describe('hasInjectedWallet', () => { + test('returns false when window is undefined (SSR)', () => { + assert.equal(hasInjectedWallet(), false) + }) +}) + +describe('multi-RPC config validation', () => { + test('comma-separated RPC URLs are parsed correctly', () => { + process.env.NEXT_PUBLIC_WALLET_RPC_MAINNET = 'https://rpc-a.example.com,https://rpc-b.example.com' + const envValue = process.env.NEXT_PUBLIC_WALLET_RPC_MAINNET + const urls = envValue + ?.split(',') + .map((part) => part.trim()) + .filter(Boolean) ?? [] + assert.equal(urls.length, 2) + assert.equal(urls[0], 'https://rpc-a.example.com') + assert.equal(urls[1], 'https://rpc-b.example.com') + delete process.env.NEXT_PUBLIC_WALLET_RPC_MAINNET + }) + + test('empty CSV segments are filtered out', () => { + const raw = 'https://rpc-a.example.com,, ,https://rpc-b.example.com,' + const urls = raw + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + assert.equal(urls.length, 2) + }) +}) + +describe('connector validation', () => { + test('supported connector names are accepted', () => { + const supported = ['injected', 'walletConnect'] + for (const name of supported) { + assert.ok(supported.includes(name)) + } + }) + + test('unsupported connector name is rejected', () => { + const supported = ['injected', 'walletConnect'] + assert.equal(supported.includes('coinbaseWallet'), false) + assert.equal(supported.includes('unknown'), false) + }) +}) diff --git a/test/tsconfig.json b/test/tsconfig.json index dd5309b..cb3d1da 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -33,6 +33,7 @@ "../lib/config-validation.js", "../lib/wallet/validation.js", "../lib/wallet/siwe-session.ts", + "../lib/wallet/config.ts", "../lib/config.ts", "../lib/features.ts", "../components/feature-gate.tsx",