Files
qapi/src/pages/Storage/StorageQuery.tsx
2026-09-01 15:24:03 +02:00

484 lines
12 KiB
TypeScript

import { EditCodec } from "@/codec-components/EditCodec"
import { ActionButton } from "@/components/ActionButton"
import { BinaryEditButton } from "@/components/BinaryEditButton"
import { MetadataEntryInput } from "@/components/MetadataEntryInput"
import SliderToggle from "@/components/Toggle"
import { useNavigate } from "@/hashParams"
import {
CodecComponentType,
CodecComponentValue,
NOTIN,
} from "@polkadot-api/react-builder"
import { Input } from "@polkahub/ui-components"
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
import { createSignal, mergeWithKey } from "@react-rxjs/utils"
import { Binary, Enum } from "polkadot-api"
import { fromHex } from "polkadot-api/utils"
import { ChangeEvent, FC } from "react"
import {
combineLatest,
distinctUntilChanged,
filter,
firstValueFrom,
map,
merge,
scan,
startWith,
switchMap,
take,
withLatestFrom,
} from "rxjs"
import { twMerge } from "tailwind-merge"
import { selectedBlock$ } from "./BlockPicker"
import { decodeKeyArgs, decodeKeyTarget } from "./decodeKey"
import { addStorageSubscription, storageEntryState } from "./storage.state"
export const StorageQuery: FC = () => {
const isReady = useStateObservable(isReady$)
const navigate = useNavigate()
const submit = async () => {
const [entry, keyValues, keysEnabled, { hash }, selectedKey] =
await firstValueFrom(
combineLatest([
storageEntryState.selectedEntry$,
keyValues$,
argsEnabled$,
selectedBlock$,
selectedKey$,
]),
)
if (selectedKey?.type === "raw") {
const id = await addStorageSubscription({
blockHash: hash ?? null,
pallet: entry!.pallet,
item: entry!.entry,
value: Enum("rawQuery", selectedKey.key),
})
navigate(`/storage/${id}`)
} else {
const args = keyValues.slice(0, keysEnabled)
const id = await addStorageSubscription({
blockHash: hash ?? null,
pallet: entry!.pallet,
item: entry!.entry,
value: Enum("query", args),
})
navigate(`/storage/${id}`)
}
}
return (
<div className="flex flex-col gap-4 items-start w-full">
<MetadataEntryInput
state={storageEntryState}
labels={{
group: "Pallet",
item: "Entry",
}}
/>
<StorageKeysInput />
<KeyInput />
<ActionButton disabled={!isReady} onClick={submit}>
Query
</ActionButton>
</div>
)
}
const keys$ = storageEntryState.selectedEntry$.pipeState(
filter((e) => !!e),
map((entry) => entry.key),
withDefault([] as number[]),
distinctUntilChanged((a, b) => a.join(",") === b.join(",")),
)
const hashers$ = storageEntryState.selectedEntry$.pipeState(
filter((e) => !!e),
map((entry) => entry.hashers),
withDefault([] as string[]),
)
const [toggleKey$, toggleKey] = createSignal<number>()
export const [changeKeysEnabled$, setKeysEnabled] = createSignal<number>()
const argsEnabled$ = keys$.pipeState(
switchMap((k) =>
mergeWithKey({
toggle: toggleKey$,
set: changeKeysEnabled$,
}).pipe(
/*
acc=2
[X,X, , ]
0 1 2 3
toggle 0 => acc=0
toggle 1 => acc=1
toggle 2 => acc=3
toggle 3 => acc=4
*/
scan(
(acc, evt) =>
evt.type === "set" || acc > evt.payload
? evt.payload
: evt.payload + 1,
k.length,
),
startWith(k.length),
),
),
withDefault(0),
)
export const [keyValueChange$, setKeyValue] = createSignal<{
idx: number
value: unknown | NOTIN
}>()
export const keyValues$ = keys$.pipeState(
switchMap((keys) => {
const values: unknown[] = keys.map(() => NOTIN)
return keyValueChange$.pipe(
scan((acc, change) => {
const newValue = [...acc]
newValue[change.idx] = change.value
return newValue
}, values),
startWith(values),
)
}),
withDefault([] as unknown[]),
)
export const StorageKeysInput: FC<{
disableToggle?: boolean
}> = ({ disableToggle }) => {
const keys = useStateObservable(keys$)
const hashers = useStateObservable(hashers$)
const argsEnabled = useStateObservable(argsEnabled$)
return (
<div>
<label>Params</label>
<ol className="flex flex-col gap-2">
{keys.map((type, idx) => (
<li key={idx} className="flex flex-row gap-2 items-center">
{disableToggle ? null : (
<SliderToggle
isToggled={argsEnabled > idx}
toggle={() => toggleKey(idx)}
/>
)}
<StorageArgInput
idx={idx}
hasher={hashers[idx]}
type={type}
disabled={argsEnabled <= idx}
/>
</li>
))}
</ol>
</div>
)
}
const builderState$ = state(
selectedBlock$.pipe(
map(({ ctx }) => ({
...ctx.dynamicBuilder,
lookup: ctx.lookup,
})),
),
null,
)
const keysCodec$ = combineLatest([keys$, builderState$]).pipe(
map(([keys, builder]) => keys.map((type) => builder?.buildDefinition(type))),
)
const argInputValue$ = state(
(idx: number) =>
keyValues$.pipe(
withLatestFrom(keysCodec$),
map(([v, codecs], i): CodecComponentValue => {
if (i === 0) {
try {
return {
type: CodecComponentType.Initial,
value: codecs[idx]?.enc(v[idx]),
}
} catch {
return {
type: CodecComponentType.Initial,
}
}
}
return {
type: CodecComponentType.Updated,
value:
v[idx] === NOTIN
? {
empty: true,
}
: {
empty: false,
decoded: v[idx],
},
}
}),
),
{
type: CodecComponentType.Initial,
} satisfies CodecComponentValue,
)
const StorageArgInput: FC<{
idx: number
type: number
disabled: boolean
hasher: string
}> = ({ idx, type, disabled, hasher }) => {
const builder = useStateObservable(builderState$)
const value = useStateObservable(argInputValue$(idx))
if (!builder) return null
const codec = builder.buildDefinition(type)
const getBinValue = () => {
try {
return (
(value.type === CodecComponentType.Initial
? value.value
: value.value.empty
? null
: (value.value.encoded ?? codec.enc(value.value.decoded))) ?? null
)
} catch (ex) {
console.error(ex)
}
}
const binaryValue = getBinValue()
const getTypeName = () => {
const lookupEntry = builder.lookup(type)
switch (lookupEntry.type) {
case "primitive":
return lookupEntry.value
case "compact":
return lookupEntry.size
case "enum":
return "Enum"
case "array":
if (
lookupEntry.value.type === "primitive" &&
lookupEntry.value.value === "u8"
) {
return "Binary"
}
return null
case "bitSequence":
case "AccountId20":
case "AccountId32":
return lookupEntry.type
default:
return null
}
}
return (
<div
className={twMerge(
"border-l px-2",
disabled && "pointer-events-none opacity-50",
)}
>
<div className="flex justify-between">
<div>
{getTypeName()} ({hasher})
</div>
<BinaryEditButton
initialValue={
typeof binaryValue === "string"
? fromHex(binaryValue)
: (binaryValue ?? undefined)
}
onValueChange={(value) => setKeyValue({ idx, value })}
decode={codec.dec}
/>
</div>
<EditCodec
metadata={builder.lookup.metadata}
codecType={type}
value={value}
onUpdate={(value) =>
setKeyValue({ idx, value: value.empty ? NOTIN : value.decoded })
}
/>
</div>
)
}
const keyCodec$ = state(
combineLatest([selectedBlock$, storageEntryState.selectedEntry$]).pipe(
map(([{ ctx }, selectedEntry]) =>
selectedEntry
? ctx.dynamicBuilder.buildStorage(
selectedEntry.pallet,
selectedEntry.entry,
).keys
: null,
),
),
)
export const encodedKey$ = state(
combineLatest([keyCodec$, keyValues$, argsEnabled$]).pipe(
map(([codec, keyValues, keysEnabled]) => {
const args = keyValues.slice(0, keysEnabled)
if (
keyValues.length < keysEnabled ||
!args.every((v) => v !== NOTIN) ||
!codec
) {
return null
}
try {
return codec.enc(...args)
} catch (_) {
return null
}
}),
),
null,
)
export const [selectedKeyChange$, setSelectedKey] = createSignal<{
type: "fromValue" | "raw"
key: string
}>()
const selectedKey$ = state(
merge(
encodedKey$.pipe(
distinctUntilChanged(),
map((key) => (key ? { type: "fromValue", key } : null)),
),
selectedKeyChange$,
),
)
const isReady$ = state(
selectedKey$.pipe(map((v) => v && v.key.length > 2)),
false,
)
const [keyInputChange$, onKeyInputChange] =
createSignal<ChangeEvent<HTMLInputElement>>()
const [keyInputBlur$, onKeyInputBlur] = createSignal<void>()
const keyInput$ = state(
merge(
keyInputChange$.pipe(
map((v) => v.currentTarget.value),
withLatestFrom(selectedBlock$.pipe(map((v) => v.ctx))),
map(([value, ctx]) => {
const error =
value.length > 3 &&
(() => {
try {
return !decodeKeyTarget(ctx, Binary.fromHex(value))
} catch {
return true
}
})()
return { value, error }
}),
),
merge(
selectedKey$,
keyInputBlur$.pipe(switchMap(() => selectedKey$.pipe(take(1)))),
).pipe(map((value) => ({ value: value?.key ?? "", error: false }))),
),
{
value: "",
error: false,
},
)
export const KeyInput: FC = () => {
const keyInput = useStateObservable(keyInput$)
const builder = useStateObservable(builderState$)
const selectedEntry = useStateObservable(storageEntryState.selectedEntry$)
const keysEnabled = useStateObservable(argsEnabled$)
if (!builder || !selectedEntry) return null
return (
<div className="w-full">
<label>Encoded key</label>
<Input
type="text"
className={twMerge(
"min-w-0 w-full tabular-nums text-muted-foreground",
keyInput.error && "text-red-600",
)}
placeholder="Fill in all the storage keys to calculate the encoded key"
value={keyInput.value}
onChange={onKeyInputChange}
onBlur={() => {
onKeyInputBlur()
try {
const ctx = {
dynamicBuilder: builder,
lookup: builder.lookup,
}
const target = decodeKeyTarget(ctx, Binary.fromHex(keyInput.value))
if (!target) return
let newKeysEnabled = keysEnabled
if (
target.pallet.name !== selectedEntry.pallet ||
target.item.name !== selectedEntry.entry
) {
storageEntryState.selectEntry({
group: target.pallet.name,
item: target.item.name,
})
newKeysEnabled =
target.item.type.tag === "plain"
? 0
: target.item.type.value.hashers.length
}
const args = decodeKeyArgs(ctx, target)
if (!args) {
setKeysEnabled(0)
setSelectedKey({
type: "raw",
key: keyInput.value,
})
return
}
if (args.length !== newKeysEnabled) {
if (args.length > newKeysEnabled) {
toggleKey(args.length - 1)
} else {
toggleKey(args.length)
}
}
args.forEach((value, idx) =>
setKeyValue({
idx,
value,
}),
)
} catch {
return
}
}}
/>
</div>
)
}