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
44 changes: 27 additions & 17 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down
94 changes: 82 additions & 12 deletions components/wallet/connect-button.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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<boolean | null>(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 (
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center justify-end gap-2">
{connectors.map((connector) => (
<Button
key={connector.id}
id={`wallet-connect-${connector.id}`}
size="sm"
variant={connector.id === 'injected' ? 'default' : 'outline'}
onClick={() => handleConnect(connector.id)}
disabled={isConnecting}
aria-busy={isConnecting}
>
{isConnecting ? "Connecting…" : connector.name}
</Button>
))}
<Button
id="wallet-connect-cancel"
variant="ghost"
size="sm"
onClick={() => setShowConnectorPicker(false)}
>
Cancel
</Button>
</div>
</div>
)
}

return (
<Button
id="wallet-connect-btn"
size="sm"
onClick={() => connect({ connector: injected() })}
disabled={isConnecting}
aria-busy={isConnecting}
>
{isConnecting ? "Connecting…" : "Connect Wallet"}
</Button>
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
id="wallet-connect-btn"
size="sm"
onClick={handleQuickConnect}
disabled={isConnecting}
aria-busy={isConnecting}
>
{isConnecting ? "Connecting…" : showFallbackPrompt ? "Connect Wallet" : "Connect Wallet"}
</Button>
</div>
{showFallbackPrompt && (
<p className="max-w-xs text-right text-xs text-muted-foreground">
No injected wallet detected — another connector may be used.
</p>
)}
</div>
);
}

Expand Down
85 changes: 81 additions & 4 deletions lib/wallet/config.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string, EndpointHealth>()

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<WalletRuntimeConfig['transports']>((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'))
}
Expand All @@ -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()
Expand Down
43 changes: 43 additions & 0 deletions lib/wallet/connectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading