feat: partial key storage getEntries (#182)

This commit is contained in:
Victor Oliva
2026-09-01 15:24:03 +02:00
committed by GitHub
parent d13ae9e728
commit 0fa2b88371
4 changed files with 154 additions and 92 deletions

View File

@@ -25,11 +25,12 @@ import {
scan,
startWith,
switchMap,
take,
withLatestFrom,
} from "rxjs"
import { twMerge } from "tailwind-merge"
import { selectedBlock$ } from "./BlockPicker"
import { decodeKey } from "./decodeKey"
import { decodeKeyArgs, decodeKeyTarget } from "./decodeKey"
import { addStorageSubscription, storageEntryState } from "./storage.state"
export const StorageQuery: FC = () => {
@@ -37,23 +38,35 @@ export const StorageQuery: FC = () => {
const navigate = useNavigate()
const submit = async () => {
const [entry, keyValues, keysEnabled, { hash }] = await firstValueFrom(
combineLatest([
storageEntryState.selectedEntry$,
keyValues$,
argsEnabled$,
selectedBlock$,
]),
)
const args = keyValues.slice(0, keysEnabled)
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}`)
const id = await addStorageSubscription({
blockHash: hash ?? null,
pallet: entry!.pallet,
item: entry!.entry,
value: Enum("query", args),
})
navigate(`/storage/${id}`)
}
}
return (
@@ -136,17 +149,6 @@ export const keyValues$ = keys$.pipeState(
withDefault([] as unknown[]),
)
const isReady$ = state(
combineLatest([keyValues$, argsEnabled$]).pipe(
map(
([keyValues, keysEnabled]) =>
keyValues.length >= keysEnabled &&
keyValues.slice(0, keysEnabled).every((v) => v !== NOTIN),
),
),
false,
)
export const StorageKeysInput: FC<{
disableToggle?: boolean
}> = ({ disableToggle }) => {
@@ -348,6 +350,26 @@ export const encodedKey$ = state(
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>()
@@ -361,7 +383,7 @@ const keyInput$ = state(
value.length > 3 &&
(() => {
try {
return !decodeKey(ctx, Binary.fromHex(value))
return !decodeKeyTarget(ctx, Binary.fromHex(value))
} catch {
return true
}
@@ -370,9 +392,10 @@ const keyInput$ = state(
return { value, error }
}),
),
merge(encodedKey$, keyInputBlur$.pipe(switchMap(() => encodedKey$))).pipe(
map((value) => ({ value: value ?? "", error: false })),
),
merge(
selectedKey$,
keyInputBlur$.pipe(switchMap(() => selectedKey$.pipe(take(1)))),
).pipe(map((value) => ({ value: value?.key ?? "", error: false }))),
),
{
value: "",
@@ -404,39 +427,47 @@ export const KeyInput: FC = () => {
onKeyInputBlur()
try {
const decoded = decodeKey(
{
dynamicBuilder: builder,
lookup: builder.lookup,
},
Binary.fromHex(keyInput.value),
)
if (!decoded) return
const ctx = {
dynamicBuilder: builder,
lookup: builder.lookup,
}
const target = decodeKeyTarget(ctx, Binary.fromHex(keyInput.value))
if (!target) return
let newKeysEnabled = keysEnabled
if (
decoded.pallet.name !== selectedEntry.pallet ||
decoded.item.name !== selectedEntry.entry
target.pallet.name !== selectedEntry.pallet ||
target.item.name !== selectedEntry.entry
) {
storageEntryState.selectEntry({
group: decoded.pallet.name,
item: decoded.item.name,
group: target.pallet.name,
item: target.item.name,
})
newKeysEnabled =
decoded.item.type.tag === "plain"
target.item.type.tag === "plain"
? 0
: decoded.item.type.value.hashers.length
: target.item.type.value.hashers.length
}
if (decoded.args.length !== newKeysEnabled) {
if (decoded.args.length > newKeysEnabled) {
toggleKey(decoded.args.length - 1)
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(decoded.args.length)
toggleKey(args.length)
}
}
decoded.args.forEach((value, idx) =>
args.forEach((value, idx) =>
setKeyValue({
idx,
value,

View File

@@ -32,7 +32,7 @@ import {
stringifyArg,
} from "./storage.state"
import { setValue } from "./StorageDecode"
import { setKeysEnabled, setKeyValue } from "./StorageQuery"
import { setKeysEnabled, setKeyValue, setSelectedKey } from "./StorageQuery"
export const StorageSubscriptions: FC = () => {
const params = useParams()
@@ -174,8 +174,7 @@ const ValuesSubscriptionBox: FC<{ subscription: string }> = ({
? status.value.length - 1
: status.value.findIndex((v) => v.height > target) - 1
const targetValue = status.value[targetValueIdx] as
| StorageSubscriptionValue
| undefined
StorageSubscriptionValue | undefined
const hasNext = targetValueIdx < status.value.length - 1
const hasPrev = targetValueIdx > 0
@@ -494,13 +493,19 @@ const useSynchronizeInputs = (id: string) => {
group: params.pallet,
item: params.item,
})
setMode(params.value.type)
setMode(params.value.type === "rawQuery" ? "query" : params.value.type)
// Let entry settle
await Promise.resolve()
if (cancelled) return
if (params.value.type === "query") {
if (cancelled) return
setKeysEnabled(params.value.value.length)
params.value.value.forEach((value, idx) => setKeyValue({ idx, value }))
} else if (params.value.type === "rawQuery") {
setKeysEnabled(0)
setSelectedKey({
type: "raw",
key: params.value.value,
})
} else {
setValue(params.value.value)
}

View File

@@ -3,6 +3,7 @@ import { Twox128 } from "@polkadot-api/substrate-bindings"
import { fromHex, toHex } from "polkadot-api/utils"
const textEncoder = new TextEncoder()
const twoxHash = (value: string) => toHex(Twox128(textEncoder.encode(value)))
const hashersToLength: Record<string, number> = {
Identity: 0,
Twox64Concat: 8,
@@ -13,12 +14,10 @@ const hashersToLength: Record<string, number> = {
Twox256: -32,
}
export const decodeKey = (
ctx: Pick<RuntimeContext, "lookup" | "dynamicBuilder">,
export const decodeKeyTarget = (
ctx: Pick<RuntimeContext, "lookup">,
key: Uint8Array,
) => {
const twoxHash = (v: string) => toHex(Twox128(textEncoder.encode(v)))
const keyHex = toHex(key)
const pallet = ctx.lookup.metadata.pallets.find(
(p) => p.storage && keyHex.startsWith(twoxHash(p.storage.prefix)),
@@ -31,6 +30,17 @@ export const decodeKey = (
)
if (!item) return null
return { pallet, item, keyRemaining }
}
export const decodeKeyArgs = (
ctx: Pick<RuntimeContext, "dynamicBuilder">,
{
pallet,
item,
keyRemaining,
}: NonNullable<ReturnType<typeof decodeKeyTarget>>,
) => {
const codec = ctx.dynamicBuilder.buildStorage(pallet.name, item.name)
const hasherLengths =
item.type.tag === "plain"
@@ -56,6 +66,8 @@ export const decodeKey = (
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)
// If reencoding ends up with a longer key, it means the key was actually cut out
if (reEnc.length > argsRemaining.length) return null
argsRemaining = argsRemaining.slice(reEnc.length)
args.push(value)
} catch {
@@ -64,14 +76,8 @@ export const decodeKey = (
}
}
return {
pallet,
item,
entry: getEntry(ctx, item.type),
args,
}
return args
}
export type DecodedKey = ReturnType<typeof decodeKey>
export const getEntry = (
ctx: Pick<RuntimeContext, "lookup">,

View File

@@ -111,6 +111,7 @@ export type StorageSubscription = {
item: string
value: Enum<{
decode: HexString
rawQuery: HexString
query: unknown[]
}>
}
@@ -132,7 +133,7 @@ const storageSubscriptionId = (
sub.pallet,
sub.item,
sub.value.type,
...(sub.value.type === "decode"
...(sub.value.type === "decode" || sub.value.type === "rawQuery"
? [sub.value.value]
: sub.value.value.map((v, i) =>
Binary.toHex(
@@ -162,12 +163,14 @@ export const idToStorageSubscription = async (
value:
type === "decode"
? Enum("decode", values[0])
: Enum(
"query",
values.map((v, i) =>
ctx.dynamicBuilder.buildDefinition(entry.keys[i].type).dec(v),
: type === "rawQuery"
? Enum("rawQuery", values[0])
: Enum(
"query",
values.map((v, i) =>
ctx.dynamicBuilder.buildDefinition(entry.keys[i].type).dec(v),
),
),
),
}
}
@@ -177,38 +180,55 @@ export const storageSubscriptionToWorkspaceEntry = async ({
item,
value,
}: StorageSubscription): Promise<WorkspaceEntryData<StorageEntryContext>> => {
const [ctx, unsafeApi] = await firstValueFrom(
const [ctx, unsafeApi, chainHead] = await firstValueFrom(
combineLatest([
blockHash ? runtimeCtxAt$(blockHash) : runtimeCtx$,
genericUnsafeApi$,
chainClient$.pipe(map((v) => v.chainHead)),
]),
)
const entry = getEntry(ctx, getStorageItem(ctx, pallet, item)!.item.type)
const storageEntry = unsafeApi.query[pallet][item]
const name = `${pallet}.${item}`
const args = value.type === "query" ? value.value : []
const isEntries = value.type === "query" && args.length !== entry.keys.length
const isEntries =
(value.type === "query" && args.length !== entry.keys.length) ||
value.type === "rawQuery"
const at = (blockHash: string) => {
const value$ = from(
isEntries
? storageEntry.getEntries(...args, {
at: blockHash,
})
: storageEntry.getValue(...args, {
at: blockHash,
}),
)
const hash$ = chainClient$.pipe(
switchMap(({ chainHead }) =>
chainHead
.storage$(blockHash, "hash", (ctx) =>
ctx.dynamicBuilder.buildStorage(pallet, item).keys.enc(...args),
const value$ =
value.type === "rawQuery"
? chainHead
.storage$(
blockHash,
"descendantsValues",
() => value.value,
null,
(values, ctx) => {
const codecs = ctx.dynamicBuilder.buildStorage(pallet, item)
return values.map(({ key, value }) => ({
keyArgs: codecs.keys.dec(key),
value: codecs.value.dec(value),
}))
},
)
.pipe(map((r) => r.value))
: from(
isEntries
? storageEntry.getEntries(...args, {
at: blockHash,
})
: storageEntry.getValue(...args, {
at: blockHash,
}),
)
.pipe(map(({ value }) => value)),
),
)
const hash$ = chainHead
.storage$(blockHash, "hash", (ctx) =>
ctx.dynamicBuilder.buildStorage(pallet, item).keys.enc(...args),
)
.pipe(map(({ value }) => value))
const ctxType$ = runtimeCtxAt$(blockHash).pipe(
map((ctx) => {
const ctxEntry = getStorageItem(ctx, pallet, item)