Commit Graph
67 Commits
Author SHA1 Message Date
grenadeandClaude Opus 5.5 739a2f5345 fix: a reorged height takes the winner's block and events, not the loser's
ci / gate (push) Successful in 5m41s
deploy / validate (push) Skipped
deploy / build (push) Successful in 10m12s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 22s
`chain_subscribeNewHeads` reports the best head, so a fork that wins at h
arrives as its h+1 and the replacement at h is never delivered. Ingest
checked heights for gaps but never parents for continuity, so h kept the
losing block's row, and `chain_event`, upserted by index, kept its
reward. Mainnet 61041 credited baba-gorchitsa 0.31 QTC the chain paid
QUANPOOL: its "mined" read 14.97 against a balance of 14.68 that can
only ever rise.

- `record` reads the header at the hash it resolved, then compares its
  parent with the row beneath it and walks a fork back (`repair_fork`),
  re-recording each replaced block and re-reading its events.
- Event and extrinsic rows carry `block_hash` (0024); a height is cleared
  of rows from any other block before it is written.
- The backfill stays `SETTLE_DEPTH` behind the tip and re-records any
  block row that is not canonical. 0024 collapses its cursor so all
  history is re-checked.
- `RollingWindow::push` replaces a block at a height it already holds
  instead of counting both forks.

Closes #52

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKabPYLTYP2G5s7ph7oCpo
2026-10-14 08:29:07 +03:00
grenadeandClaude Opus 5.5 003a13e4ce feat: the NEAR sponsor for blackbeard wallets, off until configured
blackbeard-observer.near will create each blackbeard wallet's NEAR
account and relay its legs as NEP-366 meta-transactions, paying the gas
(blackbeard/wallet#81). This is that sponsor, built and off by default.

- blackbeard-core::sponsor judges a signed delegate action. It is decoded
  and verified by wallet-near, the code the wallet signs with, pinned by
  rev.
  - The sender must be the sponsored account of the delegate's own key.
  - Only the wallet's calls are relayed (intents.near ft_withdraw and
    mt_transfer; wrap.near ft_transfer_call, near_deposit and
    storage_deposit), each with a gas and deposit cap.
  - A pool swap to Ref DCL must pay the fee, at least ceil(amount × bps),
    in its input token to the fee account, in the same action.
  - Seven tests cover forged, misaddressed, over-limit and fee-less
    delegates.
- blackbeard-data::sponsor submits to gargravarr first, the node tracking
  the sponsor's shard, and returns at INCLUDED_FINAL.
- blackbeard-api::sponsor holds the key, one nonce counter, per-address,
  per-sender and daily limits. Routes:
  - GET /v1/near/sponsor: the terms;
  - POST /v1/near/accounts: create the account for a key, or find it;
  - POST /v1/near/relay: relay a signed delegate action.
  Without [near_sponsor] in the config, the terms say enabled: false and
  the POSTs answer 503. A configured key that will not load stops the
  daemon.
- blackbeard-cli near-sponsor-keygen writes a new ML-DSA-65 key, mode 600,
  where it will be used. It never overwrites.

The config template carries the section commented out, with the steps
to turn it on. Registering and funding the account are the operator's.

Refs #51

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 13:39:12 +03:00
grenadeandClaude Opus 5.5 8892f0a399 feat: on-chain context for each pause in quoting, and pool settings kept
ci / gate (push) Successful in 4m32s
deploy / validate (push) Skipped
deploy / build (push) Successful in 9m51s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 17s
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
2026-10-07 17:43:29 +03:00
grenadeandClaude Opus 5.5 ab2b59d178 feat: pause log, asymmetry and daily summary for 1Click quote probes
ci / gate (push) Successful in 4m46s
deploy / validate (push) Skipped
deploy / build (push) Successful in 9m39s
deploy / deploy-web (push) Successful in 15s
deploy / deploy-api (push) Successful in 25s
The probe record from 6b3bf76 says, minute by minute, whether 1Click
quoted QTC. This turns it into the episodes a reader looks for.

