Block detail extrinsics

This commit is contained in:
Victor Oliva
2024-11-01 17:22:06 +01:00
parent 2c28081bdf
commit 6154acf967
6 changed files with 272 additions and 17 deletions
+9 -7
View File
@@ -4,12 +4,12 @@ import { getDynamicBuilder } from "@polkadot-api/metadata-builders"
import { state, useStateObservable } from "@react-rxjs/core"
import { FC, ReactNode } from "react"
import { map } from "rxjs"
import { blockInfo$, BlockState } from "./block.state"
import { blockInfo$, blockInfoState$, BlockState } from "./block.state"
import { filterEvt } from "./Events"
import { CircularProgress } from "@/components/CircularProgress"
import { CircleAlert, CircleCheck, CircleX, Clock } from "lucide-react"
import { Link } from "react-router-dom"
const blockInfoState$ = state((hash: string) => blockInfo$(hash), null)
const maxBlockWeight$ = state(
lookup$.pipe(
map((lookup) => {
@@ -52,11 +52,13 @@ export const BlockPopover: FC<{ hash: string }> = ({ hash }) => {
return (
<div>
<h3 className="font-bold text-lg">
Block{" "}
<span className="font-mono text-sm font-normal text-slate-300">
{hash}
</span>
<h3 className="font-bold text-lg hover:text-polkadot-200">
<Link to={hash}>
Block{" "}
<span className="font-mono text-sm font-normal text-slate-300 hover:text-polkadot-200">
{hash}
</span>
</Link>
</h3>
<p>Status: {statusRep[block.status]}</p>
<div className="flex justify-between items-start">
+233
View File
@@ -0,0 +1,233 @@
import { lookup$ } from "@/chain.state"
import { ExpandBtn } from "@/components/Expand"
import { JsonDisplay } from "@/components/JsonDisplay"
import { groupBy } from "@/lib/groupBy"
import {
getDynamicBuilder,
MetadataLookup,
} from "@polkadot-api/metadata-builders"
import { SystemEvent } from "@polkadot-api/observable-client"
import {
_void,
AccountId,
Bytes,
compact,
enhanceDecoder,
Enum,
SS58String,
Struct,
u8,
Variant,
} from "@polkadot-api/substrate-bindings"
import { state, useStateObservable } from "@react-rxjs/core"
import { Codec, HexString } from "polkadot-api"
import { FC, useEffect, useRef, useState } from "react"
import { useLocation, useParams } from "react-router-dom"
import { map } from "rxjs"
import { twMerge } from "tailwind-merge"
import { blockInfoState$ } from "../block.state"
type ApplyExtrinsicEvent = SystemEvent & { phase: { type: "ApplyExtrinsic" } }
export const BlockDetail = () => {
const { hash } = useParams()
const block = useStateObservable(blockInfoState$(hash ?? ""))
const location = useLocation()
const hashParams = new URLSearchParams(location.hash.slice(1))
const eventParam = hashParams.get("event")
const defaultEventOpen =
eventParam && block?.events
? (block.events[Number(eventParam)] as ApplyExtrinsicEvent)
: null
const eventsByExtrinsic = block?.events
? groupBy(
block.events.filter(
(evt): evt is ApplyExtrinsicEvent =>
evt.phase.type === "ApplyExtrinsic",
),
(evt) => evt.phase.value,
)
: null
if (!block) return null
return (
<div className="overflow-auto">
<div>Summary</div>
<div className="p-2">
<h3>Extrinsics TODO separate unsigned</h3>
<ol>
{block.body?.map((extrinsic, i) => (
<Extrinsic
key={i}
data={extrinsic}
highlightedEvent={defaultEventOpen}
events={eventsByExtrinsic?.[i] ?? []}
/>
))}
</ol>
</div>
</div>
)
}
const extrinsicDecoder$ = state(
lookup$.pipe(map((lookup) => createExtrinsicCodec(lookup))),
null,
)
const Extrinsic: FC<{
data: HexString
highlightedEvent: ApplyExtrinsicEvent | null
events: ApplyExtrinsicEvent[]
}> = ({ data, events, highlightedEvent }) => {
const decode = useStateObservable(extrinsicDecoder$)
const [expanded, setExpanded] = useState(
(highlightedEvent && events.includes(highlightedEvent)) ?? false,
)
if (!decode) return null
const decoded = decode(data)
return (
<li className="p-2 border rounded border-polkadot-700 mb-2">
<button
onClick={() => setExpanded((e) => !e)}
className="flex gap-1 items-center"
>
<ExpandBtn expanded={expanded} />
{decoded.call.type}.{decoded.call.value.type}
</button>
{expanded ? (
<div className="overflow-hidden">
<div className="overflow-auto max-h-[80vh] p-2">
<JsonDisplay src={decoded.call.value.value} />
</div>
<div className="p-2 overflow-auto max-h-[80vh] border-t">
<ol className="flex flex-col gap-1">
{events.map((evt, i) => (
<ExtrinsicEvent
key={i}
index={i}
evt={evt}
defaultOpen={highlightedEvent === evt}
/>
))}
</ol>
</div>
</div>
) : null}
</li>
)
}
const ExtrinsicEvent: FC<{
evt: ApplyExtrinsicEvent
index: number
defaultOpen: boolean
}> = ({ evt, index, defaultOpen }) => {
const [expanded, setExpanded] = useState(defaultOpen)
const ref = useRef<HTMLLIElement>(null)
useEffect(() => {
if (defaultOpen) {
ref.current?.scrollIntoView()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<li
className={twMerge(
"px-2 py-1",
expanded && "py-2 bg-white bg-opacity-10 rounded overflow-auto",
)}
ref={ref}
>
<button
onClick={() => setExpanded((e) => !e)}
className="flex gap-1 items-center"
>
<span>{index}.</span>
<ExpandBtn expanded={expanded} />
<span>{`${evt.event.type}.${evt.event.value.type}`}</span>
</button>
{expanded && <JsonDisplay src={evt.event.value.value} />}
</li>
)
}
const createExtrinsicCodec = (lookup: MetadataLookup) => {
const dynamicBuilder = getDynamicBuilder(lookup)
// https://spec.polkadot.network/id-extrinsics#id-extrinsics-body
const extrinsicHeader = enhanceDecoder(u8.dec, (value) => ({
signed: (value & 0x80) > 0,
version: value & 0x7f,
}))
const sender =
"address" in lookup.metadata.extrinsic
? (dynamicBuilder.buildDefinition(
lookup.metadata.extrinsic.address,
) as Codec<
// TODO Assume MultiAddress, but can be anything really :/
Enum<{
Id: SS58String
}>
>)
: AccountId()
const v14Signature = Variant({
Ed25519: Bytes(64),
Sr25519: Bytes(64),
Ecdsa: Bytes(65),
})
const signature =
"signature" in lookup.metadata.extrinsic
? (dynamicBuilder.buildDefinition(
lookup.metadata.extrinsic.signature,
) as typeof v14Signature)
: v14Signature
const extra =
"extra" in lookup.metadata.extrinsic
? (dynamicBuilder.buildDefinition(
lookup.metadata.extrinsic.extra,
) as Codec<unknown[]>)
: Struct({
mortality: Variant({
Immortal: _void,
...Object.fromEntries(
new Array(255).fill(0).map((_, i) => [`Mortal${i}`, u8]),
),
}),
nonce: compact,
tip: compact,
})
const call = dynamicBuilder.buildDefinition(
"call" in lookup.metadata.extrinsic
? lookup.metadata.extrinsic.call
: lookup.metadata.extrinsic.type,
) as Codec<{ type: string; value: { type: string; value: unknown } }>
const signedExtrinsicV4 = Struct({
sender,
signature,
extra,
call,
})
// Externally, it's an opaque
return enhanceDecoder(Bytes().dec, (bytes: Uint8Array) => {
const header = extrinsicHeader(bytes)
return header.signed
? {
version: header.version,
signed: true as const,
...signedExtrinsicV4.dec(bytes.slice(1)),
}
: {
version: header.version,
signed: false as const,
call: call.dec(bytes.slice(1)),
}
})
}
type DecodedExtrinsic = ReturnType<ReturnType<typeof createExtrinsicCodec>>
+1
View File
@@ -0,0 +1 @@
export * from "./BlockDetail"
+10 -3
View File
@@ -14,6 +14,7 @@ import { MAX_LENGTH } from "./BlockTable"
import { FC } from "react"
import { JsonDisplay } from "@/components/JsonDisplay"
import { groupBy } from "@/lib/groupBy"
import { Link } from "react-router-dom"
export const Events = () => {
const events = useStateObservable(recentEvents$)
@@ -95,7 +96,11 @@ const EventPopover: FC<{ event: EventInfo }> = ({ event }) => {
return (
<div>
<div className="flex justify-between">
<h3 className="font-bold text-lg">Event {eventKey(event)}</h3>
<h3 className="font-bold text-lg">
<Link to={`${event.hash}#event=${event.index}`}>
Event {eventKey(event)}
</Link>
</h3>
<p>
Status:{" "}
{event.status === BlockState.Finalized ? "Finalized" : "Pending"}
@@ -151,9 +156,11 @@ const recentEvents$ = state(
hash: block.hash,
number: block.number,
events: block.events
?.filter((evt) => evt.phase.type === "ApplyExtrinsic")
?.map((evt, index) => ({ ...evt, index }))
.filter((evt) => evt.phase.type === "ApplyExtrinsic")
.filter(filterEvt)
.map((evt) => ({
index: evt.index,
event: evt.event,
extrinsicNumber: (evt.phase as any).value as number,
})),
@@ -178,7 +185,7 @@ const recentEvents$ = state(
)
.flatMap(({ status, hash, number, events }) => {
const eventInfo = events!.map(
({ event, extrinsicNumber }, index): EventInfo => ({
({ event, extrinsicNumber, index }): EventInfo => ({
status,
hash,
number,
+17 -7
View File
@@ -3,18 +3,28 @@ import { blocksByHeight$ } from "./block.state"
import { BlockTable } from "./BlockTable"
import { Summary } from "./Summary"
import { Events } from "./Events"
import { Route, Routes } from "react-router-dom"
import { BlockDetail } from "./Detail"
const explorer$ = blocksByHeight$
export const Explorer = withSubscribe(
() => (
<div className="overflow-auto">
<Summary />
<div className="flex gap-2 items-start flex-wrap lg:flex-nowrap">
<BlockTable />
<Events />
</div>
</div>
<Routes>
<Route path=":hash" element={<BlockDetail />} />
<Route
path="*"
element={
<div className="overflow-auto">
<Summary />
<div className="flex gap-2 items-start flex-wrap lg:flex-nowrap">
<BlockTable />
<Events />
</div>
</div>
}
/>
</Routes>
),
{
source$: explorer$,
+2
View File
@@ -146,6 +146,8 @@ export const [blockInfo$, recordedBlocks$] = partitionByKey(
),
)
export const blockInfoState$ = state((hash: string) => blockInfo$(hash), null)
export const blocksByHeight$ = state(
recordedBlocks$.pipe(
mergeMap((change) => {