Merge branch 'vo-block-explorer'

This commit is contained in:
Victor Oliva
2024-10-31 17:22:05 +01:00
14 changed files with 837 additions and 6 deletions
+1
View File
@@ -39,6 +39,7 @@ export default tseslint.config(
},
],
"react-refresh/only-export-components": "off",
"prefer-const": "warn",
},
},
)
+1
View File
@@ -15,6 +15,7 @@
"dependencies": {
"@headlessui/react": "^2.2.0",
"@polkadot-api/metadata-builders": "^0.9.1",
"@polkadot-api/observable-client": "^0.5.14",
"@polkadot-api/substrate-bindings": "^0.9.3",
"@polkadot-api/substrate-client": "^0.2.2",
"@polkadot-api/utils": "^0.1.2",
@@ -1,3 +1,32 @@
diff --git a/dist/esm/chainHead/chainHead.mjs b/dist/esm/chainHead/chainHead.mjs
index f1962b05e17ac7c3c66c0ab453a61cbece1525e0..f9ab1b168f3b72410055cc315ba1864576254f9d 100644
--- a/dist/esm/chainHead/chainHead.mjs
+++ b/dist/esm/chainHead/chainHead.mjs
@@ -157,7 +157,7 @@ const getChainHead$ = (chainHead) => {
best$.pipe(map((b) => b.hash))
);
const _body$ = withOptionalHash$(commonEnhancer(lazyFollower("body")));
- const body$ = (hash) => upsertCachedStream(hash, "body", _body$(hash, true));
+ const body$ = (hash, canonical) => upsertCachedStream(hash, "body", _body$(hash, canonical));
const _storage$ = commonEnhancer(lazyFollower("storage"));
const storage$ = withOptionalHash$(
(hash, withCanonicalChain2, type, keyMapper, childTrie = null, mapper) => pinnedBlocks$.pipe(
@@ -237,11 +237,11 @@ const getChainHead$ = (chainHead) => {
runtime$,
metadata$,
header$,
- body$,
- call$: withCanonicalChain(call$),
- storage$: withCanonicalChain(storage$),
+ body$: withCanonicalChain(body$, false),
+ call$: withCanonicalChain(call$, false),
+ storage$: withCanonicalChain(storage$, false),
storageQueries$,
- eventsAt$: withCanonicalChain(eventsAt$),
+ eventsAt$: withCanonicalChain(eventsAt$, false),
trackTx$,
trackTxWithoutEvents$,
validateTx$,
diff --git a/dist/esm/getObservableClient.mjs b/dist/esm/getObservableClient.mjs
index 511be34f2b04e0125858e03ff1dfce2759d3041b..b86e02a655903e3f5277269c5f46ca03b00b7ada 100644
--- a/dist/esm/getObservableClient.mjs
+7 -4
View File
@@ -6,7 +6,7 @@ settings:
patchedDependencies:
'@polkadot-api/observable-client':
hash: 2dyuwkurmwrhcez6heyjslsxsu
hash: 37l2tyyzplymltubkxaic2hvlm
path: patches/@polkadot-api__observable-client.patch
'@polkadot-api/substrate-client':
hash: lsuryiezeyeaoedwoxh2e4wda4
@@ -25,6 +25,9 @@ importers:
'@polkadot-api/metadata-builders':
specifier: ^0.9.1
version: 0.9.1
'@polkadot-api/observable-client':
specifier: ^0.5.14
version: 0.5.14(patch_hash=37l2tyyzplymltubkxaic2hvlm)(@polkadot-api/substrate-client@0.2.2(patch_hash=lsuryiezeyeaoedwoxh2e4wda4))(rxjs@7.8.1)
'@polkadot-api/substrate-bindings':
specifier: ^0.9.3
version: 0.9.3
@@ -2971,7 +2974,7 @@ snapshots:
'@polkadot-api/json-rpc-provider': 0.0.4
'@polkadot-api/known-chains': 0.5.5
'@polkadot-api/metadata-compatibility': 0.1.11
'@polkadot-api/observable-client': 0.5.14(patch_hash=2dyuwkurmwrhcez6heyjslsxsu)(@polkadot-api/substrate-client@0.2.2(patch_hash=lsuryiezeyeaoedwoxh2e4wda4))(rxjs@7.8.1)
'@polkadot-api/observable-client': 0.5.14(patch_hash=37l2tyyzplymltubkxaic2hvlm)(@polkadot-api/substrate-client@0.2.2(patch_hash=lsuryiezeyeaoedwoxh2e4wda4))(rxjs@7.8.1)
'@polkadot-api/polkadot-sdk-compat': 2.3.1
'@polkadot-api/sm-provider': 0.1.3(smoldot@2.0.30)
'@polkadot-api/smoldot': 0.3.3
@@ -3038,7 +3041,7 @@ snapshots:
'@polkadot-api/metadata-builders': 0.9.1
'@polkadot-api/substrate-bindings': 0.9.3
'@polkadot-api/observable-client@0.5.14(patch_hash=2dyuwkurmwrhcez6heyjslsxsu)(@polkadot-api/substrate-client@0.2.2(patch_hash=lsuryiezeyeaoedwoxh2e4wda4))(rxjs@7.8.1)':
'@polkadot-api/observable-client@0.5.14(patch_hash=37l2tyyzplymltubkxaic2hvlm)(@polkadot-api/substrate-client@0.2.2(patch_hash=lsuryiezeyeaoedwoxh2e4wda4))(rxjs@7.8.1)':
dependencies:
'@polkadot-api/metadata-builders': 0.9.1
'@polkadot-api/substrate-bindings': 0.9.3
@@ -4325,7 +4328,7 @@ snapshots:
'@polkadot-api/logs-provider': 0.0.6
'@polkadot-api/metadata-builders': 0.9.1
'@polkadot-api/metadata-compatibility': 0.1.11
'@polkadot-api/observable-client': 0.5.14(patch_hash=2dyuwkurmwrhcez6heyjslsxsu)(@polkadot-api/substrate-client@0.2.2(patch_hash=lsuryiezeyeaoedwoxh2e4wda4))(rxjs@7.8.1)
'@polkadot-api/observable-client': 0.5.14(patch_hash=37l2tyyzplymltubkxaic2hvlm)(@polkadot-api/substrate-client@0.2.2(patch_hash=lsuryiezeyeaoedwoxh2e4wda4))(rxjs@7.8.1)
'@polkadot-api/pjs-signer': 0.5.2
'@polkadot-api/polkadot-sdk-compat': 2.3.1
'@polkadot-api/polkadot-signer': 0.1.6
+2
View File
@@ -1,5 +1,6 @@
import { Navigate, Route, Routes } from "react-router-dom"
import { Constants } from "./pages/Constants"
import { Explorer } from "./pages/Explorer"
import { Extrinsics } from "./pages/Extrinsics"
import { Header } from "./pages/Header"
import { RuntimeCalls } from "./pages/RuntimeCalls"
@@ -11,6 +12,7 @@ export default function App() {
<Header />
<Routes>
<Route path="explorer/*" element={<Explorer />} />
<Route path="extrinsics/*" element={<Extrinsics />} />
<Route path="storage/*" element={<Storage />} />
<Route path="constants/*" element={<Constants />} />
+12 -2
View File
@@ -8,6 +8,7 @@ import {
V15,
} from "@polkadot-api/substrate-bindings"
import { toHex } from "@polkadot-api/utils"
import { createClient as createSubstrateClient } from "@polkadot-api/substrate-client"
import { shareLatest } from "@react-rxjs/core"
import { createClient, PolkadotClient } from "polkadot-api"
import { chainSpec } from "polkadot-api/chains/polkadot"
@@ -30,6 +31,7 @@ import {
switchMap,
tap,
} from "rxjs"
import { getObservableClient } from "@polkadot-api/observable-client"
export type ChainSource = { id: string } & (
| {
@@ -57,8 +59,16 @@ export const chainClient$ = selectedSource$.pipe(
map((src) => [src.id, getProvider(src)] as const),
switchMap(([id, provider]) => {
const client = createClient(provider)
return concat(of({ id, client }), NEVER).pipe(
finalize(() => client.destroy()),
const substrateClient = createSubstrateClient(provider)
const observableClient = getObservableClient(substrateClient)
return concat(
of({ id, client, substrateClient, observableClient }),
NEVER,
).pipe(
finalize(() => {
client.destroy()
observableClient.destroy()
}),
)
}),
shareLatest(),
+225
View File
@@ -0,0 +1,225 @@
import { CopyText } from "@/components/Copy"
import { state, useStateObservable } from "@react-rxjs/core"
import { FC } from "react"
import { combineLatest, debounceTime, map, switchMap } from "rxjs"
import { twMerge } from "tailwind-merge"
import {
BlockInfo,
blocksByHeight$,
chainHead$,
finalized$,
} from "./block.state"
const best$ = chainHead$.pipeState(switchMap((chainHead) => chainHead.best$))
interface PositionedBlock {
block: BlockInfo
position: number
branched: number | null
branches: number[]
}
// 1 hour in polkadot
export const MAX_LENGTH = 3600 / 6
const blockTable$ = state(
combineLatest([blocksByHeight$, best$]).pipe(
debounceTime(0),
map(([blocks, best]) => {
const result: Array<PositionedBlock> = []
const blockPositions: Record<string, number> = {}
const positionsTaken = new Set<number>()
const lockFreePosition = () => {
for (let i = 0; ; i++) {
if (!positionsTaken.has(i)) {
positionsTaken.add(i)
return i
}
}
}
for (
let height = best.number;
blocks[height] && result.length < MAX_LENGTH;
height--
) {
const competingBlocks = [...blocks[height].values()]
if (competingBlocks.length > 1) {
if (height === best.number) {
competingBlocks.sort((a) => (a.hash === best.hash ? -1 : 1))
} else {
competingBlocks.sort((a, b) =>
(blockPositions[a.hash] ?? Number.POSITIVE_INFINITY) <
(blockPositions[b.hash] ?? Number.POSITIVE_INFINITY)
? -1
: 1,
)
}
}
const positionsMerged: number[] = []
competingBlocks.forEach((block) => {
const branches = [...positionsTaken].filter(
(v) => !positionsMerged.includes(v),
)
const position = blockPositions[block.hash] ?? lockFreePosition()
if (blockPositions[block.parent] != null) {
// then it means the parent was already discovered by a previous
// so this is the start of a branch
result.push({
block,
branched: blockPositions[block.parent],
branches,
position,
})
positionsMerged.push(position)
} else {
// We put our parent underneath us
blockPositions[block.parent] = position
result.push({
block,
branched: null,
branches,
position,
})
}
})
positionsMerged.forEach((v) => positionsTaken.delete(v))
}
return result
}),
),
[],
)
export const BlockTable = () => {
const rows = useStateObservable(blockTable$)
const finalized = useStateObservable(finalized$)
const numberSpan = (idx: number) => {
const initialIdx = idx
const number = rows[idx].block.number
do {
idx++
} while (number === rows[idx]?.block.number)
return idx - initialIdx
}
if (!finalized) return null
return (
<div className="w-full p-2 border border-polkadot-800 rounded">
<h2 className="font-bold p-2 border-b border-slate-400 mb-2">
Recent Blocks
</h2>
<table className="border-collapse m-auto">
<tbody>
{rows.map((row, i) => (
<tr
key={row.block.hash}
className={twMerge(
row.block.number <= finalized.number ? "bg-polkadot-900" : "",
row.block.number === finalized.number &&
row.position === 0 &&
"border-t",
)}
>
{rows[i - 1]?.block.number !== row.block.number ? (
<td
rowSpan={numberSpan(i)}
className={twMerge(
"px-2",
numberSpan(i) > 1
? twMerge(
i > 0 ? "border-y" : "border-b",
"border-slate-500",
)
: null,
row.block.number === finalized.number && "border-t-white",
row.block.number === finalized.number + 1 &&
"border-b-white",
)}
>
{row.block.number.toLocaleString()}
</td>
) : null}
<td className="p-0">
<ForkRenderer row={row} />
</td>
<td className="max-w-xs w-full">
<div className="flex gap-1">
<div
className={twMerge(
"overflow-hidden text-ellipsis whitespace-nowrap font-mono text-sm",
row.position === 0
? ""
: row.block.number > finalized.number
? "opacity-80"
: "opacity-50",
)}
>
{row.block.hash}
</div>
<CopyText text={row.block.hash} binary />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
const CELL_WIDTH = 20
const CELL_HEIGHT = 40
const CIRCLE_R = 5
const ForkRenderer: FC<{ row: PositionedBlock }> = ({ row }) => {
const totalCells = Math.max(row.position, ...row.branches) + 1
const getPositionCenter = (p: number) => CELL_WIDTH * p + CELL_WIDTH / 2
return (
<svg
height={CELL_HEIGHT}
width={CELL_WIDTH * totalCells}
className="stroke-polkadot-200"
>
{row.branches.map((branch, i) => (
<line
key={i}
x1={getPositionCenter(branch)}
y1={0}
x2={getPositionCenter(branch)}
y2={
row.branched != null && branch === row.position
? CELL_HEIGHT / 2
: CELL_HEIGHT
}
/>
))}
{row.branched != null ? (
<line
x1={getPositionCenter(row.branched)}
y1={CELL_HEIGHT / 2}
x2={getPositionCenter(row.position)}
y2={CELL_HEIGHT / 2}
/>
) : row.branches.includes(row.position) ? null : (
<line
x1={getPositionCenter(row.position)}
y1={CELL_HEIGHT / 2}
x2={getPositionCenter(row.position)}
y2={CELL_HEIGHT}
/>
)}
<circle
cx={getPositionCenter(row.position)}
cy={CELL_HEIGHT / 2}
r={CIRCLE_R}
className={
row.position === 0 ? "fill-polkadot-500" : "fill-polkadot-600"
}
/>
</svg>
)
}
+121
View File
@@ -0,0 +1,121 @@
import { lookup$ } from "@/chain.state"
import { getDynamicBuilder } from "@polkadot-api/metadata-builders"
import { state, useStateObservable } from "@react-rxjs/core"
import {
animationFrames,
combineLatest,
distinctUntilChanged,
map,
switchMap,
} from "rxjs"
import { chainHead$ } from "./block.state"
const best$ = chainHead$.pipeState(switchMap((chainHead) => chainHead.best$))
const bestBlockTime$ = best$.pipeState(
// Only count when increasing height
map((block) => block.number),
distinctUntilChanged((prev, current) => prev >= current),
switchMap(() => {
const timestamp = Date.now()
return animationFrames().pipe(map(() => Date.now() - timestamp))
}),
)
const targetTime$ = state(
lookup$.pipe(
map((lookup) => {
const ct = lookup.metadata.pallets
.find((p) => p.name === "Babe")
?.constants.find((ct) => ct.name === "ExpectedBlockTime")
if (!ct) return null
try {
const res = getDynamicBuilder(lookup)
.buildDefinition(ct.type)
.dec(ct.value)
return Number(res)
} catch (_) {
return null
}
}),
),
)
const timeProps$ = state(
combineLatest([bestBlockTime$, targetTime$]).pipe(
map(([bestBlockTime, targetTime]) => {
const time = (Math.round(bestBlockTime / 100) / 10).toFixed(1)
const progress = targetTime ? bestBlockTime / targetTime : null
return {
time,
progress: progress ? Math.round(progress * 100) / 100 : null,
}
}),
distinctUntilChanged((a, b) => a.time === b.time),
),
{
time: "0",
progress: null,
},
)
const SIZE = 50
const STROKE_W = 4
const CENTER = SIZE / 2
const RADIUS = CENTER - STROKE_W
const PERIMTER = Math.PI * 2 * RADIUS
export const BlockTime = () => {
const { time, progress } = useStateObservable(timeProps$)
return (
<svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`}>
<path
d={`
M${CENTER} ${STROKE_W}
a ${RADIUS} ${RADIUS} 0 0 1 0 ${RADIUS * 2}
a ${RADIUS} ${RADIUS} 0 0 1 0 -${RADIUS * 2}
`}
strokeWidth={STROKE_W}
fill="transparent"
className="transition-all ease-linear stroke-polkadot-500"
opacity="0.3"
/>
{progress ? (
<path
d={`
M${CENTER} ${STROKE_W}
a ${RADIUS} ${RADIUS} 0 0 1 0 ${RADIUS * 2}
a ${RADIUS} ${RADIUS} 0 0 1 0 -${RADIUS * 2}
`}
strokeDasharray={`${progress * PERIMTER} ${PERIMTER}`}
strokeWidth={STROKE_W}
strokeLinecap="round"
fill="transparent"
className="transition-all ease-linear stroke-polkadot-500"
opacity="0.7"
/>
) : null}
{progress && progress > 1 ? (
<path
d={`
M${CENTER} ${STROKE_W}
a ${RADIUS} ${RADIUS} 0 0 1 0 ${RADIUS * 2}
a ${RADIUS} ${RADIUS} 0 0 1 0 -${RADIUS * 2}
`}
strokeDasharray={`${(progress - 1) * PERIMTER} ${PERIMTER}`}
strokeWidth={STROKE_W}
strokeLinecap="round"
fill="transparent"
className="transition-all ease-linear stroke-polkadot-500"
/>
) : null}
<text
x={CENTER}
y={CENTER}
textAnchor="middle"
dy=".3em"
className="tabular-nums fill-polkadot-200"
>
{time}s
</text>
</svg>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { state, useStateObservable } from "@react-rxjs/core"
import { combineKeys } from "@react-rxjs/utils"
import { filter, map, takeWhile } from "rxjs"
import { twMerge } from "tailwind-merge"
import {
blockInfo$,
BlockState,
finalized$,
recordedBlocks$,
} from "./block.state"
import { MAX_LENGTH } from "./BlockTable"
export const Events = () => {
const events = useStateObservable(recentEvents$)
const finalized = useStateObservable(finalized$)
const firstFinalized = finalized
? events.find((evt) => evt.number <= finalized?.number)
: events[0]
return (
<div className="w-full p-2 border border-polkadot-800 rounded">
<h2 className="font-bold p-2 border-b border-slate-400 mb-2">
Recent Events
</h2>
<table className="w-full">
<tbody>
{events.map((evt) => (
<tr
key={`${evt.hash}-${evt.index}`}
className={twMerge(
evt === firstFinalized ? "border-t border-white" : "",
finalized && evt.number <= finalized.number
? "bg-polkadot-900"
: "",
)}
>
<td className="p-2 w-full">{`${evt.event.type}.${evt.event.value.type}`}</td>
<td className="p-2 whitespace-nowrap">{`${evt.number}-${evt.extrinsicNumber}`}</td>
</tr>
))}
</tbody>
</table>
{events.length === 0 ? (
<div className="text-slate-400">(No events yet)</div>
) : null}
</div>
)
}
const blackList = new Set([
"System.*",
"Balances.Deposit",
"Treasury.Deposit",
"ParaInclusion.*",
"Balances.Withdraw",
"Balances.Endowed",
"Balances.Locked",
"TransactionPayment.TransactionFeePaid",
"Staking.Rewarded",
])
const recentEvents$ = state(
combineKeys(recordedBlocks$, (key) =>
blockInfo$(key).pipe(
map((block) => ({
status: block.status,
hash: block.hash,
number: block.number,
events: block.events
?.filter((evt) => evt.phase.type === "ApplyExtrinsic")
.filter(
(evt) =>
!blackList.has(`${evt.event.type}.*`) &&
!blackList.has(`${evt.event.type}.${evt.event.value.type}`),
)
.map((evt) => ({
event: evt.event,
extrinsicNumber: (evt.phase as any).value as number,
})),
})),
takeWhile(
(r) =>
(r.status !== BlockState.Finalized &&
r.status !== BlockState.Pruned) ||
r.events == null,
true,
),
filter((result) => Boolean(result.events?.length)),
),
).pipe(
map((events) =>
[...events.values()]
.reverse()
.filter(
(evt) =>
evt.status === BlockState.Best ||
evt.status === BlockState.Finalized,
)
.flatMap(({ status, hash, number, events }) =>
events!.map(({ event, extrinsicNumber }, index) => ({
status,
hash,
number,
event,
extrinsicNumber,
index,
})),
)
.slice(0, MAX_LENGTH),
),
),
[],
)
+22
View File
@@ -0,0 +1,22 @@
import { withSubscribe } from "@/components/withSuspense"
import { blocksByHeight$ } from "./block.state"
import { BlockTable } from "./BlockTable"
import { Summary } from "./Summary"
import { Events } from "./Events"
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>
),
{
source$: explorer$,
},
)
+45
View File
@@ -0,0 +1,45 @@
import { FC, PropsWithChildren } from "react"
import { map, switchMap } from "rxjs"
import { chainHead$ } from "./block.state"
import { BlockTime } from "./BlockTime"
import { twMerge } from "tailwind-merge"
const finalized$ = chainHead$.pipeState(
switchMap((chainHead) => chainHead.finalized$),
map((v) => v.number.toLocaleString()),
)
const best$ = chainHead$.pipeState(
switchMap((chainHead) => chainHead.best$),
map((v) => v.number.toLocaleString()),
)
export const Summary: FC = () => {
return (
<div className="flex gap-2 items-center p-2">
<SummaryItem title="Block Time">
<BlockTime />
</SummaryItem>
<div className="flex-1" />
<SummaryItem
className="border border-slate-500 rounded p-2"
title="Finalized"
>
{finalized$}
</SummaryItem>
<SummaryItem className="border border-slate-500 rounded p-2" title="Best">
{best$}
</SummaryItem>
</div>
)
}
const SummaryItem: FC<
PropsWithChildren<{ title: string; className?: string }>
> = ({ title, className, children }) => {
return (
<div className={twMerge("flex flex-col items-center", className)}>
<h3>{title}</h3>
<div className="tabular-nums text-polkadot-200 text-sm">{children}</div>
</div>
)
}
+252
View File
@@ -0,0 +1,252 @@
import { chainClient$ } from "@/chain.state"
import {
ChainHead$,
PinnedBlocks,
SystemEvent,
} from "@polkadot-api/observable-client"
import { FollowEventWithRuntime } from "@polkadot-api/substrate-client"
import { state, withDefault } from "@react-rxjs/core"
import { partitionByKey } from "@react-rxjs/utils"
import {
catchError,
combineLatest,
concat,
concatMap,
filter,
finalize,
forkJoin,
map,
mergeMap,
NEVER,
Observable,
of,
scan,
skip,
startWith,
switchMap,
take,
takeUntil,
takeWhile,
withLatestFrom,
} from "rxjs"
export const chainHead$ = state(
chainClient$.pipe(
switchMap(({ observableClient }) => {
const chainHead = observableClient.chainHead$()
return concat(of(chainHead), NEVER).pipe(finalize(chainHead.unfollow))
}),
),
)
export const finalized$ = chainHead$.pipeState(
switchMap((chainHead) => chainHead.finalized$),
withDefault(null),
)
export enum BlockState {
Fork = "fork",
Best = "best",
Finalized = "finalized",
Pruned = "pruned",
}
export interface BlockInfo {
hash: string
parent: string
number: number
body: string[] | null
events: SystemEvent[] | null
header: unknown | null
status: BlockState
}
export const [blockInfo$, recordedBlocks$] = partitionByKey(
chainHead$.pipe(
switchMap((chainHead) =>
chainHead.follow$.pipe(
withInitializedNumber(),
scan(
(acc, evt) => {
switch (evt.type) {
case "initialized": {
const blockNumbers: Record<string, number> = {}
evt.finalizedBlockHashes.forEach((hash, i) => {
const parent = blockNumbers[evt.finalizedBlockHashes[i - 1]]
blockNumbers[hash] = parent ? parent + 1 : evt.number
})
return {
value: evt.finalizedBlockHashes.map((hash, i) => ({
hash,
parent: evt.finalizedBlockHashes[i - 1] || evt.parentHash,
number: blockNumbers[hash],
})),
blockNumbers,
}
}
case "newBlock": {
const number = acc.blockNumbers[evt.parentBlockHash] + 1
acc.blockNumbers[evt.blockHash] = number
return {
value: [
{
hash: evt.blockHash,
parent: evt.parentBlockHash,
number,
},
],
blockNumbers: acc.blockNumbers,
}
}
}
return { value: [], blockNumbers: acc.blockNumbers }
},
{ value: [], blockNumbers: {} } as {
value: Array<{
hash: string
parent: string
number: number
}>
blockNumbers: Record<string, number>
},
),
mergeMap(({ value }) => value),
),
),
),
(v) => v.hash,
(initialized$) =>
initialized$.pipe(
take(1),
withLatestFrom(chainHead$),
switchMap(
([{ hash, parent, number }, chainHead]): Observable<BlockInfo> =>
concat(
combineLatest({
hash: of(hash),
parent: of(parent),
number: of(number),
body: chainHead.body$(hash).pipe(
startWith(null),
catchError(() => of(null)),
),
events: chainHead.eventsAt$(hash).pipe(
startWith(null),
catchError(() => of(null)),
),
header: chainHead.header$(hash).pipe(
startWith(null),
catchError(() => of(null)),
),
status: getBlockStatus$(chainHead, hash, number),
}),
NEVER,
),
),
// Reset when chainHead is changed
takeUntil(chainHead$.pipe(skip(1))),
),
)
export const blocksByHeight$ = state(
recordedBlocks$.pipe(
mergeMap((change) => {
const targets$ = forkJoin(
[...change.keys].map((hash) => blockInfo$(hash).pipe(take(1))),
)
return targets$.pipe(
map((targets) => ({
type: change.type,
targets,
})),
)
}),
scan(
(acc, evt) => {
if (evt.type === "remove") {
for (const { hash, number } of evt.targets) {
acc[number]?.delete(hash)
if (!acc[number]?.size) {
delete acc[number]
}
}
} else {
for (const block of evt.targets) {
acc[block.number] = acc[block.number] ?? new Map()
acc[block.number].set(block.hash, block)
}
}
return acc
},
{} as Record<number, Map<string, BlockInfo>>,
),
),
)
function withInitializedNumber() {
return (source$: Observable<FollowEventWithRuntime>) =>
source$.pipe(
withLatestFrom(chainHead$),
concatMap(([event, chainHead]) => {
return event.type !== "initialized"
? of(event)
: chainHead.header$(event.finalizedBlockHashes[0]).pipe(
map((header) => ({
...event,
number: header.number,
parentHash: header.parentHash,
})),
)
}),
)
}
const getBlockStatus$ = (
chainHead: ChainHead$,
hash: string,
number: number,
): Observable<BlockState> =>
chainHead.pinnedBlocks$.pipe(
take(1),
switchMap((pinnedBlocks) => {
const block = (hash: string) => pinnedBlocks.blocks.get(hash)
const blockNum = (hash: string) => block(hash)?.number
const finalized = blockNum(pinnedBlocks.finalized)!
if (number <= finalized) {
// assume we are in the list of finalized blocks on `initialized`
return of(BlockState.Finalized)
}
const getInitialState = (pinnedBlocks: PinnedBlocks) => {
let bestBranch = pinnedBlocks.best
while (blockNum(bestBranch)! > finalized && hash !== bestBranch) {
bestBranch = block(bestBranch)!.parent
}
return block(bestBranch)?.hash === hash
? BlockState.Best
: BlockState.Fork
}
const initialState = getInitialState(pinnedBlocks)
return chainHead.follow$.pipe(
concatMap((evt) => {
switch (evt.type) {
case "bestBlockChanged":
return chainHead.pinnedBlocks$.pipe(take(1), map(getInitialState))
case "finalized":
if (evt.finalizedBlockHashes.includes(hash))
return of(BlockState.Finalized)
if (evt.prunedBlockHashes.includes(hash))
return of(BlockState.Pruned)
}
return of(null)
}),
filter((v) => v !== null),
takeWhile(
(v) => v !== BlockState.Finalized && v !== BlockState.Pruned,
true,
),
startWith(initialState),
)
}),
)
+1
View File
@@ -0,0 +1 @@
export * from "./Explorer"
+6
View File
@@ -4,6 +4,12 @@ export const Header = () => (
<div className="flex bg-polkadot-900 p-2 items-center flex-shrink-0">
<h1 className="text-xl flex-1">PAPI Console</h1>
<div className="flex flex-row items-center justify-end gap-2">
<Link
to="/explorer"
className="text-polkadot-200 border-polkadot-200 cursor-pointer border-b hover:text-polkadot-500 hover:border-polkadot-500"
>
Explorer
</Link>
<Link
to="/storage"
className="text-polkadot-200 border-polkadot-200 cursor-pointer border-b hover:text-polkadot-500 hover:border-polkadot-500"