diff --git a/.gitignore b/.gitignore
index 74a7f2e..9122f85 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,8 @@ config.json
# Environment secrets
.env
+.env.local
+**/.env.local
keys/
@@ -39,3 +41,7 @@ clients/typescript/docs/
.vercel
.claude/
+
+# Editor / tool config
+.emdash.json
+.vscode/
diff --git a/.prettierignore b/.prettierignore
index 17a9f54..91eefa0 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -19,3 +19,7 @@ pnpm-lock.yaml
# TypeDoc generated API docs
clients/typescript/docs/
+
+# Editor / tool config
+.emdash.json
+.vscode/
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 2244eab..021a7cf 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -24,6 +24,8 @@ export default [
},
{
ignores: [
+ '**/.claude/**',
+ '**/.git/**',
'**/dist/**',
'**/node_modules/**',
'**/target/**',
diff --git a/justfile b/justfile
index 27d80a6..fd68ad9 100644
--- a/justfile
+++ b/justfile
@@ -166,6 +166,30 @@ ensure-surfpool:
just kill-validator
exit 1
+# Bootstrap localnet (validator + program + mock USDC) and write webapp/.env.local for pnpm dev (no api server)
+dev-local: ensure-surfpool
+ #!/usr/bin/env bash
+ set -euo pipefail
+
+ pushd {{webapp_dir}}/scripts > /dev/null
+ pnpm install --silent
+ NETWORK=localnet RPC_URL=http://127.0.0.1:8899 pnpm run init
+ popd > /dev/null
+
+ PROG_ID=$(just program-id)
+ USDC_MINT=$(node -e "const c=JSON.parse(require('fs').readFileSync('{{webapp_dir}}/config.json'));process.stdout.write(c.networks.localnet.tokens.find(t=>t.symbol==='USDC').mint)")
+
+ {
+ echo "VITE_DEFAULT_CLUSTER=solana:localnet"
+ echo "VITE_LOCALNET_PROGRAM=$PROG_ID"
+ echo "VITE_LOCALNET_USDC_MINT=$USDC_MINT"
+ } > {{webapp_dir}}/.env.local
+ echo "✓ {{webapp_dir}}/.env.local written"
+ echo " VITE_LOCALNET_PROGRAM=$PROG_ID"
+ echo " VITE_LOCALNET_USDC_MINT=$USDC_MINT"
+ echo ""
+ echo "Next: pnpm --filter webapp dev"
+
# Stop all validators
kill-validator:
#!/usr/bin/env bash
diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx
index 6672360..ff9d3bf 100644
--- a/webapp/src/App.tsx
+++ b/webapp/src/App.tsx
@@ -1,4 +1,7 @@
+import { lazy, Suspense } from 'react';
import { Route, Routes, Navigate, useLocation } from 'react-router';
+import { useQuery } from '@tanstack/react-query';
+import { createSolanaRpc } from '@solana/kit';
import { AppProviders } from '@/components/app-providers';
import { AppLayout } from '@/components/app-layout';
import { Dashboard } from '@/routes/dashboard';
@@ -8,13 +11,12 @@ import { Delegations } from '@/routes/delegations';
import { Subscriptions } from '@/routes/subscriptions';
import { Plans } from '@/routes/plans';
import { CollectPayments } from '@/routes/collect-payments';
-import { Program } from '@/routes/program';
-import { Setup } from '@/routes/setup';
import { useNetworkConfig } from '@/hooks/use-token-config';
-import { clusterIdToNetwork } from '@/lib/api-client';
+import { clusterIdToNetwork } from '@/lib/cluster';
import { useClusterConfig } from '@/hooks/use-cluster-config';
-import { useQuery } from '@tanstack/react-query';
-import { createSolanaRpc } from '@solana/kit';
+
+const Setup = lazy(() => import('@/routes/setup').then(m => ({ default: m.Setup })));
+const Program = lazy(() => import('@/routes/program').then(m => ({ default: m.Program })));
function useRpcReachable() {
const { id, url } = useClusterConfig();
@@ -63,7 +65,7 @@ function useIsSetupValid(): { ready: boolean; loading: boolean } {
return { ready: true, loading: false };
}
-function SetupGuard({ children }: { children: React.ReactNode }) {
+function DevSetupGuard({ children }: { children: React.ReactNode }) {
const location = useLocation();
const { ready } = useIsSetupValid();
if (!ready && location.pathname !== '/setup') {
@@ -72,78 +74,89 @@ function SetupGuard({ children }: { children: React.ReactNode }) {
return <>{children}>;
}
+function ProdSetupGuard({ children }: { children: React.ReactNode }) {
+ return <>{children}>;
+}
+
+const HAS_LOCALNET_CONFIG = !!import.meta.env.VITE_LOCALNET_USDC_MINT;
+const SetupGuard = import.meta.env.DEV && !HAS_LOCALNET_CONFIG ? DevSetupGuard : ProdSetupGuard;
+
export default function App() {
return (
-
- } />
-
-
-
- }
- path="/"
- />
-
-
-
- }
- path="/marketplace"
- />
-
-
-
- }
- path="/delegations"
- />
-
-
-
- }
- path="/subscriptions"
- />
-
-
-
- }
- path="/plans"
- />
-
-
-
- }
- path="/plans/collect"
- />
-
-
-
- }
- path="/faucet"
- />
-
-
-
- }
- path="/program"
- />
- } />
-
+
+
+ {import.meta.env.DEV && } />}
+
+
+
+ }
+ path="/"
+ />
+
+
+
+ }
+ path="/marketplace"
+ />
+
+
+
+ }
+ path="/delegations"
+ />
+
+
+
+ }
+ path="/subscriptions"
+ />
+
+
+
+ }
+ path="/plans"
+ />
+
+
+
+ }
+ path="/plans/collect"
+ />
+
+
+
+ }
+ path="/faucet"
+ />
+ {import.meta.env.DEV && (
+
+
+
+ }
+ path="/program"
+ />
+ )}
+ } />
+
+
);
diff --git a/webapp/src/components/app-header.tsx b/webapp/src/components/app-header.tsx
index 6ccdf23..8065c81 100644
--- a/webapp/src/components/app-header.tsx
+++ b/webapp/src/components/app-header.tsx
@@ -1,33 +1,70 @@
-import { useState } from 'react';
+import { useCluster } from '@solana/connector/react';
import { Button } from '@solana/design-system';
-import { Menu, X, Settings2 } from 'lucide-react';
-import { WalletButton } from './solana/solana-provider';
-import { TimeTravelButton } from './time-travel/time-travel-button';
+import { ChevronDown, Menu, RotateCcw, Settings2, X } from 'lucide-react';
+import { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router';
-import { useCluster } from '@solana/connector/react';
-import { NAV_ITEMS } from './nav-items';
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
import { CURRENT_PROGRAM_VERSION } from '@subscriptions/client';
-function NetworkButton() {
+import { NAV_ITEMS } from './nav-items';
+import { WalletButton } from './solana/solana-provider';
+import { TimeTravelButton } from './time-travel/time-travel-button';
+
+function ClusterButton() {
+ const { cluster, clusters, setCluster } = useCluster();
const navigate = useNavigate();
- const setupCluster = localStorage.getItem('setup-cluster') ?? '';
- const label =
- setupCluster === 'solana:devnet' ? 'Devnet' : setupCluster === 'solana:testnet' ? 'Testnet' : 'Localnet';
return (
- }
- onClick={() => {
- localStorage.removeItem('setup-complete-localnet');
- localStorage.removeItem('setup-complete-devnet');
- localStorage.removeItem('setup-cluster');
- navigate('/setup');
- }}
- size="sm"
- variant="secondary"
- >
- {label}
-
+
+
+ }
+ iconRight={}
+ size="sm"
+ variant="secondary"
+ >
+ {cluster?.label ?? 'Network'}
+
+
+
+ Network
+
+ {clusters.map(c => (
+ {
+ void setCluster(c.id);
+ }}
+ >
+ {c.label}
+
+ ))}
+ {import.meta.env.DEV && (
+ <>
+
+ {
+ localStorage.removeItem('setup-complete-localnet');
+ localStorage.removeItem('setup-complete-devnet');
+ localStorage.removeItem('setup-cluster');
+ navigate('/setup');
+ }}
+ >
+
+ Rerun setup
+
+ >
+ )}
+
+
);
}
@@ -63,7 +100,7 @@ export function AppHeader() {
-
+
{showMenu && (
@@ -97,7 +134,7 @@ export function AppHeader() {
-
+
diff --git a/webapp/src/components/nav-items.ts b/webapp/src/components/nav-items.ts
index 77e2f63..7d447ce 100644
--- a/webapp/src/components/nav-items.ts
+++ b/webapp/src/components/nav-items.ts
@@ -21,5 +21,14 @@ export const NAV_ITEMS: NavItem[] = [
path: '/plans',
},
{ clusterFilter: ['solana:localnet', 'solana:devnet'], icon: Droplets, label: 'Faucet', path: '/faucet' },
- { clusterFilter: ['solana:devnet', 'solana:testnet'], icon: Code2, label: 'Program', path: '/program' },
+ ...(import.meta.env.DEV
+ ? [
+ {
+ clusterFilter: ['solana:devnet', 'solana:testnet'],
+ icon: Code2,
+ label: 'Program',
+ path: '/program',
+ } satisfies NavItem,
+ ]
+ : []),
];
diff --git a/webapp/src/components/solana/solana-provider.tsx b/webapp/src/components/solana/solana-provider.tsx
index 5992c3c..68719ce 100644
--- a/webapp/src/components/solana/solana-provider.tsx
+++ b/webapp/src/components/solana/solana-provider.tsx
@@ -27,22 +27,28 @@ 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 || 'solana:localnet';
- return id === 'solana:devnet' || id === 'solana:testnet' || id === 'solana:localnet'
+ const id = stored || configured || (import.meta.env.DEV ? 'solana:localnet' : 'solana:devnet');
+ return id === 'solana:devnet' || id === 'solana:testnet' || id === 'solana:localnet' || id === 'solana:mainnet'
? (id as SolanaClusterId)
- : 'solana:localnet';
+ : 'solana:devnet';
}
-function networkFromClusterId(clusterId: SolanaClusterId): 'devnet' | 'localnet' | 'testnet' {
+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';
return 'localnet';
}
const clusters = [
- { id: 'solana:localnet' as const, label: 'Localnet', url: '/rpc' },
+ ...(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',
+ },
];
export function WalletButton() {
diff --git a/webapp/src/config/networks.ts b/webapp/src/config/networks.ts
new file mode 100644
index 0000000..2031018
--- /dev/null
+++ b/webapp/src/config/networks.ts
@@ -0,0 +1,46 @@
+import type { Network } from '@/lib/cluster';
+
+export interface TokenConfig {
+ decimals: number;
+ mint: string;
+ name: string;
+ symbol: string;
+}
+
+export interface NetworkConfig {
+ programAddress: string | null;
+ tokens: TokenConfig[];
+}
+
+const PROGRAM_ID = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44';
+
+const DEVNET_USDC = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU';
+const MAINNET_USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
+
+export const STATIC_NETWORKS: Record = {
+ devnet: {
+ programAddress: PROGRAM_ID,
+ tokens: [{ decimals: 6, mint: DEVNET_USDC, name: 'USD Coin', symbol: 'USDC' }],
+ },
+ localnet: {
+ programAddress: import.meta.env.VITE_LOCALNET_PROGRAM ?? PROGRAM_ID,
+ tokens: import.meta.env.VITE_LOCALNET_USDC_MINT
+ ? [
+ {
+ decimals: 6,
+ mint: import.meta.env.VITE_LOCALNET_USDC_MINT,
+ name: 'USD Coin (mock)',
+ symbol: 'USDC',
+ },
+ ]
+ : [],
+ },
+ mainnet: {
+ programAddress: PROGRAM_ID,
+ tokens: [{ decimals: 6, mint: MAINNET_USDC, name: 'USD Coin', symbol: 'USDC' }],
+ },
+ testnet: {
+ programAddress: PROGRAM_ID,
+ tokens: [],
+ },
+};
diff --git a/webapp/src/hooks/use-token-config.ts b/webapp/src/hooks/use-token-config.ts
index 1ba2989..f26370a 100644
--- a/webapp/src/hooks/use-token-config.ts
+++ b/webapp/src/hooks/use-token-config.ts
@@ -1,16 +1,26 @@
import { useQuery } from '@tanstack/react-query';
+import { type NetworkConfig, STATIC_NETWORKS } from '@/config/networks';
import { useClusterConfig } from '@/hooks/use-cluster-config';
-import type { NetworkConfigResponse } from '@/lib/api-client';
-import { api, clusterIdToNetwork } from '@/lib/api-client';
+import { api } from '@/lib/api-client';
+import { clusterIdToNetwork } from '@/lib/cluster';
export function useNetworkConfig() {
const { id } = useClusterConfig();
const network = clusterIdToNetwork(id);
- return useQuery({
- queryFn: () => api.config.getNetworkConfig(network),
- queryKey: ['network-config', network],
+ return useQuery({
+ queryFn: async () => {
+ if (import.meta.env.DEV) {
+ try {
+ return await api.config.getNetworkConfig(network);
+ } catch {
+ return STATIC_NETWORKS[network];
+ }
+ }
+ return STATIC_NETWORKS[network];
+ },
+ queryKey: ['network-config', network, import.meta.env.DEV],
retry: 2,
staleTime: 30_000,
});
diff --git a/webapp/src/lib/cluster.ts b/webapp/src/lib/cluster.ts
new file mode 100644
index 0000000..44c1f3f
--- /dev/null
+++ b/webapp/src/lib/cluster.ts
@@ -0,0 +1,8 @@
+export type Network = 'localnet' | 'devnet' | 'testnet' | 'mainnet';
+
+export function clusterIdToNetwork(id: string): Network {
+ if (id.includes('devnet')) return 'devnet';
+ if (id.includes('testnet')) return 'testnet';
+ if (id.includes('mainnet')) return 'mainnet';
+ return 'localnet';
+}
diff --git a/webapp/vercel.json b/webapp/vercel.json
new file mode 100644
index 0000000..cc3b588
--- /dev/null
+++ b/webapp/vercel.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "https://openapi.vercel.sh/vercel.json",
+ "framework": "vite",
+ "buildCommand": "pnpm --filter @subscriptions/client build && pnpm --filter webapp build",
+ "installCommand": "pnpm install --frozen-lockfile",
+ "outputDirectory": "dist"
+}