- Pauses: runs of consecutive unquoted minutes per direction and pair,
  found by blackbeard_core::quote_probe::pauses on the every-minute probes
  only. A quote ends a pause. An unanswered minute does not, since nothing
  is known about it, so it is counted inside. More than 15 minutes
  without answers ends a pause as unobserved, not as continuing unseen.
  Each pause carries how many of its minutes a solver alone could have
  filled it, and the pool's price at its start, at its end, and 1 and 4
  hours after. The price comes from the probe's own pool readings, not
  from any quote, and the note says a later price move is not evidence
  of why a pause happened.
- Asymmetry: per counter, buy availability minus sell availability.
- Daily summary: per UTC day over the last month, rendered as UTC days.

Pauses are computed with a day of lookback before the window, and one
already under way at the start of the record is marked as such.

Refs #45

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tyzzRVjH9AdwGtYwsH5VC
2026-10-07 17:23:09 +03:00
grenadeandClaude Opus 5.5 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
2026-10-07 16:20:50 +03:00
grenadeandClaude Opus 5.5 7c02840f91 feat: Quantus transaction page, addressed by hash, with history hashed
ci / gate (push) Successful in 4m33s
deploy / validate (push) Skipped
deploy / build (push) Successful in 9m32s
deploy / deploy-web (push) Successful in 25s
deploy / deploy-api (push) Successful in 25s
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
2026-10-07 16:01:47 +03:00
grenadeandClaude Opus 5.5 6b3bf76b7e feat: probe whether 1Click will quote QTC, against what could have filled it
ci / gate (push) Successful in 4m40s
deploy / validate (push) Skipped
deploy / build (push) Successful in 9m23s
deploy / deploy-web (push) Successful in 10s
deploy / deploy-api (push) Successful in 24s
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
2026-10-07 13:39:52 +03:00
grenadeandClaude Opus 5.5 21ea353942 feat: NEAR transaction and Intents account pages
ci / gate (push) Successful in 4m40s
deploy / validate (push) Skipped
deploy / build (push) Successful in 9m5s
deploy / deploy-web (push) Successful in 10s
deploy / deploy-api (push) Successful in 19s
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
2026-10-07 12:22:25 +03:00
grenadeandClaude Opus 5.5 94472f1238 fix(api): bridge history treats a failed archive read as unresolved
ci / gate (push) Successful in 4m24s
deploy / validate (push) Skipped
deploy / build (push) Successful in 9m14s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 19s
The pre-node history read found all sixteen supply changes and recorded
thirteen. Two silent paths dropped mints: a chunk read error read as an
empty chunk, and a block_at error discarded a mint already found. The
cursor was then saved, so the 0.996, 0.5 and 5,990 QTC deposits stayed
unlinked for good.

Archive reads are now bounded, with backoff, and log at warn when they
give up. Each change is linked, mismatched or unread, and unread changes
are retried in later passes. The cursor is saved only once nothing is
unread. supply_at no longer retries forever. Migration 0019 clears the
cursor so the read runs again.

/bridge/deposits and the hash job also skip the fee payer's gas top-ups,
which were counted as 337 unlinked deposits.

Closes #46

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tyzzRVjH9AdwGtYwsH5VC
2026-10-07 11:08:06 +03:00
grenadeandClaude Opus 5.5 084fd5ba2c feat: link QTC bridge deposits and withdrawals across both chains
ci / gate (push) Successful in 4m32s
deploy / validate (push) Skipped
deploy / build (push) Successful in 10m22s
deploy / deploy-web (push) Successful in 12s
deploy / deploy-api (push) Successful in 30s
#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
2026-10-07 09:10:33 +03:00
grenadeandClaude Opus 5.5 69050a45c1 feat: index QTC's 1Click fills, which settle as transfers, not token_diff
ci / gate (push) Successful in 4m33s
deploy / validate (push) Skipped
deploy / build (push) Successful in 8m45s
deploy / deploy-web (push) Successful in 8s
deploy / deploy-api (push) Successful in 21s
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
2026-10-06 20:38:34 +03:00
grenadeandClaude Opus 5.5 6988368d6e fix(api): fetch relay swaps first, and never idle while the queue has work
ci / gate (push) Successful in 4m32s
deploy / validate (push) Skipped
deploy / build (push) Successful in 9m35s
deploy / deploy-api (push) Successful in 42s
deploy / deploy-web (push) Successful in 10s
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
2026-10-06 19:24:36 +03:00
grenadeandClaude Opus 5.5 6152ba85c2 fix(api): queue only direct pool calls about the asset, and re-read the rest
ci / gate (push) Successful in 4m5s
deploy / validate (push) Skipped
deploy / build (push) Successful in 9m0s
deploy / deploy-web (push) Successful in 8s
deploy / deploy-api (push) Successful in 19s
The direct-call scan matched any transaction reaching the DCL contract,
which hosts hundreds of pools: 4,881 strangers' swaps queued in the first
hour after launch, from 176 signers, each to be fetched from a
rate-limited public endpoint — enough on its own to keep the market page
starved. A call now counts only when it moves the asset itself or its
arguments name the asset's contract, which every pool id holding it does;
a buy with any token names the pool, a sell is a transfer of the asset.

