**Fork with Chopsticks / Forklift is gone.** Neither can fork a chain in this list. Chopsticks is built on `@polkadot/types`, which cannot decode a Quantus block at all — it caps fixed arrays at 2048 bytes where ML-DSA signatures are 5261 and 7219, reads the extrinsic preamble byte as a version, and knows nothing of this chain's header layout. Forklift resolves its own unpatched copy of `@polkadot-api/substrate-bindings`, so it hits the very header bug this console patches around. So the two buttons offered things that could only fail, and fail without saying why. Removed rather than disabled: a greyed-out control still asks the reader to work out why. `forkMethod` stays in SelectedChain because the hash params and chain.state still read it; nothing in the UI can now set it to anything but "none", so the two light-client guards that reset it go with it. **The workflow step called "the patch is applied"** — an assertion sitting in a list of imperatives (install, build, ship the bundle), reading like a claim CI was making rather than a thing it was doing. Now "verify the signers-common patch". Verified in a browser: the switcher shows Quantus, Heisenberg, Planck and Localhost with both endpoints per chain and no FORK WITH row, and the explorer still runs — Finalized 54,153, Best 54,253. Refs #3 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
568 lines
17 KiB
TypeScript
568 lines
17 KiB
TypeScript
import { CopyText } from "@/components/Copy"
|
|
import {
|
|
ForkMethodIcon,
|
|
Spinner,
|
|
} from "@/components/Icons"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command"
|
|
import {
|
|
Dialog,
|
|
DialogBody,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog"
|
|
import { Label } from "@/components/ui/label"
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
|
import {
|
|
AUTO_RPC_ENDPOINT,
|
|
currentWsStatus$,
|
|
LIGHT_CLIENT_ENDPOINT,
|
|
Network,
|
|
networkCategories,
|
|
onChangeChain,
|
|
SelectedChain,
|
|
selectedChain$,
|
|
} from "@/state/chains/chain.state"
|
|
import { addCustomNetwork, getCustomNetwork } from "@/state/chains/networks"
|
|
import { Input } from "@polkahub/ui-components"
|
|
import { useStateObservable } from "@react-rxjs/core"
|
|
import { Check, ChevronDown } from "lucide-react"
|
|
import { StatusChange } from "polkadot-api/ws"
|
|
import { FC, useMemo, useState } from "react"
|
|
import { twMerge } from "tailwind-merge"
|
|
|
|
export function NetworkSwitcher({ className }: { className?: string }) {
|
|
const [open, setOpen] = useState(false)
|
|
const selectedChain = useStateObservable(selectedChain$)
|
|
const websocketStatus = useStateObservable(currentWsStatus$)
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
className={twMerge(
|
|
"min-w-0 gap-2 justify-between text-base px-3 border border-border bg-input",
|
|
className,
|
|
)}
|
|
>
|
|
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-left flex items-center gap-1">
|
|
{getChainLabel(selectedChain)}
|
|
<ForkMethodIcon className="inline-block align-text-top" />
|
|
<span className="text-sm text-muted-foreground">
|
|
{getConnectionLabel(selectedChain, websocketStatus)}
|
|
</span>
|
|
</span>
|
|
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</DialogTrigger>
|
|
<NetworkSwitchDialogContent
|
|
key={`${selectedChain.network.id}-${selectedChain.endpoint}-${selectedChain.forkMethod}`}
|
|
selectedChain={selectedChain}
|
|
onClose={() => setOpen(false)}
|
|
/>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
const NetworkSwitchDialogContent: FC<{
|
|
selectedChain: SelectedChain
|
|
onClose: () => void
|
|
}> = ({ selectedChain, onClose }) => {
|
|
const [query, setQuery] = useState("")
|
|
const [network, setNetwork] = useState(selectedChain.network)
|
|
const [endpoint, setEndpoint] = useState(selectedChain.endpoint)
|
|
const customUrl = normalizeWsUrl(query)
|
|
// Forking is not offered.
|
|
//
|
|
// Chopsticks is built on `@polkadot/types`, which cannot decode a Quantus
|
|
// block at all: it caps fixed arrays at 2048 bytes where ML-DSA signatures are
|
|
// 5261 and 7219, reads the extrinsic preamble byte as a version, and knows
|
|
// nothing of this chain's header layout. Forklift resolves its own unpatched
|
|
// copy of `@polkadot-api/substrate-bindings`, so it hits the very header bug
|
|
// this console patches around.
|
|
//
|
|
// Neither can fork a chain in this list, so the two buttons offered things
|
|
// that could only fail, and fail without saying why. `forkMethod` stays in
|
|
// SelectedChain — the hash params and chain.state still read it — but nothing
|
|
// in the UI can now set it to anything other than "none".
|
|
const selectedForkMethod = "none" as const
|
|
const hasChanged =
|
|
network.id !== selectedChain.network.id ||
|
|
endpoint !== selectedChain.endpoint ||
|
|
selectedForkMethod !== selectedChain.forkMethod
|
|
|
|
const selectNetwork = (
|
|
next: Network,
|
|
nextEndpoint = defaultEndpoint(next),
|
|
) => {
|
|
setNetwork(next)
|
|
setEndpoint(nextEndpoint)
|
|
}
|
|
|
|
const selectCustomUrl = (url: string) => {
|
|
setNetwork(createCustomNetwork(url))
|
|
setEndpoint(url)
|
|
}
|
|
|
|
const setConnection = (nextEndpoint: string) => {
|
|
setEndpoint(nextEndpoint)
|
|
}
|
|
|
|
|
|
const confirm = () => {
|
|
if (network.id === "custom") {
|
|
if (
|
|
endpoint.startsWith("ws://localhost") ||
|
|
endpoint.startsWith("ws://127.0.0.1")
|
|
) {
|
|
const localNetwork = networkCategories.find(
|
|
(cat) => cat.name === "Localhost",
|
|
)?.networks[0]
|
|
if (localNetwork) {
|
|
onChangeChain({
|
|
network: localNetwork,
|
|
endpoint,
|
|
forkMethod: selectedForkMethod,
|
|
})
|
|
onClose()
|
|
return
|
|
}
|
|
}
|
|
addCustomNetwork(endpoint)
|
|
onChangeChain({
|
|
network: getCustomNetwork(),
|
|
endpoint,
|
|
forkMethod: selectedForkMethod,
|
|
})
|
|
} else {
|
|
onChangeChain({ network, endpoint, forkMethod: selectedForkMethod })
|
|
}
|
|
onClose()
|
|
}
|
|
|
|
return (
|
|
<DialogContent
|
|
className="flex h-[min(720px,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-160 flex-col"
|
|
onEscapeKeyDown={(evt) => {
|
|
if (
|
|
evt.target instanceof HTMLElement &&
|
|
(evt.target.tagName === "INPUT" ||
|
|
evt.target.attributes.getNamedItem("cmdk-list"))
|
|
) {
|
|
evt.preventDefault()
|
|
}
|
|
}}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>Switch Network</DialogTitle>
|
|
</DialogHeader>
|
|
<DialogBody className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
|
|
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden md:flex-row">
|
|
<NetworkList
|
|
query={query}
|
|
selectedNetwork={network}
|
|
customUrl={customUrl}
|
|
onQueryChange={setQuery}
|
|
onNetworkSelect={selectNetwork}
|
|
onCustomSelect={selectCustomUrl}
|
|
/>
|
|
<ConnectionList
|
|
network={network}
|
|
endpoint={endpoint}
|
|
onEndpointChange={setConnection}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center justify-end gap-3 border-t pt-3">
|
|
<Button
|
|
className="min-w-28"
|
|
onClick={confirm}
|
|
disabled={
|
|
!hasChanged ||
|
|
(network.id === "custom" && !normalizeWsUrl(endpoint))
|
|
}
|
|
>
|
|
Confirm
|
|
</Button>
|
|
</div>
|
|
</DialogBody>
|
|
</DialogContent>
|
|
)
|
|
}
|
|
|
|
|
|
const NetworkList: FC<{
|
|
query: string
|
|
selectedNetwork: Network
|
|
customUrl: string | null
|
|
onQueryChange: (value: string) => void
|
|
onNetworkSelect: (network: Network) => void
|
|
onCustomSelect: (url: string) => void
|
|
}> = ({
|
|
query,
|
|
selectedNetwork,
|
|
customUrl,
|
|
onQueryChange,
|
|
onNetworkSelect,
|
|
onCustomSelect,
|
|
}) => {
|
|
const categories = useMemo(
|
|
() => networkCategories.filter((category) => category.name !== "Custom"),
|
|
[],
|
|
)
|
|
const selectedValue = getNetworkItemValue(categories, selectedNetwork)
|
|
|
|
return (
|
|
<Command
|
|
defaultValue={selectedValue}
|
|
className="flex min-h-0 flex-1 basis-0 flex-col overflow-hidden rounded-md border bg-transparent"
|
|
>
|
|
<CommandInput
|
|
value={query}
|
|
onValueChange={onQueryChange}
|
|
placeholder="Search chains, or enter a WebSocket URL"
|
|
className="border-none focus:ring-0"
|
|
autoFocus
|
|
/>
|
|
<CommandList className="max-h-none min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
|
<CommandEmpty>
|
|
<div className="px-3 py-4 text-sm text-muted-foreground">
|
|
No matching chains or endpoints.
|
|
</div>
|
|
</CommandEmpty>
|
|
{customUrl ? (
|
|
<CommandGroup heading="Custom">
|
|
<NetworkItem
|
|
value={`custom websocket rpc url ${customUrl}`}
|
|
selected={selectedNetwork.id === "custom"}
|
|
title={customUrl}
|
|
onSelect={() => onCustomSelect(customUrl)}
|
|
/>
|
|
</CommandGroup>
|
|
) : null}
|
|
{categories.map((category) => (
|
|
<CommandGroup key={category.name} heading={category.name}>
|
|
{category.networks.map((network) => (
|
|
<NetworkItem
|
|
key={network.id}
|
|
value={networkSearchValue(category.name, network)}
|
|
selected={selectedNetwork.id === network.id}
|
|
title={network.display}
|
|
onSelect={() => onNetworkSelect(network)}
|
|
/>
|
|
))}
|
|
</CommandGroup>
|
|
))}
|
|
</CommandList>
|
|
</Command>
|
|
)
|
|
}
|
|
|
|
const NetworkItem: FC<{
|
|
value: string
|
|
selected: boolean
|
|
title: string
|
|
onSelect: () => void
|
|
}> = ({ value, selected, title, onSelect }) => (
|
|
<CommandItem value={value} onSelect={onSelect}>
|
|
<Check
|
|
className={twMerge(
|
|
"mr-2 h-4 w-4 shrink-0",
|
|
selected ? "opacity-100" : "opacity-0",
|
|
)}
|
|
/>
|
|
<div className="truncate text-sm">{title}</div>
|
|
</CommandItem>
|
|
)
|
|
|
|
const getNetworkItemValue = (
|
|
categories: Array<{ name: string; networks: Network[] }>,
|
|
network: Network,
|
|
) => {
|
|
if (network.id === "custom") {
|
|
return undefined
|
|
}
|
|
|
|
const category = categories.find((category) =>
|
|
category.networks.some((x) => x.id === network.id),
|
|
)
|
|
return category ? networkSearchValue(category.name, network) : undefined
|
|
}
|
|
|
|
const ConnectionList: FC<{
|
|
network: Network
|
|
endpoint: string
|
|
onEndpointChange: (endpoint: string) => void
|
|
}> = ({ network, endpoint, onEndpointChange }) => {
|
|
const entries = Object.entries(network.endpoints)
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 basis-0 flex-col overflow-hidden rounded-md border">
|
|
<div className="shrink-0 px-3 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
Connection
|
|
</div>
|
|
<RadioGroup
|
|
className="min-h-0 overflow-y-auto overflow-x-hidden p-3 space-y-1"
|
|
value={endpoint}
|
|
onValueChange={onEndpointChange}
|
|
>
|
|
{network.lightclient ? (
|
|
<ConnectionOption
|
|
value={LIGHT_CLIENT_ENDPOINT}
|
|
selected={endpoint === LIGHT_CLIENT_ENDPOINT}
|
|
title="Smoldot"
|
|
subtitle="Light client"
|
|
badge="Default"
|
|
/>
|
|
) : null}
|
|
{entries.length > 1 &&
|
|
network.id !== "custom" &&
|
|
network.id !== "localhost" ? (
|
|
<ConnectionOption
|
|
value={AUTO_RPC_ENDPOINT}
|
|
selected={endpoint === AUTO_RPC_ENDPOINT}
|
|
title="Any WS RPC"
|
|
subtitle={`One of ${entries.length} endpoints`}
|
|
/>
|
|
) : null}
|
|
{network.id !== "custom"
|
|
? entries.map(([name, url]) => (
|
|
<ConnectionOption
|
|
key={url}
|
|
value={url}
|
|
selected={endpoint === url}
|
|
title={name}
|
|
subtitle={formatUrl(url)}
|
|
copy={url}
|
|
/>
|
|
))
|
|
: null}
|
|
{network.id === "localhost" ? (
|
|
<CustomPortOption
|
|
endpoint={endpoint}
|
|
knownEndpoints={entries.map(([, url]) => url)}
|
|
onEndpointChange={onEndpointChange}
|
|
/>
|
|
) : null}
|
|
{network.id === "custom" && endpoint ? (
|
|
<ConnectionOption
|
|
value={endpoint}
|
|
selected
|
|
title="WebSocket URL"
|
|
subtitle={formatUrl(endpoint)}
|
|
copy={endpoint}
|
|
/>
|
|
) : null}
|
|
</RadioGroup>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const CustomPortOption: FC<{
|
|
endpoint: string
|
|
knownEndpoints: string[]
|
|
onEndpointChange: (endpoint: string) => void
|
|
}> = ({ endpoint, knownEndpoints, onEndpointChange }) => {
|
|
const isKnownEndpoint = (port: string) =>
|
|
knownEndpoints.some(
|
|
(knownEndpoint) => getLocalhostPort(knownEndpoint) === port,
|
|
)
|
|
const [port, setPort] = useState(() => {
|
|
const initialPort = getLocalhostPort(endpoint)
|
|
return !initialPort || isKnownEndpoint(initialPort) ? "" : initialPort
|
|
})
|
|
const endpointValue = port ? `ws://127.0.0.1:${port}` : ""
|
|
const value =
|
|
port && !isKnownEndpoint(port)
|
|
? port === getLocalhostPort(endpoint)
|
|
? endpoint
|
|
: endpointValue
|
|
: `custom-localhost-port-${port || "empty"}`
|
|
const selected =
|
|
!!port && getLocalhostPort(endpoint) === port && !isKnownEndpoint(port)
|
|
|
|
return (
|
|
<div
|
|
className={twMerge(
|
|
"rounded-md border border-transparent px-3 py-2",
|
|
selected && !isKnownEndpoint(port) && "border-polkadot bg-polkadot/5",
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<RadioGroupItem
|
|
value={value}
|
|
id="connection-localhost-custom-port"
|
|
disabled={!port || isKnownEndpoint(port)}
|
|
/>
|
|
<Label
|
|
htmlFor="connection-localhost-custom-port"
|
|
className="min-w-0 flex-1 cursor-pointer"
|
|
>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className="truncate text-sm font-medium">Other ports</span>
|
|
<Badge variant="outline" className="shrink-0 text-xs">
|
|
RPC
|
|
</Badge>
|
|
</div>
|
|
<div className="truncate text-xs text-muted-foreground">
|
|
Local RPC node
|
|
</div>
|
|
</Label>
|
|
</div>
|
|
<Input
|
|
type="number"
|
|
placeholder="Port"
|
|
value={port}
|
|
className="mt-2"
|
|
onChange={(evt) => {
|
|
const nextPort = evt.target.value
|
|
setPort(nextPort)
|
|
if (nextPort) onEndpointChange(`ws://127.0.0.1:${nextPort}`)
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const ConnectionOption: FC<{
|
|
value: string
|
|
selected: boolean
|
|
title: string
|
|
subtitle: string
|
|
badge?: string
|
|
copy?: string
|
|
}> = ({ value, selected, title, subtitle, badge, copy }) => (
|
|
<div
|
|
className={twMerge(
|
|
"flex min-w-0 items-center gap-3 rounded-md border border-transparent px-3 py-2",
|
|
selected && "border-polkadot bg-polkadot/5",
|
|
)}
|
|
>
|
|
<RadioGroupItem value={value} id={`connection-${value}`} />
|
|
<Label
|
|
htmlFor={`connection-${value}`}
|
|
className="min-w-0 flex-1 cursor-pointer"
|
|
>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className="truncate text-sm font-medium">{title}</span>
|
|
{badge ? (
|
|
<Badge variant="outline" className="shrink-0 text-xs">
|
|
{badge}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
|
|
</Label>
|
|
{copy ? <CopyText text={copy} /> : null}
|
|
</div>
|
|
)
|
|
|
|
const defaultEndpoint = (network: Network) => {
|
|
if (network.lightclient) return LIGHT_CLIENT_ENDPOINT
|
|
const endpoints = Object.values(network.endpoints)
|
|
return network.id === "localhost" || endpoints.length === 1
|
|
? endpoints[0]
|
|
: AUTO_RPC_ENDPOINT
|
|
}
|
|
|
|
const createCustomNetwork = (url: string): Network => ({
|
|
id: "custom",
|
|
display: url,
|
|
lightclient: false,
|
|
endpoints: { "WebSocket URL": url },
|
|
})
|
|
|
|
const normalizeWsUrl = (value: string) => {
|
|
const trimmed = value.trim()
|
|
if (!trimmed) return null
|
|
const candidate =
|
|
trimmed.startsWith("localhost:") || trimmed.startsWith("127.0.0.1:")
|
|
? `ws://${trimmed}`
|
|
: trimmed
|
|
|
|
try {
|
|
const url = new URL(candidate)
|
|
return url.protocol === "ws:" || url.protocol === "wss:"
|
|
? url.toString()
|
|
: null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const getLocalhostPort = (value: string) => {
|
|
try {
|
|
const url = new URL(value)
|
|
return isLocalUrl(url) ? url.port : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const networkSearchValue = (category: string, network: Network) =>
|
|
[category, network.display, network.id].join(" ")
|
|
|
|
const getChainLabel = ({ network, endpoint }: SelectedChain) => {
|
|
if (network.id !== "custom") return network.display
|
|
try {
|
|
const url = new URL(endpoint)
|
|
return isLocalUrl(url) ? "Localhost" : url.hostname
|
|
} catch {
|
|
return endpoint
|
|
}
|
|
}
|
|
|
|
const getConnectionLabel = (
|
|
selectedChain: SelectedChain,
|
|
websocketStatus: StatusChange | null,
|
|
) => {
|
|
const { network, endpoint, forkMethod } = selectedChain
|
|
if (endpoint === LIGHT_CLIENT_ENDPOINT) return "Smoldot"
|
|
if (endpoint === AUTO_RPC_ENDPOINT) {
|
|
if (forkMethod !== "none") return null
|
|
if (!websocketStatus) return <Spinner className="inline-block" />
|
|
|
|
const isReady = "uri" in websocketStatus
|
|
const activeEndpoint = isReady
|
|
? findEndpointName(network, websocketStatus.uri)
|
|
: null
|
|
return (
|
|
activeEndpoint ??
|
|
(isReady ? (
|
|
formatUrl(websocketStatus.uri)
|
|
) : (
|
|
<Spinner className="inline-block" />
|
|
))
|
|
)
|
|
}
|
|
return findEndpointName(network, endpoint) ?? formatUrl(endpoint)
|
|
}
|
|
|
|
const findEndpointName = (network: Network, endpoint: string) =>
|
|
Object.entries(network.endpoints).find(([, url]) => endpoint === url)?.[0]
|
|
|
|
const formatUrl = (value: string) => {
|
|
try {
|
|
const url = new URL(value)
|
|
if (isLocalUrl(url))
|
|
return url.port ? `${url.hostname}:${url.port}` : url.hostname
|
|
return url.hostname
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
|
|
const isLocalUrl = (url: URL) =>
|
|
url.hostname === "localhost" || url.hostname === "127.0.0.1"
|