Block detail extrinsics
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>>
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./BlockDetail"
|
||||
@@ -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,
|
||||
|
||||
@@ -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$,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user