ref_pending keeps no arguments, so the bad entries cannot be filtered in
place: migration 0016 drops every entry not signed by the relay and the
scan's cursor, and the scan reads the span again from our node.

Refs #39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
2026-10-06 19:01:29 +03:00
grenadeandClaude Opus 5.5 6116954f13 feat: QTC into and out of NEAR Intents, block by block
ci / gate (push) Successful in 4m23s
deploy / validate (push) Skipped
deploy / build (push) Successful in 9m6s
deploy / deploy-web (push) Successful in 8s
deploy / deploy-api (push) Successful in 20s
#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
2026-10-06 18:54:40 +03:00
grenadeandClaude Opus 5.5 0851b244ba fix(api): read the pools already known while discovery is rate-limited
ci / gate (push) Successful in 4m23s
deploy / validate (push) Skipped
deploy / build (push) Successful in 8m47s
deploy / deploy-web (push) Successful in 9s
deploy / deploy-api (push) Successful in 22s
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
2026-10-06 18:36:44 +03:00
grenadeandClaude Opus 5.5 5361dec713 feat: record the Ref pool's bid side over time
ci / gate (push) Successful in 4m7s
deploy / validate (push) Skipped
deploy / build (push) Successful in 8m55s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 18s
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
2026-10-06 18:17:11 +03:00
grenadeandClaude Opus 5.5 1d25689e3d feat(data): ask our own NEAR node for view calls while it is at the tip
FastNEAR kept answering 429 after the pacer shipped: its limit is per
address, and every host behind the office IP shares it. gargravarr tracks
the pool's and the token's shards, so `pool_rpc_urls` now also serves
view calls first — pool state, order book, quotes, supply, balances — but
only while its newest block is under two minutes old and it is not
syncing, re-checked every 30 s. Behind the tip it would answer `final`
from its own final block: on 2026-10-06, 11,536 blocks behind, it gave
qtc.omft.near's supply as of three hours earlier, which would have
replaced "unreadable" with a figure that looked current and was not. Any
call it cannot answer falls back to the public endpoint.

Refs #39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
2026-10-06 18:17:11 +03:00
grenadeandClaude Opus 5.5 26dfdc956c fix: pace public NEAR view calls, and never publish "no pools" from a failed read
ci / gate (push) Successful in 4m30s
deploy / validate (push) Skipped
deploy / build (push) Canceled after 7s
deploy / deploy-api (push) Skipped
deploy / deploy-web (push) Skipped
From 17:32 every Ref pool read came back FastNEAR 429, minute after minute:
a restart fires discovery pages, pool and order-book views, supply and
balance reads and inventory at once, from one office address, against an
endpoint measured at about two requests a second sustained. Every call to a
public view endpoint now passes one process-wide gate, 600 ms apart.

Worse than the outage, the pool loop read an empty pool list after a failed
discovery and published it, so the market page said no Ref DCL pool holds
QTC — "could not list" turned into "there are none". The slot now stays
unread until a discovery has succeeded; one that succeeds and finds none is
still published as none.

