feat: show digest information in block detail (#152)

* feat: decode digest from header

* separate into summary + detail

* break down each digest into its own file
This commit is contained in:
Victor Oliva
2026-06-03 13:06:58 +02:00
committed by GitHub
parent b2f625ba1d
commit 846f7a3562
13 changed files with 645 additions and 47 deletions
+6 -30
View File
@@ -1,15 +1,7 @@
import { blockInfoState$, BlockState, finalized$ } from "@/state/block.state"
import { client$ } from "@/state/chains/chain.state"
import { Polkadot_people } from "@polkadot-api/descriptors"
import {
_void,
Bytes,
HexString,
Struct,
u32,
u64,
Variant,
} from "@polkadot-api/substrate-bindings"
import { HexString } from "@polkadot-api/substrate-bindings"
import { state, useStateObservable } from "@react-rxjs/core"
import { BlockHeader, PolkadotClient } from "polkadot-api"
import { AddressIdentity } from "polkahub"
@@ -24,6 +16,8 @@ import {
switchMap,
take,
} from "rxjs"
import { decodeAuraPreRuntime } from "./digests/aura"
import { decodeBabePreRuntime } from "./digests/babe"
const validatorCache: WeakMap<
PolkadotClient,
@@ -108,24 +102,6 @@ export const BlockAuthor: FC<{ hash: HexString; header: BlockHeader }> = ({
)
}
const DigestWithVRF = Struct({
authority_index: u32,
slot: u64,
vrf_signature: Struct({
pre_output: Bytes(32),
proof: Bytes(64),
}),
})
const BabePreDigest = Variant({
Uknown: _void,
Primary: DigestWithVRF,
SecondaryPlain: Struct({
authority_index: u32,
slot: u64,
}),
SecondaryVRF: DigestWithVRF,
})
const getAuthorityIdx = (header: BlockHeader) => {
const preRuntime = header.digests.find(
(d) => d.type === "preRuntime" && ["BABE", "aura"].includes(d.value.engine),
@@ -138,12 +114,12 @@ const getAuthorityIdx = (header: BlockHeader) => {
switch (preRuntime?.engine) {
case "BABE": {
const babe = BabePreDigest.dec(preRuntime.payload)
if (!babe.value) return null
const babe = decodeBabePreRuntime(preRuntime.payload)
if (!babe?.value) return null
return BigInt(babe.value.authority_index)
}
case "aura": {
return u64.dec(preRuntime.payload)
return decodeAuraPreRuntime(preRuntime.payload)?.slot ?? null
}
}
+40 -17
View File
@@ -6,13 +6,16 @@ import {
blocksByHeight$,
BlockState,
} from "@/state/block.state"
import { shortStr } from "@/utils"
import { state, useStateObservable } from "@react-rxjs/core"
import { combineKeys } from "@react-rxjs/utils"
import { BlockHeader, HexString } from "polkadot-api"
import { FC, ReactNode, useContext } from "react"
import { filter, map, startWith, switchMap, take } from "rxjs"
import { AddBlockToWorkspace } from "../ExplorerWorkspaceEntry"
import { BlockAuthor } from "./BlockAuthor"
import { BlockStatusIcon, statusText } from "./BlockState"
import { DigestDetails } from "./DigestDetails"
export const BlockInfoView: FC = () => {
const block = useContext(BlockContext)
@@ -46,20 +49,34 @@ export const BlockInfoView: FC = () => {
</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} />
</DetailTile>
<DetailTile label="Block author">
<BlockAuthor hash={block.hash} header={block.header} />
</DetailTile>
</div>
<HeaderDetails hash={block.hash as HexString} header={block.header} />
)}
</section>
)
}
const HeaderDetails: FC<{ hash: HexString; header: BlockHeader }> = ({
hash,
header,
}) => {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<DetailTile label="Header commitments">
<div className="space-y-3">
<HashPreview title="Storage root" value={header.stateRoot} />
<HashPreview title="Extrinsics root" value={header.extrinsicRoot} />
</div>
</DetailTile>
<DetailTile label="Block author">
<BlockAuthor hash={hash} header={header} />
</DetailTile>
<DetailTile label="Digest details" className="md:col-span-2">
<DigestDetails header={header} />
</DetailTile>
</div>
)
}
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">
<BlockStatusIcon size={20} state={state} />
@@ -71,17 +88,23 @@ const HashPreview: FC<{ title: string; value: string }> = ({
title,
value,
}) => (
<div className="font-mono text-sm">
{title}: {value.slice(0, 18)}…
<CopyText className="align-middle" text={value} binary />
<div className="flex min-w-0 items-center gap-2">
<span className="w-28 shrink-0 text-xs text-foreground/55">{title}</span>
<span className="min-w-0 truncate font-mono text-sm text-foreground">
{shortStr(value, 10)}
</span>
<CopyText className="shrink-0 text-foreground/65" text={value} binary />
</div>
)
const DetailTile: FC<{ label: string; children: ReactNode }> = ({
label,
children,
}) => (
<div className="rounded-xl border border-foreground/10 bg-foreground/2 p-4">
const DetailTile: FC<{
label: string
children: ReactNode
className?: string
}> = ({ label, children, className = "" }) => (
<div
className={`rounded-lg border border-foreground/10 bg-foreground/2 p-4 ${className}`}
>
<p className="text-xs uppercase tracking-widest text-foreground/60">
{label}
</p>
+144
View File
@@ -0,0 +1,144 @@
import { ExpandBtn } from "@/components/Expand"
import { BlockHeader, HexString } from "polkadot-api"
import { toHex } from "polkadot-api/utils"
import { FC, ReactNode, useState } from "react"
import { auraPreDigest } from "./digests/aura"
import { babePreDigest } from "./digests/babe"
import { beefyConsensus } from "./digests/beef"
import { cmlsPreDigest } from "./digests/cmls"
import { HexDisplay } from "./digests/components"
import { fronConsensus } from "./digests/fron"
import { ismpConsensus } from "./digests/ismp"
import { istmConsensus } from "./digests/istm"
import { randPreDigest } from "./digests/rand"
import { rsprConsensus } from "./digests/rspr"
export const DigestDetails: FC<{ header: BlockHeader }> = ({ header }) => {
if (!header.digests.length) return <span className="text-slate-400">N/A</span>
return (
<ol className="divide-y divide-foreground/10">
{header.digests.map((digest, idx) => (
<DigestRow key={idx} digest={digest} />
))}
</ol>
)
}
const DigestRow: FC<{ digest: HeaderDigest }> = ({ digest }) => {
const [expanded, setExpanded] = useState(false)
const view = getDigestView(digest)
const canExpand = view.details != null
const rawValue =
digest.type === "other"
? toHex(digest.value)
: (digest.value?.payload ?? null)
const engine = digest.type === "other" ? null : (digest.value?.engine ?? null)
return (
<li className="py-2.5 last:pb-0 space-y-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 sm:grid sm:grid-cols-[2rem_6rem_3rem_minmax(0,1fr)_auto] sm:gap-2">
{canExpand ? (
<div>
<button
className="flex h-6 w-6 items-center justify-center rounded-md text-foreground/55 hover:bg-foreground/5 hover:text-foreground"
onClick={() => setExpanded((value) => !value)}
title={expanded ? "Hide details" : "Show details"}
type="button"
>
<ExpandBtn expanded={expanded} />
</button>
</div>
) : (
<div />
)}
<div className="flex items-center gap-2">
<DigestTypeLabel type={digest.type} />
</div>
<span className="font-mono text-sm text-foreground/75">
{engine ?? "-"}
</span>
{view.summary ? (
<div className="flex items-baseline gap-2 text-sm text-foreground">
<span className="shrink-0 text-foreground/55">
{view.summary.label}
</span>
{view.summary.value}
</div>
) : (
<div />
)}
<div className="text-sm hidden sm:block">
{rawValue ? <HexDisplay value={rawValue} /> : null}
</div>
</div>
{canExpand && expanded ? (
<div className="rounded-md bg-foreground/5 px-3 py-2 sm:ml-48">
{view.details}
</div>
) : null}
</li>
)
}
type DigestView = {
summary: {
label: string
value: ReactNode
} | null
details: ReactNode | null
}
const getDigestView = (digest: HeaderDigest): DigestView => {
if (digest.type === "other" || digest.type === "runtimeUpdated")
return {
summary: null,
details: null,
}
const decoder = digestDecoders[digest.type][digest.value.engine]
return (
decoder?.(digest.value.payload) ?? {
summary: null,
details: null,
}
)
}
const digestDecoders: Record<
string,
Record<string, (payload: HexString) => DigestView | null>
> = {
preRuntime: {
BABE: babePreDigest,
aura: auraPreDigest,
CMLS: cmlsPreDigest,
rand: randPreDigest,
},
consensus: {
BEEF: beefyConsensus,
RPSR: rsprConsensus,
fron: fronConsensus,
ISMP: ismpConsensus,
ISTM: istmConsensus,
},
seal: {
// Generally, seals are just the raw signature
},
}
const formatDigestType = (type: string) =>
type === "preRuntime"
? "pre-runtime"
: type === "runtimeUpdated"
? "runtime updated"
: type
const DigestTypeLabel: FC<{ type: string }> = ({ type }) => (
<span className="w-fit whitespace-nowrap rounded-md border border-foreground/15 bg-foreground/5 px-2 py-0.5 text-xs font-medium text-foreground/75">
{formatDigestType(type)}
</span>
)
type HeaderDigest = BlockHeader["digests"][number]
@@ -0,0 +1,18 @@
import { Struct, u64 } from "@polkadot-api/substrate-bindings"
import { HexString } from "polkadot-api"
import { createDecodeDigestFn } from "./components"
export const auraPreDigest = (payload: HexString) => {
const decoded = decodeAuraPreRuntime(payload)
return decoded
? {
summary: { label: "Slot", value: decoded.slot.toString() },
details: null,
}
: null
}
const AuraDigest = Struct({
slot: u64,
})
export const decodeAuraPreRuntime = createDecodeDigestFn(AuraDigest)
@@ -0,0 +1,81 @@
import {
_void,
Bytes,
Struct,
u32,
u64,
Variant,
} from "@polkadot-api/substrate-bindings"
import { HexString } from "polkadot-api"
import { toHex } from "polkadot-api/utils"
import { createDecodeDigestFn, FieldGrid, HexDisplay } from "./components"
export const babePreDigest = (payload: HexString) => {
const decoded = decodeBabePreRuntime(payload)
if (!decoded) return null
const digestValue =
decoded.value && typeof decoded.value == "object"
? decoded.value
: {
authority_index: null,
slot: null,
}
const vrf =
decoded.value &&
typeof decoded.value == "object" &&
"vrf_signature" in decoded.value
? decoded.value.vrf_signature
: null
return {
summary:
digestValue.slot != null
? { label: "Slot", value: digestValue.slot.toString() }
: { label: "Kind", value: decoded.type },
details: (
<FieldGrid
fields={[
{ label: "Kind", value: decoded.type },
digestValue.authority_index != null
? {
label: "Authority",
value: `#${digestValue.authority_index}`,
}
: null,
vrf
? {
label: "VRF output",
value: <HexDisplay value={toHex(vrf.pre_output)} />,
}
: null,
vrf
? {
label: "VRF proof",
value: <HexDisplay value={toHex(vrf.proof)} />,
}
: null,
]}
/>
),
}
}
const DigestWithVRF = Struct({
authority_index: u32,
slot: u64,
vrf_signature: Struct({
pre_output: Bytes(32),
proof: Bytes(64),
}),
})
const BabePreDigest = Variant({
Unknown: _void,
Primary: DigestWithVRF,
SecondaryPlain: Struct({
authority_index: u32,
slot: u64,
}),
SecondaryVRF: DigestWithVRF,
})
export const decodeBabePreRuntime = createDecodeDigestFn(BabePreDigest)
@@ -0,0 +1,59 @@
import {
_void,
Hex,
Struct,
u32,
u64,
Variant,
Vector,
} from "@polkadot-api/substrate-bindings"
import { HexString } from "polkadot-api"
import { createDecodeDigestFn, FieldList, HexDisplay } from "./components"
export const beefyConsensus = (payload: HexString) => {
const decoded = decodeBeefyConsensus(payload)
if (!decoded) return null
if (decoded.type === "AuthoritiesChange") {
return {
summary: {
label: "AuthoritiesChange",
value: decoded.value.id.toString(),
},
details: (
<FieldList
title="Validators"
values={decoded.value.validators.map((hash) => (
<HexDisplay value={hash} />
))}
/>
),
}
}
if (decoded.type === "MmrRoot") {
return {
summary: {
label: "MMR root",
value: <HexDisplay value={decoded.value} />,
},
details: null,
}
}
if (decoded.type === "OnDisabled") {
return {
summary: { label: "OnDisabled", value: `#${decoded.value}` },
details: null,
}
}
return { summary: { label: "Kind", value: decoded.type }, details: null }
}
const BeefyConsensusLog = Variant({
Unknown: _void,
AuthoritiesChange: Struct({
validators: Vector(Hex(33)),
id: u64,
}),
OnDisabled: u32,
MmrRoot: Hex(32),
})
const decodeBeefyConsensus = createDecodeDigestFn(BeefyConsensusLog)
@@ -0,0 +1,79 @@
import {
_void,
bool,
compactNumber,
Hex,
Struct,
u8,
Variant,
} from "@polkadot-api/substrate-bindings"
import { HexString } from "polkadot-api"
import { createDecodeDigestFn, FieldGrid, HexDisplay } from "./components"
export const cmlsPreDigest = (payload: HexString) => {
const decoded = decodeCmlsPreRuntime(payload)
if (!decoded) return null
if (decoded.type === "BlockBundleInfo") {
return {
summary: { label: "Bundle", value: `#${decoded.value.index}` },
details: (
<FieldGrid
fields={[
{
label: "Is last",
value: decoded.value.isLast ? "Yes" : "No",
},
]}
/>
),
}
}
if (decoded.type === "CoreInfo") {
return {
summary: {
label: "Core info selector",
value: `${decoded.value.selector}`,
},
details: (
<FieldGrid
fields={[
{
label: "Claim offset",
value: decoded.value.claimQueueOffset.toString(),
},
{
label: "Cores",
value: decoded.value.numberOfCores.toString(),
},
]}
/>
),
}
}
if (decoded.type === "RelayParent") {
return {
summary: {
label: "Relay parent",
value: <HexDisplay value={decoded.value} />,
},
details: null,
}
}
return { summary: { label: "Kind", value: decoded.type }, details: null }
}
const ClmsDigestItem = Variant({
RelayParent: Hex(32),
CoreInfo: Struct({
selector: u8,
claimQueueOffset: u8,
numberOfCores: compactNumber,
}),
BlockBundleInfo: Struct({
index: u8,
isLast: bool,
}),
UseFullCore: _void,
})
const decodeCmlsPreRuntime = createDecodeDigestFn(ClmsDigestItem)
@@ -0,0 +1,62 @@
import { CopyText } from "@/components/Copy"
import { shortStr } from "@/utils"
import { Codec, HexString } from "polkadot-api"
import { FC, ReactNode } from "react"
export const FieldGrid: FC<{
fields: Array<{
label: string
value: ReactNode
} | null>
}> = ({ fields }) => (
<div className="grid gap-x-6 gap-y-1 md:grid-cols-2">
{fields.map((field, idx) =>
field ? <DigestDetailField key={idx} field={field} /> : null,
)}
</div>
)
export const FieldList: FC<{
title: string
values: Array<ReactNode>
}> = ({ title, values }) => (
<div className="space-y-2 text-sm">
<div className="text-xs font-medium uppercase tracking-wide">{title}</div>
{values.length ? (
<ol className="list-decimal space-y-1 pl-5">
{values.map((value, i) => (
<li key={i}>{value}</li>
))}
</ol>
) : (
<div className="text-foreground/50">(Empty)</div>
)}
</div>
)
export const DigestDetailField: FC<{
field: { label: string; value: ReactNode }
}> = ({ field }) => (
<div className="grid grid-cols-[6.5rem_minmax(0,1fr)] items-baseline gap-2 text-sm">
<span className="text-foreground/55">{field.label}</span>
{field.value}
</div>
)
export const HexDisplay: FC<{ value: HexString }> = ({ value }) => (
<span className="flex items-center gap-1">
<span className="truncate font-mono">{shortStr(value, 8)}</span>
<CopyText className="shrink-0 text-foreground" text={value} binary={true} />
</span>
)
export const createDecodeDigestFn =
<T,>(codec: Codec<T>) =>
(payload: HexString) => {
try {
return codec.dec(payload)
} catch (ex) {
console.error(ex)
return null
}
}
@@ -0,0 +1,42 @@
import {
_void,
Hex,
Struct,
Variant,
Vector,
} from "@polkadot-api/substrate-bindings"
import { HexString } from "polkadot-api"
import { createDecodeDigestFn, FieldList, HexDisplay } from "./components"
export const fronConsensus = (payload: HexString) => {
const decoded = decodeFrontierConsensus(payload)
if (!decoded) return null
if (decoded.type === "Hashes") {
return {
summary: {
label: "Block Hash",
value: <HexDisplay value={decoded.value.blockHash} />,
},
details: (
<FieldList
title="Tx Hashes"
values={decoded.value.txHashes.map((hash) => (
<HexDisplay value={hash} />
))}
/>
),
}
}
return { summary: { label: "Kind", value: decoded.type }, details: [] }
}
const FrontierConsensusLog = Variant({
Unknown: _void,
Hashes: Struct({
blockHash: Hex(32),
txHashes: Vector(Hex(32)),
}),
// It's actually an ethereum block header, might be too long
Block: _void,
})
const decodeFrontierConsensus = createDecodeDigestFn(FrontierConsensusLog)
@@ -0,0 +1,31 @@
import { Hex, Struct } from "@polkadot-api/substrate-bindings"
import { HexString } from "polkadot-api"
import { createDecodeDigestFn, FieldGrid, HexDisplay } from "./components"
export const ismpConsensus = (payload: HexString) => {
const decoded = decodeIsmpConsensus(payload)
return decoded
? {
summary: {
label: "Child trie",
value: <HexDisplay value={decoded.childTrieRoot} />,
},
details: (
<FieldGrid
fields={[
{
label: "MMR root",
value: <HexDisplay value={decoded.mmrRoot} />,
},
]}
/>
),
}
: null
}
const IsmpConsensusDigest = Struct({
mmrRoot: Hex(32),
childTrieRoot: Hex(32),
})
const decodeIsmpConsensus = createDecodeDigestFn(IsmpConsensusDigest)
@@ -0,0 +1,21 @@
import { Struct, u64 } from "@polkadot-api/substrate-bindings"
import { HexString } from "polkadot-api"
import { createDecodeDigestFn } from "./components"
export const istmConsensus = (payload: HexString) => {
const decoded = decodeIsmpTimestampConsensus(payload)
return decoded
? {
summary: {
label: "Timestamp",
value: new Date(Number(decoded.timestamp) * 1000).toISOString(),
},
details: [],
}
: null
}
const IsmpTimestampDigest = Struct({
timestamp: u64,
})
const decodeIsmpTimestampConsensus = createDecodeDigestFn(IsmpTimestampDigest)
@@ -0,0 +1,31 @@
import { Hex, Struct } from "@polkadot-api/substrate-bindings"
import { HexString } from "polkadot-api"
import { createDecodeDigestFn, FieldGrid, HexDisplay } from "./components"
export const randPreDigest = (payload: HexString) => {
const decoded = decodeRandVrfConsensus(payload)
return decoded
? {
summary: {
label: "VRF output",
value: <HexDisplay value={decoded.vrf_output} />,
},
details: (
<FieldGrid
fields={[
{
label: "VRF proof",
value: <HexDisplay value={decoded.vrf_proof} />,
},
]}
/>
),
}
: null
}
const RandVRFPreDigest = Struct({
vrf_output: Hex(32),
vrf_proof: Hex(64),
})
const decodeRandVrfConsensus = createDecodeDigestFn(RandVRFPreDigest)
@@ -0,0 +1,31 @@
import { compactNumber, Hex, Struct } from "@polkadot-api/substrate-bindings"
import { HexString } from "polkadot-api"
import { createDecodeDigestFn, FieldGrid, HexDisplay } from "./components"
export const rsprConsensus = (payload: HexString) => {
const decoded = decodeRsprConsensus(payload)
return decoded
? {
summary: {
label: "Relay block",
value: decoded.blockNumber.toString(),
},
details: (
<FieldGrid
fields={[
{
label: "Storage root",
value: <HexDisplay value={decoded.storageRoot} />,
},
]}
/>
),
}
: null
}
const RsprConsensusLog = Struct({
storageRoot: Hex(32),
blockNumber: compactNumber,
})
const decodeRsprConsensus = createDecodeDigestFn(RsprConsensusLog)