afa2845edc01d11ea7ea5353cc53cb06755f9c02
44
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8892f0a399
|
feat: on-chain context for each pause in quoting, and pool settings kept
Each of the newest pauses now says what happened on-chain around QTC while it ran, so a reader can tell "not quoting but still trading" from "offline": - pool swaps by known solvers, with the QTC they sold into and bought out of the pool; - direct pool swaps by anyone else; - 1Click fills, which a pause should have none of; - bridge deposits arriving on NEAR; - the largest solver QTC holding at the pause's start and end, from the probe's own readings; - changes to the pool's state or whitelist. That last needs data that was not kept: the pool's state and whitelist were read every minute and held only in memory. ref_pool_settings (migration 0023) records them when either changes, and only a row with a predecessor counts as a change. The first row is how the pool was first seen. Context is computed for the 12 newest pauses, a few aggregate queries each. It is counts and amounts only, and the page says it does not explain why quotes stopped. Refs #45 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tyzzRVjH9AdwGtYwsH5VC |
||
|
|
ab2b59d178
|
feat: pause log, asymmetry and daily summary for 1Click quote probes
The probe record from
|
||
|
|
464bb498ca
|
feat: search box in the chain header
ci / gate (push) Successful in 4m21s
A pasted hash, address or account now opens its page. On this chain one
string can be several things: 0x and 64 hex is a block hash, a
transaction hash, an account id or a miner's preimage; 64 hex without
the prefix is also a NEAR implicit account. So nothing is decided by
shape.
GET /v1/chains/{chain}/search?q= looks for every kind the string could
be, and offers a page only when the data confirms it exists:
- a block that is recorded or that the node has;
- an indexed transaction hash;
- an account with a balance or with activity;
- a miner with blocks;
- a telemetry node;
- a cached runtime version;
- a NEAR Intents account the contract knows.
A NEAR transaction hash is the one exception. It is offered as a
candidate and labelled as looked up when opened, because confirming it
would spend a public RPC call on every search.
The header box searches on Enter, never while typing. One hit opens it;
several are listed to choose from with the arrow keys, never chosen for
the reader; none says what the string was checked as, and a transaction
hash missed while the #48 backfill runs says so.
The masthead's right-hand group now takes the free width, right
aligned, so the search box no longer wraps the connection light and
theme toggle onto a second line.
Closes #49
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tyzzRVjH9AdwGtYwsH5VC
|
||
|
|
7c02840f91
|
feat: Quantus transaction page, addressed by hash, with history hashed
A transaction is addressed by its hash, which is what a wallet shows and
what the bridge writes into a NEAR mint. But chain_extrinsic.hash had
been written only since migration 0018: 4,045 of 285,708 mainnet
extrinsics had one. Links from the NEAR pages could therefore land only
on the block.
- Block page anchors: each extrinsic row has id x<index>. A link with
that fragment scrolls to the row and marks it, and the fragment
survives the height-to-hash redirect.
- Hash backfill (hashscan): the work list is the rows with no hash, and
it only shrinks, because live ingest hashes everything it writes. Each
block is read from our own node and hashed with the function the
bridge links use. Verified against the known deposit 0xc4444afc at
180,029/1, at about 6 ms a block. The client holds only our own
endpoints, so the job waits when bob's node is down rather than
walking history through a public fallback. Chains with no node of
ours (the testnets) are not backfilled and are hashed from now on
only.
- /quantus/tx/<hash> and GET /v1/chains/{chain}/tx/{hash}: the
extrinsic as the block page decodes it, its events, its fee, and for a
bridge deposit the NEAR mint that names it. A miss while the backfill
is running says how many extrinsics are still unhashed, not that the
transaction does not exist.
- Links from the NEAR transaction page, the Intents account page and the
bridge deposits table now go to the transaction, and the block page's
index cell links to it.
Closes #48
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tyzzRVjH9AdwGtYwsH5VC
|
||
|
|
6b3bf76b7e
|
feat: probe whether 1Click will quote QTC, against what could have filled it
On NEAR Intents a price exists only when a solver chooses to quote, and nothing on-chain records a refusal. Every minute the observer now asks 1Click for a keyless dry quote on QTC pairs only, both ways, against native USDC and Ethereum USDC: the smallest size each minute and the full grid each quarter hour, averaging about 4 requests a minute against a published limit of 1,200. At the same moment it reads two things from its own nodes: the Ref pool's quote for the same input, and every solver's balance of the output token inside intents.near. So each "No liquidity available" is stored with whether one solver alone held enough to settle the trade. A probe that got no answer is recorded as `failed`, never as a refusal. Only our own observations are kept: the 1ClickSwap API Terms (s4.1(e)) forbid keeping API data past 24 hours, so a quote becomes a markup against the pool on arrival and raw responses are pruned hourly. The first live round, 2026-10-07: "No liquidity available" both ways. Selling 1 QTC, the pool would have paid 101.53 USDC and three solvers each held enough. Buying with 100 USDC, solver-ref-2.near held 2,308 QTC. The near-intents market page shows a strip per direction and counter token. It is an emphasis chart: the accent marks only "no quote while a solver could fill", the other states are quiet ink told apart by height, and the neutral token is validated against the accent in both themes. Beside it are a summary table and a list of the latest refusals. The pause log and the price-move statistics in #45 come later, once there is history to support them. Refs #45 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tyzzRVjH9AdwGtYwsH5VC |
||
|
|
21ea353942
|
feat: NEAR transaction and Intents account pages
Adds /quantus/near/tx/<hash> and /quantus/near/intents/<account>, the last two parts of #44. A transaction is assembled from our own nodes where possible. The node tracking the signer's shard returns the transaction. Each receipt it lacks is fetched from the node tracking that receipt's receiver, using EXPERIMENTAL_receipt and a light client proof. A public node is used only when ours cannot serve all of it. The page says which source it used. Once a transaction is complete and every block it touched is final, it is stored in near_tx (migration 0020) as raw JSON and decoded on every read, so decoder fixes apply to stored transactions too. The decoder in blackbeard-core::near_tx orders the receipt tree, decodes function-call arguments, parses EVENT_JSON lines into amounts tied to their tokens, and reads the BRIDGED_FROM, WITHDRAW_TO and refund memos. It is tested against the bridge mint piPTXyoq and the failed swap with refund E4ysnVht. The account page shows balances in intents.near, read live from frankie, the account's intent signing keys, and the QTC activity this observer indexes. It says that activity is not the account's whole history. The bridge, fills and swap tables now link to these pages instead of nearblocks. Both pages still link out to nearblocks. Closes #44 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tyzzRVjH9AdwGtYwsH5VC |
||
|
|
084fd5ba2c
|
feat: link QTC bridge deposits and withdrawals across both chains
#44 part 3. A deposit is linked exactly: the NEAR mint is bridge-mng.near calling omft.near.ft_deposit, whose arguments carry the Intents account and BRIDGED_FROM's txHash — Blake2-256 of the depositor's Quantus transfer into their deposit address, length prefix included (checked: extrinsic 1 of block 180,029 hashes to 0xc4444afc…, the hash in mint piPTXyoq…). `chain_extrinsic.hash` is stored from now on and filled for the bridge's deposit transfers. `bridgescan` reads mints from gargravarr (bridge-mng.near is on shard 8), each confirmed executed before it is recorded. History before our nodes is found by bisecting the token's supply — 16 changes before gargravarr's oldest block, 204 archive calls — and a rise is accepted as mints only when the mints around it sum to it exactly. Withdrawals (ft_withdraw with WITHDRAW_TO) come from the intents scan, rewound to frankie's oldest block (migration 0018); custody's payout is matched by a strict, stated rule, as an inference, with the fee it implies — every pre-node withdrawal paid exactly 0.002 QTC less. `/bridge/deposits` and `/bridge/withdrawals`; a linked-transactions panel on the NEAR Intents page. Refs #44 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
69050a45c1
|
feat: index QTC's 1Click fills, which settle as transfers, not token_diff
Since launch the NEAR Intents page showed no price and no settlements while
QTC traded heavily: the scan counts `token_diff` trades, and 1Click settles
no QTC swap that way. A buy is a solver transferring QTC to a one-off
1Click account, passed on to a shared one; a sell is the mirror; the user's
payment is on their own chain and never on NEAR.
The scan now keeps every executed transfer of the asset (migration 0017,
cursor wound back to before the first QTC swap; pool swaps already recorded
are not re-queued), unclassified. `/v1/chains/{chain}/intents/fills`
classifies at read time against the solvers known then — the solver table
plus every Ref pool whitelist, since solver-ref-2.near never appears in a
token_diff — so a solver found later classifies its past fills without a
re-read. Solver to account is a buy, account to solver a sell; hops between
non-solvers (the pass-on, a flat 0.002 QTC per-swap fee to 5880ad…) are not
fills. Fills are pushed live; the NEAR Intents page lists them without a
price, and shows the Ref pool's restock price in its place.
Checked against the chain: the traced buy at #218,780,964 (2,991.6053 QTC,
solver-ref-2.near to d4524d…) and the sell at #218,758,672 classify as such,
and their pass-on hops do not.
Closes #43
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
|
||
|
|
6988368d6e
|
fix(api): fetch relay swaps first, and never idle while the queue has work
After launch the Ref fetch loop slept ten seconds between batches of twenty, oldest first: 1,574 relay swaps — real 1Click trades through the pool — waited behind thousands of an arbitrage bot's direct calls, which the re-read had queued and which mostly fail, draining at about six a minute. The page's pool swaps and price chart ran hours behind. The queue now serves the relay's entries first, then the least-retried, newest first, and loops straight on while batches (now fifty) come back full; FastNEAR's pacing is the only limit, about a hundred a minute. Refs #37, #39 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
6116954f13
|
feat: QTC into and out of NEAR Intents, block by block
#39 item 4, as replaced: the original check (intents.near's balance against indexed events) could not see the trades it was for, because a trade moves QTC inside intents.near and leaves its total alone. What the balance does move with is deposits and withdrawals, so that is what is recorded. `intentsflow` reads intents.near's `ft_balance_of` on qtc.omft.near at every NEAR block from gargravarr, which tracks the token's shard, forward to its final block and back to the oldest it holds, and records each change (migration 0015). `view_at` treats only UNKNOWN_BLOCK as a skipped height: a garbage-collected block is an error, never "nothing changed". `/v1/chains/{chain}/intents/flows` sums deposits and withdrawals over a span; the NEAR Intents market page shows it after the bridge. Verified on the live chain through a tunnel to gargravarr: the scan's balance at #218,805,000 (36,044.097 QTC) and at #218,811,064 (72,671.542) match direct reads, and across 1,044 rows every balance less its change is the previous balance. Refs #39 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
0851b244ba
|
fix(api): read the pools already known while discovery is rate-limited
After the last deploy every pool listing came back FastNEAR 429 for twenty minutes, and with nothing discovered nothing was read: the market page had no ladder at all. The pools swaps have been recorded in certainly hold the asset, so the loop now starts from those and reads them while discovery retries; discovery still finds any pool nobody has swapped in. Refs #37 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
5361dec713
|
feat: record the Ref pool's bid side over time
Each pool read now writes a snapshot — totals, swap volumes, and what
selling 10, 100, 1,000 and 5,000 of the asset would fetch from the
pool's own `quote` view — and the order book whenever it changed
(migration 0014). `/v1/chains/{chain}/ref/bids` turns that into the
series #41 asks for: posted orders, money held otherwise, net swapped
in, the quotes; the book now against N hours ago; and every level
change, classified by `dcl::level_changes` — a shrink the pool's payout
in the same minute accounts for is "taken by a swap" (consistent with
one, not proof), the rest "withdrawn", allocated nearest the price
first. The `ref-dcl` page shows it as four one-hue sparkline tiles and
two tables, never one chart in several colours.
Also records in CLAUDE.md why three deploys were cancelled: Gitea
cancels runs by commit when a branch at that commit is deleted.
Refs #41
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
|
||
|
|
8eb812a19a
|
feat: read the NEAR bridge from the Quantus side — custody against minted
`/v1/chains/{chain}/bridge` sets the bridge's custody account against
qtc.omft.near's supply on NEAR, and lists every movement through custody,
newest first and paged. The bridge's two accounts, custody and the fee payer
that funds each deposit, are named in `[chains.bridge]` with the evidence
beside them and validated as addresses at startup; neither is inferred.
`blackbeard_core::bridge::classify` tells a deposit by who and when: a
transfer into custody from an address the fee payer funded at most
`max_sweep_lag` blocks before (every mainnet deposit so far: one). Not by
amount — the funding varies (0.0010166025, 0.0010169025 before the 150k
sweep, 0.0010168025 before the 5,990) — and not by one-time addresses,
which #39 assumed: one address deposited three times. Anything else into
custody is an other inflow; locked is custody's balance less those, which
derives the 5 QTC seed instead of configuring it. Tested against every real
transfer on both accounts.
Each transfer is counted once. Every ordinary transfer emits both
`Balances::Transfer` and `Wormhole::NativeTransferred`; the query takes the
first, plus the second only for a hidden-sender exit.
Verified end to end against the live chain (custody balance read from bob)
and NEAR (the listing's supply): locked 155,994.701, minted 155,994.698,
locked − minted +0.003 QTC; 13 deposits from 11 addresses, 3 withdrawals.
The market page shows it beside the backing ceiling, reporting flows and
saying what they cannot establish: an address is not a person, and summed
flows are only the whole history if the index covers it.
Refs #39
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
|
||
|
|
9becf2629e
|
fix(data): count each visible transfer once, not once per pallet
ci / gate (push) Successful in 4m29s
Every visible transfer emits `Balances::Transfer` and `Wormhole::NativeTransferred` with the same payload in the same extrinsic (193,686 of 193,691 on mainnet), and `account_flows`, `flows_for` and `wormhole_routes` added the two: every account page's sent and received, the distribution table's `sent on`, and the wormhole page's visible routes were doubled. A `Balances::Transfer` now counts only when it has no such twin, which keeps the five that stand alone. Checked on production data: the bridge custody account's `received` goes from 372,003.992 over 30 receipts to 186,001.996 over 15. Closes #42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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> |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
1e8869e7a1
|
feat: index the whole event surface, and show what has never fired
The event interest list was an allowlist of two entries, which meant every capability the chain grew was invisible until somebody remembered to add a line — the exact failure the metadata-oracle approach exists to avoid. It is a denylist now. Nothing is excluded for being unused. Whether a capability is exercised is the question an analyst is asking: a chain that ships vesting and governance nobody touches is telling you something, and it can only tell you that if the silence is recorded rather than filtered on the way in. The rule for exclusion is narrower than volume and narrower than usefulness — an event carrying no account can never answer "everything involving this account", which is what the index is for. Three kinds both name no account and fire every block, so they are skipped: ZkTree::LeafInserted, QPoW::DifficultyAdjusted, System::ExtrinsicSuccess. They render as "not indexed" rather than as a zero, because a zero reads as disuse. Balances::Minted looked like a fourth. It fired exactly as often as MinerRewarded across a 120-block sample and for the same reason, but it names an account, minting is not only for miners, and showing a reward twice on one page is a presentation problem to solve on that page rather than a reason to lose the record. Kept. `/:chain/event` is the call index's other half — 19 of 107 kinds fired — and `/:chain/event/:pallet/:variant` the feed behind a row. Measured on live mainnet the widening took the index from ~1.2 to ~3.4 rows per block and bought 739 accounts' worth of wormhole transfers that were previously invisible. Signatures also lose their associated-type ceremony: the runtime writes `<<T as frame_system::Config>::Lookup as StaticLookup>::Source`, which is correct and forty characters of scaffolding around one word, and three of those in a row pushed the counts off the side of the table. Generics are untouched — `BalanceOf<T>` is information. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
6b45004907
|
feat: the call index — declared surface against actual use
Every explorer shows what happened on a chain. None shows the difference
between what a chain can do and what it has done, because none holds the
runtime's declared surface next to the activity. We already hold both, so this
is a join rather than new indexing.
quantus v152 6 of 58 dispatchables used
heisenberg v148 6 of 58 — and a different six
The gap is the interesting half. ReversibleTransfers, TechReferenda, Vesting
and Preimage are shipped, documented and untouched; "this chain has governance
nobody has used" is a different statement from "this chain has no governance",
and only one of them appears on a list of what happened. Unused calls keep their
signature and their docs, both read from the chain rather than a source tree,
and are dimmed rather than hidden.
Two chains on the same runtime family diverging is the other thing it surfaces:
mainnet has exercised the wormhole batch verifiers and nothing else, while
Heisenberg has a whole multisig lifecycle and no wormhole traffic.
`/:chain/call/:pallet/:call` is the feed behind a row — who dispatched it, with
what arguments, and whether it worked. Nothing is written per call type: the
arguments render through the same `Payload` as everything else, so a pallet
added next year gets a row on the index when the runtime declares it and a
working page the moment somebody uses it.
`BlockExtrinsic` gained `height` and `at`, which are redundant on a block's own
page and the entire point on a feed where every row is a different block.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
|
||
|
|
edce856263
|
feat: decode extrinsics, and show them
An event is the chain's account of what happened; an extrinsic is the request that caused it, and carries what no event does — who signed, what they called, the nonce and tip, and whether it worked. A history built from events alone omits every failed attempt and never names a submitter. The same oracle does both. The metadata's extrinsic type carries four parameters and the registry names all of them, including `qp_dilithium_crypto::types::DilithiumSignatureScheme` — the part that looked like it would need hand-written post-quantum knowledge and does not. The chain describes its own signature scheme, so `decode_extrinsic` reads a Quantus transaction without a line of code that knows Quantus exists. Two facts about this chain's extrinsics, both now in CLAUDE.md. The first byte is not the version: the top two bits are a type tag, and mainnet carries `0x84` (signed, v4) and `0x05` (bare, v5) in the same block while the metadata declares version 4 — check the byte against the metadata and you reject every timestamp inherent on the chain. And a signature is 5.3 KiB, two orders of magnitude larger than the call it authorises; it is decoded to find where the call begins and then discarded, keeping only the scheme's name and the byte count. Both halves of a block index in one pass, off calls already being made. Whether a dispatch succeeded comes from the `ExtrinsicSuccess`/`ExtrinsicFailed` events in that same decoded list — read, used, not stored. The block page grows a table below the facts: call, signer, arguments, and the signature's size. The account page merges extrinsics into the history it already had, ordered by block with the request above the results it caused. `source` is 1 for an extrinsic and 0 for an event so that every part of the sort key descends, which lets the paging cursor be a plain row comparison rather than a mixed-direction one Postgres cannot express. Nested calls are why this had to be generic. A `Utility::batch_all` carries whole calls in its arguments, so a transfer's recipient can be three levels down — and because the decoder collects account ids wherever they appear, that batched transfer shows on the recipient's page tagged *received* even though they signed nothing. Verified on mainnet 12,616: the batch renders both its transfers with destinations and values, above the two `Balances::Transfer` events they produced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
efbe901646
|
feat: section indexes, so the routes can be found
Every route added lately was reachable only by already knowing an address — a block hash, a preimage, an SS58 string, a spec_version. Fine for a link somebody sent you, useless for discovering the pages exist. A kind with nothing after it is now the index of that kind, and a nav under the chain chips names them. Not the same page three times. Blocks is the record behind the live ticker, paged by height. Accounts ranks by tokens earned over the whole indexed record, which is a different question from the standings' blocks-per-window and here a different answer: difficulty rose 346-fold inside mainnet's first day, so an early block cost a three-hundredth of a current one. Runtimes is the only list with no other home — a block does not name its runtime and neither do the standings. No `/:chain/miner`: the standings already are the index of miners, and a second page of the same rows under another address would be two answers to one question. That path redirects there, along with anything else that resolves to the front page without being spelled like it, so the address bar and the section nav agree about where the reader is. Two things caught by looking at the rendered pages: The block index's gaps came from `observed_at` and read *three milliseconds* between blocks on a chain targeting twelve seconds — exactly the trap CLAUDE.md records, since a gap fill writes a whole batch within one second. They come from `authored_at` now, the chain's own clock. The first page of that index came from the live ticker, which is ordered oldest-first because that is the order it is pushed in, so page one climbed and every page after it descended. The index sends a cursor for its first page too. Accounts shows the address beside any node name: one node legitimately reports for several payout addresses, and the first cut had two rows reading `pearl-prover` with nothing to tell them apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
b006f54cd5
|
feat: a runtime's own page
`/quantus/runtime/152` — every pallet, call, event, error, storage entry and constant the runtime declares, with the constants' values decoded against their own declared types. Nothing transcribed from a source tree: a source tree says what the chain should be running, metadata says what it ran. The set of runtimes is found rather than waited for. Decoding caches one the moment it needs it, so left alone the cache is "whatever the backfill has walked past" — days on a chain a million blocks deep. `spec_version` only increases, so the boundaries are a sorted sequence and bisection finds each in log₂(height) probes. On Heisenberg that turned up six runtimes and the block each took over at: v126 from 1, v128 from 132, v131 from 342,813, v136 from 669,129, v144 from 812,055, v148 from 977,079 — two more than the four upgrade boundaries this approach was originally verified against. Discovery is its own task, not a poll step. `state_getRuntimeVersion` at an old block makes the node instantiate the runtime WASM from that block's state: ~4 s against Heisenberg's endpoint versus ~0.25 s at the tip, and a hundred of those inside a four-second loop stalls difficulty and the summary broadcast for minutes on every start. Reading Heisenberg's two ends side by side is the case for the page. Between v126 and v148 the chain dropped Referenda, ConvictionVoting, Recovery, Assets and AssetsHolder, added Vesting and Origins, gained `WeightReclaim` and moved `ChargeTransactionPayment` after the two Quantus-specific extensions. Every one breaks a decoder written against the other version and none is visible from a block. `U256`/`U512` now render as one decimal rather than four or eight little-endian limbs, identified by registry path exactly as `AccountId32` already was. Difficulty as `[1189189, 0, 0, 0, 0, 0, 0, 0]` describes the bytes correctly and tells a reader nothing. `ChainSummary` gained `spec_version`/`spec_name` so the footer can say what the site is decoding against, and link to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
d5a286f220
|
feat: account pages, built on the event index
`/quantus/account/qzp2AxZw…` answers the question the official explorer answers badly: what an address has actually been paid, every reward, back as far as the index has read. Everything comes from this observer's own decoding — no subsquid, no external indexer, nothing that stops working when the node prunes the state behind it. There is no case per event type anywhere in this. `chain_event` gained an `accounts` column filled by the decoder itself, which knows which values are `AccountId32` by registry path — the same knowledge that keeps it from rendering a block hash as an address — so "everything involving this account" is one containment query that already covers pallets nobody has written yet. The frontend labels what it recognises and renders anything else as its own key and value, so a new event type shows up as itself rather than not at all. Events also carry the block's own timestamp now. `block.authored_at` holds the same thing but only for blocks this process was running for, and a payment history that renders "height 412" with no date because the observer was not alive in March is not a history. The preimage and the address are two ends of one identity and each page now links to the other. Address to preimage is a lookup among preimages seen, not a calculation: the derivation runs one way. Verified against mainnet: 13,384 reward events indexed from genesis to the tip, quanpool's page reconciling to 3,784.98 QTC over 12,346 blocks, and transfers rendering their counterparties as linked addresses without a line of code that knows what a transfer is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
eb26a7bb3c
|
feat: decode events against the runtime's own metadata
A header says who mined a block, not what they were paid: the amount is remaining supply over an emission divisor plus fees, quantized with dust carried forward, so only the `MinerRewarded` event knows it. Reading it means decoding SCALE against the registry of the runtime that produced the block, and this chain's shapes differ from vanilla Substrate because of the post-quantum signatures. So the chain describes itself. `state_getMetadata` at a block hash executes `Metadata_metadata` against the runtime WASM in that block's state; the v14 registry that comes back drives a decoder that knows nothing about mining, rewards or transfers. `INDEXED_EVENTS` is the only place a name appears — adding transfers or anything the chain grows next is one line and a query, not a migration, a struct and a decoder. Metadata is cached per spec_version, because a node that starts pruning would otherwise make history undecodable. Blocks are tried against the tip's runtime first, which costs no extra call; `decode_events` refuses a partial read, so a block from before an upgrade fails loudly rather than decoding into plausible nonsense, and that failure is what asks which runtime actually produced it. `backfill` walks outward from the tip — to the tip first, then toward genesis — because live indexing alone leaves holes an account page would show: a restart, an RPC blip, a gap fill that ran before the metadata was cached. Progress is a cursor rather than `max(height)`, since a block with no indexed event and a block never read are otherwise the same answer. Verified on mainnet: block 13160's preimage derives, through the same Poseidon2 the chain runs, to exactly the `miner` field of its own reward event — so the header author and the payee join with no lookup table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
9eb4bee649
|
fix: a miner's hashrate is the work it did, not its share of today's network
Reported against a Grafana panel showing roughly a sixth of what the site did. Grafana was closer to the truth. The estimate was `share of blocks in the window × current network hashrate`. That is only correct while the network is the same size across the whole window. Measured on mainnet's launch day it was nowhere near: difficulty rose 346-fold inside one 14,400-block window, from 96.4B to 33.4T. A miner that won its blocks early — when a block cost a three-hundredth of what it costs now — had that share multiplied by today's network. For this observer's own node the site reported 25.4 GH/s against an actual 2.5 GH/s. Difficulty is expected hashes per block by definition, so the difficulty of the blocks a miner won, summed and divided by the time they spanned, is its hashrate directly — with no reference to the network's size, and immune to that size changing underneath, because every block carries the difficulty in force when it was won. The observer has recorded per-block difficulty correctly since the parent-state fix, so the data was already there. `miner_series` had the same flaw and is fixed the same way: summed work per bucket over the bucket's own duration. The window's duration is taken from `authored_at`, not `observed_at`. A stretch this process caught up on carries observation times seconds apart for blocks minutes apart, and dividing real work by that would invent hashrate — the same trap the history series was built to avoid. The headline network figure is unchanged. It divides current difficulty by the measured interval because it answers a different question: how hard is a block to win right now, not what did this miner do over the last twelve hours. The two no longer sum to each other, which is correct and is what the footer now says. `standings_rank_by_blocks_then_recency` asserted 400.0 from the old formula; its third argument is now the window's duration, so it asserts the work-based figure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
17ad5b1f74
|
fix: a restart no longer empties the ticker and the hashrate
Reported from the live site: the network hashrate occasionally drops to a fraction of what it was, the live blocks table truncates to a single row and rebuilds, and the cycle repeats on an unclear schedule. The schedule is `blackbeard-api-cert.path`. It watches the host certificate and runs `systemctl restart blackbeard-api` when it rotates — four times in the last day, on top of every deploy. Nothing was crashing; `NRestarts` is 0. What a restart lost was two pieces of memory-only state. The block ticker is built from live blocks and has no other source, so it came back with one row and refilled over the next minute. The tip samples behind the measured interval were dropped outright, because the window replay pushes every row with `at_tip: false` — correct for blocks a previous process caught up on, wrong for the ones it genuinely watched arrive, which the `at_tip` column has recorded since `0002`. Without them `measured_interval` returns `None` and the hashrate falls back to the configured target: at mainnet's ~1.5s against a 12s target, an eight-fold drop, correctly labelled nominal and wrong. Warm start now restores both. It runs after the attributions and carried names because the ticker rows need their display names. Restored tip samples are capped at ten minutes old — the interval stays arithmetically valid across a gap, since blocks kept being produced and both terms grow together, but it stops being *current*, and a sample from an hour ago averaged with one from now describes the hour rather than the tip. The underlying restart is left alone: it is a certificate-rotation safeguard and not mine to remove. It is now simply not visible, which is what it should always have been — and worth noting the store already recycles pooled connections hourly for exactly that rotation, so the restart is belt-and-braces over a case already handled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
ffcfb79cab
|
feat(attribution): carry a miner's name across chains by reward preimage
Live telemetry names the miners who win often and leaves everyone else as a hex string. Measured on mainnet: of 22 miners, 2 were named and 14 had no name available anywhere — but 6 already had one on Planck, under the same reward preimage. Those 6 are exactly the small operators the naming is worst for, because a name needs a block whose first reporter the feed could separate and they win few blocks. The preimage is derived from a secret only its owner holds, so the same preimage on two chains is the same operator. That is an inference the site can stand behind, unlike guessing from timing — and it is the only lever that works without waiting on the feed at all. It is a separate `AttributionSource` and renders as one. `Carried` names carry no confidence and no votes, because they assert nothing about this chain: only that this is who the operator was last known to be. Any live attribution here outranks one, and the row shows an `elsewhere` chip rather than the telemetry `node` chip so the two can never be read as the same claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
53dac3717b
|
feat: block explorer routes, addressed by hash
`#/:chain/:height` opens a block and then rewrites the address to
`#/:chain/:hash`. The redirect is the point, not tidiness: `block` is keyed on
(chain, height) and a reorg overwrites, so a height names a *position* and a
link to one comes to mean a different block the moment the chain forks there. A
hash names one block for good — including after it has lost, which is the only
way an orphan is linkable at all.
`GET /v1/chains/{chain}/blocks/{ref}` takes either spelling; a decimal number
and 32 bytes of hex cannot be confused, so a caller holding a number is not made
to guess which one this API wanted. The same reasoning puts block refs in the
second path segment beside the window: no window name is all digits or 0x plus
64 hex characters.
The answer is assembled from two sources because neither is sufficient, and
they fail at different times:
- The node holds the block and forgets it. Non-canonical bodies go once finality
passes them (blocks-pruning defaults to archive-canonical, and Planck finalises
~100 blocks back), and difficulty is a state read behind a 256-block default.
- The observer holds what the node never had — when the block was seen, whether
that sighting was at the tip — and what it has since forgotten: the difficulty
read while the state behind it still existed.
So resolution degrades in a stated order rather than failing. Node first; a
header it cannot serve falls back to `block_displacement`, which is what makes
an orphan describable at all; a node that is away falls back to the recorded
row. `from_node` tells the reader which they are looking at, and every value
neither source has is spelled out in words — "the body is pruned", "this
observer never saw this block" — because a blank on a block page reads as zero.
Verified against the live chain: a tip block carries difficulty, one extrinsic,
a 1.9 s gap and 473 ms of propagation; height 1000000 keeps its body and loses
difficulty to state pruning; genesis has no author digest and does not panic; a
seeded displacement serves as an orphan with the winner linked, and the winner
lists it in `also_seen`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
|
||
|
|
0ce58094e4
|
feat(data): record what a reorg displaced
`block` is keyed on (chain, height) so a replacement overwrites rather than accumulates — a schema keyed on the hash would inflate the losing fork's author forever. The cost is that the losing block vanishes without a trace, and with it any record that the height was ever contested. `block_displacement` is that trace: one row per displacement, holding the whole row that was about to be overwritten. It cannot be reconstructed later. `blocks-pruning` defaults to archive-canonical, which discards non-canonical bodies once finality passes them — about a hundred blocks behind the tip on Planck — and difficulty is a state read against a 256-block default. Minutes after a reorg the chain itself can no longer answer for the block, so what is captured at the moment of the swap is all there will ever be. That is why the row holds the author and the difficulty rather than just the hash: the author is the interesting field, it says who lost the race, and it costs a table that gains a row only when the chain forks. The capture is a CTE ahead of the upsert, in the same statement. Every data-modifying CTE sees the same snapshot, so it reads the pre-update row even though the insert below it is replacing that row in the same breath, and a failure rolls back both — which two statements without a transaction would not. A trigger would do the same thing invisibly to anyone reading the query. Nothing reads the table yet, deliberately. The read shape belongs with the block route that will use it; what matters now is that the history exists by the time that lands, which it cannot do retroactively. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 |
||
|
|
7e5c2de936
|
feat: sparkline history under the headline tiles
Adds `GET /v1/chains/{chain}/series` and a sparkline under network hashrate,
difficulty, block time and miners. Height gets none — it only goes up, and a
straight diagonal reports nothing.
The chart is bucketed by height, not by time: every point is the same number of
blocks, so every point carries equal statistical weight and a stretch nobody
recorded stays the same width on the axis as one that was. A bucket is the
window over fifty, which makes a full window exactly fifty buckets — that is
what lets the rolling miner count at the last point be the same number printed
in the tile above it, rather than a per-bucket count that would sit well below
it. Buckets are anchored to absolute height for the same reason `miner_series`
bins from the epoch: anchored to the tip instead, every boundary slides by one
on every block and the chart shifts under a reader watching it.
Interval comes from `authored_at`, never `observed_at`. A gap fill writes a
whole batch of observation times within the same second, so deltas taken from
it read as a chain producing hundreds of blocks a second — the trap
`RollingWindow`'s `at_tip` flag exists to avoid, which until now lived only in
memory. It is a column as of `0002`, so the distinction survives a restart and
`observed_at` is interpretable at all.
Each point's interval is a three-bucket moving average. The mean of one
bucket's gaps still carries real Poisson error — 72 blocks of a 15 s interval
lands at ±1.8 s — and across fifty points that draws a chain that appears to
change size every few minutes and does not.
Fixes a wrong number found on the way in: `pallet_qpow` retargets in
`on_finalize`, so `QPoWApi_get_difficulty` at the latest state is the
difficulty for the *next* block, not the one just seen. Every stored difficulty
was off by one block — invisible, because a gradual retarget still looks
plausible. It is now asked at `header.parent_hash`, the same call the miner
made when it built the block. A node that has pruned that state gets `None`
rather than the current value: substituting it is exactly how a gap fill would
stamp today's difficulty across a stretch of old heights. The CLI backfill had
the same bug at greater scale, copying one reading across an entire range.
Unknowns are drawn as breaks in the line, never interpolated: an outage, a
difficulty a pruned node cannot supply, a point without a full window of
authors behind it. A chart that guesses across what was never recorded is the
same class of lie as a hashrate divided by a sync speed, and just as hard to
catch afterwards.
Read against the dataviz skill first, per CLAUDE.md. Single series, one hue;
emphasis on the newest point is `--data-bright`, a lighter step of the same
bronze, because the usual "current period in the accent" convention is the one
change that would break this palette. The hover readout borrows each tile's
note line — five floating tooltips across a row of 180px tiles is a pile, not a
hover layer — and the same readout is on keyboard focus and in the aria-label,
since the line does not start at zero and its height is not a quantity anyone
should be estimating.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
|
||
|
|
110fbc3631
|
feat: blackbeard.observer — live Quantus mining leaderboard
Cargo workspace plus a Vite frontend, following ~/git/architecture/generic.md. Every block header carries its author's wormhole reward preimage in a `pow_` PreRuntime digest, so authorship for the whole network is derivable from headers alone — no indexer, no registration, no way for a miner to be left out. That decoding, the hashrate maths and the telemetry name attribution live in blackbeard-core with no I/O at all, so the parts that are easy to get subtly wrong are exercised by unit tests rather than only against a live chain. The browser holds one WebSocket: snapshot on subscribe, deltas thereafter. The head stream is itself a push (chain_subscribeNewHeads), so a block reaches the page the moment the node imports it. Messages are serialised once per broadcast, and leaderboards are recomputed only for windows a socket is actually watching. No RxJS — useSyncExternalStore is React's own contract for this. Verified against the live Planck testnet: 12/12 headers decoded, telemetry names attributed (quanpool-planck, baba-gorchitsa, …), warm start restoring 84 blocks and 5 held names across a restart. Three findings worth recording, all in CLAUDE.md: - substrate-telemetry sends its JSON in *binary* frames. A text-only client connects, subscribes, reports healthy and receives nothing at all — and a Python probe hides it, because json.loads accepts bytes. - Difficulty is a little-endian U512; decoding it big-endian gives a number wrong by ~10^150 that still renders fine. - Planck's real block interval is ~13-15s against a 6s target with enormous variance, so a measured interval needs 20 tip samples before it is publishable. Deploy assets, the Gitea Actions workflow and script/infra-setup.sh are included; port 25864 is registered in architecture/port-allocations.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi |