Merge pull request #45 from polkadot-api/chopsticks
feat: chopsticks integration
This commit is contained in:
@@ -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
693
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
191
src/chopsticks/chopsticks.ts
Normal file
191
src/chopsticks/chopsticks.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
94
src/components/CommandPopover.tsx
Normal file
94
src/components/CommandPopover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
105
src/components/icons/chopsticks_dark.svg
Normal file
105
src/components/icons/chopsticks_dark.svg
Normal 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 |
103
src/components/icons/chopsticks_light.svg
Normal file
103
src/components/icons/chopsticks_light.svg
Normal 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 |
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal 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 }
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
219
src/pages/Explorer/Detail/BlockStorageDiff.tsx
Normal file
219
src/pages/Explorer/Detail/BlockStorageDiff.tsx
Normal 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,
|
||||
}),
|
||||
),
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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$)
|
||||
|
||||
131
src/pages/Storage/StorageSet.tsx
Normal file
131
src/pages/Storage/StorageSet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user