Commit Graph
117 Commits
Author SHA1 Message Date
grenadeandClaude Opus 5.5 fa5f844264 feat(ops): a Grafana dashboard for the three NEAR nodes
ci / gate (push) Successful in 4m8s
`asset/grafana/neard.py` generates `neard.json` — sync (heads, blocks behind,
state-sync parts downloaded and applied per shard, flat-storage deltas,
chunk apply time) and resources (neard memory and CPU, on-disk size, the
busy-ness of the disks neard is on, host IO pressure, network) — from the
`neard` scrape job, joined to node_exporter by the `host` label both carry.
File-provisioned like the fleet's other dashboards, under the `blackbeard`
folder; installed on golgafrinchans by hand (2026-10-06).

Refs #39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
2026-10-06 16:56:01 +03:00
grenadeandClaude Opus 5.5 e78e627152 feat(api): read direct calls to the asset's pools on the shards they are signed on
ci / gate (push) Successful in 4m31s
deploy / validate (push) Skipped
deploy / build (push) Successful in 9m14s
deploy / deploy-api (push) Successful in 31s
deploy / deploy-web (push) Successful in 14s
The relay's pool swaps are found on intents.near's shard; a whitelisted
account calling the pool itself signs on its own shard, which nothing read —
#37's coverage figure measured the gap at 99.75% of buys. `poolscan` follows
the pools' and the token's shards on gargravarr (`pool_rpc_urls`), which
tracks shards 4 and 8, and queues every transaction that calls a pool or
`ft_transfer_call`s tokens into one. `refdcl::fetch` reads each whole, as it
does the relay's, so a swap is parsed one way and recorded once whichever
road it came by.

Reads only what the node holds; history before it synced stays the coverage
figure's to account for. Records the three-node shard split and the
Prometheus neard scrape config (installed on golgafrinchans) in the repo.

Refs #39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
2026-10-06 16:38:21 +03:00
grenadeandClaude Opus 5.5 c483d51dc7 fix(web): mark a section's nav link on its subjects' pages, not only its index
ci / gate (push) Successful in 4m26s
deploy / validate (push) Skipped
deploy / build (push) Successful in 1m26s
deploy / deploy-api (push) Skipped
deploy / deploy-web (push) Successful in 6s
`/quantus/market/near-intents` marked nothing in the section nav, nor did a
block, account or runtime page: the nav tested the index plus a hand-kept list
of exceptions that every newer section had missed. `sectionOf` in
lib/routes.ts now answers it once, over every subject field. Block zero stays
with Genesis alone.

