Merge pull request #65 from polkadot-api/vo/storage-keys

feat: decode storage keys entry and partial keys
This commit is contained in:
Victor Oliva
2025-06-25 14:07:47 +02:00
committed by GitHub
3 changed files with 273 additions and 120 deletions
+11 -77
View File
@@ -5,12 +5,11 @@ 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 { FC, useState } from "react"
import { map } from "rxjs"
import { selectedEntry$, setSelectedEntry } from "./storage.state"
import { partialEntry$, selectedEntry$, selectEntry } from "./storage.state"
import { StorageDecode } from "./StorageDecode"
import { StorageQuery } from "./StorageQuery"
import { StorageSet } from "./StorageSet"
@@ -36,97 +35,32 @@ const metadataStorage$ = state(
export const Storage = withSubscribe(
() => {
const { lookup, entries } = useStateObservable(metadataStorage$)
const [pallet, setPallet] = useHashState("pallet", "System")
const [entry, setEntry] = useHashState("entry", "Account")
const { entries } = useStateObservable(metadataStorage$)
const partialEntry = useStateObservable(partialEntry$)
const selectedEntry = useStateObservable(selectedEntry$)
const selectedPallet =
(pallet && lookup.metadata.pallets.find((p) => p.name === pallet)) || null
useEffect(
() =>
setEntry((prev) => {
if (!selectedPallet?.storage?.items[0]) return null
return selectedPallet.storage.items.some((v) => v.name === prev)
? prev
: selectedPallet.storage.items[0].name
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedPallet?.name],
)
useEffect(() => {
const storageEntry =
(entry &&
selectedPallet?.storage?.items.find((it) => it.name === entry)) ||
null
const storageEntryType = storageEntry?.type
if (!storageEntryType) {
return setSelectedEntry(null)
}
if (storageEntryType.tag === "plain") {
return setSelectedEntry({
value: storageEntryType.value,
key: [],
pallet: pallet!,
entry: entry!,
docs: storageEntry.docs,
})
}
if (storageEntryType.value.hashers.length === 1) {
return setSelectedEntry({
value: storageEntryType.value.value,
key: [storageEntryType.value.key],
pallet: pallet!,
entry: entry!,
docs: storageEntry.docs,
})
}
const keyDef = lookup(storageEntryType.value.key)
const key = (() => {
if (keyDef.type === "array") {
return new Array(keyDef.len).fill(keyDef.value.id)
}
if (keyDef.type === "tuple") {
return keyDef.value.map((e) => e.id)
}
throw new Error("Invalid key type " + keyDef.type)
})()
setSelectedEntry({
key,
value: storageEntryType.value.value,
pallet: pallet!,
entry: entry!,
docs: storageEntry.docs,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedPallet, entry])
return (
<div className="p-4 pb-0 flex flex-col gap-2 items-start">
<div className="flex items-center gap-2">
<label>
Pallet
<SearchableSelect
value={pallet}
setValue={(v) => setPallet(v)}
value={partialEntry.pallet}
setValue={(v) => selectEntry({ pallet: v })}
options={Object.keys(entries).map((e) => ({
text: e,
value: e,
}))}
/>
</label>
{selectedPallet && pallet && (
{partialEntry.pallet && (
<label>
Entry
<SearchableSelect
value={entry}
setValue={(v) => setEntry(v)}
value={partialEntry.entry}
setValue={(v) => selectEntry({ entry: v })}
options={
Object.keys(entries[pallet]).map((s) => ({
Object.keys(entries[partialEntry.pallet]).map((s) => ({
text: s,
value: s,
})) ?? []
@@ -170,7 +104,7 @@ const StorageEntry: FC = () => {
},
{
value: "decode",
content: "Decode",
content: "Decode Value",
},
...(canSetStorage
? [
+151 -39
View File
@@ -1,18 +1,20 @@
import {
dynamicBuilder$,
runtimeCtx$,
unsafeApi$,
} from "@/state/chains/chain.state"
import { EditCodec } from "@/codec-components/EditCodec"
import { ActionButton } from "@/components/ActionButton"
import { BinaryEditButton } from "@/components/BinaryEditButton"
import { CopyText } from "@/components/Copy"
import SliderToggle from "@/components/Toggle"
import {
dynamicBuilder$,
runtimeCtx$,
unsafeApi$,
} from "@/state/chains/chain.state"
import {
CodecComponentType,
CodecComponentValue,
NOTIN,
} from "@polkadot-api/react-builder"
import { Twox128 } from "@polkadot-api/substrate-bindings"
import { toHex } from "@polkadot-api/utils"
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import { Binary } from "polkadot-api"
@@ -23,14 +25,17 @@ import {
firstValueFrom,
from,
map,
ObservedValueOf,
scan,
startWith,
switchMap,
withLatestFrom,
} from "rxjs"
import { twMerge } from "tailwind-merge"
import {
addStorageSubscription,
selectedEntry$,
selectEntry,
stringifyArg,
} from "./storage.state"
@@ -165,28 +170,40 @@ const builderState$ = state(
),
null,
)
const keysCodec$ = combineLatest([keys$, builderState$]).pipe(
map(([keys, builder]) => keys.map((type) => builder?.buildDefinition(type))),
)
const keyInputValue$ = state(
(idx: number) =>
keyValues$.pipe(
map(
(v, i): CodecComponentValue =>
i === 0
? {
type: CodecComponentType.Initial,
}
: {
type: CodecComponentType.Updated,
value:
v[idx] === NOTIN
? {
empty: true,
}
: {
empty: false,
decoded: v[idx],
},
},
),
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,
@@ -310,13 +327,10 @@ export const KeyDisplay: FC = () => {
const key = useStateObservable(encodedKey$)
const builder = useStateObservable(builderState$)
const selectedEntry = useStateObservable(selectedEntry$)
const keys = useStateObservable(keys$)
const keysEnabled = useStateObservable(keysEnabled$)
if (!builder || !selectedEntry) return null
const codec = builder.buildStorage(selectedEntry.pallet, selectedEntry.entry)
return (
<div className="flex w-full overflow-hidden border border-card-foreground/60 px-3 p-2 gap-2 items-center bg-card text-card-foreground">
<div className="shrink-0 text-sm font-bold">Encoded key:</div>
@@ -329,19 +343,117 @@ export const KeyDisplay: FC = () => {
{key ?? "Fill in all the storage keys to calculate the encoded key"}
</div>
<CopyText text={key ?? ""} disabled={key === null} binary />
{keys.length === keysEnabled && (
<BinaryEditButton
initialValue={key ? Binary.fromHex(key).asBytes() : undefined}
onValueChange={(value: unknown[]) => {
value.forEach((value, idx) => setKeyValue({ idx, value }))
}}
decode={(v) =>
codec.keys.dec(
typeof v === "string" ? v : Binary.fromBytes(v).asHex(),
)
<BinaryEditButton
initialValue={key ? Binary.fromHex(key).asBytes() : undefined}
onValueChange={(value: NonNullable<DecodedKey>) => {
let newKeysEnabled = keysEnabled
if (
value.pallet.name !== selectedEntry.pallet ||
value.item.name !== selectedEntry.entry
) {
selectEntry({
pallet: value.pallet.name,
entry: value.item.name,
})
newKeysEnabled =
value.item.type.tag === "plain"
? 0
: value.item.type.value.hashers.length
}
/>
)}
if (value.args.length !== newKeysEnabled) {
if (value.args.length > newKeysEnabled) {
toggleKey(value.args.length - 1)
} else {
toggleKey(value.args.length)
}
}
value.args.forEach((value, idx) =>
setKeyValue({
idx,
value,
}),
)
}}
decode={(v) => {
const decoded = decodeKey(builder, v)
if (!decoded) throw null
return decoded
}}
/>
</div>
)
}
const textEncoder = new TextEncoder()
const hashersToLength: Record<string, number> = {
Identity: 0,
Twox64Concat: 8,
Blake2128Concat: 16,
Blake2128: -16,
Blake2256: -32,
Twox128: -16,
Twox256: -32,
}
const decodeKey = (
builder: NonNullable<ObservedValueOf<typeof builderState$>>,
key: Uint8Array,
) => {
const twoxHash = (v: string) => toHex(Twox128(textEncoder.encode(v)))
const keyHex = toHex(key)
const pallet = builder.lookup.metadata.pallets.find(
(p) => p.storage && keyHex.startsWith(twoxHash(p.storage.prefix)),
)
if (!pallet) return null
const keyRemaining = keyHex.replace(twoxHash(pallet.storage!.prefix), "0x")
const item = pallet.storage!.items.find((v) =>
keyRemaining.startsWith(twoxHash(v.name)),
)
if (!item) return null
const codec = builder.buildStorage(pallet.name, item.name)
const hasherLengths =
item.type.tag === "plain"
? []
: item.type.value.hashers.map((x) => hashersToLength[x.tag])
let argsRemaining = Binary.fromHex(
keyRemaining.replace(twoxHash(item.name), "0x"),
).asBytes()
const args: any[] = []
const argsLen = codec.args.inner.length
for (let i = 0; i < argsLen && argsRemaining.length; i++) {
const hashLength = hasherLengths[i]
if (argsRemaining.length < Math.abs(hashLength)) return null
argsRemaining = argsRemaining.slice(Math.abs(hashLength))
if (hashLength < 0) {
// Signals a non-reversible hasher
args.push(null)
} else {
const argCodec = codec.args.inner[i]
try {
const value = argCodec.dec(argsRemaining)
// This is needed not just for the length, but see case AccountId: Can decode 0x, but then can't re-encode back. <- TODO bug?
const reEnc = argCodec.enc(value)
argsRemaining = argsRemaining.slice(reEnc.length)
args.push(value)
} catch {
return null
}
}
}
return {
pallet,
item,
args,
}
}
type DecodedKey = ReturnType<typeof decodeKey>
+111 -4
View File
@@ -1,9 +1,11 @@
import { bytesToString } from "@/components/BinaryInput"
import { selectedChainChanged$ } from "@/state/chains/chain.state"
import { getHashParams } from "@/hashParams"
import { runtimeCtx$, selectedChainChanged$ } from "@/state/chains/chain.state"
import { state } from "@react-rxjs/core"
import {
createKeyedSignal,
createSignal,
mergeWithKey,
partitionByKey,
toKeySet,
} from "@react-rxjs/utils"
@@ -22,6 +24,7 @@ import {
startWith,
switchMap,
takeUntil,
withLatestFrom,
} from "rxjs"
import { v4 as uuid } from "uuid"
@@ -33,9 +36,113 @@ export type StorageMetadataEntry = {
docs: string[]
}
export const [entryChange$, setSelectedEntry] =
createSignal<StorageMetadataEntry | null>()
export const selectedEntry$ = state(entryChange$, null)
export const [entryChange$, selectEntry] = createSignal<{
pallet?: string | null
entry?: string | null
}>()
const palletEntries$ = runtimeCtx$.pipe(
map((ctx) =>
Object.fromEntries(
ctx.lookup.metadata.pallets.map((p) => [p.name, p.storage?.items ?? []]),
),
),
)
const initialValue$ = palletEntries$.pipe(
map(() => {
const params = getHashParams()
const pallet = params.get("pallet") ?? "System"
const entry = params.get("entry") ?? "Account"
return { entry, pallet }
}),
)
export const partialEntry$ = state(
mergeWithKey({ entryChange$, initialValue$ }).pipe(
withLatestFrom(palletEntries$),
scan(
(acc, [evt, pallets]) => {
const newValue =
evt.type === "entryChange$"
? { ...acc, ...evt.payload }
: {
pallet: acc.pallet ?? evt.payload.pallet,
entry: acc.entry ?? evt.payload.entry,
}
const selectedPallet = newValue.pallet ? pallets[newValue.pallet] : null
if (!selectedPallet?.find((it) => it.name === newValue.entry)) {
newValue.entry = selectedPallet?.[0]?.name ?? null
}
return newValue
},
{
pallet: null as string | null,
entry: null as string | null,
},
),
),
{
pallet: null,
entry: null,
},
)
export const selectedEntry$ = state(
partialEntry$.pipe(
withLatestFrom(palletEntries$, runtimeCtx$),
map(([partialEntry, entries, ctx]): StorageMetadataEntry | null => {
const entry = partialEntry.pallet
? entries[partialEntry.pallet]?.find(
(v) => v.name === partialEntry.entry,
)
: null
if (!entry?.type) return null
const { type, docs } = entry
const pallet = partialEntry.pallet!
if (type.tag === "plain") {
return {
value: type.value,
key: [],
pallet,
entry: entry.name,
docs,
}
}
if (type.value.hashers.length === 1) {
return {
value: type.value.value,
key: [type.value.key],
pallet,
entry: entry.name,
docs,
}
}
const keyDef = ctx.lookup(type.value.key)
const key = (() => {
if (keyDef.type === "array") {
return new Array(keyDef.len).fill(keyDef.value.id)
}
if (keyDef.type === "tuple") {
return keyDef.value.map((e) => e.id)
}
throw new Error("Invalid key type " + keyDef.type)
})()
return {
key,
value: type.value.value,
pallet,
entry: entry.name,
docs,
}
}),
),
null,
)
export const [newStorageSubscription$, addStorageSubscription] = createSignal<{
name: string