9 Commits
Author SHA1 Message Date
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 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 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 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 4fb856827c fix: a bogus length prefix aborted the daemon, and the gap fill never resumed
deploy / build (push) Successful in 8m3s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 17s
Two faults, one visible symptom: both testnet leaderboards frozen for a day at
the height their gap opened at, while mainnet was fine.

The daemon had aborted 316 times in ten hours, always identically —
`memory allocation of 82014765760 bytes failed`, 76.4 GiB. That is
1,025,184,572 x 80, and 80 is `size_of::<scale_value::Value<u32>>()`:
scale-value sizes a sequence's `Vec` from the compact length in the blob before
decoding a single item, so a blob that disagrees with the registry asks for an
allocation of any size at all and Rust aborts on the failed one. The step that
would have caught the mismatch is the one that never runs, which is why
`decode_events(&blob).ok()` could not help — there was no `Err`, only SIGABRT.

Every decode now goes through `Runtime::decode_checked`, which walks the bytes
first with scale-decode's `IgnoreVisitor`. That crate has no `with_capacity`
anywhere, so an impossible length runs out of input on the first item and comes
back as an error. The regression test is the exact four-byte blob, reproduced
against the mainnet fixture before the guard went in — the abort message matched
production byte for byte.

`fill_gap` then turned a short process lifetime into no progress whatsoever: it
accumulated the whole gap and wrote once after the loop, so every interruption
discarded everything read. Each block is four RPC round trips, one a historical
state read; 1,696 blocks against a remote endpoint does not fit in the two
minutes the aborts allowed. And this is not only about the aborts —
`blackbeard-api-cert.path` restarts the service several times a day, so any gap
wider than that interval was already unfillable, logging
`filling a gap in the head stream` on every start with the same `from` and
looking like progress. It flushes every 256 blocks now, and says how many it
recorded.

Both traps are in CLAUDE.md, because neither looks broken from the outside: one
presents as a service that is merely restarting, the other as a catch-up that is
merely slow.

Closes #10
Closes #11

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 12:02:07 +03:00
grenadeandClaude Opus 5 6b0e02e059 feat: read chain state, decoded against the runtime
deploy / build (push) Successful in 7m59s
deploy / deploy-web (push) Failing after 4s
deploy / deploy-api (push) Successful in 16s
The observer could read headers, events, extrinsics, metadata and difficulty,
and not a single pallet storage item. Answering "which address is this chain's
treasury" meant going outside it and hand-deriving twox128 prefixes, which is a
gap in the tool rather than an answer.

The treasury is the case that shows why. `set_treasury_account` reads *never* on
the call index and `TreasuryAccountUpdated` reads *never* on the event index —
both true, because the address was set at genesis, so no extrinsic ever carried
it and no event ever announced it. It exists only in state. It is
qzjsuLN7Nhu4bjvmUbjSTr2ZTeZ7oRxXpQP9fdv6PcHUCRrVR, and it has never been funded.

`Runtime::storage_key` builds a key entirely from the runtime's own description
— the pallet's storage prefix, the item name, and each key's declared hasher —
because a wrong hasher yields a key that reads as *absent* rather than as an
error, and nothing would catch it. Its test pins two keys against ones read off
the live chain by hand. Keys are SCALE-encoded against the type the entry
declares, so a map on an account, a u32 or a tuple all work without this knowing
which it is.

Absent is not zero, and only the modifier knows which: an `optional` entry
holding nothing means nothing, a `default` entry means the runtime's default,
and the state page marks the latter rather than passing it off as something the
chain wrote. `read_balance` deliberately does not apply the default —
`AccountInfo` zeroes, Substrate reaps empty accounts, and falling back would
turn "does not exist" into "holds nothing", which is the treasury's exact case.

/:chain/state lists all 40 keyless entries decoded, and an account page now
carries a real balance beside the flows it already summed. Those are different
numbers: rewards say what an account was paid, a balance says what it has, and
they differ by every transfer out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 08:33:21 +03:00
grenadeandClaude Opus 5 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
2026-09-09 16:47:10 +03:00
grenadeandClaude Opus 5 a3eb670d11 feat: identify miners by the address they are paid at, not the preimage
deploy / build (push) Waiting to run
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
A reward preimage is exact and unrecognisable — no miner knows theirs by sight.
The address is the string they configured and the one their balance shows
against, and the two are related by a derivation the chain performs anyway:
`pallets/mining-rewards` pays `qp_wormhole::derive_wormhole_address`, which is
`qp_poseidon_core::rehash_to_bytes`. Running the same function reproduces the
address exactly, with no RPC and no lookup table.

The derivation takes the preimage and nothing else — no genesis, no chain
parameters — so one preimage is one account on every Quantus chain. The unit
test pins it against a real pair (this observer's own node and the address it
was paid at), so a dependency upgrade that changed the derivation fails the
build rather than quietly relabelling every miner on the site.

Unnamed miners now render as their address. The miner panel shows all three
identifiers, because they answer different questions: the node name is an
inference and may be absent, the address is what the miner recognises, and the
preimage is what the header actually carries and the only one the chain
asserts.

SS58 is written out rather than pulled from `sp-core`, which would bring a
substrate runtime's worth of dependencies to format 36 bytes — the same trade
the readme records for declining subxt. Base58 is hand-rolled because it is
long division and nothing else; blake2 is not, because checksums are not worth
hand-rolling. Both are covered by the address test vector.

`RecentBlock` gains the attribution source: the ticker cannot tell a node name
from an address by looking, since `quanpool-payout-mainnet` and `quantus-radar`
both begin with the letter Quantus addresses do. Two tests that asserted the
old display policy now assert the new one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 15:13:25 +03:00
grenadeandClaude Opus 5 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
2026-09-04 12:33:54 +03:00