feat: read chain state, decoded against the runtime
deploy / build (push) Successful in 7m59s
deploy / deploy-web (push) Failing after 4s
deploy / deploy-api (push) Successful in 16s

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:
2026-09-10 08:33:21 +03:00
co-authored by Claude Opus 5
parent 1e8869e7a1
commit 6b0e02e059
21 changed files with 915 additions and 8 deletions
+8
View File
@@ -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} />
)}
+19
View File
@@ -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.
*/
+24
View File
@@ -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>, };
+44
View File
@@ -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, };
+11
View File
@@ -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)
+42
View File
@@ -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">
+144
View File
@@ -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>
)
}
+9
View File
@@ -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;
}
+2 -1
View File
@@ -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' },
]