Files
observer/web/src/components/StateIndex.tsx
T
grenadeandClaude Opus 5 6b0e02e059
deploy / build (push) Successful in 7m59s
deploy / deploy-web (push) Failing after 4s
deploy / deploy-api (push) Successful in 16s
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
2026-09-10 08:33:21 +03:00

145 lines
5.4 KiB
TypeScript

/**
* 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>
)
}