Refs #37, #39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
2026-10-06 17:56:42 +03:00
grenadeandClaude Opus 5.5 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
2026-10-06 17:49:05 +03:00
grenadeandClaude Opus 5.5 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
2026-10-06 17:42:57 +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 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 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 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 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 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 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 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 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 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 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 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
grenadeandClaude Opus 5 fac73f392c feat: a nodes index, so a node need not have mined to be visible
deploy / build (push) Successful in 7m23s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 16s
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
2026-09-13 19:19:58 +03:00
grenadeandClaude Opus 5 e7cfe42881 feat(telemetry): read what a node is doing, live off the socket
deploy / build (push) Successful in 7m28s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 16s
Peer count, transactions in its pool, upload, download and state cache — the
volatile half of the feed, decoded from `NodeStatsUpdate`, `NodeHardware` and
`NodeIOUpdate`, plus the first values `AddedNode` itself carries so a freshly
announced node reads correctly rather than waiting for an update frame.

**Never persisted, and the shape says so.** These live in `NodeActivity`, held
in memory and attached to the miner route only when the feed is currently
reporting the node — a node answered from the cache gets its hardware and no
activity at all. A peer count from an hour ago is not a peer count, and storing
one would turn a live reading into a stale number that still looks live.

Only the newest sample of each series is kept. The feed sends rolling arrays and
already redraws them; this is a readout, not a chart. `NodeHardware` carries
three parallel series and the third is the feed's own x-axis, which is why the
test asserts the download figure is not the timestamps.

An update naming a node the feed has not introduced is dropped rather than
counted a decode error: a feed joined mid-stream sees these before the
`AddedNode` that explains them, and it will be re-sent.

Bandwidth renders in decimal units while memory and state cache render in
binary. That is not an inconsistency — a reader comparing bandwidth against a
NIC or a speed test is comparing against decimal, and one comparing memory
against what they bought is comparing against binary.

Verified against the live feed: 34 peers, 103 kB/s up, 20.5 kB/s down, 528 MiB
of state cache, read off mainnet and rendered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-13 18:33:00 +03:00
grenadeandClaude Opus 5 28e99bee44 feat: show the node a miner's blocks were reported from
deploy / build (push) Successful in 7m37s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 17s
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
2026-09-13 17:26:28 +03:00
grenadeandClaude Opus 5 0b3c1b2e1b feat(telemetry): decode the whole of NodeDetails, and locate nodes by country
We were reading two fields of eleven — the name and the peer id — and dropping
the rest, including the hardware every node already announces. `NodeMetadata`
now carries the slow-moving half: implementation, version, the target triple,
CPU, cores, memory, kernel, distro, whether it is virtualised, and the process
start time.

The volatile half stays out on purpose. Peer count, transaction queue,
bandwidth and state cache change every few seconds, are already a rolling series
in the feed, and would be a lot of rows for something nobody would page back
through.

`startup_time` is kept rather than derived, so node uptime becomes a fact about
the node rather than about how long this observer has been watching it.

**Location is resolved to a country and no finer.** The feed sends
`[latitude, longitude, city]` and no country, so `blackbeard_core::geo` derives
one offline from a 508 KiB boundary set — no network call, and no third party
told where these nodes are. The city is read and discarded: those coordinates
come from an IP geolocation database and at city resolution are wrong often
enough that publishing one would assert something we do not know. Nothing
downstream can render a city because nothing downstream is given one. The
lookup returns subdivisions before countries, and `GB-ENG` is exactly the extra
precision this is avoiding, so only the two-letter code survives.

`LocatedNode` is a separate frame because the telemetry server geolocates
asynchronously — `AddedNode` almost always carries a null location and the
country follows a moment later. Which means a reconnect, which re-sends every
node with a null location again, must not erase a country already resolved.
There is a test for that, because it would present as countries silently
vanishing hours after a restart.

The mapping is pinned by tests built from frames captured off the live feed
rather than from a spec: the format is positional and differs between telemetry
releases, so bytes the feed actually sent are the only honest fixture. If a
future release renumbers a field, those tests are what says so.

Refs #15

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-13 17:10:32 +03:00
grenadeandClaude Opus 5 813e49c43c fix: time a head by when it arrived, not by when we got round to it
deploy / build (push) Successful in 7m52s
deploy / deploy-api (push) Successful in 18s
deploy / deploy-web (push) Successful in 5s
The gap-fill guard fixed the coarse half of #14 — Planck went from 0.175 s/block
to 4.9 — and left the rest standing. Measured just now against a real ~30 s on
Planck and ~6 s on Heisenberg:

    quantus     9.59 s     plausible
    planck      4.919 s    six times too fast
    heisenberg  0.052 s    a hundred times too fast