Closes #40

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
2026-10-06 14:10:10 +03:00
grenadeandClaude Opus 5.5 7cd2197997 feat: chart the Ref DCL pool's swap price on its own market page
ci / gate (push) Successful in 4m22s
deploy / validate (push) Skipped
deploy / build (push) Successful in 8m45s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 22s
`/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
2026-10-06 11:26:08 +03:00
grenadeandClaude Opus 5.5 f6b1e8ad14 test(web): assert dates and hours, not one ICU build's word order
ci / gate (push) Successful in 4m30s
deploy / validate (push) Skipped
deploy / build (push) Successful in 9m11s
deploy / deploy-web (push) Successful in 34s
deploy / deploy-api (push) Successful in 18s
The runner's Node carries English-only ICU and formats en-GB as en-US
("Oct 5", "EDT"); browsers carry full ICU. What #38 guards is which day
and which hour, so that is what the test checks.

Refs #38

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
2026-10-06 11:05:36 +03:00
grenadeandClaude Opus 5.5 21ec54bd24 fix(web): label local times with their zone and keep UTC days on their own date
ci / gate (push) Failing after 4m31s
Instants (blocks, swaps, node starts) render in the viewer's zone and say
which: tables carry the zone, charts name it once, titles give UTC. Daily
buckets the API cut at UTC midnight render in UTC and are headed "(UTC)" —
FlowChart formatted them as local dates, putting every bar a day early
anywhere west of Greenwich.

All date formatting goes through lib/format.ts (when, utc, clock, utcDay,
utcMonth, zone), one locale and a 24-hour clock. `pnpm test` runs Node's
built-in runner under America/New_York, where the old formatter turns
5 Oct into 4 Oct, and is part of the shared CI gate.

Closes #38

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
2026-10-06 10:58:52 +03:00
grenadeandClaude Opus 5.5 b85e74f1ca feat: index QTC's Ref DCL pool — its swaps, its ladder, and how much we see
ci / gate (push) Successful in 5m7s
deploy / validate (push) Skipped
deploy / build (push) Successful in 9m3s
deploy / deploy-api (push) Successful in 42s
deploy / deploy-web (push) Successful in 12s
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
2026-10-06 09:50:34 +03:00
grenadeandClaude Opus 5.5 d6654ac4b0 feat(api): flag QTC on NEAR that exceeds what exists outside the vesting pot
ci / gate (push) Successful in 4m26s
deploy / validate (push) Skipped
deploy / build (push) Successful in 8m35s
deploy / deploy-web (push) Successful in 7s
deploy / deploy-api (push) Successful in 20s
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
2026-10-04 17:12:02 +03:00
grenadeandClaude Opus 5.5 a13a6ef1a5 fix(api): pace the intents backfill only for someone else's endpoint
ci / gate (push) Successful in 4m22s
deploy / validate (push) Skipped
deploy / build (push) Successful in 8m22s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 18s
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
2026-10-03 23:34:56 +03:00
grenadeandClaude Opus 5.5 a07a3bc580 fix(api): sort solver inventory with sort_by_key
ci / gate (push) Successful in 4m22s
deploy / validate (push) Skipped
deploy / build (push) Successful in 8m23s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 18s
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
2026-10-03 23:12:46 +03:00
grenadeandClaude Opus 5.5 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
2026-10-03 23:12:32 +03:00
grenadeandClaude Opus 5.5 fb7148cc98 feat(ui): a price chart on the market page, and its placeholder
ci / gate (push) Successful in 4m20s
deploy / validate (push) Skipped
deploy / build (push) Successful in 8m32s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 18s
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
2026-10-03 22:38:23 +03:00
grenadeandClaude Opus 5.5 5d22137a43 feat(ui): a market route — /:chain/market and /:chain/market/near-intents
ci / gate (push) Successful in 4m2s
deploy / validate (push) Skipped
deploy / build (push) Successful in 8m23s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 17s
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
2026-10-03 22:04:59 +03:00
grenadeandClaude Opus 5.5 ef2afa8e4e fix(data): a skipped NEAR height no longer stalls the intents scan
ci / gate (push) Successful in 4m14s
deploy / validate (push) Skipped
deploy / build (push) Successful in 8m5s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 16s
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
2026-10-03 21:48:48 +03:00
grenadeandClaude Opus 5.5 40bc2f67d1 fix(core): one swap per taker leg, and solver hops are never trades
ci / gate (push) Successful in 4m36s
deploy / validate (push) Skipped
deploy / build (push) Successful in 8m11s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 18s
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
2026-10-03 21:24:53 +03:00
grenadeandClaude Opus 5.5 ec615cac52 ci: gate every branch, and make a "validate" dispatch ship nothing
ci / gate (push) Successful in 4m24s
deploy / validate (push) Skipped
deploy / build (push) Successful in 8m27s
deploy / deploy-web (push) Successful in 7s
deploy / deploy-api (push) Successful in 17s
deploy.yaml was the only workflow and ran only on main, so a pushed branch
started no run at all: the first time CI saw a change was the push that
deployed it. And its `mode: validate` input, offered as "check only", was read
by no job — a validate dispatch built and deployed whatever ref it ran on.

ci.yaml now runs on every branch but main, on the `rust` runner, with no deploy
job, no secrets and no artifact. The gate itself moves into a local composite
action, .gitea/actions/gate, which both ci.yaml and deploy.yaml's `build` job
use, so the gate a branch passes is the gate main runs rather than a copy that
can drift. Its commands are CLAUDE.md's "Run the gate CI runs" verbatim. One
deliberate change on the way: the drift check is `git status --porcelain`, as
CLAUDE.md and #28 state it, not `git diff --quiet`, which cannot see the new
untracked file a new DTO generates.

Both deploy jobs now require `github.ref == 'refs/heads/main'` and
`inputs.mode != 'validate'` (empty on a push, so the push path and its
"what changed" gating are unchanged). A validate dispatch on main runs the
build and gate plus a new `validate` job: the read-only checks from the end of
deploy-api and deploy-web — API health from bob, reachability from the edge,
the chain list through the vhost — against what is already deployed. Those
checks are copied rather than shared, because the deploy jobs run from the
build artifact without a checkout and restructuring them is a change only a
production deploy would test; the comment says they must change together.

Closes #28

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
2026-10-03 19:12:17 +03:00
grenadeandClaude Opus 5.5 b05b208dfa feat(config): follow QTC on NEAR Intents through our own node
deploy / build (push) Successful in 9m35s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 22s
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
2026-10-03 18:35:32 +03:00
grenadeandClaude Opus 5.5 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
2026-10-03 18:35:32 +03:00
grenadeandClaude Opus 5.5 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
2026-10-03 18:35:32 +03:00
grenadeandClaude Opus 5.5 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
2026-10-03 18:35:32 +03:00
grenadeandClaude Opus 5.5 d40e17bb1f docs(config): point the intents example at near.internal
Refs #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
2026-10-03 18:35:32 +03:00
grenadeandClaude Opus 5.5 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
2026-10-03 18:35:32 +03:00
grenadeandClaude Opus 5.5 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
2026-10-03 18:35:32 +03:00
grenadeandClaude Opus 5.5 dc68fe3be3 infra: publish the NEAR node as near.blackbeard.observer
Through the existing nodes role: a Cloudflare CNAME to the site indirection,
a Let's Encrypt cert by DNS-01 on oolon, and a vhost beside the Quantus and
Planck endpoints. The upstream is https://near.internal rather than frankie,
so next week's move to new hardware changes one internal vhost and nothing
here, and frankie's firewall still admits only the hanzalova proxy and bob.

Throttled harder than the Quantus endpoint, because the node is a four-core
stopgap and the swap indexer reads it too (via near.internal, outside these
limits): 5 r/s per client with a burst of 20, 8 connections per client, and a
30 r/s ceiling across all clients so many addresses together cannot starve
it. Only POST / and GET /status, /health pass; bodies over 64 KiB are refused.
Hetzner and OVH hosting networks get the site's 403, and the name has its own
JSON log.

Checked from outside after install: 405 for other methods and paths, 413 for
a 100 KB body, 32 of 40 parallel requests answered 429, and the log carries
the real client address. /health answers 503 because the node is not synced;
that is neard's own answer, passed through.

Closes #29

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
2026-10-03 12:34:35 +03:00
grenadeandClaude Opus 5 0de203c819 fix(ui): link the repositories at their new paths
deploy / build (push) Successful in 1m30s
deploy / deploy-api (push) Skipped
deploy / deploy-web (push) Successful in 15s
Both moved from quantus/ to blackbeard/: the observer to blackbeard/observer,
the miner to blackbeard/miner. Gitea 301s the old paths, but a redirect
survives only until something else takes the name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4Ldjix2ofMdQ7dzqgnYA8
2026-09-22 15:30:02 +03:00
grenadeandClaude Opus 5 6c79718ff1 docs: issues live at blackbeard/observer
deploy / build (push) Successful in 1m34s
deploy / deploy-api (push) Skipped
deploy / deploy-web (push) Successful in 5s
The repository moved from quantus/blackbeard.observer. Gitea redirects the
web URL, but the MCP issue API does not follow it, so filing #24 against the
old path failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4Ldjix2ofMdQ7dzqgnYA8
2026-09-22 15:29:03 +03:00
grenadeandClaude Opus 5 6d8c3fe3ea infra: a dedicated access log for the public vhost, and deny hosting networks
deploy / build (push) Successful in 8m30s
deploy / deploy-web (push) Successful in 7s
deploy / deploy-api (push) Successful in 18s
The proxy's shared access.log mixes every site on oolon and records neither
Host nor Origin. Origin is the only field that separates our UI's WebSocket
from another site's, because browsers send no Referer on the upgrade. The
public vhost now writes its own JSON-lines log carrying both.

Ten days of the shared log showed no third-party site using the API, and two
headless pollers: an OVH host polling summary once a minute (88% of all
no-referer REST) and a Hetzner host identifying as `stardust-devops`. The
public vhost now refuses the hosting networks of both providers, from the
prefixes RIPEstat reports as announced by AS24940, AS213230, AS212317 and
AS16276, generated by script/deny-networks.py and committed so the list is
reviewed. AS35540 is OVH's residential broadband and is left out. Against the
same ten days, the list matches the two pollers and no visitor.

Refusals are a plain-text 403 and are still logged with "denied":1, so a VPN
user caught by it shows up rather than disappearing.

Closes #24

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4Ldjix2ofMdQ7dzqgnYA8
2026-09-22 15:03:12 +03:00
grenadeandClaude Opus 5 501a9e6ad4 fix(data): re-read event history under the current keep policy
deploy / build (push) Successful in 8m35s
deploy / deploy-web (push) Successful in 7s
deploy / deploy-api (push) Successful in 16s
Until 1e8869e the backfill kept only MiningRewards::* and Balances::Transfer.
The skip-list that replaced that allowlist only applied to blocks read after
the deploy, and event_scan never revisits a range it has claimed. So on
mainnet every block below 18510 has no Wormhole::* (or any other newly kept
kind) while indexed_from says 1. Planck and Heisenberg have the same hole over
whatever they had read by then.

This was found checking #22's endpoint against a mining address whose
deposits began at transfer_count 17,142. A mainnet node decodes
Wormhole::NativeTransferred in blocks 18400 and 18509, under the same spec 152
as the indexed blocks.

Migration 0008 collapses each chain's cursor to its top (low = high), so the
backfill walks back to genesis again. record_events and record_extrinsics
upsert on their primary keys, so re-reading is safe. Until the walk finishes,
indexed_from reports the real, shrinking gap. CLAUDE.md now says that keeping
more events needs a migration like this one.

Closes #23

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-16 17:23:34 +03:00
grenadeandClaude Opus 5 82a4ac9c59 feat(api): wormhole deposits by recipient
deploy / build (push) Successful in 8m40s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 18s
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
2026-09-16 17:07:48 +03:00
grenadeandClaude Fable 5.1 54db950957 infra: public node endpoints quantus. and planck.blackbeard.observer
deploy / build (push) Successful in 10m25s
deploy / deploy-web (push) Failing after 3s
deploy / deploy-api (push) Failing after 5s
One name per chain for the blackbeard wallet (blackbeard/wallet #53), on
the same edge proxy as the site: a CNAME to the site indirection, a Let's
Encrypt certificate, and a vhost that proxies JSON-RPC over HTTP and
WebSocket to the chain's upstream. quantus_node is our node on bob across
the mesh (bob's firewalld now admits oolon by name, lair/quantus deploy);
planck_node is Quantus's two official testnet hosts over TLS with SNI and
verification, since we run no testnet node. Upstreams and the per-client
throttle zones (20 requests a second with a burst of 40, 16 concurrent
connections, 429 on excess) live in conf.d/blackbeard-nodes.conf so each
vhost stands alone.

script/infra-setup.sh gains a nodes role that does the three steps per
name, enables the vhosts only after nginx -t passes and disables them
again if it does not, and ends by asking each name for system_chain.
Run today: both names carry certificates to 2026-12-15 and Planck
answers; the quantus record was still propagating when the script asked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 09:11:11 +03:00
grenadeandClaude Opus 5 efaf48711c feat(ui): a caution mark where the tagline's bullet was
deploy / build (push) Successful in 45s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Successful in 5s
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>
2026-09-14 17:21:44 +03:00
grenadeandClaude Opus 5 b04a45f54a feat(ui): a face on the about page
deploy / build (push) Successful in 44s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Successful in 4s
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>
2026-09-14 17:02:10 +03:00
grenadeandClaude Opus 5 6ed9a6c35a feat: an about route, and somewhere to say thanks
deploy / build (push) Successful in 8m29s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s
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>
2026-09-14 16:48:21 +03:00
grenadeandClaude Opus 5 2bbe8e4d5d feat: a vesting route — when 99.5% of the supply actually arrives
deploy / build (push) Successful in 9m23s
deploy / deploy-web (push) Successful in 7s
deploy / deploy-api (push) Successful in 16s
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>
2026-09-14 16:31:53 +03:00
grenadeandClaude Opus 5 da7f773564 feat: how concentrated the balance is, once the treasury is set aside
deploy / build (push) Successful in 7m40s
deploy / deploy-api (push) Successful in 16s
deploy / deploy-web (push) Successful in 6s
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>
2026-09-14 15:32:11 +03:00
grenadeandClaude Opus 5 67e7b2ec24 fix: the waiting counter was never once visible
deploy / build (push) Successful in 44s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Successful in 5s
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>
2026-09-14 14:45:39 +03:00
grenadeandClaude Opus 5 a245caef99 feat(ui): count the wait instead of describing it
deploy / build (push) Successful in 43s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Successful in 5s
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>
2026-09-14 14:35:50 +03:00
grenadeandClaude Opus 5 ee575ed33f feat(ui): two columns for the stage, and a console that links out
deploy / build (push) Successful in 1m24s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Successful in 4s
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>
2026-09-14 14:25:59 +03:00
grenadeandClaude Opus 5 578a54ef87 fix: the backfill was re-announcing blocks the console had already shown
deploy / build (push) Successful in 8m18s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 16s
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>
2026-09-14 14:12:41 +03:00
grenadeandClaude Opus 5 ad22866d3c feat(ui): say what each mark was
deploy / build (push) Successful in 7m36s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 16s
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>
2026-09-14 14:01:16 +03:00
grenadeandClaude Opus 5 4f59af9847 feat(ui): the wormhole holds what it has swallowed
deploy / build (push) Successful in 39s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Successful in 4s
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>
2026-09-14 13:44:56 +03:00
grenadeandClaude Opus 5 c08face7ad feat(ui): a live wormhole, drawn from the blocks as they land
deploy / build (push) Successful in 8m19s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 16s
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>
2026-09-14 12:58:16 +03:00
grenadeandClaude Opus 5 864a76ffb6 feat(ui): show what goes into the wormhole and what comes out
deploy / build (push) Successful in 7m33s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 15s
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>
2026-09-14 12:06:57 +03:00
grenadeandClaude Opus 5 f81ebd4746 feat: who the wormhole exits pay, and how much of it is the protocol itself
deploy / build (push) Successful in 7m37s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 16s
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>
2026-09-14 11:49:48 +03:00
grenadeandClaude Opus 5 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>
2026-09-14 11:43:39 +03:00
grenadeandClaude Opus 5 e3c95d4945 feat: a wormhole page — what enters, what leaves, what is still inside
deploy / build (push) Successful in 7m33s
deploy / deploy-api (push) Successful in 17s
deploy / deploy-web (push) Successful in 5s
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>
2026-09-14 09:32:27 +03:00
grenadeandClaude Opus 5 365cd7a803 fix(ui): "sent nothing" was a claim this chain cannot support
deploy / build (push) Successful in 44s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Successful in 5s
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>
2026-09-14 09:23:10 +03:00
grenadeandClaude Opus 5 df2306660a feat(ui): show where the mining rewards went
deploy / build (push) Successful in 8m29s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 16s
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
2026-09-14 09:03:48 +03:00
grenadeandClaude Opus 5 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
2026-09-14 08:59:55 +03:00
grenadeandClaude Opus 5 aaf37baaca feat: a network page — supply, capability and daily activity
deploy / build (push) Successful in 7m27s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 17s
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
2026-09-14 08:43:13 +03:00