Merge remote-tracking branch 'origin/main' into vo/storage-sub

# Conflicts:
#	src/pages/Explorer/block.state.ts
This commit is contained in:
Victor Oliva
2025-11-16 15:57:28 -03:00
26 changed files with 2005 additions and 823 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "0.1.0-autogenerated.9612188710233485064",
"version": "0.1.0-autogenerated.8379875965201711085",
"name": "@polkadot-api/descriptors",
"files": [
"dist"
+9 -9
View File
@@ -16,10 +16,11 @@
"dependencies": {
"@acala-network/chopsticks-core": "^1.2.4",
"@ledgerhq/hw-transport-webusb": "^6.29.13",
"@monaco-editor/react": "^4.7.0",
"@polkadot-api/descriptors": "file:.papi/descriptors",
"@polkadot-api/json-rpc-provider-proxy": "^0.2.6",
"@polkadot-api/json-rpc-provider-proxy": "^0.2.7",
"@polkadot-api/metadata-builders": "^0.13.7",
"@polkadot-api/observable-client": "^0.16.1",
"@polkadot-api/observable-client": "^0.16.2",
"@polkadot-api/react-builder": "0.3.9",
"@polkadot-api/react-components": "^0.4.1",
"@polkadot-api/substrate-bindings": "^0.16.5",
@@ -40,17 +41,16 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@react-rxjs/core": "^0.10.8",
"@react-rxjs/utils": "^0.9.7",
"buffer": "^6.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"idb-keyval": "^6.2.2",
"lucide-react": "^0.553.0",
"polkadot-api": "^1.20.4",
"polkadot-api": "^1.20.6",
"polkahub": "0.3.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-portal": "^4.3.0",
"react-router-dom": "^7.9.5",
"react-router-dom": "^7.9.6",
"react-svg": "^16.3.0",
"react-virtuoso": "^4.14.1",
"react18-json-view": "^0.2.9",
@@ -63,11 +63,11 @@
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.17",
"@types/node": "^24.10.0",
"@types/react": "^19.2.3",
"@types/react-dom": "^19.2.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/react-portal": "^4.0.7",
"@vitejs/plugin-react": "^5.1.0",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
+1021 -736
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "chain_getBlock params",
"oneOf": [
{
"type": "object",
"description": "{} or { hash }.",
"maxProperties": 1,
"additionalProperties": false,
"properties": { "hash": { "$ref": "./common.json#/$defs/hex" } }
},
{
"type": "array",
"description": "[] or [hash].",
"maxItems": 1,
"items": { "$ref": "./common.json#/$defs/hex" }
}
]
}
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "chain_getBlockHash params",
"oneOf": [
{
"type": "array",
"description": "[] or [blockNumber].",
"maxItems": 1,
"items": {
"type": "number"
}
}
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "chain_getHeader params",
"oneOf": [
{
"type": "object",
"description": "{} or { hash }.",
"maxProperties": 1,
"additionalProperties": false,
"properties": { "hash": { "$ref": "./common.json#/$defs/hex" } }
},
{
"type": "array",
"description": "[] or [hash].",
"maxItems": 1,
"items": { "$ref": "./common.json#/$defs/hex" }
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "common.json",
"$defs": {
"hex": {
"type": "string",
"pattern": "^0x[0-9a-fA-F]*$",
"description": "0x-prefixed hex."
}
}
}
@@ -0,0 +1,35 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "state_getReadProof params",
"oneOf": [
{
"type": "array",
"description": "[keys] or [keys, at].",
"minItems": 1,
"maxItems": 2,
"prefixItems": [
{
"type": "array",
"description": "Storage keys to prove.",
"items": { "$ref": "./common.json#/$defs/hex" }
},
{ "$ref": "./common.json#/$defs/hex" }
]
},
{
"type": "object",
"description": "{ keys } or { keys, at } or { keys, hash }.",
"required": ["keys"],
"additionalProperties": false,
"properties": {
"keys": {
"type": "array",
"description": "Storage keys to prove.",
"items": { "$ref": "./common.json#/$defs/hex" }
},
"at": { "$ref": "./common.json#/$defs/hex" },
"hash": { "$ref": "./common.json#/$defs/hex" }
}
}
]
}
+3 -1
View File
@@ -1,3 +1,4 @@
import { VaultTxModal } from "polkahub"
import { Navigate, Route, Routes } from "react-router-dom"
import { Accounts } from "./pages/Accounts/Accounts"
import { Constants } from "./pages/Constants"
@@ -9,7 +10,7 @@ import { RpcCalls } from "./pages/RpcCalls"
import { RuntimeCalls } from "./pages/RuntimeCalls"
import { Storage } from "./pages/Storage"
import { Transactions } from "./pages/Transactions"
import { VaultTxModal } from "polkahub"
import { ViewFns } from "./pages/ViewFns"
export default function App() {
return (
@@ -26,6 +27,7 @@ export default function App() {
<Route path="rpcCalls/*" element={<RpcCalls />} />
<Route path="metadata/*" element={<Metadata />} />
<Route path="accounts/*" element={<Accounts />} />
<Route path="viewFns/*" element={<ViewFns />} />
<Route path="*" element={<Navigate to="/explorer" replace />} />
</Routes>
</div>
@@ -1,6 +1,7 @@
import { lookupToType, TypeIcon, TypeIcons } from "@/components/Icons"
import { getLookupFn, Var } from "@polkadot-api/metadata-builders"
import { UnifiedMetadata } from "@polkadot-api/substrate-bindings"
import { ArrowLeftToLine, ArrowRightToLine } from "lucide-react"
import { FC, ReactElement } from "react"
import { twMerge as clsx } from "tailwind-merge"
@@ -9,12 +10,23 @@ export const FocusPath: FC<{
typeId: number
value: string[] | null
onFocus: (value: string[] | null) => void
}> = ({ metadata, typeId, value, onFocus }) => {
collapsed?: boolean
onToggleCollapse?: () => void
}> = ({ metadata, typeId, value, onFocus, collapsed, onToggleCollapse }) => {
const breadcrumbs = getBreadcrumbs(metadata, typeId, value ?? [], onFocus)
return (
<div className="shrink-0 px-2">
<div className="flex max-w-full flex-wrap gap-1 items-center border border-border text-sm px-2 py-1">
{onToggleCollapse ? (
<button onClick={onToggleCollapse} className="max-sm:hidden">
{collapsed ? (
<ArrowRightToLine size={16} />
) : (
<ArrowLeftToLine size={16} />
)}
</button>
) : null}
{breadcrumbs.map((v, i) => (
<span key={i} className="whitespace-nowrap flex gap-1 items-center">
{i > 0 && <span className="text-foreground/50 mx-2">&gt;</span>}
@@ -20,6 +20,7 @@ import { EditCodec } from "../EditCodec"
import { TreeCodec } from "../EditCodec/Tree"
import { BinaryDisplay } from "./BinaryDisplay"
import { FocusPath } from "./FocusPath"
import { cn } from "@/lib/utils"
const editTypeMetadataProps$ = state(
runtimeCtx$.pipe(
@@ -41,6 +42,7 @@ export const LookupTypeEdit: FC<{
const [treeRef, setTreeRef] = useState<HTMLDivElement | null>(null)
const [listRef, setListRef] = useState<HTMLDivElement | null>(null)
const [focusingSubtree, setFocusingSubtree] = useState<string[] | null>(null)
const [collapsed, setCollapsed] = useState(false)
useEffect(() => {
if (!listRef || !treeRef) return
@@ -65,6 +67,8 @@ export const LookupTypeEdit: FC<{
typeId={type}
value={focusingSubtree}
onFocus={setFocusingSubtree}
collapsed={collapsed}
onToggleCollapse={() => setCollapsed((c) => !c)}
/>
)}
<SubtreeFocus
@@ -78,7 +82,12 @@ export const LookupTypeEdit: FC<{
{tree && (
<div
ref={setTreeRef}
className="w-96 sticky top-0 pl-2 pb-16 leading-loose overflow-hidden max-sm:hidden"
className={cn(
"w-96 sticky top-0 pl-2 pb-16 leading-loose overflow-hidden max-sm:hidden",
{
hidden: collapsed,
},
)}
>
<div className="relative">
<TreeCodec {...codecProps} />
+14
View File
@@ -4,6 +4,7 @@ import { Binary } from "@polkadot-api/substrate-bindings"
import { FileUp } from "lucide-react"
import { FC, useState } from "react"
import { twMerge } from "tailwind-merge"
import { SwitchBinary } from "./Icons"
import { TextInputField } from "./TextInputField"
import { useGenericSynchronizeInput } from "./useSynchroniseInput"
@@ -76,6 +77,19 @@ export const BinaryInput: React.FC<{
uploadError ? "border-red-600" : null,
)}
>
<button
className="align-middle mr-2 cursor-pointer text-foreground/90"
type="button"
onClick={() => {
if (inputValue.startsWith("0x")) {
setLocalInput(serializeValue(Binary.fromHex(inputValue)))
} else {
setLocalInput(Binary.fromText(inputValue).asHex())
}
}}
>
<SwitchBinary size={24} />
</button>
{input}
<label title="Load from file" className="text-foreground/80">
<FileUp size={16} />
-2
View File
@@ -1,5 +1,3 @@
import { Buffer } from "buffer"
globalThis.Buffer = Buffer
import { dynamicBuilder$ } from "@/state/chains/chain.state"
import { RemoveSubscribe, Subscribe } from "@react-rxjs/core"
import { PolkaHubProvider } from "polkahub"
+154
View File
@@ -0,0 +1,154 @@
import { client$ } from "@/state/chains/chain.state"
import { Polkadot_people } from "@polkadot-api/descriptors"
import {
_void,
Bytes,
HexString,
Struct,
u32,
u64,
Variant,
} from "@polkadot-api/substrate-bindings"
import { state, useStateObservable } from "@react-rxjs/core"
import { BlockHeader, PolkadotClient } from "polkadot-api"
import { AddressIdentity } from "polkahub"
import { FC, useEffect } from "react"
import {
catchError,
combineLatest,
from,
map,
Observable,
switchMap,
take,
} from "rxjs"
import { blockInfo$, BlockState, finalized$ } from "../block.state"
const validatorCache: WeakMap<
PolkadotClient,
Record<number, string[]>
> = new WeakMap()
const fetchValidators = (
client: PolkadotClient,
at: HexString,
): Promise<string[]> =>
client.getUnsafeApi<Polkadot_people>().query.Session.Validators.getValue({
at,
})
const fetchCachedValidators$ = (client: PolkadotClient, at: HexString) => {
const api = client.getUnsafeApi<Polkadot_people>()
return from(api.query.Session.CurrentIndex.getValue()).pipe(
switchMap(async (idx) => {
const cache = validatorCache.get(client) ?? {}
validatorCache.set(client, cache)
const cached = cache[idx]
if (cached) return { idx, validators: cached }
const validators = await fetchValidators(client, at)
return { idx, validators }
}),
)
}
const validators$ = state(
(block: HexString) =>
client$.pipe(
switchMap((client) =>
combineLatest([
fetchCachedValidators$(client, block),
blockInfo$(block).pipe(map((v) => v.status)),
]).pipe(
map(([{ idx, validators }, blockStatus]) => {
if (
blockStatus === BlockState.Finalized ||
blockStatus === BlockState.Unknown
) {
const cache = validatorCache.get(client) ?? {}
validatorCache.set(client, cache)
cache[idx] = validators
}
return validators
}),
withHodl(() => client.hodlBlock(block)),
take(1),
catchError(() => [null]),
),
),
),
null,
)
// Subscribe to the validators of the finalized block so that best blocks author's of the same session will load faster
const finalizedValidators$ = finalized$.pipe(
switchMap((block) => validators$(block.hash)),
)
export const BlockAuthor: FC<{ hash: HexString; header: BlockHeader }> = ({
hash,
header,
}) => {
const validators = useStateObservable(validators$(hash))
const idx = getAuthorityIdx(header)
useEffect(() => {
const sub = finalizedValidators$.subscribe()
return () => sub.unsubscribe()
}, [])
if (idx == null || validators == null) return <div className="h-10" />
return (
<AddressIdentity
addr={validators[Number(idx % BigInt(validators.length))]}
/>
)
}
const DigestWithVRF = Struct({
authority_index: u32,
slot: u64,
vrf_signature: Struct({
pre_output: Bytes(32),
proof: Bytes(64),
}),
})
const BabePreDigest = Variant({
Uknown: _void,
Primary: DigestWithVRF,
SecondaryPlain: Struct({
authority_index: u32,
slot: u64,
}),
SecondaryVRF: DigestWithVRF,
})
const getAuthorityIdx = (header: BlockHeader) => {
const preRuntime = header.digests.find((d) => d.type === "preRuntime")?.value
switch (preRuntime?.engine) {
case "BABE": {
const babe = BabePreDigest.dec(preRuntime.payload)
if (!babe.value) return null
return BigInt(babe.value.authority_index)
}
case "aura": {
return u64.dec(preRuntime.payload)
}
}
return null
}
const withHodl =
<T,>(hodl: () => () => void) =>
(source$: Observable<T>) =>
new Observable<T>((obs) => {
obs.add(source$.subscribe(obs))
try {
const hodled = hodl()
obs.add(hodled)
} catch (_) {
// Nothing, it might happen if the block is not pinned
}
return obs
})
+61 -37
View File
@@ -2,7 +2,7 @@ import { CopyText } from "@/components/Copy"
import { Link } from "@/hashParams"
import { state, useStateObservable } from "@react-rxjs/core"
import { combineKeys } from "@react-rxjs/utils"
import { FC } from "react"
import { FC, ReactNode } from "react"
import { filter, map, startWith, switchMap, take } from "rxjs"
import {
BlockInfo,
@@ -10,53 +10,77 @@ import {
blocksByHeight$,
BlockState,
} from "../block.state"
import { BlockAuthor } from "./BlockAuthor"
import { BlockStatusIcon, statusText } from "./BlockState"
export const BlockInfoView: FC<{
block: BlockInfo
}> = ({ block }) => (
<div className="p-2">
<div className="flex flex-wrap justify-between">
<h2 className="font-bold text-xl whitespace-nowrap overflow-hidden text-ellipsis">
Block {block.hash}
</h2>
<p className="text-xl">{block.number.toLocaleString()}</p>
</div>
<p className="flex gap-1 items-center py-1">
Status:
<BlockStatusIcon state={block.status} />
{statusText[block.status]}
</p>
<div className="flex flex-wrap justify-between">
<div className="flex flex-col">
<div>Parent</div>
<section className="space-y-4 px-4 py-2">
<header className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<p className="text-3xl font-semibold tracking-tight">
#{block.number.toLocaleString()}
</p>
<div className="flex flex-wrap items-center gap-2 text-sm font-mono text-foreground/90">
<span className="truncate max-w-full">{block.hash}</span>
<CopyText className="text-foreground/70" text={block.hash} binary />
</div>
</div>
<div className="flex flex-wrap items-center gap-3 md:justify-end">
<StatusChip state={block.status} />
</div>
</header>
<div className="grid gap-4 md:grid-cols-2">
<DetailTile label="Parent block">
<BlockLink hash={block.parent} />
</div>
<div className="flex flex-col items-end">
<div>Children</div>
</DetailTile>
<DetailTile label="Children">
<BlockChildren hash={block.hash} />
</div>
</DetailTile>
</div>
{block.header && (
<div className="text-foreground/80 py-2">
<p>
State root: {block.header.stateRoot.slice(0, 18)}{" "}
<CopyText
className="align-middle"
text={block.header.stateRoot}
binary
/>
</p>
<p>
Extrinsic root: {block.header.extrinsicRoot.slice(0, 18)}{" "}
<CopyText
className="align-middle"
text={block.header.extrinsicRoot}
binary
/>
</p>
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
<DetailTile label="Roots">
<HashPreview title="state" value={block.header.stateRoot} />
<HashPreview title="extrinsic" value={block.header.extrinsicRoot} />
</DetailTile>
<DetailTile label="Block author">
<BlockAuthor hash={block.hash} header={block.header} />
</DetailTile>
</div>
)}
</section>
)
const StatusChip: FC<{ state: BlockState }> = ({ state }) => (
<span className="inline-flex items-center gap-2 rounded-full border border-foreground/20 bg-foreground/2 px-4 py-2 text-sm font-medium">
<BlockStatusIcon size={20} state={state} />
{statusText[state]}
</span>
)
const HashPreview: FC<{ title: string; value: string }> = ({
title,
value,
}) => (
<div className="font-mono text-sm">
{title}: {value.slice(0, 18)}…
<CopyText className="align-middle" text={value} binary />
</div>
)
const DetailTile: FC<{ label: string; children: ReactNode }> = ({
label,
children,
}) => (
<div className="rounded-xl border border-foreground/10 bg-foreground/2 p-4">
<p className="text-xs uppercase tracking-widest text-foreground/60">
{label}
</p>
<div className="mt-2 text-base text-muted-foreground">{children}</div>
</div>
)
+21 -11
View File
@@ -1,9 +1,16 @@
import { chopsticksInstance$ } from "@/chopsticks/chopsticks"
import { chainClient$, client$ } from "@/state/chains/chain.state"
import { SystemEvent } from "@polkadot-api/observable-client"
import { blockHeader } from "@polkadot-api/substrate-bindings"
import { liftSuspense, state } from "@react-rxjs/core"
import { combineKeys, partitionByKey } from "@react-rxjs/utils"
import { FixedSizeBinary, HexString, PolkadotClient } from "polkadot-api"
import {
BlockHeader,
FixedSizeBinary,
HexString,
PolkadotClient,
BlockInfo as RawBlockInfo,
} from "polkadot-api"
import {
catchError,
combineLatest,
@@ -33,7 +40,6 @@ import {
toArray,
withLatestFrom,
} from "rxjs"
import { BlockInfo as RawBlockInfo } from "polkadot-api"
export const finalized$ = client$.pipeState(
switchMap((client) => client.finalizedBlock$),
@@ -52,13 +58,7 @@ export interface BlockInfo {
number: number
body: string[] | null
events: SystemEvent[] | null
header: {
parentHash: HexString
number: number
stateRoot: HexString
extrinsicRoot: HexString
digests: unknown[]
} | null
header: BlockHeader | null
status: BlockState
diff: Record<string, [string | null, string | null]> | null
}
@@ -175,6 +175,7 @@ const getUnpinnedBlockInfo$ = (hash: string): Observable<BlockInfo> =>
),
)
const digestCodec = blockHeader.inner.digests.inner
const getUnpinnedBlockInfoFallback$ = (
hash: string,
client: PolkadotClient,
@@ -185,7 +186,7 @@ const getUnpinnedBlockInfoFallback$ = (
block: {
extrinsics: HexString[]
header: {
digest: { logs: Array<unknown> }
digest: { logs: Array<string> }
extrinsicsRoot: string
number: HexString
parentHash: HexString
@@ -223,7 +224,16 @@ const getUnpinnedBlockInfoFallback$ = (
body: extrinsics,
events: null,
header: {
digests: header.digest.logs,
digests: header.digest.logs
.map((log) => {
try {
return digestCodec.dec(log)
} catch (ex) {
console.error(ex)
return null
}
})
.filter((v) => v != null),
extrinsicRoot: header.extrinsicsRoot,
number,
parentHash: header.parentHash,
+10 -1
View File
@@ -8,6 +8,7 @@ import { SubtreeFocus } from "@/codec-components/common/SubtreeFocus"
import { EditCodec } from "@/codec-components/EditCodec"
import { TreeCodec } from "@/codec-components/EditCodec/Tree/index"
import { FocusPath } from "@/codec-components/LookupTypeEdit"
import { cn } from "@/lib/utils"
import {
CodecComponentUpdate,
CodecComponentValue,
@@ -31,6 +32,7 @@ export const EditMode: React.FC<{
}, [])
const [focusingSubtree, setFocusingSubtree] = useState<string[] | null>(null)
const [collapsed, setCollapsed] = useState(false)
return (
<div className="flex flex-col items-start overflow-hidden">
@@ -39,6 +41,8 @@ export const EditMode: React.FC<{
typeId={codecType}
value={focusingSubtree}
onFocus={setFocusingSubtree}
collapsed={collapsed}
onToggleCollapse={() => setCollapsed((c) => !c)}
/>
<SubtreeFocus
value={{ callback: setFocusingSubtree, path: focusingSubtree }}
@@ -50,7 +54,12 @@ export const EditMode: React.FC<{
>
<div
ref={treeRef}
className="min-w-fit w-96 sticky top-0 pl-2 pb-16 leading-loose overflow-hidden max-sm:hidden text-sm"
className={cn(
"min-w-fit w-96 sticky top-0 pl-2 pb-16 leading-loose overflow-hidden max-sm:hidden text-sm",
{
hidden: collapsed,
},
)}
>
<div className="relative">
{!value.value ? (
+3
View File
@@ -82,6 +82,9 @@ export const Header = () => {
</NavLink>
))}
</nav>
<NavLink to="/viewFns" onClick={() => setIsOpen(false)}>
View Functions
</NavLink>
<NavLink to="/metadata" onClick={() => setIsOpen(false)}>
Metadata
</NavLink>
+49 -12
View File
@@ -1,8 +1,8 @@
import { CopyText } from "@/components/Copy"
import { SearchableSelect } from "@/components/Select"
import { UnifiedMetadata } from "@polkadot-api/substrate-bindings"
import { FC, useEffect, useState } from "react"
import { FC, Fragment, useState } from "react"
import { LookupLink } from "./Lookup"
import { CopyText } from "@/components/Copy"
type Pallet = UnifiedMetadata["pallets"][number]
export const Pallets: FC<{ pallets: Array<Pallet> }> = ({ pallets }) => {
@@ -22,7 +22,7 @@ export const Pallets: FC<{ pallets: Array<Pallet> }> = ({ pallets }) => {
/>
</label>
{pallet && (
<>
<Fragment key={pallet.name}>
<p>Index: {pallet.index}</p>
{pallet.storage && (
<div>
@@ -36,6 +36,12 @@ export const Pallets: FC<{ pallets: Array<Pallet> }> = ({ pallets }) => {
<PalletConstants pallet={pallet} />
</div>
)}
{pallet.viewFns.length > 0 && (
<div>
<h4>View Functions</h4>
<PalletViewFns pallet={pallet} />
</div>
)}
{pallet.calls != null && (
<div>
<h4>Calls</h4>
@@ -54,7 +60,7 @@ export const Pallets: FC<{ pallets: Array<Pallet> }> = ({ pallets }) => {
<LookupLink id={pallet.errors.type} />
</div>
)}
</>
</Fragment>
)}
</div>
)
@@ -68,10 +74,6 @@ type StorageEntry = Pallet["storage"] extends
const PalletStorage: FC<{ pallet: Pallet }> = ({ pallet }) => {
const [entry, setEntry] = useState<StorageEntry | null>(null)
useEffect(() => {
setEntry(null)
}, [pallet])
if (!pallet.storage) return null
const value =
@@ -129,10 +131,6 @@ type ConstantEntry = Pallet["constants"] extends Array<infer R> ? R : never
const PalletConstants: FC<{ pallet: Pallet }> = ({ pallet }) => {
const [entry, setEntry] = useState<ConstantEntry | null>(null)
useEffect(() => {
setEntry(null)
}, [pallet])
if (!pallet.constants.length) return null
return (
@@ -166,3 +164,42 @@ const PalletConstants: FC<{ pallet: Pallet }> = ({ pallet }) => {
</div>
)
}
type ViewFnEntry = Pallet["viewFns"] extends Array<infer R> ? R : never
const PalletViewFns: FC<{ pallet: Pallet }> = ({ pallet }) => {
const [entry, setEntry] = useState<ViewFnEntry | null>(null)
if (!pallet.viewFns.length) return null
return (
<div className="flex flex-col p-2 gap-2">
<SearchableSelect
value={entry}
setValue={setEntry}
options={pallet.viewFns.map((c) => ({
text: c.name,
value: c,
}))}
/>
{entry && (
<>
<div>
<h4>Inputs</h4>
<ol>
{entry.inputs.map((input) => (
<li key={input.name}>
<div>{input.name}</div>
<LookupLink id={input.type} />
</li>
))}
</ol>
</div>
<div>
<h4>Output</h4>
<LookupLink id={entry.output} />
</div>
</>
)}
</div>
)
}
+63 -11
View File
@@ -1,14 +1,16 @@
import { ActionButton } from "@/components/ActionButton"
import { LoadingMetadata } from "@/components/Loading"
import { SearchableSelect } from "@/components/Select"
import { Textarea } from "@/components/ui/textarea"
import { withSubscribe } from "@/components/withSuspense"
import { chainClient$ } from "@/state/chains/chain.state"
import { state, useStateObservable } from "@react-rxjs/core"
import { useState } from "react"
import { lazy, Suspense, useRef, useState } from "react"
import { firstValueFrom, map, switchMap } from "rxjs"
import { RpcCallResults } from "./RpcCallResults"
import { addRpcCallQuery } from "./rpcCalls.state"
import { useTheme } from "@/ThemeProvider"
const Editor = lazy(() => import("@monaco-editor/react"))
const chainRpcMethods$ = state(
chainClient$.pipe(
@@ -19,11 +21,19 @@ const chainRpcMethods$ = state(
),
)
const SCHEMA_URL = window.location.origin
export const RpcCalls = withSubscribe(
() => {
const methods = useStateObservable(chainRpcMethods$)
const [method, setMethod] = useState<string | null>(null)
const [params, setParams] = useState<string>("[\n\n]")
const setSchema = useRef<((method: string | null) => void) | null>(null)
const [method, _setMethod] = useState<string | null>(null)
const [params, setParams] = useState<string>("[\n \n]")
const theme = useTheme()
const setMethod = (method: string | null) => {
_setMethod(method)
setSchema.current?.(method)
}
const submit = async () => {
const { client } = await firstValueFrom(chainClient$)
@@ -54,14 +64,56 @@ export const RpcCalls = withSubscribe(
allowCustomValue
/>
</label>
<label className="w-full">
<div className="w-full">
JSON Payload
<Textarea
className="w-full font-mono text-sm"
value={params}
onChange={(e) => setParams(e.target.value)}
/>
</label>
<Suspense
fallback={<div className="border rounded h-80 w-full" />}
>
<Editor
className="border rounded h-80"
language="json"
onMount={(editor, monaco) => {
const model = editor.getModel()
if (!model) return
setSchema.current = (method) => {
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
schemas: method
? [
{
uri: `${SCHEMA_URL}/rpc_schemas/${method}.json`,
fileMatch: [model.uri.toString()],
},
]
: [],
enableSchemaRequest: true,
schemaRequest: "ignore",
})
monaco.languages.json.jsonDefaults.setModeConfiguration(
method
? { completionItems: true, diagnostics: true }
: {
completionItems: false,
diagnostics: true,
},
)
}
setSchema.current(method)
}}
value={params}
loading={<div className="border rounded p-1 h-80 w-full" />}
onChange={(v) => setParams(v ?? "[]")}
options={{
minimap: {
enabled: false,
},
lineNumbers: "off",
}}
theme={theme === "dark" ? "vs-dark" : "light"}
/>
</Suspense>
</div>
<ActionButton disabled={!isReady} onClick={submit}>
Call
</ActionButton>
+182
View File
@@ -0,0 +1,182 @@
import {
dynamicBuilder$,
lookup$,
unsafeApi$,
} from "@/state/chains/chain.state"
import {
InlineLookupTypeEdit,
LookupTypeEdit,
} from "@/codec-components/LookupTypeEdit"
import { ActionButton } from "@/components/ActionButton"
import { ExpandBtn } from "@/components/Expand"
import { getTypeComplexity } from "@/utils/shape"
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import { Circle, Dot } from "lucide-react"
import { FC, useState } from "react"
import {
combineLatest,
filter,
firstValueFrom,
map,
scan,
startWith,
switchMap,
} from "rxjs"
import { twMerge } from "tailwind-merge"
import { addViewFnCall, selectedEntry$ } from "./viewFns.state"
export const ViewFnQuery: FC = () => {
const selectedEntry = useStateObservable(selectedEntry$)
const isReady = useStateObservable(isReady$)
if (!selectedEntry) return null
const submit = async () => {
const [entry, unsafeApi, inputValues, builder] = await firstValueFrom(
combineLatest([
selectedEntry$,
unsafeApi$,
inputValues$,
dynamicBuilder$,
]),
)
const decodedValues = inputValues.map((v, i) =>
builder
.buildDefinition(selectedEntry.inputs[i].type)
.dec(v as Uint8Array),
)
const promise = unsafeApi.view[entry!.pallet][entry!.name](...decodedValues)
addViewFnCall({
name: `${entry!.pallet}.${entry!.name}(…)`,
promise,
type: entry!.output,
})
}
return (
<div className="flex flex-col gap-4 items-start w-full">
<ViewFnInputValues />
<ActionButton disabled={!isReady} onClick={submit}>
Call
</ActionButton>
</div>
)
}
const [inputValueChange$, setInputValue] = createSignal<{
idx: number
value: Uint8Array | "partial" | null
}>()
const inputValues$ = selectedEntry$.pipeState(
filter((v) => !!v),
map((v) => v.inputs),
switchMap((inputs) => {
const values: Array<Uint8Array | "partial" | null> = inputs.map(() => null)
return inputValueChange$.pipe(
scan((acc, change) => {
const newValue = [...acc]
newValue[change.idx] = change.value
return newValue
}, values),
startWith(values),
)
}),
withDefault([] as Array<Uint8Array | "partial" | null>),
)
const isReady$ = inputValues$.pipeState(
map((inputValues) => inputValues.every((v) => v instanceof Uint8Array)),
withDefault(false),
)
const ViewFnInputValues: FC = () => {
const selectedEntry = useStateObservable(selectedEntry$)
if (!selectedEntry || !selectedEntry.inputs.length) return null
return (
<div className="w-full">
Inputs
<ol className="flex flex-col gap-2">
{selectedEntry.inputs.map((input, idx) => (
<RuntimeValueInput
key={idx}
idx={idx}
name={input.name}
type={input.type}
/>
))}
</ol>
</div>
)
}
const inputValue$ = state(
(idx: number) => inputValues$.pipe(map((v) => v[idx])),
null,
)
const lookupState$ = state(lookup$, null)
const RuntimeValueInput: FC<{ idx: number; name: string; type: number }> = ({
idx,
type,
name,
}) => {
const value = useStateObservable(inputValue$(idx))
const [expanded, setExpanded] = useState(false)
const lookup = useStateObservable(lookupState$)
if (!lookup) return null
const shape = lookup(type)
const complexity = getTypeComplexity(shape)
return (
<li key={idx} className="border rounded p-2 w-full">
<div
className={twMerge(
"flex items-center select-none",
complexity !== "inline" && "cursor-pointer",
)}
onClick={() => setExpanded((e) => !e)}
>
{complexity === "inline" ? (
<Dot size={16} />
) : (
<ExpandBtn expanded={expanded} />
)}
<Circle
size={8}
strokeWidth={4}
className={twMerge(
"mr-1",
value === null
? "text-red-600"
: value === "partial"
? "text-orange-600"
: "text-green-600",
)}
/>
<div className="text-foreground/80">{name}</div>
{complexity === "inline" ? (
<div className="px-2">
<InlineLookupTypeEdit
type={type}
value={value}
onValueChange={(value) => setInputValue({ idx, value })}
/>
</div>
) : null}
</div>
{expanded && complexity !== "inline" && (
<div className="py-2 max-h-[60svh] overflow-hidden flex flex-col justify-stretch">
<LookupTypeEdit
type={type}
value={value}
onValueChange={(value) => setInputValue({ idx, value })}
tree={complexity === "tree"}
/>
</div>
)}
</li>
)
}
+99
View File
@@ -0,0 +1,99 @@
import { ButtonGroup } from "@/components/ButtonGroup"
import { useStateObservable } from "@react-rxjs/core"
import { Trash2 } from "lucide-react"
import { FC, useState } from "react"
import { ValueDisplay } from "../Storage/StorageSubscriptions"
import {
removeViewFnResult,
ViewFnResult,
viewFnResult$,
viewFnResultKeys$,
} from "./viewFns.state"
import { PathsRoot } from "@/codec-components/common/paths.state"
export const ViewFnResults: FC = () => {
const keys = useStateObservable(viewFnResultKeys$)
if (!keys.length) return null
return (
<div className="p-2 w-full border-t border-border">
<h2 className="text-lg text-foreground mb-2">Results</h2>
<ul className="flex flex-col gap-2">
{keys.map((key) => (
<ViewFnResultBox key={key} subscription={key} />
))}
</ul>
</div>
)
}
const ViewFnResultBox: FC<{ subscription: string }> = ({ subscription }) => {
const [mode, setMode] = useState<"json" | "decoded">("decoded")
const viewFnResult = useStateObservable(viewFnResult$(subscription))
if (!viewFnResult) return null
return (
<li className="border rounded bg-card text-card-foreground p-2">
<div className="flex justify-between items-center pb-1 overflow-hidden">
<h3 className="overflow-hidden text-ellipsis whitespace-nowrap">
{viewFnResult.name}
</h3>
<div className="flex items-center shrink-0 gap-2">
<ButtonGroup
value={mode}
onValueChange={setMode as any}
items={[
{
value: "decoded",
content: "Decoded",
},
{
value: "json",
content: "JSON",
},
]}
/>
<button onClick={() => removeViewFnResult(subscription)}>
<Trash2
size={20}
className="text-destructive cursor-pointer hover:text-polkadot-500"
/>
</button>
</div>
</div>
<PathsRoot.Provider value={subscription}>
<ResultDisplay viewFnResult={viewFnResult} mode={mode} />
</PathsRoot.Provider>
</li>
)
}
const ResultDisplay: FC<{
viewFnResult: ViewFnResult
mode: "json" | "decoded"
}> = ({ viewFnResult, mode }) => {
if ("error" in viewFnResult) {
return (
<div className="text-sm">
<div>The call crashed</div>
<div>Message: {viewFnResult.error.message ?? "N/A"}</div>
</div>
)
}
if (!("result" in viewFnResult)) {
return <div className="text-sm text-foreground/50">Loading…</div>
}
return (
<div className="max-h-[60svh] overflow-auto">
<ValueDisplay
mode={mode}
type={viewFnResult.type}
value={viewFnResult.result}
title={"Result"}
/>
</div>
)
}
+112
View File
@@ -0,0 +1,112 @@
import { lookup$ } from "@/state/chains/chain.state"
import { DocsRenderer } from "@/components/DocsRenderer"
import { LoadingMetadata } from "@/components/Loading"
import { SearchableSelect } from "@/components/Select"
import { withSubscribe } from "@/components/withSuspense"
import { state, useStateObservable } from "@react-rxjs/core"
import { useEffect, useState } from "react"
import { map } from "rxjs"
import { ViewFnResults } from "./ViewFnResults"
import { selectedEntry$, setSelectedFn } from "./viewFns.state"
import { ViewFnQuery } from "./ViewFnQuery"
const metadataViewFns$ = state(
lookup$.pipe(
map((lookup) => ({
lookup,
entries: Object.fromEntries(
lookup.metadata.pallets
.filter((p) => p.viewFns.length)
.map((p) => [
p.name,
Object.fromEntries(
p.viewFns.map((method) => [method.name, method]),
),
]),
),
})),
),
)
export const ViewFns = withSubscribe(
() => {
const { lookup, entries } = useStateObservable(metadataViewFns$)
const defaultPallet = Object.keys(entries)[0] ?? null
const defaultFn = defaultPallet
? Object.keys(entries[defaultPallet])[0]
: null
const [pallet, setPallet] = useState<string | null>(defaultPallet)
const [fnName, setFnName] = useState<string | null>(defaultFn)
const entry = useStateObservable(selectedEntry$)
const selectedPallet =
(pallet && lookup.metadata.pallets.find((p) => p.name === pallet)) || null
useEffect(
() =>
setFnName((prev) => {
if (!selectedPallet?.viewFns[0]) return null
return selectedPallet.viewFns.some((v) => v.name === prev)
? prev
: selectedPallet.viewFns[0].name
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedPallet?.name],
)
useEffect(() => {
const selectedMethod =
(fnName && selectedPallet?.viewFns.find((it) => it.name === fnName)) ||
null
setSelectedFn(
selectedMethod
? { ...selectedMethod, pallet: selectedPallet!.name }
: null,
)
}, [selectedPallet, fnName])
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)}
options={Object.keys(entries).map((e) => ({
text: e,
value: e,
}))}
/>
</label>
{selectedPallet && pallet && (
<label>
Function
<SearchableSelect
value={fnName}
setValue={(v) => setFnName(v)}
options={
Object.keys(entries[pallet]).map((s) => ({
text: s,
value: s,
})) ?? []
}
/>
</label>
)}
</div>
{!!entry?.docs.length && (
<div className="w-full">
Docs
<DocsRenderer docs={entry.docs} />
</div>
)}
<ViewFnQuery />
<ViewFnResults />
</div>
)
},
{
fallback: <LoadingMetadata />,
},
)
+1
View File
@@ -0,0 +1 @@
export * from "./ViewFns"
+79
View File
@@ -0,0 +1,79 @@
import { UnifiedMetadata } from "@polkadot-api/substrate-bindings"
import { state } from "@react-rxjs/core"
import {
createKeyedSignal,
createSignal,
partitionByKey,
toKeySet,
} from "@react-rxjs/utils"
import {
catchError,
from,
map,
Observable,
of,
startWith,
switchMap,
takeUntil,
} from "rxjs"
import { v4 as uuid } from "uuid"
type Pallet = UnifiedMetadata["pallets"][number]
export type ViewFnEntry = Pallet["viewFns"][number] & {
pallet: string
}
export const [entryChange$, setSelectedFn] = createSignal<ViewFnEntry | null>()
export const selectedEntry$ = state(entryChange$, null)
export const [newViewFnCall$, addViewFnCall] = createSignal<{
name: string
type: number
promise: Promise<unknown>
}>()
export const [removeViewFnResult$, removeViewFnResult] =
createKeyedSignal<string>()
export type ViewFnResult = {
name: string
type: number
} & ({ result: unknown } | { error?: any })
const [getViewFnSubscription$, viewFnKeyChange$] = partitionByKey(
newViewFnCall$,
() => uuid(),
(src$, id) =>
src$.pipe(
switchMap(
({ promise, ...props }): Observable<ViewFnResult> =>
from(promise).pipe(
map((result) => ({
...props,
result,
paused: false,
})),
catchError((ex) => {
console.error(ex)
return of({
...props,
error: ex,
})
}),
startWith(props),
),
),
takeUntil(removeViewFnResult$(id)),
),
)
export const viewFnResultKeys$ = state(
viewFnKeyChange$.pipe(
toKeySet(),
map((keys) => [...keys].reverse()),
),
[],
)
export const viewFnResult$ = state(
(key: string): Observable<ViewFnResult> => getViewFnSubscription$(key),
null,
)
+2
View File
@@ -5,4 +5,6 @@ export const whitelist: Polkadot_peopleWhitelistEntry[] = [
"query.Identity.SuperOf",
"query.System.Account",
"tx.Balances.transfer_keep_alive",
"query.Session.Validators",
"query.Session.CurrentIndex",
]