`record` stamped `observed_at` with `Utc::now()`, which runs *after* two RPC
round trips and behind a 64-deep channel. On a remote endpoint those round trips
are hundreds of milliseconds, so heads queue and then drain in a burst — a batch
carrying near-identical observation times while their heights march on.
`measured_interval` divides elapsed time by height difference and reads that as
a chain producing blocks twenty times faster than it does.

`observed_at` means "when this observer saw it", so it is now stamped where the
frame is read off the socket: `subscribe_new_heads` sends a `SeenHead` carrying
that moment and `record` uses it rather than asking the clock again. Every
consumer improves at once, because the same field is the DB row, the ticker's
gap, and the tip sample.

Worth noting what this does *not* need: no threshold, no plausibility check, no
discarding of samples. The measurement was always sound; it was being given the
wrong time.

Refs #14

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 15:09:16 +03:00
grenadeandClaude Opus 5 9def0c8b6a feat: route reads that need old state to an endpoint that has it
deploy / build (push) Successful in 7m53s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s
Failover moved on a transport failure and never on a JSON-RPC error, both
right — but a node that has pruned the state being asked for does neither. It
answers `{"result": null}`, which is a success, so whichever endpoint happened
to be sticky decided how far back the whole site could see.

The tempting fix is wrong. Null is also the correct answer for most keys read
here: an account with no balance, an item never set, the treasury's
`System::Account` entry, absent because the treasury was never funded. Retrying
every one of those against every endpoint would multiply the load to re-derive
an answer already in hand, and still end in null. The distinction is not in the
response — it is whether the node could have known, which is a property of the
endpoint.

So `classify_depth` probes each one at startup, reading `System::Number` at
block one. That key is derived in a test rather than pasted, because a mistyped
key is absent everywhere and would classify every endpoint as pruned. An
endpoint that answers holds all state after block one; one that cannot be
reached stays `Unknown` and routes as pruned, since an unreachable host must not
become the archive of record by default.

Reads naming a block hash then go through `call_deep`. It tries the sticky
endpoint first, so the common case — mainnet's loopback node, which is an
archive — costs no extra round trip. Only a null from an endpoint never shown to
hold old state buys a second opinion, and `state_call`'s refusal is treated the
same way, because that is how difficulty is read and a node that cannot execute
against dropped state errors rather than returning null.

All seven configured endpoints probe as archives right now, so this changes no
current behaviour. It is what stops history going quietly blank the day one of
them stops being one — and it says so in the log when it happens, because an
endpoint that cannot see as far back as the site is asking is a configuration
fact worth surfacing rather than a chain fact.

Closes #9

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 12:12:11 +03:00
grenadeandClaude Opus 5 1a59b2d398 feat: several endpoints per chain, so one host cannot stop it
deploy / build (push) Successful in 8m19s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 16s
A chain entry held exactly one `rpc_url` and one `ws_url`, and when that host
went down everything for that chain stopped — headers, difficulty, backfill,
state reads, runtime discovery. The endpoints already existed in pairs:
`a2-planck` and `a2-heisenberg` both answer and neither was configured. We were
choosing single points of failure the network went to some trouble to avoid.

`rpc_urls` and `ws_urls` are lists now, and `rpc_url`/`ws_url` still work as a
one-element list — a config naming one endpoint is still a valid config, and
making every deployment rewrite its entry would be this feature breaking the
thing it exists to make reliable.

Endpoints are stuck to rather than balanced across, which is the design and not
laziness: a storage read at an old block hash needs a node that still holds that
block's state, and nodes prune on their own schedules, so alternating would
return a mixture of answers and absences that reads as sparse data rather than a
configuration problem. The cursor is shared across clones so a failover one task
finds is not rediscovered by every other task on the chain.

Failing over on the wrong thing was the trap worth avoiding. A JSON-RPC error is
the node answering — moving on `count exceeds maximum value` would hide a
caller's mistake behind a second node making the same complaint — so a new
`Malformed` variant separates "did not answer" from "answered, with an error".
A pruned block returns `{"result": null}`, a success, and never looks unhealthy.

