From 8e4a6e0220008812b5b6dc35d32bc4488ea8c711 Mon Sep 17 00:00:00 2001 From: Keith Alfaro Date: Mon, 27 Feb 2023 08:34:10 -0500 Subject: [PATCH] Chain Metadata Explorer (#4408) * Start pulling chain metadata * render initial metadata response * decode constants * add ability to load metadata from different networks * function casing * cleanup pass * add ability to expand and collapse pallet divs * rough out a search bar * decode pallet errors * decode events * decode runtime and get event param types and names * format runtime * add type lookup for constants * minor types improvements * cleanup * add comments to functions and handle empty children in dom tree * add ability to expand or collapse all nested items * add section for RPCs * add pallet extrinsics * comments * add ability to expand and collapse constants, errors, events, extrinsics, storage * display polkadotjs module version number * whitespace formatting * unify styling before consolidation * breakout css styling * change expand/collapse tree buttons to text * finalize formatting before consolidation refactor * start refactor and consolidation pass * deal w/ merge conflict related to pkgs * revert package changes * newlines * formatting * restore polkadotjs api version display * consolidate param extraction * align organization with display order * legacy comments * more closely align rpc and runtime extraction for further abstraction to come * consolidate rpc and runtime to share abstraction for decoding chain data * refactor constants, errors, events, extrinsics, storage to single abstraction * let to const * unify formatting * fix expand/collapse all bug * significantly improve storage type decoding results and abstraction * decode tuple types * finish decoding storage types * formatting * styling * unify polkadot and kusama styling * set default network based on current doc * add tool description and styling * improve description * display chain metadata version * else if else if else if else if to switch * comment * add UI for search filter * add basic search div filtering * add metadata explorer sidebar nav links to polkadot/kusama wiki/guide * add search query highlighting and throttle * more styling updates * remove legacy log statement --- components/Metadata.jsx | 511 ++++++++++++++++++++++++++++++++++++++ docs/general/metadata.md | 18 ++ kusama-guide/sidebars.js | 1 + polkadot-wiki/sidebars.js | 1 + 4 files changed, 531 insertions(+) create mode 100644 components/Metadata.jsx create mode 100644 docs/general/metadata.md diff --git a/components/Metadata.jsx b/components/Metadata.jsx new file mode 100644 index 000000000000..0ae7d8b22bc0 --- /dev/null +++ b/components/Metadata.jsx @@ -0,0 +1,511 @@ +import React from "react"; +import { useState, useEffect } from "react"; +import { ApiPromise, WsProvider } from "@polkadot/api"; +import Packages from "./../package.json"; + +// Load PolkadotJS version +const PolkadotJSVersion = Packages.devDependencies["@polkadot/api"].substring(1); + +// Chains that will appear in the dropdown selection menu (add new parachains here) +const Networks = [ + { name: "polkadot", rpc: "wss://rpc.polkadot.io" }, + { name: "kusama", rpc: "wss://kusama-rpc.polkadot.io" }, + { name: "statemine", rpc: "wss://statemine-rpc.polkadot.io" }, + { name: "statemint", rpc: "wss://statemint-rpc.polkadot.io" }, + { name: "westend", rpc: "wss://westend-rpc.polkadot.io" }, + { name: "rococo", rpc: "wss://rococo-rpc.polkadot.io" }, +]; + +// Track all top-level containers for expand/collapse all functionality +let Expandable = []; + +// Timeout for performing search requests +let SearchThrottle; + +// Component +export default function Metadata({ version }) { + const [returnValue, setReturnValue] = useState(""); + + useEffect(async () => { + // Load default network + let defaultNetwork = "polkadot"; + if (document.title === "Metadata Explorer ยท Guide") { defaultNetwork = "kusama"; } + const network = Networks.find(network => { return network.name === defaultNetwork }); + const wsUrl = network.rpc; + + // Build selection dropdown + let options = []; + Networks.forEach(chain => { + const option = + options.push(option); + }); + const dropdown = ( + + ) + + // Set loading status + setReturnValue(
Loading Metadata Explorer...
); + + // Fetch metadata from the chain + await GetMetadata(version, wsUrl, dropdown, setReturnValue); + }, []); + + return (returnValue); +} + +// Retrieve metadata from selected chain and render results +async function GetMetadata(version, wsUrl, dropdown, setReturnValue) { + ToggleLoading("metadataLoading", false); + // Load websocket + const wsProvider = new WsProvider(wsUrl); + const api = await ApiPromise.create({ provider: wsProvider }); + + // Clear any existing expandable containers + Expandable = []; + + // Fetch metadata from on-chain + const rawMeta = await api.rpc.state.getMetadata(); + const meta = rawMeta.toHuman(); + + // Set types for currently loaded metadata + const types = meta.metadata[version].lookup.types; + + // Pallets + const pallets = meta.metadata[version].pallets; + pallets.sort((a, b) => a.name.localeCompare(b.name)); + let palletData = []; + pallets.forEach(pallet => { + // Pallet extractions + const constants = BuildPalletItems(pallet, api.consts[`${Camel(pallet.name)}`], "constants", types); + const errors = BuildPalletItems(pallet, api.errors[`${Camel(pallet.name)}`], "errors", types); + const events = BuildPalletItems(pallet, api.events[`${Camel(pallet.name)}`], "events", types); + const extrinsics = BuildPalletItems(pallet, api.tx[`${Camel(pallet.name)}`], "extrinsics", types); + const storage = BuildPalletItems(pallet, api.query[Camel(pallet.name)], "storage", types); + + // Format pallet extractions for rendering + const constantElements = CompilePalletSection(pallet.name, "constants", constants); + const errorElements = CompilePalletSection(pallet.name, "errors", errors); + const eventElements = CompilePalletSection(pallet.name, "events", events); + const extrinsicElements = CompilePalletSection(pallet.name, "extrinsics", extrinsics); + const storageElements = CompilePalletSection(pallet.name, "storage", storage); + + // Compile all elements for the given pallet + palletData.push( +
+ { ToggleExpand(pallet.name) }}>+ {pallet.name} +
+ {constantElements} + {errorElements} + {eventElements} + {extrinsicElements} + {storageElements} +
+
+ ) + Expandable.push(pallet.name); + Expandable.push(`${pallet.name}-constants`, `${pallet.name}-errors`, `${pallet.name}-events`, `${pallet.name}-extrinsics`, `${pallet.name}-storage`); + }); + + // Extract RPC and Runtime data + const rpcs = BuildRPCOrRuntime(api.rpc, "rpc"); + const runtime = BuildRPCOrRuntime(api.call, "runtime"); + + ToggleLoading("metadataLoading", true); + + // Render + setReturnValue( +
+
+ Search()} />
+ {dropdown} +
+ + +
+
+ metadata{` ${version}`}  + @polkadot/api{` V${PolkadotJSVersion}`} +
+
{`Connecting to ${wsUrl}...`}
+
Searching...
+
{`Matches: `}0
+
+ Pallets: + {palletData} +
+ RPC: + {rpcs} +
+ Runtime: + {runtime} +
+ ); +} + +// Format lists for a given pallet invocation +function BuildPalletItems(pallet, call, type, types) { + let output = []; + if (call !== undefined && call !== null) { + const keys = Object.keys(call).sort((a, b) => a.localeCompare(b)); + keys.forEach(key => { + const meta = call[key].meta.toHuman(); + const description = FormatDescription(meta.docs.join(" ")); + const keyUpper = key.charAt(0).toUpperCase() + key.slice(1); + let list; + switch (type) { + case "constants": + const constType = types[meta.type].type.def; + list = ( + + ) + break; + case "errors": + list = ( + + ) + break; + case "events": + list = ( + + ) + break; + case "extrinsics": + list = ( + + ) + break; + case "storage": + list = ( + + ) + break; + default: + item = undefined; + break; + } + const item = ( +
  • + {keyUpper} + {list} +
  • + ) + output.push(item); + }); + } + output = IsEmpty(output); + return output; +} + +// Format lists for a given RPC or runtime +function BuildRPCOrRuntime(call, type) { + let output = []; + const keys = Object.keys(call); + keys.sort((a, b) => a.localeCompare(b)); + keys.forEach(key => { + let children = []; + const methods = call[key]; + const methodKeys = Object.keys(methods); + methodKeys.sort((a, b) => a.localeCompare(b)); + methodKeys.forEach(methodKey => { + const childCall = methods[methodKey].meta; + const callDescription = FormatDescription(childCall.description); + let listItems; + switch (type) { + case "rpc": + listItems = ( + + ) + break; + case "runtime": + listItems = ( + + ) + break; + default: + break; + } + const item = ( +
    + {`${methodKey.charAt(0).toUpperCase() + methodKey.slice(1)}`} + {listItems} +
    + ) + children.push(item); + }); + children = IsEmpty(children); + const header = key.charAt(0).toUpperCase() + key.slice(1); + const formattedCalls = ( +
    + { ToggleExpand(key) }}>+ {header} +
    +
      + {children} +
    +
    +
    + ) + output.push(formattedCalls); + Expandable.push(key); + }); + return output; +} + +// Enforce lower casings of first character on camel case api calls +function Camel(input) { + return input.charAt(0).toLowerCase() + input.slice(1); +} + +// If any sub-sections (Constants, Errors, Events, Storage) contain no children display "None" +function IsEmpty(result) { + if (result.length === 0) { return (

    None

    ) } + else { return result; } +} + +// Construction pallet sub-categories (constants, errors, events, extrinsics, storage) +function CompilePalletSection(palletName, category, items) { + return ( + + ) +} + +// Format a description string +function FormatDescription(description) { + let descriptionItems = description.split("`"); + let output = []; + for (let i = 0; i < descriptionItems.length; i++) { + if (i % 2 === 0) { + output.push(

    {descriptionItems[i]}

    ) + } else { + output.push(

    {descriptionItems[i]}

    ) + } + } + return {output}; +} + +// Extract and format arguments from metadata +function FormatArgs(item, type, types = null) { + let params = "("; + switch (type) { + case "rpc": + item.params.forEach(param => { + params += `${param.name}: ${param.type}, `; + }) + break; + case "extrinsics": + for (let i = 0; i < item.args.length; i++) { + params += `${item.args[i].name}: ${item.args[i].type}, ` + } + break; + case "events": + for (let i = 0; i < item.args.length; i++) { + params += `${item.fields[i].typeName}: ${item.args[i]}, ` + } + break; + case "storage": + const key = Object.keys(item.type)[0]; + if (key === "Plain") { + const typeKey = item.type.Plain; + const def = types[typeKey].type.def + params = StorageDecoder(def, types); + } else if (key === "Map") { + const typeKey = item.type.Map.key; + const def = types[typeKey].type.def + params = StorageDecoder(def, types); + } else { + console.log("Unknown Storage Type"); + } + break; + default: + break; + } + params = `${params.slice(0, -2)})`; + if (params === "(" || params === ")") { params = "None"; } + return params; +} + +// Decode and format storage return types +function StorageDecoder(def, types) { + let params = "("; + const type = Object.keys(def)[0]; + switch (type) { + case "Array": + const length = def.Array.len; + const arrayTypeDef = types[def.Array.type].type.def; + const typeDefKey = Object.keys(arrayTypeDef)[0]; + const typeDefValue = arrayTypeDef[typeDefKey]; + params += `Array[${length}]: ${typeDefKey} ${typeDefValue} )`; + break; + case "Compact": + params = StorageDecoder(types[def.Compact.type].type.def, types); + break; + case "Composite": + def.Composite.fields.forEach((item) => { + params = StorageDecoder(types[item.type].type.def, types); + }) + break; + case "Primitive": + const primitiveType = def.Primitive; + params += `Primitive: ${primitiveType}) `; + break; + case "Sequence": + params = StorageDecoder(types[def.Sequence.type].type.def, types); + break; + case "Tuple": + params += "Tuple: [ " + def.Tuple.forEach((item) => { + params += `${StorageDecoder(types[item].type.def, types)}, `; + }) + params = `${params.slice(0, -2)}] `; + break; + case "Variant": + params += "Variant: " + def.Variant.variants.forEach((variant) => { + let fieldNames = []; + variant.fields.forEach((field) => { + fieldNames.push(field.typeName); + }) + params += `{${variant.name}: [${fieldNames.join(", ")}]}, ` + }); + params = `${params.slice(0, -2)} `; + break; + default: + console.log("Unknown Decoder Type"); + break; + } + return params; +} + +// Display loading notification +function ToggleLoading(id, hidden) { + const el = document.getElementById(id); + if (el !== null) { + if (hidden === false) { el.style.display = "block"; } + else { el.style.display = "none" }; + } +} + +// Expand or collapse a div +function ToggleExpand(id) { + const div = document.getElementById(id); + const button = document.getElementById(`${id}-button`); + if (div.style.maxHeight === "0px") { + div.style.maxHeight = "100%"; + button.innerText = "-"; + } else { + div.style.maxHeight = "0px"; + button.innerText = "+"; + } +} + +// Expand or collapse all divs +function ExpandAll(bool) { + Expandable.forEach(item => { + const div = document.getElementById(item); + const button = document.getElementById(`${item}-button`); + if (bool) { + div.style.maxHeight = "100%"; + button.innerText = "-"; + } else { + div.style.maxHeight = "0px"; + button.innerText = "+"; + } + }) +} + +// Search content +function Search() { + ToggleLoading("searchLoading", false); + clearTimeout(SearchThrottle); + SearchThrottle = setTimeout(function () { + const query = document.getElementById("metaSearch").value; + if (query.length < 2) { + ExpandAll(false); + Expandable.forEach((elementId) => { + const div = document.getElementById(elementId); + const searchable = div.getElementsByClassName("searchable"); + for (let item of searchable) { item.style.background = "transparent"; } + }) + ToggleLoading("searchResults", true); + } else { + const matcher = new RegExp(query, "gi"); + let matchCount = 0; + Expandable.forEach((elementId) => { + const div = document.getElementById(elementId); + const searchable = div.getElementsByClassName("searchable"); + const button = document.getElementById(`${elementId}-button`); + if (matcher.test(div.innerText)) { + for (let item of searchable) { + if (matcher.test(item.innerText)) { + item.style.background = "#ffff00"; + matchCount += 1; + } else { item.style.background = "transparent"; } + } + document.getElementById("searchCount").innerText = matchCount; + div.style.maxHeight = "100%"; + button.innerText = "-"; + } else { + for (let item of searchable) { item.style.background = "transparent"; } + div.style.maxHeight = "0px"; + button.innerText = "+"; + } + }); + ToggleLoading("searchResults", false); + } + ToggleLoading("searchLoading", true); + }, 200); // Perform search after 0.2s +} + +// Styling +const PinkText = { color: "#e6007a" }; +const ExplorerControls = { textAlign: "center " }; +const DescriptionRegular = { margin: "0px", display: "inline" }; +const DescriptionHighlighting = { color: "#e6007a", margin: "0px", display: "inline", background: "#f0f0f0", paddingLeft: "5px", paddingRight: "5px" } +const TopLevelDiv = { maxHeight: "0px", overflow: "hidden" }; +const CollapsedDiv = { maxHeight: "0px", overflow: "hidden", margin: "0px" }; +const NoMargin = { margin: "0px" }; +const DropDownStyle = { border: "1px solid #e6007a", width: "400px", height: "40px", fontSize: "16px", textAlign: "center", fontWeight: "bold", margin: "1px", cursor: "pointer" }; +const ExpandCollapseButton = { border: "1px solid #e6007a", width: "199px", height: "28px", margin: "1px", fontWeight: "bold", cursor: "pointer" }; +const LoadingStatus = { display: "none" }; +const TreeControl = { margin: "0px", color: "#e6007a", cursor: "pointer" }; +const SearchStyle = { border: "1px solid #e6007a", width: "400px", height: "40px", fontSize: "16px", textAlign: "center", margin: "1px" } +const TopLevel = { fontSize: "18px" } +const SecondLevel = { paddingLeft: "16px" } \ No newline at end of file diff --git a/docs/general/metadata.md b/docs/general/metadata.md new file mode 100644 index 000000000000..bea73f75efcd --- /dev/null +++ b/docs/general/metadata.md @@ -0,0 +1,18 @@ +--- +id: metadata +title: Metadata Explorer +sidebar_label: Metadata Explorer +description: Visualize metadata and documentation for various parachains. +keywords: [docs, substrate, metadata, explorer, search, api, tools, js, javascript] +slug: ../metadata +--- + +import Metadata from "./../../components/Metadata"; + +The `Metadata Explorer` tool helps visualize the metadata of various parachains by retrieving the +latest data directly from the chain using the [polkadot-js api](https://github.com/polkadot-js/api). +The dropdown below allows you to update the chain selection to visualize. You can search all +sub-categories using the provided search field. The information is categorized by the chains +`Pallets`, `RPC` and `Runtime` information. + + diff --git a/kusama-guide/sidebars.js b/kusama-guide/sidebars.js index a7fed0215773..eb9556a2c100 100644 --- a/kusama-guide/sidebars.js +++ b/kusama-guide/sidebars.js @@ -12,6 +12,7 @@ module.exports = { "learn/learn-balance-transfers", "learn/learn-auction", "learn/learn-parachains", + "general/metadata", "learn/learn-parathreads", "learn/learn-bridges", "learn/learn-crowdloans", diff --git a/polkadot-wiki/sidebars.js b/polkadot-wiki/sidebars.js index 76ec2c95b0e6..b90715de5134 100644 --- a/polkadot-wiki/sidebars.js +++ b/polkadot-wiki/sidebars.js @@ -39,6 +39,7 @@ module.exports = { }, "general/faq", "general/glossary", + "general/metadata", ], }, {