feat: fork with forklift (#167)

* feat: fork with forklift

* fix: missing entry input in storage set

* fix: use best number to produce new blocks

* fix empty block table on chain stop

* fix: make network selector highlight the selected chain

* fix: handle case where forklift has all storage diff values
This commit is contained in:
Victor Oliva
2026-07-03 11:02:26 +02:00
committed by GitHub
parent 97924a3af8
commit 2a1e7b6313
17 changed files with 403 additions and 132 deletions
+67 -2
View File
@@ -1,11 +1,23 @@
import type { BlockDiff } from "@/state/block.state"
import { chainClient$ } from "@/state/chains/chain.state"
import { Blockchain, ChopsticksProvider } from "@acala-network/chopsticks-core"
import {
getSyncProvider,
InnerJsonRpcProvider,
} from "@polkadot-api/json-rpc-provider-proxy"
import { blockHeader } from "@polkadot-api/substrate-bindings"
import { Binary, blockHeader } from "@polkadot-api/substrate-bindings"
import type { JsonRpcProvider } from "polkadot-api"
import { BehaviorSubject } from "rxjs"
import {
BehaviorSubject,
catchError,
map,
Observable,
startWith,
switchMap,
take,
toArray,
withLatestFrom,
} from "rxjs"
export const chopsticksInstance$ = new BehaviorSubject<Blockchain | null>(null)
@@ -181,3 +193,56 @@ const withChopsticksEnhancer =
},
}
}
export const getChopsticksBlockDiff$ = (
parent: string,
hash: string,
): Observable<BlockDiff | null> =>
chopsticksInstance$.pipe(
take(1),
switchMap((chain) => (chain ? chain.getBlock(hash as any) : [null])),
switchMap((block) => (block ? block.storageDiff() : [null])),
map((diff) =>
diff && Object.keys(diff).length > 0
? (diff 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((v) => ({
...Object.fromEntries(
Object.entries(diff)
.filter(([key, newValue]) => (v[key] ?? null) !== newValue)
.map(([key, newValue]) => {
const prevValue = v[key] ?? null
return [
key,
{
value: newValue ? Binary.fromHex(newValue) : null,
prev: prevValue ? Binary.fromHex(prevValue) : null,
},
] as const
}),
),
})),
)
}),
catchError((ex) => {
console.error(ex)
return [null]
}),
)
+21
View File
@@ -18,11 +18,15 @@ 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 forkliftLogoDark from "./icons/forklift_dark.svg"
import forkliftLogoLight from "./icons/forklift_light.svg"
import enumSvg from "./icons/enum.svg"
import focusSvg from "./icons/focus.svg"
import githubLightSvg from "./icons/github_light.svg"
import githubDarkSvg from "./icons/github_dark.svg"
import switchBinarySvg from "./icons/switch_binary.svg"
import { useStateObservable } from "@react-rxjs/core"
import { selectedChain$ } from "@/state/chains/chain.state"
type CustomIconProps = Omit<Props, "ref" | "src"> & { size?: number }
const CustomIcon: FC<
@@ -64,6 +68,7 @@ export const Enum = customIcon(enumSvg)
export const BinaryEdit = customIcon(binarySvg)
export const SwitchBinary = customIcon(switchBinarySvg)
export const Chopsticks = themeIcon(chopsticksLogoLight, chopsticksLogoDark)
export const Forklift = themeIcon(forkliftLogoLight, forkliftLogoDark)
export const GithubIcon = themeIcon(githubLightSvg, githubDarkSvg)
export const Spinner = (props: LucideProps) => (
@@ -124,3 +129,19 @@ export const CopyBinaryIcon = ({ size = 16, ...props }: CustomIconProps) => (
</text>
</svg>
)
export const ForkMethodIcon: FC<{
size?: number
className?: string
}> = ({ size, className }) => {
const chain = useStateObservable(selectedChain$)
const method = chain.forkMethod
const Icon =
method === "chopsticks"
? Chopsticks
: method === "forklift"
? Forklift
: null
return Icon ? <Icon size={size} className={className} /> : null
}
+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="110 110 800 800">
<path
fill="#fffd"
fill-rule="evenodd"
d="m201 259-8 24v230l-32 1-18 7-9 20v176l11 20 17 7 22-1-1-32 10-29 24-25 33-13 43 5 29 21 17 30 3 44h120l1-36 11-27 23-24 32-13 31 1 31 15 25 33 5 43h249l13-9 4-22-16-17-186-2V285l-8-13-55-3-14 15-1 345-26-18-1-98-91-241-15-23-27-13H235l-24 10Zm60 39 171-2 6 5 89 225-2 74h-95l-6-8v-30l-7-23-10-14-22-10-127-3Z"
/>
<circle cx="261.9" cy="723.5" r="65.3" fill="#fffd" />
<circle cx="542.1" cy="723.5" r="65" fill="#fffd" />
<rect
width="149"
height="151"
x="709"
y="510"
fill="#e20093"
rx="18"
ry="18"
/>
</svg>

After

Width:  |  Height:  |  Size: 682 B

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="110 110 800 800">
<path
fill="#000d"
fill-rule="evenodd"
d="m201 259-8 24v230l-32 1-18 7-9 20v176l11 20 17 7 22-1-1-32 10-29 24-25 33-13 43 5 29 21 17 30 3 44h120l1-36 11-27 23-24 32-13 31 1 31 15 25 33 5 43h249l13-9 4-22-16-17-186-2V285l-8-13-55-3-14 15-1 345-26-18-1-98-91-241-15-23-27-13H235l-24 10Zm60 39 171-2 6 5 89 225-2 74h-95l-6-8v-30l-7-23-10-14-22-10-127-3Z"
/>
<circle cx="261.9" cy="723.5" r="65.3" fill="#000d" />
<circle cx="542.1" cy="723.5" r="65" fill="#000d" />
<rect
width="149"
height="151"
x="709"
y="510"
fill="#e20093"
rx="18"
ry="18"
/>
</svg>

After

Width:  |  Height:  |  Size: 682 B

+1 -1
View File
@@ -31,7 +31,7 @@ export const setHashParams = (
location.hash = "#" + params.toString()
}
const persistingKeys = ["networkId", "endpoint", "chopsticks"]
const persistingKeys = ["networkId", "endpoint", "fork"]
const usePersistKeys = () => {
const location = useLocation()
+7 -2
View File
@@ -5,13 +5,18 @@ import { BlockInfo, blocksByHeight$, finalized$ } from "@/state/block.state"
import { client$ } from "@/state/chains/chain.state"
import { state, useStateObservable } from "@react-rxjs/core"
import { FC } from "react"
import { combineLatest, debounceTime, map, switchMap } from "rxjs"
import { combineLatest, debounceTime, map, repeat, switchMap } from "rxjs"
import { twMerge } from "tailwind-merge"
import { BlockPopover } from "./BlockPopover"
import * as Finalizing from "./FinalizingTable"
const best$ = client$.pipeState(
switchMap((client) => client.bestBlocks$.pipe(map(([best]) => best))),
switchMap((client) =>
client.bestBlocks$.pipe(
repeat(),
map(([best]) => best),
),
),
)
interface PositionedBlock {
+2 -2
View File
@@ -1,4 +1,4 @@
import { Chopsticks } from "@/components/Icons"
import { ForkMethodIcon } from "@/components/Icons"
import { Loading } from "@/components/Loading"
import { getHashParams } from "@/hashParams"
import { groupBy } from "@/lib/groupBy"
@@ -122,7 +122,7 @@ export const BlockBody: FC = () => {
value="diff"
>
Diff
<Chopsticks
<ForkMethodIcon
className="inline-block align-middle ml-2"
size={20}
/>
+15 -15
View File
@@ -52,26 +52,26 @@ const blockDiff$ = state(
return Object.entries(block.diff)
.map(
([key, [prevValue, newValue]]): {
([key, { value, prev }]): {
key: HexString
decodedKey: [string, ...unknown[]]
prevValue: HexString | null
prevValue: Uint8Array | null | undefined
decodedPrevValue: unknown
newValue: HexString | null
newValue: Uint8Array | null
decodedNewValue: unknown
} | null => {
try {
if (wellKnownKeys[key]) {
return {
key,
prevValue,
newValue,
prevValue: prev,
newValue: value,
decodedKey: [wellKnownKeys[key].name],
decodedNewValue: newValue
? wellKnownKeys[key].codec.dec(newValue)
decodedNewValue: value
? wellKnownKeys[key].codec.dec(value)
: null,
decodedPrevValue: prevValue
? wellKnownKeys[key].codec.dec(prevValue)
decodedPrevValue: prev
? wellKnownKeys[key].codec.dec(prev)
: null,
}
}
@@ -86,18 +86,18 @@ const blockDiff$ = state(
if (storageCodec) {
return {
key,
prevValue,
newValue,
prevValue: prev,
newValue: value,
decodedKey: [
pallet.name,
entry,
...storageCodec.keys.dec(key),
],
decodedNewValue: newValue
? storageCodec.value.dec(newValue)
decodedNewValue: value
? storageCodec.value.dec(value)
: null,
decodedPrevValue: prevValue
? storageCodec.value.dec(prevValue)
decodedPrevValue: prev
? storageCodec.value.dec(prev)
: null,
}
}
+13 -9
View File
@@ -1,4 +1,4 @@
import { Chopsticks } from "@/components/Icons"
import { ForkMethodIcon } from "@/components/Icons"
import { Button } from "@/components/ui/button"
import {
canProduceBlocks$,
@@ -7,13 +7,13 @@ import {
} from "@/state/chains/chain.state"
import { useStateObservable, withDefault } from "@react-rxjs/core"
import { FC, PropsWithChildren, ReactElement, useEffect, useState } from "react"
import { firstValueFrom, map, switchMap } from "rxjs"
import { firstValueFrom, map, repeat, switchMap } from "rxjs"
import { twMerge } from "tailwind-merge"
import { BlockTime } from "./BlockTime"
import { EpochRemainingTime } from "./EpochTime"
const finalizedNum$ = client$.pipeState(
switchMap((chainHead) => chainHead.finalizedBlock$),
switchMap((chainHead) => chainHead.finalizedBlock$.pipe(repeat())),
map((v) => v.number),
)
const finalized$ = finalizedNum$.pipeState(map((v) => v.toLocaleString()))
@@ -77,14 +77,18 @@ const SummaryItem: FC<
)
}
const bestNum$ = client$.pipeState(
switchMap((chainHead) => chainHead.bestBlocks$.pipe(repeat())),
map((v) => v[0].number),
)
const Jump = () => {
const finalized = useStateObservable(finalizedNum$)
const [value, setValue] = useState(finalized + 1)
const best = useStateObservable(bestNum$)
const [value, setValue] = useState(best + 1)
const [loading, setLoading] = useState(false)
useEffect(() => {
setValue((v) => Math.max(v, finalized + 1))
}, [finalized])
setValue((v) => Math.max(v, best + 1))
}, [best])
return (
<SummaryItem title="" className="bg-card/0 border-none text-center">
@@ -107,7 +111,7 @@ const Jump = () => {
const client = await firstValueFrom(client$)
await client._request(
"dev_newBlock",
value === finalized + 1
value === best + 1
? []
: [
{
@@ -122,7 +126,7 @@ const Jump = () => {
disabled={loading}
>
New Block{" "}
<Chopsticks className="inline-block align-middle ml-1" size={20} />
<ForkMethodIcon className="inline-block align-middle ml-1" size={20} />
</Button>
</SummaryItem>
)
+81 -32
View File
@@ -1,6 +1,10 @@
import { CopyText } from "@/components/Copy"
import { Chopsticks, Spinner } from "@/components/Icons"
import SliderToggle from "@/components/Toggle"
import {
Chopsticks,
Forklift,
ForkMethodIcon,
Spinner,
} from "@/components/Icons"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
@@ -24,6 +28,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import {
AUTO_RPC_ENDPOINT,
currentWsStatus$,
ForkMethod,
LIGHT_CLIENT_ENDPOINT,
Network,
networkCategories,
@@ -56,9 +61,7 @@ export function NetworkSwitcher({ className }: { className?: string }) {
>
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-left flex items-center gap-1">
{getChainLabel(selectedChain)}
{selectedChain.withChopsticks ? (
<Chopsticks className="inline-block align-text-top" />
) : null}
<ForkMethodIcon className="inline-block align-text-top" />
<span className="text-sm text-muted-foreground">
{getConnectionLabel(selectedChain, websocketStatus)}
</span>
@@ -67,7 +70,7 @@ export function NetworkSwitcher({ className }: { className?: string }) {
</Button>
</DialogTrigger>
<NetworkSwitchDialogContent
key={`${selectedChain.network.id}-${selectedChain.endpoint}`}
key={`${selectedChain.network.id}-${selectedChain.endpoint}-${selectedChain.forkMethod}`}
selectedChain={selectedChain}
onClose={() => setOpen(false)}
/>
@@ -82,16 +85,14 @@ const NetworkSwitchDialogContent: FC<{
const [query, setQuery] = useState("")
const [network, setNetwork] = useState(selectedChain.network)
const [endpoint, setEndpoint] = useState(selectedChain.endpoint)
const [withChopsticks, setWithChopsticks] = useState(
selectedChain.withChopsticks,
)
const [forkMethod, setForkMethod] = useState(selectedChain.forkMethod)
const customUrl = normalizeWsUrl(query)
const canFork = endpoint !== LIGHT_CLIENT_ENDPOINT
const forked = canFork && withChopsticks
const selectedForkMethod = canFork ? forkMethod : "none"
const hasChanged =
network.id !== selectedChain.network.id ||
endpoint !== selectedChain.endpoint ||
forked !== selectedChain.withChopsticks
selectedForkMethod !== selectedChain.forkMethod
const selectNetwork = (
next: Network,
@@ -100,7 +101,7 @@ const NetworkSwitchDialogContent: FC<{
setNetwork(next)
setEndpoint(nextEndpoint)
if (nextEndpoint === LIGHT_CLIENT_ENDPOINT) {
setWithChopsticks(false)
setForkMethod("none")
}
}
@@ -112,13 +113,12 @@ const NetworkSwitchDialogContent: FC<{
const setConnection = (nextEndpoint: string) => {
setEndpoint(nextEndpoint)
if (nextEndpoint === LIGHT_CLIENT_ENDPOINT) {
setWithChopsticks(false)
setForkMethod("none")
}
}
const toggleChopsticks = () => {
if (endpoint === LIGHT_CLIENT_ENDPOINT) return
setWithChopsticks(!withChopsticks)
const toggleForkMethod = (method: ForkMethod) => {
setForkMethod((current) => (current === method ? "none" : method))
}
const confirm = () => {
@@ -134,7 +134,7 @@ const NetworkSwitchDialogContent: FC<{
onChangeChain({
network: localNetwork,
endpoint,
withChopsticks: forked,
forkMethod: selectedForkMethod,
})
onClose()
return
@@ -144,10 +144,10 @@ const NetworkSwitchDialogContent: FC<{
onChangeChain({
network: getCustomNetwork(),
endpoint,
withChopsticks: forked,
forkMethod: selectedForkMethod,
})
} else {
onChangeChain({ network, endpoint, withChopsticks: forked })
onChangeChain({ network, endpoint, forkMethod: selectedForkMethod })
}
onClose()
}
@@ -187,16 +187,26 @@ const NetworkSwitchDialogContent: FC<{
<div className="flex shrink-0 items-center justify-between gap-3 border-t pt-3">
{canFork ? (
<div className="flex items-center gap-2">
<Chopsticks size={18} />
<Label htmlFor="use-chopsticks" className="cursor-pointer">
Fork with Chopsticks
</Label>
<SliderToggle
id="use-chopsticks"
isToggled={forked}
toggle={toggleChopsticks}
/>
<div className="flex min-w-0 items-center gap-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Fork with
</div>
<div className="flex flex-wrap gap-2">
<ForkMethodOption
value="chopsticks"
title="Chopsticks"
selected={selectedForkMethod === "chopsticks"}
icon={<Chopsticks size={18} />}
onSelect={toggleForkMethod}
/>
<ForkMethodOption
value="forklift"
title="Forklift"
selected={selectedForkMethod === "forklift"}
icon={<Forklift size={18} />}
onSelect={toggleForkMethod}
/>
</div>
</div>
) : (
<div />
@@ -217,6 +227,27 @@ const NetworkSwitchDialogContent: FC<{
)
}
const ForkMethodOption: FC<{
value: Exclude<ForkMethod, "none">
title: string
selected: boolean
icon?: React.ReactNode
onSelect: (value: ForkMethod) => void
}> = ({ value, title, selected, icon, onSelect }) => (
<button
type="button"
className={twMerge(
"flex items-center gap-1.5 rounded-md border border-transparent px-2 py-1.5 text-sm",
selected && "border-polkadot bg-polkadot/5",
)}
aria-pressed={selected}
onClick={() => onSelect(value)}
>
{icon}
{title}
</button>
)
const NetworkList: FC<{
query: string
selectedNetwork: Network
@@ -236,9 +267,13 @@ const NetworkList: FC<{
() => networkCategories.filter((category) => category.name !== "Custom"),
[],
)
const selectedValue = getNetworkItemValue(categories, selectedNetwork)
return (
<Command className="flex min-h-0 flex-1 basis-0 flex-col overflow-hidden rounded-md border bg-transparent">
<Command
defaultValue={selectedValue}
className="flex min-h-0 flex-1 basis-0 flex-col overflow-hidden rounded-md border bg-transparent"
>
<CommandInput
value={query}
onValueChange={onQueryChange}
@@ -297,6 +332,20 @@ const NetworkItem: FC<{
</CommandItem>
)
const getNetworkItemValue = (
categories: Array<{ name: string; networks: Network[] }>,
network: Network,
) => {
if (network.id === "custom") {
return undefined
}
const category = categories.find((category) =>
category.networks.some((x) => x.id === network.id),
)
return category ? networkSearchValue(category.name, network) : undefined
}
const ConnectionList: FC<{
network: Network
endpoint: string
@@ -524,10 +573,10 @@ const getConnectionLabel = (
selectedChain: SelectedChain,
websocketStatus: StatusChange | null,
) => {
const { network, endpoint, withChopsticks } = selectedChain
const { network, endpoint, forkMethod } = selectedChain
if (endpoint === LIGHT_CLIENT_ENDPOINT) return "Smoldot"
if (endpoint === AUTO_RPC_ENDPOINT) {
if (withChopsticks) return null
if (forkMethod !== "none") return null
if (!websocketStatus) return <Spinner className="inline-block" />
const isReady = "uri" in websocketStatus
+3 -3
View File
@@ -1,5 +1,5 @@
import { ButtonGroup } from "@/components/ButtonGroup"
import { Chopsticks } from "@/components/Icons"
import { ForkMethodIcon } from "@/components/Icons"
import { LoadingMetadata } from "@/components/Loading"
import { withSubscribe } from "@/components/withSuspense"
import { createState } from "@/lib/externalState"
@@ -7,11 +7,11 @@ import { canSetStorage$ } from "@/state/chains/chain.state"
import { useStateObservable } from "@react-rxjs/core"
import { FC } from "react"
import { Route, Routes } from "react-router-dom"
import { CenteredScrollContainer } from "../AppShell"
import { StorageDecode } from "./StorageDecode"
import { StorageQuery } from "./StorageQuery"
import { StorageSet } from "./StorageSet"
import { StorageSubscriptions } from "./StorageSubscriptions"
import { CenteredScrollContainer } from "../AppShell"
export const Storage = withSubscribe(
() => (
@@ -54,7 +54,7 @@ const StorageEntry: FC = () => {
content: (
<>
Set Value
<Chopsticks
<ForkMethodIcon
className="inline-block align-middle ml-2"
size={20}
/>
+10 -2
View File
@@ -1,5 +1,6 @@
import { LookupTypeEdit } from "@/codec-components/LookupTypeEdit"
import { Chopsticks } from "@/components/Icons"
import { ForkMethodIcon } from "@/components/Icons"
import { MetadataEntryInput } from "@/components/MetadataEntryInput"
import { Button } from "@/components/ui/button"
import { chainClient$, client$, lookup$ } from "@/state/chains/chain.state"
import { getTypeComplexity } from "@/utils"
@@ -71,6 +72,13 @@ export const StorageSet: FC = () => {
return (
<div className="flex flex-col gap-4 items-start w-full overflow-hidden">
<MetadataEntryInput
state={storageEntryState}
labels={{
group: "Pallet",
item: "Entry",
}}
/>
<KeyInput />
<StorageKeysInput disableToggle />
{currentValue && currentValue.encodedKey ? (
@@ -112,7 +120,7 @@ export const StorageSet: FC = () => {
}}
>
Set Storage
<Chopsticks />
<ForkMethodIcon />
</Button>
</>
) : null}
+22 -47
View File
@@ -1,5 +1,6 @@
import { chopsticksInstance$ } from "@/chopsticks/chopsticks"
import { chainClient$, client$ } from "@/state/chains/chain.state"
import { getChopsticksBlockDiff$ } from "@/chopsticks/chopsticks"
import { client$, selectedChain$ } from "@/state/chains/chain.state"
import { getForkliftBlockDiff$ } from "@/state/forklift"
import { SystemEvent } from "@polkadot-api/observable-client"
import { blockHeader, SizedHex } from "@polkadot-api/substrate-bindings"
import { liftSuspense, state, SUSPENSE } from "@react-rxjs/core"
@@ -37,8 +38,6 @@ import {
take,
takeWhile,
tap,
toArray,
withLatestFrom,
} from "rxjs"
export const finalized$ = client$.pipeState(
@@ -52,6 +51,13 @@ export enum BlockState {
Pruned = "pruned",
Unknown = "unknown",
}
export type BlockDiff = Record<
string,
{
value: Uint8Array | null
prev: Uint8Array | null
}
>
export interface BlockInfo {
hash: string
parent: string
@@ -60,7 +66,7 @@ export interface BlockInfo {
events: SystemEvent[] | null
header: BlockHeader | null
status: BlockState
diff: Record<string, [string | null, string | null]> | null
diff: BlockDiff | null
}
export const BlockContext = createContext<BlockInfo | null>(null)
@@ -83,7 +89,9 @@ finalizedBlocks$.pipe(liftSuspense(), retry()).subscribe()
const MAX_HEIGHT = 600
const sharedBlocks$ = client$.pipe(
liftSuspense(),
switchMap((client) => (client === SUSPENSE ? [] : client.blocks$)),
switchMap((client) =>
client === SUSPENSE ? [] : client.blocks$.pipe(repeat()),
),
share(),
)
export const [blockInfo$, recordedBlocks$] = partitionByKey(
@@ -428,47 +436,14 @@ const getBlockStatus$ = (
const getBlockDiff$ = (
parent: string,
hash: string,
): Observable<Record<string, [string | null, string | null]> | null> =>
chopsticksInstance$.pipe(
): Observable<BlockDiff | null> =>
selectedChain$.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,
switchMap(({ forkMethod }) =>
forkMethod === "chopsticks"
? getChopsticksBlockDiff$(parent, hash)
: forkMethod === "forklift"
? getForkliftBlockDiff$(parent, hash)
: [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]
}),
)
+27 -13
View File
@@ -1,4 +1,5 @@
import { createChopsticksProvider } from "@/chopsticks/chopsticks"
import { createForkliftProvider } from "@/state/forklift"
import { getHashParams, setHashParams } from "@/hashParams"
import { DotAh } from "@polkadot-api/descriptors"
import { getDynamicBuilder, getLookupFn } from "@polkadot-api/metadata-builders"
@@ -68,23 +69,24 @@ import {
export type ChainSource = WebsocketSource | SmoldotSource
export const LIGHT_CLIENT_ENDPOINT = "light-client"
export const AUTO_RPC_ENDPOINT = "auto-rpc"
export type ForkMethod = "none" | "chopsticks" | "forklift"
export type SelectedChain = {
network: Network
endpoint: string
withChopsticks: boolean
forkMethod: ForkMethod
}
export const getChainSource = ({
endpoint,
network,
withChopsticks,
forkMethod,
}: SelectedChain) =>
endpoint === LIGHT_CLIENT_ENDPOINT
? createSmoldotSource(network.id, network.relayChain)
: createWebsocketSource(
network.id,
endpoint === AUTO_RPC_ENDPOINT ? shuffleEndpoints(network) : endpoint,
withChopsticks,
forkMethod,
)
const shuffleEndpoints = (network: Network) =>
Object.values(network.endpoints)
@@ -101,9 +103,11 @@ console.log("You can enable JSON-RPC logs by calling `setRpcLogsEnabled(true)`")
export const getProvider = (source: ChainSource) => {
const provider =
source.type === "websocket"
? source.withChopsticks
? source.forkMethod === "chopsticks"
? createChopsticksProvider(source.endpoint)
: getWebsocketProvider(source)
: source.forkMethod === "forklift"
? createForkliftProvider(source.endpoint)
: getWebsocketProvider(source)
: getSmoldotProvider(source)
const recorder = withLogsRecorder((msg) => {
@@ -118,11 +122,11 @@ export const getProvider = (source: ChainSource) => {
export const [selectedChainChanged$, onChangeChain] =
createSignal<SelectedChain>()
selectedChainChanged$.subscribe(({ network, endpoint, withChopsticks }) =>
selectedChainChanged$.subscribe(({ network, endpoint, forkMethod }) =>
setHashParams({
networkId: network.id,
endpoint,
chopsticks: withChopsticks ? "true" : null,
fork: forkMethod === "none" ? null : forkMethod,
}),
)
@@ -142,16 +146,14 @@ export const isValidUri = (input: string): boolean => {
const defaultSelectedChain: SelectedChain = {
network: defaultNetwork,
endpoint: LIGHT_CLIENT_ENDPOINT,
withChopsticks: false,
forkMethod: "none",
}
const getDefaultChain = (): SelectedChain => {
const hashParams = getHashParams()
if (hashParams.has("networkId") && hashParams.has("endpoint")) {
const networkId = hashParams.get("networkId")!
const endpoint = hashParams.get("endpoint")!
const withChopsticks =
hashParams.get("chopsticks") === "true" &&
endpoint !== LIGHT_CLIENT_ENDPOINT
const forkMethod = getForkMethod(hashParams, endpoint)
if (networkId === "custom") {
if (!isValidUri(endpoint)) return defaultSelectedChain
@@ -159,15 +161,27 @@ const getDefaultChain = (): SelectedChain => {
return {
network: getCustomNetwork(),
endpoint,
withChopsticks,
forkMethod,
}
}
const network = findNetwork(networkId)
if (network) return { network, endpoint, withChopsticks }
if (network) return { network, endpoint, forkMethod }
}
return defaultSelectedChain
}
const getForkMethod = (
hashParams: URLSearchParams,
endpoint: string,
): ForkMethod => {
if (endpoint === LIGHT_CLIENT_ENDPOINT) return "none"
const fork = hashParams.get("fork")
if (fork === "chopsticks" || fork === "forklift") return fork
return "none"
}
export const selectedChain$ = state<SelectedChain>(
selectedChainChanged$,
getDefaultChain(),
+3 -3
View File
@@ -5,15 +5,15 @@ export interface WebsocketSource {
type: "websocket"
id: string
endpoint: string | string[]
withChopsticks: boolean
forkMethod: "none" | "chopsticks" | "forklift"
}
export async function createWebsocketSource(
id: string,
endpoint: string | string[],
withChopsticks: boolean,
forkMethod: WebsocketSource["forkMethod"],
): Promise<WebsocketSource> {
return { type: "websocket", id, endpoint, withChopsticks }
return { type: "websocket", id, endpoint, forkMethod }
}
export type WsStatusJsonRpcProvider = WsJsonRpcProvider & {
+94
View File
@@ -0,0 +1,94 @@
import {
forklift,
Forklift,
fromWorker,
wsSource,
} from "@polkadot-api/forklift"
import Worker from "@polkadot-api/forklift/worker?worker"
import { Binary, type JsonRpcProvider } from "polkadot-api"
import {
BehaviorSubject,
catchError,
map,
Observable,
startWith,
switchMap,
take,
toArray,
withLatestFrom,
} from "rxjs"
import type { BlockDiff } from "./block.state"
import { chainClient$ } from "./chains/chain.state"
const forkliftInstance$ = new BehaviorSubject<Forklift | null>(null)
export const createForkliftProvider = (
endpoint: string | string[],
): JsonRpcProvider => {
return (onMsg) => {
const worker = new Worker()
const instance = forklift(wsSource(endpoint), {
mockSignatureHost: true,
executor: fromWorker(worker),
})
forkliftInstance$.next(instance)
const connection = instance.serve(onMsg)
return {
send: connection.send,
disconnect() {
forkliftInstance$.next(null)
connection.disconnect()
instance.destroy()
worker.terminate()
},
}
}
}
export const getForkliftBlockDiff$ = (
parent: string,
hash: string,
): Observable<BlockDiff | null> =>
forkliftInstance$.pipe(
take(1),
switchMap((chain) => (chain ? chain.getStorageDiff(hash) : [null])),
startWith(null),
withLatestFrom(chainClient$),
switchMap(([diff, { chainHead }]) => {
if (!diff) return [null]
const missingPrevValues = Object.entries(diff).filter(
([_, { prev }]) => prev === undefined,
)
if (!missingPrevValues.length) return [diff as BlockDiff]
return chainHead
.storageQueries$(
parent,
missingPrevValues.map(([key]) => ({ key, type: "value" })),
)
.pipe(
toArray(),
catchError(() => [
missingPrevValues.map(([key]) => ({ key, value: null })),
]),
map((v) => ({
...(diff as BlockDiff),
...Object.fromEntries(
v.map((v) => [
v.key,
{
value: diff[v.key].value,
prev: v.value == null ? null : Binary.fromHex(v.value),
},
]),
),
})),
)
}),
catchError((ex) => {
console.error(ex)
return [null]
}),
)
+1 -1
View File
@@ -95,7 +95,7 @@ const proxyProvider = createProxyProvider((address) =>
const multisigProvider$ = selectedChain$.pipe(
switchMap(getChainSource),
map((source) => {
if (source.type === "websocket" && source.withChopsticks) {
if (source.type === "websocket" && source.forkMethod !== "none") {
return null
}