Merge pull request #135 from polkadot-api/vo/ext-analyzer
feat: extrinsic analyzer
This commit is contained in:
@@ -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.
@@ -6,7 +6,7 @@
|
||||
"chain": "polkadot_people",
|
||||
"metadata": ".papi/metadata/polkadot_people.scale",
|
||||
"genesis": "0x67fa177a097bfa18f77ea95ab56e9bcdfeb0e5b8a40e46298bb93e16b6fc5008",
|
||||
"codeHash": "0x19d152fef983bf5be332cd07dc6f5c7291d842a4e2d5501eca02a86c10df211d"
|
||||
"codeHash": "0x3f0652ba1159157eff567b8e0e5987c8fc67a614f0909ce0f65f0d8a2b532864"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
@@ -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)))
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user