Merge pull request #45 from polkadot-api/chopsticks

feat: chopsticks integration
This commit is contained in:
Victor Oliva
2025-05-18 00:06:45 +02:00
committed by GitHub
23 changed files with 2045 additions and 205 deletions

View File

@@ -14,8 +14,10 @@
"semi": false
},
"dependencies": {
"@acala-network/chopsticks-core": "^1.0.5",
"@noble/hashes": "^1.8.0",
"@polkadot-api/descriptors": "file:.papi/descriptors",
"@polkadot-api/json-rpc-provider-proxy": "^0.2.4",
"@polkadot-api/metadata-builders": "^0.12.0",
"@polkadot-api/observable-client": "^0.10.0",
"@polkadot-api/react-builder": "0.2.7",

693
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,191 @@
import { Blockchain } from "@acala-network/chopsticks-core"
import { getSyncProvider } from "@polkadot-api/json-rpc-provider-proxy"
import { blockHeader } from "@polkadot-api/substrate-bindings"
import { state } from "@react-rxjs/core"
import { JsonRpcProvider } from "polkadot-api/ws-provider/web"
import { BehaviorSubject, map } from "rxjs"
export const chopsticksInstance$ = new BehaviorSubject<Blockchain | null>(null)
export const isChopsticks$ = state(
chopsticksInstance$.pipe(map((v) => !!v)),
false,
)
export const createChopsticksProvider = (endpoint: string) =>
withChopsticksEnhancer(
getSyncProvider(async () => {
const { ChopsticksProvider, setup } = await import(
"@acala-network/chopsticks-core"
)
chopsticksInstance$.getValue()?.close()
const chain = await setup({
endpoint,
mockSignatureHost: true,
})
chopsticksInstance$.next(chain)
const innerProvider = new ChopsticksProvider(chain)
return (onMessage) => {
return {
send: async (message: string) => {
const parsed = JSON.parse(message)
if (parsed.method === "chainHead_v1_follow") {
const subscription = await innerProvider.subscribe(
"chainHead_v1_followEvent",
parsed.method,
parsed.params,
(err, result) => {
if (err) {
console.error(err)
return
}
onMessage(
JSON.stringify({
jsonrpc: "2.0",
method: "chainHead_v1_followEvent",
params: {
subscription,
result,
},
}),
)
},
)
onMessage(
JSON.stringify({
jsonrpc: "2.0",
id: parsed.id,
result: subscription,
}),
)
return
}
const response = await innerProvider.send(
parsed.method,
parsed.params,
)
onMessage(
JSON.stringify({
jsonrpc: "2.0",
id: parsed.id,
result: response,
}),
)
},
disconnect: () => {
chain?.close()
chopsticksInstance$.next(null)
},
}
}
}),
)
/**
* Chopsticks can create block number discontinuities on the chain, which breaks an assumption of polkadot-api.
* The spec-compliant way of solving this is by emitting a stop event when that happens
*/
const withChopsticksEnhancer =
(parent: JsonRpcProvider): JsonRpcProvider =>
(onMessage) => {
// if it's chopsticks, we can assume there's immediate finality, and there are no forks or reorgs
let previousNumber: number | null = null
let waitingForNumber: any = null
const messageQueue: any[] = []
const processMessage = (parsed: any) => {
if (parsed.id?.startsWith("chopsticks-header-")) {
const decodedHeader = blockHeader.dec(parsed.result)
const currentNumber = decodedHeader.number
if (
waitingForNumber &&
previousNumber !== null &&
currentNumber > previousNumber + 1
) {
onMessage(
JSON.stringify({
...waitingForNumber,
params: {
...waitingForNumber.params,
result: {
event: "stop",
},
},
}),
)
inner.send(
JSON.stringify({
jsonrpc: "2.0",
id: "chopsticks-stopped",
method: "chainHead_v1_unfollow",
params: [waitingForNumber.params.subscription],
}),
)
messageQueue.length = 0
previousNumber = currentNumber
waitingForNumber = null
return
}
previousNumber = currentNumber
if (waitingForNumber) {
onMessage(JSON.stringify(waitingForNumber))
waitingForNumber = null
}
if (messageQueue.length) {
const [next] = messageQueue.splice(0, 1)
processMessage(next)
}
return
}
if (waitingForNumber) {
messageQueue.push(parsed)
return
}
if (
parsed.method === "chainHead_v1_followEvent" &&
parsed.params?.result?.event === "newBlock"
) {
const { blockHash } = parsed.params.result
waitingForNumber = parsed
inner.send(
JSON.stringify({
jsonrpc: "2.0",
id: "chopsticks-header-" + blockHash,
method: "chainHead_v1_header",
params: [parsed.params.subscription, blockHash],
}),
)
return
}
onMessage(JSON.stringify(parsed))
if (messageQueue.length) {
const [next] = messageQueue.splice(0, 1)
processMessage(next)
}
}
const inner = parent((msg) => {
const parsed = JSON.parse(msg)
processMessage(parsed)
})
return {
send(message) {
inner.send(message)
},
disconnect() {
inner.disconnect()
},
}
}

View File

@@ -19,6 +19,7 @@ import { EditCodec } from "../EditCodec"
import { TreeCodec } from "../EditCodec/Tree"
import { BinaryDisplay } from "./BinaryDisplay"
import { FocusPath } from "./FocusPath"
import { twMerge } from "tailwind-merge"
const editTypeMetadataProps$ = state(
runtimeCtx$.pipe(
@@ -35,7 +36,8 @@ export const LookupTypeEdit: FC<{
value: Uint8Array | "partial" | null
onValueChange: (value: Uint8Array | "partial" | null) => void
tree?: boolean
}> = ({ type, value, onValueChange, tree = true }) => {
className?: string
}> = ({ type, value, onValueChange, tree = true, className }) => {
const treeRef = useRef<HTMLDivElement | null>(null)
const listRef = useRef<HTMLDivElement | null>(null)
const [focusingSubtree, setFocusingSubtree] = useState<string[] | null>(null)
@@ -50,7 +52,12 @@ export const LookupTypeEdit: FC<{
if (!codecProps) return null
return (
<div className="flex flex-col items-start overflow-hidden">
<div
className={twMerge(
"flex flex-col items-start overflow-hidden",
className,
)}
>
<BinaryDisplay {...codecProps} className="pb-2" />
{tree && (
<FocusPath

View File

@@ -0,0 +1,94 @@
import * as React from "react"
import { Command, CommandInput } from "./ui/command"
import { useEffect } from "react"
type CommandPopoverProps = React.PropsWithChildren<{
placeholder?: string
value?: string
selectedValue?: unknown
onValueChange?: (value: string) => void
}>
export function CommandPopover({
placeholder,
value,
onValueChange,
selectedValue,
children,
}: CommandPopoverProps) {
const [open, setOpen] = React.useState(false)
const inputRef = React.useRef<HTMLInputElement>(null)
const commandRef = React.useRef<HTMLDivElement>(null)
const handleInputChange = (value: string) => {
onValueChange?.(value)
if (!open) setOpen(true)
}
// Handle click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
commandRef.current &&
!commandRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [])
useEffect(() => {
setOpen(false)
onValueChange?.("")
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedValue])
// Handle keyboard events for showing/hiding the command menu
const handleKeyDown = (e: React.KeyboardEvent) => {
// Show the command menu on arrow down when it's closed
if (e.key === "ArrowDown" && !open) {
e.preventDefault()
setOpen(true)
}
// Hide the command menu on escape
if (e.key === "Escape" && open) {
e.preventDefault()
setOpen(false)
}
}
return (
<div className="relative w-full">
<Command
className="rounded-lg border shadow-md overflow-visible bg-transparent"
onKeyDown={handleKeyDown}
>
<CommandInput
ref={inputRef}
value={value}
onValueChange={handleInputChange}
onClick={() => setOpen(true)}
placeholder={placeholder}
className="border-none focus:ring-0"
/>
{open && (
<div
ref={commandRef}
className="absolute w-full top-[calc(100%+4px)] left-0 rounded-lg border bg-popover shadow-md z-50"
>
{children}
</div>
)}
</Command>
</div>
)
}

View File

@@ -1,9 +1,5 @@
import { ReactSVG, Props } from "react-svg"
import focusSvg from "./icons/focus.svg"
import enumSvg from "./icons/enum.svg"
import binarySvg from "./icons/binary.svg"
import walletConnectSvg from "./icons/walletConnect.svg"
import { useEffect, useRef } from "react"
import { useTheme } from "@/ThemeProvider"
import { LookupEntry } from "@polkadot-api/metadata-builders"
import {
Ban,
Binary,
@@ -16,38 +12,57 @@ import {
LucideProps,
User,
} from "lucide-react"
import { LookupEntry } from "@polkadot-api/metadata-builders"
import { FC, useEffect, useRef } from "react"
import { Props, ReactSVG } from "react-svg"
import { twMerge } from "tailwind-merge"
import binarySvg from "./icons/binary.svg"
import chopsticksLogoDark from "./icons/chopsticks_dark.svg"
import chopsticksLogoLight from "./icons/chopsticks_light.svg"
import enumSvg from "./icons/enum.svg"
import focusSvg from "./icons/focus.svg"
import walletConnectSvg from "./icons/walletConnect.svg"
type CustomIconProps = Omit<Props, "ref" | "src"> & { size?: number }
const customIcon =
(url: string) =>
({ size = 16, ...props }: CustomIconProps) => {
const ref = useRef<SVGSVGElement | null>(null)
useEffect(() => {
if (!ref.current) return
ref.current.setAttribute("width", String(size))
ref.current.setAttribute("height", String(size))
}, [size])
return (
<ReactSVG
{...props}
src={url}
beforeInjection={(svg) => {
ref.current = svg
svg.setAttribute("width", String(size))
svg.setAttribute("height", String(size))
}}
/>
)
const CustomIcon: FC<
CustomIconProps & {
url: string
}
> = ({ size = 16, url, ...props }) => {
const ref = useRef<SVGSVGElement | null>(null)
useEffect(() => {
if (!ref.current) return
ref.current.setAttribute("width", String(size))
ref.current.setAttribute("height", String(size))
}, [size])
return (
<ReactSVG
{...props}
src={url}
beforeInjection={(svg) => {
ref.current = svg
svg.setAttribute("width", String(size))
svg.setAttribute("height", String(size))
}}
/>
)
}
const customIcon = (url: string) => (props: CustomIconProps) => (
<CustomIcon url={url} {...props} />
)
const themeIcon = (light: string, dark: string) => (props: CustomIconProps) => {
const theme = useTheme()
return <CustomIcon url={theme === "light" ? light : dark} {...props} />
}
export const Focus = customIcon(focusSvg)
export const Enum = customIcon(enumSvg)
export const BinaryEdit = customIcon(binarySvg)
export const WalletConnect = customIcon(walletConnectSvg)
export const Chopsticks = themeIcon(chopsticksLogoLight, chopsticksLogoDark)
export const Spinner = (props: LucideProps) => (
<LoaderCircle

View File

@@ -9,7 +9,8 @@ import { useTheme } from "@/ThemeProvider"
export const JsonDisplay: FC<{
src: unknown
}> = ({ src }) => {
collapsed?: boolean | number
}> = ({ src, ...props }) => {
const theme = useTheme()
return (
@@ -30,6 +31,7 @@ export const JsonDisplay: FC<{
2,
)
}
{...props}
/>
)
}

View File

@@ -3,9 +3,11 @@ import * as Toggle from "@radix-ui/react-toggle"
const SliderToggle: React.FC<{
isToggled: boolean
toggle: () => void
}> = ({ isToggled, toggle }) => {
id?: string
}> = ({ isToggled, toggle, id }) => {
return (
<Toggle.Root
id={id}
pressed={isToggled}
onPressedChange={() => toggle()}
className={

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="31"
height="37"
viewBox="0 0 31 37"
fill="none"
version="1.1"
id="svg6"
sodipodi:docname="chopsticks.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="9.0017274"
inkscape:cx="16.774558"
inkscape:cy="16.663468"
inkscape:window-width="1392"
inkscape:window-height="1051"
inkscape:window-x="2793"
inkscape:window-y="57"
inkscape:window-maximized="0"
inkscape:current-layer="svg6" />
<circle
cx="15.444743"
cy="18.474794"
r="15"
fill="url(#paint0_linear_3000_4054)"
id="circle1" />
<circle
cx="15.444743"
cy="18.474794"
r="15"
fill="url(#paint1_linear_3000_4054)"
fill-opacity="0.8"
id="circle2" />
<rect
x="-2.6022379"
y="5.396699"
width="3"
height="36"
rx="1.5"
transform="rotate(-30)"
fill="white"
fill-opacity="0.7"
id="rect2" />
<rect
x="12.785393"
y="4.2307425"
width="3"
height="36"
rx="1.5"
transform="rotate(-15)"
fill="white"
fill-opacity="0.7"
id="rect3" />
<defs
id="defs6">
<linearGradient
id="paint0_linear_3000_4054"
x1="62.285"
y1="74.922897"
x2="24.7505"
y2="36.729698"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-25.555257,-32.999808)">
<stop
stop-color="#E40C5B"
stop-opacity="0.88"
id="stop3" />
<stop
offset="1"
stop-color="#FF4C3B"
id="stop4" />
</linearGradient>
<linearGradient
id="paint1_linear_3000_4054"
x1="22.985001"
y1="31.403601"
x2="67.765999"
y2="45.6506"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-25.555257,-32.999808)">
<stop
stop-color="white"
stop-opacity="0.53"
id="stop5" />
<stop
offset="1"
stop-color="white"
stop-opacity="0"
id="stop6" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="31"
height="37"
viewBox="0 0 31 37"
fill="none"
version="1.1"
id="svg6"
sodipodi:docname="chopsticks.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="9.0017274"
inkscape:cx="16.774558"
inkscape:cy="16.663468"
inkscape:window-width="1392"
inkscape:window-height="1051"
inkscape:window-x="2793"
inkscape:window-y="57"
inkscape:window-maximized="0"
inkscape:current-layer="svg6" />
<circle
cx="15.444743"
cy="18.474794"
r="15"
fill="url(#paint0_linear_3000_4054)"
id="circle1" />
<circle
cx="15.444743"
cy="18.474794"
r="15"
fill="url(#paint1_linear_3000_4054)"
fill-opacity="0.8"
id="circle2" />
<rect
x="-2.6022379"
y="5.396699"
width="3"
height="36"
rx="1.5"
transform="rotate(-30)"
fill="#232429"
id="rect2" />
<rect
x="12.785393"
y="4.2307425"
width="3"
height="36"
rx="1.5"
transform="rotate(-15)"
fill="#232429"
id="rect3" />
<defs
id="defs6">
<linearGradient
id="paint0_linear_3000_4054"
x1="62.285"
y1="74.922897"
x2="24.7505"
y2="36.729698"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-25.555257,-32.999808)">
<stop
stop-color="#E40C5B"
stop-opacity="0.88"
id="stop3" />
<stop
offset="1"
stop-color="#FF4C3B"
id="stop4" />
</linearGradient>
<linearGradient
id="paint1_linear_3000_4054"
x1="22.985001"
y1="31.403601"
x2="67.765999"
y2="45.6506"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-25.555257,-32.999808)">
<stop
stop-color="white"
stop-opacity="0.53"
id="stop5" />
<stop
offset="1"
stop-color="white"
stop-opacity="0"
id="stop6" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border border-neutral-200 px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-neutral-950 focus-visible:ring-neutral-950/50 focus-visible:ring-[3px] aria-invalid:ring-red-500/20 dark:aria-invalid:ring-red-500/40 aria-invalid:border-red-500 transition-[color,box-shadow] overflow-hidden dark:border-neutral-800 dark:focus-visible:border-neutral-300 dark:focus-visible:ring-neutral-300/50 dark:aria-invalid:ring-red-900/20 dark:dark:aria-invalid:ring-red-900/40 dark:aria-invalid:border-red-900",
{
variants: {
variant: {
default:
"border-transparent bg-neutral-900 text-neutral-50 [a&]:hover:bg-neutral-900/90 dark:bg-neutral-50 dark:text-neutral-900 dark:[a&]:hover:bg-neutral-50/90",
secondary:
"border-transparent bg-neutral-100 text-neutral-900 [a&]:hover:bg-neutral-100/90 dark:bg-neutral-800 dark:text-neutral-50 dark:[a&]:hover:bg-neutral-800/90",
destructive:
"border-transparent bg-red-500 text-white [a&]:hover:bg-red-500/90 focus-visible:ring-red-500/20 dark:focus-visible:ring-red-500/40 dark:bg-red-500/60 dark:bg-red-900 dark:[a&]:hover:bg-red-900/90 dark:focus-visible:ring-red-900/20 dark:dark:focus-visible:ring-red-900/40 dark:dark:bg-red-900/60",
outline:
"text-neutral-950 [a&]:hover:bg-neutral-100 [a&]:hover:text-neutral-900 dark:text-neutral-50 dark:[a&]:hover:bg-neutral-800 dark:[a&]:hover:text-neutral-50",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -31,7 +31,7 @@ export const setHashParams = (
location.hash = "#" + params.toString()
}
const persistingKeys = ["networkId", "endpoint"]
const persistingKeys = ["networkId", "endpoint", "chopsticks"]
const usePersistKeys = () => {
const location = useLocation()

View File

@@ -1,3 +1,4 @@
import { Chopsticks } from "@/components/Icons"
import { Loading } from "@/components/Loading"
import { groupBy } from "@/lib/groupBy"
import { runtimeCtx$ } from "@/state/chains/chain.state"
@@ -8,9 +9,10 @@ import { useLocation, useParams } from "react-router-dom"
import { combineLatest, distinctUntilChanged, filter, map, take } from "rxjs"
import { twMerge } from "tailwind-merge"
import { BlockInfo, blockInfoState$ } from "../block.state"
import { BlockEvents } from "./BlockEvents"
import { BlockStorageDiff } from "./BlockStorageDiff"
import { ApplyExtrinsicEvent, Extrinsic } from "./Extrinsic"
import { createExtrinsicCodec, DecodedExtrinsic } from "./extrinsicDecoder"
import { BlockEvents } from "./BlockEvents"
const blockExtrinsics$ = state((hash: string) => {
const decoder$ = runtimeCtx$.pipe(
@@ -32,13 +34,14 @@ const blockExtrinsics$ = state((hash: string) => {
)
}, [])
type Tab = "signed" | "unsigned" | "events"
type Tab = "signed" | "unsigned" | "events" | "diff"
export const BlockBody: FC<{
block: BlockInfo
}> = ({ block }) => {
const { hash } = useParams()
const [selectedTab, setSelectedTab] = useState<Tab | null>(null)
const extrinsics = useStateObservable(blockExtrinsics$(hash ?? ""))
const location = useLocation()
const hashParams = new URLSearchParams(location.hash.slice(1))
const eventParam = hashParams.get("event")
@@ -102,7 +105,7 @@ export const BlockBody: FC<{
</Tabs.Trigger>
<Tabs.Trigger
className={twMerge(
"bg-secondary text-secondary-foreground/80 px-4 py-2 hover:text-polkadot-500 border-t border-r rounded-tr border-polkadot-200",
"bg-secondary text-secondary-foreground/80 px-4 py-2 hover:text-polkadot-500 border-t border-r border-polkadot-200",
"disabled:text-secondary-foreground/50 disabled:pointer-events-none",
"data-[state=active]:font-bold data-[state=active]:text-secondary-foreground",
)}
@@ -113,7 +116,7 @@ export const BlockBody: FC<{
</Tabs.Trigger>
<Tabs.Trigger
className={twMerge(
"bg-secondary text-secondary-foreground/80 px-4 py-2 hover:text-polkadot-500 border-t border-r rounded-tr border-polkadot-200",
"bg-secondary text-secondary-foreground/80 px-4 py-2 hover:text-polkadot-500 border-t border-r last:rounded-tr border-polkadot-200",
"disabled:text-secondary-foreground/50 disabled:pointer-events-none",
"data-[state=active]:font-bold data-[state=active]:text-secondary-foreground",
)}
@@ -121,6 +124,22 @@ export const BlockBody: FC<{
>
Events
</Tabs.Trigger>
{block.diff && (
<Tabs.Trigger
className={twMerge(
"bg-secondary text-secondary-foreground/80 px-4 py-2 hover:text-polkadot-500 border-t border-r rounded-tr border-polkadot-200",
"disabled:text-secondary-foreground/50 disabled:bg-secondary/50 disabled:pointer-events-none",
"data-[state=active]:font-bold data-[state=active]:text-secondary-foreground",
)}
value="diff"
>
Diff
<Chopsticks
className="inline-block align-middle ml-2"
size={20}
/>
</Tabs.Trigger>
)}
</Tabs.List>
<Tabs.Content value="signed" className="py-2">
<ol>
@@ -149,6 +168,9 @@ export const BlockBody: FC<{
<Tabs.Content value="events" className="py-2">
<BlockEvents block={block} highlightedEvent={defaultEventOpen} />
</Tabs.Content>
<Tabs.Content value="diff" className="py-2">
<BlockStorageDiff block={block} />
</Tabs.Content>
</Tabs.Root>
</div>
)

View File

@@ -0,0 +1,219 @@
import { bytesToString } from "@/components/BinaryInput"
import { CopyText } from "@/components/Copy"
import { JsonDisplay } from "@/components/JsonDisplay"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import { groupBy } from "@/lib/groupBy"
import { dynamicBuilder$, lookup$ } from "@/state/chains/chain.state"
import {
HexString,
Struct,
Tuple,
Twox128,
u32,
u64,
u8,
Vector,
} from "@polkadot-api/substrate-bindings"
import { toHex } from "@polkadot-api/utils"
import { state, useStateObservable } from "@react-rxjs/core"
import { Binary, Codec } from "polkadot-api"
import { FC } from "react"
import { combineLatest, map } from "rxjs"
import { BlockInfo, blockInfo$ } from "../block.state"
const TWOX128_LEN = 32
const blockDiff$ = state(
(hash: string) =>
// TODO for current block
combineLatest([lookup$, dynamicBuilder$, blockInfo$(hash)]).pipe(
map(([lookup, dynamicBuilder, block]) => {
if (!block.diff) return null
const palletKeys = Object.fromEntries(
lookup.metadata.pallets.map((pallet) => [
toHex(Twox128(Binary.fromText(pallet.name).asBytes())).slice(2),
{
name: pallet.name,
entries: Object.fromEntries(
pallet.storage?.items.map((item) => [
toHex(Twox128(Binary.fromText(item.name).asBytes())).slice(2),
item.name,
]) ?? [],
),
},
]),
)
return Object.entries(block.diff)
.map(
([key, [prevValue, newValue]]): {
key: HexString
decodedKey: [string, ...unknown[]]
prevValue: HexString | null
decodedPrevValue: unknown
newValue: HexString | null
decodedNewValue: unknown
} | null => {
try {
if (wellKnownKeys[key]) {
return {
key,
prevValue,
newValue,
decodedKey: [wellKnownKeys[key].name],
decodedNewValue: newValue
? wellKnownKeys[key].codec.dec(newValue)
: null,
decodedPrevValue: prevValue
? wellKnownKeys[key].codec.dec(prevValue)
: null,
}
}
const pallet = palletKeys[key.slice(2, 2 + TWOX128_LEN)]
if (pallet) {
const entry =
pallet.entries[
key.slice(2 + TWOX128_LEN, 2 + TWOX128_LEN * 2)
]
const storageCodec =
entry && dynamicBuilder.buildStorage(pallet.name, entry)
if (storageCodec) {
return {
key,
prevValue,
newValue,
decodedKey: [
pallet.name,
entry,
...storageCodec.keys.dec(key),
],
decodedNewValue: newValue
? storageCodec.value.dec(newValue)
: null,
decodedPrevValue: prevValue
? storageCodec.value.dec(prevValue)
: null,
}
}
}
console.warn("uknown key", key)
} catch (ex) {
console.error(ex)
}
return null
},
)
.filter((v) => !!v)
}),
map((diffResult) => {
if (!diffResult) return null
const groups = groupBy(diffResult, (v) => v.decodedKey[0])
return Object.entries(groups)
.map(([name, group]) => ({
name,
changes: group.sort((a, b) => a.key.localeCompare(b.key)),
}))
.sort((a, b) => a.name.localeCompare(b.name))
}),
),
null,
)
export const BlockStorageDiff: FC<{
block: BlockInfo
}> = ({ block }) => {
const diff = useStateObservable(blockDiff$(block.hash))
if (!diff) return null
return (
<Accordion type="multiple">
{diff.map(({ name, changes }) => (
<AccordionItem key={name} value={name}>
<AccordionTrigger>{name}</AccordionTrigger>
<AccordionContent className="space-y-4">
{changes.map((change) => (
<div key={change.key}>
<div>
<CopyText binary text={change.key} />{" "}
{change.decodedKey
.map((v) =>
typeof v === "object"
? `(${JSON.stringify(v, (_, v) =>
typeof v === "bigint"
? `${v}n`
: v instanceof Binary
? bytesToString(v)
: v,
)})`
: String(v),
)
.join(".")}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="overflow-auto">
<div className="text-muted-foreground">Previous Value</div>
<JsonDisplay collapsed={1} src={change.decodedPrevValue} />
</div>
<div className="overflow-auto">
<div className="text-muted-foreground">New Value</div>
<JsonDisplay collapsed={1} src={change.decodedNewValue} />
</div>
</div>
</div>
))}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)
}
const strToHex = (v: string) => Binary.fromText(v).asHex()
const wellKnownKeys: Record<
string,
{
name: string
codec: Codec<any>
}
> = {
[strToHex(":code")]: {
name: ":code",
codec: Vector(u8),
},
[strToHex(":heappages")]: {
name: ":heappages",
codec: u64,
},
[strToHex(":extrinsic_index")]: {
name: ":extrinsic_index",
codec: u32,
},
[strToHex(":intrablock_entropy")]: {
name: ":intrablock_entropy",
codec: Vector(u8, 32),
},
[strToHex(":transaction_level:")]: {
name: ":transaction_level:",
codec: u32,
},
[strToHex(":grandpa_authorities")]: {
name: ":grandpa_authorities",
codec: Tuple(
u8,
Vector(
Struct({
id: Vector(u8, 32),
weight: u64,
}),
),
),
},
}

View File

@@ -1,15 +1,19 @@
import { chopsticksInstance$, isChopsticks$ } from "@/chopsticks/chopsticks"
import { Chopsticks } from "@/components/Icons"
import { Button } from "@/components/ui/button"
import { client$, runtimeCtx$ } from "@/state/chains/chain.state"
import { useStateObservable, withDefault } from "@react-rxjs/core"
import { FC, PropsWithChildren } from "react"
import { map, switchMap } from "rxjs"
import { FC, PropsWithChildren, ReactElement, useEffect, useState } from "react"
import { firstValueFrom, map, switchMap } from "rxjs"
import { twMerge } from "tailwind-merge"
import { BlockTime } from "./BlockTime"
import { EpochRemainingTime } from "./EpochTime"
const finalized$ = client$.pipeState(
const finalizedNum$ = client$.pipeState(
switchMap((chainHead) => chainHead.finalizedBlock$),
map((v) => v.number.toLocaleString()),
map((v) => v.number),
)
const finalized$ = finalizedNum$.pipeState(map((v) => v.toLocaleString()))
const best$ = client$.pipeState(
switchMap((chainHead) => chainHead.bestBlocks$),
map(([v]) => v.number.toLocaleString()),
@@ -29,11 +33,17 @@ const hasEpoch$ = runtimeCtx$.pipeState(
export const Summary: FC = () => {
const hasEpoch = useStateObservable(hasEpoch$)
const canJump = useStateObservable(isChopsticks$)
return (
<div className="flex gap-4 items-center py-2">
<SummaryItem title="Block Time" className="bg-card/0 border-none">
<BlockTime />
</SummaryItem>
{canJump ? (
<Jump />
) : (
<SummaryItem title="Block Time" className="bg-card/0 border-none">
<BlockTime />
</SummaryItem>
)}
{hasEpoch ? (
<SummaryItem title="Epoch" className="bg-card/0 border-none">
<EpochRemainingTime />
@@ -47,7 +57,7 @@ export const Summary: FC = () => {
}
const SummaryItem: FC<
PropsWithChildren<{ title: string; className?: string }>
PropsWithChildren<{ title: string | ReactElement; className?: string }>
> = ({ title, className, children }) => {
return (
<div
@@ -63,3 +73,51 @@ const SummaryItem: FC<
</div>
)
}
const Jump = () => {
const finalized = useStateObservable(finalizedNum$)
const [value, setValue] = useState(finalized + 1)
const [loading, setLoading] = useState(false)
useEffect(() => {
setValue((v) => Math.max(v, finalized + 1))
}, [finalized])
return (
<SummaryItem title="" className="bg-card/0 border-none text-center">
<div className="text-left">
<span className="text-sm">New Height</span>
<input
className="block border rounded p-1"
type="number"
value={value}
onChange={(evt) => setValue(evt.target.valueAsNumber)}
/>
</div>
<Button
size="sm"
variant="secondary"
className="py-1 h-auto mt-2"
onClick={async () => {
setLoading(true)
try {
const chop = await firstValueFrom(chopsticksInstance$)
await chop?.newBlock(
value !== finalized + 1
? {
unsafeBlockHeight: value,
}
: {},
)
} finally {
setLoading(false)
}
}}
disabled={loading}
>
New Block{" "}
<Chopsticks className="inline-block align-middle ml-1" size={20} />
</Button>
</SummaryItem>
)
}

View File

@@ -1,3 +1,4 @@
import { chopsticksInstance$ } from "@/chopsticks/chopsticks"
import { chainClient$, client$ } from "@/state/chains/chain.state"
import { SystemEvent } from "@polkadot-api/observable-client"
import { state } from "@react-rxjs/core"
@@ -30,6 +31,7 @@ import {
takeWhile,
tap,
timer,
toArray,
withLatestFrom,
} from "rxjs"
@@ -57,6 +59,7 @@ export interface BlockInfo {
digests: unknown[]
} | null
status: BlockState
diff: Record<string, [string | null, string | null]> | null
}
export const [blockInfo$, recordedBlocks$] = partitionByKey(
client$.pipe(switchMap((client) => client.blocks$)),
@@ -98,6 +101,7 @@ export const [blockInfo$, recordedBlocks$] = partitionByKey(
}),
),
status: getBlockStatus$(client, hash, number),
diff: getBlockDiff$(parent, hash),
}),
NEVER,
),
@@ -159,6 +163,7 @@ const getUnpinnedBlockInfo$ = (hash: string): Observable<BlockInfo> => {
},
number: Number(header.number),
status: BlockState.Finalized,
diff: null,
}),
),
tap((v) => disconnectedBlocks$.next(v)),
@@ -265,3 +270,51 @@ const getBlockStatus$ = (
true,
),
)
const getBlockDiff$ = (
parent: string,
hash: string,
): Observable<Record<string, [string | null, string | null]> | null> =>
chopsticksInstance$.pipe(
take(1),
switchMap((chain) => (chain ? chain.getBlock(hash as any) : [null])),
switchMap((block) => (block ? block.storageDiff() : [null])),
map((v) =>
v && Object.keys(v).length > 0
? (v as Record<string, string | null>)
: null,
),
startWith(null),
withLatestFrom(chainClient$),
switchMap(([diff, { chainHead }]) => {
if (!diff) return [null]
return chainHead
.storageQueries$(
parent,
Object.keys(diff).map((key) => ({
key,
type: "value",
})),
)
.pipe(
toArray(),
map((v) => Object.fromEntries(v.map((v) => [v.key, v.value]))),
map(
(previousResults): Record<string, [string | null, string | null]> =>
Object.fromEntries(
Object.entries(diff)
.map(([key, newValue]) => [
key,
[previousResults[key] ?? null, newValue],
])
.filter(([, [prevVal, newVal]]) => prevVal !== newVal),
),
),
catchError((ex) => {
console.error(ex)
return [null]
}),
)
}),
)

View File

@@ -1,15 +1,12 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import { CommandPopover } from "@/components/CommandPopover"
import { CopyText } from "@/components/Copy"
import { Chopsticks } from "@/components/Icons"
import SliderToggle from "@/components/Toggle"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
@@ -33,61 +30,8 @@ import {
} from "@/state/chains/chain.state"
import { addCustomNetwork, getCustomNetwork } from "@/state/chains/networks"
import { useStateObservable } from "@react-rxjs/core"
import { useCommandState } from "cmdk"
import { Check, ChevronDown } from "lucide-react"
import { FC, useEffect, useRef, useState } from "react"
const EmptyOption: React.FC<{
enteredText: string
selectedNetwork: Network
selectedRpc: string
setSelectedNetwork: React.Dispatch<React.SetStateAction<Network>>
setSelectedRpc: React.Dispatch<React.SetStateAction<string>>
}> = (props) =>
useCommandState((x) => x.filtered.count) ? null : <Empty {...props} />
const Empty: React.FC<{
enteredText: string
selectedNetwork: Network
selectedRpc: string
setSelectedNetwork: React.Dispatch<React.SetStateAction<Network>>
setSelectedRpc: React.Dispatch<React.SetStateAction<string>>
}> = ({
enteredText,
selectedNetwork,
selectedRpc,
setSelectedNetwork,
setSelectedRpc,
}) => {
const initialValue = useRef({
selectedNetwork,
selectedRpc,
})
const isValid = isValidUri(enteredText)
useEffect(() => {
setSelectedNetwork({
id: "custom-network",
lightclient: false,
endpoints: { custom: enteredText },
display: enteredText,
})
setSelectedRpc(isValid ? enteredText : "")
return () => {
if (!isValid) {
setSelectedNetwork(initialValue.current.selectedNetwork)
setSelectedRpc(initialValue.current.selectedRpc)
}
}
}, [enteredText, isValid])
return isValid ? (
<div className="relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50">
<Check className="mr-2 h-4 w-4 opacity-100" />
{enteredText}
</div>
) : (
<CommandEmpty>No networks found.</CommandEmpty>
)
}
import { FC, useState } from "react"
export function NetworkSwitcher() {
const [open, setOpen] = useState(false)
@@ -125,10 +69,14 @@ const NetworkSwitchDialogContent: FC<{
const currentRpc = selectedChain.endpoint ?? "light-client"
const [selectedRpc, setSelectedRpc] = useState<string>(currentRpc)
const [enteredText, setEnteredText] = useState<string>("")
const [withChopsticks, setWithChopsticks] = useState(
selectedChain.withChopsticks ?? false,
)
const hasChanged =
selectedNetwork.id !== selectedChain.network.id ||
selectedRpc !== currentRpc
selectedRpc !== currentRpc ||
selectedChain.withChopsticks !== withChopsticks
const handleNetworkSelect = (network: Network) => {
if (network === selectedNetwork) return
@@ -142,107 +90,156 @@ const NetworkSwitchDialogContent: FC<{
}
const handleConfirm = () => {
const chopsticksEnabled = selectedRpc !== "light-client" && withChopsticks
if (selectedNetwork.id === "custom-network") {
addCustomNetwork(selectedRpc)
onChangeChain({ network: getCustomNetwork(), endpoint: selectedRpc })
onChangeChain({
network: getCustomNetwork(),
endpoint: selectedRpc,
withChopsticks: chopsticksEnabled,
})
setEnteredText("")
} else {
onChangeChain({
network: selectedNetwork,
endpoint: selectedRpc,
withChopsticks: chopsticksEnabled,
})
}
onClose()
}
return (
<DialogContent className="sm:max-w-[425px]">
<DialogContent
className="sm:max-w-[425px] min-h-[450px] max-h-full flex 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>
<Command className="rounded-lg border shadow-md">
<CommandInput
<div className="h-full grow flex flex-col">
<CommandPopover
placeholder="Search or enter a custom URI"
value={enteredText}
onValueChange={setEnteredText}
/>
<CommandList>
<EmptyOption
{...{
enteredText,
selectedNetwork,
selectedRpc,
setSelectedRpc,
setSelectedNetwork,
}}
/>
<ScrollArea className="h-[260px]">
{networkCategories.map((category) => (
<CommandGroup key={category.name} heading={category.name}>
{category.networks.map((network) => (
<CommandItem
key={network.id}
onSelect={() => handleNetworkSelect(network)}
value={
network.display.includes(category.name)
? network.display
: `${category.name} ${network.display}`
}
>
<Check
className={`mr-2 h-4 w-4 ${
selectedNetwork.id === network.id
? "opacity-100"
: "opacity-0"
}`}
/>
{network.display}
</CommandItem>
))}
</CommandGroup>
))}
</ScrollArea>
</CommandList>
</Command>
{selectedNetwork && selectedNetwork.id !== "custom-network" && (
<Accordion type="single" collapsible className="w-full -mt-3">
<AccordionItem value="connection-options">
<AccordionTrigger>Connection Options</AccordionTrigger>
<AccordionContent>
<ScrollArea className="h-[155px] rounded-lg border p-2">
<RadioGroup>
{selectedNetwork.lightclient ? (
<div className="flex items-center space-x-2">
<RadioGroupItem
value="light-client"
id="light-client"
checked={selectedRpc === "light-client"}
onClick={() => setSelectedRpc("light-client")}
selectedValue={selectedNetwork.id}
>
<CommandList>
<CommandEmpty>
<div className="text-foreground/50">No networks found.</div>
</CommandEmpty>
<ScrollArea className="h-[260px]">
{networkCategories.map((category) => (
<CommandGroup key={category.name} heading={category.name}>
{category.networks.map((network) => (
<CommandItem
key={network.id}
onSelect={() => handleNetworkSelect(network)}
value={
network.display.includes(category.name)
? network.display
: `${category.name} ${network.display}`
}
>
<Check
className={`mr-2 h-4 w-4 ${
selectedNetwork.id === network.id
? "opacity-100"
: "opacity-0"
}`}
/>
<Label htmlFor="light-client">
Light Client (smoldot)
</Label>
</div>
{network.display}
</CommandItem>
))}
{category.name === "Custom" && isValidUri(enteredText) ? (
<CommandItem
value={enteredText}
onSelect={() => {
handleNetworkSelect({
id: "custom-network",
lightclient: false,
endpoints: { custom: enteredText },
display: enteredText,
})
}}
>
<Check
className={`mr-2 h-4 w-4 ${
selectedNetwork.id === "custom-network"
? "opacity-100"
: "opacity-0"
}`}
/>
{enteredText}
</CommandItem>
) : null}
</CommandGroup>
))}
</ScrollArea>
</CommandList>
</CommandPopover>
<div className="h-[50vh] flex flex-col gap-2">
{selectedNetwork ? (
<div className="grow-1 overflow-hidden flex flex-col">
<p className="py-2">Network: {selectedNetwork.display}</p>
<div className="overflow-auto">
<RadioGroup value={selectedRpc} onValueChange={setSelectedRpc}>
{selectedNetwork.lightclient ? (
<ConnectionOption
value="light-client"
isSelected={selectedRpc === "light-client"}
name="Smoldot"
type="light"
/>
) : null}
{Object.entries(selectedNetwork.endpoints).map(
([rpcName, url]) => (
<div key={url} className="flex items-center space-x-2">
<RadioGroupItem
value={url}
id={url}
checked={selectedRpc === url}
onClick={() => setSelectedRpc(url)}
/>
<Label htmlFor={url}>{rpcName}</Label>
</div>
<ConnectionOption
value={url}
isSelected={selectedRpc === url}
name={rpcName}
type="rpc"
url={url}
/>
),
)}
</RadioGroup>
</ScrollArea>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</div>
</div>
) : null}
{selectedRpc && selectedRpc !== "light-client" && (
<div className="mt-4 p-3 border rounded-md bg-muted/30">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Chopsticks size={20} />
<Label
htmlFor="use-chopsticks"
className="font-medium cursor-pointer"
>
Fork with Chopsticks
</Label>
</div>
<SliderToggle
id="use-chopsticks"
isToggled={withChopsticks}
toggle={() => setWithChopsticks(!withChopsticks)}
/>
</div>
<p className="text-xs text-muted-foreground mt-1 ml-6">
Create a local fork of this chain
</p>
</div>
)}
</div>
</div>
<Button
onClick={handleConfirm}
disabled={
@@ -256,3 +253,54 @@ const NetworkSwitchDialogContent: FC<{
</DialogContent>
)
}
const ConnectionOption: FC<{
isSelected: boolean
value: string
name: string
type: "light" | "rpc"
url?: string
}> = ({ isSelected, value, name, type, url }) => (
<div
className={`overflow-hidden p-3 border rounded-md ${isSelected ? "border-primary bg-primary/5" : "border-border"}`}
>
<div className="flex items-start space-x-2">
<RadioGroupItem value={value} id={`chain-${value}`} className="mt-1" />
<div className="grid gap-0.5 flex-grow">
<Label htmlFor={`chain-${value}`} className="font-medium">
{name}
{type === "light" ? (
<Badge variant="outline" className="ml-2 text-xs">
Light Client
</Badge>
) : (
<Badge variant="outline" className="ml-2 text-xs">
RPC
</Badge>
)}
<p className="text-xs text-muted-foreground">
{type === "light"
? "Light client for a decentralized experience"
: url?.includes("127.0.0.1")
? "Local RPC node"
: "Remote RPC node"}
</p>
</Label>
</div>
</div>
{/* Show URL for RPC endpoints */}
{url ? (
<div className="mt-2 pt-2 border-t">
<div className="flex items-center justify-between gap-1">
<div className="flex-1 overflow-hidden">
<code className="text-xs bg-muted p-1 rounded block overflow-hidden text-ellipsis whitespace-nowrap">
{url}
</code>
</div>
<CopyText text={url} />
</div>
</div>
) : null}
</div>
)

View File

@@ -1,17 +1,20 @@
import { lookup$ } from "@/state/chains/chain.state"
import { isChopsticks$ } from "@/chopsticks/chopsticks"
import { ButtonGroup } from "@/components/ButtonGroup"
import { DocsRenderer } from "@/components/DocsRenderer"
import { Chopsticks } from "@/components/Icons"
import { LoadingMetadata } from "@/components/Loading"
import { SearchableSelect } from "@/components/Select"
import { withSubscribe } from "@/components/withSuspense"
import { useHashState } from "@/lib/externalState"
import { lookup$ } from "@/state/chains/chain.state"
import { state, useStateObservable } from "@react-rxjs/core"
import { FC, useEffect, useState } from "react"
import { map } from "rxjs"
import { selectedEntry$, setSelectedEntry } from "./storage.state"
import { StorageDecode } from "./StorageDecode"
import { StorageQuery } from "./StorageQuery"
import { StorageSet } from "./StorageSet"
import { StorageSubscriptions } from "./StorageSubscriptions"
import { DocsRenderer } from "@/components/DocsRenderer"
import { LoadingMetadata } from "@/components/Loading"
import { useHashState } from "@/lib/externalState"
const metadataStorage$ = state(
lookup$.pipe(
@@ -150,7 +153,8 @@ export const Storage = withSubscribe(
const StorageEntry: FC = () => {
const selectedEntry = useStateObservable(selectedEntry$)
const [mode, setMode] = useState<"query" | "decode">("query")
const canSetStorage = useStateObservable(isChopsticks$)
const [mode, setMode] = useState<"query" | "decode" | "set">("query")
if (!selectedEntry) return null
@@ -168,9 +172,31 @@ const StorageEntry: FC = () => {
value: "decode",
content: "Decode",
},
...(canSetStorage
? [
{
value: "set",
content: (
<>
Set
<Chopsticks
className="inline-block align-middle ml-2"
size={20}
/>
</>
),
},
]
: []),
]}
/>
{mode === "query" ? <StorageQuery /> : <StorageDecode />}
{mode === "query" ? (
<StorageQuery />
) : mode === "decode" ? (
<StorageDecode />
) : (
<StorageSet />
)}
</>
)
}

View File

@@ -103,7 +103,7 @@ const [keyValueChange$, setKeyValue] = createSignal<{
idx: number
value: unknown | NOTIN
}>()
const keyValues$ = keys$.pipeState(
export const keyValues$ = keys$.pipeState(
switchMap((keys) => {
const values: unknown[] = keys.map(() => NOTIN)
return keyValueChange$.pipe(
@@ -129,7 +129,9 @@ const isReady$ = state(
false,
)
const StorageKeysInput: FC = () => {
export const StorageKeysInput: FC<{
disableToggle?: boolean
}> = ({ disableToggle }) => {
const keys = useStateObservable(keys$)
const keysEnabled = useStateObservable(keysEnabled$)
@@ -137,10 +139,12 @@ const StorageKeysInput: FC = () => {
<ol className="flex flex-col gap-2">
{keys.map((type, idx) => (
<li key={idx} className="flex flex-row gap-2 items-center">
<SliderToggle
isToggled={keysEnabled > idx}
toggle={() => toggleKey(idx)}
/>
{disableToggle ? null : (
<SliderToggle
isToggled={keysEnabled > idx}
toggle={() => toggleKey(idx)}
/>
)}
<StorageKeyInput
idx={idx}
type={type}
@@ -271,7 +275,7 @@ const StorageKeyInput: FC<{ idx: number; type: number; disabled: boolean }> = ({
)
}
const encodedKey$ = state(
export const encodedKey$ = state(
combineLatest([
dynamicBuilder$,
selectedEntry$,
@@ -301,7 +305,8 @@ const encodedKey$ = state(
),
null,
)
const KeyDisplay: FC = () => {
export const KeyDisplay: FC = () => {
const key = useStateObservable(encodedKey$)
const builder = useStateObservable(builderState$)
const selectedEntry = useStateObservable(selectedEntry$)

View File

@@ -0,0 +1,131 @@
import { chopsticksInstance$ } from "@/chopsticks/chopsticks"
import { LookupTypeEdit } from "@/codec-components/LookupTypeEdit"
import { Chopsticks } from "@/components/Icons"
import { Button } from "@/components/ui/button"
import { chainClient$, lookup$ } from "@/state/chains/chain.state"
import { getTypeComplexity } from "@/utils"
import { setStorage } from "@acala-network/chopsticks-core"
import { toHex } from "@polkadot-api/utils"
import { state, useStateObservable } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import { FC, useState } from "react"
import {
combineLatest,
filter,
firstValueFrom,
map,
merge,
switchMap,
withLatestFrom,
} from "rxjs"
import { selectedEntry$ } from "./storage.state"
import { encodedKey$, KeyDisplay, StorageKeysInput } from "./StorageQuery"
import { Binary } from "polkadot-api"
const [setValue$, setValue] = createSignal<Uint8Array | "partial" | null>()
const currentValue$ = state(
merge(
combineLatest([
encodedKey$.pipe(filter((v) => v != null)),
selectedEntry$.pipe(filter((v) => v != null)),
chainClient$,
]).pipe(
switchMap(([key, entry, client]) =>
client.chainHead.storage$(
null,
"value",
() => key,
null,
(data, ctx) => {
// We must comply with the original mapper, or the cache will contain a wrong value.
const codec = ctx.dynamicBuilder.buildStorage(
entry.pallet,
entry.entry,
)
return data === null ? codec.fallback : codec.value.dec(data)
},
),
),
withLatestFrom(lookup$, selectedEntry$.pipe(filter((v) => v != null))),
map(([v, lookup, entry]) => {
if (v.raw !== null) return v.raw
const pallet = lookup.metadata.pallets.find(
(p) => p.name == entry.pallet,
)!
const storageItem = pallet.storage!.items.find(
(i) => i.name === entry.entry,
)!
return storageItem.modifier ? storageItem.fallback : null
}),
map((v) => (v != null ? Binary.fromHex(v).asBytes() : v)),
),
setValue$,
).pipe(
withLatestFrom(encodedKey$),
map(([value, encodedKey]) => ({ value, encodedKey })),
),
null,
)
export const StorageSet: FC = () => {
const selectedEntry = useStateObservable(selectedEntry$)
const lookup = useStateObservable(lookup$)
const currentValue = useStateObservable(currentValue$)
const [isLoading, setIsLoading] = useState(false)
if (!lookup || !selectedEntry) return null
const shape = lookup(selectedEntry.value)
const complexity = getTypeComplexity(shape)
return (
<div className="flex flex-col gap-4 items-start w-full overflow-hidden">
<KeyDisplay />
<StorageKeysInput disableToggle />
{currentValue ? (
<>
<LookupTypeEdit
/* A bit of a shame… this component doesn't change the value reactively... */
key={currentValue.encodedKey}
className="w-full border rounded pt-2"
type={selectedEntry.value}
value={currentValue.value}
onValueChange={setValue}
tree={complexity === "tree"}
/>
<Button
variant="secondary"
disabled={
isLoading ||
!currentValue.encodedKey ||
!(currentValue.value instanceof Uint8Array)
}
onClick={async () => {
setIsLoading(true)
try {
const chopsticks = await firstValueFrom(chopsticksInstance$)
if (
!chopsticks ||
!currentValue.encodedKey ||
!(currentValue.value instanceof Uint8Array)
)
return false
await setStorage(chopsticks, [
[currentValue.encodedKey, toHex(currentValue.value)],
])
await chopsticks.newBlock()
} finally {
setIsLoading(false)
}
}}
>
Set Storage
<Chopsticks />
</Button>
</>
) : null}
</div>
)
}

View File

@@ -37,20 +37,26 @@ import {
} from "./websocket"
import { getHashParams, setHashParams } from "@/hashParams"
import { withLogsRecorder } from "polkadot-api/logs-provider"
import {
chopsticksInstance$,
createChopsticksProvider,
} from "@/chopsticks/chopsticks"
export type ChainSource = WebsocketSource | SmoldotSource
export type SelectedChain = {
network: Network
endpoint: string
withChopsticks: boolean
}
export const getChainSource = ({
endpoint,
network: { id, relayChain },
withChopsticks,
}: SelectedChain) =>
endpoint === "light-client"
? createSmoldotSource(id, relayChain)
: createWebsocketSource(id, endpoint)
: createWebsocketSource(id, endpoint, withChopsticks)
const setRpcLogsEnabled = (enabled: boolean) =>
localStorage.setItem("rpc-logs", String(enabled))
@@ -59,9 +65,14 @@ console.log("You can enable JSON-RPC logs by calling `setRpcLogsEnabled(true)`")
;(window as any).setRpcLogsEnabled = setRpcLogsEnabled
export const getProvider = (source: ChainSource) => {
// TODO bug: provider is not getting disconnected
chopsticksInstance$.next(null)
const provider =
source.type === "websocket"
? getWebsocketProvider(source)
? source.withChopsticks
? createChopsticksProvider(source.endpoint)
: getWebsocketProvider(source)
: getSmoldotProvider(source)
return withLogsRecorder((msg) => {
@@ -96,22 +107,25 @@ export const isValidUri = (input: string): boolean => {
const defaultSelectedChain: SelectedChain = {
network: defaultNetwork,
endpoint: "light-client",
withChopsticks: false,
}
const getDefaultChain = (): SelectedChain => {
const hashParams = getHashParams()
if (hashParams.has("networkId") && hashParams.has("endpoint")) {
const networkId = hashParams.get("networkId")!
const endpoint = hashParams.get("endpoint")!
if (networkId === "custom") {
if (!isValidUri(endpoint)) return defaultSelectedChain
addCustomNetwork(endpoint)
return {
network: getCustomNetwork(),
endpoint,
withChopsticks: false,
}
}
const network = findNetwork(networkId)
if (network) return { network, endpoint }
if (network) return { network, endpoint, withChopsticks: false }
}
return defaultSelectedChain

View File

@@ -40,11 +40,13 @@ const networks = {
Westend,
Custom: [
{
id: "custom",
display: "Custom",
id: "localhost",
display: "Localhost",
lightclient: false,
endpoints: {
"ws://127.0.0.1:9944": "ws://127.0.0.1:9944",
"Port 9944": "ws://127.0.0.1:9944",
"Port 3000": "ws://127.0.0.1:3000",
"Port 8132": "ws://127.0.0.1:8132",
},
} as Network,
],

View File

@@ -6,13 +6,15 @@ export interface WebsocketSource {
type: "websocket"
id: string
endpoint: string
withChopsticks: boolean
}
export async function createWebsocketSource(
id: string,
endpoint: string,
withChopsticks: boolean,
): Promise<WebsocketSource> {
return { type: "websocket", id, endpoint }
return { type: "websocket", id, endpoint, withChopsticks }
}
export function getWebsocketProvider(source: WebsocketSource): JsonRpcProvider {