From c6fd59ee2abf52da09cb1c1f86c92c2dd59827db Mon Sep 17 00:00:00 2001 From: Jo D Date: Mon, 1 Jun 2026 17:34:53 -0400 Subject: [PATCH 1/2] fix(webapp): detect network for custom RPC so wallet signs Custom RPC stored cluster id `solana:custom`, which ConnectorKit passes straight to the wallet as the signing chain. Wallets only sign for `solana:mainnet|devnet|testnet`, so signing failed with "Failed to sign transaction 1 of 1". Custom RPC now detects the network via getGenesisHash and overrides the matching real cluster's URL, keeping the wallet chain valid. Drops the `solana:custom` cluster and the `custom` Network entry. --- webapp/src/components/app-header.tsx | 36 ++++++++++----- .../src/components/solana/solana-provider.tsx | 29 +++++++----- webapp/src/config/networks.ts | 4 -- webapp/src/lib/cluster.ts | 3 +- webapp/src/lib/custom-rpc.ts | 44 +++++++++++++------ 5 files changed, 76 insertions(+), 40 deletions(-) diff --git a/webapp/src/components/app-header.tsx b/webapp/src/components/app-header.tsx index 32320bc..3fb9cb3 100644 --- a/webapp/src/components/app-header.tsx +++ b/webapp/src/components/app-header.tsx @@ -23,7 +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 { clearCustomRpc, detectNetwork, isValidRpcUrl, readCustomRpc, saveCustomRpc } from '@/lib/custom-rpc'; import { cn } from '@/lib/utils'; import { NAV_ITEMS, type NavItem } from './nav-items'; @@ -35,26 +35,39 @@ function ClusterButton() { const navigate = useNavigate(); const [dialogOpen, setDialogOpen] = useState(false); const [url, setUrl] = useState(''); + const [saving, setSaving] = useState(false); - const hasCustom = readCustomCluster() !== null; + const hasCustom = readCustomRpc() !== null; function openDialog() { - setUrl(readCustomCluster()?.url ?? ''); + setUrl(readCustomRpc()?.url ?? ''); setDialogOpen(true); } - function handleSave() { + async function handleSave() { const trimmed = url.trim(); if (!isValidRpcUrl(trimmed)) { toast.error('Enter a valid http(s) RPC URL'); return; } - saveCustomCluster(trimmed); - window.location.reload(); + setSaving(true); + try { + const network = await detectNetwork(trimmed); + if (!network) { + toast.error('Could not detect mainnet, devnet, or testnet from this RPC'); + return; + } + saveCustomRpc(trimmed, network); + window.location.reload(); + } catch { + toast.error('Could not reach RPC URL'); + } finally { + setSaving(false); + } } function handleRemove() { - clearCustomCluster(); + clearCustomRpc(); window.location.reload(); } @@ -119,7 +132,8 @@ function ClusterButton() { Custom RPC endpoint - Point the app at your own Solana RPC URL. Saving reloads the page and selects it. + Point the app at your own Solana RPC URL. The network is detected from the endpoint; saving + reloads the page and selects it. - - + diff --git a/webapp/src/components/solana/solana-provider.tsx b/webapp/src/components/solana/solana-provider.tsx index 33d0820..909151d 100644 --- a/webapp/src/components/solana/solana-provider.tsx +++ b/webapp/src/components/solana/solana-provider.tsx @@ -8,6 +8,7 @@ import { useWallet, useWalletConnectors, useWalletInfo, + type SolanaCluster, type SolanaClusterId, } from '@solana/connector/react'; import { Button } from '@solana/design-system'; @@ -22,14 +23,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { CUSTOM_CLUSTER_ID, readCustomCluster } from '@/lib/custom-rpc'; +import { readCustomRpc } 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'; @@ -38,23 +38,32 @@ 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' || clusterId === CUSTOM_CLUSTER_ID) return 'mainnet'; + if (clusterId === 'solana:mainnet') return 'mainnet'; return 'localnet'; } -function buildClusters() { - const custom = readCustomCluster(); - return [ +function buildClusters(): SolanaCluster[] { + const clusters: SolanaCluster[] = [ ...(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:devnet', label: 'Devnet', url: 'https://api.devnet.solana.com' }, + { id: 'solana:testnet', label: 'Testnet', url: 'https://api.testnet.solana.com' }, { - id: 'solana:mainnet' as const, + id: 'solana:mainnet', label: 'Mainnet', url: import.meta.env.VITE_MAINNET_RPC_URL ?? 'https://api.mainnet-beta.solana.com', }, - ...(custom ? [custom] : []), ]; + + const custom = readCustomRpc(); + if (custom) { + const target = clusters.find(c => c.id === `solana:${custom.network}`); + if (target) { + target.url = custom.url; + target.label = `${target.label} (${custom.label})`; + } + } + + return clusters; } export function WalletButton() { diff --git a/webapp/src/config/networks.ts b/webapp/src/config/networks.ts index 3654ce3..2031018 100644 --- a/webapp/src/config/networks.ts +++ b/webapp/src/config/networks.ts @@ -18,10 +18,6 @@ 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 191788d..44c1f3f 100644 --- a/webapp/src/lib/cluster.ts +++ b/webapp/src/lib/cluster.ts @@ -1,7 +1,6 @@ -export type Network = 'localnet' | 'devnet' | 'testnet' | 'mainnet' | 'custom'; +export type Network = 'localnet' | 'devnet' | 'testnet' | 'mainnet'; 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 index 7e3e659..77f55ec 100644 --- a/webapp/src/lib/custom-rpc.ts +++ b/webapp/src/lib/custom-rpc.ts @@ -1,32 +1,48 @@ -import type { SolanaCluster } from '@solana/connector/react'; +import { createSolanaRpc } from '@solana/kit'; -export const CUSTOM_CLUSTER_ID = 'solana:custom' as const; +export type CustomNetwork = 'mainnet' | 'devnet' | 'testnet'; const URL_KEY = 'custom-rpc-url'; const LABEL_KEY = 'custom-rpc-label'; +const NETWORK_KEY = 'custom-rpc-network'; const SETUP_CLUSTER_KEY = 'setup-cluster'; -const SETUP_COMPLETE_KEY = 'setup-complete-custom'; -export function readCustomCluster(): SolanaCluster | null { +const GENESIS_TO_NETWORK: Record = { + '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d': 'mainnet', + EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG: 'devnet', + '4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY': 'testnet', +}; + +export interface CustomRpc { + label: string; + network: CustomNetwork; + url: string; +} + +export function readCustomRpc(): CustomRpc | null { const url = localStorage.getItem(URL_KEY); - if (!url) return null; - return { id: CUSTOM_CLUSTER_ID, label: localStorage.getItem(LABEL_KEY) || 'Custom', url }; + const network = localStorage.getItem(NETWORK_KEY) as CustomNetwork | null; + if (!url || !network) return null; + return { label: localStorage.getItem(LABEL_KEY) || 'Custom', network, url }; } -export function saveCustomCluster(url: string, label?: string): void { +export async function detectNetwork(url: string): Promise { + const genesisHash = await createSolanaRpc(url).getGenesisHash().send(); + return GENESIS_TO_NETWORK[genesisHash] ?? null; +} + +export function saveCustomRpc(url: string, network: CustomNetwork, label?: string): void { localStorage.setItem(URL_KEY, url); + localStorage.setItem(NETWORK_KEY, network); localStorage.setItem(LABEL_KEY, label?.trim() || 'Custom'); - localStorage.setItem(SETUP_CLUSTER_KEY, CUSTOM_CLUSTER_ID); - localStorage.setItem(SETUP_COMPLETE_KEY, 'true'); + localStorage.setItem(SETUP_CLUSTER_KEY, `solana:${network}`); + localStorage.setItem(`setup-complete-${network}`, 'true'); } -export function clearCustomCluster(): void { +export function clearCustomRpc(): void { localStorage.removeItem(URL_KEY); + localStorage.removeItem(NETWORK_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 { From 320bb70228a2fadab2b9f44fa9fb027fdc50b3ac Mon Sep 17 00:00:00 2001 From: Jo D Date: Mon, 1 Jun 2026 17:35:02 -0400 Subject: [PATCH 2/2] feat(webapp): selectable multi-token support The webapp was effectively USDC-locked: amounts divided by a hardcoded 1e6, "USDC" was hardcoded in labels, and delegation/account views pinned the USDC mint, despite the program and SDK being mint-agnostic. - Add a SelectedToken context (persisted to localStorage) + token picker, scoping the delegations page, wallet overview, and dashboard summary to the chosen token. - Route all amount formatting/parsing through token decimals instead of a fixed 1e6 multiplier; replace hardcoded "USDC" labels with token.symbol. - Show per-token breakdowns in the payment collection totals instead of summing across mints. - Add tokens from the UI: paste a mint, decimals are read on-chain, symbol and name are user-supplied; stored per network in localStorage. --- webapp/src/components/account/account-ui.tsx | 80 ++++++---- webapp/src/components/app-providers.tsx | 6 +- .../components/dashboard/summary-cards.tsx | 25 +-- .../delegation/active-delegations.tsx | 62 ++++++-- .../delegation-management-panel.tsx | 20 +-- .../plan/collect-payments-panel.tsx | 30 +++- .../plan/enhanced-collect-payments.tsx | 103 +++++++++--- .../src/components/token/add-token-dialog.tsx | 150 ++++++++++++++++++ webapp/src/components/token/token-picker.tsx | 57 +++++++ webapp/src/hooks/use-selected-token.tsx | 50 ++++++ webapp/src/hooks/use-token-config.ts | 14 +- webapp/src/lib/custom-tokens.ts | 24 +++ webapp/src/lib/utils.ts | 2 - 13 files changed, 517 insertions(+), 106 deletions(-) create mode 100644 webapp/src/components/token/add-token-dialog.tsx create mode 100644 webapp/src/components/token/token-picker.tsx create mode 100644 webapp/src/hooks/use-selected-token.tsx create mode 100644 webapp/src/lib/custom-tokens.ts diff --git a/webapp/src/components/account/account-ui.tsx b/webapp/src/components/account/account-ui.tsx index d48065d..ab0d4d4 100644 --- a/webapp/src/components/account/account-ui.tsx +++ b/webapp/src/components/account/account-ui.tsx @@ -13,9 +13,10 @@ import { toast } from 'sonner'; import type { TokenAccountEntry } from '@/lib/types'; import { useGetBalanceQuery, useGetTokenAccountsQuery, useAirdropSol, useAirdropUsdc } from './account-data-access'; import { useDelegations, useIncomingDelegations } from '@/hooks/use-delegations'; -import { useUsdcMint, useUsdcMintRaw } from '@/hooks/use-token-config'; import { useSubscriptionAuthorityStatus } from '@/hooks/use-subscription-authority-status'; -import { USDC_MULTIPLIER, cn, recurringAvailable } from '@/lib/utils'; +import { useSelectedToken } from '@/hooks/use-selected-token'; +import { TokenPicker } from '@/components/token/token-picker'; +import { cn, recurringAvailable } from '@/lib/utils'; import { getBlockTimestamp } from '@/hooks/use-time-travel'; import { useClusterConfig } from '@/hooks/use-cluster-config'; import { useProgramAddress } from '@/hooks/use-token-config'; @@ -67,7 +68,10 @@ export function WalletBalanceCards({ address: addr }: { address: Address }) { const solQuery = useGetBalanceQuery({ address: addr }); const tokenQuery = useGetTokenAccountsQuery({ address: addr }); const { url: rpcUrl } = useClusterConfig(); - const usdcMint = useUsdcMint(); + const { selectedMint, selectedToken } = useSelectedToken(); + const decimals = selectedToken?.decimals ?? 0; + const symbol = selectedToken?.symbol ?? ''; + const divisor = 10 ** decimals; const progAddr = useProgramAddress(); const outgoing = useDelegations(); const incoming = useIncomingDelegations(); @@ -83,16 +87,21 @@ export function WalletBalanceCards({ address: addr }: { address: Address }) { const reservedAmount = useMemo(() => { let total = 0; - for (const d of outgoing.fixed) total += Number(d.data.amount) / USDC_MULTIPLIER; - for (const d of outgoing.recurring) total += Number(d.data.amountPerPeriod) / USDC_MULTIPLIER; + for (const d of outgoing.fixed) { + if (d.data.mint === selectedMint) total += Number(d.data.amount) / divisor; + } + for (const d of outgoing.recurring) { + if (d.data.mint === selectedMint) total += Number(d.data.amountPerPeriod) / divisor; + } return total; - }, [outgoing.fixed, outgoing.recurring]); + }, [outgoing.fixed, outgoing.recurring, selectedMint, divisor]); const incomingAmount = useMemo(() => { let total = 0; for (const d of incoming.all) { + if (d.data.mint !== selectedMint) continue; if (d.type === 'Fixed') { - total += Number(d.data.amount) / USDC_MULTIPLIER; + total += Number(d.data.amount) / divisor; } else { total += Number( @@ -103,22 +112,21 @@ export function WalletBalanceCards({ address: addr }: { address: Address }) { d.data.periodLengthS, blockTime, ), - ) / USDC_MULTIPLIER; + ) / divisor; } } return total; - }, [incoming.all, blockTime]); + }, [incoming.all, blockTime, selectedMint, divisor]); - const usdcAccount = useMemo(() => { + const tokenAccount = useMemo(() => { return (tokenQuery.data as TokenAccountEntry[] | undefined)?.find(entry => { - return entry.account?.data?.parsed?.info?.mint === usdcMint; + return entry.account?.data?.parsed?.info?.mint === selectedMint; }); - }, [tokenQuery.data, usdcMint]); + }, [tokenQuery.data, selectedMint]); - const usdcBalance = usdcAccount?.account?.data?.parsed?.info?.tokenAmount?.uiAmount ?? 0; + const tokenBalance = tokenAccount?.account?.data?.parsed?.info?.tokenAmount?.uiAmount ?? 0; - const { mint: usdcMintRaw } = useUsdcMintRaw(); - const { data: statusData } = useSubscriptionAuthorityStatus(usdcMintRaw); + const { data: statusData } = useSubscriptionAuthorityStatus(selectedMint); const delegationId = statusData?.data?.initId ?? null; const [spinning, setSpinning] = useState(false); @@ -151,15 +159,18 @@ export function WalletBalanceCards({ address: addr }: { address: Address }) { )} - + + + + + ); +} diff --git a/webapp/src/components/token/token-picker.tsx b/webapp/src/components/token/token-picker.tsx new file mode 100644 index 0000000..628a6cc --- /dev/null +++ b/webapp/src/components/token/token-picker.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { Button } from '@solana/design-system'; +import { ChevronDown, Plus } from 'lucide-react'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import type { TokenConfig } from '@/config/networks'; +import { useSelectedToken } from '@/hooks/use-selected-token'; +import { ellipsify } from '@/lib/utils'; +import { AddTokenDialog } from './add-token-dialog'; + +function tokenLabel(token: TokenConfig): string { + return token.symbol || token.name || ellipsify(token.mint); +} + +export function TokenPicker() { + const { tokens, selectedMint, selectedToken, setSelectedMint } = useSelectedToken(); + const [addOpen, setAddOpen] = useState(false); + + return ( +
+ {tokens && tokens.length > 1 && ( + + + + + + {tokens.map(token => ( + setSelectedMint(token.mint)} + className={token.mint === selectedMint ? 'font-medium' : undefined} + > + {tokenLabel(token)} + + ))} + + + )} +
+ ); +} diff --git a/webapp/src/hooks/use-selected-token.tsx b/webapp/src/hooks/use-selected-token.tsx new file mode 100644 index 0000000..7a7ea32 --- /dev/null +++ b/webapp/src/hooks/use-selected-token.tsx @@ -0,0 +1,50 @@ +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'; + +import type { TokenConfig } from '@/config/networks'; +import { useTokenConfig } from '@/hooks/use-token-config'; + +const STORAGE_KEY = 'selected-token-mint'; + +interface SelectedTokenValue { + selectedMint: string | null; + selectedToken: TokenConfig | null; + setSelectedMint: (mint: string) => void; + tokens: TokenConfig[] | undefined; +} + +const SelectedTokenContext = createContext(null); + +export function SelectedTokenProvider({ children }: { children: ReactNode }) { + const { data: tokens } = useTokenConfig(); + const [override, setOverride] = useState(() => { + try { + return localStorage.getItem(STORAGE_KEY); + } catch { + return null; + } + }); + + const setSelectedMint = useCallback((mint: string) => { + try { + localStorage.setItem(STORAGE_KEY, mint); + } catch { + /* empty */ + } + setOverride(mint); + }, []); + + const value = useMemo(() => { + const overrideValid = override != null && (tokens?.some(t => t.mint === override) ?? false); + const selectedMint = (overrideValid ? override : tokens?.[0]?.mint) ?? null; + const selectedToken = tokens?.find(t => t.mint === selectedMint) ?? null; + return { selectedMint, selectedToken, setSelectedMint, tokens }; + }, [tokens, override, setSelectedMint]); + + return {children}; +} + +export function useSelectedToken(): SelectedTokenValue { + const ctx = useContext(SelectedTokenContext); + if (!ctx) throw new Error('useSelectedToken must be used within SelectedTokenProvider'); + return ctx; +} diff --git a/webapp/src/hooks/use-token-config.ts b/webapp/src/hooks/use-token-config.ts index f26370a..e74154b 100644 --- a/webapp/src/hooks/use-token-config.ts +++ b/webapp/src/hooks/use-token-config.ts @@ -4,6 +4,13 @@ import { type NetworkConfig, STATIC_NETWORKS } from '@/config/networks'; import { useClusterConfig } from '@/hooks/use-cluster-config'; import { api } from '@/lib/api-client'; import { clusterIdToNetwork } from '@/lib/cluster'; +import { readCustomTokens } from '@/lib/custom-tokens'; + +function withCustomTokens(network: string, config: NetworkConfig): NetworkConfig { + const custom = readCustomTokens(network).filter(c => !config.tokens.some(t => t.mint === c.mint)); + if (custom.length === 0) return config; + return { ...config, tokens: [...config.tokens, ...custom] }; +} export function useNetworkConfig() { const { id } = useClusterConfig(); @@ -11,14 +18,15 @@ export function useNetworkConfig() { return useQuery({ queryFn: async () => { + let base = STATIC_NETWORKS[network]; if (import.meta.env.DEV) { try { - return await api.config.getNetworkConfig(network); + base = await api.config.getNetworkConfig(network); } catch { - return STATIC_NETWORKS[network]; + base = STATIC_NETWORKS[network]; } } - return STATIC_NETWORKS[network]; + return withCustomTokens(network, base); }, queryKey: ['network-config', network, import.meta.env.DEV], retry: 2, diff --git a/webapp/src/lib/custom-tokens.ts b/webapp/src/lib/custom-tokens.ts new file mode 100644 index 0000000..6c7c863 --- /dev/null +++ b/webapp/src/lib/custom-tokens.ts @@ -0,0 +1,24 @@ +import type { TokenConfig } from '@/config/networks'; + +const KEY_PREFIX = 'custom-tokens:'; + +export function readCustomTokens(network: string): TokenConfig[] { + try { + const raw = localStorage.getItem(KEY_PREFIX + network); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as TokenConfig[]) : []; + } catch { + return []; + } +} + +export function addCustomToken(network: string, token: TokenConfig): void { + const existing = readCustomTokens(network).filter(t => t.mint !== token.mint); + localStorage.setItem(KEY_PREFIX + network, JSON.stringify([...existing, token])); +} + +export function removeCustomToken(network: string, mint: string): void { + const existing = readCustomTokens(network).filter(t => t.mint !== mint); + localStorage.setItem(KEY_PREFIX + network, JSON.stringify(existing)); +} diff --git a/webapp/src/lib/utils.ts b/webapp/src/lib/utils.ts index ed2eab9..481b5d3 100644 --- a/webapp/src/lib/utils.ts +++ b/webapp/src/lib/utils.ts @@ -3,8 +3,6 @@ import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; import type { QueryClient } from '@tanstack/react-query'; -export const USDC_DECIMALS = 6; -export const USDC_MULTIPLIER = 10 ** USDC_DECIMALS; export const SECONDS_PER_DAY = 86400; const TIME_DRIFT_ALLOWED_SECS = 120;