main
60 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
82a4ac9c59
|
feat(api): wormhole deposits by recipient
GET /v1/chains/{chain}/wormhole/deposits?to=<addr>,…&after=<h>:<i>&limit=<n>
Every Wormhole::NativeTransferred to up to 100 addresses (SS58 or 0x hex),
oldest first, with a height:event_index cursor. A deposit carries its
recipient, its amount as a decimal string, its transfer_count and its
leaf_index: what a wallet needs to work out what it can still spend.
A wormhole address's System::Account balance is a receipt total. An exit
credits the exit account and records a nullifier, and never debits the
wormhole address. A deposit is spent when the nullifier its secret and
transfer_count derive is used. The wallet computes and checks that itself;
nothing here asks for nullifiers, because naming one pairs a deposit with its
exit.
This is for the Quantus extension's wormhole balance (quantus/extension#14),
which would otherwise ask Quantus's Subsquid and tell Quantus which addresses
are one person's.
- The query reads chain_event through the accounts GIN index, then keeps only
rows whose `to` is one of the addresses: accounts holds the sender too, and
a transfer from a wormhole address is not a deposit to it.
- The response carries indexed_from/indexed_to. On a chain still backfilling,
a client can't take the sum as a balance without checking it against
Wormhole::TransferCount.
- CORS on this route alone allows any origin, GET only, merged outside the
site's allowlist so that allowlist can never answer a preflight for it.
Extension origins can't be listed (Chrome ids vary by install, Firefox's are
random per install), and the data is public.
The integration test covers the ways this is silently wrong: a deposit from an
address counted as one to it, another event kind leaking in, a cursor that
skips a same-block sibling, and another chain's rows. Unit tests cover both
address spellings, the cursor, and u128 amounts written as strings.
Closes #22
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
|
|||
|
efaf48711c
|
feat(ui): a caution mark where the tagline's bullet was
The masthead's separator between "quantus mining" and "this is sparta" is now the caution mark rather than a middle dot, scaled to the line it sits on. `.mark-tagline` gains an explicit `line-height` for that reason: the image is sized from it, and a line height inherited from `body` would quietly resize the mark the day the body's changed. The height is declared twice — `1.4em` then `1lh` — which is the same number here precisely because the line height above is stated, so a browser that does not know the unit lands in the same place rather than near it. `alt` is empty and `aria-hidden` is set, on purpose. The mark stands in for punctuation and the words either side already say what it says; announcing it would be reading the separator aloud. Imported as a hashed asset like the portrait, and it keeps its own transparency rather than being given a ground — the masthead sits on a radial glow, not a flat surface, so a background would show as a rectangle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
b04a45f54a
|
feat(ui): a face on the about page
The about panel's prose is capped at 68 characters a line, which is a measure worth keeping whatever width the panel happens to be — and left the right-hand half of it empty. A portrait of the author now sits there, linked to rob.tn, taking the space the measure was already leaving rather than any of it. Imported as a module asset rather than dropped in `public/`. Vite hashes it by content, so a replaced photograph invalidates its own cache instead of being served stale from behind a long max-age; `public/` is for the favicon, which needs a fixed path, and for nothing else here. The name is in both `alt` and the link's `title`. The image is the only content of that link, so its `alt` is the link's accessible name — an empty one would leave a screen reader announcing an unlabelled link to an unexplained destination. It stacks under the prose below the breakpoint, and the photograph carries an alpha channel so it gets a ground of its own rather than sitting on whichever surface the theme supplies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
6ed9a6c35a
|
feat: an about route, and somewhere to say thanks
Adds `/:chain/about`, linked from the header just before the connection light and the theme toggle — it is about the site rather than the chain, so it sits there instead of in the section nav, and `header: true` on its `SECTIONS` entry keeps it out while still letting the URL parse. The page says what the observer is, that it and the CUDA-optimised miner are built and freely sourced by a member of the Quantus user community rather than by the project, and takes donations in QTC. **The QR is generated in the browser from the address printed beside it, never shipped as an image.** A supplied PNG next to a typed address is two independent claims about where money goes, and nothing on the page could tell a reader if they ever stopped agreeing — a mismatch sends funds somewhere unrecoverable. Verified rather than trusted. The address was put through this crate's own SS58 decoder: checksum and network prefix both hold, and it round-trips to the same string. The QR matrix was then decoded from first principles — function-pattern map, mask 3 unapplied, codewords de-interleaved across version 4-M's two blocks — and reads back the address exactly. A note for whoever checks this again: an independent encoder produces a different matrix for the same string at every mask, because mode segmentation differs, so comparing matrices proves nothing and only a decode does. `the_donation_address_on_the_site_is_a_valid_quantus_address` reads the literal out of the frontend source and validates it, so a typo fails CI rather than costing somebody their money. Confirmed to fail on one changed character. Also on the vesting page, from verifying it live: a "claimable now" figure and a per-grant claimable column. Two grants began vesting at launch while the other forty-six wait for 2027, so 68,871 QTC is already claimable and none of it has been taken — a page that only said "first claimable" would have missed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
2bbe8e4d5d
|
feat: a vesting route — when 99.5% of the supply actually arrives
The network page said 5,669,940 QTC sits in the vesting pot and stopped there, which is the largest economic fact about this chain rendered as a single number. `/:chain/vesting` now carries the schedules, the release curve and the evidence. It is the only forward-looking page on the site and it is exact rather than projected: the runtime vests nothing before a grant's cliff, the whole grant from its end, and linearly between, so summing that across the schedules is arithmetic on chain state. `blackbeard-core::vesting` restates those three branches with tests, because until 2027 nothing on chain will contradict them — covering the cliff, the mid-window, the `end` branch that makes the final figure exactly `total` rather than `total` less a rounding crumb, the `start < cliff` case that unlocks a chunk at once, and a grant large enough that `total × elapsed` would wrap a `u128` if the product were not split. Decoded by field name against the metadata, never by offset. The figures that justified building this were read positionally out of 89 bytes and summed to the pot exactly — good evidence the layout was guessed right, and exactly the shortcut this repository forbids elsewhere. A captured mainnet entry now pins the names, so a reordered struct fails the build rather than quietly paying the wrong address. It also retires a weak claim. "Held in the vesting pot" rested on an eight-byte `PalletId` spelling `qvesting` and a `type PalletId` line read from a source tree that may not be the deployed commit. `matches_pot` replaces that with arithmetic: the grants sum to the pot's balance to within the existential deposit, and if a grant is ended or the pot topped up the page says the two have parted company rather than going on describing one as the other. Measured: 48 grants, 5,669,940.000 QTC scheduled against 5,669,940.001 held, nothing claimed, first unlock 2027-09-09, fully vested 2030-09-08. `Field` becomes one component instead of four. Going to add a fourth copy found that the three had already parted company — two rendered `note` as a visible caption, the third passed it to `title` as a hover — so the shared one takes both as separate props rather than silently changing a page. The same lesson `isStandings` records, found the same way. Closes #21 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
da7f773564
|
feat: how concentrated the balance is, once the treasury is set aside
The last piece of #17. It was specified as "the share of issuance held by the top 1, 10 and 100" plus a Gini, and done literally that produces a number that is true and says nothing — because the top account is the vesting pot holding 99.5% of the chain. The figure would sit near 1.000 and move only when the pot paid out, describing an undistributed reserve rather than an economy. So the pot is reported first and then excluded, by its own storage key rather than by being the largest: "the biggest account" is a description that would silently start meaning something else the day the pot drains. Everything else is computed over what is left and labelled as being outside it. The denominator is the counted balance, not issuance. The two differ, and in the direction that matters: a wormhole exit credits its destination without debiting the note's source, so balances summed across the chain exceed issuance, and shares of issuance would add up to more than everything. Measured on mainnet: 1,949 accounts, 27,245 QTC outside the pot, top 1 at 39.6%, top 10 at 72.2%, top 100 at 92.2%, Gini 0.965. That top holder is a mining address — keyless, its balance a cumulative receipt that cannot fall however much its operator pays out — so the panel says in its own words that this is partly a statement about who mined most rather than who holds most. The Gini lives in `blackbeard-core` with its own tests, per the rule about statistics being easy to overstate and hard to notice: pinned against the published coefficient for 1,2,3,4, and against a dust holder beside two whales, which is the case an `f64` accumulator would round away and report as perfect equality between the whales. `RpcClient::storage_values` is new — `state_queryStorageAt`, which turns two thousand round trips into four. Chunked at 500 rather than sent whole because nothing documents a ceiling here, and an undocumented ceiling is one that gets discovered in production. Nothing renders unless the account walk reached the end of the map. Every share divides by a total, and a partial sum makes all of them wrong while looking entirely reasonable. Closes #17 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
67e7b2ec24
|
fix: the waiting counter was never once visible
It refused to count until a block arrived while the page was open, on the grounds that the block the snapshot hands over on load may be a minute old and starting it at zero would lie. That was honest and completely useless: on mainnet the block that triggers the counter is the same block that puts the first row in the console, so it became visible and unnecessary in the same tick. Reported from the live site after a refresh — the placeholder was still there, because the counter had no window in which to exist. It now seeds from the snapshot's block. The ticking is still `performance.now()`, which cannot be skewed or stepped; only the seed can be inexact, and only for that first block, whose age nothing but the server's `observed_at` can attest. A seed outside a plausible range is refused outright and the plain text stands — no counter beats a wrong one. Measured against the live API: the counter appears within 800ms of load reading "26,378 ms since #43,588", climbs, and gives way to rows about two seconds later, which is how long the console is honestly empty. Also recorded, because it wasted a verification: `/v1/healthz`'s `commit` is stamped into the API binary, so a frontend-only push leaves it on the previous sha *forever*. A watcher polling it for the sha just pushed waits for something that will never arrive and reports "still deploying" over a site that finished minutes ago. Compare the served bundle name to the local build instead — Vite hashes it by content, so equal names mean byte-identical. Refs #17 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
a245caef99
|
feat(ui): count the wait instead of describing it
The empty console explained what would eventually appear there, which is the least interesting thing it could say while the chain is visibly producing blocks a few feet away. It now counts milliseconds since the last block, with the height linked, and resets each time one lands. The clock is `performance.now()`, not `observed_at`. That field is the server's wall clock, and subtracting it from the browser's would fold in whatever the two disagree by — a viewer whose machine runs fast would watch the counter go backwards. "Since this page saw it" is both unfakeable and the honest claim. Counting starts at the first block that arrives while the page is open, not at the first the ticker hands over: that one came from the snapshot and may be a minute old, so starting from when it was rendered would begin at zero and lie about it. The number is written straight to the DOM. Sixty React renders a second would redraw the mouth, the population and the whole console for one span of text — the same reason the flight marks are not React state. Under reduced motion it updates four times a second instead of sixty. The caption's idle line changes with it: "watching for the next block" beside a counter timing a block the page has plainly seen was a contradiction, so it now says which of the two is actually missing. Refs #17 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
ee575ed33f
|
feat(ui): two columns for the stage, and a console that links out
Stacked, the mouth and the lines describing it could not be read at the same time, which is the whole point of having both. They sit side by side once there is room: the figure takes the space left over and the console takes a fixed column. The breakpoint is measured rather than chosen. At 1000px the console came out 360px wide and *every* line wrapped to two — worse than stacking — because one line needs about 354px for its height, kind and amount columns plus a shortened address and three gaps. The column's minimum is 400px and the breakpoint is where that can exist without starving the figure. The console now links out. A block height goes to the block, an address to the account, and the count of what is not shown names its block instead of saying "this block" — so the line that tells you 897 notes went unlisted is also the way to go and read them. Links fall back to plain text before the chain is known, because `href` would otherwise render `//block/43466`, which looks clickable and goes nowhere. They are marked with a dotted underline rather than a colour, following `a.ticker-height`: the console's columns already carry meaning through colour — muted for the height and address, bright for the kind — and tinting a link would make it momentarily read as a different column. Refs #17 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
578a54ef87
|
fix: the backfill was re-announcing blocks the console had already shown
The wormhole pulse was broadcast from inside `index_events`, which has two callers: `record`, for a block that just arrived, and `backfill`, walking history. So the backfill re-announced every block it re-read — and because it walks towards the tip, the blocks it re-announced were exactly the ones a viewer had just watched. The live console listed #43,446 three times. This is the live/backfill confusion this repository has already been bitten by twice, arriving by a third road, so the guard is a type rather than a comment: `Pass::Live` or `Pass::Backfill`, and a caller cannot forget to say which. The duplicates did more damage than repeating themselves. Console lines were keyed `height-index`, which looked unique and was not, and React given duplicate keys renders duplicates *and* stops honouring the list's own length cap — twelve lines had become eighteen on screen. Lines are keyed on a monotonic counter now, so a reorg re-recording a height cannot reproduce it. Neither half was caught by the build, the lint, or the tests. Both were obvious within a minute of watching the live page. Refs #17 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
ad22866d3c
|
feat(ui): say what each mark was
The stage drew a dot per note and left the reader to guess. A dot travelling inward could be anybody paying anybody, and without text beside it the animation is an ornament rather than a reading of the chain. Each pulse now carries a sample of the individual notes — kind, recipient, amount — and a console below the stage lists them newest-first, the way the block ticker reads, which also means no auto-scroll to fight with and the line a reader is looking for never moves. `kind` is the third split this codebase makes on the producing extrinsic and the first that separates all three cases: a reward has no extrinsic at all because it is paid in block initialisation, a transfer has an ordinary one, and an exit has a `verify_*_batch`. The recorded sender distinguishes none of them. Two caps, and they turned out to interact. `MAX_PULSE_NOTES` bounds the wire — a block has carried hundreds of notes and sending every one for a handful of lines is kilobytes a block of text nobody reads. `CONSOLE_LINES_PER_BLOCK` bounds one block's share of the console, because without it a single busy block fills every line and twelve lines from one block look exactly like twelve blocks of one, which loses the reader all sense of rate. The count of what is not shown hangs on the block's first line rather than its last: the list is newest-first, so a count on the last line is the first thing trimmed, and a block of 260 showed four notes while saying nothing about it. That one reached a screenshot before it was caught. The console fills before the reduced-motion guard, so a viewer who asked for less motion still gets the whole reading — twelve lines, no moving marks, population still drawn, caption still live. Refs #17 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
4f59af9847
|
feat(ui): the wormhole holds what it has swallowed
The stage drew the flow and nothing else, which on a chain committing about 1.9 notes a block and settling an exit every fifth one meant it was nearly always still. That was honest and it was also nearly empty, while the page's headline figure — tens of thousands of notes committed and not yet spent — sat in a box beside it. The mouth now holds a sample of that population, turning slowly, with arriving notes passing in front of it on their way in. The stock is read, never inferred. A pulse's `notes_out` is an exit crediting an account, and `credit_and_record` inserts a fresh leaf for that credit, so an exit adds a note rather than retiring one; what retires a note is a nullifier, and those are not counted per block. Accumulating pulses into the figure would drift from the truth while looking authoritative, so it is re-read from the chain every two minutes instead. It turns in CSS rather than in the animation loop. The flight marks stop when the chain is quiet, but the population is always there, and a JS loop running forever on a page left open all day is a cost this does not need to pay. Each shell is a ring of marks on a circle inside a vertically squashed group, so one composited rotation traces an ellipse in the mouth's own plane. Reduced motion stops the turning and keeps the marks: how much is inside is the information. One mark stands for many and the caption says how many, whether or not a block has arrived yet — forty-five turning marks with nothing explaining them would be worse than none. Flight time goes from 1.5s to 5s, measured against the real rate rather than the mock that made the first version look busy: at twelve-second blocks the few notes there are now overlap instead of appearing alone. Refs #17 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
c08face7ad
|
feat(ui): a live wormhole, drawn from the blocks as they land
The wormhole page described a pool that was never shown doing anything. It now opens with a stage where a note arriving falls inward and is swallowed, and a note leaving climbs out and escapes — driven entirely by blocks this observer has actually decoded. Nothing is on a loop: a still stage means a still chain. `index_events` now reports what each block did to the pool, split by the producing extrinsic exactly as the daily flow query and `exit_cohorts` are — a settled batch releases value, anything else commits it, and the recorded sender cannot say because it is the same sentinel either way. The exit extrinsics are collected inside `index_extrinsics`, the one place the body is already decoded; finding them in a second pass would re-run `decode_extrinsic` over Dilithium signatures at 5.3 KiB apiece. It is broadcast after the block rather than inside it, because the counts cost two more round trips and the site's ticker must not wait for one panel. A block that touched no notes broadcasts nothing. On the front end the pulse takes a side channel — `Observer.onWormhole`, apart from the state listeners — because a pulse is an event, not a fact about the page, and routing it through `ObserverState` would re-render the leaderboard, the ticker and the whole tree once per block for one animation. Marks are pooled SVG nodes mutated from a loop that starts on a pulse and ends itself. Honest by construction: marks are capped and the caption is not, so a block committing 900 notes draws 28 and says nine hundred. `prefers-reduced-motion` stops the motion and keeps the information — no marks, caption still live, aria-label still describing the last block. The stage takes the headline stat row's place on this page. Hashrate, difficulty, block time, miners and height are on every other page and say nothing about the pool. Refs #17 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
864a76ffb6
|
feat(ui): show what goes into the wormhole and what comes out
The wormhole page counted the pool but never drew the flow through it. It now leads with a meter — 42% of every note ever created is still unspent — and a per-day chart of value arriving against value leaving. Direction is read from the producing extrinsic, never from the sender, which is the same sentinel whichever way the value is going: anything settled by `verify_public_batch` or `verify_private_batch` is leaving, everything else is arriving. `entered + left` reconciles to the day's total exactly. **The chart is diverging with a single hue.** In and out want two hues either side of a neutral midpoint, and this site has one data colour. The two-shade alternative was measured rather than assumed and fails outright — `#bd8829` against `#e0a63a` scores a normal-vision ΔE of 9.9, under the 15 floor. So direction is carried by which side of the zero rule a bar sits on, which survives greyscale, print, forced-colors and every form of colour vision, and leaves no categorical pair to validate. Rendering it is what found the rest. Same hue plus touching bars drew one continuous mark through the rule and the encoding disappeared, so a surface gap is held open at zero. Five days across a full-width panel drew 170px slabs, so bar width is capped and the columns space out instead. And `rx` rounds all four corners of a rect, so bars read as detached lozenges until they became paths with rounded data-ends only. Ships with the hover layer, direct labels on the busiest day each way, and a visually-hidden table so the series exists outside the picture. Refs #17 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
f81ebd4746
|
feat: who the wormhole exits pay, and how much of it is the protocol itself
The premise was that pools are the best surface for observing wormhole transfers, because payouts must leave a pattern in the batches. The pattern is there. Most of it is not payouts. Two things break the obvious reading. Batch *size* is aggregation: the pallet documents a public batch as one segment per inner private batch and a segment as one client, so a 120-output batch is an aggregator bundling unrelated people. And the most recurrent recipient is the aggregator itself — one address is credited in 91.5% of every public batch anyone submitted and in none of the private ones, which is the rebate minted to the proof's `aggregator_address`. QUANPOOL's mining address appears in 501 batches for 7.54 QTC total, 0.015 each: the fee share paid to whoever included the batch. Classified, three addresses take 76% of everything the exits pay. A cohort table that did not say so would be describing aggregator plumbing and calling it an economy, so the share is the section's eyebrow and its first sentence. What survives the subtraction is the finding worth shipping: among genuine recipients, 35 addresses paid twenty or more times take two thirds of the remaining value at twice everyone else's average credit. That is a recurring-payment shape and the page says it is no more than that — the chain records no sender, so a regular payee is as plausibly an exchange deposit or one person on a schedule as a pool meeting its obligations. Batch size is not rendered anywhere, on purpose. Closes #20 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
4d9d41888a
|
fix: a miner's balance is a receipt total, and the supply figures were wrong
Two claims this repository shipped, both disproved by measuring mainnet. **A mining address is keyless, so its balance can only rise.** `retained` was documented here as "the one measure privacy cannot hide", reasoning that a payment debits the account whichever route it takes. It does not. `mining-rewards` pays `derive_wormhole_address(preimage)`, which no Dilithium keypair signs for, and `Wormhole::credit_and_record` credits an exit's destination with `Unbalanced::increase_balance` while never debiting the note's source. QUANPOOL's balance rose at all twelve samples between blocks 1,000 and 42,400 and fell at none, nonce 0 throughout — as every miner's must. So `retained` read ~100% for everyone because it had to, and a number identical for all reads as an accusation aimed at whoever mined most. The column is gone, `balance` is `received`, and the table says plainly that a pool paying out in full and one paying nothing produce the same row. **Four empty lock maps are not "nothing is withheld".** Locks, Holds, Freezes and Reserves are all empty — counted, not assumed — and the network page concluded circulating equals total issuance. Wrong by a factor of 436: this runtime vests from a *pot*, not with locks, and `modlqvesting` holds 5,669,940 QTC, 99.54% of every unit of balance on the chain. The page now reports the pot and issuance-minus-pot, and still declines the word "circulating" because the release schedule is not read here. The pot address is derived from `Vesting::PalletId` in metadata rather than a hard-coded `qvesting`: it is a consequence of a runtime constant, and a runtime that changed it would leave the site reporting an empty pot while looking perfectly healthy. Both steps are pinned against the real mainnet metadata fixture. Also recorded: never sum account balances and call it supply. They exceed `TotalIssuance` by 13,427 QTC at block 42,400 and by exactly zero at genesis, bisecting to block 4,800 and a `verify_public_batch` — private value is counted twice by any such total. Single blocks reconcile exactly, so a spot check says everything is fine. Refs #17 Closes #19 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
e3c95d4945
|
feat: a wormhole page — what enters, what leaves, what is still inside
The observer had nothing to say about this chain's central feature, which is
why the distribution table's outflow column could ship reading zero for every
miner and look like evidence.
A transfer inserts a commitment as a note; spending one proves a nullifier,
which the runtime records so it cannot be spent twice. That makes the pool
countable even though its movements are not:
ZkTree::LeafCount 82,691 notes created
Wormhole::UsedNullifiers 47,796 notes spent
outstanding 34,895 unspent — still inside
Outstanding is the headline. It is the part of the chain's activity that has not
moved, and every one of those notes will name a recipient and an amount and not
a sender when it does.
The routes table separates transfers by the extrinsic that produced them,
because a hidden sender means two different things. In block initialisation it
is the chain paying a miner and the "sender" is the runtime; inside
`verify_public_batch` or `verify_private_batch` it is somebody spending a note
and the origin genuinely is not recorded. Collapsing those would make mining
rewards look like anonymous payments — 23,876 of them.
Notes per day come from the `leaf_index` each transfer carries rather than from
`ZkTree::LeafInserted`, which is in `SKIPPED_EVENTS` because it fires every block
and names no account. Same fact, cheaper road.
`SegmentsDenied` and `ExitMintFailed` have never fired. Shown anyway, because a
proof rejection nobody has ever seen is a finding rather than a gap — though it
is a weaker statement than it looks, and the note says so: never refused is not
the same as never attempted.
Closes #18
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|||
|
365cd7a803
|
fix(ui): "sent nothing" was a claim this chain cannot support
The distribution table shipped with a `sent on` column reading zero for every
miner, which implies nothing left those addresses. On this chain that is not
what a zero means, and the correction came from the observation that payouts are
themselves wormhole transactions.
`Wormhole::NativeTransferred` records `to` and `amount` and does not record who
sent it. Splitting the events by the extrinsic that produced them shows what
that costs:
(no extrinsic — initialisation) hidden 23,876 events 72 recipients
Utility::batch_all visible 15,303 2,145
Wormhole::verify_public_batch hidden 8,136 538
Balances::transfer_allow_death visible 6,941 651
Wormhole::verify_private_batch hidden 1,758 116
Hidden-sender transfers in block initialisation are mining rewards. The 9,894
produced by `verify_public_batch` and `verify_private_batch`, reaching 654
recipients, are real payments with no origin recorded — the chain's privacy
working as designed. So the column was blind to exactly the mechanism a
wormhole-held balance would use to pay out, while reading as proof it had not.
It is `sent visibly` now, the note leads with the limit rather than burying it,
and a `retained` column carries the one measure privacy cannot hide: balance
over what the chain paid in. A payment debits the account whichever route it
takes, so a balance that has never fallen is a balance nothing has left.
A second correction, from running the table over the full index rather than a
dev slice: *every* one of the top 25 miners shows zero visible outflow and holds
almost exactly what it mined, including another address calling itself
`quantus-mainnet-pool`. Zero outflow is universal among mining addresses and
distinguishes nobody. The accounts distributing to hundreds of recipients have
never mined a block — they are funded from the genesis endowment — so setting a
miner beside them is a category error, and one this repository made before the
numbers came in.
Refs #17
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|||
|
df2306660a
|
feat(ui): show where the mining rewards went
A table on the network page: each miner's share of blocks, what the chain paid it, what it holds, what it has sent on, and to how many distinct addresses. Built to answer a concern without ever being the thing that makes the accusation. There is no badge, no flag and no word like "delinquent" anywhere in it. What earns it its place is comparison — one miner's recipient count says almost nothing, and beside its neighbours' it says a great deal — so the reader draws the conclusion and the site lays out the evidence and names its limits. Three limits are on the page, not in a footnote: - an operator may hold several addresses, settle off-chain, or be a solo miner keeping what it earned, which is the ordinary case and unremarkable - `sent on` counts only transfers out of the address the chain pays, so an operator distributing from a different wallet shows nothing - the name column is the weakest and comes from telemetry rather than the chain. There is no such thing as a "pool" here: the word appears only inside a name an operator chose for its own node `holds` is chain state and `mined`/`sent on`/`recipients` are over the indexed range, which the note spells out — on a partially indexed chain a miner appears to hold far more than it ever mined, and that discrepancy looks exactly like the thing the table is for. The window is fixed at 100,800 blocks rather than carried from the route: the window selector is hidden on this page, so the route's value would mean the shares silently reflect whatever span the reader last chose on the standings, invisibly and differently for two people reading the same page. The longest window is also the right one for the question — what a miner did with its rewards is about the chain's history, not its last hour. Refs #17 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
06f3ece228
|
feat: account flows, and a distribution table for mining rewards
Reported concern: one pool's balance is disproportionately large, and it is not
clear whether rewards are reaching miners. This makes the question answerable
from the chain rather than from impressions.
Measured on mainnet while building it, blocks 1–42,274:
account mined received sent recipients
QUANPOOL 10,171 5,207 0 0
qzomrwjT… 0 11,382 4,071 90
qzo1Pr3X… 0 3,875 7,528 986
qzo4QjZz… 0 1,257 2,448 742
qznbesUc… 0 1,600 0 0
Direction comes from each transfer event's own `from` and `to` — `Wormhole::
NativeTransferred` and `Balances::Transfer` both carry them — so every figure is
something the chain recorded.
**A nonce cannot answer this, and reaching for one is the obvious mistake.**
Wormhole transfers dispatch unsigned: `verify_private_batch` and
`verify_public_batch` have no signer at all. An address here can move a fortune
with a nonce of zero, and I nearly reported one as proof that nothing had ever
been sent.
The presentation reports flows and does not judge them, because the data cannot
settle what they mean. Two facts in the table above are why: several large
accounts send nothing — ordinary for a solo miner keeping what it earned — and
the accounts distributing to hundreds of recipients have never mined a block, so
payout wallets here are funded from somewhere other than mining addresses. There
is also no such thing as a "pool" on this chain: the word appears only in a name
its operator reports over telemetry, which `MinerFlow::attribution` carries so a
reader can weigh it.
What earns the feature its place is comparison. One miner's recipient count says
little; beside its neighbours' it says a great deal, and the reader draws the
conclusion rather than the site.
Refs #17
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
|
|||
|
aaf37baaca
|
feat: a network page — supply, capability and daily activity
The site answered "who is mining" thoroughly and "what is this economy doing"
not at all. Everything needed was already reachable and none of it was
assembled anywhere.
Measured against mainnet rather than assumed:
total issuance 5,682,913.17 QTC
endowed at genesis 5,670,000.00 QTC
mined since 12,913.17 QTC <- emission to date, printed nowhere else
accounts 1,925 counted, not inferred from indexed events
locks/holds/freezes/reserves 0 all four, counted
vesting schedules 48
referenda 0
reversible transfers 0
calls used 7 of 58
events fired 18 of 107
**No "circulating supply" field, deliberately.** With every immobilising map
empty it would equal total issuance exactly, and printing it as a separate
headline would assert a distinction this chain does not currently make. The page
states what was counted and lets that be read. Vesting is a count rather than a
sum for the same reason: this runtime's vesting does not touch the balances
locks, so what it holds and when it releases would be a guess.
**Signed extrinsics are separated from the total**, because three quarters of
this chain's extrinsics are inherents — 513 signed of 2,052 on the day measured
— and a single "transactions per day" line would be mostly clockwork, a number
that reads as adoption and is not.
Two kinds of certainty share the page and are kept apart. Supply and the map
sizes are chain state, read now. The daily activity is our index, and the
Activity panel prints the range it covered and says "the whole chain" or "a
partial index" — a chart whose axis claims a month over a week of data is the
failure this repository has already shipped twice.
One thing found while building it: a counter that has never moved is *absent*
from storage, not zero. `TechReferenda::ReferendumCount` and
`ReversibleTransfers::NextTransactionId` both read as nothing, and reporting
them unknown would have hidden the most interesting fact about them — that
governance and reversible transfers are shipped and have never been used.
`plain_value` falls back to the entry's declared default, which is only sound
because `StorageTarget` says whether it is `Default` or `Optional`.
Refs #17
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
|
|||
|
3551b832a0
|
fix(web): the standings do not belong under a node
Reported: the board was still rendering beneath the node route. It was a fourth place spelling out "is this the standings?" by hand — the board's own render condition, which I missed when extracting `isStandings` for the other three. That predicate now has one definition and four callers: the section nav, the window selector, the canonicalising redirect, and the board. Grepped for survivors; there are none. Worth recording how both node-route bugs were found, because it is the same lesson twice in one feature: the route parsed, the page existed, every gate was green, and only opening the page in a browser showed that the address bar threw it away the first time and that thirty mining rows sat under it the second. Checked: `/quantus/node/<peer>` renders one panel, no `.two-col`, no board rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
fac73f392c
|
feat: a nodes index, so a node need not have mined to be visible
Reported: `baba-gorchitsa` shows its hardware on telemetry.quantus.com and
nothing here. Not a decoding failure — we held the data. Measured, the same node
on both chains at the same moment:
quantus attribution=carried, 0 votes, 33 blocks on the feed: yes
planck attribution=telemetry, 1 vote, 2969 blocks on the feed: no
Exactly inverted. On mainnet we had the metadata and no join to it; on Planck
the join and no node. The mainnet name is *carried* from Planck, and
`Attribution::peer_id` is `None` for anything but a telemetry attribution
because there is no node behind a carried name — that reasoning stands. The
mistake was upstream: node metadata was reachable *only* through the mining
inference, and the feed hands us every node unconditionally. 288 on mainnet, of
which a handful have ever won a block.
So `/:chain/node` lists all of them and `/:chain/node/:peer` is one, neither
gated on mining. The index merges the live feed over the cache, so a node not
re-announced since our last restart still appears, faint, with its peer count
absent rather than stale — a peer count from an hour ago is not a peer count.
Live rows sort first, then by name, and the filter matches name, peer id, CPU,
version or country, because "find my node" is the question the page exists for.
The miner panel now links to the node's own page instead of being the only way
to see it.
`reported` beside the count is the feed's own `AddedChain` figure and will not
match in either direction — 284 against 288 listed, measured, because it is a
snapshot the feed refreshes on its own cadence. Shown next to our count rather
than instead of it, since the difference is a real fact about how each was
arrived at. My first comment claimed it was "usually larger"; it was smaller the
first time I looked at it.
One trap caught in the browser rather than by the type checker: "is this the
standings?" existed three times, and adding a section updated two. The redirect
in `App` judged `/quantus/node/<peer>` to be the standings and replaced it with
`/quantus/3600-blocks` — route parsed, page existed, address bar threw it away.
It is `isStandings` now, in one place.
Closes #16
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
|
|||
|
e7cfe42881
|
feat(telemetry): read what a node is doing, live off the socket
Peer count, transactions in its pool, upload, download and state cache — the volatile half of the feed, decoded from `NodeStatsUpdate`, `NodeHardware` and `NodeIOUpdate`, plus the first values `AddedNode` itself carries so a freshly announced node reads correctly rather than waiting for an update frame. **Never persisted, and the shape says so.** These live in `NodeActivity`, held in memory and attached to the miner route only when the feed is currently reporting the node — a node answered from the cache gets its hardware and no activity at all. A peer count from an hour ago is not a peer count, and storing one would turn a live reading into a stale number that still looks live. Only the newest sample of each series is kept. The feed sends rolling arrays and already redraws them; this is a readout, not a chart. `NodeHardware` carries three parallel series and the third is the feed's own x-axis, which is why the test asserts the download figure is not the timestamps. An update naming a node the feed has not introduced is dropped rather than counted a decode error: a feed joined mid-stream sees these before the `AddedNode` that explains them, and it will be re-sent. Bandwidth renders in decimal units while memory and state cache render in binary. That is not an inconsistency — a reader comparing bandwidth against a NIC or a speed test is comparing against decimal, and one comparing memory against what they bought is comparing against binary. Verified against the live feed: 34 peers, 103 kB/s up, 20.5 kB/s down, 528 MiB of state cache, read off mainnet and rendered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
28e99bee44
|
feat: show the node a miner's blocks were reported from
Caches what the telemetry feed says each node *is*, and surfaces it on the miner route: client version, target triple, CPU, cores, memory, kernel, distribution, whether it is virtualised, country, and how long the node process has been up. Kept in `node_telemetry`, keyed by peer id — never by the feed's `node_id`, which is per-connection and would accumulate a row per reconnect for the same machine. Written every five minutes rather than every thirty seconds like the attributions beside it: a CPU does not change, and mainnet alone has nearly three hundred nodes. The route reads the live feed first and the cache second, so a node the feed has not re-announced since our last restart still answers. Nulls never overwrite. The telemetry server geolocates asynchronously, so every reconnect re-sends every node with a null location — a naive upsert would blank the country of every node on the site for as long as the lookups took to catch up. The guard is in the feed state and in the `on conflict` clause both, with a test for each. **Location is a country and no finer.** The feed sends coordinates and a city from an IP geolocation database, and at city resolution those are wrong often enough that showing one would assert something nobody knows. `blackbeard_core::geo` derives the country offline from a 508 KiB boundary set — no network call, and nothing told where these nodes are — and the coordinates are then dropped. The schema has no city, latitude or longitude column: a column that does not exist cannot be rendered by mistake. The lookup returns subdivisions before countries and `GB-ENG` is exactly the precision being avoided, so only the two-letter code survives. **The panel wears its uncertainty in the header, not a footnote.** A miner is joined to a node by the same first-reporter voting that gives it a display name, so `Attribution` now carries the peer id it settled on — `None` for a preimage or a carried name, because there is no node behind either. The heading reads "reported by" and the confidence and vote count sit beside it. The note says the CPU is the node's and not necessarily what is hashing: on this network the work is on GPUs that telemetry does not report at all. Checked against the live feed end to end, not a fixture: real frames decoded, cached, served and rendered. One node reports an Ubuntu userspace on a Fedora kernel, which is a container seeing its host — the panel shows that faithfully rather than tidying it away. Closes #15 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
7ec7dda1a7
|
fix(web): a window segment names its unit, or it is just a height
`/quantus/3600` sent the standings straight to block 3,600. `isBlockRef` matches
any bare run of digits and is consulted two lines before the window is even
considered, so the rename walked into a collision that already existed — and
this module's own header warns about exactly it:
Shape works until two kinds of thing can look alike... Naming the kind
costs six characters and removes the whole class of problem.
Naming the window for its block count was right; leaving it as a bare number was
not. The segment is `3600-blocks` now, in the standings path and as the miner
suffix both, which is a distinct shape from a height and from a hash and says
what the number counts.
Bare counts are deliberately not accepted back, though the site emitted them for
about an hour: `/quantus/3600` cannot be told from block 3,600, and guessing
would make one of the two silently open the wrong page. The duration names from
before the rename still resolve and are still rewritten.
Checked in a browser against the live chain:
/quantus/six_hours -> /quantus/3600-blocks last 3,600 blocks · 11 h
/quantus/week -> /quantus/100800-blocks last 100,800 blocks · 29 h
/quantus/600-blocks -> unchanged last 600 blocks · 2 h
/quantus/block/3600 -> canonicalised to a hash #3,600
/planck/3600-blocks -> unchanged last 3,600 blocks · 32 h
That last line is the one this whole thread was about.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
|
|||
|
2594190869
|
fix(web): an empty path segment is not a window
`tsc -b` in CI caught what `tsc --noEmit` locally did not:
src/lib/routes.ts(131,3): error TS2322:
Type '"" | Window | null' is not assignable to type 'Window | null'.
`segment && LEGACY_WINDOWS[segment]` returns `''` for an empty path rather than
falling through to the `?? null`, so the root route's window was typed as the
empty string. Checked explicitly instead.
The real lesson is the command: CI runs `pnpm build`, which is `tsc -b && vite
build`. `tsc --noEmit` resolves a different project graph and `vite build` does
no type checking at all, so running those two is not the same gate and passed
over this. Run `pnpm format:check && pnpm lint && pnpm build` before pushing —
which is what the workflow itself runs, verbatim.
Nothing deployed from the failed run: `deploy-api` and `deploy-web` were both
skipped, so the site stayed on a consistent pair.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
|
|||
|
ef367d5993
|
fix: stop timing the catch-up, and name windows for what they are
Planck's page reported a **0.175 s** block time against a true 29.8 — a factor of 170 — flagged *measured* rather than nominal, so the headline block time, the network hashrate (877 GH/s on a testnet nobody mines) and the window label were all wrong and none of them looked it. `measured_interval` divides elapsed time by height difference, which is the chain's rate only if every height between two samples was watched arriving. A gap fill is proof they were not. `record()` marked every head-stream block `at_tip: true`, including the head that landed right after `fill_gap` closed a 1,251-block gap — so the pair straddling it measured how fast this observer caught up. `ingest` now forgets its tip samples whenever it fills a gap and records the closing head with `at_tip: false`. The interval goes nominal until twenty fresh samples exist, which is `MIN_TIP_SAMPLES` doing its job: nominal and labelled nominal beats measured and wrong. The reported symptom was smaller and had the same root. `baba-gorchitsa` showed three blocks in Planck's "six hours" having left for mainnet a day earlier — and it was right to: 3,600 Planck blocks currently span **29 hours**, because the chain's rate fell ninefold when its miners left. The label was built by multiplying the block count by the current interval, which describes the rate now rather than the period covered. It comes from `span_seconds` instead — the authored-time span of exactly the blocks tallied, already the denominator of every per-miner hashrate — and carries no "~", because nothing is estimated. So the names went too. A window is a block count, and calling one `six_hours` is a promise the site cannot keep on a chain whose rate moves; `/planck/six_hours` was the reason a reader believed a day-old row was current. The selector reads 600 / 3.6k / 14.4k / 100.8k and the URL carries the count. Less friendly than `6h`, and true on every chain. The old names still parse and are never emitted, so shared links keep working — the router rewrites them. Closes #14 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
4774b8cfaa
|
feat(ui): a light theme, following the browser, with a toggle in the masthead
Dark, light, or whatever the browser asks for. Auto is the default, so a reader who has never touched it gets their own system's answer rather than ours. The light palette is selected, not inverted. `#bd8829` carries every magnitude on this site at 6.6:1 against the warm-black and **2.77:1** against paper — the validator says so, and 2.77 is under the 3:1 floor a mark has to clear. So light mode gets its own step of the same bronze, and its `--data-bright` sits *darker* than `--data`, because emphasis on paper is weight rather than glare. Same for the washes: a glow at 0.08 alpha on warm-black is a smear at 0.08 on paper, so the nine colour literals that were still loose in the stylesheet became tokens — each one was a colour the second theme could not have overridden. Every value was chosen by running the dataviz validator against the surface it actually sits on, both modes. The single-hue rule is untouched and still load-bearing in both: bronze and crimson fail CVD separation as a categorical pair whichever ground they are on. `auto` is a preference rather than a third palette. It resolves to a concrete `light` or `dark` before the stylesheet ever sees it, which is what keeps this to one definition per palette instead of one per palette per media query — and it has to resolve before the *first paint*, because anything running after the bundle loads runs after the page has been painted once, and on a light preference that is a full-screen flash of warm-black. Hence the inline script, whose duplication of `lib/theme.ts` is the cheaper of the two costs. Two things that would otherwise bite: `localStorage` throws rather than returning null where site data is blocked, so every access is guarded and falls back to what the browser wants; and `auto` keeps listening, so a machine that turns dark at sunset does not leave a reader on the daylight palette until they reload. The toggle shows the state it is in, never the state it would move to — a control that displays its own destination is why these get guessed at — and its accessible name carries that state, since the icon cannot. Checked in a browser, both themes, on the standings, a miner page and the share chart. Worth recording what that turned up: dark carries 46 text elements under 4.5:1 and light carries 3, each beating its dark counterpart. The gap is `--text-muted` at 3.7, the deliberate existing value CLAUDE.md has always documented — not introduced here, and not something to change without deciding to change the dark design. Closes #12 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
adaf00e1f5
|
ui: separate mainnet from testnets in the chain nav
`Quantus` and `Heisenberg` sat side by side, identically styled. One is the real
network. Somebody reading a balance, a reward or a transfer had no way to tell
from the chrome whether the number meant anything, and Planck makes it two
testnets against one mainnet.
Two labelled groups rather than a styling difference alone, because a lone
visual difference reads as decoration to anyone who has not been told what it
means:
MAINNET [ Quantus 329 ] TESTNETS [ Planck 110 ] [ Heisenberg 4 ]
The dashed border on a testnet chip is a second channel behind the heading
rather than the only one, and every chip's tooltip now says outright that a
testnet's balances and rewards are not real.
No backend work: `ChainInfo.mainnet` has been on the wire since the registry was
written and the frontend simply ignored it. It stays operator-asserted and is
never inferred from the name — a chain publishing itself as "Quantus Staging
Mainnet" must not collect the heading by having the word in its title.
A chain list with no mainnet in it renders one `TESTNETS` group rather than an
empty `MAINNET` heading.
Closes #5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
|
|||
|
88086552cb
|
fix: show a block's events, joined to the extrinsics that caused them
The block page listed extrinsics and nothing else. Block 1 holds twenty-eight indexed events and the route returned none of them — visible from the outside, because an account page linked to a `Wormhole::NativeTransferred` at block 1 and block 1 showed no sign of it. `phase` and `extrinsic_index` were stored for exactly this join and had never been used. Each extrinsic now carries its own events beneath it, and the block's own events sit in their phases. Block 1 is the argument for doing it that way. As extrinsics alone it is a single `Timestamp::set`. Its events are the entire opening distribution: twenty-one `Wormhole::NativeTransferred` in `Initialization`, owned by no extrinsic at all and sent from the minting account — not block zero, and not anything a transaction did. `Vesting::LaunchMomentSet` fires there too, so vesting has been exercised despite the call index showing zero `Vesting::*` dispatched: a pallet the runtime uses looks unused from the call side alone. Closes #3 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
dd8086df0d
|
feat: mark the accounts a chain names, and make genesis unmissable
Some accounts are special and nothing about the address said so. The treasury looked like any other empty account, the minting sentinel looked like a wallet, and the twenty-one accounts endowed at genesis looked like they earned it. No curated list. The chain names them itself, in three places that are three different claims and are not flattened into one badge: a runtime constant (compiled in, immutable for that spec_version), a storage value (assigned, and changeable), and a balance in block zero (history, and not a role — an account can be endowed and have no job). Labels derive from the chain's own name, so a pallet added next year is labelled without an edit. The chip is the claim; the citation beneath it is the evidence. Two rules hold it together, both learned the hard way and both now in CLAUDE.md. An account is told from a hash by registry path, never by shape — after `normalise` both are `0x` and sixty-four hex characters, so `decode_typed` now returns the account set the decoder collected while it still knew. And a storage entry names an account only if its value *is* one: `System::Events` is full of accounts and names none, and the first cut labelled half the chain's active addresses `Events`. Computing all of this walks every plain storage entry and enumerates block zero — dozens of round trips, fine once and pathological on every account page view — so it is cached for five minutes. Constants change with the runtime, state assignments almost never, genesis never. Block zero gets the endowments, because they have no extrinsic and no event and are invisible to anyone who has not read the chainspec, and a Genesis link sits in the nav to give somebody who would never think to look an unmissable way to. Mainnet started with 5,670,000 QTC across 21 accounts — one holds 5,669,940 and the other twenty got 3 each — shown beside what each holds today, so what a founding account did with its stake is one row. When genesis state cannot be read the page says so rather than showing an empty table: missing data and a chain that endowed nobody are different claims. Closes #2 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
c08d3c4e04
|
feat: pending reversible transfers, with a countdown
Scheduled transfers that have not yet landed, counting down to the block they execute at. `ReversibleTransfers` is the most distinctive thing this chain does and the observer said nothing about it. The only view here built from both halves, each authoritative about a different thing. State — `PendingTransfers` — says what is still pending: a cancelled or executed transfer is simply gone from the map, and that absence beats replaying every event since genesis and reconstructing the set, which fails silently. The event index says when each is due, because `TransactionScheduled` carries `execute_at` and, contrary to what the issue assumed, the stored struct does not. A transfer scheduled before the index reaches shows as pending with an unknown deadline rather than being dropped for half a story. Enumerating a map needs the prefix and `state_getKeysPaged` — there is no list, only keys derived from the things in it — and recovering a key from a storage key needs the hasher to have kept it. `Blake2_128Concat` and `Twox64Concat` do, `Twox128` and friends do not, so `key_offset` is `None` there rather than a guess. Verified against a populated map since the target one is empty: `System::Account` enumerates, its keys decode back to accounts, and the balances read. `execute_at` is a `DispatchTime<BlockNumber, Moment>` — an enum, so reading the wrong arm would show a millisecond timestamp as a height. Time remaining is an estimate from a block interval that moves; the block count is the fact and the page says so. The page reads "nothing is in flight" today, correctly and deliberately: zero of this pallet's six calls have been dispatched on mainnet. It exists before the first one because catching the first one is the point. CLAUDE.md gains the workflow this was the first of — issue first, closed by the commit, and corrections recorded as comments on the issue rather than as surprises in a diff. Closes #1 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
6b0e02e059
|
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 |
|||
|
1e8869e7a1
|
feat: index the whole event surface, and show what has never fired
The event interest list was an allowlist of two entries, which meant every capability the chain grew was invisible until somebody remembered to add a line — the exact failure the metadata-oracle approach exists to avoid. It is a denylist now. Nothing is excluded for being unused. Whether a capability is exercised is the question an analyst is asking: a chain that ships vesting and governance nobody touches is telling you something, and it can only tell you that if the silence is recorded rather than filtered on the way in. The rule for exclusion is narrower than volume and narrower than usefulness — an event carrying no account can never answer "everything involving this account", which is what the index is for. Three kinds both name no account and fire every block, so they are skipped: ZkTree::LeafInserted, QPoW::DifficultyAdjusted, System::ExtrinsicSuccess. They render as "not indexed" rather than as a zero, because a zero reads as disuse. Balances::Minted looked like a fourth. It fired exactly as often as MinerRewarded across a 120-block sample and for the same reason, but it names an account, minting is not only for miners, and showing a reward twice on one page is a presentation problem to solve on that page rather than a reason to lose the record. Kept. `/:chain/event` is the call index's other half — 19 of 107 kinds fired — and `/:chain/event/:pallet/:variant` the feed behind a row. Measured on live mainnet the widening took the index from ~1.2 to ~3.4 rows per block and bought 739 accounts' worth of wormhole transfers that were previously invisible. Signatures also lose their associated-type ceremony: the runtime writes `<<T as frame_system::Config>::Lookup as StaticLookup>::Source`, which is correct and forty characters of scaffolding around one word, and three of those in a row pushed the counts off the side of the table. Generics are untouched — `BalanceOf<T>` is information. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
6b45004907
|
feat: the call index — declared surface against actual use
Every explorer shows what happened on a chain. None shows the difference
between what a chain can do and what it has done, because none holds the
runtime's declared surface next to the activity. We already hold both, so this
is a join rather than new indexing.
quantus v152 6 of 58 dispatchables used
heisenberg v148 6 of 58 — and a different six
The gap is the interesting half. ReversibleTransfers, TechReferenda, Vesting
and Preimage are shipped, documented and untouched; "this chain has governance
nobody has used" is a different statement from "this chain has no governance",
and only one of them appears on a list of what happened. Unused calls keep their
signature and their docs, both read from the chain rather than a source tree,
and are dimmed rather than hidden.
Two chains on the same runtime family diverging is the other thing it surfaces:
mainnet has exercised the wormhole batch verifiers and nothing else, while
Heisenberg has a whole multisig lifecycle and no wormhole traffic.
`/:chain/call/:pallet/:call` is the feed behind a row — who dispatched it, with
what arguments, and whether it worked. Nothing is written per call type: the
arguments render through the same `Payload` as everything else, so a pallet
added next year gets a row on the index when the runtime declares it and a
working page the moment somebody uses it.
`BlockExtrinsic` gained `height` and `at`, which are redundant on a block's own
page and the entire point on a feed where every row is a different block.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
|
|||
|
93358dd920
|
ui: the window belongs to the miner page, not just the standings
Choosing a window on a miner's page navigated back to the standings. Not a mis-wired link: there was no URL for "this miner, over a day", so the only thing the control could do was go somewhere that had one. So the miner route carries it: `/quantus/miner/0x…/day`. That is the right place for it regardless — a miner's rank and hashrate are *of* a window, and a link sent without one shows the recipient something other than what the sender was looking at. In the path rather than a query string, so it reads the way the standings' own window already does. The bare `/quantus/miner/0x…` still resolves, at the default window, so existing links keep working. And the control now appears only where it changes something: the standings, and a miner's page, which is one row of those same standings. A block happened once, a balance has no span, a runtime is not a rate, and the three indexes are not ranked — on all of those it was a live control that did nothing, which teaches a reader to distrust it on the two pages where it works. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
f9ea292a70
|
fix: a hashrate estimate now carries its error bar
The leaderboard credited a miner with "1.55 GH/s" on the strength of one block, last seen four hours earlier, to three significant figures. Block finding is Poisson: k blocks carries a relative standard error of 1/√k, so one block is ±100% and it takes twenty-five to reach ±20%. The estimator was unbiased and the presentation was not honest, which is the half that matters to a reader. `miner_hashrate` now returns the count's uncertainty with the figure, and the leaderboard and miner page render it — dimmed below four blocks, where the error bar is wider than half the value. Checked first that the arithmetic itself is sound, because the obvious suspicion was the formula. It is the chain's own: `qpow-math::is_valid_nonce` accepts when `hash < U512::MAX / difficulty`, `verify_nonce_internal` passes it what `get_difficulty()` returns, and the hash is uniform — 200,000 samples of `hash_squeeze_twice` gave 0.4995 of 2^512 against a uniform 0.5, and 12.48% below 2^509 against 12.5%. Acceptance is 1/difficulty, so expected trials is difficulty, exactly. The two network estimators — difficulty over tip interval, and summed work over window span — also agree within 5% on live data. What that leaves is recorded in CLAUDE.md and is not an observer bug: our three miners measure 1.66 GH/s (1.39 instantaneous), while the chain credited the same preimage 5.3 GH/s over 29 blocks. Sampling does not explain 14 sigma. `miner_cpu_hash_rate` is 0 on all three — every hash is on GPU — and the miner's CPU path calls `qpow_math::get_nonce_hash` directly while the CUDA kernel is a separate implementation with its own counter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
edce856263
|
feat: decode extrinsics, and show them
An event is the chain's account of what happened; an extrinsic is the request that caused it, and carries what no event does — who signed, what they called, the nonce and tip, and whether it worked. A history built from events alone omits every failed attempt and never names a submitter. The same oracle does both. The metadata's extrinsic type carries four parameters and the registry names all of them, including `qp_dilithium_crypto::types::DilithiumSignatureScheme` — the part that looked like it would need hand-written post-quantum knowledge and does not. The chain describes its own signature scheme, so `decode_extrinsic` reads a Quantus transaction without a line of code that knows Quantus exists. Two facts about this chain's extrinsics, both now in CLAUDE.md. The first byte is not the version: the top two bits are a type tag, and mainnet carries `0x84` (signed, v4) and `0x05` (bare, v5) in the same block while the metadata declares version 4 — check the byte against the metadata and you reject every timestamp inherent on the chain. And a signature is 5.3 KiB, two orders of magnitude larger than the call it authorises; it is decoded to find where the call begins and then discarded, keeping only the scheme's name and the byte count. Both halves of a block index in one pass, off calls already being made. Whether a dispatch succeeded comes from the `ExtrinsicSuccess`/`ExtrinsicFailed` events in that same decoded list — read, used, not stored. The block page grows a table below the facts: call, signer, arguments, and the signature's size. The account page merges extrinsics into the history it already had, ordered by block with the request above the results it caused. `source` is 1 for an extrinsic and 0 for an event so that every part of the sort key descends, which lets the paging cursor be a plain row comparison rather than a mixed-direction one Postgres cannot express. Nested calls are why this had to be generic. A `Utility::batch_all` carries whole calls in its arguments, so a transfer's recipient can be three levels down — and because the decoder collects account ids wherever they appear, that batched transfer shows on the recipient's page tagged *received* even though they signed nothing. Verified on mainnet 12,616: the batch renders both its transfers with destinations and values, above the two `Balances::Transfer` events they produced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
efbe901646
|
feat: section indexes, so the routes can be found
Every route added lately was reachable only by already knowing an address — a block hash, a preimage, an SS58 string, a spec_version. Fine for a link somebody sent you, useless for discovering the pages exist. A kind with nothing after it is now the index of that kind, and a nav under the chain chips names them. Not the same page three times. Blocks is the record behind the live ticker, paged by height. Accounts ranks by tokens earned over the whole indexed record, which is a different question from the standings' blocks-per-window and here a different answer: difficulty rose 346-fold inside mainnet's first day, so an early block cost a three-hundredth of a current one. Runtimes is the only list with no other home — a block does not name its runtime and neither do the standings. No `/:chain/miner`: the standings already are the index of miners, and a second page of the same rows under another address would be two answers to one question. That path redirects there, along with anything else that resolves to the front page without being spelled like it, so the address bar and the section nav agree about where the reader is. Two things caught by looking at the rendered pages: The block index's gaps came from `observed_at` and read *three milliseconds* between blocks on a chain targeting twelve seconds — exactly the trap CLAUDE.md records, since a gap fill writes a whole batch within one second. They come from `authored_at` now, the chain's own clock. The first page of that index came from the live ticker, which is ordered oldest-first because that is the order it is pushed in, so page one climbed and every page after it descended. The index sends a cursor for its first page too. Accounts shows the address beside any node name: one node legitimately reports for several payout addresses, and the first cut had two rows reading `pearl-prover` with nothing to tell them apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
b006f54cd5
|
feat: a runtime's own page
`/quantus/runtime/152` — every pallet, call, event, error, storage entry and constant the runtime declares, with the constants' values decoded against their own declared types. Nothing transcribed from a source tree: a source tree says what the chain should be running, metadata says what it ran. The set of runtimes is found rather than waited for. Decoding caches one the moment it needs it, so left alone the cache is "whatever the backfill has walked past" — days on a chain a million blocks deep. `spec_version` only increases, so the boundaries are a sorted sequence and bisection finds each in log₂(height) probes. On Heisenberg that turned up six runtimes and the block each took over at: v126 from 1, v128 from 132, v131 from 342,813, v136 from 669,129, v144 from 812,055, v148 from 977,079 — two more than the four upgrade boundaries this approach was originally verified against. Discovery is its own task, not a poll step. `state_getRuntimeVersion` at an old block makes the node instantiate the runtime WASM from that block's state: ~4 s against Heisenberg's endpoint versus ~0.25 s at the tip, and a hundred of those inside a four-second loop stalls difficulty and the summary broadcast for minutes on every start. Reading Heisenberg's two ends side by side is the case for the page. Between v126 and v148 the chain dropped Referenda, ConvictionVoting, Recovery, Assets and AssetsHolder, added Vesting and Origins, gained `WeightReclaim` and moved `ChargeTransactionPayment` after the two Quantus-specific extensions. Every one breaks a decoder written against the other version and none is visible from a block. `U256`/`U512` now render as one decimal rather than four or eight little-endian limbs, identified by registry path exactly as `AccountId32` already was. Difficulty as `[1189189, 0, 0, 0, 0, 0, 0, 0]` describes the bytes correctly and tells a reader nothing. `ChainSummary` gained `spec_version`/`spec_name` so the footer can say what the site is decoding against, and link to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
1260cc6d64
|
ui: the block route is a page too
Same reasoning as the miner and account routes one commit ago, which the block route was deliberately left out of: a block is read against the standings, and the ticker row below is what you clicked to get here. On reflection that is an argument about how someone arrived, not about what the URL names — and the URL names one block. The board is what `/:chain/:window` is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
e330d2b78a
|
ui: a miner and an account are pages, not dialogs
Both were panels that opened above the standings, which is right for a block — "who won this one, and where do they sit" is read against the board, and the ticker row below is what you clicked to get here — and wrong for the other two. A miner's page and an account's page are somebody's own record and the whole subject of the URL that reached them; eleven other miners' rows underneath is the site failing to notice which page it is on. The close buttons go with it. Neither panel opened over something the reader was looking at, so there is nothing to close back to, and the back button and the chain chip already do what it would. "Mark as yours" stays: that is a real control and not navigation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
d6f4c0c21e
|
ui: only advertise chains we can actually read
The nav listed every chain telemetry names, with the ones we hold no RPC endpoint for as disabled chips saying "no endpoint". The reasoning was that they are part of the network and hiding them misrepresents its shape. In practice it put five dead chips above the standings on every page — four of which have never had anything behind them — and made the one live chain the smallest thing in the row. Nothing is blocked. `/:chain/...` still resolves for any chain the backend knows, `/v1/chains` still reports every one of them with its `tracking`, and promoting one is still a single `[[chains]]` entry. This is only about what the page offers unprompted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
d5a286f220
|
feat: account pages, built on the event index
`/quantus/account/qzp2AxZw…` answers the question the official explorer answers badly: what an address has actually been paid, every reward, back as far as the index has read. Everything comes from this observer's own decoding — no subsquid, no external indexer, nothing that stops working when the node prunes the state behind it. There is no case per event type anywhere in this. `chain_event` gained an `accounts` column filled by the decoder itself, which knows which values are `AccountId32` by registry path — the same knowledge that keeps it from rendering a block hash as an address — so "everything involving this account" is one containment query that already covers pallets nobody has written yet. The frontend labels what it recognises and renders anything else as its own key and value, so a new event type shows up as itself rather than not at all. Events also carry the block's own timestamp now. `block.authored_at` holds the same thing but only for blocks this process was running for, and a payment history that renders "height 412" with no date because the observer was not alive in March is not a history. The preimage and the address are two ends of one identity and each page now links to the other. Address to preimage is a lookup among preimages seen, not a calculation: the derivation runs one way. Verified against mainnet: 13,384 reward events indexed from genesis to the tip, quanpool's page reconciling to 3,784.98 QTC over 12,346 blocks, and transfers rendering their counterparties as linked addresses without a line of code that knows what a transfer is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
49c266a905
|
ui: real URLs for every view
The hash router was the right size for a chain and a window. It stopped being once a block, a miner and — shortly — an account each became a page worth sending someone: a fragment never reaches a server, so those URLs cannot carry a title, be crawled, or be resolved by anything but the page itself. So paths, via react-router, which was already a dependency and unused. Both vhosts already answer any path with `index.html`, so nothing outside the client changed. `lib/routes` holds the grammar in one place and `fromLegacyHash` rewrites the old form on load, so every `#/…` link already published still lands where it meant to. Segments are named — `/quantus/block/13160`, not `/quantus/13160` — rather than told apart by shape. Shape works until two kinds of thing can look alike, and an SS58 address is arbitrary base58 with no rule keeping it clear of a window name forever. Six characters buys the whole class of problem away before accounts arrive. Everything navigable is an anchor now, chain chips and the window control included, which is what "real URLs" has to mean in practice: middle-click opens another chain's 24h view in a tab, and the back button walks the panels. The miner panel had no address at all before this — opening one changed the page and not the URL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
6a29250c88
|
feat(ui): link the source repository from the footer
The repository is public now. The site's claim is that every miner is derived from public block headers with no registration and no opt-in; the code doing the deriving being readable is the other half of that, so it belongs beside the caveats rather than nowhere. Its own line under them, not a fourth item in the `space-between` flow, which would strand a short line against a long one — and it is a different kind of statement from the three above: where to go and check the numbers, not a caveat about them. Opens in its own tab so the WebSocket behind the page keeps its connection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
9eb4bee649
|
fix: a miner's hashrate is the work it did, not its share of today's network
Reported against a Grafana panel showing roughly a sixth of what the site did. Grafana was closer to the truth. The estimate was `share of blocks in the window × current network hashrate`. That is only correct while the network is the same size across the whole window. Measured on mainnet's launch day it was nowhere near: difficulty rose 346-fold inside one 14,400-block window, from 96.4B to 33.4T. A miner that won its blocks early — when a block cost a three-hundredth of what it costs now — had that share multiplied by today's network. For this observer's own node the site reported 25.4 GH/s against an actual 2.5 GH/s. Difficulty is expected hashes per block by definition, so the difficulty of the blocks a miner won, summed and divided by the time they spanned, is its hashrate directly — with no reference to the network's size, and immune to that size changing underneath, because every block carries the difficulty in force when it was won. The observer has recorded per-block difficulty correctly since the parent-state fix, so the data was already there. `miner_series` had the same flaw and is fixed the same way: summed work per bucket over the bucket's own duration. The window's duration is taken from `authored_at`, not `observed_at`. A stretch this process caught up on carries observation times seconds apart for blocks minutes apart, and dividing real work by that would invent hashrate — the same trap the history series was built to avoid. The headline network figure is unchanged. It divides current difficulty by the measured interval because it answers a different question: how hard is a block to win right now, not what did this miner do over the last twelve hours. The two no longer sum to each other, which is correct and is what the footer now says. `standings_rank_by_blocks_then_recency` asserted 400.0 from the old formula; its third argument is now the window's duration, so it asserts the work-based figure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
18a99ddc8f
|
fix(ui): head the miner panel with the best identifier, not the preimage
The panel body listed the address and preimage, but its heading still read `0x134e73f0…df59` — the one identifier a miner does not recognise, given top billing. It now falls back through name, address, preimage, which is the order a reader can actually use them in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
|||
|
a3eb670d11
|
feat: identify miners by the address they are paid at, not the preimage
A reward preimage is exact and unrecognisable — no miner knows theirs by sight. The address is the string they configured and the one their balance shows against, and the two are related by a derivation the chain performs anyway: `pallets/mining-rewards` pays `qp_wormhole::derive_wormhole_address`, which is `qp_poseidon_core::rehash_to_bytes`. Running the same function reproduces the address exactly, with no RPC and no lookup table. The derivation takes the preimage and nothing else — no genesis, no chain parameters — so one preimage is one account on every Quantus chain. The unit test pins it against a real pair (this observer's own node and the address it was paid at), so a dependency upgrade that changed the derivation fails the build rather than quietly relabelling every miner on the site. Unnamed miners now render as their address. The miner panel shows all three identifiers, because they answer different questions: the node name is an inference and may be absent, the address is what the miner recognises, and the preimage is what the header actually carries and the only one the chain asserts. SS58 is written out rather than pulled from `sp-core`, which would bring a substrate runtime's worth of dependencies to format 36 bytes — the same trade the readme records for declining subxt. Base58 is hand-rolled because it is long division and nothing else; blake2 is not, because checksums are not worth hand-rolling. Both are covered by the address test vector. `RecentBlock` gains the attribution source: the ticker cannot tell a node name from an address by looking, since `quanpool-payout-mainnet` and `quantus-radar` both begin with the letter Quantus addresses do. Two tests that asserted the old display policy now assert the new one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |