move mortality analyzer to signed extensions

This commit is contained in:
Victor Oliva
2026-05-05 13:11:34 +02:00
parent 689741ed82
commit 23345e7e35
9 changed files with 211 additions and 191 deletions

View File

@@ -8,14 +8,15 @@ 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 { 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 { BlockContext } from "./blockContext"
const blockExtrinsics$ = state((hash: string) => {
const body$ = blockInfoState$(hash).pipe(
@@ -44,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)

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>
)
}

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">

View File

@@ -8,11 +8,12 @@ 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, FileSearch } 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" }
@@ -93,51 +94,6 @@ export const Extrinsic: FC<{
)
}
export 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

View File

@@ -0,0 +1,46 @@
import { client$ } from "@/state/chains/chain.state"
import { useStateObservable, withDefault } from "@react-rxjs/core"
import { FC, useContext } from "react"
import { map, switchMap } from "rxjs"
import { BlockContext } from "./blockContext"
const finalizedNumber$ = client$.pipeState(
switchMap((v) => v.finalizedBlock$),
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>Immortal</div>
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
return (
<div>
period={period} phase={phase}{" "}
{birthBlock != null
? `currentBlock=${selectedBlockNumber?.toLocaleString()} from=${birthBlock.toLocaleString()} to=${(birthBlock + period).toLocaleString()}`
: null}
</div>
)
}

View File

@@ -0,0 +1,73 @@
import { ExpandBtn } from "@/components/Expand"
import { JsonDisplay } from "@/components/JsonDisplay"
import { Dot } from "lucide-react"
import { toHex } from "polkadot-api/utils"
import { ComponentType, FC, useState } from "react"
import { MortalityAnalyzer } from "./MortalityAnalyzer"
export 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]) => {
const KnownExtension = knownSignedExtensions[key]
return KnownExtension ? (
<KnownExtension key={key} id={key} value={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 knownSignedExtensions: Record<
string,
ComponentType<{ id: string; value: unknown }>
> = {
CheckMortality: ({ id, value }) => {
return (
<li className="space-y-2">
<div className="flex gap-2 items-center">
<Dot size={16} />
{id}
</div>
<MortalityAnalyzer mortality={value as any} />
</li>
)
},
}

View File

@@ -0,0 +1,4 @@
import { createContext } from "react"
import { BlockInfo } from "../block.state"
export const BlockContext = createContext<BlockInfo | null>(null)

View File

@@ -1,25 +1,31 @@
import { CopyBinary } from "@/codec-components/ViewCodec/CopyBinary"
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 { DecodedExtrinsic, getExtrinsicDecoder } from "@polkadot-api/tx-utils"
import { state, useStateObservable } from "@react-rxjs/core"
import { useStateObservable, withDefault } from "@react-rxjs/core"
import { HexString, TxCallData } from "polkadot-api"
import { toHex } from "polkadot-api/utils"
import { FC, useMemo } from "react"
import { map, merge } from "rxjs"
import { Sender, SignedExtensions } from "../../Explorer/Detail/Extrinsic"
import { AnalyzeMortality, analyzeMortality$ } from "./Mortality"
import { map, merge, switchMap } from "rxjs"
import { Sender } from "../../Explorer/Detail/Extrinsic"
import { AnalyzePriority, analyzePriority$ } from "./Priority"
import { selectedBlock$ } from "./selectedBlock"
import { selectedBlock$, selectedBlockHex$ } from "./selectedBlock"
export const extDecoder$ = state(
selectedBlock$.pipe(
map((block) => getExtrinsicDecoder(block.ctx.metadataRaw)),
),
const extDecoder$ = selectedBlock$.pipeState(
map((block) => getExtrinsicDecoder(block.ctx.metadataRaw)),
)
const selectedBlockInfo$ = selectedBlockHex$.pipeState(
switchMap((hex) => (hex ? blockInfoState$(hex) : [null])),
withDefault(null),
)
export const ExtrinsicDecoder: FC<{
extrinsic: HexString
}> = ({ extrinsic }) => {
const block = useStateObservable(selectedBlockInfo$)
const extrinsicDecoder = useStateObservable(extDecoder$)
const decodeResult = useMemo(() => {
try {
@@ -43,19 +49,24 @@ export const ExtrinsicDecoder: FC<{
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>
<BlockContext value={block}>
<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>
</BlockContext>
)
}
@@ -63,7 +74,6 @@ const SignedInfo: FC<{
extrinsic: HexString
decoded: DecodedExtrinsic & { type: "signed" }
}> = ({ extrinsic, decoded }) => {
const mortality = decoded.extra.CheckMortality
const txPayment =
decoded.extra.ChargeAssetTxPayment ?? decoded.extra.ChargeTxPayment
@@ -71,7 +81,6 @@ const SignedInfo: FC<{
<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>
)
@@ -97,8 +106,4 @@ const CallData: FC<{ call: TxCallData; callData: Uint8Array }> = ({
</div>
)
export const extrinsicDecoder$ = merge(
extDecoder$,
analyzeMortality$,
analyzePriority$,
)
export const extrinsicDecoder$ = merge(extDecoder$, analyzePriority$)

View File

@@ -1,66 +0,0 @@
import { client$ } from "@/state/chains/chain.state"
import { polkadot_people } from "@polkadot-api/descriptors"
import { state, useStateObservable } from "@react-rxjs/core"
import { FC } from "react"
import { combineLatest, map, switchMap } from "rxjs"
import { selectedBlockHex$ } from "./selectedBlock"
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)),
),
),
)
export 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>
)
}
export const analyzeMortality$ = selectedBlockNumber$