diff --git a/src/pages/Storage/StorageQuery.tsx b/src/pages/Storage/StorageQuery.tsx index 3896b2c..a446873 100644 --- a/src/pages/Storage/StorageQuery.tsx +++ b/src/pages/Storage/StorageQuery.tsx @@ -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>() const [keyInputBlur$, onKeyInputBlur] = createSignal() @@ -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, diff --git a/src/pages/Storage/StorageSubscriptions.tsx b/src/pages/Storage/StorageSubscriptions.tsx index 0bcb49f..af3a510 100644 --- a/src/pages/Storage/StorageSubscriptions.tsx +++ b/src/pages/Storage/StorageSubscriptions.tsx @@ -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) } diff --git a/src/pages/Storage/decodeKey.ts b/src/pages/Storage/decodeKey.ts index 5635cd8..7d52ec7 100644 --- a/src/pages/Storage/decodeKey.ts +++ b/src/pages/Storage/decodeKey.ts @@ -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 = { Identity: 0, Twox64Concat: 8, @@ -13,12 +14,10 @@ const hashersToLength: Record = { Twox256: -32, } -export const decodeKey = ( - ctx: Pick, +export const decodeKeyTarget = ( + ctx: Pick, 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, + { + pallet, + item, + keyRemaining, + }: NonNullable>, +) => { 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 export const getEntry = ( ctx: Pick, diff --git a/src/pages/Storage/storage.state.ts b/src/pages/Storage/storage.state.ts index 3107152..8679fe2 100644 --- a/src/pages/Storage/storage.state.ts +++ b/src/pages/Storage/storage.state.ts @@ -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> => { - 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)