diff --git a/webapp/src/components/app-header.tsx b/webapp/src/components/app-header.tsx
index c036b90..32320bc 100644
--- a/webapp/src/components/app-header.tsx
+++ b/webapp/src/components/app-header.tsx
@@ -1,9 +1,18 @@
import { useEffect, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router';
import { useCluster } from '@solana/connector/react';
-import { Button } from '@solana/design-system';
-import { ChevronDown, Menu, RotateCcw, Settings2 } from 'lucide-react';
+import { Button, TextInput } from '@solana/design-system';
+import { ChevronDown, Menu, Plus, RotateCcw, Settings2, Trash2 } from 'lucide-react';
+import { toast } from 'sonner';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
@@ -14,6 +23,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { CURRENT_PROGRAM_VERSION } from '@solana/subscriptions';
import solanaLogo from '@/assets/solana-logo.svg';
+import { clearCustomCluster, isValidRpcUrl, readCustomCluster, saveCustomCluster } from '@/lib/custom-rpc';
import { cn } from '@/lib/utils';
import { NAV_ITEMS, type NavItem } from './nav-items';
@@ -23,50 +33,110 @@ import { TimeTravelButton } from './time-travel/time-travel-button';
function ClusterButton() {
const { cluster, clusters, setCluster } = useCluster();
const navigate = useNavigate();
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [url, setUrl] = useState('');
+
+ const hasCustom = readCustomCluster() !== null;
+
+ function openDialog() {
+ setUrl(readCustomCluster()?.url ?? '');
+ setDialogOpen(true);
+ }
+
+ function handleSave() {
+ const trimmed = url.trim();
+ if (!isValidRpcUrl(trimmed)) {
+ toast.error('Enter a valid http(s) RPC URL');
+ return;
+ }
+ saveCustomCluster(trimmed);
+ window.location.reload();
+ }
+
+ function handleRemove() {
+ clearCustomCluster();
+ window.location.reload();
+ }
return (
-
-
- }
- iconRight={}
- size="sm"
- variant="secondary"
- >
- {cluster?.label ?? 'Network'}
-
-
-
- Network
-
- {clusters.map(c => (
- {
- void setCluster(c.id);
- }}
+ <>
+
+
+ }
+ iconRight={}
+ size="sm"
+ variant="secondary"
>
- {c.label}
-
- ))}
- {import.meta.env.DEV && (
- <>
-
+ {cluster?.label ?? 'Network'}
+
+
+
+ Network
+
+ {clusters.map(c => (
{
- localStorage.removeItem('setup-complete-localnet');
- localStorage.removeItem('setup-complete-devnet');
- localStorage.removeItem('setup-cluster');
- navigate('/setup');
+ void setCluster(c.id);
}}
>
-
- Rerun setup
+ {c.label}
- >
- )}
-
-
+ ))}
+
+
+
+ {hasCustom ? 'Edit custom RPC' : 'Add custom RPC'}
+
+ {hasCustom && (
+
+
+ Remove custom RPC
+
+ )}
+ {import.meta.env.DEV && (
+ <>
+
+ {
+ localStorage.removeItem('setup-complete-localnet');
+ localStorage.removeItem('setup-complete-devnet');
+ localStorage.removeItem('setup-cluster');
+ navigate('/setup');
+ }}
+ >
+
+ Rerun setup
+
+ >
+ )}
+
+
+
+
+ >
);
}
diff --git a/webapp/src/components/solana/solana-provider.tsx b/webapp/src/components/solana/solana-provider.tsx
index 107a8cc..33d0820 100644
--- a/webapp/src/components/solana/solana-provider.tsx
+++ b/webapp/src/components/solana/solana-provider.tsx
@@ -22,12 +22,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
+import { CUSTOM_CLUSTER_ID, readCustomCluster } from '@/lib/custom-rpc';
import { ellipsify } from '@/lib/utils';
function defaultClusterId(): SolanaClusterId {
const stored = localStorage.getItem('setup-cluster');
const configured = import.meta.env.VITE_DEFAULT_CLUSTER;
const id = stored || configured || (import.meta.env.DEV ? 'solana:localnet' : 'solana:devnet');
+ if (id === CUSTOM_CLUSTER_ID && readCustomCluster()) return CUSTOM_CLUSTER_ID;
return id === 'solana:devnet' || id === 'solana:testnet' || id === 'solana:localnet' || id === 'solana:mainnet'
? (id as SolanaClusterId)
: 'solana:devnet';
@@ -36,20 +38,24 @@ function defaultClusterId(): SolanaClusterId {
function networkFromClusterId(clusterId: SolanaClusterId): 'devnet' | 'localnet' | 'mainnet' | 'testnet' {
if (clusterId === 'solana:devnet') return 'devnet';
if (clusterId === 'solana:testnet') return 'testnet';
- if (clusterId === 'solana:mainnet') return 'mainnet';
+ if (clusterId === 'solana:mainnet' || clusterId === CUSTOM_CLUSTER_ID) return 'mainnet';
return 'localnet';
}
-const clusters = [
- ...(import.meta.env.DEV ? [{ id: 'solana:localnet' as const, label: 'Localnet', url: '/rpc' }] : []),
- { id: 'solana:devnet' as const, label: 'Devnet', url: 'https://api.devnet.solana.com' },
- { id: 'solana:testnet' as const, label: 'Testnet', url: 'https://api.testnet.solana.com' },
- {
- id: 'solana:mainnet' as const,
- label: 'Mainnet',
- url: import.meta.env.VITE_MAINNET_RPC_URL ?? 'https://api.mainnet-beta.solana.com',
- },
-];
+function buildClusters() {
+ const custom = readCustomCluster();
+ return [
+ ...(import.meta.env.DEV ? [{ id: 'solana:localnet' as const, label: 'Localnet', url: '/rpc' }] : []),
+ { id: 'solana:devnet' as const, label: 'Devnet', url: 'https://api.devnet.solana.com' },
+ { id: 'solana:testnet' as const, label: 'Testnet', url: 'https://api.testnet.solana.com' },
+ {
+ id: 'solana:mainnet' as const,
+ label: 'Mainnet',
+ url: import.meta.env.VITE_MAINNET_RPC_URL ?? 'https://api.mainnet-beta.solana.com',
+ },
+ ...(custom ? [custom] : []),
+ ];
+}
export function WalletButton() {
const { account, isConnected, isConnecting } = useWallet();
@@ -154,7 +160,7 @@ export function SolanaProvider({ children }: { children: ReactNode }) {
return getDefaultConfig({
appName: 'Subscriptions',
autoConnect: true,
- clusters,
+ clusters: buildClusters(),
enableMobile: true,
network: networkFromClusterId(initialCluster),
persistClusterSelection: false,
diff --git a/webapp/src/config/networks.ts b/webapp/src/config/networks.ts
index 2031018..3654ce3 100644
--- a/webapp/src/config/networks.ts
+++ b/webapp/src/config/networks.ts
@@ -18,6 +18,10 @@ const DEVNET_USDC = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU';
const MAINNET_USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
export const STATIC_NETWORKS: Record = {
+ custom: {
+ programAddress: PROGRAM_ID,
+ tokens: [{ decimals: 6, mint: MAINNET_USDC, name: 'USD Coin', symbol: 'USDC' }],
+ },
devnet: {
programAddress: PROGRAM_ID,
tokens: [{ decimals: 6, mint: DEVNET_USDC, name: 'USD Coin', symbol: 'USDC' }],
diff --git a/webapp/src/lib/cluster.ts b/webapp/src/lib/cluster.ts
index 44c1f3f..191788d 100644
--- a/webapp/src/lib/cluster.ts
+++ b/webapp/src/lib/cluster.ts
@@ -1,6 +1,7 @@
-export type Network = 'localnet' | 'devnet' | 'testnet' | 'mainnet';
+export type Network = 'localnet' | 'devnet' | 'testnet' | 'mainnet' | 'custom';
export function clusterIdToNetwork(id: string): Network {
+ if (id === 'solana:custom') return 'custom';
if (id.includes('devnet')) return 'devnet';
if (id.includes('testnet')) return 'testnet';
if (id.includes('mainnet')) return 'mainnet';
diff --git a/webapp/src/lib/custom-rpc.ts b/webapp/src/lib/custom-rpc.ts
new file mode 100644
index 0000000..7e3e659
--- /dev/null
+++ b/webapp/src/lib/custom-rpc.ts
@@ -0,0 +1,39 @@
+import type { SolanaCluster } from '@solana/connector/react';
+
+export const CUSTOM_CLUSTER_ID = 'solana:custom' as const;
+
+const URL_KEY = 'custom-rpc-url';
+const LABEL_KEY = 'custom-rpc-label';
+const SETUP_CLUSTER_KEY = 'setup-cluster';
+const SETUP_COMPLETE_KEY = 'setup-complete-custom';
+
+export function readCustomCluster(): SolanaCluster | null {
+ const url = localStorage.getItem(URL_KEY);
+ if (!url) return null;
+ return { id: CUSTOM_CLUSTER_ID, label: localStorage.getItem(LABEL_KEY) || 'Custom', url };
+}
+
+export function saveCustomCluster(url: string, label?: string): void {
+ localStorage.setItem(URL_KEY, url);
+ localStorage.setItem(LABEL_KEY, label?.trim() || 'Custom');
+ localStorage.setItem(SETUP_CLUSTER_KEY, CUSTOM_CLUSTER_ID);
+ localStorage.setItem(SETUP_COMPLETE_KEY, 'true');
+}
+
+export function clearCustomCluster(): void {
+ localStorage.removeItem(URL_KEY);
+ localStorage.removeItem(LABEL_KEY);
+ localStorage.removeItem(SETUP_COMPLETE_KEY);
+ if (localStorage.getItem(SETUP_CLUSTER_KEY) === CUSTOM_CLUSTER_ID) {
+ localStorage.removeItem(SETUP_CLUSTER_KEY);
+ }
+}
+
+export function isValidRpcUrl(value: string): boolean {
+ try {
+ const { protocol } = new URL(value);
+ return protocol === 'http:' || protocol === 'https:';
+ } catch {
+ return false;
+ }
+}