feat: extrinsic analyzer

This commit is contained in:
Victor Oliva
2026-05-05 09:54:35 +02:00
parent a582e62859
commit 228cc3e54f
8 changed files with 366 additions and 33 deletions
+9 -5
View File
@@ -1,9 +1,13 @@
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 { toHex } from "polkadot-api/utils"
import { FC, useState } from "react"
import { useLocation } from "react-router-dom"
import { catchError, combineLatest, filter, map, take } from "rxjs"
@@ -12,9 +16,6 @@ import { BlockInfo, 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"
const blockExtrinsics$ = state((hash: string) => {
const body$ = blockInfoState$(hash).pipe(
@@ -25,10 +26,13 @@ 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,
hash: toHex(Blake2256(raw)),
+2 -2
View File
@@ -92,7 +92,7 @@ export const Extrinsic: FC<{
)
}
const SignedExtensions: FC<{ extra: Record<string, unknown> }> = ({
export const SignedExtensions: FC<{ extra: Record<string, unknown> }> = ({
extra,
}) => (
<div className="space-y-2">
@@ -173,7 +173,7 @@ export const EventDisplay: FC<{
)
}
const Sender: React.FC<{
export const Sender: React.FC<{
sender: Enum<{ Id: SS58String }> | SS58String | HexString
}> = ({ sender }) => {
const value: string | null =
+318
View File
@@ -0,0 +1,318 @@
import { CopyBinary } from "@/codec-components/ViewCodec/CopyBinary"
import { JsonDisplay } from "@/components/JsonDisplay"
import { TextInputField } from "@/components/TextInputField"
import { Button } from "@/components/ui/button"
import { client$ } from "@/state/chains/chain.state"
import { polkadot_people } from "@polkadot-api/descriptors"
import { DecodedExtrinsic, getExtrinsicDecoder } from "@polkadot-api/tx-utils"
import { state, Subscribe, useStateObservable } from "@react-rxjs/core"
import { ChevronLeft } from "lucide-react"
import { Binary, HexString, TxCallData } from "polkadot-api"
import { toHex } from "polkadot-api/utils"
import { FC, useEffect, useMemo, useState } from "react"
import { combineLatest, firstValueFrom, map, merge, switchMap } from "rxjs"
import { Sender, SignedExtensions } from "../Explorer/Detail/Extrinsic"
import { BlockPicker, selectedBlock$ } from "../Storage/BlockPicker"
const extDecoder$ = state(
selectedBlock$.pipe(
map((block) => getExtrinsicDecoder(block.ctx.metadataRaw)),
),
)
const selectedBlockHex$ = state(selectedBlock$.pipe(map((v) => v.hash)))
export const ExtrinsicAnalyzer: FC<{ onClose: () => void }> = ({ onClose }) => {
const [extrinsicHex, setExtrinsicHex] = useState("")
return (
<div className="p-2 space-y-2">
<div className="flex items-center gap-2">
<Button
className="has-[>svg]:px-1"
type="button"
variant="ghost"
onClick={onClose}
>
<ChevronLeft />
</Button>
<h2 className="text-lg font-bold">Analyze Extrinsic</h2>
</div>
<div className="flex items-center gap-1 flex-wrap">
<div>
<label>
Block
<BlockPicker />
</label>
</div>
<div className="grow">
<label>
Extrinsic
<TextInputField
className="w-full"
value={extrinsicHex}
onChange={setExtrinsicHex}
placeholder="Extrinsic Hex"
/>
</label>
</div>
</div>
<Subscribe source$={extAnalyzer$} fallback={null}>
{extrinsicHex ? <DecodeExtrinsic extrinsic={extrinsicHex} /> : null}
</Subscribe>
</div>
)
}
const DecodeExtrinsic: FC<{
extrinsic: HexString
}> = ({ extrinsic }) => {
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])
if (decodeResult.type === "error") {
return <div>Can't decode: {decodeResult.value.message}</div>
}
const decoded = decodeResult.value
return (
<div>
<h2 className="capitalize text-xl font-bold">
{decoded.type} Transaction v{decoded.version}
</h2>
{decoded.type === "signed" ? (
<SignedInfo extrinsic={extrinsic} decoded={decoded} />
) : decoded.type === "general" ? (
<div>TODO</div>
) : (
<AnalyzePriority extrinsic={extrinsic} />
)}
<CallData call={decoded.call as TxCallData} callData={decoded.callData} />
</div>
)
}
const SignedInfo: FC<{
extrinsic: HexString
decoded: DecodedExtrinsic & { type: "signed" }
}> = ({ extrinsic, decoded }) => {
const mortality = decoded.extra.CheckMortality
const txPayment =
decoded.extra.ChargeAssetTxPayment ?? decoded.extra.ChargeTxPayment
return (
<div className="space-y-2 mb-4">
<Sender sender={decoded.address} />
<SignedExtensions extra={decoded.extra} />
{mortality ? <AnalyzeMortality mortality={mortality} /> : null}
<AnalyzePriority extrinsic={extrinsic} txPayment={txPayment ?? {}} />
</div>
)
}
const CallData: FC<{ call: TxCallData; callData: Uint8Array }> = ({
call,
callData,
}) => (
<div>
<div className="flex gap-2 items-baseline">
<div className="text-lg">
{call.type}.{call.value.type}
</div>
<div className="flex gap-1 items-center overflow-hidden">
<div className="shrink overflow-hidden text-ellipsis text-muted-foreground">
{toHex(callData)}
</div>
<CopyBinary value={callData} />
</div>
</div>
<JsonDisplay src={call.value.value} />
</div>
)
const selectedBlockNumber$ = state(
combineLatest([selectedBlockHex$, client$]).pipe(
switchMap(([selectedBlock, client]) =>
selectedBlock
? (client
.getTypedApi(polkadot_people)
.query.System.Number.getValue({
at: selectedBlock,
})
.catch(() => null) as Promise<number | null>)
: client.finalizedBlock$.pipe(map((block) => block.number)),
),
),
)
const AnalyzeMortality: FC<{ mortality: { type: string; value: number } }> = ({
mortality,
}) => {
const selectedBlockNumber = useStateObservable(selectedBlockNumber$)
if (mortality.type === "Immortal") return null
const parseResult = /^Mortal(\d+)$/.exec(mortality.type)
if (parseResult === null) return null
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 referencedBlocks = birthBlock
? new Array(5)
.fill(0)
.map((_, i) => (birthBlock - period * i).toLocaleString())
: null
return (
<div>
<b>Mortality:</b> period={period} phase={phase}{" "}
{referencedBlocks ? (
<>
currentBlock={selectedBlockNumber?.toLocaleString()} nextPeriod=
{(birthBlock! + period).toLocaleString()} oldPeriods=
{referencedBlocks.join(" ")}
</>
) : null}
</div>
)
}
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,
maxWeight: weights.max_block,
}),
() => null,
),
),
),
)
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$)
console.log(extrinsic.length)
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
console.log(queryInfo)
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
return { priority, maxTxPerBlockLength, maxTxPerBlockWeight }
})()
return (
<div>
<b>Priority:</b> tip={tip.toLocaleString()} fee=
{queryInfo.partial_fee.toLocaleString()} class={queryInfo.class.type}{" "}
weight=
{queryInfo.weight.proof_size.toLocaleString() +
"/" +
queryInfo.weight.ref_time.toLocaleString()}{" "}
{priority ? (
<>
txPerBlockLength={priority.maxTxPerBlockLength.toLocaleString()}{" "}
txPerBlockWeight={priority.maxTxPerBlockWeight.toLocaleString()}{" "}
priority={priority.priority.toLocaleString()}
</>
) : null}
</div>
)
}
const extAnalyzer$ = merge(
extDecoder$,
selectedBlockHex$,
selectedBlockNumber$,
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
}
+17 -8
View File
@@ -1,4 +1,5 @@
import { BinaryDisplay } from "@/codec-components/LookupTypeEdit"
import { ActionButton } from "@/components/ActionButton"
import { ButtonGroup } from "@/components/ButtonGroup"
import { LoadingMetadata } from "@/components/Loading"
import { withSubscribe } from "@/components/withSuspense"
@@ -8,18 +9,18 @@ import {
CodecComponentType,
CodecComponentValue,
} from "@polkadot-api/react-builder"
import { fromHex, toHex } from "polkadot-api/utils"
import { state, useStateObservable } from "@react-rxjs/core"
import { FileSearch, Settings } from "lucide-react"
import { fromHex, toHex } from "polkadot-api/utils"
import { useLayoutEffect, useState } from "react"
import { useLocation } from "react-router-dom"
import { map } from "rxjs"
import { twMerge } from "tailwind-merge"
import { ExtrinsicAnalyzer } from "./ExtrinsicAnalyzer"
import { CustomSignedExt, customSignedExtensions$ } from "./CustomSignedExt"
import { EditMode } from "./EditMode"
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(
@@ -55,7 +56,7 @@ const customExtensionsCount$ = state(
export const Extrinsics = withSubscribe(
() => {
const [viewMode, setViewMode] = useState<"edit" | "json">("edit")
const [editingExtensions, setEditingExtensions] = useState(false)
const [page, setPage] = useState<"analyze" | "extensions" | null>(null)
const extrinsicProps = useStateObservable(extrinsicProps$)
const location = useLocation()
@@ -83,8 +84,10 @@ export const Extrinsics = withSubscribe(
}
}, [binaryValue])
if (editingExtensions)
return <CustomSignedExt onClose={() => setEditingExtensions(false)} />
if (page === "extensions")
return <CustomSignedExt onClose={() => setPage(null)} />
if (page === "analyze")
return <ExtrinsicAnalyzer onClose={() => setPage(null)} />
return (
<div
@@ -119,9 +122,15 @@ export const Extrinsics = withSubscribe(
]}
/>
<div className="flex flex-row items-center gap-2">
<ActionButton
className="text-foreground/70"
onClick={() => setPage("analyze")}
>
<FileSearch />
</ActionButton>
<ActionButton
className="text-foreground/70 flex items-center gap-1"
onClick={() => setEditingExtensions(true)}
onClick={() => setPage("extensions")}
>
{customExtensionsCount$}
<Settings />
+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"
+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))