Merge pull request #135 from polkadot-api/vo/ext-analyzer

feat: extrinsic analyzer
This commit is contained in:
Victor Oliva
2026-05-05 16:23:05 +02:00
committed by GitHub
24 changed files with 1032 additions and 245 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "0.1.0-autogenerated.8379875965201711085",
"version": "0.1.0-autogenerated.11438231719699111413",
"name": "@polkadot-api/descriptors",
"files": [
"dist"
Binary file not shown.
+1 -1
View File
@@ -6,7 +6,7 @@
"chain": "polkadot_people",
"metadata": ".papi/metadata/polkadot_people.scale",
"genesis": "0x67fa177a097bfa18f77ea95ab56e9bcdfeb0e5b8a40e46298bb93e16b6fc5008",
"codeHash": "0x19d152fef983bf5be332cd07dc6f5c7291d842a4e2d5501eca02a86c10df211d"
"codeHash": "0x3f0652ba1159157eff567b8e0e5987c8fc67a614f0909ce0f65f0d8a2b532864"
}
}
}
+3
View File
@@ -4,6 +4,9 @@ export const whitelist: Polkadot_peopleWhitelistEntry[] = [
"query.Identity.IdentityOf",
"query.Identity.SuperOf",
"query.System.Account",
"query.System.Number",
"const.System.BlockLength",
"const.System.BlockWeights",
"tx.Balances.transfer_keep_alive",
"query.Session.Validators",
"query.Session.CurrentIndex",
+8 -6
View File
@@ -1,6 +1,13 @@
import { ButtonHTMLAttributes, forwardRef } from "react"
import { twMerge } from "tailwind-merge"
export const actionButtonClassName = (disabled = false) =>
twMerge(
"text-accent-foreground bg-accent/90 px-3 py-1",
"cursor-pointer select-none hover:bg-accent",
disabled && "opacity-50 pointer-events-none",
)
export const ActionButton = forwardRef<
HTMLButtonElement,
ButtonHTMLAttributes<HTMLButtonElement>
@@ -8,11 +15,6 @@ export const ActionButton = forwardRef<
<button
{...props}
ref={ref}
className={twMerge(
"text-accent-foreground bg-accent/90 px-3 py-1",
"cursor-pointer select-none hover:bg-accent",
props.disabled && "opacity-50 pointer-events-none",
props.className,
)}
className={twMerge(actionButtonClassName(props.disabled), props.className)}
/>
))
+3 -1
View File
@@ -18,7 +18,9 @@ export const TokenAmount: FC<{
const formattedValue = (
Number(children) /
10 ** properties.tokenDecimals
).toLocaleString(undefined)
).toLocaleString(undefined, {
maximumSignificantDigits: 3,
})
return (
<span
+41 -1
View File
@@ -1,4 +1,4 @@
import { FC } from "react"
import { FC, useLayoutEffect } from "react"
import {
LinkProps,
NavigateFunction,
@@ -82,3 +82,43 @@ export const useNavigate = (): NavigateFunction => {
return navigate(persistKeys(toOrDelta), options)
}
}
export const useSyncHashParam = <T extends any[]>(
key: string,
dependencies: T,
getFn: (...args: T) => string | null,
) => {
const location = useLocation()
useLayoutEffect(
() =>
setHashParams(
{
[key]: getFn(...dependencies),
},
location,
),
// eslint-disable-next-line react-hooks/exhaustive-deps
dependencies,
)
return getHashParams(location).get(key)
}
export const useHashParam = (key: string) =>
getHashParams(useLocation()).get(key)!
export const useHashParamState = <T extends string | null>(
key: string,
init?: () => T,
): [
null extends T ? string | null : string,
(value: string | null) => void,
] => {
const location = useLocation()
return [
getHashParams(location).get(key) ?? init?.() ?? (null as any),
(value: string | null) => setHashParams({ [key]: value }, location),
] as const
}
+15 -10
View File
@@ -1,20 +1,22 @@
import { Chopsticks } from "@/components/Icons"
import { Loading } from "@/components/Loading"
import { getHashParams } from "@/hashParams"
import { groupBy } from "@/lib/groupBy"
import { runtimeCtx$, runtimeCtxAt$ } from "@/state/chains/chain.state"
import { Blake2256 } from "@polkadot-api/substrate-bindings"
import { getExtrinsicDecoder } from "@polkadot-api/tx-utils"
import * as Tabs from "@radix-ui/react-tabs"
import { state, useStateObservable } from "@react-rxjs/core"
import { FC, useState } from "react"
import { toHex } from "polkadot-api/utils"
import { FC, useContext, useState } from "react"
import { useLocation } from "react-router-dom"
import { catchError, combineLatest, filter, map, take } from "rxjs"
import { twMerge } from "tailwind-merge"
import { BlockInfo, blockInfoState$ } from "../block.state"
import { blockInfoState$ } from "../block.state"
import { BlockEvents } from "./BlockEvents"
import { BlockStorageDiff } from "./BlockStorageDiff"
import { ApplyExtrinsicEvent, Extrinsic } from "./Extrinsic"
import { getHashParams } from "@/hashParams"
import { Blake2256 } from "@polkadot-api/substrate-bindings"
import { toHex } from "polkadot-api/utils"
import { BlockContext } from "./blockContext"
const blockExtrinsics$ = state((hash: string) => {
const body$ = blockInfoState$(hash).pipe(
@@ -25,12 +27,16 @@ const blockExtrinsics$ = state((hash: string) => {
return combineLatest([
body$,
runtimeCtxAt$(hash).pipe(catchError(() => runtimeCtx$)),
runtimeCtxAt$(hash).pipe(
catchError(() => runtimeCtx$),
map((v) => getExtrinsicDecoder(v.metadataRaw)),
),
]).pipe(
take(1),
map(([body, { txDecoder }]) =>
map(([body, txDecoder]) =>
body.map((raw, idx) => ({
idx,
raw,
hash: toHex(Blake2256(raw)),
...txDecoder(raw),
})),
@@ -39,9 +45,8 @@ const blockExtrinsics$ = state((hash: string) => {
}, [])
type Tab = "tx" | "events" | "diff"
export const BlockBody: FC<{
block: BlockInfo
}> = ({ block }) => {
export const BlockBody: FC = () => {
const block = useContext(BlockContext)!
const extrinsics = useStateObservable(blockExtrinsics$(block.hash))
const [selectedTab, setSelectedTab] = useState<Tab | null>(null)
+5 -2
View File
@@ -4,6 +4,7 @@ import { useParams } from "react-router-dom"
import { blockInfoState$ } from "../block.state"
import { BlockBody } from "./BlockBody"
import { BlockInfoView } from "./BlockInfo"
import { BlockContext } from "./blockContext"
export const BlockDetail = () => {
const { hashOrHeight } = useParams()
@@ -13,8 +14,10 @@ export const BlockDetail = () => {
return (
<div>
<BlockInfoView block={block} />
<BlockBody block={block} />
<BlockContext value={block}>
<BlockInfoView />
<BlockBody />
</BlockContext>
</div>
)
}
+42 -43
View File
@@ -2,58 +2,57 @@ import { CopyText } from "@/components/Copy"
import { Link } from "@/hashParams"
import { state, useStateObservable } from "@react-rxjs/core"
import { combineKeys } from "@react-rxjs/utils"
import { FC, ReactNode } from "react"
import { FC, ReactNode, useContext } from "react"
import { filter, map, startWith, switchMap, take } from "rxjs"
import {
BlockInfo,
blockInfoState$,
blocksByHeight$,
BlockState,
} from "../block.state"
import { blockInfoState$, blocksByHeight$, BlockState } from "../block.state"
import { BlockAuthor } from "./BlockAuthor"
import { BlockStatusIcon, statusText } from "./BlockState"
import { BlockContext } from "./blockContext"
export const BlockInfoView: FC<{
block: BlockInfo
}> = ({ block }) => (
<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 />
export const BlockInfoView: FC = () => {
const block = useContext(BlockContext)
if (!block) return null
return (
<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>
<div className="flex flex-wrap items-center gap-3 md:justify-end">
<StatusChip state={block.status} />
</div>
</header>
<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} />
</DetailTile>
<DetailTile label="Children">
<BlockChildren hash={block.hash} />
</DetailTile>
</div>
{block.header && (
<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} />
<div className="grid gap-4 md:grid-cols-2">
<DetailTile label="Parent block">
<BlockLink hash={block.parent} />
</DetailTile>
<DetailTile label="Block author">
<BlockAuthor hash={block.hash} header={block.header} />
<DetailTile label="Children">
<BlockChildren hash={block.hash} />
</DetailTile>
</div>
)}
</section>
)
{block.header && (
<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">
+23 -62
View File
@@ -8,17 +8,22 @@ import { Link } from "@/hashParams"
import { shortStr } from "@/utils"
import { SystemEvent } from "@polkadot-api/observable-client"
import { DecodedExtrinsic } from "@polkadot-api/tx-utils"
import { Dot, Edit } from "lucide-react"
import { Edit, FileSearch } from "lucide-react"
import { Enum, HexString, SS58String } from "polkadot-api"
import { toHex } from "polkadot-api/utils"
import { FC, useEffect, useRef, useState } from "react"
import { twMerge } from "tailwind-merge"
import { SignedExtensions } from "./SignedExtensions"
export type ApplyExtrinsicEvent = SystemEvent & {
phase: { type: "ApplyExtrinsic" }
}
export const Extrinsic: FC<{
extrinsic: DecodedExtrinsic & { idx: number; hash: HexString }
extrinsic: DecodedExtrinsic & {
idx: number
raw: Uint8Array
hash: HexString
}
highlightedEvent: SystemEvent | null
events: ApplyExtrinsicEvent[]
isOpen?: boolean
@@ -43,15 +48,12 @@ export const Extrinsic: FC<{
</button>
<div className="flex gap-2 items-center">
<CopyBinary value={extrinsic.callData} />
{extrinsic.callData.length > 1024 ? (
<span className="opacity-50">
<Edit size={14} />
</span>
) : (
<Link to={"/extrinsics#data=" + toHex(extrinsic.callData)}>
<Edit size={14} />
</Link>
)}
<Link to={`/extrinsics/analyzer#extrinsic=${toHex(extrinsic.raw)}`}>
<FileSearch size={15} />
</Link>
<Link to={"/extrinsics#data=" + toHex(extrinsic.callData)}>
<Edit size={14} />
</Link>
</div>
</div>
{expanded ? (
@@ -92,51 +94,6 @@ export const Extrinsic: FC<{
)
}
const SignedExtensions: FC<{ extra: Record<string, unknown> }> = ({
extra,
}) => (
<div className="space-y-2">
<h3>Signed extensions</h3>
<ul className="space-y-2">
{Object.entries(extra).map(([key, value]) => (
<SignedExtension key={key} id={key} value={value} />
))}
</ul>
</div>
)
const SignedExtension: FC<{ id: string; value: unknown }> = ({ id, value }) => {
const [expanded, setExpanded] = useState(false)
const inlineJson = JSON.stringify(value, (_, v) =>
typeof v === "bigint" ? String(v) : v instanceof Uint8Array ? toHex(v) : v,
)
if (!inlineJson || inlineJson.length < 40) {
return (
<li className="flex items-center flex-wrap gap-1">
<div className="flex gap-2 items-center">
<Dot size={16} />
{id}
</div>
{inlineJson ? (
<div className="whitespace-nowrap">
- <span className="font-mono text-sm">{inlineJson}</span>
</div>
) : null}
</li>
)
}
return (
<li className="space-y-2">
<div className="flex gap-2 items-center">
<ExpandBtn expanded={expanded} onClick={() => setExpanded((e) => !e)} />
{id}
</div>
{expanded && <JsonDisplay src={value} />}
</li>
)
}
export const EventDisplay: FC<{
evt: SystemEvent
index: number
@@ -173,15 +130,19 @@ export const EventDisplay: FC<{
)
}
export const senderToAddress = (
sender: Enum<{ Id: SS58String }> | SS58String | HexString,
) =>
typeof sender === "string"
? sender
: "type" in sender && sender.type === "Id"
? sender.value
: null
const Sender: React.FC<{
sender: Enum<{ Id: SS58String }> | SS58String | HexString
}> = ({ sender }) => {
const value: string | null =
typeof sender === "string"
? sender
: "type" in sender && sender.type === "Id"
? sender.value
: null
const value = senderToAddress(sender)
return (
value && (
<div className="flex gap-2 items-center py-2">
@@ -0,0 +1,182 @@
import { client$ } from "@/state/chains/chain.state"
import { useStateObservable, withDefault } from "@react-rxjs/core"
import { jsonSerialize } from "polkadot-api/utils"
import { FC, useContext } from "react"
import { map, switchMap } from "rxjs"
import { BlockContext } from "./blockContext"
const finalizedNumber$ = client$.pipeState(
switchMap((v) => v.bestBlocks$),
map(([block]) => block.number),
withDefault(null),
)
export const MortalityAnalyzer: FC<{
mortality: { type: string; value: number }
}> = ({ mortality }) => {
const selectedBlock = useContext(BlockContext)
const finalizedNumber = useStateObservable(finalizedNumber$)
const selectedBlockNumber = selectedBlock?.number ?? finalizedNumber
if (mortality.type === "Immortal") {
return (
<div className="rounded-lg border border-foreground/10 bg-background/60 px-3 py-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Lifetime
</div>
<div className="mt-1 text-sm font-medium text-foreground">Immortal</div>
</div>
)
}
const parseResult = /^Mortal(\d+)$/.exec(mortality.type)
if (parseResult === null) {
return (
<div className="rounded-lg border border-foreground/10 bg-background/60 px-3 py-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Lifetime
</div>
<div className="mt-1 font-mono text-sm text-foreground">
{mortality.type} {mortality.value}
</div>
</div>
)
}
const first = BigInt(parseResult[1])
const second = BigInt(mortality.value)
// from polkadot-sdk primitives runtime generic era fn decode
const encoded = first + (second << 8n)
const period = Number(2n << (encoded % (1n << 4n)))
const factor = period >> 12 || 1
const phase = Number(encoded >> 4n) * factor
// From fn birth
const birthBlock = selectedBlockNumber
? Math.floor((Math.max(selectedBlockNumber, phase) - phase) / period) *
period +
phase
: null
const deathBlock = birthBlock == null ? null : birthBlock + period
const blocksRemaining =
birthBlock == null || deathBlock == null || selectedBlockNumber == null
? null
: deathBlock - selectedBlockNumber
const contextProgress =
birthBlock == null || deathBlock == null || selectedBlockNumber == null
? null
: getTimelineProgress(birthBlock, deathBlock, selectedBlockNumber)
return (
<div className="space-y-3 rounded-lg border border-foreground/10 bg-background/60 px-3 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Lifetime
</div>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<Metric label="Period" value={period.toLocaleString()} />
<Metric label="Phase" value={phase.toLocaleString()} />
</div>
<div className="rounded-md border border-foreground/10 bg-foreground/5 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Validity Window
</div>
<div className="text-xs text-muted-foreground">
{blocksRemaining == null
? null
: `${blocksRemaining.toLocaleString()} blocks remaining`}
</div>
</div>
<div className="mt-4">
<div className="relative h-3 rounded-full bg-emerald-500/50">
{contextProgress != null ? (
<div
className="absolute top-1/2 -translate-y-2.5"
style={{ left: `${contextProgress}%` }}
>
<div className="h-5 w-0.5 bg-foreground/60" />
<div
className="text-xs whitespace-nowrap font-semibold text-foreground/60"
style={{
transform: `translateX(${contextProgress < 10 ? "-1%" : contextProgress > 90 ? "-95%" : "-50%"})`,
}}
>
{selectedBlockNumber?.toLocaleString()}
</div>
</div>
) : null}
</div>
<div className="mt-5 grid grid-cols-[1fr_1fr] items-start gap-2 text-xs text-muted-foreground">
<div>
<div className="font-semibold uppercase tracking-[0.14em] text-foreground/50">
Start
</div>
<div className="mt-1 font-mono text-sm text-foreground">
{formatOptional(birthBlock)}
</div>
</div>
<div className="text-right">
<div className="font-semibold uppercase tracking-[0.14em] text-foreground/50">
End
</div>
<div className="mt-1 font-mono text-sm text-foreground">
{formatOptional(deathBlock)}
</div>
</div>
</div>
</div>
</div>
</div>
)
}
const Metric: FC<{ label: string; value: string }> = ({ label, value }) => (
<div className="rounded-md border border-foreground/10 bg-foreground/5 px-3 py-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
{label}
</div>
<div className="mt-1 break-all font-mono text-sm text-foreground">
{value}
</div>
</div>
)
const formatOptional = (value: number | null) =>
value == null ? "N/A" : value.toLocaleString()
const getTimelineProgress = (
start: number,
end: number,
value: number,
): number => {
if (end <= start) return 0
const raw = ((value - start) / (end - start)) * 100
return Math.max(0, Math.min(100, raw))
}
export const InlineMortality: FC<{
mortality: { type: string; value: number }
}> = ({ mortality }) => {
if (mortality.type === "Immortal") return "Immortal"
const parseResult = /^Mortal(\d+)$/.exec(mortality.type)
if (parseResult === null) return JSON.stringify(mortality, jsonSerialize)
const first = BigInt(parseResult[1])
const second = BigInt(mortality.value)
// from polkadot-sdk primitives runtime generic era fn decode
const encoded = first + (second << 8n)
const period = Number(2n << (encoded % (1n << 4n)))
const factor = period >> 12 || 1
const phase = Number(encoded >> 4n) * factor
return JSON.stringify({ period, phase }) + " " + JSON.stringify(mortality)
}
@@ -0,0 +1,108 @@
import { ExpandBtn } from "@/components/Expand"
import { JsonDisplay } from "@/components/JsonDisplay"
import { Dot } from "lucide-react"
import { jsonSerialize } from "polkadot-api/utils"
import {
ComponentType,
FC,
PropsWithChildren,
ReactNode,
useState,
} from "react"
import { InlineMortality, MortalityAnalyzer } from "./MortalityAnalyzer"
export const SignedExtensions: FC<{
extra: Record<string, unknown>
title?: boolean
}> = ({ extra, title = true }) => (
<div className="space-y-2">
{title ? (
<h3 className="text-sm font-semibold">Signed extensions</h3>
) : null}
<ul className="space-y-3">
{Object.entries(extra).map(([key, value]) => (
<SignedExtension key={key} id={key} value={value} />
))}
</ul>
</div>
)
const SignedExtension: FC<{ id: string; value: unknown }> = ({ id, value }) => {
const knownExtension = knownSignedExtensions[id]
if (knownExtension) {
const { expanded: ExpandedView, inline: InlineView } = knownExtension
return ExpandedView ? (
<ExpandableLine
id={id}
inlineContent={InlineView ? <InlineView id={id} value={value} /> : null}
>
<ExpandedView id={id} value={value} />
</ExpandableLine>
) : InlineView ? (
<InlineLine
id={id}
inlineContent={InlineView ? <InlineView id={id} value={value} /> : null}
/>
) : null
}
const inlineJson = JSON.stringify(value, jsonSerialize)
return !inlineJson || inlineJson.length < 40 ? (
<InlineLine id={id} inlineContent={inlineJson} />
) : (
<ExpandableLine id={id} inlineContent={null}>
<JsonDisplay src={value} />
</ExpandableLine>
)
}
export const knownSignedExtensions: Record<
string,
{
inline?: ComponentType<{ id: string; value: unknown }>
expanded?: ComponentType<{ id: string; value: unknown }>
}
> = {
CheckMortality: {
expanded: ({ value }) => <MortalityAnalyzer mortality={value as any} />,
inline: ({ value }) => <InlineMortality mortality={value as any} />,
},
}
const ExpandableLine: FC<
PropsWithChildren<{ id: string; inlineContent?: ReactNode }>
> = ({ id, inlineContent, children }) => {
const [expanded, setExpanded] = useState(false)
return (
<li className="space-y-2 rounded-lg border border-foreground/10 bg-foreground/5 px-3 py-2">
<div className="flex gap-2 items-center">
<ExpandBtn expanded={expanded} onClick={() => setExpanded((e) => !e)} />
{id}
{inlineContent ? (
<div className="max-w-full whitespace-nowrap font-mono text-sm">
- {inlineContent}
</div>
) : null}
</div>
{expanded && children}
</li>
)
}
const InlineLine: FC<{ id: string; inlineContent?: ReactNode }> = ({
id,
inlineContent,
}) => (
<li className="flex flex-wrap items-center gap-2 rounded-lg border border-foreground/10 bg-foreground/5 px-3 py-2">
<div className="flex gap-2 items-center">
<Dot size={16} />
{id}
</div>
{inlineContent ? (
<div className="max-w-full whitespace-nowrap font-mono text-sm">
- {inlineContent}
</div>
) : null}
</li>
)
@@ -0,0 +1,4 @@
import { createContext } from "react"
import { BlockInfo } from "../block.state"
export const BlockContext = createContext<BlockInfo | null>(null)
@@ -0,0 +1,55 @@
import { TextInputField } from "@/components/TextInputField"
import { useHashParamState } from "@/hashParams"
import { Subscribe } from "@react-rxjs/core"
import { FC } from "react"
import { BlockPicker } from "../../Storage/BlockPicker"
import { ExtrinsicDecoder, extrinsicDecoder$ } from "./ExtrinsicDecoder"
export const ExtrinsicAnalyzer: FC = () => {
const [extrinsicHex, setExtrinsicHex] = useHashParamState(
"extrinsic",
() => "",
)
return (
<div className="space-y-4 p-3 md:p-4">
<header className="space-y-2">
<h2 className="text-2xl font-semibold tracking-tight">
Analyze Extrinsic
</h2>
<p className="max-w-3xl text-sm text-muted-foreground">
Decode a SCALE-encoded extrinsic against the selected block metadata,
inspect its signer and extensions, and review the exact call payload
and fee priority data.
</p>
</header>
<section className="rounded-xl border border-foreground/10 bg-card p-4 shadow-sm">
<div className="flex gap-4 flex-col lg:flex-row">
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-foreground/60">
Reference Block
</div>
<BlockPicker />
</div>
<div className="space-y-2 w-full">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-foreground/60">
Extrinsic Bytes (hex)
</div>
<TextInputField
className="w-full rounded-md bg-input"
value={extrinsicHex}
onChange={setExtrinsicHex}
placeholder="0x..."
/>
</div>
</div>
</section>
<Subscribe source$={extrinsicDecoder$} fallback={null}>
{extrinsicHex ? <ExtrinsicDecoder extrinsic={extrinsicHex} /> : null}
</Subscribe>
</div>
)
}
@@ -0,0 +1,191 @@
import { CopyBinary } from "@/codec-components/ViewCodec/CopyBinary"
import { AccountIdDisplay } from "@/components/AccountIdDisplay"
import { CopyText } from "@/components/Copy"
import { JsonDisplay } from "@/components/JsonDisplay"
import { blockInfoState$ } from "@/pages/Explorer/block.state"
import { BlockContext } from "@/pages/Explorer/Detail/blockContext"
import { SignedExtensions } from "@/pages/Explorer/Detail/SignedExtensions"
import { chainClient$ } from "@/state/chains/chain.state"
import { shortStr } from "@/utils"
import { getExtrinsicDecoder } from "@polkadot-api/tx-utils"
import { useStateObservable, withDefault } from "@react-rxjs/core"
import { HexString, TxCallData } from "polkadot-api"
import { fromHex, toHex } from "polkadot-api/utils"
import { FC, ReactNode, useMemo } from "react"
import { map, merge, switchMap } from "rxjs"
import { senderToAddress } from "../../Explorer/Detail/Extrinsic"
import { AnalyzePriority, analyzePriority$ } from "./Priority"
import { selectedBlock$, selectedBlockHex$ } from "./selectedBlock"
const extDecoder$ = selectedBlock$.pipeState(
map((block) => getExtrinsicDecoder(block.ctx.metadataRaw)),
)
const selectedBlockInfo$ = selectedBlockHex$.pipeState(
switchMap((hex) => (hex ? blockInfoState$(hex) : [null])),
withDefault(null),
)
const hasher$ = chainClient$.pipeState(
switchMap((v) => v.chainHead.hasher$),
withDefault(null),
)
export const ExtrinsicDecoder: FC<{
extrinsic: HexString
}> = ({ extrinsic }) => {
const hasher = useStateObservable(hasher$)
const block = useStateObservable(selectedBlockInfo$)
const extrinsicDecoder = useStateObservable(extDecoder$)
const decodeResult = useMemo(() => {
try {
return {
type: "success" as const,
value: extrinsicDecoder(extrinsic),
}
} catch (ex) {
console.error(ex)
return {
type: "error" as const,
value: ex as Error,
}
}
}, [extrinsicDecoder, extrinsic])
const txHash = useMemo(
() => (hasher ? toHex(hasher(fromHex(extrinsic))) : null),
[extrinsic, hasher],
)
if (decodeResult.type === "error") {
return (
<div className="rounded-xl border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-700 dark:text-red-300">
<div className="text-xs font-semibold uppercase tracking-[0.2em]">
Decode Error
</div>
<div className="mt-2">
Can&apos;t decode: {decodeResult.value.message}
</div>
</div>
)
}
const decoded = decodeResult.value
const signerAddress =
decoded.type === "signed" ? senderToAddress(decoded.address) : null
return (
<BlockContext value={block}>
<div className="space-y-4">
<SectionCard className="space-y-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-foreground/60">
Decoded Extrinsic
</div>
<h2 className="text-2xl font-semibold tracking-tight capitalize">
{decoded.type} Transaction v{decoded.version}
</h2>
<div className="text-sm text-muted-foreground">
<p>
{decoded.call.type}.{decoded.call.value.type}
</p>
<p>
Hash: {txHash ? shortStr(txHash, 14) : null}{" "}
<CopyText text={txHash ?? ""} />
</p>
</div>
</div>
{signerAddress ? (
<div className="rounded-lg border border-foreground/10 bg-foreground/5 px-3 py-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Signer
</div>
<div className="mt-1">
<AccountIdDisplay value={signerAddress} />
</div>
</div>
) : null}
</div>
</SectionCard>
{decoded.type === "signed" ? (
<SectionCard title="Signed Extensions">
<SignedExtensions extra={decoded.extra} title={false} />
</SectionCard>
) : decoded.type === "general" ? (
<SectionCard>
<div className="text-sm text-muted-foreground">
General transaction analysis is not implemented yet.
</div>
</SectionCard>
) : null}
<SectionCard title="Priority Analysis">
<AnalyzePriority extrinsic={extrinsic} />
</SectionCard>
<CallData
call={decoded.call as TxCallData}
callData={decoded.callData}
/>
</div>
</BlockContext>
)
}
const CallData: FC<{ call: TxCallData; callData: Uint8Array }> = ({
call,
callData,
}) => (
<SectionCard title="Call Payload">
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-lg font-semibold tracking-tight">
{call.type}.{call.value.type}
</div>
<p className="text-sm text-muted-foreground">
Decoded arguments for the call embedded in this extrinsic.
</p>
</div>
<div className="min-w-0 rounded-lg border border-foreground/10 bg-foreground/5 px-3 py-2 lg:max-w-xl">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Call Data
</div>
<div className="mt-1 flex items-start gap-2">
<div className="shrink overflow-auto font-mono text-xs text-muted-foreground">
{toHex(callData)}
</div>
<CopyBinary value={callData} />
</div>
</div>
</div>
<div className="overflow-auto rounded-xl border border-foreground/10 bg-background/60 p-3">
<JsonDisplay src={call.value.value} />
</div>
</div>
</SectionCard>
)
export const extrinsicDecoder$ = merge(extDecoder$, analyzePriority$)
const SectionCard: FC<{
children: ReactNode
title?: string
className?: string
}> = ({ children, title, className }) => (
<section
className={[
"rounded-xl border border-foreground/10 bg-card p-4 shadow-sm",
className,
]
.filter(Boolean)
.join(" ")}
>
{title ? (
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-foreground/60">
{title}
</div>
) : null}
{children}
</section>
)
+185
View File
@@ -0,0 +1,185 @@
import { client$ } from "@/state/chains/chain.state"
import { polkadot_people } from "@polkadot-api/descriptors"
import { state, useStateObservable } from "@react-rxjs/core"
import { Binary, HexString } from "polkadot-api"
import { FC, ReactNode, useEffect, useState } from "react"
import { combineLatest, firstValueFrom, switchMap } from "rxjs"
import { selectedBlockHex$ } from "./selectedBlock"
import { TokenAmount } from "@/components/TokenAmount"
const maxBlockSize$ = state(
combineLatest([selectedBlockHex$, client$]).pipe(
switchMap(([selectedBlock, client]) =>
Promise.all([
client.getTypedApi(polkadot_people).constants.System.BlockLength({
at: selectedBlock,
}),
client.getTypedApi(polkadot_people).constants.System.BlockWeights({
at: selectedBlock,
}),
]).then(
([length, weights]) => ({
length: length.max,
maxWeight: weights.max_block,
}),
() => null,
),
),
),
)
export const AnalyzePriority: FC<{
txPayment?: { tip?: bigint }
extrinsic: HexString
}> = ({ txPayment, extrinsic }) => {
const blockHex = useStateObservable(selectedBlockHex$)
const maxBlockSize = useStateObservable(maxBlockSize$)
const [queryInfo, setQueryInfo] = useState<{
weight: Weight
class: { type: "Normal" | "Operational" | "Mandatory" }
partial_fee: bigint
} | null>(null)
const length = (extrinsic.length - 2) / 8
const tip = txPayment?.tip ?? 0n
useEffect(() => {
let cancelled = false
;(async () => {
setQueryInfo(null)
const client = await firstValueFrom(client$)
const result = await client
.getUnsafeApi()
.apis.TransactionPaymentApi.query_info(
Binary.fromOpaque(extrinsic),
length,
{
at: blockHex,
},
)
if (!cancelled) {
setQueryInfo(result as any)
}
})()
return () => {
cancelled = true
}
}, [extrinsic, blockHex, length])
if (!queryInfo) return null
const priority = (() => {
if (!maxBlockSize) return null
const maxLength =
maxBlockSize.length[
queryInfo.class.type.toLocaleLowerCase() as keyof typeof maxBlockSize.length
]
const maxTxPerBlockWeight = divWeight(
maxBlockSize.maxWeight,
queryInfo.weight,
)
const maxTxPerBlockLength = BigInt(Math.floor(maxLength / length))
const maxTxPerBlock =
maxTxPerBlockWeight < maxTxPerBlockLength
? maxTxPerBlockWeight
: maxTxPerBlockLength
const priority = (tip + 1n) * maxTxPerBlock
const limitingFactor =
maxTxPerBlockWeight < maxTxPerBlockLength ? "Weight" : "Length"
return {
priority,
maxTxPerBlockLength,
maxTxPerBlockWeight,
maxTxPerBlock,
limitingFactor,
}
})()
return (
<div className="space-y-4">
{priority ? (
<div className="flex flex-col gap-2 border-b border-foreground/10 pb-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Computed Priority
</div>
<div className="mt-1 font-mono text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
{priority.priority.toLocaleString()}
</div>
</div>
</div>
) : null}
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(18rem,0.9fr)]">
<div className="rounded-lg border border-foreground/10 bg-foreground/5 p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Inputs
</div>
<div className="mt-3 space-y-2">
<DetailRow label="Tip" value={<TokenAmount>{tip}</TokenAmount>} />
<DetailRow
label="Partial Fee"
value={<TokenAmount>{queryInfo.partial_fee}</TokenAmount>}
/>
<DetailRow label="Class" value={queryInfo.class.type} />
<DetailRow label="Encoded Length" value={length.toLocaleString()} />
<DetailRow
label="Weight"
value={`${(Number(queryInfo.weight.proof_size) / 1024).toLocaleString(undefined, { maximumSignificantDigits: 3 })} KB / ${(Number(queryInfo.weight.ref_time) / 1_000_000).toLocaleString(undefined, { maximumSignificantDigits: 3 })} ms`}
/>
</div>
</div>
<div className="rounded-lg border border-foreground/10 bg-foreground/5 p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Capacity
</div>
<div className="mt-3 space-y-2">
<DetailRow
label="Tx/Block by Length"
value={
priority ? priority.maxTxPerBlockLength.toLocaleString() : "N/A"
}
/>
<DetailRow
label="Tx/Block by Weight"
value={
priority ? priority.maxTxPerBlockWeight.toLocaleString() : "N/A"
}
/>
<DetailRow
label="Limited By"
value={priority ? priority.limitingFactor : "N/A"}
/>
</div>
</div>
</div>
{!priority ? (
<div className="rounded-lg border border-foreground/10 bg-background/60 p-3 text-xs text-muted-foreground">
Waiting for block limits to finish the priority calculation.
</div>
) : null}
</div>
)
}
export const analyzePriority$ = maxBlockSize$
type Weight = { ref_time: bigint; proof_size: bigint }
const divWeight = (a: Weight, b: Weight) => {
const ref_time = b.ref_time === 0n ? 1n : a.ref_time / b.ref_time
const proof_size = b.proof_size === 0n ? 1n : a.proof_size / b.proof_size
return ref_time < proof_size ? ref_time : proof_size
}
const DetailRow: FC<{ label: string; value: ReactNode }> = ({
label,
value,
}) => (
<div className="flex items-baseline justify-between gap-4 border-b border-foreground/8 pb-2 last:border-b-0 last:pb-0">
<div className="text-sm text-muted-foreground">{label}</div>
<div className="text-right font-mono text-sm text-foreground">{value}</div>
</div>
)
@@ -0,0 +1,6 @@
import { state } from "@react-rxjs/core"
import { map } from "rxjs"
import { selectedBlock$ } from "../../Storage/BlockPicker"
export { selectedBlock$ }
export const selectedBlockHex$ = state(selectedBlock$.pipe(map((v) => v.hash)))
+111 -86
View File
@@ -1,25 +1,27 @@
import { BinaryDisplay } from "@/codec-components/LookupTypeEdit"
import { ActionButton, actionButtonClassName } from "@/components/ActionButton"
import { ButtonGroup } from "@/components/ButtonGroup"
import { LoadingMetadata } from "@/components/Loading"
import { withSubscribe } from "@/components/withSuspense"
import { getHashParams, setHashParams } from "@/hashParams"
import { getHashParams, Link, useSyncHashParam } from "@/hashParams"
import { runtimeCtx$ } from "@/state/chains/chain.state"
import { cn } from "@/utils"
import {
CodecComponentType,
CodecComponentValue,
} from "@polkadot-api/react-builder"
import { fromHex, toHex } from "polkadot-api/utils"
import { state, useStateObservable } from "@react-rxjs/core"
import { useLayoutEffect, useState } from "react"
import { useLocation } from "react-router-dom"
import { FileSearch, Settings } from "lucide-react"
import { fromHex, toHex } from "polkadot-api/utils"
import { FC, useState } from "react"
import { Route, Routes, useLocation } from "react-router-dom"
import { map } from "rxjs"
import { twMerge } from "tailwind-merge"
import { CustomSignedExt, customSignedExtensions$ } from "./CustomSignedExt"
import { EditMode } from "./EditMode"
import { ExtrinsicAnalyzer } from "./Analyzer/ExtrinsicAnalyzer"
import { JsonMode } from "./JsonMode"
import { ExtrinsicModal } from "./SubmitTx/SubmitTx"
import { ActionButton } from "@/components/ActionButton"
import { Settings } from "lucide-react"
import { CustomSignedExt, customSignedExtensions$ } from "./CustomSignedExt"
const extrinsicProps$ = state(
runtimeCtx$.pipe(
@@ -54,9 +56,6 @@ const customExtensionsCount$ = state(
export const Extrinsics = withSubscribe(
() => {
const [viewMode, setViewMode] = useState<"edit" | "json">("edit")
const [editingExtensions, setEditingExtensions] = useState(false)
const extrinsicProps = useStateObservable(extrinsicProps$)
const location = useLocation()
const [componentValue, setComponentValue] = useState<CodecComponentValue>({
@@ -65,93 +64,119 @@ export const Extrinsics = withSubscribe(
})
const binaryValue =
(componentValue.type === CodecComponentType.Initial
? componentValue.value
? typeof componentValue.value === "string"
? fromHex(componentValue.value)
: componentValue.value
: componentValue.value.empty
? null
: componentValue.value.encoded) ?? null
useLayoutEffect(() => {
if (binaryValue && binaryValue.length < 1024 * 1024) {
setHashParams({
data:
typeof binaryValue === "string" ? binaryValue : toHex(binaryValue),
})
} else {
setHashParams({
data: null,
})
}
}, [binaryValue])
if (editingExtensions)
return <CustomSignedExt onClose={() => setEditingExtensions(false)} />
useSyncHashParam("data", [binaryValue], (v) =>
v && v.length < 1024 * 1024 ? toHex(v) : null,
)
return (
<div
className={twMerge(
"flex flex-col overflow-hidden gap-2 p-4 pb-0",
// Bypassing top-level scroll area, since we need a specific scroll area for the tree view
"absolute w-full h-full max-w-(--breakpoint-xl)",
)}
>
<BinaryDisplay
{...extrinsicProps}
value={componentValue}
onUpdate={(value) =>
setComponentValue({ type: CodecComponentType.Updated, value })
<Routes>
<Route path="analyzer" element={<ExtrinsicAnalyzer />} />
<Route
path="*"
element={
<ExtrinsicEditor
componentValue={componentValue}
onValueChange={setComponentValue}
binaryValue={binaryValue}
/>
}
/>
<div className="flex flex-row justify-between px-2">
<ButtonGroup
value={viewMode}
onValueChange={setViewMode as any}
items={[
{
value: "edit",
content: "Edit",
},
{
value: "json",
content: "JSON",
disabled: !binaryValue,
},
]}
/>
<div className="flex flex-row items-center gap-2">
<ActionButton
className="text-foreground/70 flex items-center gap-1"
onClick={() => setEditingExtensions(true)}
>
{customExtensionsCount$}
<Settings />
</ActionButton>
<ExtrinsicModal callData={binaryValue ?? undefined} />
</div>
</div>
{viewMode === "edit" ? (
<EditMode
{...extrinsicProps}
value={componentValue}
onUpdate={(value) =>
setComponentValue({ type: CodecComponentType.Updated, value })
}
/>
) : (
<JsonMode
value={
typeof binaryValue === "string"
? fromHex(binaryValue)
: binaryValue
}
decode={extrinsicProps.codec.dec}
/>
)}
</div>
</Routes>
)
},
{
fallback: <LoadingMetadata />,
},
)
const ExtrinsicEditor: FC<{
componentValue: CodecComponentValue
onValueChange: (value: CodecComponentValue) => void
binaryValue: Uint8Array | null
}> = ({ componentValue, onValueChange, binaryValue }) => {
const [viewMode, setViewMode] = useState<"edit" | "json">("edit")
const [page, setPage] = useState<"extensions" | null>(null)
const extrinsicProps = useStateObservable(extrinsicProps$)
if (page === "extensions")
return <CustomSignedExt onClose={() => setPage(null)} />
return (
<div
className={twMerge(
"flex flex-col overflow-hidden gap-2 p-4 pb-0",
// Bypassing top-level scroll area, since we need a specific scroll area for the tree view
"absolute w-full h-full max-w-(--breakpoint-xl)",
)}
>
<BinaryDisplay
{...extrinsicProps}
value={componentValue}
onUpdate={(value) =>
onValueChange({ type: CodecComponentType.Updated, value })
}
/>
<div className="flex flex-row justify-between px-2">
<ButtonGroup
value={viewMode}
onValueChange={setViewMode as any}
items={[
{
value: "edit",
content: "Edit",
},
{
value: "json",
content: "JSON",
disabled: !binaryValue,
},
]}
/>
<div className="flex flex-row items-center gap-2">
<Link
to="/extrinsics/analyzer"
className={cn(
actionButtonClassName(),
"text-foreground/70 flex items-center gap-1",
)}
>
<FileSearch />
</Link>
<ActionButton
className="text-foreground/70 flex items-center gap-1"
onClick={() => setPage("extensions")}
>
{customExtensionsCount$}
<Settings />
</ActionButton>
<ExtrinsicModal callData={binaryValue ?? undefined} />
</div>
</div>
{viewMode === "edit" ? (
<EditMode
{...extrinsicProps}
value={componentValue}
onUpdate={(value) =>
onValueChange({ type: CodecComponentType.Updated, value })
}
/>
) : (
<JsonMode
value={
typeof binaryValue === "string" ? fromHex(binaryValue) : binaryValue
}
decode={extrinsicProps.codec.dec}
/>
)}
</div>
)
}
+6 -3
View File
@@ -1,6 +1,9 @@
import { Spinner } from "@/components/Icons"
import { runtimeCtx$, runtimeCtxAt$ } from "@/state/chains/chain.state"
import { RuntimeContext } from "@polkadot-api/observable-client"
import {
CachedRuntime,
runtimeCtx$,
runtimeCtxAt$,
} from "@/state/chains/chain.state"
import { Input } from "@polkahub/ui-components"
import {
liftSuspense,
@@ -29,7 +32,7 @@ const loadedCtx$ = state(
debounceTime(200),
switchMap((v) => {
const inner$: Observable<{
ctx: Pick<RuntimeContext, "lookup" | "dynamicBuilder">
ctx: CachedRuntime
hash?: string
}> =
!v.trim() || v === "Latest"
+23 -13
View File
@@ -1,22 +1,24 @@
import { CopyText } from "@/components/Copy"
import { Link } from "@/hashParams"
import { shortStr } from "@/utils"
import { FileSearch, Trash2 } from "lucide-react"
import { TxBroadcastEvent } from "polkadot-api"
import { jsonSerialize, toHex } from "polkadot-api/utils"
import * as React from "react"
import { dismissTransaction, onGoingEvents } from "./transactions.state"
import { Trash2 } from "lucide-react"
import { jsonSerialize } from "polkadot-api/utils"
export const Transaction: React.FC<{
event:
| TxBroadcastEvent
| (TxBroadcastEvent & { raw: Uint8Array })
| {
type: "invalid" | "error"
value: any
txHash: string
raw: Uint8Array
}
index: number
onClose: () => void
}> = ({ event, onClose }) => {
}> = ({ event, index, onClose }) => {
const getStatus = () => {
const { type } = event
switch (event.type) {
@@ -71,15 +73,23 @@ export const Transaction: React.FC<{
return (
<div className="mb-4 p-3 bg-secondary/60 text-secondary-foreground/80 border border-border rounded-lg">
<div className="flex justify-between items-start">
<p className="font-medium">
{shortStr(txHash, 14)} <CopyText text={txHash} />
</p>
<button
className="text-red-500"
onClick={() => dismissTransaction(txHash)}
>
<Trash2 size={16} />
</button>
<div className="min-w-0">
<p className="font-medium">
<span className="text-muted-foreground">{index + 1}.</span>{" "}
{shortStr(txHash, 14)} <CopyText text={txHash} />
</p>
</div>
<div className="flex items-center gap-2">
<Link to={`/extrinsics/analyzer#extrinsic=${toHex(event.raw)}`}>
<FileSearch size={15} />
</Link>
<button
className="text-red-500"
onClick={() => dismissTransaction(txHash)}
>
<Trash2 size={16} />
</button>
</div>
</div>
<p
className={`text-sm ${
+3 -2
View File
@@ -40,7 +40,7 @@ export function Transactions() {
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed bottom-0 border-t border-x border-border right-0 w-full sm:w-96 h-96 bg-card text-card-foreground rounded-t-xl shadow-xl focus:outline-hidden">
<Dialog.Content className="fixed bottom-0 border-t border-x border-border right-0 w-full sm:w-md h-96 bg-card text-card-foreground rounded-t-xl shadow-xl focus:outline-hidden">
<div className="flex justify-between items-center p-4 border-b border-border">
<Dialog.Title className="text-lg font-semibold">
Transactions
@@ -52,10 +52,11 @@ export function Transactions() {
</Dialog.Close>
</div>
<div className="p-4 h-[calc(100%-4rem)] overflow-y-auto">
{list.map((event) => (
{list.map((event, index) => (
<Transaction
key={event.txHash}
event={event}
index={index}
onClose={() => setIsOpen(false)}
/>
))}
@@ -10,6 +10,7 @@ import { InvalidTxError } from "polkadot-api"
import {
catchError,
filter,
map,
merge,
mergeMap,
of,
@@ -33,6 +34,10 @@ const transactions$ = mergeWithKey({ signedTx$, unsignedTx$ }).pipe(
tap((e) => {
txHash = e.txHash
}),
map((event) => ({
...event,
raw: tx.payload,
})),
catchError((err) => {
console.log(err)
console.log(tx.payload, client.getFinalizedBlock())
@@ -43,6 +48,7 @@ const transactions$ = mergeWithKey({ signedTx$, unsignedTx$ }).pipe(
? ("invalid" as const)
: ("error" as const),
txHash,
raw: tx.payload,
value: err,
})
}),
+10 -14
View File
@@ -1,18 +1,21 @@
import { createChopsticksProvider } from "@/chopsticks/chopsticks"
import { getHashParams, setHashParams } from "@/hashParams"
import { getDynamicBuilder, getLookupFn } from "@polkadot-api/metadata-builders"
import type {
ChainHead$,
RuntimeContext,
} from "@polkadot-api/observable-client"
import {
decAnyMetadata,
HexString,
unifyMetadata,
} from "@polkadot-api/substrate-bindings"
import { getExtrinsicDecoder } from "@polkadot-api/tx-utils"
import { fromHex, toHex } from "polkadot-api/utils"
import { liftSuspense, sinkSuspense, state, SUSPENSE } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import { get, update } from "idb-keyval"
import { createClient } from "polkadot-api"
import { withLogsRecorder } from "polkadot-api/logs-provider"
import { fromHex, toHex } from "polkadot-api/utils"
import {
catchError,
concat,
@@ -47,7 +50,6 @@ import {
getWebsocketProvider,
WebsocketSource,
} from "./websocket"
import type { ChainHead$ } from "@polkadot-api/observable-client"
export type ChainSource = WebsocketSource | SmoldotSource
@@ -254,14 +256,6 @@ const uncachedRuntimeCtx$ = chainClient$.pipeState(
filter(Boolean),
)
const withTxDecoder: <T extends { metadataRaw: Uint8Array }>(
input: Observable<T>,
) => Observable<T & { txDecoder: ReturnType<typeof getExtrinsicDecoder> }> =
map((ctx) => ({
...ctx,
txDecoder: getExtrinsicDecoder(ctx.metadataRaw),
}))
export const runtimeCtxAt$ = state((atBlock: string) =>
chainClient$.pipe(
take(1),
@@ -272,17 +266,20 @@ export const runtimeCtxAt$ = state((atBlock: string) =>
client.chainHead.getRuntimeContext$(atBlock)
)
}),
withTxDecoder,
),
)
export type CachedRuntime = Pick<
RuntimeContext,
"metadataRaw" | "lookup" | "dynamicBuilder"
>
export const runtimeCtx$ = chainClient$.pipeState(
switchMap(({ id }) =>
get<MetadataCache>(IDB_KEY).then((cache) =>
cache ? [...cache.entries()].find(([, v]) => v.id === id) : undefined,
),
),
switchMap((cached) => {
switchMap((cached): Observable<RuntimeContext | CachedRuntime> => {
if (!cached) return uncachedRuntimeCtx$
const metadata = unifyMetadata(decAnyMetadata(cached[1].data))
const lookup = getLookupFn(metadata)
@@ -296,7 +293,6 @@ export const runtimeCtx$ = chainClient$.pipeState(
}),
)
}),
withTxDecoder,
)
export const lookup$ = runtimeCtx$.pipeState(map((ctx) => ctx.lookup))