c483d51dc73d0f36225ac0a4fbddcb802dd67f55
73
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7cd2197997
|
feat: chart the Ref DCL pool's swap price on its own market page
`/v1/chains/{chain}/ref/series?counter=nep141:…` buckets the latest pool
swaps exactly as `/intents/series` buckets intents trades, through one shared
`price_series`, and `ref-dcl` draws it with the same plot and figures table
(`SeriesBody`, pulled out of `PriceChart`). It is a separate chart, never a
second line on the intents one: a pool swap is usually a solver restocking
after an intents fill, and one line over both would draw one demand twice.
`RefPool::counter_token` was documented as a contract but carries the
`nep141:` prefix; the doc now says so — the first cut of the chart prefixed
it again and drew nothing.
Tracking shard 8 (option B), the other half left open on #37, is item 5 of
#39 and waits for the new NEAR host.
Closes #37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
|
||
|
|
b85e74f1ca
|
feat: index QTC's Ref DCL pool — its swaps, its ladder, and how much we see
On 2026-10-06 the team bridged 150,000 QTC to NEAR and put almost all of it in a Ref Finance DCL pool, not into solver balances, so the market page saw none of it. The pool contract (dclv2.ref-labs.near) is on shard 8 and our node tracks only intents.near's shard 7, so its swap events are never in anything the scan reads. What the scan does see is the relay transaction: the Verifier's outcome logs an ft_withdraw from the solver to the pool. Those are queued (ref_pending, migration 0012, persisted because the process restarts) and fetched whole from the public endpoint 20 blocks later, once the pool's receipt has run; its dcl.ref swaps are recorded with the solver as trader, since the pool itself records intents.near. Swaps made by calling the pool directly are missed — and the pool's own cumulative counters say by how much, so the page reports coverage instead of a disclaimer: today 99.75% of QTC bought out and 100% of QTC sold in, the gap one 0.000498 QTC buy. Pool state comes from view calls once a minute: price at the current point, holdings, whitelist, counters, and the liquidity ladder from blackbeard_core::dcl — discrete sums over points, with the current point split exactly by liquidity_x; tested against the pool's own totals (QTC to 0.01, and USDC short by exactly the LP fees total_x also holds). Pools are found by paging list_pools. It is its own market, /:chain/market/ref-dcl, never summed with NEAR Intents trades: a user's buy is a solver fill there and, after it, the solver's restock here. The pools' whitelists join the inventory poll, which now finds solver-ref-2.near holding 0.288 QTC. Migration 0013 pulls QTC's scan back to NEAR 218,700,000, before the pool, so the swaps production already passed are found on the re-read. Refs #37 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
d6654ac4b0
|
feat(api): flag QTC on NEAR that exceeds what exists outside the vesting pot
qtc.omft.near is minted only against QTC locked on Quantus by the bridge, so its supply can never legitimately exceed issuance outside the vesting pot — 221,838 QTC today. The listing poll now reads that ceiling every five minutes through one helper, issuance_outside_pot, using the same storage reads and the same subtraction the network page uses, rescales it to the NEAR token's decimals (rounding down, so the check can only get stricter), and compares it with ft_total_supply. A breach is a logged warning and, on the market page, a sentence before any figure: unbacked, or one of the two misread. Either read failing gives no verdict rather than a false all-clear. The panel also shows the ceiling itself as "most there could be". Checked live: QTC at 0 against 221,837.97 with no breach, and the breach path forced by pointing the dev config at wNEAR (35.4M on NEAR), which logged and rendered the warning as intended. In place before QTC's announced NEAR Intents launch on 2026-10-05. Closes #36 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
a13a6ef1a5
|
fix(api): pace the intents backfill only for someone else's endpoint
The one-second pause between backfill blocks was set when every read went to FastNEAR's free RPC or its archive. Since #27 was enabled the live endpoint is our own node and start_height the oldest block it holds, so the whole backfill was served by a host we run and the pause only stretched it to about six hours — stalling the tip side for each descending batch, so live pushes were missed meanwhile. The pause now follows who serves the read: heights at or above the live endpoint's earliest block go to rpc_urls, older ones to the archive, and only a set that is not ours is paced. Ours is decided from the URL host — a `.internal` name, localhost, or a loopback, private, link-local or unique-local address — and a set counts as ours only when every endpoint in it is, since failover can reach any of them. Measured against near.internal: the backfill walked about 15,000 blocks in 60 s (≈250/s, up from 1/s) while the top end kept pace with the tip. Closes #35 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
a07a3bc580
|
fix(api): sort solver inventory with sort_by_key
The lint the previous commit was pushed with; caught before CI ran. Refs #34 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
2d80325c3b
|
feat(api): solver inventory per side of each pair, read from our node
ci / gate (push) Failing after 1m34s
NEAR Intents has no order book: solvers quote privately per request and nothing on chain carries a price level. What is on chain is the inventory they fill from, inside intents.near — and intents.near is on the shard our node tracks, so mt_batch_balance_of is answered locally in about 40 ms. The scan now records every solver it sees filling any pair — a named account with no referral, which the #30 survey found to be solvers without exception — in intents_solver (migration 0011), so the list survives restarts and is there before QTC has a trade of its own. Once a minute the observer asks intents.near what each holds of the asset and every counter traded against it, and serves it at /intents/inventory: the asset as the side that serves buyers, each counter as the side that serves sellers, with how many solvers hold it and the largest holder. The market page shows it as a table, the asset and the ten counters most solvers hold with the rest folded, under a note that says what it is not: no price levels, shared across pairs so the rows do not add up, and not a ceiling. Checked live against our node with wNEAR configured: 11 solvers, 74 tokens read in one pass. Before QTC is listed the panel's job is its one row — when solvers start stocking QTC, it shows here before the first trade. Closes #34 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
fb7148cc98
|
feat(ui): a price chart on the market page, and its placeholder
The asset's price in one counter token over its latest trades, read against the dataviz skill before it was drawn. One series in the data hue and no legend: a token trades against dozens of counters, in units no axis could share, so the counter is a control above the chart rather than a hue per line. Buckets come from blackbeard_core::intents::bucket_prices over /intents/series — volume-weighted, with low and high, empty buckets left out so the line breaks where nothing traded instead of drawing a price nobody paid. The y-axis is fitted to the prices; the window is a count of trades, so the time axis covers only what they span. A table view sits under it. Until anything has traded the frame keeps the chart's height — nothing moves when the first trade lands — with a baseline and the reason, and no axes, since a scale over no data is a range nobody measured. For QTC today that reads "no QTC is bridged to NEAR yet, so none can have traded there". Rendering it caught three things the build did not. The line never drew: the API reported the bucket width truncated to whole seconds, so no two buckets looked adjacent — widths are now whole seconds by construction. The chart painted at its 720px fallback until a ResizeObserver reported, centred in a full-width panel; it is measured before first paint now. And on the market page the seventy-row counter table pushed the chart below the fold; it folds. Checked against real wNEAR trades and the QTC placeholder, light and dark. Closes #33 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
5d22137a43
|
feat(ui): a market route — /:chain/market and /:chain/market/near-intents
The market index lists the venues a chain's token is followed on, asked of a
new /v1/chains/{chain}/markets so a chain followed nowhere says so and a
second venue needs no frontend change. The near-intents page carries the
price figures and listing evidence, swaps as they settle (from the
IntentsSwap push), and the settled history, paged newest first through
/intents/swaps?order=newest&before=… — the existing `after` stays for readers
going forward, and mixing the two directions is a 400.
`market` is a subject in lib/routes.ts like `node`: in parse, href and
isStandings together. `markets` parses too and is rewritten to the singular
every other kind uses.
Rendered against 964 real wNEAR trades before it shipped, which is what
showed the panel did not scale: seventy counter tokens became seventy tiles,
NFT swaps sat in the grid as prices with no number, and "wNEAR in USDC"
appeared six times for six different bridged tokens. Now only counters with
decimals are prices, most traded first — four tiles on the network page,
eight and a table of the rest on the market page — NFT and multi-token swaps
are a count, and a bridged token is labelled by its origin (`USDC · base`),
the contract it is, not the symbol it chose.
Closes #32
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
|
||
|
|
ef2afa8e4e
|
fix(data): a skipped NEAR height no longer stalls the intents scan
NEAR does not produce a block at every height, and 218,363,444 was one it skipped. The backfill asked for its shard layout at the batch's first height, got UNKNOWN_BLOCK from every endpoint, and asked the same question every 15 s from then on: production's indexed_from stopped at 218,363,445. The tip side runs the same code and would have frozen the first time a batch began on a skip. The layout now comes from the first height in the batch that exists. The second half of it: neard sends JSON-RPC errors with HTTP 422 where FastNEAR's proxy sent them with 200, and call_one checked the status before reading the body, so the node's own "no such block" arrived as a transport failure. chunk() could not see a skip as a skip, deep() never asked the archive, and on a chain with several endpoints a node's valid answer would have been failed over. parse_response reads the body whatever the status: a JSON-RPC error is the node answering; a non-success status without one is the host not answering, as before. Checked against near.internal with the scan seeded to begin a backfill batch on the skipped height: it walked past it with no warnings. Closes #31 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
40bc2f67d1
|
fix(core): one swap per taker leg, and solver hops are never trades
A 3,000-block survey of intents.near from our own node (350 outcomes, 1,026 legs) showed the referral leg is the user — per-quote implicit accounts, none on a solver — and that its own diff mirrors the rest of the outcome to within the protocol fee in all 576 amounts checked. So each taker leg is now its own trade, priced from what that user gave and got. It also showed the pooling rule counted solver routing as trading: in 45% of outcomes with a taker the solvers traded through a pair no taker touched, usually USDT on Ethereum, and each such leg on the tracked asset became a phantom trade against a token the user never saw. Two takers on one pair — an account round-tripping wNEAR, in the case captured — were merged into one trade with a nonsense price. Now an outcome with any taker yields only the takers' trades; only an outcome with no referral at all (solvers trading with each other) is pooled, as `kind: unattributed`, with no side. `kind` is stored (migration 0010, no re-scan: production held no QTC rows) and on the wire, and the panel says how many of a figure's trades were users'. Tests run on the three captured outcomes. Checked live against near.internal with wNEAR: 18 trades from 18 outcomes, every taker an implicit account, and no counter that was only a hop. Closes #30 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
b05b208dfa
|
feat(config): follow QTC on NEAR Intents through our own node
Enables [chains.intents] for quantus: asset nep141:qtc.omft.near, read from https://near.internal (neard on frankie, tracking intents.near's shard), with view calls left on FastNEAR. start_height is the oldest block our node holds, 218,346,736 — a sound floor because qtc.omft.near held no supply at 218,369,028, so nothing can have traded before it, and it keeps the whole backfill on our node rather than on a throttled archive. The gate test that kept this off becomes a parse test that expects it on. Closes #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
cf227bf421
|
fix(data): read tx outcomes at INCLUDED_FINAL, not EXECUTED
Our NEAR node tracks only intents.near's shard. Against a swap that withdrew through omni.bridge.near, EXECUTED and EXECUTED_OPTIMISTIC both waited for the receipts on shards the node never sees and timed out with a 408 after ten seconds — the scan would have retried that block forever, stalling on exactly the trades it exists to read. INCLUDED_FINAL answered in 10 ms with all five token_diff events and the intents_executed event, matching FastNEAR's full view, because intents.near's first receipt executes in the inclusion block. Verified end to end against https://near.internal with wNEAR configured: the scan followed the tip with no errors, recorded trades and pushed one live. Refs #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
100d508588
|
feat(ws): push each swap as it settles on NEAR Intents
`ServerMessage::IntentsSwap` carries the trade exactly as /intents/swaps returns it — one conversion, `intents::to_swap`, serves both, so the route and the push cannot describe the same trade differently. Only trades read within LIVE_WITHIN_BLOCKS (~a minute) of NEAR's final tip are pushed. The backfill never broadcasts, and the threshold is what keeps an upward catch-up after a restart from announcing an hour of history as news — the confusion the wormhole pulse shipped once already. The market panel re-reads its figures on each push rather than adjusting a copy: a VWAP over the latest N trades drops its oldest as it gains one. Verified by replaying the trade at NEAR 218,261,895 with the threshold opened locally and catching the frame on /v1/ws. Refs #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
0d5ebd19ee
|
feat(ui): a market panel on the network page, from settled swaps only
Shows the token's price on NEAR Intents per counter token, volume-weighted over its latest trades with the trade count beside every figure, and in that token rather than in dollars. Before anything has settled it shows the evidence instead: supply bridged to NEAR, what intents.near holds, whether 1Click lists it, and zero swaps over the NEAR blocks read — so a rate quoted elsewhere has something true to stand against. Renders nothing when the chain answers `no_intents`, which it does while the integration is off. The asset is labelled by its own ft_metadata symbol, returned as `asset_symbol`, rather than assumed to be the chain's token: checked by rendering it with ZEC configured, where the first cut called 143,730 ZEC "QTC". Refs #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
d2567e5584
|
fix(data): send NEAR view calls to their own endpoints
The node the scan will follow tracks only intents.near's shard, and the token contracts it asks about — qtc.omft.near for supply, every counter token for ft_metadata — live on other shards, which such a node cannot answer for. Views are a few an hour, so they go to `view_urls`, FastNEAR by default, and the per-block reads stay on `rpc_urls`. Refs #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
8128201ad9
|
feat(api): index swaps settled by NEAR Intents, off by default
The price of a token on NEAR Intents is the ratio inside a trade that
`intents.near` actually executed; there is no pool, oracle or stored price,
and a 1Click quote is an offer. This reads `execute_intents` transactions on
the contract's shard, keeps the `token_diff` legs whose receipt succeeded and
whose intent is in `intents_executed`, and records one trade per asset/counter
pair per outcome, counting each unit once whichever side the taker was on.
- core: event parsing, trade building, shard lookup, price and VWAP, tested
against the logs of mainnet tx 85uy9uCF (USDT on Tron for ZEC).
- data: a NEAR client over the existing RpcClient, live then archival;
migration 0009 (intents_trade, intents_scan, near_token).
- api: a contiguous-cursor scan and a supply/listing poll per chain, and
GET /v1/chains/{chain}/intents/{swaps,price}. Prices are in counter-token
units; no USD until a stablecoin is named by contract.
Gated on `[chains.intents]`, which the config template leaves commented out:
FastNEAR's free RPC sustains about two requests a second against ~1.7 needed
to follow the tip, and neardata allows 30 blocks a minute. It waits for our
own node. A test renders the template and fails if the block is enabled.
Verified end to end against mainnet with ZEC configured: the trade at NEAR
218,261,895 was recorded and served by both routes.
Refs #27
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
|
||
|
|
501a9e6ad4
|
fix(data): re-read event history under the current keep policy
Until
|
||
|
|
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
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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
|
||
|
|
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 |
||
|
|
0b3c1b2e1b
|
feat(telemetry): decode the whole of NodeDetails, and locate nodes by country
We were reading two fields of eleven — the name and the peer id — and dropping the rest, including the hardware every node already announces. `NodeMetadata` now carries the slow-moving half: implementation, version, the target triple, CPU, cores, memory, kernel, distro, whether it is virtualised, and the process start time. The volatile half stays out on purpose. Peer count, transaction queue, bandwidth and state cache change every few seconds, are already a rolling series in the feed, and would be a lot of rows for something nobody would page back through. `startup_time` is kept rather than derived, so node uptime becomes a fact about the node rather than about how long this observer has been watching it. **Location is resolved to a country and no finer.** The feed sends `[latitude, longitude, city]` and no country, so `blackbeard_core::geo` derives one offline from a 508 KiB boundary set — no network call, and no third party told where these nodes are. The city is read and discarded: those coordinates come from an IP geolocation database and at city resolution are wrong often enough that publishing one would assert something we do not know. Nothing downstream can render a city because nothing downstream is given one. The lookup returns subdivisions before countries, and `GB-ENG` is exactly the extra precision this is avoiding, so only the two-letter code survives. `LocatedNode` is a separate frame because the telemetry server geolocates asynchronously — `AddedNode` almost always carries a null location and the country follows a moment later. Which means a reconnect, which re-sends every node with a null location again, must not erase a country already resolved. There is a test for that, because it would present as countries silently vanishing hours after a restart. The mapping is pinned by tests built from frames captured off the live feed rather than from a spec: the format is positional and differs between telemetry releases, so bytes the feed actually sent are the only honest fixture. If a future release renumbers a field, those tests are what says so. Refs #15 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
380a0e138b
|
fix: don't restore tip samples the chain's own clock contradicts
I closed #14 on an incomplete fix. Heisenberg still reports **0.054 s/block** while producing one block every five minutes — 5,500x too fast — with the running code already correct. Fixing the code did not fix the rows already written. `at_tip` in the table is whatever the process that wrote it believed, and every process before the receipt-stamping fix believed a drained channel was a tip observation. Those rows are still there, and `restore_ticker_and_timing` replays them on every start, so the bug comes back from storage each time a chain is restarted after a catch-up. Heisenberg produces a block every five minutes, which is far too slow to accumulate twenty fresh samples before the next restart, so it never got the chance to correct itself. Restored samples are now checked against the one clock the observer cannot fake. At the tip a block is seen roughly when it is authored, so the span the observer recorded should resemble the span the chain recorded for the same blocks. Propagation and skew are seconds; a catch-up is a factor. A batch that fails is left unrestored and timing starts fresh — nominal, and labelled nominal. The threshold is deliberately loose (half) because it only has to separate two populations that differ by orders of magnitude, and being generous to honest skew costs nothing: the penalty for a false negative is one window of nominal hashrate, and for a false positive it is a headline wrong by 5,000x. Reopens #14 — closing it again when all three chains have held a plausible interval across a restart, rather than when the code looks right. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
4f9902819d
|
fix: a miner's history chart spans its window, not a hard-coded hour
`bucketing` mapped each window to a fixed duration — `600 blocks -> 1 hour`, `3,600 -> 6 hours` — which is only true at a 6 s target. It was the last place still assuming a window's block count implies a duration, which is the assumption the rename retired. On Planck the gap is visible: its rate fell ninefold when its miners left for mainnet, so a 600-block window spans about five hours, and the chart underneath was drawing one. A miner's rank was computed over one period and its history plotted over another, with nothing on the page saying so. The range is the window's measured span now, with buckets sized to hit `MINER_SERIES_POINTS`, so a chart stays the same width in points however fast the chain is running. Where no span is measured yet — only before the window has filled — it falls back to the block count at the chain's target rate, which is the same guess the old table encoded. Bounded at both ends, because a measurement can be extreme in both directions: a floor so a burst cannot collapse the chart to minutes, a ceiling so `100800-blocks` on a slow chain cannot ask the database for a year of buckets. Closes #14 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
813e49c43c
|
fix: time a head by when it arrived, not by when we got round to it
The gap-fill guard fixed the coarse half of #14 — Planck went from 0.175 s/block to 4.9 — and left the rest standing. Measured just now against a real ~30 s on Planck and ~6 s on Heisenberg: quantus 9.59 s plausible planck 4.919 s six times too fast heisenberg 0.052 s a hundred times too fast `record` stamped `observed_at` with `Utc::now()`, which runs *after* two RPC round trips and behind a 64-deep channel. On a remote endpoint those round trips are hundreds of milliseconds, so heads queue and then drain in a burst — a batch carrying near-identical observation times while their heights march on. `measured_interval` divides elapsed time by height difference and reads that as a chain producing blocks twenty times faster than it does. `observed_at` means "when this observer saw it", so it is now stamped where the frame is read off the socket: `subscribe_new_heads` sends a `SeenHead` carrying that moment and `record` uses it rather than asking the clock again. Every consumer improves at once, because the same field is the DB row, the ticker's gap, and the tip sample. Worth noting what this does *not* need: no threshold, no plausibility check, no discarding of samples. The measurement was always sound; it was being given the wrong time. Refs #14 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
d12299f242
|
ci: gate the API deploy on what the API is running, not on the last push
The site is currently serving a frontend that asks for `window=3600` from an API that only knows `six_hours`, and every step behaved as designed: run 51 |
||
|
|
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 |
||
|
|
3c6f9c94c8
|
chore: log what the leaderboard cache serves, for #13
The recompute and the response disagree in production and only the recompute was visible. With `held` now logged, mainnet's window holds 20,134 and its SixHours recompute tallies 3,600 — correct — while the REST response for the same chain, window and process reports 1,177, and a request for `week` triggers no recompute at all. So the reader is served a cached board that the five-second recompute is not replacing, and both halves need to say what they did. `chain` on the recompute line too. Its absence made three chains' recomputes indistinguishable, which cost an hour of reading the wrong one. Also a test driving the real path — push a restored window, then `recompute_leaderboard` — which passes, and so places the fault after the tally rather than in it. Refs #13 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
dedee707f5
|
chore: say what the window actually holds, for #13
Static reading cannot settle #13. Warm start logs restoring 19,910 blocks for mainnet and the board it serves divides every share by 1,065, and every explanation that would reconcile the two is ruled out in the code: the deque is only ever pushed and popped at a capacity of 100,800, `tail(n)` yields `min(n, len)`, `tally` counts every entry it walks, nothing filters rows, the board recomputes every five seconds, and there is exactly one non-test `ChainRuntime` and one `RollingWindow` per chain, shared by `Arc`. So: log the length. `warm start: window restored` now carries `held` beside `blocks` — the two must agree, and when they did not, every share on the site was computed over the difference with nothing saying so. `recompute_leaderboard` logs what it asked for, what was held and what it tallied at debug. The new test pins the half of the contradiction that lives in `blackbeard-core`: a window pushed 19,910 blocks holds 19,910, tallies 3,600 for a 3,600 window, and tallies 19,910 when asked for more than it holds. That is the arithmetic the served response contradicts, so an investigation upstream of it does not have to re-establish that a deque holds what was pushed. Refs #13 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
9def0c8b6a
|
feat: route reads that need old state to an endpoint that has it
Failover moved on a transport failure and never on a JSON-RPC error, both
right — but a node that has pruned the state being asked for does neither. It
answers `{"result": null}`, which is a success, so whichever endpoint happened
to be sticky decided how far back the whole site could see.
The tempting fix is wrong. Null is also the correct answer for most keys read
here: an account with no balance, an item never set, the treasury's
`System::Account` entry, absent because the treasury was never funded. Retrying
every one of those against every endpoint would multiply the load to re-derive
an answer already in hand, and still end in null. The distinction is not in the
response — it is whether the node could have known, which is a property of the
endpoint.
So `classify_depth` probes each one at startup, reading `System::Number` at
block one. That key is derived in a test rather than pasted, because a mistyped
key is absent everywhere and would classify every endpoint as pruned. An
endpoint that answers holds all state after block one; one that cannot be
reached stays `Unknown` and routes as pruned, since an unreachable host must not
become the archive of record by default.
Reads naming a block hash then go through `call_deep`. It tries the sticky
endpoint first, so the common case — mainnet's loopback node, which is an
archive — costs no extra round trip. Only a null from an endpoint never shown to
hold old state buys a second opinion, and `state_call`'s refusal is treated the
same way, because that is how difficulty is read and a node that cannot execute
against dropped state errors rather than returning null.
All seven configured endpoints probe as archives right now, so this changes no
current behaviour. It is what stops history going quietly blank the day one of
them stops being one — and it says so in the log when it happens, because an
endpoint that cannot see as far back as the site is asking is a configuration
fact worth surfacing rather than a chain fact.
Closes #9
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
|
||
|
|
4fb856827c
|
fix: a bogus length prefix aborted the daemon, and the gap fill never resumed
Two faults, one visible symptom: both testnet leaderboards frozen for a day at the height their gap opened at, while mainnet was fine. The daemon had aborted 316 times in ten hours, always identically — `memory allocation of 82014765760 bytes failed`, 76.4 GiB. That is 1,025,184,572 x 80, and 80 is `size_of::<scale_value::Value<u32>>()`: scale-value sizes a sequence's `Vec` from the compact length in the blob before decoding a single item, so a blob that disagrees with the registry asks for an allocation of any size at all and Rust aborts on the failed one. The step that would have caught the mismatch is the one that never runs, which is why `decode_events(&blob).ok()` could not help — there was no `Err`, only SIGABRT. Every decode now goes through `Runtime::decode_checked`, which walks the bytes first with scale-decode's `IgnoreVisitor`. That crate has no `with_capacity` anywhere, so an impossible length runs out of input on the first item and comes back as an error. The regression test is the exact four-byte blob, reproduced against the mainnet fixture before the guard went in — the abort message matched production byte for byte. `fill_gap` then turned a short process lifetime into no progress whatsoever: it accumulated the whole gap and wrote once after the loop, so every interruption discarded everything read. Each block is four RPC round trips, one a historical state read; 1,696 blocks against a remote endpoint does not fit in the two minutes the aborts allowed. And this is not only about the aborts — `blackbeard-api-cert.path` restarts the service several times a day, so any gap wider than that interval was already unfillable, logging `filling a gap in the head stream` on every start with the same `from` and looking like progress. It flushes every 256 blocks now, and says how many it recorded. Both traps are in CLAUDE.md, because neither looks broken from the outside: one presents as a service that is merely restarting, the other as a catch-up that is merely slow. Closes #10 Closes #11 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
1a59b2d398
|
feat: several endpoints per chain, so one host cannot stop it
A chain entry held exactly one `rpc_url` and one `ws_url`, and when that host
went down everything for that chain stopped — headers, difficulty, backfill,
state reads, runtime discovery. The endpoints already existed in pairs:
`a2-planck` and `a2-heisenberg` both answer and neither was configured. We were
choosing single points of failure the network went to some trouble to avoid.
`rpc_urls` and `ws_urls` are lists now, and `rpc_url`/`ws_url` still work as a
one-element list — a config naming one endpoint is still a valid config, and
making every deployment rewrite its entry would be this feature breaking the
thing it exists to make reliable.
Endpoints are stuck to rather than balanced across, which is the design and not
laziness: a storage read at an old block hash needs a node that still holds that
block's state, and nodes prune on their own schedules, so alternating would
return a mixture of answers and absences that reads as sparse data rather than a
configuration problem. The cursor is shared across clones so a failover one task
finds is not rediscovered by every other task on the chain.
Failing over on the wrong thing was the trap worth avoiding. A JSON-RPC error is
the node answering — moving on `count exceeds maximum value` would hide a
caller's mistake behind a second node making the same complaint — so a new
`Malformed` variant separates "did not answer" from "answered, with an error".
A pruned block returns `{"result": null}`, a success, and never looks unhealthy.
The WebSocket rotates at reconnect, where the loop already was; racing
subscriptions across endpoints and deduplicating heads buys nothing, since heads
are a liveness signal and ingest fills gaps against `chain_getBlockHash` anyway.
Verified live with a dead endpoint configured first: Planck stayed `full` at its
real height, the RPC logged one `failed over` with from and to, and the head
subscription logged the loss with the endpoint count beside it — because "the
chain is unreachable" and "one of three endpoints is unreachable" are different
operational facts and used to look identical.
Closes #7
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
|
||
|
|
7d081c7746
|
fix: map enumeration asked for more keys than the node allows
`/heisenberg/reversible` reported 0 pending. The chain has 2. `state_getKeysPaged` caps `count` at 1,000 and *rejects* rather than truncates: `PENDING_LIMIT` was 1,600, so every call errored, and `.unwrap_or_default()` turned the error into an empty page. Nothing looked broken — it looked like a chain with nothing in flight, which is precisely what mainnet legitimately looks like. It would have stayed wrong indefinitely. Caught only because `ReversibleTransfers::NextTransactionId` is a plain counter reading 5 on Heisenberg, which contradicted the page. Every path tested during the original work happened to be under the cap — `GENESIS_LIMIT` is 500, and the `System::Account` check used 3 — so the machinery looked verified. Three fixes. The clamp lives in `rpc.rs`, because 1,000 is the node's ceiling rather than a caller's preference. The route no longer swallows the error: a chain whose runtime lacks the pallet already returns 404, so a failure here is a real fault and now says so. And the constant is under the cap so the common case is one request. Verified against Heisenberg's two real pending transfers, which also exercise the unknown-deadline path — both were scheduled before the event index reaches, so they render as pending with no countdown rather than being dropped. CLAUDE.md gains the general shape: `unwrap_or_default()` on anything that talks to a node is how a wrong answer comes to look like a right one. Closes #4 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 |