The WebSocket rotates at reconnect, where the loop already was; racing
subscriptions across endpoints and deduplicating heads buys nothing, since heads
are a liveness signal and ingest fills gaps against `chain_getBlockHash` anyway.

Verified live with a dead endpoint configured first: Planck stayed `full` at its
real height, the RPC logged one `failed over` with from and to, and the head
subscription logged the loss with the endpoint count beside it — because "the
chain is unreachable" and "one of three endpoints is unreachable" are different
operational facts and used to look identical.

Closes #7

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 11:17:03 +03:00
grenadeandClaude Opus 5 7d081c7746 fix: map enumeration asked for more keys than the node allows
deploy / build (push) Successful in 8m23s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s
`/heisenberg/reversible` reported 0 pending. The chain has 2.

`state_getKeysPaged` caps `count` at 1,000 and *rejects* rather than truncates:
`PENDING_LIMIT` was 1,600, so every call errored, and `.unwrap_or_default()`
turned the error into an empty page. Nothing looked broken — it looked like a
chain with nothing in flight, which is precisely what mainnet legitimately looks
like. It would have stayed wrong indefinitely.

Caught only because `ReversibleTransfers::NextTransactionId` is a plain counter
reading 5 on Heisenberg, which contradicted the page. Every path tested during
the original work happened to be under the cap — `GENESIS_LIMIT` is 500, and the
`System::Account` check used 3 — so the machinery looked verified.

Three fixes. The clamp lives in `rpc.rs`, because 1,000 is the node's ceiling
rather than a caller's preference. The route no longer swallows the error: a
chain whose runtime lacks the pallet already returns 404, so a failure here is a
real fault and now says so. And the constant is under the cap so the common case
is one request.

Verified against Heisenberg's two real pending transfers, which also exercise
the unknown-deadline path — both were scheduled before the event index reaches,
so they render as pending with no countdown rather than being dropped.

CLAUDE.md gains the general shape: `unwrap_or_default()` on anything that talks
to a node is how a wrong answer comes to look like a right one.

Closes #4

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 10:50:14 +03:00
grenadeandClaude Opus 5 88086552cb fix: show a block's events, joined to the extrinsics that caused them
deploy / build (push) Successful in 9m18s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 14s
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
2026-09-10 09:14:56 +03:00
grenadeandClaude Opus 5 dd8086df0d feat: mark the accounts a chain names, and make genesis unmissable
deploy / build (push) Successful in 8m4s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s
Some accounts are special and nothing about the address said so. The treasury
looked like any other empty account, the minting sentinel looked like a wallet,
and the twenty-one accounts endowed at genesis looked like they earned it.

No curated list. The chain names them itself, in three places that are three
different claims and are not flattened into one badge: a runtime constant
(compiled in, immutable for that spec_version), a storage value (assigned, and
changeable), and a balance in block zero (history, and not a role — an account
can be endowed and have no job). Labels derive from the chain's own name, so a
pallet added next year is labelled without an edit. The chip is the claim; the
citation beneath it is the evidence.

Two rules hold it together, both learned the hard way and both now in CLAUDE.md.
An account is told from a hash by registry path, never by shape — after
`normalise` both are `0x` and sixty-four hex characters, so `decode_typed` now
returns the account set the decoder collected while it still knew. And a storage
entry names an account only if its value *is* one: `System::Events` is full of
accounts and names none, and the first cut labelled half the chain's active
addresses `Events`.

Computing all of this walks every plain storage entry and enumerates block zero
— dozens of round trips, fine once and pathological on every account page view —
so it is cached for five minutes. Constants change with the runtime, state
assignments almost never, genesis never.

Block zero gets the endowments, because they have no extrinsic and no event and
are invisible to anyone who has not read the chainspec, and a Genesis link sits
in the nav to give somebody who would never think to look an unmissable way to.
Mainnet started with 5,670,000 QTC across 21 accounts — one holds 5,669,940 and
the other twenty got 3 each — shown beside what each holds today, so what a
founding account did with its stake is one row. When genesis state cannot be
read the page says so rather than showing an empty table: missing data and a
chain that endowed nobody are different claims.

Closes #2

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 09:04:45 +03:00