feat: read chain state, decoded against the runtime
The observer could read headers, events, extrinsics, metadata and difficulty, and not a single pallet storage item. Answering "which address is this chain's treasury" meant going outside it and hand-deriving twox128 prefixes, which is a gap in the tool rather than an answer. The treasury is the case that shows why. `set_treasury_account` reads *never* on the call index and `TreasuryAccountUpdated` reads *never* on the event index — both true, because the address was set at genesis, so no extrinsic ever carried it and no event ever announced it. It exists only in state. It is qzjsuLN7Nhu4bjvmUbjSTr2ZTeZ7oRxXpQP9fdv6PcHUCRrVR, and it has never been funded. `Runtime::storage_key` builds a key entirely from the runtime's own description — the pallet's storage prefix, the item name, and each key's declared hasher — because a wrong hasher yields a key that reads as *absent* rather than as an error, and nothing would catch it. Its test pins two keys against ones read off the live chain by hand. Keys are SCALE-encoded against the type the entry declares, so a map on an account, a u32 or a tuple all work without this knowing which it is. Absent is not zero, and only the modifier knows which: an `optional` entry holding nothing means nothing, a `default` entry means the runtime's default, and the state page marks the latter rather than passing it off as something the chain wrote. `read_balance` deliberately does not apply the default — `AccountInfo` zeroes, Substrate reaps empty accounts, and falling back would turn "does not exist" into "holds nothing", which is the treasury's exact case. /:chain/state lists all 40 keyless entries decoded, and an account page now carries a real balance beside the flows it already summed. Those are different numbers: rewards say what an account was paid, a balance says what it has, and they differ by every transfer out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
@@ -25,6 +25,7 @@ import { MinerPanel } from './components/MinerPanel'
|
||||
import { RuntimePanel } from './components/RuntimePanel'
|
||||
import { RuntimesIndex } from './components/RuntimesIndex'
|
||||
import { SectionNav } from './components/SectionNav'
|
||||
import { StateIndex } from './components/StateIndex'
|
||||
import { StatBar } from './components/StatBar'
|
||||
import { seconds, windowSpan } from './lib/format'
|
||||
import { href, parse, WINDOWS } from './lib/routes'
|
||||
@@ -243,6 +244,13 @@ export default function App() {
|
||||
symbol={info?.token_symbol ?? ''}
|
||||
/>
|
||||
)}
|
||||
{route.index === 'state' && chain && (
|
||||
<StateIndex
|
||||
chain={chain}
|
||||
decimals={info?.token_decimals ?? 12}
|
||||
symbol={info?.token_symbol ?? ''}
|
||||
/>
|
||||
)}
|
||||
{route.index === 'runtime' && chain && (
|
||||
<RuntimesIndex chain={chain} current={state.summary?.spec_version ?? null} />
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AccountEvent } from "./AccountEvent";
|
||||
import type { BigUintDec } from "./BigUintDec";
|
||||
import type { ChainId } from "./ChainId";
|
||||
import type { MinerId } from "./MinerId";
|
||||
import type { RewardSummary } from "./RewardSummary";
|
||||
@@ -35,6 +36,24 @@ miner: MinerId | null,
|
||||
* The telemetry name held for that miner, if one is.
|
||||
*/
|
||||
display: string | null,
|
||||
/**
|
||||
* The account's free balance right now, read from chain state rather than
|
||||
* inferred from the flows below.
|
||||
*
|
||||
* `null` when the node would not answer, and — importantly — also when the
|
||||
* account does not exist. On Substrate an account with nothing in it is
|
||||
* not an account with zero, it is absent, and those read differently: the
|
||||
* treasury address this chain has configured has never been funded, and
|
||||
* showing it a zero would imply it had been and spent.
|
||||
*/
|
||||
balance: BigUintDec | null,
|
||||
/**
|
||||
* The whole `System::Account` entry as the runtime describes it — nonce,
|
||||
* consumers, providers, and the balance breakdown. Carried alongside the
|
||||
* single number above because the number is what a reader came for and the
|
||||
* rest is what makes it checkable.
|
||||
*/
|
||||
state: Record<string, unknown> | null,
|
||||
/**
|
||||
* Mining rewards, over the whole indexed record.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ChainId } from "./ChainId";
|
||||
import type { StateEntry } from "./StateEntry";
|
||||
|
||||
/**
|
||||
* Every storage entry readable without a key, decoded.
|
||||
*/
|
||||
export type ChainState = {
|
||||
/**
|
||||
* Which chain.
|
||||
*/
|
||||
chain: ChainId,
|
||||
/**
|
||||
* The runtime the entries were described by.
|
||||
*/
|
||||
spec_version: number,
|
||||
/**
|
||||
* The block this state was read at.
|
||||
*/
|
||||
height: number,
|
||||
/**
|
||||
* The entries, in pallet declaration order.
|
||||
*/
|
||||
entries: Array<StateEntry>, };
|
||||
@@ -0,0 +1,44 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* One storage entry, read and decoded.
|
||||
*/
|
||||
export type StateEntry = {
|
||||
/**
|
||||
* Owning pallet, as the runtime names it.
|
||||
*/
|
||||
pallet: string,
|
||||
/**
|
||||
* The entry, as the runtime names it.
|
||||
*/
|
||||
name: string,
|
||||
/**
|
||||
* The value's type, rendered from the registry.
|
||||
*/
|
||||
type_name: string,
|
||||
/**
|
||||
* `optional` or `default`.
|
||||
*/
|
||||
modifier: string,
|
||||
/**
|
||||
* The first line of the runtime's documentation for it.
|
||||
*/
|
||||
docs: string | null,
|
||||
/**
|
||||
* The decoded value. Accounts as `0x` hex, big integers as decimal
|
||||
* strings — the same normalisation events and call arguments get, so one
|
||||
* renderer serves all three.
|
||||
*/
|
||||
value: unknown,
|
||||
/**
|
||||
* Whether the chain held nothing there.
|
||||
*
|
||||
* Not the same as `value` being null: for a `default` entry the runtime
|
||||
* supplies a value when storage is empty, and this says the value shown is
|
||||
* that default rather than something the chain wrote.
|
||||
*/
|
||||
is_default: boolean,
|
||||
/**
|
||||
* Why it could not be read, when it could not.
|
||||
*/
|
||||
error: string | null, };
|
||||
@@ -14,6 +14,7 @@ import type { BlockDetail } from './generated/BlockDetail'
|
||||
import type { BlockExtrinsic } from './generated/BlockExtrinsic'
|
||||
import type { CallIndex } from './generated/CallIndex'
|
||||
import type { ChainSeries } from './generated/ChainSeries'
|
||||
import type { ChainState } from './generated/ChainState'
|
||||
import type { RecentBlock } from './generated/RecentBlock'
|
||||
import type { MinerDetail } from './generated/MinerDetail'
|
||||
import type { RuntimeDetail } from './generated/RuntimeDetail'
|
||||
@@ -147,6 +148,16 @@ export function fetchCallFeed(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every storage entry readable without a key, decoded.
|
||||
*
|
||||
* Live on every call. State is what is true now, and caching now produces a
|
||||
* copy of some earlier now that is indistinguishable from the real thing.
|
||||
*/
|
||||
export function fetchState(chain: string, signal?: AbortSignal): Promise<ChainState> {
|
||||
return get<ChainState>(`/chains/${encodeURIComponent(chain)}/state`, signal)
|
||||
}
|
||||
|
||||
/** Accounts ranked by what they have been paid, most first. */
|
||||
export function fetchAccounts(chain: string, signal?: AbortSignal): Promise<AccountRow[]> {
|
||||
return get<AccountRow[]>(`/chains/${encodeURIComponent(chain)}/accounts`, signal)
|
||||
|
||||
@@ -55,6 +55,14 @@ export function AccountPanel({
|
||||
const [detail, setDetail] = useState<AccountDetail | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Pulled out of the whole `System::Account` entry rather than sent as their
|
||||
// own fields: they are the runtime's names, and reading them here keeps the
|
||||
// wire format the chain's own shape.
|
||||
const st = detail?.state as Record<string, unknown> | null | undefined
|
||||
const data = (st?.data ?? null) as Record<string, unknown> | null
|
||||
const reserved = data?.reserved !== undefined ? String(data.reserved) : null
|
||||
const nonce = st?.nonce !== undefined ? String(st.nonce) : null
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setDetail(null)
|
||||
@@ -115,6 +123,40 @@ export function AccountPanel({
|
||||
reading 0, 0, — and — is not a summary of anything; it is the
|
||||
mining shape of this page imposed on an account that has never
|
||||
mined, and it pushes the history that does exist below the fold. */}
|
||||
{/* Held now, from state — a different number from anything summed
|
||||
below, and the one most people came for. Absent is not zero:
|
||||
Substrate reaps an empty account, so a missing entry means the
|
||||
account does not exist, and a 0 would say it had been funded and
|
||||
spent. */}
|
||||
<div className="stats" style={{ margin: 0, border: 0, borderBottom: 'var(--rule)' }}>
|
||||
<div className="stat">
|
||||
<div className="stat-label">Balance</div>
|
||||
<div
|
||||
className="stat-value"
|
||||
title={
|
||||
detail.balance ? `${exactTokens(detail.balance, decimals)} ${symbol}` : undefined
|
||||
}
|
||||
>
|
||||
{detail.balance ? tokens(detail.balance, decimals, 4) : '—'}
|
||||
</div>
|
||||
<div className="stat-note">
|
||||
{detail.balance ? `${symbol} held now` : 'no account on chain'}
|
||||
</div>
|
||||
</div>
|
||||
{reserved !== null && reserved !== '0' && (
|
||||
<div className="stat">
|
||||
<div className="stat-label">Reserved</div>
|
||||
<div className="stat-value">{tokens(reserved, decimals, 4)}</div>
|
||||
<div className="stat-note">{symbol} in deposits</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="stat">
|
||||
<div className="stat-label">Nonce</div>
|
||||
<div className="stat-value">{nonce === null ? '—' : fmtHeight(Number(nonce))}</div>
|
||||
<div className="stat-note">transactions sent</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detail.rewards.count > 0 && (
|
||||
<div className="stats" style={{ margin: 0, border: 0, borderBottom: 'var(--rule)' }}>
|
||||
<div className="stat">
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* What is true right now.
|
||||
*
|
||||
* Everything else on this site is history — headers, events, extrinsics, all of
|
||||
* it a record of what happened. This is the other half, and it answers the
|
||||
* questions history cannot.
|
||||
*
|
||||
* The treasury is the case that made it necessary. This chain's treasury
|
||||
* address was set at genesis, so no extrinsic ever carried it and no event ever
|
||||
* announced it: `TreasuryPallet::set_treasury_account` shows *never* on the
|
||||
* call index and `TreasuryAccountUpdated` shows *never* on the event index, and
|
||||
* both are true. The address exists only in state, and until the observer could
|
||||
* read state the honest answer to "which address is the treasury" was that we
|
||||
* did not know.
|
||||
*
|
||||
* Read live on every request rather than indexed. State is what is true now,
|
||||
* and a cached copy of now is a copy of some earlier now wearing the same face.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { ChainState } from '../api/generated/ChainState'
|
||||
import { RequestFailed, fetchState } from '../api/rest'
|
||||
import { height as fmtHeight } from '../lib/format'
|
||||
import { Payload } from './Payload'
|
||||
|
||||
export function StateIndex({
|
||||
chain,
|
||||
decimals,
|
||||
symbol,
|
||||
}: {
|
||||
chain: string
|
||||
decimals: number
|
||||
symbol: string
|
||||
}) {
|
||||
const [state, setState] = useState<ChainState | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setState(null)
|
||||
setError(null)
|
||||
fetchState(chain, controller.signal)
|
||||
.then(setState)
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [chain])
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">State</h2>
|
||||
{state && (
|
||||
<span className="eyebrow">
|
||||
{state.entries.length} entries at #{fmtHeight(state.height)}
|
||||
{state.spec_version !== null && ` · runtime v${state.spec_version}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="empty">{error}</p>}
|
||||
{!error && state === null && <p className="empty">Reading the chain…</p>}
|
||||
|
||||
{state !== null && (
|
||||
<>
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">
|
||||
Every storage entry readable without a key, decoded
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="left">
|
||||
Entry
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.entries.map((e) => (
|
||||
<tr key={`${e.pallet}.${e.name}`}>
|
||||
<td className="left">
|
||||
<div className="call-cell">
|
||||
<code>
|
||||
{e.pallet}::{e.name}
|
||||
</code>
|
||||
<span className="runtime-type">{e.type_name}</span>
|
||||
{e.docs && <span className="runtime-doc">{e.docs}</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="left">
|
||||
{e.error ? (
|
||||
<span className="runtime-modifier" title={e.error}>
|
||||
unreadable
|
||||
</span>
|
||||
) : e.value === null || e.value === undefined ? (
|
||||
/* An `optional` entry with nothing in it. Not zero, not
|
||||
empty — the chain holds nothing there, and for the
|
||||
treasury address that difference is the answer. */
|
||||
<span className="runtime-modifier">unset</span>
|
||||
) : (
|
||||
<span className="state-value">
|
||||
{typeof e.value === 'object' ? (
|
||||
<Payload
|
||||
value={e.value}
|
||||
ctx={{ chain, addresses: {}, decimals, symbol }}
|
||||
/>
|
||||
) : (
|
||||
<span className="numeral">{String(e.value)}</span>
|
||||
)}
|
||||
{e.is_default && (
|
||||
<span
|
||||
className="runtime-modifier"
|
||||
title="Storage holds nothing here; this is the default the runtime supplies."
|
||||
>
|
||||
default
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="panel-note">
|
||||
Read live from the chain at the block above, and decoded against the runtime's own
|
||||
declaration of each entry's type — including which hash its key uses, which is why a
|
||||
value appears here at all rather than as a key nobody can compute. Only entries needing
|
||||
no key are listed; a map like <code>System::Account</code> is read per account, on its
|
||||
own page.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1326,3 +1326,12 @@ tr.mine .share-bar > i {
|
||||
.call-unused code {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* A decoded storage value and, when the chain held nothing, the note that this
|
||||
is the runtime's default rather than something it wrote. */
|
||||
.state-value {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 10px;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export interface Route {
|
||||
}
|
||||
|
||||
/** The kinds that have an index. */
|
||||
export type SectionName = 'block' | 'call' | 'event' | 'account' | 'runtime'
|
||||
export type SectionName = 'block' | 'call' | 'event' | 'account' | 'state' | 'runtime'
|
||||
|
||||
/**
|
||||
* The sections, in the order the nav shows them.
|
||||
@@ -70,6 +70,7 @@ export const SECTIONS: { id: SectionName; label: string }[] = [
|
||||
{ id: 'call', label: 'Calls' },
|
||||
{ id: 'event', label: 'Events' },
|
||||
{ id: 'account', label: 'Accounts' },
|
||||
{ id: 'state', label: 'State' },
|
||||
{ id: 'runtime', label: 'Runtimes' },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user