diff --git a/src/chopsticks/chopsticks.ts b/src/chopsticks/chopsticks.ts index 44ffaa1..abb735b 100644 --- a/src/chopsticks/chopsticks.ts +++ b/src/chopsticks/chopsticks.ts @@ -1,5 +1,76 @@ -import { - ChopsticksProvider, - setStorage, - setup, -} from "@acala-network/chopsticks-core" +import { Blockchain } from "@acala-network/chopsticks-core" +import { getSyncProvider } from "@polkadot-api/json-rpc-provider-proxy" +import { BehaviorSubject } from "rxjs" + +export const chopsticksInstance$ = new BehaviorSubject(null) + +export const createChopsticksProvider = (endpoint: string) => + 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) + }, + } + } + }) diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 9f71d18..fa32f49 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -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 & { size?: number } -const customIcon = - (url: string) => - ({ size = 16, ...props }: CustomIconProps) => { - const ref = useRef(null) - - useEffect(() => { - if (!ref.current) return - ref.current.setAttribute("width", String(size)) - ref.current.setAttribute("height", String(size)) - }, [size]) - - return ( - { - 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(null) + + useEffect(() => { + if (!ref.current) return + ref.current.setAttribute("width", String(size)) + ref.current.setAttribute("height", String(size)) + }, [size]) + + return ( + { + ref.current = svg + svg.setAttribute("width", String(size)) + svg.setAttribute("height", String(size)) + }} + /> + ) +} + +const customIcon = (url: string) => (props: CustomIconProps) => ( + +) + +const themeIcon = (light: string, dark: string) => (props: CustomIconProps) => { + const theme = useTheme() + return +} 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) => ( = ({ src }) => { + collapsed?: boolean +}> = ({ src, ...props }) => { const theme = useTheme() return ( @@ -30,6 +31,7 @@ export const JsonDisplay: FC<{ 2, ) } + {...props} /> ) } diff --git a/src/components/icons/chopsticks_dark.svg b/src/components/icons/chopsticks_dark.svg new file mode 100644 index 0000000..04d6920 --- /dev/null +++ b/src/components/icons/chopsticks_dark.svg @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/components/icons/chopsticks_light.svg b/src/components/icons/chopsticks_light.svg new file mode 100644 index 0000000..a41d473 --- /dev/null +++ b/src/components/icons/chopsticks_light.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/hashParams.tsx b/src/hashParams.tsx index 6b31966..fec30e2 100644 --- a/src/hashParams.tsx +++ b/src/hashParams.tsx @@ -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() diff --git a/src/pages/Explorer/Detail/BlockBody.tsx b/src/pages/Explorer/Detail/BlockBody.tsx index 228e38c..f52fd24 100644 --- a/src/pages/Explorer/Detail/BlockBody.tsx +++ b/src/pages/Explorer/Detail/BlockBody.tsx @@ -1,3 +1,4 @@ +import { chopsticksInstance$ } from "@/chopsticks/chopsticks" import { Loading } from "@/components/Loading" import { groupBy } from "@/lib/groupBy" import { runtimeCtx$ } from "@/state/chains/chain.state" @@ -5,12 +6,21 @@ import * as Tabs from "@radix-ui/react-tabs" import { state, useStateObservable } from "@react-rxjs/core" import { FC, useState } from "react" import { useLocation, useParams } from "react-router-dom" -import { combineLatest, distinctUntilChanged, filter, map, take } from "rxjs" +import { + combineLatest, + distinctUntilChanged, + filter, + map, + switchMap, + 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" +import { Chopsticks } from "@/components/Icons" const blockExtrinsics$ = state((hash: string) => { const decoder$ = runtimeCtx$.pipe( @@ -32,13 +42,32 @@ const blockExtrinsics$ = state((hash: string) => { ) }, []) -type Tab = "signed" | "unsigned" | "events" +const blockHasDiff$ = state( + (hash: string) => + chopsticksInstance$.pipe( + switchMap((chain) => { + if (!chain) return [null] + + return chain.getBlock(hash as any) + }), + switchMap((block) => { + if (!block) return [null] + return block.storageDiff() + }), + map((v) => Boolean(v && Object.keys(v).length > 0)), + ), + false, +) + +type Tab = "signed" | "unsigned" | "events" | "diff" export const BlockBody: FC<{ block: BlockInfo }> = ({ block }) => { const { hash } = useParams() const [selectedTab, setSelectedTab] = useState(null) const extrinsics = useStateObservable(blockExtrinsics$(hash ?? "")) + const diff = useStateObservable(blockHasDiff$(hash ?? "")) + const location = useLocation() const hashParams = new URLSearchParams(location.hash.slice(1)) const eventParam = hashParams.get("event") @@ -102,7 +131,7 @@ export const BlockBody: FC<{ Events + {diff && ( + + Diff + + + )}
    @@ -149,6 +194,9 @@ export const BlockBody: FC<{ + + + ) diff --git a/src/pages/Explorer/Detail/BlockStorageDiff.tsx b/src/pages/Explorer/Detail/BlockStorageDiff.tsx new file mode 100644 index 0000000..2db4d83 --- /dev/null +++ b/src/pages/Explorer/Detail/BlockStorageDiff.tsx @@ -0,0 +1,217 @@ +import { FC } from "react" +import { BlockInfo } from "../block.state" +import { Binary, Codec } from "polkadot-api" +import { + HexString, + Struct, + Tuple, + Twox128, + u32, + u64, + u8, + Vector, +} from "@polkadot-api/substrate-bindings" +import { state, useStateObservable } from "@react-rxjs/core" +import { dynamicBuilder$, lookup$ } from "@/state/chains/chain.state" +import { chopsticksInstance$ } from "@/chopsticks/chopsticks" +import { switchMap, map, combineLatest } from "rxjs" +import { toHex } from "@polkadot-api/utils" +import { groupBy } from "@/lib/groupBy" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion" +import { bytesToString } from "@/components/BinaryInput" +import { CopyText } from "@/components/Copy" +import { JsonDisplay } from "@/components/JsonDisplay" + +const storageDiff$ = (hash: string) => + chopsticksInstance$.pipe( + switchMap((chain) => { + if (!chain) return [null] + + return chain.getBlock(hash as any) + }), + switchMap((block) => { + if (!block) return [null] + return block.storageDiff() + }), + map((v) => + v && Object.keys(v).length > 0 + ? (v as Record) + : null, + ), + // map((v) => v ?? testDiff), + ) + +const TWOX128_LEN = 32 + +const blockDiff$ = state( + (hash: string) => + // TODO for current block + combineLatest([lookup$, dynamicBuilder$, storageDiff$(hash)]).pipe( + map(([lookup, dynamicBuilder, diff]) => { + if (!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(diff) + .filter(([, newValue]) => newValue !== null) + .map( + ([key, newValue]): { + key: HexString + decodedKey: [string, ...unknown[]] + newValue: HexString + decodedNewValue: unknown + } | null => { + try { + if (wellKnownKeys[key]) { + return { + key, + newValue: newValue!, + decodedKey: [wellKnownKeys[key].name], + decodedNewValue: wellKnownKeys[key].codec.dec(newValue!), + } + } + 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, + newValue: newValue!, + decodedKey: [ + pallet.name, + entry, + ...storageCodec.keys.dec(key), + ], + decodedNewValue: storageCodec.value.dec(newValue!), + } + } + } + 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 ( + + {diff.map(({ name, changes }) => ( + + {name} + + {changes.map((change) => ( +
    +
    + {" "} + {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(".")} +
    + +
    + ))} +
    +
    + ))} +
    + ) +} + +const strToHex = (v: string) => Binary.fromText(v).asHex() +const wellKnownKeys: Record< + string, + { + name: string + codec: Codec + } +> = { + [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, + }), + ), + ), + }, +} diff --git a/src/pages/Network/Network.tsx b/src/pages/Network/Network.tsx index 85958ca..b0324dd 100644 --- a/src/pages/Network/Network.tsx +++ b/src/pages/Network/Network.tsx @@ -1,5 +1,6 @@ import { CommandPopover } from "@/components/CommandPopover" import { CopyText } from "@/components/Copy" +import { Chopsticks } from "@/components/Icons" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { @@ -29,7 +30,7 @@ import { } from "@/state/chains/chain.state" import { addCustomNetwork, getCustomNetwork } from "@/state/chains/networks" import { useStateObservable } from "@react-rxjs/core" -import { Check, ChevronDown, Server } from "lucide-react" +import { Check, ChevronDown } from "lucide-react" import { FC, useState } from "react" export function NetworkSwitcher() { @@ -68,10 +69,14 @@ const NetworkSwitchDialogContent: FC<{ const currentRpc = selectedChain.endpoint ?? "light-client" const [selectedRpc, setSelectedRpc] = useState(currentRpc) const [enteredText, setEnteredText] = useState("") + 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 @@ -85,14 +90,20 @@ 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() @@ -185,7 +196,7 @@ const NetworkSwitchDialogContent: FC<{ ) : null} @@ -208,7 +219,7 @@ const NetworkSwitchDialogContent: FC<{
    - +
    - + setWithChopsticks(!withChopsticks)} + />

    - Create a local development fork of this chain + Create a local fork of this chain

    )} @@ -265,7 +280,7 @@ const ConnectionOption: FC<{ )}

    {type === "light" - ? "Browser light client" + ? "Light client for a decentralized experience" : url?.includes("127.0.0.1") ? "Local RPC node" : "Remote RPC node"} diff --git a/src/state/chains/chain.state.ts b/src/state/chains/chain.state.ts index 6a8ae94..58d12c5 100644 --- a/src/state/chains/chain.state.ts +++ b/src/state/chains/chain.state.ts @@ -37,20 +37,23 @@ import { } from "./websocket" import { getHashParams, setHashParams } from "@/hashParams" import { withLogsRecorder } from "polkadot-api/logs-provider" +import { 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)) @@ -61,7 +64,9 @@ console.log("You can enable JSON-RPC logs by calling `setRpcLogsEnabled(true)`") export const getProvider = (source: ChainSource) => { const provider = source.type === "websocket" - ? getWebsocketProvider(source) + ? source.withChopsticks + ? createChopsticksProvider(source.endpoint) + : getWebsocketProvider(source) : getSmoldotProvider(source) return withLogsRecorder((msg) => { @@ -96,22 +101,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 diff --git a/src/state/chains/websocket.ts b/src/state/chains/websocket.ts index f8462e5..eec8bb1 100644 --- a/src/state/chains/websocket.ts +++ b/src/state/chains/websocket.ts @@ -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 { - return { type: "websocket", id, endpoint } + return { type: "websocket", id, endpoint, withChopsticks } } export function getWebsocketProvider(source: WebsocketSource): JsonRpcProvider {