diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index a088a9cc629..b5053ad1822 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -14,12 +14,26 @@ permissions: concurrency: group: universe-ci-${{ github.ref }} - cancel-in-progress: false + # A superseded commit on a pull request proves nothing about the commit that + # replaced it, and while its run continues it holds one of the six ultra + # runners the replacement is queued for. Pushing three times to a branch + # under review used to mean three full matrix runs in a queue, and only the + # last one was worth waiting for. + # + # Pushes to develop and main are never cancelled. Every commit on those is + # validated on its own, because promotion reads per-commit check results and + # the release artifact is built from main. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: docs: name: Documentation gates - runs-on: [self-hosted, linux-ultra] + # Any runner in the fleet. This job is a checkout, a Node install and a set + # of Node scripts: no browser, no Rust, no Docker. Pinned to `linux-ultra` + # it competed for the six runners that are the only ones able to run the + # visual matrix, while eight runners carrying `universe-ci` sat idle, and a + # three minute job held a slot the fifty minute one was queued for. + runs-on: [self-hosted, universe-ci] timeout-minutes: 10 steps: - name: Checkout @@ -57,6 +71,9 @@ jobs: - name: Gateway routing run: node --test scripts/universe/gateway.test.mjs + - name: The component identity check can still fail + run: node --test scripts/universe/release-manifest.test.mjs + - name: The gateway bridges an upstream restart rather than failing run: node --test scripts/universe/gateway-restart.test.mjs @@ -160,6 +177,14 @@ jobs: working-directory: scripts/universe/visual-qa run: node --test progress-gate.test.mjs + # The public chain page gate, checked against the states it exists for. + # It runs against the live origin rather than this build, so nothing in + # this job exercises it, and a gate that has only ever passed has not + # shown it can fail. Every case it covers shipped past a green suite. + - name: The public chain page gate fires on the states that shipped + working-directory: scripts/universe/visual-qa + run: node --test chain-page-audit.test.mjs + # A fixed port here once measured another job's build: two runners share # a host, the second gateway crashed on the taken port behind "&", the # probe happily reached the first job's server, and the run died halfway @@ -192,8 +217,30 @@ jobs: # finishes show up. The deep pass takes the routes this suite holds to a # terminal state through every failure state, every theme and the widths # that change the layout, which is where a page that lies about why it is - # empty shows up. Measured at about two and a half minutes and sixteen - # minutes respectively. + # empty shows up. + # + # Measured on run 33254326169, 2026-08-29, on universe-linux-ultra-02: + # + # broad pass 64 screenshots 5m50s + # deep pass 504 screenshots 47m10s + # chain failures 48 screenshots 3m27s + # forced colours and zoom 30s + # whole job 59m57s of a 100 minute cap + # + # This comment said two and a half minutes and sixteen. The deep pass has + # since grown to roughly three times that, it is now 78 percent of the + # job, and the job sits at 60 of its 100 minutes. Nothing is failing. The + # point of writing the real numbers down is that the next person to add a + # route, a state or a theme to the deep pass is the one who meets the + # cap, and without this they meet it as a timeout forty-seven minutes + # into a run rather than as a number they could have added up first. + # + # The deep pass is routes multiplied by states multiplied by themes + # multiplied by widths. Adding one route to it currently costs about + # seven minutes. If it needs to grow again, split it the way it was split + # from the broad pass in the first place, rather than raising the cap: + # a gate that cannot finish protects nothing, which is the reason the + # split exists. - name: Every route renders and finishes, at a phone and a desktop width working-directory: scripts/universe/visual-qa run: >- @@ -206,6 +253,25 @@ jobs: node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts --routes=graphs,mining,protocols,home,blocks,tx,address --viewports=320,768,1440 + # The chain failure states have never run here. They are scoped to the + # chain routes, so the pass above never reaches them, and they were + # reviewed by hand instead: a chain that cannot be reached, one that is + # behind, one whose node is caught up while its protocol side is not, one + # that withholds readiness without saying why, and an object that simply + # is not there. Those five are the states the whole status vocabulary + # exists for, and the one set nothing measured. + # + # Two themes and two widths rather than three and three, because the cost + # has to fit beside the pass above and this is the part of the matrix + # that catches wording and state, not layout. + - name: A chain that cannot answer says so, in every failure state + working-directory: scripts/universe/visual-qa + run: >- + node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts-chain-states + --routes=dogecoin,dogecoin-drc20,zcash,zcash-block + --states=chain-authority-down,chain-behind,chain-not-ready-protocols,chain-not-ready-unexplained,chain-object-missing + --themes=default,dark --viewports=375,1440 + - name: Forced colours and 200 percent zoom working-directory: scripts/universe/visual-qa run: node modes-check.mjs --base=http://127.0.0.1:$GATEWAY_PORT @@ -218,4 +284,5 @@ jobs: path: | scripts/universe/visual-qa/artifacts scripts/universe/visual-qa/artifacts-broad + scripts/universe/visual-qa/artifacts-chain-states retention-days: 30 diff --git a/.github/workflows/universe-production-smoke.yml b/.github/workflows/universe-production-smoke.yml index a889acc7225..9667131ec59 100644 --- a/.github/workflows/universe-production-smoke.yml +++ b/.github/workflows/universe-production-smoke.yml @@ -30,7 +30,10 @@ jobs: smoke: name: Live origin answers with real content runs-on: [self-hosted, linux-ultra] - timeout-minutes: 10 + # Ten was for a job that only read the API. It now installs the browser + # harness and loads two pages in a real browser, which is the only way to + # see what the origin renders. + timeout-minutes: 25 steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -42,3 +45,32 @@ jobs: - name: Every advertised feature has routes, data, and a matching release run: node scripts/universe/synthetic-check.mjs "${{ github.event.inputs.origin || 'https://explorer.bitcoinuniverse.io' }}" + + # The step above reads the API. It cannot see what the origin renders, and + # for as long as it could not, production served a chain dashboard whose + # status line ran six facts together behind a raw snapshot identifier, + # never said why Dogecoin was degraded, and published "Release + # development" to the public, while every check here passed. The API was + # answering correctly the whole time; the origin was serving an older + # build than anything measured it. + - name: Install the visual QA harness + working-directory: scripts/universe/visual-qa + run: npm ci + + - name: The gate can still tell a bad page from a good one + working-directory: scripts/universe/visual-qa + run: node --test chain-page-audit.test.mjs + + - name: The public chain pages say what the chain documents say + working-directory: scripts/universe/visual-qa + run: >- + node chain-page-smoke.mjs + --origin="${{ github.event.inputs.origin || 'https://explorer.bitcoinuniverse.io' }}" + + - name: Keep what the chain pages looked like + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: universe-chain-page-smoke + path: scripts/universe/visual-qa/artifacts-chain-smoke + retention-days: 30 diff --git a/.github/workflows/universe-release-artifact.yml b/.github/workflows/universe-release-artifact.yml index 068a6af965f..ed6d894b1f9 100644 --- a/.github/workflows/universe-release-artifact.yml +++ b/.github/workflows/universe-release-artifact.yml @@ -74,6 +74,9 @@ jobs: - name: No em dash in what the build actually ships run: node scripts/universe/check-text.mjs frontend/dist + - name: The component identity check can still fail + run: node --test scripts/universe/release-manifest.test.mjs + # release.sh reads the frontend from frontend/build, the backend from # backend/dist, and the gateway from scripts/universe. node_modules and # the compiled gbt module are not packed: install hard-links them from the @@ -102,11 +105,19 @@ jobs: mkdir -p "$stage/production" cp -a production/linux "$stage/production/linux" test -f "$stage/production/linux/universe-explorer-gateway.socket" + # What this release is, written down once and carried with it. The + # frontend, the explorer backend and the gateway in this artifact are + # one commit, and stating so is what makes an origin serving two of + # them from different releases a detectable fault rather than an open + # question. release.sh refuses an install whose manifest names a + # different commit, and the cutover holds the live origin to it. + node scripts/universe/release-manifest.mjs emit --commit='${{ steps.sha.outputs.sha }}' --out="$stage/RELEASE-MANIFEST.json" + test -f "$stage/RELEASE-MANIFEST.json" test -f "$stage/backend/dist/index.js" test -f "$stage/frontend/build/index.html" test -f "$stage/scripts/universe/gateway.mjs" out="$PWD/mempool-$sha.tar.gz" - tar -czf "$out" -C "$stage" backend frontend scripts production + tar -czf "$out" -C "$stage" backend frontend scripts production RELEASE-MANIFEST.json sha256sum "$out" | tee "$out.sha256" printf 'name=mempool-%s\n' "$sha" >> "$GITHUB_OUTPUT" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e43bda9443..27c99b202a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,87 @@ +# Contributing to Universe Explorer + +This repository is Bitcoin Universe's fork of the Mempool Open Source Project, +released under the AGPL. Contributions to it are contributions to this fork. +The upstream project's own contribution terms are preserved further down, and +they govern work you submit to that project rather than to this one. + +## Where work happens + +`develop` is the working branch. Open a pull request against it. `main` is the +released branch and is protected: it only ever moves through a promotion from +`develop`. + +The issue tracker is turned off, so a pull request is the only durable place to +raise something. That is deliberate: a change, its reasoning, its review, and +its checks stay in one record. Open one even for a report you cannot fix +yourself, with an empty diff if you have nothing to change yet. + +## Scope conventions + +Universe changes live in `frontend/src/app/universe/`, `scripts/universe/`, and +`docs/`, and reach the inherited application through a small number of +documented integration points. Keeping them there is what makes upstream +security fixes easy to take. `UPSTREAM.md` records every subsystem this fork +modifies and the known conflict points, and +`docs/operations/UPSTREAM-SYNC.md` is the synchronization procedure. + +## What the checks hold you to + +Run these before opening a pull request. They are the same ones +`.github/workflows/universe-ci.yml` runs. + +```bash +cd frontend && npm ci && npm run lint && npm run test && npm run build:universe +cd backend && npm ci && npm run lint && npm run test:ci +node scripts/universe/generate-protocol-coverage.mjs --check +node scripts/universe/check-text.mjs +node scripts/universe/check-colors.mjs +node scripts/universe/check-palettes.mjs +node scripts/universe/check-fills.mjs +node scripts/universe/check-branding.mjs +node scripts/universe/check-origins.mjs +node --test scripts/universe/gateway.test.mjs +``` + +Four of them are easy to trip by accident, so they are enforced rather than +remembered: + +- **No em dash.** Use a colon, a comma, or two sentences. The word "canonical" + is also out of the vocabulary here. +- **No raw interface colour.** Everything comes from the tokens in + `frontend/src/styles/_universe-tokens.scss`, including in style bindings. + Every strong fill declares the ink that goes on it. +- **No third-party data origin.** Not in the source, not in the built bundle, + not as a fallback. `docs/data/ASSET-EVIDENCE.md` explains why. +- **No obsolete upstream product mark** outside the allowlist recorded in + `docs/legal/TRADEMARK-AUDIT.md`. + +## If you add or change a page + +Add it to the visual matrix in `scripts/universe/visual-qa/`. A route with no +fixture is a route no screenshot, contrast probe or unfinished-page check ever +looks at, and every defect this product has shipped visually was on a surface +in exactly that position. `docs/product/DESIGN-SYSTEM.md` has the account of +what that has cost, at the end. + +## Writing + +`docs/product/DESIGN-SYSTEM.md` has the rules the copy is held to. The short +version: say what is true and no more, distinguish "there is none" from "we +could not tell", keep exact figures exact, and explain a term at the moment it +matters rather than in a glossary. + +## Reporting a vulnerability + +Through GitHub's private vulnerability reporting for this repository: +[open a report](https://github.com/bitcoinuniverseio/mempool/security/advisories/new). +Not in a pull request, and not in a commit message, because both are public as +soon as they are written. [SECURITY.md](SECURITY.md) has what to include and +what is in scope, and `docs/security/THREAT-MODEL.md` records the trust +boundaries this deployment assumes. + +--- + # Contributing to The Mempool Open Source Project Thank you for contributing to The Mempool Open Source Project managed by Mempool Space K.K. (“Mempool”). @@ -47,5 +131,3 @@ Should You wish to submit work that is not Your original creation, You may submi ### 8. Notifications You agree to notify Mempool of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect. - -EOF diff --git a/README.md b/README.md index f50150b732f..2cee2d30e28 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,105 @@ # Universe Explorer -The Bitcoin Universe mempool and block explorer. It shows what is happening on -Bitcoin right now, and what transactions actually do across Bitcoin Universe -protocols, with the evidence behind every claim. +A block and mempool explorer for Bitcoin, Dogecoin, and Zcash that also reads +the asset protocols carried on them, and states the evidence behind every claim +it makes. Live at [explorer.bitcoinuniverse.io](https://explorer.bitcoinuniverse.io). +![A Bitcoin transaction page. Above the fold: the projected and confirmed +blocks, the transaction identifier with its confirmation count, fee and fee +rate. Below: a Universe Asset Flow panel marked Complete evidence, showing a +Runes transfer from one input to one output with the exact quantities and a +link to the evidence behind each side.](docs/product/screenshots/transaction-asset-flow.png) + ## What makes it different -Most explorers answer one of two questions. A mempool explorer tells you what -is pending and what it will cost to confirm. A protocol explorer tells you what -assets exist. Universe Explorer answers both in one place, and it never guesses. +Most explorers answer one of two questions. A mempool explorer tells you what is +pending and what it will cost to confirm. A protocol explorer tells you what +assets exist. Universe Explorer answers both, on three chains, and it never +guesses. - **Exact asset flows.** A transaction page names the inputs and outputs that carry protocol assets, the action the authority reported, and the block that proves it. Nothing is inferred from transaction shape. - **Outputs are first class.** `/outpoint/:txid/:vout` is a real page. An output - is the unit that carries assets on Bitcoin, so it gets its own address. + is the unit that carries assets, so it gets its own address. - **States that mean something.** Proven, partly proven, outside coverage, pending, and unavailable are five different answers. A missing indexer never - becomes a false zero. + becomes a false zero, and an authority that did not answer is never reported + as one that answered nothing. +- **Every page says how current it is.** Chain pages carry a status rail with + the same five readings in the same order: chain state, chain tip, blocks + behind the tip, when the reading was taken, and how complete the pending set + is. A figure that is true as of a block that is not the tip says so. +- **A verdict never travels without its evidence.** When a chain says it is not + ready, the page says which part is missing, in English rather than in the + API's codes: whose node did not answer, which indexer is behind, which index + was built without a protocol switched on. A working authority that states the + edges of its coverage is shown as exactly that, not as something broken. A + chain that withholds readiness and gives no reason is reported as having + given none. +- **Exact numbers, end to end.** Amounts cross the API as decimal strings and + are shifted by string arithmetic, never parsed into a floating point number. + Every rendered figure keeps the exact value it came from. - **Live protocol activity, measured.** The pulse page publishes its own denominator: how many arriving transactions were checked, and how many carried each protocol. Every number on it can be reproduced. -- **No trackers, no accounts.** Search is matched in your browser. Saved pages - and history live in local storage and never leave the device. +- **No trackers, no accounts.** What you type is classified on your device. + The searches that do need the server, completing a partly typed address and + searching Dogecoin, Zcash or every chain at once, go to our own backend and + to nobody else's. Saved pages and history stay in local storage. - **First-party data only.** Every figure comes from Bitcoin Universe's own - node, Electrum index, and protocol authorities. + nodes, indexes, and protocol authorities. + +## Three chains, and what each can answer + +Chains are not interchangeable, and the interface does not pretend they are. The +chain selector switches the whole explorer, and each chain declares what it can +answer at `/api/v1//status`. The overview page renders that declaration +rather than offering a page for a lookup the indexer never claimed. + +| | Bitcoin | Dogecoin | Zcash | +| --- | --- | --- | --- | +| Transaction, block, address, outpoint | yes | yes | yes | +| Pending transactions | yes | yes | yes | +| Projected blocks | yes | no | no | +| Fee unit | sat/vB | per kilobyte | ZIP-317 logical actions | +| Protocol families | Ordinals, Rare Sats, Runes, and the rest of the registry | Doginals, DRC-20, Doge TAP | Zerdinals, ZRunes, ZRC-20 | + +Three things follow from that table, and they are visible on the pages: + +- Dogecoin fees are quoted per kilobyte. The explorer prints the unit the chain + actually uses and shows a fee rate only when the first-party source reports + one. Nothing is relabelled as sat/vB. +- Zcash fee guidance follows ZIP-317 logical actions where the node reports + them, because a transaction's cost there is not a function of its byte size. +- Only the transparent side of Zcash is public. A Zcash transaction page reports + the shape of the shielded side, how many Sprout joinsplits, Sapling spends and + outputs, and Orchard actions it contains, and the one amount the chain itself + makes public: the net movement between the transparent and shielded pools. It + never infers a shielded sender, recipient, or amount. + +Object identifiers do not carry between chains, so switching chains from an +object page lands on that chain's overview and says why, rather than looking up +a Bitcoin transaction id on Dogecoin. + +![The Dogecoin overview page. The chain switcher in the header reads DOGE +Dogecoin, Ready. A status rail of five labelled readings gives Chain: Ready, +Chain tip: block 5,623,041, Behind tip: zero blocks, Last observed: 20 seconds +ago, Pending coverage: Complete. Below it a panel titled How much history is +readable now reads confirmed history complete, address history complete, +protocol history partial. A panel titled Questions this chain can answer marks +transaction, block, address and outpoint lookup and fee estimates as offered, +and projected blocks as not offered. A protocol indexers panel lists Doginals +and DRC-20 as ready with complete history, and TAP on Doge as degraded with +partial history, 184 blocks +behind.](docs/product/screenshots/dogecoin-overview.png) + +Both screenshots are taken by the review harness in +`scripts/universe/visual-qa`, against fixed review data rather than the live +chain, so they show the interface exactly as it renders and no figure in them is +a claim about real chain activity. ## Protocol coverage @@ -37,14 +110,21 @@ explorer states plainly which ones it can actually read. | --- | --- | | Live, read only | A first-party authority is running and its evidence is shown. | | Not yet available | No first-party authority for it is configured or answering here. The explorer makes no claim about it. | -| Different chain | The protocol lives on a chain this explorer does not serve. | -Live today, backed by the first-party Ord 0.29 authority: **Ordinals**, +Readable today, backed by the first-party Ord 0.29 authority: **Ordinals**, **Rare Sats**, **Runes**. +Readable is not the same as current, and the product never conflates them. An +authority that is rebuilding its index is still answering, and every page that +uses it states how far behind the chain tip it is rather than presenting its +answers as the present. `/api/v1/universe/sources` publishes the same figure, +and the production smoke check in +`.github/workflows/universe-production-smoke.yml` reads it on a schedule. + `docs/protocols/PROTOCOL-COVERAGE.md` is generated from the registry and lists -every entry with its authority. Run `node scripts/universe/generate-protocol-coverage.mjs --check` -to verify the table still matches. +every entry with its authority and its release status. Run +`node scripts/universe/generate-protocol-coverage.mjs --check` to verify the +table still matches the recorded manifest. ## Architecture @@ -53,12 +133,42 @@ Three processes behind one HTTPS origin: | Component | What it is | | --- | --- | | Explorer backend | `backend/` in this repository. Reads Bitcoin Core, Fulcrum, and MariaDB. | -| Protocol overlay | `backend-apis` standalone service. Serves `/api/v1/universe/*` and holds every indexer credential server side. | +| Protocol overlay | `backend-apis` standalone service. Serves `/api/v1/universe/*` and the chain-domain surfaces, and holds every indexer credential server side. | | Frontend | `frontend/`, an Angular application served as static files with SPA fallback. | -The browser never talks to an indexer, and no indexer origin or credential is -ever exposed to it. See `docs/architecture/` for the overlay design and -`docs/data/ASSET-EVIDENCE.md` for the evidence contract. +`scripts/universe/gateway.mjs` is the single public entry point. It serves the +built frontend and splits `/api` between the two backends by path prefix. + +```mermaid +flowchart LR + browser["Browser"] + gateway["Gateway
scripts/universe/gateway.mjs"] + backend["Explorer backend
backend/"] + overlay["Protocol overlay
backend-apis"] + core[("Bitcoin Core
Fulcrum, MariaDB")] + doge[("Dogecoin node
and confirmed history")] + zec[("Zcash node")] + auth[("Protocol indexers
ord, ord-dogecoin,
doge-tap, zcash-metaprotocols")] + + browser -->|"everything else"| gateway + gateway --> backend + gateway -->|"/api/v1/universe/*
/api/v1/chains
/api/v1/bitcoin/*, /dogecoin/*, /zcash/*"| overlay + backend --> core + overlay --> core + overlay --> doge + overlay --> zec + overlay --> auth +``` + +Everything the browser can reach is on the left of the gateway. Every node, +index and credential is on the right of the overlay, and no indexer origin or +bearer token is ever exposed to a page. Lookalike paths such as +`/api/v1/chainstats` stay on the explorer backend. The route table is recorded +in `docs/operations/DEPLOYMENT.md` and held by +`node --test scripts/universe/gateway.test.mjs`. + +`docs/architecture/` has the overlay design and `docs/data/ASSET-EVIDENCE.md` +the evidence contract. ## First-party data policy @@ -72,6 +182,23 @@ appears in the source or in a production bundle. The production build also skips asset synchronization, so nothing is downloaded from a third party at build time either; mining pool logos fall back to the bundled default. +## Design system + +One set of tokens in `frontend/src/styles/_universe-tokens.scss` carries colour, +type, space, motion, and layer for the whole product, in a light theme, a dark +theme, and a forced-contrast theme. Three rules hold it together, and each one +is measured rather than trusted: + +- Pink is the brand and only the brand. Nothing pink ever means "it worked". +- Colour carries evidence state, never protocol identity on its own. A protocol + hue always appears beside the protocol's name; an evidence state always + carries its word, so neither depends on anyone noticing a hue. +- A fill and a label are different roles. Every strong fill declares the ink + that goes on it, and `check-fills.mjs` fails the build if one does not. + +`docs/product/DESIGN-SYSTEM.md` is the full account. The gates that hold it are +in the testing section below. + ## Local development Requires Node 24.19.0 and npm 11.17.0, both pinned in `.nvmrc` and @@ -92,15 +219,37 @@ instead, set `MEMPOOL_BACKEND` in `frontend/proxy.conf.json`. ```bash cd frontend && npm run build:universe # production build, no third-party fetches -cd frontend && npm run test # Universe unit suite +cd frontend && npm run test # Universe unit suite cd frontend && npm run lint cd backend && npm run test:ci && npm run lint node scripts/universe/generate-protocol-coverage.mjs --check node scripts/universe/check-text.mjs # no em dash anywhere +node scripts/universe/check-colors.mjs # no raw interface colour +node scripts/universe/check-palettes.mjs # measured contrast, both themes +node scripts/universe/check-fills.mjs # every fill declares its ink node scripts/universe/check-branding.mjs # no obsolete upstream marks node scripts/universe/check-origins.mjs # no third-party data origins +node --test scripts/universe/gateway.test.mjs # public route table ``` +The visual matrix drives the built application across every route, theme, +viewport, and data state, and fails the run when a page never finishes loading, +when a chart panel draws nothing, or when a failure state says nothing about +why. Those three faults passed every other check in the list above and shipped +once, which is why the matrix exists. + +```bash +cd frontend && npm run build:universe +UNIVERSE_GATEWAY_PORT=8099 UNIVERSE_GATEWAY_ROOT=frontend/dist/mempool/browser \ + node scripts/universe/gateway.mjs & +cd scripts/universe/visual-qa && npm ci +node capture.mjs --base=http://127.0.0.1:8099 --routes=dogecoin,dogecoin-tx +``` + +Run it with no `--routes` for the full matrix. Every API call is answered from +the fixtures in `fixtures.mjs` and `chain-fixtures.mjs`, so a difference between +two runs means the interface changed, not that the chain moved. + The same checks run in `.github/workflows/universe-ci.yml` on the self-hosted runner fleet. The branding and origin gates also accept a built bundle path, and the release workflow runs them against `frontend/dist` before anything ships. @@ -150,13 +299,22 @@ upstream security fixes stay easy to take. ## Security -Report a suspected vulnerability privately to the Bitcoin Universe security -contact rather than opening a public issue. `docs/security/THREAT-MODEL.md` -records the trust boundaries this deployment assumes. +Report a suspected vulnerability through GitHub's private vulnerability +reporting for this repository: +[open a report](https://github.com/bitcoinuniverseio/mempool/security/advisories/new). +It stays private to the maintainers. Do not put one in a pull request, which is +public the moment it is written. [SECURITY.md](SECURITY.md) has what to include +and what is in scope, and `docs/security/THREAT-MODEL.md` records the trust +boundaries this deployment assumes. ## Contributing Work happens on `develop`. Open a pull request against it, keep Universe changes inside `frontend/src/app/universe/` and the documented integration points where -possible, and make sure the checks above pass. [CONTRIBUTING.md](CONTRIBUTING.md) -covers the details inherited from upstream. +possible, and make sure the checks above pass. + +This repository has its issue tracker turned off, so a pull request is how +anything gets raised, discussed, and recorded. Open one even for a report you +cannot fix yourself: describe what you saw and where, and leave the diff empty +if you have nothing to change yet. [CONTRIBUTING.md](CONTRIBUTING.md) has the +rest. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000000..d5ddfe33bfb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,45 @@ +# Reporting a vulnerability + +Use GitHub's private vulnerability reporting for this repository: + +**https://github.com/bitcoinuniverseio/mempool/security/advisories/new** + +It is enabled here, it is private to the maintainers until an advisory is +published, and it is the only reporting channel this project has. This +repository's issue tracker is turned off, so there is no public place to file +one by accident. + +Please do not put a suspected vulnerability in a pull request, a commit +message, or a pull request comment. All three are public the moment they are +written. + +## What to include + +Enough to reproduce it, and nothing more than you are comfortable sending: + +- what you did, against which URL or endpoint, and what happened; +- what you expected instead, and why the difference matters; +- the commit or the deployed build if you know it. The build the public site is + serving is in `GIT_COMMIT_HASH` at + [/resources/config.js](https://explorer.bitcoinuniverse.io/resources/config.js). + +No proof of exploitation is needed. A clear description of the flaw is enough, +and we would rather you stop at the point where you are confident than go +further to demonstrate impact. + +## What is in scope + +This repository is the explorer: the Angular frontend, the backend it is forked +from, the gateway in `scripts/universe/`, and the deployment at +[explorer.bitcoinuniverse.io](https://explorer.bitcoinuniverse.io). + +`docs/security/THREAT-MODEL.md` records the trust boundaries this deployment +assumes, and is the right thing to read first if you are unsure whether +something is a finding or a stated boundary. The explorer reads from +first-party nodes and indexes only, and a report about one of those services +belongs here too, because this is where the boundary is documented. + +Findings in the upstream project that this fork inherits unmodified are better +sent to [mempool/mempool](https://github.com/mempool/mempool) as well, so the +fix reaches everyone running it. `UPSTREAM.md` records which subsystems this +fork changes and which it takes as they are. diff --git a/docs/operations/DEPLOYMENT.md b/docs/operations/DEPLOYMENT.md index 981d5c9c519..6da300c690e 100644 --- a/docs/operations/DEPLOYMENT.md +++ b/docs/operations/DEPLOYMENT.md @@ -166,7 +166,8 @@ suite can see that. hard-links the dependency tree from the release in use. A release directory is never overwritten in place. 4. `universe-explorer-release preflight ` runs the gates and changes - nothing. It refuses a release whose build is incomplete, whose + nothing. It refuses a release whose build is incomplete, whose manifest is + missing or names a different commit from the one being installed, whose configuration would advertise a feature it cannot serve, whose database does not answer, whose source registry does not parse or names a token variable that is missing, or where a protocol the registry calls readable has no @@ -216,13 +217,26 @@ suite can see that. An upstream that is genuinely gone is still reported as a gateway failure within a few seconds, so a real outage is never hidden behind a long wait. After the swap it reads `/api/v1/capabilities` and fails the release if any feature is enabled with no routes registered, which - is exactly the state that shipped. A failed verification rolls back. + is exactly the state that shipped. It then holds the three component + identities to `RELEASE-MANIFEST.json`, which is where a frontend and a + backend from different releases, or an overlay that cannot name itself, stop + the cutover. A failed verification rolls back. 6. Run `node scripts/universe/synthetic-check.mjs` against the public origin. It asks the live endpoints with nothing mocked and fails on an empty range, a protocol advertised as readable whose authority cannot answer, a - configured authority with no checkpoint, or a frontend and backend on - different builds. -7. Keep the previous release directory until the stability window closes. + configured authority with no checkpoint, a frontend and backend on different + builds, or a chain document that cannot name the overlay release behind it. +7. Run `node scripts/universe/visual-qa/chain-page-smoke.mjs --release=` + against the public origin. Step 6 reads the API and cannot see what the + origin renders, which is how a release that was never promoted went on + serving an obsolete chain dashboard while every check passed. This one opens + `/dogecoin` and `/zcash` in a browser and holds each page to its own + capability document: a labelled status rail, a readable explanation whenever + the chain says it is not ready and none when it says it is, the three history + coverage dimensions, no whole snapshot identifier in the primary interface + and the whole one filed under the technical details, next actions that + resolve, and the frontend commit this release expected. +8. Keep the previous release directory until the stability window closes. Rolling back to a release from before the socket handover needs the port back, because such a gateway opens 8099 itself and dies on bind while systemd holds @@ -313,10 +327,61 @@ on the same host is a different product and is not part of this stack. ## Release identity -Every deployment publishes what it is running: the backend reports `gitCommit` -on `/api/v1/backend-info`, and the public `/source` page renders the release -SHA, the pinned upstream base, the licence, and the source repository link. A -build whose SHA is not published must not ship. +Three components run here and each publishes its own commit. They are allowed +to differ, and reading one as another is how the last identity defect went +unnoticed for as long as it did. + +| Component | Where it publishes | Release directory | +| --- | --- | --- | +| frontend | `GIT_COMMIT_HASH` in `/resources/config.js`, and the `/source` page | `releases/mempool-` | +| explorer backend | `gitCommit` on `/api/v1/backend-info` | the same directory | +| protocol overlay | `release.sha` on every `/api/v1//status` | `releases/backend-apis-` | + +A build whose SHA is not published must not ship. + +Every artifact carries `RELEASE-MANIFEST.json` at its root, generated from the +commit being built by `scripts/universe/release-manifest.mjs`. It names the one +commit the frontend, the explorer backend and the gateway in that artifact all +come from, and the contract versions this frontend reads. It deliberately does +not pin the overlay commit: the overlay is built from another repository on its +own release train, so what the manifest requires of it is the contract and an +identity it can state, and the commit it reports is recorded rather than +required. + +```bash +node scripts/universe/release-manifest.mjs verify --manifest=/opt/universe-explorer/current/RELEASE-MANIFEST.json --origin=https://explorer.bitcoinuniverse.io +``` + +`release.sh` runs the same check against the loopback gateway before the +cutover is allowed to stand. + +The overlay's identifier shipped as the literal string `development` and served +that to the public. Two faults produced it, and both are worth knowing because +neither was visible from anything that was being checked: + +- Nothing supplied the value. `universe-explorer-overlay.service` reads + `/etc/universe-explorer/overlay.env`, no example file mentioned + `UNIVERSE_EXPLORER_RELEASE_SHA`, and no install step wrote it. Meanwhile every + release directory already carried a `RELEASE-SHA` file naming its commit. +- The absence was survivable. A missing identity fell back to a placeholder and + the service started, so the only signal was a word on a public page. + +The overlay now reads its own release directory's `RELEASE-SHA` when the +variable is absent, which is an identity that cannot drift from the artifact, +and with `NODE_ENV=production` it refuses to start when neither is a commit. +Installing an overlay release therefore has one requirement beyond unpacking it: + +```bash +printf '%s +' "" > /opt/universe-explorer/releases/backend-apis-/RELEASE-SHA +ln -sfn /opt/universe-explorer/releases/backend-apis- /opt/universe-explorer/current-overlay.new +mv -Tf /opt/universe-explorer/current-overlay.new /opt/universe-explorer/current-overlay +systemctl restart universe-explorer-overlay +``` + +`synthetic-check.mjs` fails the release when any chain document reports +`development`, reports something that is not a commit, or when the three chains +disagree about which overlay answered. ## The edge diff --git a/docs/product/DESIGN-SYSTEM.md b/docs/product/DESIGN-SYSTEM.md index 3dfd0e5b1db..90e21d8211a 100644 --- a/docs/product/DESIGN-SYSTEM.md +++ b/docs/product/DESIGN-SYSTEM.md @@ -202,6 +202,45 @@ Every state token comes with a `-surface` and a `-border` so a status treatment is a tinted, bordered, worded object rather than coloured text. That keeps it legible in greyscale, in print, and under forced colours. +**The distinction has to survive the trip to get here.** These five states are +the product's whole claim, and an interface can only show a difference the code +feeding it preserved. On 29 August 2026 this shape was found again and again +through a single release, across the product, the infrastructure, the release +tooling and these gates: somewhere, "I do not know" and "I know, and the answer +is no" were the same value. The count is deliberately not given, because it +kept rising while this was being written, which is the more useful fact about +it. + +An indexer publishing `block_count` where the reader expected `blockCount` was +read as publishing no checkpoint. A WebSocket handshake sent with no `Origin` +header was read as permitted, so every probe passed and every browser was +refused. A field-name pattern matching `supply` read a token quantity as a coin +amount. A Bitcoin node answering "no such block" was read as a failure, so a +healthy chain was reported unavailable on every search. + +The worst of them were in the machinery meant to protect all of this, which is +where the shape is most expensive because its failure mode is silence. GitHub +reports a pull request as `CLEAN` whether its checks passed, failed, or never +ran, and a promotion waiter reading that would have pushed an unvalidated commit +to `main`. The contrast probe in `scripts/universe/visual-qa` recorded an empty +result when it threw, which is what a clean page also produces, so a run that +measured nothing printed the same zero as a run that measured everything. And +`check-text.mjs` and `check-branding.mjs`, pointed at a `frontend/dist` that +exists and is empty, which is its state for the whole of a build, read no files +and printed the sentence a clean output prints. Those two guard the release +artifact. + +All three now say when they measured nothing, and fail rather than pass. A gate +is allowed to find nothing. It is not allowed to look at nothing and report it +the same way. + +So the rule is not only about rendering. Anywhere this product reads something +it does not fully control, a payload, a header, a field name, a status, it must +be able to say which of the five it has. If an unrecognised input and a +definitive negative produce the same value, the interface downstream cannot +recover the difference, and it will state one of them as the other with the +full confidence of a measured colour and a word. + ### Themes **Light is the primary experience.** It is defined on `:root` in `styles.scss`, @@ -226,6 +265,19 @@ anchor rather than a washed-out outline, and it means a fee colour reads identically whichever theme someone is using. Text printed on a block face takes block ink, never page ink. +### Chains carry a mark, not a meaning + +A chain gets a small round mark beside its name, drawn from the protocol scale +(`--universe-protocol-*`). That scale is already held apart from every evidence +state and from the brand, and it is measured rather than judged, so a chain mark +can never be mistaken for a verdict. The mark never appears without the chain's +name beside it, which is the same rule protocol hues follow. + +Chain identity is otherwise carried by words and by the chain selector, not by +repainting the product. Dogecoin does not turn the interface gold and Zcash does +not turn it yellow: the tokens are the same on all three chains, because the +evidence vocabulary has to mean the same thing everywhere. + ## Type A system text face and a system data face, both resolved from the platform. @@ -252,6 +304,74 @@ above it. A transaction page leads with the hash, not with the word Panels state what they are, where they lead, and one line on why it matters. The note is what turns a number into something a newcomer can act on. +## The status rail + +Every chain page opens with the same instrument: five readings, always the same +five, always in this order. + +| Reading | Answers | +|---|---| +| Chain | Is this chain answering at all | +| Chain tip | Which block is the present | +| Behind tip | How far back the figures on this page are true as of | +| Last observed | When the reading was taken | +| Pending coverage | How complete the unconfirmed set is | + +Three rules hold it. + +- **The rail never loses a reading.** A reading whose fact is missing says so in + its own place. A rail with a hole in it reads as a page still loading, which + is exactly the wrong thing to communicate when the answer is that a fact is + unavailable. +- **Any lag at all is `partial`, never `proven`.** Zero blocks behind is the + only reading that earns the proven treatment, because a page describing a past + state of the chain is a partly proven page whatever its other figures say. +- **Not offered and not stated take different treatments.** A capability the + chain declined is `unavailable`; a capability nobody could ask about is + `neutral`. Collapsing them would report an unreachable authority as one that + answered. +- **The Chain reading is the chain's own verdict, not its node's.** A chain + publishes `ready` for everything the explorer needs and `sync.state` for the + node's view of its own blocks, and they disagree: a chain whose node is + caught up while a protocol indexer is silent publishes `sync.state: ready` + and `ready: false`. The reading takes the stricter of the two, so the rail + can never say Ready about a chain that has just said it is not. + +### Saying why + +The rail states a verdict in one word. Directly beneath it, and only when the +chain says it is not ready, the page states the evidence for that verdict: the +codes from `degradedReasons`, read into sentences by +`multichain-explorer/chain-reasons.ts`. + +- The table there is an **allowlist**, not a pattern. A pattern would match a + code it has never seen and assert a meaning for it. +- A code carried by a source that is **ready** is a stated edge of its + coverage, not a fault, and appears under its own heading rather than in the + colour that means something is broken. `tap_doge` publishes two while ready + and complete. +- A code with **no sentence in this build** keeps its own words and is marked + as one this build has no description for. +- A chain that withholds readiness and **states no reason** is reported as + having stated none. An empty space where the explanation belongs reads as a + chain that is fine. + +## Numbers + +Amounts cross the API as exact decimal strings and are never parsed into a +JavaScript number anywhere in the presentation path. Grouping and the decimal +shift are string operations, and every rendered figure keeps the exact source +string on its `title`, so the value a reader copies is the value the authority +sent. + +The rule that decides whether a figure may be shifted at all is an allowlist of +the fields that carry the chain's own coin, not a pattern over field names. A +pattern that matched `supply` once printed a DRC-20 token supply of +`100000000000` as `1,000 DOGE`, because a token's units are its own and have +nothing to do with koinu. Anything not on the allowlist is shown as the exact +integer it arrived as. Shifting an unknown quantity by eight places does not make +it more readable, it makes it wrong. + ## Writing Precise, direct, calm under uncertainty. Say what is true and no more. @@ -281,6 +401,8 @@ WCAG 2.2 AA is the floor, checked automatically rather than remembered. colour so a brand-coloured control still shows one. - Colour is never the only signal. - No critical route overflows horizontally at 320px. +- A region that scrolls with a mouse takes focus and shows it, so a wide table + is reachable from a keyboard. Three chain routes shipped without that. - Touch targets clear the minimum on the mobile navigation bar. `scripts/universe/visual-qa/capture.mjs` drives the whole route matrix across @@ -296,6 +418,8 @@ changed rather than the chain moving. | `frontend/src/styles/_universe-bootstrap-bridge.scss` | the inherited Bootstrap layer, repainted | | `frontend/src/theme-dark.scss`, `theme-contrast.scss` | the alternate themes | | `frontend/src/app/universe/_universe-tokens.scss` | Universe surface mixins, delegating to the tokens above | +| `frontend/src/app/universe/multichain-explorer/multichain-view.ts` | the chain presentation model: exact numbers, evidence tones, shape readings | +| `scripts/universe/visual-qa/chain-fixtures.mjs` | the chain states the matrix holds: at tip, behind tip, authority unreachable, object missing | | `scripts/universe/check-text.mjs` | the em dash gate, over source and over the built output | | `scripts/universe/check-colors.mjs` | no raw interface colour, including in style and attribute bindings | | `scripts/universe/check-palettes.mjs` | every dynamic palette, role separation, theme parity, retired values | @@ -319,3 +443,12 @@ the numbers were four orders of magnitude too small. A route that renders nothing passes every automated check. When a fixture is missing or wrong, the review it feeds is not weaker; it is absent, and it reports success. + +The sixth case was a whole surface rather than a defect. The Dogecoin and Zcash +routes shipped with no fixture and no route entry, so eleven pages reached +production without one screenshot, contrast probe or unfinished-page check ever +looking at them. They are in the matrix now, and the states they hold are the +ones the interface has to be honest about rather than the ones that are easy to +produce: a chain at its tip, a chain behind its tip, an authority that cannot be +reached, and an object that simply does not exist. Chain states are scoped to +chain routes, so the matrix does not pay for combinations that measure nothing. diff --git a/docs/product/EXPERIENCE-BRIEF.md b/docs/product/EXPERIENCE-BRIEF.md index 5bd8de7a13d..7d12de2d1f3 100644 --- a/docs/product/EXPERIENCE-BRIEF.md +++ b/docs/product/EXPERIENCE-BRIEF.md @@ -1,11 +1,12 @@ # Universe Explorer experience brief -Written 26 August 2026 to direct implementation. Every statement here is meant -to settle a build decision, not to describe an aspiration. +Written 26 August 2026 to direct implementation. Amended 29 August 2026, when +the product grew from one chain to three. Every statement here is meant to +settle a build decision, not to describe an aspiration. ## The promise -**See what a Bitcoin transaction actually does, with the proof attached.** +**See what a transaction actually does, with the proof attached.** Everything else follows from that sentence. If a screen cannot show what it proves, it does not ship. @@ -20,6 +21,46 @@ proves, it does not ship. | Analysts and researchers | "What is happening across protocols right now" | The pulse page, with a published denominator so the number can be checked. | | Operators self-hosting | "Can I run this and trust it" | One documented stack, first-party data only, source published on the site. | +## What three chains changed + +Bitcoin, Dogecoin, and Zcash are now all first-class in the same product. That +is one promise on three chains, not three products sharing a header, and five +rules follow from it. + +1. **Chains are not interchangeable, and the interface never pretends they + are.** Each chain declares what it can answer at `/api/v1//status`, + and the overview page renders that declaration. Where a chain does not offer + a lookup, the explorer offers no page for it rather than an empty one. +2. **Not offered and not stated are different answers.** An authority that + declined a read and an authority that never answered are not the same fact, + and a status rail that cannot reach its chain says so rather than reporting + six capabilities as absent. +3. **Units are the chain's own.** Dogecoin fees are quoted per kilobyte and are + never relabelled as sat/vB. Zcash fee guidance follows ZIP-317 logical + actions. A koinu is not a satoshi and a page that shifted one by the other's + precision would be printing a different number, not a friendlier one. +4. **Privacy is a product boundary, not a gap to fill.** Only the transparent + side of Zcash is public. A Zcash page reports the shape of the shielded side + and the single amount the chain itself makes public, the net movement between + the pools. It never infers a shielded sender, recipient, or amount, and it + never presents that limit as a temporary one. +5. **Identifiers do not carry between chains.** Switching chains from an object + page lands on that chain's overview and says why. Looking up a Bitcoin + transaction id on Dogecoin would produce a confident wrong answer. + +The status rail is the surface all five of these meet on. Five readings, always +the same five in the same order: chain state, chain tip, blocks behind the tip, +when the reading was taken, and how complete the pending set is. A reading whose +fact is missing says so in its own place rather than disappearing, because a +rail with a hole in it reads as a page still loading. + +A verdict there never travels alone. When a chain says it is not ready, the +page says which part is missing, in English rather than in the codes the API +uses, and separates a source that is failing from one that is working and +stating the edge of what it covers. A one-word verdict with the evidence +withheld is the shape of every other explorer, and it is the thing this one +exists not to be. + ## The emotional outcomes to design for 1. **Oriented in seconds.** Live data above the fold, one dominant search field, @@ -102,6 +143,6 @@ sighting, an unusual verified event. ## The one-sentence position -Universe Explorer is the only self-hostable, mempool-grade Bitcoin explorer that -shows exactly which outputs carry which protocol assets, with first-party -evidence and a stated block for every claim. +Universe Explorer is the only self-hostable, mempool-grade explorer for Bitcoin, +Dogecoin, and Zcash that shows exactly which outputs carry which protocol +assets, with first-party evidence and a stated block for every claim. diff --git a/docs/product/screenshots/dogecoin-overview.png b/docs/product/screenshots/dogecoin-overview.png new file mode 100644 index 00000000000..40e1a4ef51d Binary files /dev/null and b/docs/product/screenshots/dogecoin-overview.png differ diff --git a/docs/product/screenshots/transaction-asset-flow.png b/docs/product/screenshots/transaction-asset-flow.png new file mode 100644 index 00000000000..f609ce7f963 Binary files /dev/null and b/docs/product/screenshots/transaction-asset-flow.png differ diff --git a/frontend/src/app/components/master-page/master-page.component.html b/frontend/src/app/components/master-page/master-page.component.html index b0491913b47..c7653fc7f00 100644 --- a/frontend/src/app/components/master-page/master-page.component.html +++ b/frontend/src/app/components/master-page/master-page.component.html @@ -6,20 +6,17 @@ - - @if (enterpriseInfo) { -
- -
- @if (enterpriseInfo.dualLogo) { -
- } + @if (enterpriseInfo) { +
+ +
+ @if (enterpriseInfo.dualLogo) { +
} - @if (!enterpriseInfo || enterpriseInfo.dualLogo) { - - } -
+ } + @if (!enterpriseInfo || enterpriseInfo.dualLogo) { + + }
diff --git a/frontend/src/app/components/master-page/master-page.component.scss b/frontend/src/app/components/master-page/master-page.component.scss index c99eaa6ea63..42f726fbcfa 100644 --- a/frontend/src/app/components/master-page/master-page.component.scss +++ b/frontend/src/app/components/master-page/master-page.component.scss @@ -81,12 +81,6 @@ } } -// Dim the mark while the socket is down. Paired with the worded status beside -// it, never as the only signal. -.brand-dimmed { - opacity: 0.45; -} - .subdomain_logo { align-self: center; margin: auto; @@ -171,7 +165,12 @@ width: 22px; } -.dropdown-item { +// Scoped to the network picker on purpose. Its rows are an icon beside a +// label, which is what wants a flex row. Unscoped, this beat the chain +// switcher's own two-column grid, because a component style carries an +// attribute selector as well as the class, and collapsed every chain row +// into one line with its detail line running off the end. +.network-picker .dropdown-item { align-items: center; display: flex; } diff --git a/frontend/src/app/components/master-page/master-page.component.ts b/frontend/src/app/components/master-page/master-page.component.ts index 7636f8f252f..d90ded74b65 100644 --- a/frontend/src/app/components/master-page/master-page.component.ts +++ b/frontend/src/app/components/master-page/master-page.component.ts @@ -9,6 +9,12 @@ import { StorageService } from '@app/services/storage.service'; import { UniverseApiService } from '@app/universe/universe-api.service'; import { UniverseLocalService } from '@app/universe/universe-local.service'; import { ChainCapabilityEnvelope, ExplorerChain } from '@app/universe/universe.types'; +import { describeChainReasons } from '@app/universe/multichain-explorer/chain-reasons'; +import { + availabilityLabel, + completenessLabel, + formatExactInteger, +} from '@app/universe/multichain-explorer/multichain-view'; import { explorerChainFromUrl, explorerSectionRoute, @@ -171,14 +177,39 @@ export class MasterPageComponent implements OnInit, OnDestroy { } chainState(capability: ChainCapabilityEnvelope | undefined): string { - if (!capability) {return 'status unavailable';} - return capability.ready ? 'ready' : 'degraded'; + if (!capability) {return availabilityLabel(null);} + return capability.ready ? availabilityLabel('ready') : availabilityLabel('degraded'); } + /** + * The line under a chain's name in the switcher. + * + * It used to read "Tip 964557; mempool ready, complete" whether the chain + * said it was ready or not, so a chain marked degraded sat beside a sentence + * in which nothing was wrong, in the wire's words rather than in English. + * The reason is in the same document the verdict came from, so a degraded + * chain leads with it and a ready one keeps the tip and pending reading. + */ chainDetail(capability: ChainCapabilityEnvelope | undefined): string { - if (!capability) {return 'Tip and mempool unavailable';} - const tip = capability.tip?.heightAtomic ?? 'unknown tip'; - return `Tip ${tip}; mempool ${capability.mempool.state}, ${capability.mempool.completeness}`; + if (!capability) { + return $localize`:@@master-page.chain-status-unavailable:This chain did not report its status.`; + } + // Grouped the way the status rail groups it. A block height printed as a + // bare run of digits in one place and as 2,884,120 in another is the same + // fact in two voices, and the long one is where it is hardest to read. + const tip = formatExactInteger(capability.tip?.heightAtomic ?? null); + const height = tip + ? $localize`:@@master-page.chain-tip:Block ${tip.display}:HEIGHT:` + : $localize`:@@master-page.chain-tip-none:No tip reported`; + if (!capability.ready) { + const [first] = describeChainReasons(capability.degradedReasons ?? []); + return first ? `${height}. ${first.text}` : height; + } + if (!capability.mempool.supported) { + return height; + } + const coverage = completenessLabel(capability.mempool.completeness); + return $localize`:@@master-page.chain-ready-detail:${height}:BLOCK:. Pending coverage ${coverage}:COVERAGE:.`; } chainRoute(kind: 'dashboard' | 'mempool' | 'protocols'): string { diff --git a/frontend/src/app/components/privacy-policy/privacy-policy.component.html b/frontend/src/app/components/privacy-policy/privacy-policy.component.html index 8ec42bee577..73e84a17d11 100644 --- a/frontend/src/app/components/privacy-policy/privacy-policy.component.html +++ b/frontend/src/app/components/privacy-policy/privacy-policy.component.html @@ -11,10 +11,20 @@

What this explorer collects

Searches and browsing

- Whatever you type into the search box is matched in your browser first. The - explorer only asks its own backend for the specific transaction, block, - address, output, or asset you opened, and it never sends your query - anywhere else. + What you type is classified in your browser. Working out whether it looks + like a transaction, a block, an address, an output, or an asset happens on + your device and asks nobody. +

+

+ Some searches then need the server, and in those the text you typed does + reach us. Completing a partly typed address, searching on Dogecoin or + Zcash, and widening a search to every chain are all answered by our own + backend. Opening a result asks our backend for that one object. +

+

+ None of it reaches anyone else. There is no third-party search, + autocomplete, suggestion, or analytics call at any point, and no query is + tied to a visitor, because there is nothing here to tie it to.

What your browser stores

diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.scss b/frontend/src/app/components/transactions-list/transactions-list.component.scss index d604a980a3a..4b933ced150 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.scss +++ b/frontend/src/app/components/transactions-list/transactions-list.component.scss @@ -404,6 +404,11 @@ h2 { .accelerate { background-color: var(--tertiary); + // The badge had no foreground of its own, so it inherited the surrounding + // row's text colour over a filled purple chip. This is the paired + // foreground for both fills it wears: the lavender at rest and the brand + // colour the pulse animation paints. + color: var(--u-brand-contrast); margin-left: 5px; &.blink { @@ -416,4 +421,4 @@ h2 { 0% { background-color: var(--u-brand); box-shadow: var(--u-brand-hover) 0px 0px 12px -2px; } 50% { background-color: var(--u-brand); box-shadow: var(--u-brand-hover) 0px 0px 18px -2px;} 100% { background-color: var(--u-brand); box-shadow: var(--u-brand-hover) 0px 0px 12px -2px; } -} \ No newline at end of file +} diff --git a/frontend/src/app/services/seo.service.ts b/frontend/src/app/services/seo.service.ts index 30f56b09564..8be39248ddb 100644 --- a/frontend/src/app/services/seo.service.ts +++ b/frontend/src/app/services/seo.service.ts @@ -3,6 +3,10 @@ import { Title, Meta } from '@angular/platform-browser'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { filter, map, switchMap } from 'rxjs'; import { StateService } from '@app/services/state.service'; +import { + explorerChainFromUrl, + explorerChainName, +} from '@app/universe/universe-chain-routing'; @Injectable({ providedIn: 'root' @@ -10,7 +14,7 @@ import { StateService } from '@app/services/state.service'; export class SeoService { network = ''; baseTitle = 'Universe Explorer'; - baseDescription = 'Universe Explorer is the live protocol-aware Bitcoin explorer for the Bitcoin Universe ecosystem.'; + baseDescription = 'Universe Explorer is the live protocol-aware explorer for Bitcoin, Dogecoin, and Zcash in the Bitcoin Universe ecosystem.'; // Overwritten below from the canonical link in index.html; this is only the fallback. baseDomain = 'explorer.bitcoinuniverse.io'; @@ -98,6 +102,16 @@ export class SeoService { {return this.baseTitle + ' - Liquid Network';} if (this.network === 'liquidtestnet') {return this.baseTitle + ' - Liquid Testnet';} + // On mainnet the network is empty, which used to mean every page fell back + // to "Bitcoin Explorer". That put the wrong chain in the tab title, the + // shared link title, and the search result of every Dogecoin and Zcash + // page. The chain is in the URL, so the title can simply say which one. + if (!this.network) { + const chain = explorerChainFromUrl(this.router.url); + if (chain !== 'bitcoin') { + return this.baseTitle + ' - ' + explorerChainName(chain); + } + } return this.baseTitle + ' - ' + (this.network ? this.ucfirst(this.network) : 'Bitcoin') + ' Explorer'; } diff --git a/frontend/src/app/shared/components/global-footer/global-footer.component.html b/frontend/src/app/shared/components/global-footer/global-footer.component.html index 37b8a54fc50..39a14a1da31 100644 --- a/frontend/src/app/shared/components/global-footer/global-footer.component.html +++ b/frontend/src/app/shared/components/global-footer/global-footer.component.html @@ -11,7 +11,7 @@
@if (!enterpriseInfo?.footer_img) { } diff --git a/frontend/src/app/universe/multichain-explorer/chain-reasons.spec.ts b/frontend/src/app/universe/multichain-explorer/chain-reasons.spec.ts new file mode 100644 index 00000000000..80ae31784ce --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/chain-reasons.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + describeChainReason, + describeChainReasons, +} from '@app/universe/multichain-explorer/chain-reasons'; + +describe('describeChainReason', () => { + it('reads a fault as a sentence rather than a code', () => { + const reading = describeChainReason('base-chain-authority-unavailable'); + expect(reading.kind).toBe('fault'); + expect(reading.text).toContain('did not answer'); + expect(reading.text).not.toContain('-'); + }); + + it('separates a working authority stating its edges from one that is broken', () => { + // tap_doge publishes both of these while ready and complete. Read as + // faults they describe an outage that is not happening. + expect(describeChainReason('pending-protocol-coverage-unavailable').kind).toBe('limit'); + expect(describeChainReason('reorg-evidence-tail-only').kind).toBe('limit'); + expect(describeChainReason('protocol-authority-unavailable').kind).toBe('fault'); + }); + + it('keeps an unrecognised code, and does not claim to know what it means', () => { + const reading = describeChainReason('some-future-authority-problem'); + expect(reading.kind).toBe('unstated'); + expect(reading.text).toBe('Some future authority problem'); + expect(reading.code).toBe('some-future-authority-problem'); + }); + + it('says the index cannot serve a protocol at all, not that it is merely behind', () => { + // The flag is fixed when the index is created, so this is not something + // that heals by waiting, and the wording has to say so. + expect(describeChainReason('authority-capability-disabled').text).toContain('rebuilt'); + expect(describeChainReason('protocol-authority-stale').text).toContain('behind the chain tip'); + }); + + it('reads a list in the order it was given', () => { + expect( + describeChainReasons(['protocol-history-unavailable', 'reorg-evidence-tail-only']).map((r) => r.kind) + ).toEqual(['fault', 'limit']); + }); +}); diff --git a/frontend/src/app/universe/multichain-explorer/chain-reasons.ts b/frontend/src/app/universe/multichain-explorer/chain-reasons.ts new file mode 100644 index 00000000000..5e72939c17d --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/chain-reasons.ts @@ -0,0 +1,133 @@ +/** + * The words behind a chain's own verdict on itself. + * + * `/api/v1//status` states `ready` and, when that is false, a list of + * codes saying why. The codes are a machine contract, so they arrive as + * `protocol-history-unavailable` and the like. Printing them is a field dump, + * and dropping them leaves a verdict with no evidence behind it, which is the + * one thing this product must never do. + * + * Two further distinctions the raw list does not make on its own: + * + * - A code on a source that is *ready* is not a fault. `tap_doge` reports + * `pending-protocol-coverage-unavailable` and `reorg-evidence-tail-only` + * while ready and complete: they are the stated edges of what that authority + * covers, not something broken. Shown under a failure heading they read as + * an outage that is not happening. + * - A code this build has no sentence for is not the same as no code. It is + * shown, humanised, and marked as stated but not explained here, rather than + * silently dropped or given a meaning it may not have. + */ + +export type ChainReasonKind = + /** Something the explorer depends on is not answering, or is not current. */ + | 'fault' + /** A stated edge of what a working source covers. */ + | 'limit' + /** A code this build carries no sentence for. */ + | 'unstated'; + +export interface ChainReasonReading { + readonly code: string; + readonly text: string; + readonly kind: ChainReasonKind; +} + +interface ReasonCopy { + readonly text: string; + readonly kind: Exclude; +} + +/** + * An allowlist, deliberately. A pattern over these codes would match a new one + * it has never seen and assert a meaning for it, and the whole point of this + * table is that an unrecognised code stays unrecognised. + * + * Each sentence says what did not happen, not what it implies. The overlay + * derives these in `chain-capabilities.service.ts`; the wording here follows + * that derivation rather than paraphrasing the code name. + */ +const REASON_COPY: Record = { + 'base-chain-authority-unavailable': { + text: $localize`:@@universe.reason.base-chain-authority-unavailable:The node that serves this chain's own blocks and transactions did not answer.`, + kind: 'fault', + }, + 'confirmed-history-authority-unavailable': { + text: $localize`:@@universe.reason.confirmed-history-authority-unavailable:The source of confirmed history did not answer, so history older than the pending set cannot be read.`, + kind: 'fault', + }, + 'mempool-collector-unavailable': { + text: $localize`:@@universe.reason.mempool-collector-unavailable:The collector that watches pending transactions did not answer.`, + kind: 'fault', + }, + 'mempool-collector-degraded': { + text: $localize`:@@universe.reason.mempool-collector-degraded:The pending-transaction collector answered, but reports itself as not healthy.`, + kind: 'fault', + }, + 'mempool-collector-recent-failure': { + text: $localize`:@@universe.reason.mempool-collector-recent-failure:The pending-transaction collector's most recent attempt failed, so the set below may have missed arrivals.`, + kind: 'fault', + }, + 'mempool-coverage-partial': { + text: $localize`:@@universe.reason.mempool-coverage-partial:The pending set is a partial view of the node's mempool rather than all of it.`, + kind: 'fault', + }, + 'node-initial-block-download': { + text: $localize`:@@universe.reason.node-initial-block-download:The node is still downloading the chain for the first time, so it does not yet hold every block.`, + kind: 'fault', + }, + 'protocol-history-unavailable': { + text: $localize`:@@universe.reason.protocol-history-unavailable:At least one protocol indexer this chain needs is not answering, so protocol history cannot be read. Blocks, transactions and addresses are unaffected.`, + kind: 'fault', + }, + 'protocol-history-partial': { + text: $localize`:@@universe.reason.protocol-history-partial:Protocol history is readable but does not yet cover the whole chain.`, + kind: 'fault', + }, + 'protocol-authority-unavailable': { + text: $localize`:@@universe.reason.protocol-authority-unavailable:This protocol's indexer did not answer.`, + kind: 'fault', + }, + 'protocol-authority-stale': { + text: $localize`:@@universe.reason.protocol-authority-stale:This protocol's indexer answered, but its last checkpoint is behind the chain tip.`, + kind: 'fault', + }, + 'authority-capability-disabled': { + text: $localize`:@@universe.reason.authority-capability-disabled:The running index was built without this protocol switched on, so it cannot serve it at all until it is rebuilt.`, + kind: 'fault', + }, + 'pending-protocol-coverage-unavailable': { + text: $localize`:@@universe.reason.pending-protocol-coverage-unavailable:This authority reads confirmed blocks only. It says nothing about pending transactions.`, + kind: 'limit', + }, + 'reorg-evidence-tail-only': { + text: $localize`:@@universe.reason.reorg-evidence-tail-only:This authority keeps reorganisation evidence for recent blocks only, not for the whole chain.`, + kind: 'limit', + }, +}; + +/** + * A code with no sentence here, made readable without being interpreted. The + * words are the code's own: `protocol-authority-stale` becomes "Protocol + * authority stale" and nothing is added to it. + */ +function readableCode(code: string): string { + const words = code.replace(/[_-]+/g, ' ').trim(); + if (!words) { + return code; + } + return words.charAt(0).toUpperCase() + words.slice(1); +} + +/** One code, read. An unknown code keeps its own words rather than borrowing any. */ +export function describeChainReason(code: string): ChainReasonReading { + const copy = REASON_COPY[code]; + if (copy) { + return { code, text: copy.text, kind: copy.kind }; + } + return { code, text: readableCode(code), kind: 'unstated' }; +} + +export function describeChainReasons(codes: readonly string[]): readonly ChainReasonReading[] { + return codes.map(describeChainReason); +} diff --git a/frontend/src/app/universe/multichain-explorer/live-model-check.spec.ts b/frontend/src/app/universe/multichain-explorer/live-model-check.spec.ts new file mode 100644 index 00000000000..8eb011b0447 --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/live-model-check.spec.ts @@ -0,0 +1,188 @@ +// Drives the chain presentation model with payloads captured from the live +// public origin rather than written by hand. +// +// The hand-written fixtures encode what the API was expected to send. These +// encode what it actually sent, and the difference between the two is where +// three real design faults were hiding. Every assertion here is about an +// invariant the model must hold for whatever the chain returns, never about a +// value the chain will move: a lag of one block becomes two while you read it. +import { describe, expect, it } from 'vitest'; +import { LIVE_PAYLOADS } from '@app/universe/multichain-explorer/live-payloads'; +import { + chainProfile, + classifyPayload, + readCollection, + readProtocolCoverage, + readRecordFacts, + readStatusRail, + readTransaction, + readTransactionList, +} from '@app/universe/multichain-explorer/multichain-view'; + +const PAYLOADS: Record = { + dogecoin_status: LIVE_PAYLOADS.dogecoinStatus, + zcash_status: LIVE_PAYLOADS.zcashStatus, + dogecoin_mempool: LIVE_PAYLOADS.dogecoinMempool, + zcash_mempool: LIVE_PAYLOADS.zcashMempool, + dogecoin_protocols: LIVE_PAYLOADS.dogecoinProtocols, + zcash_protocols: LIVE_PAYLOADS.zcashProtocols, +}; +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- captured payloads are read as the API sends them +const load = (name: string): any => structuredClone(PAYLOADS[name]); + +const DOGE = chainProfile('dogecoin'); +const ZEC = chainProfile('zcash'); +const NOW = Date.parse('2026-08-29T08:30:00.000Z'); + +describe('live dogecoin status', () => { + const status = load('dogecoin_status'); + + it('fills all five rail readings from the real envelope', () => { + const rail = readStatusRail(status, DOGE, NOW); + expect(rail.map((r) => r.id)).toEqual(['state', 'tip', 'lag', 'freshness', 'mempool']); + expect(rail.every((r) => r.value && r.value.length > 0)).toBe(true); + }); + + it('reports a null lag as not stated rather than as zero', () => { + // Production sends lagBlocksAtomic: null on this chain right now. Reading + // that as zero would claim the page is describing the tip. + expect(status.lagBlocksAtomic).toBeNull(); + const lag = readStatusRail(status, DOGE, NOW).find((r) => r.id === 'lag'); + expect(lag?.tone).toBe('neutral'); + expect(lag?.exact).toBeNull(); + }); + + it('lists each protocol once, and offers no route to one it cannot open', () => { + const readings = readProtocolCoverage(status, DOGE); + const labels = readings.map((r) => r.label); + expect(new Set(labels).size).toBe(labels.length); + + // tap_doge in the envelope, doge-tap on the route. + const tap = readings.filter((r) => r.label === 'TAP on Doge'); + expect(tap).toHaveLength(1); + expect(tap[0].routeId).toBe('doge-tap'); + + // dunes is reported by the chain and has no page here. + const dunes = readings.find((r) => r.protocolId === 'dunes'); + expect(dunes).toBeDefined(); + expect(dunes?.routeId).toBeNull(); + }); +}); + +describe('live zcash status', () => { + it('reads any non-zero lag as partly proven, not as proven', () => { + const status = load('zcash_status'); + const lag = readStatusRail(status, ZEC, NOW).find((r) => r.id === 'lag'); + expect(lag?.exact).toBe(status.lagBlocksAtomic); + expect(lag?.tone).toBe(status.lagBlocksAtomic === '0' ? 'proven' : 'partial'); + }); +}); + +describe('live mempool payloads', () => { + for (const [name, profile] of [ + ['dogecoin_mempool', DOGE], + ['zcash_mempool', ZEC], + ] as const) { + it(`${name} reads as transactions, not as a table of field names`, () => { + const payload = load(name); + const list = readTransactionList(payload, profile); + expect(list).not.toBeNull(); + expect(list!.rows.length).toBeGreaterThan(0); + + for (const row of list!.rows) { + expect(row.txid).toMatch(/^[0-9a-f]{64}$/); + expect(row.statusLabel.length).toBeGreaterThan(0); + expect(['proven', 'partial', 'pending', 'unavailable', 'neutral']).toContain(row.statusTone); + } + }); + + it(`${name} keeps every reported cost exact`, () => { + const payload = load(name); + const list = readTransactionList(payload, profile)!; + for (const row of list.rows) { + if (row.fee) { + expect(row.fee.exact).toMatch(/^-?(0|[1-9][0-9]*)$/); + } + if (row.logicalActions) { + expect(row.logicalActions.exact).toMatch(/^(0|[1-9][0-9]*)$/); + } + } + }); + + it(`${name} picks a cost column its rows can actually fill`, () => { + const payload = load(name); + const list = readTransactionList(payload, profile)!; + if (list.costColumn === 'amount') { + expect(list.rows.some((row) => row.fee)).toBe(true); + } else if (list.costColumn === 'logical-actions') { + // No row reports an amount, so an amount column would have been empty. + expect(list.rows.every((row) => !row.fee)).toBe(true); + expect(list.rows.some((row) => row.logicalActions)).toBe(true); + } + }); + } + + it('does not fall through to the generic table for a transaction list', () => { + const payload = load('dogecoin_mempool'); + expect(classifyPayload(payload)).toBe('collection'); + expect(readTransactionList(payload, DOGE)).not.toBeNull(); + }); +}); + +describe('live transaction envelopes', () => { + it('every pending dogecoin transaction reads without loss', () => { + const payload = load('dogecoin_mempool'); + for (const raw of payload.transactions) { + const tx = readTransaction(raw, DOGE); + expect(tx).not.toBeNull(); + expect(tx!.txid).toBe(raw.txid); + // A total is only shown when every side carried an amount. + if (tx!.inputTotal) { + expect(tx!.inputs.every((input) => input.amount)).toBe(true); + } + } + }); + + it('a real zcash transaction reports shielded structure without participants', () => { + const payload = load('zcash_mempool'); + const shielded = payload.transactions.find( + (t: { shielded?: unknown }) => t.shielded + ); + if (!shielded) { + return; // none pending in this capture, which is itself a valid state + } + const tx = readTransaction(shielded, ZEC); + expect(tx!.shielded).not.toBeNull(); + expect(tx!.shielded!.notice.length).toBeGreaterThan(0); + expect(tx!.shielded!.components).toHaveLength(5); + }); +}); + +describe('live protocol manifests', () => { + for (const [name, profile] of [ + ['dogecoin_protocols', DOGE], + ['zcash_protocols', ZEC], + ] as const) { + it(`${name} renders as a table with no constant column wasting a slot`, () => { + const payload = load(name); + const collection = readCollection(payload, profile); + expect(collection).not.toBeNull(); + const keys = collection!.columns.map((c) => c.key); + // chain and schemaVersion are identical on every registry row. + expect(keys).not.toContain('schemaVersion'); + expect(keys).not.toContain('chain'); + }); + + it(`${name} leaves no scalar unreported`, () => { + const payload = load(name); + const collection = readCollection(payload, profile); + const facts = readRecordFacts(payload, profile, [ + 'chain', + 'network', + collection!.sourceKey, + ]); + expect(facts.length).toBeGreaterThan(0); + expect(facts.every((f) => f.label.length > 0)).toBe(true); + }); + } +}); diff --git a/frontend/src/app/universe/multichain-explorer/live-payloads.ts b/frontend/src/app/universe/multichain-explorer/live-payloads.ts new file mode 100644 index 00000000000..81f059a122d --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/live-payloads.ts @@ -0,0 +1,1599 @@ +/** + * Payloads captured from the live public origin, not written by hand. + * + * https://explorer.bitcoinuniverse.io at 2026-08-29T08:26Z, release + * mempool-8f6005ae0, overlay backend-apis-d1b34dbd. Only the pending + * transaction lists are trimmed, to four and five entries; nothing else is + * edited, reordered or tidied. + * + * They are here because the hand-written fixtures encoded what the API was + * expected to send and the real thing differs in ways that changed the + * design. Three of them, each found by these payloads and not by the + * fixtures: + * + * - the pending list carries whole transaction envelopes, not summary rows, + * so a generic table spent three of its seven columns on schemaVersion, + * chain and network, identical in every row; + * - Zcash reports no fee amount on a pending transaction at all, because a + * shielded transaction's fee cannot be read from its transparent side, and + * reports ZIP-317 logical actions instead; + * - the capability envelope names TAP on Doge `tap_doge` while the API + * serves it at `/protocols/doge-tap`, and reports `dunes`, which this + * explorer has no page for. + * + * Refresh them by re-capturing from the same endpoints. They are a record of + * a shape, so a refresh that changes one is a finding rather than a chore. + */ + +export const LIVE_PAYLOADS = { + dogecoinStatus: { + schemaVersion: 'universe-chain-capability-v1', + chain: 'dogecoin', + network: 'mainnet', + asset: { + symbol: 'DOGE', + name: 'Dogecoin', + precision: 8, + atomicUnit: 'koinu' + }, + ready: false, + tip: { + heightAtomic: '6351906', + blockHash: '648c241e0a6602ebae1b6e589a228cd5b3e60d6988423343f6dae74216779576', + observedAt: '2026-08-29T08:27:13.782Z' + }, + sync: { + state: 'degraded', + initialBlockDownload: false, + progressDecimal: null, + updatedAt: '2026-08-29T08:27:13.782Z' + }, + mempool: { + supported: true, + state: 'ready', + completeness: 'complete', + snapshotId: 'dogecoin-mainnet-125515f9916eb54b416b2ace409554db', + sequenceAtomic: '1554', + observedAt: '2026-08-29T08:27:13.782Z' + }, + reads: { + transaction: true, + block: true, + address: true, + outpoint: true, + feeEstimates: true, + projectedBlocks: false + }, + protocols: [ + { + protocolId: 'doginals', + state: 'unavailable', + coverage: 'unavailable', + updatedAt: null, + lagBlocksAtomic: null, + degradedReasons: [ + 'authority-capability-disabled' + ] + }, + { + protocolId: 'drc20', + state: 'unavailable', + coverage: 'unavailable', + updatedAt: null, + lagBlocksAtomic: null, + degradedReasons: [ + 'authority-capability-disabled' + ] + }, + { + protocolId: 'tap_doge', + state: 'degraded', + coverage: 'unavailable', + updatedAt: null, + lagBlocksAtomic: null, + degradedReasons: [ + 'protocol-authority-unavailable' + ] + }, + { + protocolId: 'dunes', + state: 'unavailable', + coverage: 'unavailable', + updatedAt: null, + lagBlocksAtomic: null, + degradedReasons: [ + 'authority-capability-disabled' + ] + } + ], + coverage: { + confirmedHistory: 'unavailable', + addressHistory: 'unavailable', + protocolHistory: 'unavailable' + }, + updatedAt: '2026-08-29T08:27:13.782Z', + lagBlocksAtomic: null, + degradedReasons: [ + 'base-chain-authority-unavailable', + 'confirmed-history-authority-unavailable', + 'protocol-history-unavailable' + ], + release: { + sha: 'development' + } + }, + zcashStatus: { + schemaVersion: 'universe-chain-capability-v1', + chain: 'zcash', + network: 'mainnet', + asset: { + symbol: 'ZEC', + name: 'Zcash', + precision: 8, + atomicUnit: 'zatoshi' + }, + ready: false, + tip: { + heightAtomic: '3464548', + blockHash: '000000000094e3f346dd672170a92ff16ad6e74694c9ffd43d72296e4bda42ab', + observedAt: '2026-08-29T08:27:13.196Z' + }, + sync: { + state: 'degraded', + initialBlockDownload: true, + progressDecimal: null, + updatedAt: '2026-08-29T08:27:13.790Z' + }, + mempool: { + supported: true, + state: 'ready', + completeness: 'complete', + snapshotId: 'zcash-fcdd0365c79832b5d61484b5d0cc834e143ac771', + sequenceAtomic: '57', + observedAt: '2026-08-29T08:27:13.196Z' + }, + reads: { + transaction: true, + block: true, + address: true, + outpoint: true, + feeEstimates: true, + projectedBlocks: false + }, + protocols: [ + { + protocolId: 'zerdinals', + state: 'ready', + coverage: 'complete', + updatedAt: '2026-08-29T08:27:13.790Z', + lagBlocksAtomic: '2', + degradedReasons: [] + }, + { + protocolId: 'zrunes', + state: 'ready', + coverage: 'complete', + updatedAt: '2026-08-29T08:27:13.790Z', + lagBlocksAtomic: '2', + degradedReasons: [] + }, + { + protocolId: 'zrc20', + state: 'ready', + coverage: 'complete', + updatedAt: '2026-08-29T08:27:13.790Z', + lagBlocksAtomic: '2', + degradedReasons: [] + } + ], + coverage: { + confirmedHistory: 'complete', + addressHistory: 'complete', + protocolHistory: 'complete' + }, + updatedAt: '2026-08-29T08:27:13.196Z', + lagBlocksAtomic: '2', + degradedReasons: [], + release: { + sha: 'development' + } + }, + dogecoinMempool: { + snapshot: { + chain: 'dogecoin', + network: 'mainnet', + snapshotId: 'dogecoin-mainnet-125515f9916eb54b416b2ace409554db', + sequenceAtomic: '1554', + tip: { + heightAtomic: '6351906', + blockHash: '648c241e0a6602ebae1b6e589a228cd5b3e60d6988423343f6dae74216779576', + observedAt: '2026-08-29T08:27:13.782Z' + }, + initialBlockDownload: false, + observedAt: '2026-08-29T08:27:13.782Z', + completeness: 'complete' + }, + transactions: [ + { + schemaVersion: 'universe-transaction-v1', + chain: 'dogecoin', + network: 'mainnet', + txid: '00d2039119ea4ecb7d026f4c89a84a908de826a3d68adc036f905dc574e0a4a7', + status: 'pending', + firstSeenAt: '2026-08-29T08:26:53.779Z', + observedAt: '2026-08-29T08:27:13.782Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '2751', + fee: { + amountAtomic: '10884900', + rateDecimal: null, + rateUnit: null + }, + conflicts: [], + replacement: null, + expiry: null, + transparent: { + inputs: [ + { + indexAtomic: '0', + previousOutpoint: '1b6675e3f71d8bf44e8c3309c6f86d50d5bab1759d6647777959ee02d5887313:80', + address: null, + valueAtomic: '122645319284', + scriptSigHex: '473044022053f94d02b596cf107085a79f4a6dd505110a7f7cd5c02260ee45c3d7d45bfc39022028de83441de3e8f2927cbec735f704e6cf9c875e7c1caf19663746cd2d7baa1e0121038981275966cee875fa3dc0c403aad7cd77f6fedd2e6d4488ab8f45a5e51c04cc', + coinbase: false + } + ], + outputs: [ + { + indexAtomic: '0', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914f351ce53d9e6e808b183d40e1d30a679aa3b1ba087', + spent: null + }, + { + indexAtomic: '1', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9149c877b266645cdbb0b22a86740b82b19465c40bd87', + spent: null + }, + { + indexAtomic: '2', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9144b13a115b62c2908b474d6fb1f72b993f75a2ebc87', + spent: null + }, + { + indexAtomic: '3', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914e353e26d9aa368283b5e330fc8fd4addafe0d17987', + spent: null + }, + { + indexAtomic: '4', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914519433b6d2bff4cdb965b4b4758dfc73c3ff1bf587', + spent: null + }, + { + indexAtomic: '5', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9140bb0296f0e744fc0ff5e24bd037d0112a0b3ff5387', + spent: null + }, + { + indexAtomic: '6', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914e4b311be790f5a84088158b574f041eb05a5113687', + spent: null + }, + { + indexAtomic: '7', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9146e69f43c5d40e84b213eab57e01022105e4e02f887', + spent: null + }, + { + indexAtomic: '8', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91416066f7c799f43cafa85550899356a32b11d687a87', + spent: null + }, + { + indexAtomic: '9', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9144ee9af04979865296f117189fdd28d105d92daf787', + spent: null + }, + { + indexAtomic: '10', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914d314c510ba7d93c1e36c8df3259059c3101833cf87', + spent: null + }, + { + indexAtomic: '11', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914ad3a7df5d5e66031961d49b0df2bfa590a1c336987', + spent: null + }, + { + indexAtomic: '12', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9144fad30104fa12d596fa2ee80f90c9a91a3627b9887', + spent: null + }, + { + indexAtomic: '13', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914be3bc741874795b1f25e530d335d9ca6189d22ad87', + spent: null + }, + { + indexAtomic: '14', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9144f930025f9540c42ee6370ba27da98f86ffc42f987', + spent: null + }, + { + indexAtomic: '15', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914ae0786d14929a5da804df259b2c62bfb28128d6b87', + spent: null + }, + { + indexAtomic: '16', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9147ba26de4403ee1e298658afd64c3d8a76165cb7b87', + spent: null + }, + { + indexAtomic: '17', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914fe7d364d6193fc1d3e1ced723f2cb7202b11838987', + spent: null + }, + { + indexAtomic: '18', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91434616c7fd40a4256607b586fcbe644183e2bac9787', + spent: null + }, + { + indexAtomic: '19', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9140111e815a3f7a62f3fe324739e0ea1470c27883a87', + spent: null + }, + { + indexAtomic: '20', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91432e3de02856a9814aee166cb21bdacfa1ab5d7e787', + spent: null + }, + { + indexAtomic: '21', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914f9b8a0e13eaa5511c47743fecc13828cfd7df34887', + spent: null + }, + { + indexAtomic: '22', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914aec8308026a6cd3d546ff5251aec750c621b8a8287', + spent: null + }, + { + indexAtomic: '23', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914491b6304333bcc45bf6224a9e2a9ac1739fb650087', + spent: null + }, + { + indexAtomic: '24', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914c15d1d600998b738f7de342e409f1115b5d8cfbf87', + spent: null + }, + { + indexAtomic: '25', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9141767fc89c583518ba816d8b4ba00f1107a8e5b3e87', + spent: null + }, + { + indexAtomic: '26', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9148efaffaee1d88bb5f3ca2664ec536b9057cd5fd687', + spent: null + }, + { + indexAtomic: '27', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91477d8fc2319ad8e75f8a264b01bfd9581ac4c50f487', + spent: null + }, + { + indexAtomic: '28', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91484e68cc0fd8785617d91d5dbe53e615b8ec7362787', + spent: null + }, + { + indexAtomic: '29', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914121e246489354aa999dc99f0d7959989081499b487', + spent: null + }, + { + indexAtomic: '30', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914b9bd3e9313405c5b53851a65cdd8b0adaa4c8a8a87', + spent: null + }, + { + indexAtomic: '31', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914913577f7ed3e1c6c43add217fc17388c519c3f5587', + spent: null + }, + { + indexAtomic: '32', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9142353d0b44a112962cb750b82c9178d049fad917687', + spent: null + }, + { + indexAtomic: '33', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9144108dad0d0bbe6a314e0ec5c33ae9d5f06ae0a6c87', + spent: null + }, + { + indexAtomic: '34', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9147b17c2cd7ab0150badd8baeaf1648f525864428287', + spent: null + }, + { + indexAtomic: '35', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914f6edd5a06996e0081cf1fada0ce527999853035d87', + spent: null + }, + { + indexAtomic: '36', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91459443886670fdeebb8f1c1b0d27dccc181d969e987', + spent: null + }, + { + indexAtomic: '37', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9142f20a56df6997a1c6862f595229b7206a66cda8187', + spent: null + }, + { + indexAtomic: '38', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914373c4d1aa25e2caa539a2fafaf1ba4a29892166287', + spent: null + }, + { + indexAtomic: '39', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91473e5121c30cc33bfbcf2a60a1a18169af3b3464e87', + spent: null + }, + { + indexAtomic: '40', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914cbcc6bdd735ffe3b45596d473f1f797b5d79d2fe87', + spent: null + }, + { + indexAtomic: '41', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9145a10270165f4d4c1692877cc689d1a2585eb194d87', + spent: null + }, + { + indexAtomic: '42', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9142f8b5d8f9e3cf756c35724bedb8fd39f5f1d964a87', + spent: null + }, + { + indexAtomic: '43', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914a6c471d5cf899e818005462794a34bdf1f4d634587', + spent: null + }, + { + indexAtomic: '44', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9140b7fe409b308ffb4d1a548e64992a24b09b641be87', + spent: null + }, + { + indexAtomic: '45', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914fb96a3ac34d85535b9ba6bca00ad8c8a9cd85f6f87', + spent: null + }, + { + indexAtomic: '46', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91467967d690e4ca4bb5476429017e53ee4b3a234e087', + spent: null + }, + { + indexAtomic: '47', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914a9380fe298761b20467e3ae75473e3d399fc25e587', + spent: null + }, + { + indexAtomic: '48', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914768793cc89063d66208d30993add92e487af246287', + spent: null + }, + { + indexAtomic: '49', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914f7e6e7f09e20c493e4d56cf0cd4364892aae87ae87', + spent: null + }, + { + indexAtomic: '50', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9149346a048c2d8d71d1dfc24d194b9e80e2b09909b87', + spent: null + }, + { + indexAtomic: '51', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914805e457185f06cc8cca6aa171fe0d59874ec55d987', + spent: null + }, + { + indexAtomic: '52', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914cf9861f0b1fcbf81f922cfbe4cd8764ff555b0bc87', + spent: null + }, + { + indexAtomic: '53', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914083585a6a05ced615539513a7e8087a0499bec0087', + spent: null + }, + { + indexAtomic: '54', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914b64094cb985e8128b1e110805c01094f2216d50287', + spent: null + }, + { + indexAtomic: '55', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9142e3b0fe57ebe2f3e09089eca65e9008eed88021687', + spent: null + }, + { + indexAtomic: '56', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914d89a9a832f0ef58e4c2ba7f6eb164279ce2abbd087', + spent: null + }, + { + indexAtomic: '57', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914c6c2ea6fca6f947cdf12438a87a1411f891e7ad287', + spent: null + }, + { + indexAtomic: '58', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9144b12b11f20ef9f7fc8f1ee4c48680aa85e70ea9b87', + spent: null + }, + { + indexAtomic: '59', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914892830d7e23803c3429e50839c11c2b1606a784d87', + spent: null + }, + { + indexAtomic: '60', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91468f90a79ebc33f4142f25233537461d9a98efe5187', + spent: null + }, + { + indexAtomic: '61', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9145bac018119ec73b4bde99e9961928f8140fca8a787', + spent: null + }, + { + indexAtomic: '62', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9148a552cb7c5c673fae953f74c8e6cfe0805562d4c87', + spent: null + }, + { + indexAtomic: '63', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914f9232fdd70c160e3af4057db284b9fadf1b529ad87', + spent: null + }, + { + indexAtomic: '64', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91485033b8b775e06d8b3c31c694db747b9165dbddb87', + spent: null + }, + { + indexAtomic: '65', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914ed087b0a907b01336b15510bfb42bc80cd4d468687', + spent: null + }, + { + indexAtomic: '66', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91443af873d267fa175ef33c8150b3eeba3f299569c87', + spent: null + }, + { + indexAtomic: '67', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914c3f1a64fc7a05ca0175e3de5c36eaf365da394e187', + spent: null + }, + { + indexAtomic: '68', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914db47501d3714cba1cdb72d28062a05cc716b9c4187', + spent: null + }, + { + indexAtomic: '69', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9144c27422c3f26060a46145d1b05231ef7f723b3d887', + spent: null + }, + { + indexAtomic: '70', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9148495e72695c4bb7d04ac90d1899b3d30c241a3cf87', + spent: null + }, + { + indexAtomic: '71', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9144697ee495b0e6ad435d8b614cd3e602099cdf83d87', + spent: null + }, + { + indexAtomic: '72', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9141e69a1f1c3d9e40b9fb7525ba548347c05c3ed2087', + spent: null + }, + { + indexAtomic: '73', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9146536e84f2c09e8153627aba4fb99405710ce147a87', + spent: null + }, + { + indexAtomic: '74', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914aef5a15b76db13493fcb93e3d31d4847d72bf97c87', + spent: null + }, + { + indexAtomic: '75', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914b934cea9a3230d6b1bd3626f9f4eff74416ff67587', + spent: null + }, + { + indexAtomic: '76', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914f5e0186152ed7b60867f8e8a702348cc76371ec087', + spent: null + }, + { + indexAtomic: '77', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914f832cbb66f65a4ee594d6bdb9c712a75e031e1cf87', + spent: null + }, + { + indexAtomic: '78', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914744e18b8e20eb54a7182ba92ac3361e5e7985d1687', + spent: null + }, + { + indexAtomic: '79', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9147fcf6405df275d29b272b99c7395d5c697a5ea5687', + spent: null + }, + { + indexAtomic: '80', + address: null, + valueAtomic: '122474434384', + scriptPubKeyHex: '76a9141df6354ebf997bb69cad25ce0b3927ef36d629dd88ac', + spent: null + } + ] + }, + shielded: null, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'dogecoin-core:raw:00d2039119ea4ecb7d026f4c89a84a908de826a3d68adc036f905dc574e0a4a7', + 'dogecoin-core:mempool:00d2039119ea4ecb7d026f4c89a84a908de826a3d68adc036f905dc574e0a4a7' + ], + completeness: 'complete' + }, + { + schemaVersion: 'universe-transaction-v1', + chain: 'dogecoin', + network: 'mainnet', + txid: '0291b7485ba29475fc8612e5184d4aace1593a77be7a1350f535bb4cf52b7da7', + status: 'pending', + firstSeenAt: '2026-08-29T07:06:18.954Z', + observedAt: '2026-08-29T08:27:13.782Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '223', + fee: { + amountAtomic: '153600', + rateDecimal: null, + rateUnit: null + }, + conflicts: [], + replacement: null, + expiry: null, + transparent: { + inputs: [ + { + indexAtomic: '0', + previousOutpoint: '3a6ddcdeaa8e0c3a7474b1e9a1699c31e43d9a674ca8a04d8a37d9d64326ca87:0', + address: null, + valueAtomic: '20000000', + scriptSigHex: '473044022052d9eff8cba7e5fd10960358e447c59660783c7f90236851f0439dfab7e402cb02206d2e2d5f4117649fdad9bff1a9a8ffd30a3ea21a619aa9fe4f1fdd142f73a37d014104d75c6b53f27ea174087acb315e49f9d23ca4d13faf3d4b83c0277caa3e138df5a271f356bbd59eaeb03fa06bca244066655b1ab7bd26f179aa4240fd6d2d5eff', + coinbase: false + } + ], + outputs: [ + { + indexAtomic: '0', + address: null, + valueAtomic: '19846400', + scriptPubKeyHex: '76a914f251dc7a7fa93ae5e87aacebeb0da73837ffe91388ac', + spent: null + } + ] + }, + shielded: null, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'dogecoin-core:raw:0291b7485ba29475fc8612e5184d4aace1593a77be7a1350f535bb4cf52b7da7', + 'dogecoin-core:mempool:0291b7485ba29475fc8612e5184d4aace1593a77be7a1350f535bb4cf52b7da7' + ], + completeness: 'complete' + }, + { + schemaVersion: 'universe-transaction-v1', + chain: 'dogecoin', + network: 'mainnet', + txid: '02e488a61a6ec16185328eb615f61c07132bfc43678a4643d7448373a521c6c7', + status: 'pending', + firstSeenAt: '2026-08-29T06:26:45.080Z', + observedAt: '2026-08-29T08:27:13.782Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '223', + fee: { + amountAtomic: '153600', + rateDecimal: null, + rateUnit: null + }, + conflicts: [], + replacement: null, + expiry: null, + transparent: { + inputs: [ + { + indexAtomic: '0', + previousOutpoint: 'ba1412b2e9c440c90565dfee4351a6a277801c16c2283f151bd3da1adec43b45:1', + address: null, + valueAtomic: '20000000', + scriptSigHex: '47304402203c586eef205018df76b2fd60cdf2acd655bf71cc382c3421a4f3e71d2af41ff3022015507f0b84a24cf7bce31440e9f3d95d42a85ffa6a3f75189c6ab15ccf76d9bc014104dc5dbe584383396cba30667fb93b492a1e44c75a2b0ae751c1ef2235f92894602dbfaed953a5dcdd52fe42b00744aa4005194913b21f2797ffbf5ea258bdec4d', + coinbase: false + } + ], + outputs: [ + { + indexAtomic: '0', + address: null, + valueAtomic: '19846400', + scriptPubKeyHex: '76a914f251dc7a7fa93ae5e87aacebeb0da73837ffe91388ac', + spent: null + } + ] + }, + shielded: null, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'dogecoin-core:raw:02e488a61a6ec16185328eb615f61c07132bfc43678a4643d7448373a521c6c7', + 'dogecoin-core:mempool:02e488a61a6ec16185328eb615f61c07132bfc43678a4643d7448373a521c6c7' + ], + completeness: 'complete' + }, + { + schemaVersion: 'universe-transaction-v1', + chain: 'dogecoin', + network: 'mainnet', + txid: '0341487a72eddc478ee804849750eb60b88263ee422b08397eae991b66946d0b', + status: 'pending', + firstSeenAt: '2026-08-29T07:06:48.961Z', + observedAt: '2026-08-29T08:27:13.782Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '223', + fee: { + amountAtomic: '153600', + rateDecimal: null, + rateUnit: null + }, + conflicts: [], + replacement: null, + expiry: null, + transparent: { + inputs: [ + { + indexAtomic: '0', + previousOutpoint: '39581e38a19abcc87781a7b0840ff9026c0d592709d60db30ad371815ec4a0ae:2', + address: null, + valueAtomic: '20000000', + scriptSigHex: '47304402203224a4789a3722558165b00452b34cde1b837755553f879b6323f74b2c25574b02205bb4a3e70da5a7a7d9c1617a9f567f8affab782fff5b0688b7b2ab64348c37ce014104d9d8fed4b18ee5bfd043c7fd2f961c229193cc05640dd3a54b1ea722192d12e4676fdf047f686e6be5ff0e40f96f81561e86be6a27d582b4a0a8d6305d049b11', + coinbase: false + } + ], + outputs: [ + { + indexAtomic: '0', + address: null, + valueAtomic: '19846400', + scriptPubKeyHex: '76a914f251dc7a7fa93ae5e87aacebeb0da73837ffe91388ac', + spent: null + } + ] + }, + shielded: null, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'dogecoin-core:raw:0341487a72eddc478ee804849750eb60b88263ee422b08397eae991b66946d0b', + 'dogecoin-core:mempool:0341487a72eddc478ee804849750eb60b88263ee422b08397eae991b66946d0b' + ], + completeness: 'complete' + } + ] + }, + zcashMempool: { + snapshot: { + network: 'mainnet', + snapshotId: 'zcash-fcdd0365c79832b5d61484b5d0cc834e143ac771', + sequenceAtomic: '57', + tip: { + heightAtomic: '3464548', + blockHash: '000000000094e3f346dd672170a92ff16ad6e74694c9ffd43d72296e4bda42ab', + observedAt: '2026-08-29T08:27:13.196Z' + }, + initialBlockDownload: true, + completeness: 'complete', + transactionCountAtomic: '5', + hydratedCountAtomic: '0', + observedAt: '2026-08-29T08:27:13.196Z' + }, + transactions: [ + { + schemaVersion: 'universe-transaction-v1', + chain: 'zcash', + network: 'mainnet', + txid: '4bda6a72c537e3ab42f81495e625d92dc29a2d544405212634176cc720c9d338', + status: 'pending', + firstSeenAt: '2026-08-29T08:26:23.192Z', + observedAt: '2026-08-29T08:27:13.196Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '244', + fee: { + amountAtomic: null, + rateDecimal: null, + rateUnit: 'zatoshi/logical-action', + logicalActionsAtomic: '2', + model: 'ZIP-317-revision-1' + }, + conflicts: [], + replacement: null, + expiry: { + heightAtomic: '3464648', + state: 'pending' + }, + transparent: { + inputs: [ + { + indexAtomic: '0', + previousOutpoint: 'c697245abda6bebf4bb96d9c6230bcb74848d0ee845331ee3cb9784cb03e06ef:1', + address: null, + valueAtomic: null, + scriptSigHex: '473044022070bf646632f52d917f4a19987d2949b763219d432b43cc25a457a4f6239833180220287f4f10c191eab86a0c78b374715c03d454e5314a6c749479e9b2531edd44170121033e842058be42e762effd7daf3b9e6cc44321f904cc8736fa50b90d261fe7b1e9', + coinbase: false + } + ], + outputs: [ + { + indexAtomic: '0', + address: 't1Uo52SqpupL6Z1TPVHWhGSxrsjVgcyMKsM', + valueAtomic: '100000000', + scriptPubKeyHex: '76a91477cddbb738117e9116e0527c66d36cf472b8965688ac', + spent: null + }, + { + indexAtomic: '1', + address: 't1YhRyphDWJ8h6oUwrMJ4NaH7QYJgRsFS9i', + valueAtomic: '529578076', + scriptPubKeyHex: '76a914a29d5b9834b8cb63c8516a4094c6a7f946a56dfa88ac', + spent: null + } + ] + }, + shielded: { + sproutJoinSplitsAtomic: '0', + saplingSpendsAtomic: '0', + saplingOutputsAtomic: '0', + orchardActionsAtomic: '0', + ironwoodActionsAtomic: '0', + valueBalanceAtomic: null, + saplingValueBalanceAtomic: '0', + orchardValueBalanceAtomic: '0', + ironwoodValueBalanceAtomic: null, + privacyNotice: 'Shielded sender, recipient, address, amount, ownership, and direction are not observable from public transaction data.' + }, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'zebra:getrawmempool:4bda6a72c537e3ab42f81495e625d92dc29a2d544405212634176cc720c9d338', + 'zebra:getrawtransaction:4bda6a72c537e3ab42f81495e625d92dc29a2d544405212634176cc720c9d338' + ], + completeness: 'complete' + }, + { + schemaVersion: 'universe-transaction-v1', + chain: 'zcash', + network: 'mainnet', + txid: '58f5e4e5bc127396c05f27f724c493727a5f64d1e2b1644ab19ca1d87f76a079', + status: 'pending', + firstSeenAt: '2026-08-29T08:25:18.187Z', + observedAt: '2026-08-29T08:27:13.196Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '245', + fee: { + amountAtomic: null, + rateDecimal: null, + rateUnit: 'zatoshi/logical-action', + logicalActionsAtomic: '2', + model: 'ZIP-317-revision-1' + }, + conflicts: [], + replacement: null, + expiry: { + heightAtomic: '0', + state: 'pending' + }, + transparent: { + inputs: [ + { + indexAtomic: '0', + previousOutpoint: '9543e886776f8d053482697b62d7e0c7e1ae8579a2f7d834dfaf83b4a93001ff:0', + address: null, + valueAtomic: null, + scriptSigHex: '483045022100aa573afdbb53a283c5ccabe017ccc9e792d47ce7461b76309425251e87077ddd022041c8cdc81c0b4627c671eeb2356d5174105799379f85407e8f3d6a3182a6462701210214e3b384c0c97fefa02268d20c4d1dc1916aabce7b7947523bed78889244cdc5', + coinbase: false + } + ], + outputs: [ + { + indexAtomic: '0', + address: 't1NLjGNiwrw4dDQkM9immE5aRh6ut1eRnbS', + valueAtomic: '505000000', + scriptPubKeyHex: '76a9143101cb3ae364ac29b91f3a2a837b3f6a8f67e79d88ac', + spent: null + }, + { + indexAtomic: '1', + address: 't1PZswZWuE9cnQMKY7TQFiK9hWhvnjNghh3', + valueAtomic: '121628907', + scriptPubKeyHex: '76a9143e7691b7045db5e10b64cea1cec5d0eb98e3ce8b88ac', + spent: null + } + ] + }, + shielded: { + sproutJoinSplitsAtomic: '0', + saplingSpendsAtomic: '0', + saplingOutputsAtomic: '0', + orchardActionsAtomic: '0', + ironwoodActionsAtomic: '0', + valueBalanceAtomic: null, + saplingValueBalanceAtomic: '0', + orchardValueBalanceAtomic: '0', + ironwoodValueBalanceAtomic: null, + privacyNotice: 'Shielded sender, recipient, address, amount, ownership, and direction are not observable from public transaction data.' + }, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'zebra:getrawmempool:58f5e4e5bc127396c05f27f724c493727a5f64d1e2b1644ab19ca1d87f76a079', + 'zebra:getrawtransaction:58f5e4e5bc127396c05f27f724c493727a5f64d1e2b1644ab19ca1d87f76a079' + ], + completeness: 'complete' + }, + { + schemaVersion: 'universe-transaction-v1', + chain: 'zcash', + network: 'mainnet', + txid: '982b98c61601fcec1315ff345a3be904017a122a9397ec0deba18dc4aa32450f', + status: 'pending', + firstSeenAt: '2026-08-29T08:25:48.189Z', + observedAt: '2026-08-29T08:27:13.196Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '244', + fee: { + amountAtomic: null, + rateDecimal: null, + rateUnit: 'zatoshi/logical-action', + logicalActionsAtomic: '2', + model: 'ZIP-317-revision-1' + }, + conflicts: [], + replacement: null, + expiry: { + heightAtomic: '0', + state: 'pending' + }, + transparent: { + inputs: [ + { + indexAtomic: '0', + previousOutpoint: '4d77c31aa6c0466263880a634b36bed79f51051c5af9acffad28ceac904dc08f:1', + address: null, + valueAtomic: null, + scriptSigHex: '473044022035c6595c9bae8c49db73d337c25fe01a2e12b0f076a2ee3d6fe292ffafc8184a02205eca36cdd18982060e31cd5d1c32687981bf4ce9d793ba0f4d1192a854339cdf012102106a2dcaaac2ae3b24358a03f4264e05db420c5b090399bc23885fa02fef7716', + coinbase: false + } + ], + outputs: [ + { + indexAtomic: '0', + address: 't1VN3eH8nxqCQFcJ2i5Zv54LdD7XLnKXRDN', + valueAtomic: '196131846', + scriptPubKeyHex: '76a9147e0a6e63687b23f0fabbd8127a5df01a7b31ae1a88ac', + spent: null + }, + { + indexAtomic: '1', + address: 't1Ku2KLyndDPsR32jwnrTMd3yvi9tfFP8ML', + valueAtomic: '3872726483', + scriptPubKeyHex: '76a9141634f5ff0b8f6603a17570436d6c12a91f4b1fed88ac', + spent: null + } + ] + }, + shielded: { + sproutJoinSplitsAtomic: '0', + saplingSpendsAtomic: '0', + saplingOutputsAtomic: '0', + orchardActionsAtomic: '0', + ironwoodActionsAtomic: '0', + valueBalanceAtomic: null, + saplingValueBalanceAtomic: '0', + orchardValueBalanceAtomic: '0', + ironwoodValueBalanceAtomic: null, + privacyNotice: 'Shielded sender, recipient, address, amount, ownership, and direction are not observable from public transaction data.' + }, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'zebra:getrawmempool:982b98c61601fcec1315ff345a3be904017a122a9397ec0deba18dc4aa32450f', + 'zebra:getrawtransaction:982b98c61601fcec1315ff345a3be904017a122a9397ec0deba18dc4aa32450f' + ], + completeness: 'complete' + }, + { + schemaVersion: 'universe-transaction-v1', + chain: 'zcash', + network: 'mainnet', + txid: 'c0f08743c4e645b1bb7409c64fb376adaeeca8bc9f5bdb7c8f60ef69e85cbb8c', + status: 'pending', + firstSeenAt: '2026-08-29T08:26:13.191Z', + observedAt: '2026-08-29T08:27:13.196Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '15512', + fee: { + amountAtomic: null, + rateDecimal: null, + rateUnit: 'zatoshi/logical-action', + logicalActionsAtomic: '5', + model: 'ZIP-317-revision-1' + }, + conflicts: [], + replacement: null, + expiry: { + heightAtomic: '3464589', + state: 'pending' + }, + transparent: { + inputs: [], + outputs: [ + { + indexAtomic: '0', + address: 't1WZUWvWzzL7bnTuDkfxnXoeWV5trVTqLqw', + valueAtomic: '100800000', + scriptPubKeyHex: '76a9148b2be54997a7cc4885bd0ef844a956b4449dbf8588ac', + spent: null + } + ] + }, + shielded: { + sproutJoinSplitsAtomic: '0', + saplingSpendsAtomic: '0', + saplingOutputsAtomic: '0', + orchardActionsAtomic: '0', + ironwoodActionsAtomic: '4', + valueBalanceAtomic: null, + saplingValueBalanceAtomic: '0', + orchardValueBalanceAtomic: '0', + ironwoodValueBalanceAtomic: '100825000', + privacyNotice: 'Shielded sender, recipient, address, amount, ownership, and direction are not observable from public transaction data.' + }, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'zebra:getrawmempool:c0f08743c4e645b1bb7409c64fb376adaeeca8bc9f5bdb7c8f60ef69e85cbb8c', + 'zebra:getrawtransaction:c0f08743c4e645b1bb7409c64fb376adaeeca8bc9f5bdb7c8f60ef69e85cbb8c' + ], + completeness: 'complete' + }, + { + schemaVersion: 'universe-transaction-v1', + chain: 'zcash', + network: 'mainnet', + txid: 'dada060cafb1f3e4acf1f7f7c3fb7feba0454b9392eb29cb2c28ef8992581c5f', + status: 'pending', + firstSeenAt: '2026-08-29T08:26:58.194Z', + observedAt: '2026-08-29T08:27:13.196Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '18340', + fee: { + amountAtomic: null, + rateDecimal: null, + rateUnit: 'zatoshi/logical-action', + logicalActionsAtomic: '5', + model: 'ZIP-317-revision-1' + }, + conflicts: [], + replacement: null, + expiry: { + heightAtomic: '3464588', + state: 'pending' + }, + transparent: { + inputs: [], + outputs: [ + { + indexAtomic: '0', + address: 't1PLVRnDwxoeMxxTmPDhwXzEny21oJZNmCu', + valueAtomic: '148000000', + scriptPubKeyHex: '76a9143bee5e4f9f397189af886895d4940be34e0455a388ac', + spent: null + } + ] + }, + shielded: { + sproutJoinSplitsAtomic: '0', + saplingSpendsAtomic: '0', + saplingOutputsAtomic: '0', + orchardActionsAtomic: '2', + ironwoodActionsAtomic: '2', + valueBalanceAtomic: null, + saplingValueBalanceAtomic: '0', + orchardValueBalanceAtomic: '45945', + ironwoodValueBalanceAtomic: '147979055', + privacyNotice: 'Shielded sender, recipient, address, amount, ownership, and direction are not observable from public transaction data.' + }, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'zebra:getrawmempool:dada060cafb1f3e4acf1f7f7c3fb7feba0454b9392eb29cb2c28ef8992581c5f', + 'zebra:getrawtransaction:dada060cafb1f3e4acf1f7f7c3fb7feba0454b9392eb29cb2c28ef8992581c5f' + ], + completeness: 'complete' + } + ] + }, + dogecoinProtocols: { + chain: 'dogecoin', + network: 'mainnet', + items: [ + { + schemaVersion: 'universe-explorer-protocol-v1', + id: 'doginals', + aliases: [ + 'doginal' + ], + displayName: 'Doginals', + shortName: 'Doginals', + family: 'OTHER', + chain: 'dogecoin', + networks: [ + 'mainnet' + ], + icon: 'protocol-doginals', + visualToken: 'protocol-doginals', + implementedReadOperations: [], + authorizedReadOperations: [], + releaseStatus: 'BLOCKED', + indexerAuthority: 'ord-dogecoin', + coverage: 'unknown' + }, + { + schemaVersion: 'universe-explorer-protocol-v1', + id: 'drc20', + aliases: [ + 'drc-20' + ], + displayName: 'DRC-20', + shortName: 'DRC-20', + family: 'OTHER', + chain: 'dogecoin', + networks: [ + 'mainnet' + ], + icon: 'protocol-drc20', + visualToken: 'protocol-drc20', + implementedReadOperations: [], + authorizedReadOperations: [], + releaseStatus: 'BLOCKED', + indexerAuthority: 'ord-dogecoin', + coverage: 'unknown' + }, + { + schemaVersion: 'universe-explorer-protocol-v1', + id: 'tap_doge', + aliases: [ + 'tap-on-doge', + 'doge-tap', + 'tap-doge' + ], + displayName: 'TAP on Doge', + shortName: 'Doge TAP', + family: 'OTHER', + chain: 'dogecoin', + networks: [ + 'mainnet' + ], + icon: 'protocol-tap-doge', + visualToken: 'protocol-tap-doge', + implementedReadOperations: [], + authorizedReadOperations: [], + releaseStatus: 'BLOCKED', + indexerAuthority: 'index-doge-tap', + coverage: 'unknown' + }, + { + schemaVersion: 'universe-explorer-protocol-v1', + id: 'dunes', + aliases: [], + displayName: 'Dunes', + shortName: 'Dunes', + family: 'OTHER', + chain: 'dogecoin', + networks: [ + 'mainnet' + ], + icon: 'protocol-dunes', + visualToken: 'protocol-dunes', + implementedReadOperations: [], + authorizedReadOperations: [], + releaseStatus: 'BLOCKED', + indexerAuthority: 'ord-dogecoin', + coverage: 'unknown' + } + ], + confirmedHistory: { + configured: false, + configurationFailure: 'blockbook-unconfigured', + state: 'unavailable', + lastAttemptAt: null, + lastSuccessAt: null, + lastFailureKind: null, + tip: null, + initialSync: null, + mempoolInSync: null, + release: null + }, + mempool: { + configured: true, + configurationFailure: null, + running: false, + lastAttemptAt: '2026-08-29T08:27:13.782Z', + lastSuccessAt: '2026-08-29T08:27:13.782Z', + lastFailureKind: null, + snapshot: { + chain: 'dogecoin', + network: 'mainnet', + snapshotId: 'dogecoin-mainnet-125515f9916eb54b416b2ace409554db', + sequenceAtomic: '1554', + tip: { + heightAtomic: '6351906', + blockHash: '648c241e0a6602ebae1b6e589a228cd5b3e60d6988423343f6dae74216779576', + observedAt: '2026-08-29T08:27:13.782Z' + }, + initialBlockDownload: false, + observedAt: '2026-08-29T08:27:13.782Z', + completeness: 'complete' + } + }, + protocolAuthority: { + configured: true, + state: 'unavailable', + lastAttemptAt: '2026-08-29T08:27:12.791Z', + lastSuccessAt: null, + lastFailureKind: 'transport', + checkpoint: null, + capabilities: null, + authorityId: 'ord-dogecoin' + }, + dogeTapAuthority: { + configured: true, + state: 'unavailable', + lastAttemptAt: '2026-08-29T08:27:12.792Z', + lastSuccessAt: null, + lastFailureKind: 'transport', + authorityId: 'index-doge-tap', + sourceId: null, + coverage: null, + pendingCoverage: null, + reorgCoverage: null, + sourceSequenceAtomic: null, + initialScanComplete: null, + quarantinedRecordCountAtomic: null, + release: null + } + }, + zcashProtocols: { + chain: 'zcash', + network: 'mainnet', + items: [ + { + schemaVersion: 'universe-explorer-protocol-v1', + id: 'zerdinals', + aliases: [ + 'universe-zerdinals' + ], + displayName: 'Zerdinals', + shortName: 'Zerdinals', + family: 'OTHER', + chain: 'zcash', + networks: [ + 'mainnet' + ], + icon: 'protocol-zerdinals', + visualToken: 'protocol-zerdinals', + implementedReadOperations: [], + authorizedReadOperations: [], + releaseStatus: 'BLOCKED', + indexerAuthority: 'index-zcash-metaprotocols', + coverage: 'unknown' + }, + { + schemaVersion: 'universe-explorer-protocol-v1', + id: 'zrunes', + aliases: [], + displayName: 'ZRunes', + shortName: 'zRunes', + family: 'OTHER', + chain: 'zcash', + networks: [ + 'mainnet' + ], + icon: 'protocol-zrunes', + visualToken: 'protocol-zrunes', + implementedReadOperations: [], + authorizedReadOperations: [], + releaseStatus: 'BLOCKED', + indexerAuthority: 'index-zcash-metaprotocols', + coverage: 'unknown' + }, + { + schemaVersion: 'universe-explorer-protocol-v1', + id: 'zrc20', + aliases: [], + displayName: 'ZRC-20', + shortName: 'ZRC-20', + family: 'OTHER', + chain: 'zcash', + networks: [ + 'mainnet' + ], + icon: 'protocol-zrc20', + visualToken: 'protocol-zrc20', + implementedReadOperations: [], + authorizedReadOperations: [], + releaseStatus: 'BLOCKED', + indexerAuthority: 'index-zcash-metaprotocols', + coverage: 'unknown' + } + ], + status: { + configured: true, + configurationFailure: null, + state: 'ready', + running: false, + lastAttemptAt: '2026-08-29T08:27:13.783Z', + lastSuccessAt: '2026-08-29T08:27:15.103Z', + lastFailureKind: null, + snapshot: { + network: 'mainnet', + snapshotId: 'zcash-fcdd0365c79832b5d61484b5d0cc834e143ac771', + sequenceAtomic: '57', + tip: { + heightAtomic: '3464548', + blockHash: '000000000094e3f346dd672170a92ff16ad6e74694c9ffd43d72296e4bda42ab', + observedAt: '2026-08-29T08:27:13.196Z' + }, + initialBlockDownload: true, + completeness: 'complete', + transactionCountAtomic: '5', + hydratedCountAtomic: '0', + observedAt: '2026-08-29T08:27:13.196Z' + }, + confirmedCoverage: 'complete', + confirmedLagBlocksAtomic: '2' + } + } +} as const; diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html index 31dbf32d645..53e94781606 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html @@ -1,145 +1,670 @@ -
-
-
-

{{ chainName }} mainnet / {{ ticker }}

-

{{ chainName }} {{ pageLabel() }}

-

{{ reference }}

+
+ + +
+ +

{{ chainName }} {{ pageLabel() }}

+ {{ reference }} + +
+
-
-

- Switched from {{ switchedFrom }}. Object identifiers were not carried - between chains. +

{{ pageLede() }}

+ +

+ You came from {{ switchedFrom }}. Object identifiers do not carry between chains, so this is the {{ chainName }} overview rather than the same object on {{ chainName }}.

-
- - {{ - stateLabel(capability) - }} - Tip {{ capability.tip?.heightAtomic || "not available" }} - Updated {{ freshness(capability) }} - Mempool {{ capability.mempool.state }}, - {{ capability.mempool.completeness }} - {{ - capability.mempool.snapshotId - ? "snapshot " + capability.mempool.snapshotId - : "no snapshot id" - }} - - status unavailable + +
+
+ {{ reading.label }} + {{ reading.value }} +
-
+
+

Why {{ chainName }} is not ready

+
    +
  • + {{ reason.text }} + Reported as this code, which this build has no description for. +
  • +
+
+ +
+

How much history is readable now

+
    +
  • + {{ reading.label }} + {{ reading.stateLabel }} + {{ reading.detail }} +
  • +
+
+
+ + - -
-

What is happening now

-
-
-
Chain tip
-
{{ capability.tip?.heightAtomic || "not available" }}
+ + + + + + + +
+

Questions this chain can answer

+

+ Each entry is a claim the indexer makes about itself. Where it says no, the explorer offers no page for that lookup rather than an empty one. +

+
    +
  • + {{ read.label }} + {{ read.stateLabel }} +
  • +
+
+ +
+

Protocol indexers

+
    +
  • + {{ protocol.label }} + + {{ protocol.label }} + + {{ protocol.stateLabel }} + + {{ protocol.historyLabel }} + + + {{ protocol.lag.display }} blocks behind + + + + + What it does not cover +
      +
    • {{ reason.text }}
    • +
    +
    + + This chain reports it. This explorer has no page for it yet, so there is nothing to open. + +
  • +
+
+ +
+

What this chain does differently

+

+ Dogecoin fees are quoted per kilobyte, not in sat/vB. This explorer prints the unit the chain actually uses and shows a fee rate only when the first-party source reports one. +

+

+ Zcash fee guidance follows ZIP-317 logical actions where the node reports them. A transaction's cost is not a simple function of its byte size on this chain. +

+

+ Only the transparent side of Zcash is public. This explorer reports the shape of a shielded transaction, never who was involved or how much moved. +

+

+ No projected blocks are shown for this chain. Projecting the next block needs a fee market this explorer does not model here, and a guess presented as a forecast would be worse than nothing. +

+
+ + + + +
+ Where these readings came from +

+ The identifiers behind this page, for checking a reading against the document it was rendered from. +

+
+ +
{{ detail.label }}
+
+ {{ detail.display }} + + {{ detail.exact }} + + {{ detail.note }} +
+
+
+
+ +
+ + + + + + +
+

Status

+

+ {{ tx.statusLabel }} +

+
+
+
In block
+
+ {{ short(tx.block.hash) }} + {{ tx.block.height.display }} +
+
+
+
Confirmations
+
{{ tx.confirmations.display }}
+
+
+
First seen by our node
+
{{ tx.firstSeenAt.display }}
+
+
+
Size
+
{{ tx.size.display }} bytes
+
+
+
Virtual size
+
{{ tx.virtualSize.display }} vbytes
+
+
+
Fee paid
+
{{ tx.feeAmount.display }} {{ ticker }}
+
+
+
Fee rate
+
{{ tx.feeRate }} {{ tx.feeRateUnit }}
+
+
+
ZIP-317 logical actions
+
{{ tx.logicalActions.display }}
+
+
+
Evidence
+
{{ tx.completenessLabel }}
+
+
+
+ +
+

Why this transaction may never confirm

+

+ Replaced by + {{ short(tx.replacedByTxid) }} +

+ +

+ Expires at block + {{ tx.expiry.height.display }} + {{ tx.expiry.state }} +

+
+ +
+

Transparent value

+
+
+

+ Inputs + {{ tx.inputTotal.display }} {{ ticker }} +

+
    +
  1. + + {{ short(party.address, 12) }} + + Newly issued by the block + No standard address + + + {{ party.amount.display }} + not reported +
  2. +
+

+ No input total is shown because at least one input arrived without an amount. A partial sum would read as a whole one. +

+
+ + + +
+

+ Outputs + {{ tx.outputTotal.display }} {{ ticker }} +

+
    +
  1. + + {{ short(party.address, 12) }} + No standard address + Spent + + {{ party.amount.display }} + not reported +
  2. +
+
-
-
Sync
-
{{ capability.sync.state }}
+
+ +
+

Shielded structure

+

{{ shielded.notice }}

+
+
+
{{ component.label }}
+
+ {{ component.count.display }} + Not reported +
+
+
+

+ Net movement between the transparent and shielded pools: {{ shielded.valueBalance.display }} {{ ticker }}. This is the only shielded amount the chain itself makes public. +

+
+ +
+

Protocol actions

+ + +

Evaluated

+
    +
  • + {{ action.protocolLabel }} + {{ action.actionType }} + {{ action.stateLabel }} +
  • +
+
+ + +

Candidates

+

+ These look like protocol actions in the raw transaction. Whether the protocol accepts them is decided when the transaction confirms, so nothing here is a result yet. +

+
    +
  • + {{ action.protocolLabel }} + {{ action.actionType }} + {{ action.stateLabel }} +
  • +
+
+
+ +
+ + + + + + +
+

Block

+
+
+
Height
+
{{ blk.height.display }}
+
+
+
Hash
+
{{ blk.hash }}
+
+
+
Mined
+
{{ blk.time.display }}
+
+
+
Transactions
+
{{ blk.transactionCount.display }}
+
+
+
Size
+
{{ blk.sizeBytes.display }} bytes
+
+
+
Confirmations
+
{{ blk.confirmations.display }}
+
+
+
Difficulty
+
{{ blk.difficulty }}
+
+
+
Merkle root
+
{{ blk.merkleRoot }}
+
+
+ + +
+ +
+

Transactions in this block

+ + +
+ + +

+ This block's response also carries {{ unlistedSummary(blk.unlisted) }}. This page has no reading for those yet, so they are counted here rather than left out silently. +

+ +
+ + + + + + +
+

Balance

+
+
+ Confirmed + {{ addr.balance.display }} {{ ticker }} +
+
+ Pending + {{ addr.unconfirmedBalance.display }} {{ ticker }} +
+
+ Received in total + {{ addr.totalReceived.display }} {{ ticker }} +
+
+ Sent in total + {{ addr.totalSent.display }} {{ ticker }} +
+
+ Transactions + {{ addr.transactionCount.display }} +
-
-
Pending coverage
-
{{ capability.mempool.completeness }}
+
+ +
+

Unspent outputs

+
+ + + + + + + + + + + + + + + +
OutpointAmountBlock
+ {{ short(utxo.txid) }}:{{ utxo.vout }} + + {{ utxo.amount.display }} + not reported + + {{ utxo.height.display }} + + not in a block yet + not reported + +
+
+ +
+

Transaction history

+ + +
+ +
+ + + + +
+

Output

+
-
Confirmed history
-
{{ capability.coverage.confirmedHistory }}
+
Transaction
+
{{ out.txid }}
-
Address history
-
{{ capability.coverage.addressHistory }}
+
Output index
+
{{ out.vout }}
-
-
Release
-
{{ capability.release.sha }}
+
+
Amount
+
{{ out.amount.display }} {{ ticker }}
+
+
+
Address
+
{{ out.address }}
+
+
+
State
+
+ + Spent + Unspent + +
-

- Fee estimates are shown only when the first-party source supports them. - Dogecoin pages do not label fees as sat/vB. -

-

- Zcash fee guidance uses ZIP-317 logical actions when available. - Transparent-chain facts do not reveal shielded participants or shielded - amounts. -

-

- Projected blocks are not claimed for this chain. -

- Open mempool - Open protocols
- - -
-

Zcash privacy boundary

-

- This view can report transparent-chain evidence only. Shielded sender, - recipient, and amount details are not inferred. -

-
- -
-

Verified summary

+ + + +
+

What the authority reported

+ +

Other reported fields

+
-
-
{{ item.key }}
-
{{ item.value }}
+
+
{{ fact.label }}
+
+ {{ fact.display }} + {{ fact.display }} + {{ fact.unit }} +
-
-

Results

-
+ + + +
+

Transactions

+

+ Showing the first {{ list.shownCount }} of {{ list.totalCount }} the authority returned. +

+

+ These transactions report no fee amount. A shielded transaction's fee cannot be read from its transparent side, so the chain reports what it costs under ZIP-317 instead. +

+
+ + + + + + + + + + + + + + + + + + + + + +
TransactionStateFirst seenSize + Fee {{ ticker }} + ZIP-317 actions
{{ short(row.txid) }}{{ row.statusLabel }} + {{ row.firstSeenAt.display }} + not reported + + {{ row.size.display }} + not reported + + {{ row.fee.display }} + not reported + + {{ row.logicalActions.display }} + not reported +
+
+
+ +
+

{{ collection.title }}

+

+ Showing the first {{ collection.shownCount }} of {{ collection.totalCount }} rows the authority returned. +

+

+ {{ collection.hiddenColumnCount }} further fields per row are not in this table. Every one of them is in the full response below. +

+

+ {{ collection.constantColumnCount }} fields held the same value on every row, so they are not columns here. +

+

+ Each row also carries {{ collection.structuredFields.join(', ') }}, which a table cannot hold. This explorer has no reading for them yet, so they are in the full response below and nowhere else on this page. +

+
+ + + + + - - + @@ -147,8 +672,53 @@

Results

+ +
+

{{ empty.label }}

+

+ + None + Not established + +

+

+ Our own {{ chainName }} node has none right now, and it reported its view as complete. This is none, not an answer that failed to arrive. +

+ +

+ The {{ chainName }} authority returned an empty list and did not say its view is complete, so this is not the same as none. +

+
+
+ +

+ The {{ chainName }} authority answered, and had nothing to report for this request. +

+ + + + + + +
- Full first-party response + The response this page was built from +

+ Exactly what our own {{ chainName }} authority returned, unedited. Every figure above is a rendering of a value in here. +

{{ vm.payload | json }}
+ diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss index 44f73faa9ab..f8b279bf7c3 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss @@ -1,130 +1,955 @@ +// Dogecoin and Zcash explorer pages. +// +// These share their skeleton with the single-asset pages (an identity block, a +// status block, evidence), so they take the same mixins rather than a second +// set of literals. Everything below is either a token or derived from one; the +// colour gate refuses raw interface colour here. +// +// Two things are specific to a chain page and live only here: the status rail, +// which is the trust anchor a reader learns to look at first, and the value +// flow, which is the one place a transaction's shape is worth drawing. + +@use '../universe-tokens' as u; + :host { + @include u.universe-protocol-tokens; + @include u.universe-state-tokens; + display: block; } -.multichain-page { - padding-top: 2rem; - padding-bottom: 4rem; - color: var(--fg); +.chain-page { + padding: 1.25rem 0 3rem; + color: var(--u-text-primary); } -.page-head { +// --------------------------------------------------------------------------- +// Head +// --------------------------------------------------------------------------- +// +// The heading itself is the product's shared `.title-block`: the category as a +// small label, the identifier carrying the weight, and the page action pushed +// to the end of the rule. Only the chain mark and the save control are added +// here, so a Dogecoin transaction page and a Bitcoin one open the same way. + +// A chain gets a mark, not a colour that means something. The hue comes from +// the protocol scale, which is held away from every evidence state, and it +// never appears without the chain's name beside it. +.chain-mark { + width: 0.5rem; + height: 0.5rem; + border-radius: var(--u-radius-pill); + flex: none; + align-self: center; + + &.chain-mark-dogecoin { + background: var(--universe-protocol-ordinals); + } + + &.chain-mark-zcash { + background: var(--universe-protocol-alkanes); + } +} + +.lede { + margin: 0 0 var(--u-space-4); + max-width: var(--u-width-prose); + color: var(--u-text-secondary); + line-height: var(--u-leading-normal); +} + +.save { + flex: none; + background: var(--u-surface-raised); + border: 1px solid var(--u-border-strong); + border-radius: var(--u-radius-md); + color: var(--u-text-primary); + font-size: var(--u-text-sm); + font-weight: var(--u-weight-medium); + padding: 0.4rem 0.8rem; + + @include u.universe-motion(all, var(--u-duration-fast)); + + &:hover { + background: var(--u-surface-hover); + border-color: var(--u-brand-border); + } + + &.is-saved { + background: var(--u-brand-subtle); + border-color: var(--u-brand-border); + color: var(--u-brand); + } +} + +// --------------------------------------------------------------------------- +// Status rail +// --------------------------------------------------------------------------- + +.status-rail { + @include u.universe-surface; + + display: grid; + grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); + gap: var(--u-space-3) var(--u-space-5); + padding: var(--u-space-3) var(--u-space-4); + margin-bottom: var(--u-space-3); +} + +.reading { display: flex; - justify-content: space-between; - gap: 1rem; - align-items: flex-start; + flex-direction: column; + gap: 0.3rem; + min-width: 0; } -.eyebrow { - color: var(--transparent-fg); - font-size: 0.8rem; - font-weight: 700; - letter-spacing: 0.08em; +.reading-label { + color: var(--u-text-muted); + font-size: var(--u-text-2xs); + font-weight: var(--u-weight-semibold); + letter-spacing: var(--u-eyebrow-tracking); text-transform: uppercase; } -.lede { +.universe-chip { + @include u.universe-chip; +} + +// --------------------------------------------------------------------------- +// Panels +// --------------------------------------------------------------------------- + +.panel { + @include u.universe-surface; + + padding: var(--u-space-4); + margin-bottom: var(--u-space-3); + + h2 { + font-size: var(--u-text-lg); + font-weight: var(--u-weight-semibold); + margin: 0 0 var(--u-space-3); + } + + h3 { + font-size: var(--u-text-base); + font-weight: var(--u-weight-semibold); + color: var(--u-text-secondary); + margin: var(--u-space-4) 0 var(--u-space-2); + } + + h3:first-of-type { + margin-top: 0; + } +} + +.panel-lede { + color: var(--u-text-secondary); + font-size: var(--u-text-sm); + line-height: var(--u-leading-normal); + max-width: var(--u-width-prose); + margin: 0 0 var(--u-space-3); +} + +.notice { + @include u.universe-surface; + + padding: var(--u-space-3) var(--u-space-4); + margin-bottom: var(--u-space-3); + font-size: var(--u-text-sm); + line-height: var(--u-leading-normal); + max-width: var(--u-width-prose); + + &.switched { + border-left: 3px solid var(--universe-state-pending); + } + + &.failure { + border-left: 3px solid var(--universe-state-unavailable); + color: var(--universe-state-unavailable); + } + + &.empty { + border-left: 3px solid var(--universe-state-neutral); + color: var(--u-text-secondary); + } +} + +.secondary { + color: var(--u-text-muted); +} + +// --------------------------------------------------------------------------- +// Why a chain is not ready +// --------------------------------------------------------------------------- + +// Sits directly beneath the rail, because it is the evidence for the verdict +// the rail just gave. Bordered like a notice rather than raised like a panel: +// it is a qualification of what is above it, not a section of its own. +.not-ready { + @include u.universe-surface; + + border-left: 3px solid var(--universe-state-partial); + padding: var(--u-space-3) var(--u-space-4); + margin-bottom: var(--u-space-3); + max-width: var(--u-width-prose); + + h2 { + font-size: var(--u-text-base); + font-weight: var(--u-weight-semibold); + margin: 0 0 var(--u-space-2); + } + + ul { + display: grid; + gap: var(--u-space-2); + font-size: var(--u-text-sm); + line-height: var(--u-leading-normal); + list-style: none; + margin: 0; + padding: 0; + } +} + +.reason-raw { + color: var(--u-text-muted); + display: block; + font-size: var(--u-text-xs); +} + +// --------------------------------------------------------------------------- +// Readiness band +// --------------------------------------------------------------------------- + +// What is wrong, and what it costs, read together. +// +// Both were prose columns the width of the container, which at a desktop width +// left the fault panel a narrow card with an empty half page beside it. Side by +// side they fill the row for a reason, and each keeps a readable measure. +.readiness-band { + display: grid; + gap: var(--u-space-3); + margin-bottom: var(--u-space-3); + + .not-ready, + .history-coverage { + margin-bottom: 0; + max-width: none; + } +} + +@media (min-width: 992px) { + .readiness-band.fault { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: start; + } + + // A ready chain has no fault panel beside this one, so stacking the three + // dimensions left a full width card with content in its first third. Read + // across instead: three short columns fill the row they are given. + .readiness-band:not(.fault) .history-coverage ul { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--u-space-4); + } + + .readiness-band:not(.fault) .history-coverage li { + grid-template-columns: minmax(0, 1fr); + align-content: start; + justify-items: start; + gap: var(--u-space-1); + } +} + +// The three history dimensions the capability document reports. Bordered like +// the fault panel beside it rather than raised, because it qualifies the rail +// above rather than opening a new section. +.history-coverage { + @include u.universe-surface; + + border-left: 3px solid var(--universe-state-neutral); + padding: var(--u-space-3) var(--u-space-4); + + h2 { + font-size: var(--u-text-base); + font-weight: var(--u-weight-semibold); + margin: 0 0 var(--u-space-2); + } + + ul { + display: grid; + gap: var(--u-space-2); + list-style: none; + margin: 0; + padding: 0; + } + + // One column for the names and one for the states, shared by all three + // rows, so the states line up and can be read down. Sized per row they + // stepped sideways with the length of each name. + li { + display: grid; + grid-template-columns: minmax(0, 10rem) minmax(0, max-content); + align-items: center; + justify-content: start; + gap: var(--u-space-2); + } + + .coverage-label { + font-size: var(--u-text-sm); + font-weight: var(--u-weight-semibold); + } + + .coverage-detail { + grid-column: 1 / -1; + color: var(--u-text-muted); + font-size: var(--u-text-xs); + line-height: var(--u-leading-snug); + max-width: var(--u-width-prose); + } +} + +.identifier { + @include u.universe-identifier; +} + +.numeric { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.dot { + display: inline-block; + width: 3px; + height: 3px; + margin: 0 0.45rem; + border-radius: var(--u-radius-pill); + background: var(--u-text-faint); + vertical-align: middle; +} + +// --------------------------------------------------------------------------- +// Facts +// --------------------------------------------------------------------------- + +.facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: var(--u-space-3) var(--u-space-5); + margin: 0; + + > div { + min-width: 0; + } + + dt { + color: var(--u-text-muted); + font-size: var(--u-text-xs); + font-weight: var(--u-weight-medium); + } + + dd { + margin: 0.2rem 0 0; + min-width: 0; + overflow-wrap: anywhere; + } +} + +.verdict { + margin: 0 0 var(--u-space-3); +} + +// --------------------------------------------------------------------------- +// Chain overview +// --------------------------------------------------------------------------- + +.read-grid, +.coverage-list, +.action-list, +.conflict-list, +.identifier-list { + list-style: none; + margin: 0; + padding: 0; +} + +.read-grid { + display: grid; + // Capped, not stretched. At a desktop width four equal fractions put each + // label and its state at opposite ends of a wide cell, with the whitespace + // between them doing the binding. + grid-template-columns: repeat(auto-fit, minmax(15rem, 20rem)); + gap: var(--u-space-2) var(--u-space-4); + + li { + display: flex; + align-items: center; + gap: var(--u-space-2); + padding: var(--u-space-2) 0; + border-top: 1px solid var(--u-divider); + } + + // The label does not stretch to the end of its track. It used to, which put + // every state chip against the right edge of a 320 pixel cell with a hand's + // width of nothing binding it to the words it answers. + .read-label { + flex: 0 1 auto; + min-width: 0; + font-size: var(--u-text-sm); + } + + li.off .read-label { + color: var(--u-text-muted); + } +} + +.coverage-list li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--u-space-2) var(--u-space-3); + padding: var(--u-space-3) 0; + border-top: 1px solid var(--u-divider); + + &:first-child { + border-top: 0; + padding-top: 0; + } +} + +.coverage-name { + font-weight: var(--u-weight-semibold); + min-width: 7rem; +} + +.coverage-detail { + color: var(--u-text-muted); + font-size: var(--u-text-sm); +} + +.coverage-reasons-label { + flex-basis: 100%; + color: var(--u-text-muted); + font-size: var(--u-text-2xs); + font-weight: var(--u-weight-semibold); + letter-spacing: var(--u-eyebrow-tracking); + text-transform: uppercase; +} + +.coverage-reasons { + flex-basis: 100%; + color: var(--universe-state-partial); + font-size: var(--u-text-sm); + line-height: var(--u-leading-normal); + list-style: none; + margin: 0; + padding: 0; +} + +// An authority that is working still states the edges of its coverage. Those +// sentences sit under their own label and must not wear the partial colour, +// which on this page means something is wrong. +.coverage-reasons-label + .coverage-reasons { + color: var(--u-text-secondary); +} + +.entry-actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: var(--u-space-3); + margin-bottom: var(--u-space-3); +} + +.entry { + @include u.universe-surface; + + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: var(--u-space-4); + color: var(--u-text-primary); + text-decoration: none; + + @include u.universe-motion(all, var(--u-duration-fast)); + + &:hover, + &:focus-visible { + border-color: var(--u-brand-accent); + background: var(--u-surface-hover); + text-decoration: none; + } + + .entry-title { + font-weight: var(--u-weight-semibold); + color: var(--u-brand); + } + + .entry-detail { + color: var(--u-text-muted); + font-size: var(--u-text-sm); + line-height: var(--u-leading-snug); + } +} + +// --------------------------------------------------------------------------- +// Transaction value flow +// --------------------------------------------------------------------------- + +.flow-grid { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: var(--u-space-4); + align-items: start; +} + +.flow-side { + min-width: 0; + + h3 { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--u-space-3); + margin: 0 0 var(--u-space-2); + padding-bottom: var(--u-space-2); + border-bottom: 1px solid var(--u-divider); + } +} + +.flow-total { + font-family: var(--u-font-data); + font-variant-numeric: tabular-nums; + font-weight: var(--u-weight-semibold); + color: var(--u-text-primary); +} + +// The direction of the transaction, drawn once. It carries no information the +// column headings do not, so it is hidden from assistive technology and simply +// disappears when the columns stack. +.flow-arrow { + align-self: center; + width: 1.5rem; + height: 1px; + background: var(--u-border-strong); + position: relative; + + &::after { + content: ''; + position: absolute; + right: 0; + top: -3px; + border-left: 6px solid var(--u-border-strong); + border-top: 3.5px solid transparent; + border-bottom: 3.5px solid transparent; + } +} + +.party-list { + list-style: none; + margin: 0; + padding: 0; + + li { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + align-items: baseline; + gap: var(--u-space-3); + padding: var(--u-space-2) 0; + border-bottom: 1px solid var(--u-divider); + } + + li:last-child { + border-bottom: 0; + } + +} + +.party-address { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--u-space-2); + min-width: 0; overflow-wrap: anywhere; } -.save, -.protocol-nav a { - border: 1px solid var(--primary); - border-radius: 0.45rem; - color: var(--fg); - padding: 0.55rem 0.75rem; - background: transparent; +.party-amount { + flex: none; + font-family: var(--u-font-data); + font-variant-numeric: tabular-nums; } -.status-panel, -.panel, -.switch-notice { - margin-top: 1rem; - padding: 1rem; - border: 1px solid var(--box-border); - border-radius: 0.6rem; - background: var(--box-bg); +.flow-note { + color: var(--u-text-muted); + font-size: var(--u-text-sm); + line-height: var(--u-leading-normal); + max-width: var(--u-width-prose); + margin: var(--u-space-3) 0 0; } -.status-panel { +.action-list li, +.conflict-list li { display: flex; flex-wrap: wrap; - gap: 0.6rem 1.2rem; + align-items: baseline; + gap: var(--u-space-2) var(--u-space-3); + padding: var(--u-space-2) 0; + border-top: 1px solid var(--u-divider); } -.state { - color: var(--u-state-proven); - font-weight: 700; +.action-protocol { + font-weight: var(--u-weight-semibold); + min-width: 6rem; } -.state.degraded, -.error { - color: var(--u-state-partial); +.action-type { + color: var(--u-text-secondary); + font-size: var(--u-text-sm); } -.facts { +.panel.warning { + border-left: 3px solid var(--universe-state-partial); +} + +// --------------------------------------------------------------------------- +// Figures +// --------------------------------------------------------------------------- + +.figure-row { display: grid; - grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); - gap: 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); + gap: var(--u-space-4); } -.facts div { +.figure { + display: flex; + flex-direction: column; + gap: 0.2rem; min-width: 0; } -.facts dt { - color: var(--transparent-fg); +.figure-label { + color: var(--u-text-muted); + font-size: var(--u-text-2xs); + font-weight: var(--u-weight-semibold); + letter-spacing: var(--u-eyebrow-tracking); + text-transform: uppercase; } -.facts dd { - margin: 0.15rem 0 0; +.figure-value { + font-family: var(--u-font-data); + font-size: var(--u-text-xl); + font-variant-numeric: tabular-nums; + font-weight: var(--u-weight-semibold); + line-height: var(--u-leading-tight); overflow-wrap: anywhere; } -.panel a + a { - margin-left: 1rem; +.figure-unit { + color: var(--u-text-muted); + font-size: var(--u-text-sm); + font-weight: var(--u-weight-normal); +} + +// The unit belongs to the column, so it is stated once in the header rather +// than repeated down every row of a dense table. +.column-unit { + color: var(--u-text-faint); + font-weight: var(--u-weight-normal); +} + +// --------------------------------------------------------------------------- +// Lists and tables +// --------------------------------------------------------------------------- + +.identifier-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr)); + gap: 0 var(--u-space-4); + + li { + padding: 0.3rem 0; + border-bottom: 1px solid var(--u-divider); + min-width: 0; + } +} + +.pager { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--u-space-4); + margin-top: var(--u-space-4); + padding-top: var(--u-space-3); + border-top: 1px solid var(--u-divider); + font-size: var(--u-text-sm); +} + +.pager-position { + color: var(--u-text-muted); +} + +.block-nav { + display: flex; + flex-wrap: wrap; + gap: var(--u-space-4); + margin-top: var(--u-space-4); + padding-top: var(--u-space-3); + border-top: 1px solid var(--u-divider); + font-size: var(--u-text-sm); } .protocol-nav { display: flex; flex-wrap: wrap; - gap: 0.6rem; - margin-top: 1rem; + gap: var(--u-space-2); + margin-bottom: var(--u-space-3); + + a { + border: 1px solid var(--u-border); + border-radius: var(--u-radius-pill); + background: var(--u-surface-raised); + color: var(--u-text-secondary); + font-size: var(--u-text-sm); + padding: 0.3rem 0.8rem; + text-decoration: none; + + @include u.universe-motion(all, var(--u-duration-fast)); + + &:hover { + border-color: var(--u-brand-border); + color: var(--u-brand); + } + + &.current { + background: var(--u-brand-subtle); + border-color: var(--u-brand-border); + color: var(--u-brand); + font-weight: var(--u-weight-semibold); + } + } } +// A table wide enough to scroll is a scrollable region, and a region a mouse +// can pan has to be reachable from a keyboard too. It takes focus, so it also +// has to show that it has it. .table-wrap { overflow-x: auto; + + &:focus-visible { + outline: 2px solid var(--u-focus-ring); + outline-offset: 2px; + } } table { - min-width: 100%; + width: 100%; + border-collapse: collapse; + font-size: var(--u-text-sm); } -th, -td { - padding: 0.55rem; +thead th { + color: var(--u-text-muted); + font-size: var(--u-text-2xs); + font-weight: var(--u-weight-semibold); + letter-spacing: var(--u-eyebrow-tracking); + text-transform: uppercase; text-align: left; + padding: 0 var(--u-space-3) var(--u-space-2) 0; + border-bottom: 1px solid var(--u-border); + white-space: nowrap; + + &.numeric { + text-align: right; + } +} + +tbody td { + padding: var(--u-space-2) var(--u-space-3) var(--u-space-2) 0; + border-bottom: 1px solid var(--u-divider); vertical-align: top; overflow-wrap: anywhere; } -pre { - max-height: 36rem; - overflow: auto; - white-space: pre-wrap; +tbody tr:last-child td { + border-bottom: 0; +} + +// --------------------------------------------------------------------------- +// Evidence +// --------------------------------------------------------------------------- + +.evidence { + summary { + cursor: pointer; + font-weight: var(--u-weight-semibold); + color: var(--u-text-secondary); + } + + &[open] summary { + margin-bottom: var(--u-space-3); + } + + pre { + @include u.universe-identifier; + + background: var(--u-surface-sunken); + border: 1px solid var(--u-border); + border-radius: var(--u-radius-sm); + color: var(--u-text-secondary); + margin: 0; + max-height: 32rem; + overflow: auto; + padding: var(--u-space-3); + white-space: pre-wrap; + } +} + +// --------------------------------------------------------------------------- +// Technical source details +// --------------------------------------------------------------------------- + +// The identifiers the page was rendered from. Closed by default: the snapshot +// identifier used to be the widest thing in the primary status line and said +// nothing to almost everyone reading it, and it is still needed exactly by the +// few people checking a figure against its document. +.source-details { + summary { + cursor: pointer; + font-weight: var(--u-weight-semibold); + color: var(--u-text-secondary); + + // The only control that opens this, and a disclosure triangle beside a + // word is about 18 pixels tall. 44 is the smallest target a finger can be + // expected to hit. + @media (pointer: coarse), (max-width: 991.98px) { + align-items: center; + display: flex; + min-height: 44px; + } + } + + &[open] summary { + margin-bottom: var(--u-space-3); + } +} + +.source-list { + display: grid; + grid-template-columns: minmax(0, 12rem) minmax(0, 1fr); + gap: var(--u-space-2) var(--u-space-4); + margin: 0; + + dt { + color: var(--u-text-muted); + font-size: var(--u-text-sm); + font-weight: var(--u-weight-semibold); + min-width: 0; + } + + dd { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--u-space-2); + margin: 0; + min-width: 0; + } +} + +.source-value { + @include u.universe-identifier; + + // A shortened identifier still has to survive a narrow column and a doubled + // font size without pushing the page sideways. overflow-wrap: anywhere; + min-width: 0; +} + +// The whole identifier, for a screen reader and for anything reading the page +// rather than looking at it. Clipped rather than `display: none`, which would +// take it away from both. +.source-exact { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.source-note { + flex-basis: 100%; + color: var(--u-text-muted); + font-size: var(--u-text-xs); + line-height: var(--u-leading-snug); + max-width: var(--u-width-prose); +} + +.source-copy { + background: none; + border: 1px solid var(--u-border); + border-radius: var(--u-radius-sm); + color: var(--u-text-secondary); + cursor: pointer; + font-size: var(--u-text-2xs); + font-weight: var(--u-weight-semibold); + letter-spacing: var(--u-eyebrow-tracking); + padding: 0.15rem var(--u-space-2); + text-transform: uppercase; + + @include u.universe-motion(all, var(--u-duration-fast)); + + &:hover, + &:focus-visible { + border-color: var(--u-brand-accent); + color: var(--u-brand); + } + + @media (pointer: coarse) { + min-height: 44px; + padding-inline: var(--u-space-3); + } +} + +// --------------------------------------------------------------------------- +// Narrow +// --------------------------------------------------------------------------- + +@media (max-width: 767px) { + .flow-grid { + grid-template-columns: 1fr; + gap: var(--u-space-5); + } + + .flow-arrow { + display: none; + } } -@media (max-width: 32rem) { - .multichain-page { - padding-top: 1rem; +@media (max-width: 575px) { + .chain-page { + padding-top: var(--u-space-4); + } + + .facts { + grid-template-columns: 1fr; + } + + .coverage-name { + min-width: 0; + flex-basis: 100%; } - .page-head { - flex-direction: column; + .source-list { + grid-template-columns: 1fr; + gap: var(--u-space-1) 0; } - .save { - width: 100%; + .source-list dt { + margin-top: var(--u-space-2); } } diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts index 81d7968856a..6951ac0bd48 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, + ChangeDetectorRef, Component, OnDestroy, OnInit, @@ -23,6 +24,42 @@ import { ChainExplorerPayload, ExplorerChain, } from '@app/universe/universe.types'; +import { + AddressReading, + BlockReading, + ChainProfile, + ChainShape, + CollectionReading, + CoverageReading, + EmptyListReading, + Fact, + OutpointReading, + ProtocolReading, + ReadCapability, + SourceDetail, + StatusReading, + TransactionListReading, + TransactionReading, + chainProfile, + classifyPayload, + readAddress, + readBlock, + readCapabilities, + readCollection, + readEmptyList, + readHistoryCoverage, + readIdentifierList, + readNotReadyReasons, + readOutpoint, + readProtocolCoverage, + readRecordFacts, + readSourceDetails, + readStatusRail, + readTransaction, + readTransactionList, + shortenIdentifier, +} from '@app/universe/multichain-explorer/multichain-view'; +import { ChainReasonReading } from '@app/universe/multichain-explorer/chain-reasons'; import { Observable, Subject, @@ -55,14 +92,42 @@ interface ExplorerViewModel { readonly capabilityError: string | null; readonly payload: ChainExplorerPayload | null; readonly payloadError: string | null; - readonly rows: readonly Record[]; - readonly summary: readonly { key: string; value: string }[]; + readonly rail: readonly StatusReading[]; + /** Why the chain withholds readiness. Null when it does not. */ + readonly notReady: readonly ChainReasonReading[] | null; + readonly reads: readonly ReadCapability[]; + readonly coverage: readonly CoverageReading[]; + readonly protocols: readonly ProtocolReading[]; + readonly sourceDetails: readonly SourceDetail[]; + readonly shape: ChainShape; + readonly transaction: TransactionReading | null; + readonly block: BlockReading | null; + readonly address: AddressReading | null; + readonly outpoint: OutpointReading | null; + readonly collection: CollectionReading | null; + readonly transactionList: TransactionListReading | null; + readonly emptyList: EmptyListReading | null; + readonly facts: readonly Fact[]; + /** True when the page is showing a response it has no purpose-built reading for. */ + readonly generic: boolean; } interface RequestContext { readonly page: MultichainPage; readonly params: ParamMap; readonly ruleset?: string; + /** 1-based list page, from the `page` query parameter. */ + readonly listPage: number; +} + +/** Rows a paged list requests at a time. Also the offset step. */ +const LIST_PAGE_SIZE = 100; + +function listPageFrom(value: string | null): number { + if (!value || !/^[1-9][0-9]{0,5}$/.test(value)) { + return 1; + } + return Number(value); } const PAGE_KINDS: Partial> = { @@ -73,6 +138,42 @@ const PAGE_KINDS: Partial> = { 'protocol-detail': 'protocol', }; +/** + * Fields a purpose-built reading already presents. They are skipped when the + * page lists the remaining scalars, so a value never appears twice: once in + * its designed place and again in the leftover record. + */ +const PRESENTED_FIELDS: Partial> = { + transaction: [ + 'schemaVersion', 'chain', 'network', 'txid', 'status', 'block', 'fee', + 'transparent', 'shielded', 'protocolActions', 'replacement', 'expiry', + 'confirmationsAtomic', 'sizeBytesAtomic', 'virtualSizeBytesAtomic', + 'firstSeenAt', 'completeness', + ], + block: [ + 'chain', 'network', 'block', 'pagination', + // The Zcash spellings, read by the block reading rather than dumped. + 'schemaVersion', 'hash', 'height', 'prev_hash', 'time', 'tx_count', + 'transactions', + // What the source says about its own coverage of the chain. The status + // rail already states every one of these from the capability document, + // and a second copy as a nested field table is not a second fact. + 'checkpoint', 'coverage', + ], + address: [ + 'chain', 'network', 'address', 'balanceAtomic', 'totalReceivedAtomic', + 'totalSentAtomic', 'unconfirmedBalanceAtomic', 'transactionCountAtomic', + 'unconfirmedTransactionsAtomic', + // The Zcash spellings, read by the address reading. `address_type` and + // `publicly_observable` are not repeated here because they are facts about + // this address that nothing else on the page states. + 'schemaVersion', 'balance', 'utxos', 'transactions', 'checkpoint', 'coverage', + ], + outpoint: ['chain', 'network', 'outpoint', 'output', 'transaction'], + collection: ['chain', 'network'], + record: ['chain', 'network'], +}; + @Component({ selector: 'app-multichain-explorer', standalone: true, @@ -85,6 +186,7 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { private readonly destroyed$ = new Subject(); readonly chain: Exclude; + readonly profile: ChainProfile; readonly chainName: string; readonly ticker: string; readonly protocolIds: readonly string[]; @@ -100,15 +202,14 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { private readonly api: UniverseApiService, private readonly live: UniverseWebsocketService, private readonly local: UniverseLocalService, - private readonly seo: SeoService + private readonly seo: SeoService, + private readonly changes: ChangeDetectorRef ) { this.chain = this.chainFromUrl(router.url); - this.chainName = this.chain === 'dogecoin' ? 'Dogecoin' : 'Zcash'; - this.ticker = this.chain === 'dogecoin' ? 'DOGE' : 'ZEC'; - this.protocolIds = - this.chain === 'dogecoin' - ? ['doginals', 'drc20', 'doge-tap'] - : ['zerdinals', 'zrunes', 'zrc20']; + this.profile = chainProfile(this.chain); + this.chainName = this.profile.name; + this.ticker = this.profile.ticker; + this.protocolIds = this.profile.protocols.map((tab) => tab.id); } ngOnInit(): void { @@ -121,6 +222,7 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { page: data.page as MultichainPage, params, ruleset: queryParams.get('ruleset') ?? undefined, + listPage: listPageFrom(queryParams.get('page')), })) ); @@ -132,6 +234,7 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { this.route.snapshot.queryParamMap.get('switchedFrom'); this.saved = this.isSaved(context); this.seo.setTitle(this.pageTitle(context)); + this.seo.setDescription(this.pageDescription(context)); this.recordVisit(context); return merge(timer(0, 15_000), this.live.stream$(this.chain)).pipe( auditTime(100), @@ -151,14 +254,9 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { ), ]) ), - map(([status, result]): ExplorerViewModel => ({ - capability: status.capability, - capabilityError: status.error, - payload: result.payload, - payloadError: result.error, - rows: this.rowsFrom(result.payload), - summary: this.summaryFrom(result.payload), - })) + map(([status, result]): ExplorerViewModel => + this.viewModel(status.capability, status.error, result.payload, result.error) + ) ); }), takeUntil(this.destroyed$) @@ -170,6 +268,57 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { this.destroyed$.complete(); } + private viewModel( + capability: ChainCapabilityEnvelope | null, + capabilityError: string | null, + payload: ChainExplorerPayload | null, + payloadError: string | null + ): ExplorerViewModel { + const shape = classifyPayload(payload); + const transaction = payload && shape === 'transaction' ? readTransaction(payload, this.profile) : null; + const block = payload && shape === 'block' ? readBlock(payload) : null; + const address = payload && shape === 'address' ? readAddress(payload, this.profile) : null; + const outpoint = payload && shape === 'outpoint' ? readOutpoint(payload, this.profile) : null; + // A list of whole transaction envelopes gets read as transactions. Only + // what is left over falls through to the generic table. + const transactionList = + payload && shape === 'collection' + ? readTransactionList(payload, this.profile) + : null; + const collection = + payload && !transactionList && (shape === 'collection' || shape === 'record') + ? readCollection(payload, this.profile) + : null; + const skip = [ + ...(PRESENTED_FIELDS[shape] ?? []), + ...(collection ? [collection.sourceKey] : []), + // The transaction list owns the array it read, whichever field it was in. + ...(transactionList ? ['transactions', 'items'] : []), + ]; + return { + capability, + capabilityError, + payload, + payloadError, + rail: readStatusRail(capability, this.profile, Date.now()), + notReady: readNotReadyReasons(capability), + reads: readCapabilities(capability), + coverage: readHistoryCoverage(capability), + protocols: readProtocolCoverage(capability, this.profile), + sourceDetails: readSourceDetails(capability), + shape, + transaction, + block, + address, + outpoint, + collection, + transactionList, + emptyList: transactionList || collection ? null : readEmptyList(payload), + facts: readRecordFacts(payload, this.profile, skip), + generic: shape === 'collection' || shape === 'record', + }; + } + toggleSaved(): void { const kind = PAGE_KINDS[this.page]; if (!kind || !this.reference) { @@ -185,42 +334,150 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { }); } - pageLabel(): string { - return this.page.replace('protocol-', '').replace('-', ' '); + /** True when this page addresses a single object a visitor can save. */ + savable(): boolean { + return !!PAGE_KINDS[this.page] && !!this.reference; } - stateLabel(capability: ChainCapabilityEnvelope): string { - return capability.ready ? 'ready' : 'degraded'; + pageLabel(): string { + switch (this.page) { + case 'dashboard': + return $localize`:@@universe.chain.page-dashboard:overview`; + case 'mempool': + return $localize`:@@universe.chain.page-mempool:pending transactions`; + case 'block': + return $localize`:@@universe.chain.page-block:block`; + case 'transaction': + return $localize`:@@universe.chain.page-transaction:transaction`; + case 'address': + return $localize`:@@universe.chain.page-address:address`; + case 'outpoint': + return $localize`:@@universe.chain.page-outpoint:outpoint`; + case 'protocols': + return $localize`:@@universe.chain.page-protocols:protocols`; + case 'protocol-list': + return $localize`:@@universe.chain.page-protocol-list:protocol assets`; + case 'protocol-detail': + return $localize`:@@universe.chain.page-protocol-detail:protocol asset`; + case 'protocol-holders': + return $localize`:@@universe.chain.page-protocol-holders:holders`; + case 'protocol-events': + return $localize`:@@universe.chain.page-protocol-events:events`; + default: + return ''; + } } - freshness(capability: ChainCapabilityEnvelope): string { - const observed = Date.parse(capability.updatedAt); - if (!Number.isFinite(observed)) { - return 'freshness unknown'; - } - const seconds = Math.max(0, Math.floor((Date.now() - observed) / 1000)); - if (seconds < 60) { - return `${seconds}s ago`; + /** One sentence saying what the reader is looking at, under the heading. */ + pageLede(): string { + switch (this.page) { + case 'dashboard': + return $localize`:@@universe.chain.lede-dashboard:What this explorer can answer about ${this.chainName}:CHAIN: right now, and how far behind the chain tip each answer is.`; + case 'mempool': + return $localize`:@@universe.chain.lede-mempool:Transactions seen by our own ${this.chainName}:CHAIN: node and not yet in a block.`; + case 'protocols': + return $localize`:@@universe.chain.lede-protocols:The asset protocols this explorer indexes on ${this.chainName}:CHAIN:, and the state of each indexer.`; + default: + return ''; } - return `${Math.floor(seconds / 60)}m ago`; } - value(value: unknown): string { - if (value === null || value === undefined) { - return 'not available'; + /** Query parameters for a link to another page of the current list. */ + pageLink(page: number): Record { + return { page: String(page) }; + } + + isProtocolPage(): boolean { + return this.page.startsWith('protocol'); + } + + activeProtocolId(): string { + return this.route.snapshot.paramMap.get('protocol') ?? ''; + } + + short(value: string, lead = 8): string { + return shortenIdentifier(value, lead); + } + + identifierList(values: unknown): readonly string[] { + return readIdentifierList(values); + } + + trackByIndex(index: number): number { + return index; + } + + /** + * "inscriptions (3), zrune events (1)". + * + * The count sits beside the name rather than in front of it. The names come + * from the response's own field names, which are already plural, so "1 + * inscriptions" is what putting the number first produces, for every list + * with one entry in it. + */ + unlistedSummary(entries: readonly { label: string; count: number }[]): string { + return entries + .map((entry) => `${entry.label.toLowerCase()} (${entry.count})`) + .join(', '); + } + + /** + * The identifier a technical detail was last copied from. + * + * Kept so the confirmation names what was copied rather than appearing next + * to every row at once, and cleared on the next copy rather than on a timer, + * because a message that vanishes on its own is one a screen reader can miss. + */ + copiedDetailId: string | null = null; + + /** + * What the copy control is called, naming the value it copies. + * + * A row of buttons all reading "Copy" is unusable without the table around + * them, which a screen reader is under no obligation to convey, and the + * confirmation has to reach the same place the action did. + */ + copyActionLabel(detail: { id: string; label: string }): string { + return this.copiedDetailId === detail.id + ? $localize`:@@universe.chain.source-copied-label:Copied ${detail.label}:LABEL:` + : $localize`:@@universe.chain.source-copy-label:Copy ${detail.label}:LABEL:`; + } + + async copyDetail(detail: { id: string; exact: string | null }): Promise { + if (!detail.exact) { + return; } - if (typeof value === 'object') { - return JSON.stringify(value); + try { + await navigator.clipboard.writeText(detail.exact); + this.copiedDetailId = detail.id; + this.changes.markForCheck(); + } catch { + // A browser that refuses the clipboard has still shown the exact value, + // which is selectable. Saying "copied" when nothing was would be worse + // than saying nothing. + this.copiedDetailId = null; + this.changes.markForCheck(); } - return String(value); } - keys(row: Record): string[] { - return Object.keys(row).slice(0, 8); + trackById(_index: number, item: { id: string }): string { + return item.id; } - trackRow(index: number): number { - return index; + trackByKey(_index: number, item: { key: string }): string { + return item.key; + } + + trackByProtocol(_index: number, item: { protocolId: string }): string { + return item.protocolId; + } + + trackByText(_index: number, value: string): string { + return value; + } + + trackByTxid(_index: number, item: { txid: string }): string { + return item.txid; } private chainFromUrl(url: string): Exclude { @@ -243,9 +500,19 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { case 'transaction': return this.api.getChainTransaction$(this.chain, txid); case 'block': - return this.api.getChainBlock$(this.chain, reference); + return this.api.getChainBlock$( + this.chain, + reference, + LIST_PAGE_SIZE, + (context.listPage - 1) * LIST_PAGE_SIZE + ); case 'address': - return this.api.getChainAddress$(this.chain, reference); + return this.api.getChainAddress$( + this.chain, + reference, + LIST_PAGE_SIZE, + (context.listPage - 1) * LIST_PAGE_SIZE + ); case 'outpoint': return this.api.getChainOutpoint$( this.chain, @@ -331,54 +598,45 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { ); } - private pageTitle(context: RequestContext): string { - const suffix = this.referenceFrom(context); - return `${this.chainName} ${context.page.replace('-', ' ')}${suffix ? ` ${suffix}` : ''}`; - } - - private rowsFrom( - payload: ChainExplorerPayload | null - ): readonly Record[] { - if (!payload) { - return []; - } - for (const key of [ - 'transactions', - 'items', - 'data', - 'utxos', - 'holders', - 'events', - 'outputs', - 'inputs', - ]) { - const candidate = payload[key]; - if (Array.isArray(candidate)) { - return candidate - .slice(0, 100) - .map((item) => - typeof item === 'object' && item !== null - ? (item as Record) - : { value: item } - ); - } + /** + * What a shared link to this page says it is. + * + * Without this every chain page fell back to the site description, so a + * Dogecoin transaction and the Zcash protocol list previewed identically + * wherever a link was pasted. + */ + private pageDescription(context: RequestContext): string { + const reference = this.referenceFrom(context); + switch (context.page) { + case 'dashboard': + return $localize`:@@universe.chain.meta-dashboard:What Universe Explorer can answer about ${this.chainName}:CHAIN: right now, how far behind the chain tip each answer is, and which protocol indexers are running.`; + case 'mempool': + return $localize`:@@universe.chain.meta-mempool:${this.chainName}:CHAIN: transactions seen by Bitcoin Universe's own node and not yet in a block, read from first-party data.`; + case 'protocols': + return $localize`:@@universe.chain.meta-protocols:The asset protocols Universe Explorer indexes on ${this.chainName}:CHAIN:, and the state and coverage of each indexer.`; + case 'transaction': + return $localize`:@@universe.chain.meta-transaction:${this.chainName}:CHAIN: transaction ${reference}:REFERENCE:: its state, transparent value, and the protocol actions the authority reported, with the evidence behind each.`; + case 'block': + return $localize`:@@universe.chain.meta-block:${this.chainName}:CHAIN: block ${reference}:REFERENCE:: its header facts and the transactions it contains, from first-party data.`; + case 'address': + return $localize`:@@universe.chain.meta-address:${this.chainName}:CHAIN: address ${reference}:REFERENCE:: balance, history, and unspent outputs, with the block each figure is true as of.`; + case 'outpoint': + return $localize`:@@universe.chain.meta-outpoint:${this.chainName}:CHAIN: output ${reference}:REFERENCE:: what it carries and whether it has been spent.`; + default: + return $localize`:@@universe.chain.meta-generic:${this.chainName}:CHAIN: data from Bitcoin Universe's own node and protocol indexers, with the evidence behind every claim.`; } - return []; } - private summaryFrom( - payload: ChainExplorerPayload | null - ): readonly { key: string; value: string }[] { - if (!payload) { - return []; - } - return Object.entries(payload) - .filter( - ([, value]) => - !Array.isArray(value) && (typeof value !== 'object' || value === null) - ) - .slice(0, 24) - .map(([key, value]) => ({ key, value: this.value(value) })); + /** + * The tab title. The chain is already the suffix the SEO service appends, so + * naming it here too produced "Dogecoin dashboard - Universe Explorer - + * Dogecoin". It also said "dashboard" where the heading said "overview". + */ + private pageTitle(context: RequestContext): string { + const label = this.pageLabel(); + const reference = this.referenceFrom(context); + const subject = label.charAt(0).toUpperCase() + label.slice(1); + return reference ? `${subject} ${reference}` : subject; } private errorMessage(error: unknown): string { @@ -386,6 +644,15 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { typeof error === 'object' && error !== null && 'status' in error ? String((error as { status: unknown }).status) : ''; - return status ? `Request failed (${status}).` : 'Request failed.'; + if (status === '404') { + return $localize`:@@universe.chain.error-not-found:The ${this.chainName}:CHAIN: authority has no record of this object. It may not exist, or it may be outside the range this indexer covers.`; + } + if (status === '503') { + return $localize`:@@universe.chain.error-unavailable:The ${this.chainName}:CHAIN: authority is not answering right now. Nothing here is stale data presented as current: the page shows no facts rather than old ones.`; + } + if (status === '400') { + return $localize`:@@universe.chain.error-malformed:That identifier is not a valid ${this.chainName}:CHAIN: reference, so no lookup was made.`; + } + return $localize`:@@universe.chain.error-generic:The request to the ${this.chainName}:CHAIN: authority did not complete.`; } } diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts new file mode 100644 index 00000000000..6c4827a359e --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts @@ -0,0 +1,920 @@ +import { describe, expect, it } from 'vitest'; +import { + ChainCapabilityEnvelope, + ChainExplorerPayload, +} from '@app/universe/universe.types'; +import { + chainProfile, + classifyPayload, + formatAtomicAmount, + formatElapsed, + formatExactInteger, + formatTimestamp, + historyLabel, + humanizeFieldName, + readAddress, + readBlock, + readCapabilities, + readCollection, + readEmptyList, + readHistoryCoverage, + readNotReadyReasons, + readOutpoint, + readPaging, + readProtocolCoverage, + readRecordFacts, + readSourceDetails, + readStatusRail, + readTransaction, + shortenIdentifier, +} from '@app/universe/multichain-explorer/multichain-view'; + +const DOGE = chainProfile('dogecoin'); +const ZEC = chainProfile('zcash'); +const TXID = 'a'.repeat(64); +const BLOCK_HASH = 'b'.repeat(64); + +function capability( + overrides: Partial = {} +): ChainCapabilityEnvelope { + return { + schemaVersion: 'universe-chain-capability-v1', + chain: 'dogecoin', + network: 'mainnet', + asset: { symbol: 'DOGE', name: 'Dogecoin', precision: 8, atomicUnit: 'koinu' }, + ready: true, + tip: { heightAtomic: '5123456', blockHash: BLOCK_HASH, observedAt: '2026-08-29T05:00:00.000Z' }, + sync: { state: 'ready', initialBlockDownload: false, progressDecimal: '1', updatedAt: '2026-08-29T05:00:00.000Z' }, + mempool: { supported: true, state: 'ready', completeness: 'complete', snapshotId: 's1', sequenceAtomic: '9', observedAt: '2026-08-29T05:00:00.000Z' }, + reads: { transaction: true, block: true, address: true, outpoint: true, feeEstimates: false, projectedBlocks: false }, + protocols: [], + coverage: { confirmedHistory: 'complete', addressHistory: 'complete', protocolHistory: 'partial' }, + updatedAt: '2026-08-29T05:00:00.000Z', + lagBlocksAtomic: '0', + degradedReasons: [], + release: { sha: 'abc1234' }, + ...overrides, + } as ChainCapabilityEnvelope; +} + +describe('formatExactInteger', () => { + it('groups digits and keeps the source string', () => { + expect(formatExactInteger('5123456')).toEqual({ display: '5,123,456', exact: '5123456' }); + }); + + it('keeps zero as zero rather than treating it as absent', () => { + expect(formatExactInteger('0')).toEqual({ display: '0', exact: '0' }); + }); + + it('groups a negative value on the digits only', () => { + expect(formatExactInteger('-1234')?.display).toBe('-1,234'); + }); + + it('rejects anything that is not an exact integer string', () => { + expect(formatExactInteger('1.5')).toBeNull(); + expect(formatExactInteger('007')).toBeNull(); + expect(formatExactInteger('')).toBeNull(); + expect(formatExactInteger(null)).toBeNull(); + expect(formatExactInteger(undefined)).toBeNull(); + }); +}); + +describe('formatAtomicAmount', () => { + it('shifts an atomic amount by the chain precision', () => { + expect(formatAtomicAmount('100000000', 8)?.display).toBe('1'); + }); + + it('drops trailing zeros but keeps significant ones', () => { + expect(formatAtomicAmount('123450000', 8)?.display).toBe('1.2345'); + expect(formatAtomicAmount('100000001', 8)?.display).toBe('1.00000001'); + }); + + it('pads an amount smaller than one whole unit', () => { + expect(formatAtomicAmount('1', 8)?.display).toBe('0.00000001'); + expect(formatAtomicAmount('0', 8)?.display).toBe('0'); + }); + + it('groups the whole part of a large balance', () => { + expect(formatAtomicAmount('123456789012345678', 8)?.display).toBe('1,234,567,890.12345678'); + }); + + it('stays exact past the range a double counts single units in', () => { + // 2^53 koinu plus one. Parsing this as a number would lose the last digit. + const amount = formatAtomicAmount('9007199254740993', 8); + expect(amount?.display).toBe('90,071,992.54740993'); + expect(amount?.exact).toBe('9007199254740993'); + }); + + it('keeps the sign on a negative value balance', () => { + expect(formatAtomicAmount('-50000000', 8)?.display).toBe('-0.5'); + }); +}); + +describe('formatElapsed', () => { + const now = Date.parse('2026-08-29T06:00:00.000Z'); + + it('reports coarse units', () => { + expect(formatElapsed('2026-08-29T05:59:30.000Z', now)).toContain('30'); + expect(formatElapsed('2026-08-29T05:30:00.000Z', now)).toContain('30'); + expect(formatElapsed('2026-08-29T01:00:00.000Z', now)).toContain('5'); + }); + + it('says one minute rather than 1 minutes', () => { + expect(formatElapsed('2026-08-29T05:59:00.000Z', now)).toBe('1 minute ago'); + expect(formatElapsed('2026-08-29T05:59:59.000Z', now)).toBe('1 second ago'); + expect(formatElapsed('2026-08-29T05:00:00.000Z', now)).toBe('1 hour ago'); + expect(formatElapsed('2026-08-26T06:00:00.000Z', now)).toBe('3 days ago'); + }); + + it('refuses to invent a freshness it cannot compute', () => { + expect(formatElapsed('not a date', now)).toBeNull(); + expect(formatElapsed(null, now)).toBeNull(); + // A timestamp from the future is a clock problem, not a fresh reading. + expect(formatElapsed('2026-08-29T07:00:00.000Z', now)).toBeNull(); + }); +}); + +describe('historyLabel', () => { + it('reads as a phrase rather than a bare adjective', () => { + expect(historyLabel('complete')).toBe('Complete history'); + expect(historyLabel('partial')).toBe('Partial history'); + expect(historyLabel(undefined)).toBe('History not stated'); + }); +}); + +describe('formatTimestamp', () => { + it('reads at a glance and keeps the exact string', () => { + const moment = formatTimestamp('2026-08-29T04:02:00.000Z'); + expect(moment?.display).toBe('29 Aug 2026, 04:02 UTC'); + expect(moment?.exact).toBe('2026-08-29T04:02:00.000Z'); + }); + + it('always reports UTC, so two readers describe the same block the same way', () => { + expect(formatTimestamp('2026-01-05T23:07:09Z')?.display).toBe('5 Jan 2026, 23:07 UTC'); + }); + + it('returns nothing rather than an invented date', () => { + expect(formatTimestamp('not a date')).toBeNull(); + expect(formatTimestamp(null)).toBeNull(); + }); +}); + +describe('readPaging', () => { + it('offers the neighbouring pages that exist', () => { + expect(readPaging({ pageAtomic: '2', totalPagesAtomic: '3' })).toEqual({ + page: 2, totalPages: 3, previousPage: 1, nextPage: 3, + }); + }); + + it('offers no previous page on the first and no next on the last', () => { + expect(readPaging({ pageAtomic: '1', totalPagesAtomic: '3' })?.previousPage).toBeNull(); + expect(readPaging({ pageAtomic: '3', totalPagesAtomic: '3' })?.nextPage).toBeNull(); + }); + + it('rejects paging it cannot trust rather than linking nowhere', () => { + expect(readPaging({ pageAtomic: '0', totalPagesAtomic: '3' })).toBeNull(); + expect(readPaging({ pageAtomic: '2' })).toBeNull(); + expect(readPaging(null)).toBeNull(); + }); +}); + +describe('humanizeFieldName', () => { + it('drops the exact-transport suffix the contract uses', () => { + expect(humanizeFieldName('heightAtomic')).toBe('Height'); + expect(humanizeFieldName('progressDecimal')).toBe('Progress'); + }); + + it('splits camel case into a sentence', () => { + expect(humanizeFieldName('totalReceivedAtomic')).toBe('Total received'); + expect(humanizeFieldName('unconfirmedBalanceAtomic')).toBe('Unconfirmed balance'); + }); + + it('keeps the names a reader already knows', () => { + expect(humanizeFieldName('txid')).toBe('Transaction id'); + expect(humanizeFieldName('utxos')).toBe('Unspent outputs'); + expect(humanizeFieldName('drc20')).toBe('DRC-20'); + }); + + it('returns the key unchanged when there is nothing to humanise', () => { + expect(humanizeFieldName('')).toBe(''); + }); +}); + +describe('shortenIdentifier', () => { + it('keeps both ends so the value stays checkable', () => { + const short = shortenIdentifier(TXID); + expect(short.startsWith('aaaaaaaa')).toBe(true); + expect(short.endsWith('aaaaaa')).toBe(true); + }); + + it('leaves a short value alone', () => { + expect(shortenIdentifier('abc')).toBe('abc'); + }); +}); + +describe('readStatusRail', () => { + it('always returns the same five readings, in order', () => { + const ids = readStatusRail(capability(), DOGE, Date.now()).map((r) => r.id); + expect(ids).toEqual(['state', 'tip', 'lag', 'freshness', 'mempool']); + }); + + it('keeps all five readings when there is no status at all', () => { + const rail = readStatusRail(null, DOGE, Date.now()); + expect(rail).toHaveLength(5); + expect(rail[0].tone).toBe('unavailable'); + }); + + it('calls a chain at the tip proven and a lagging one partial', () => { + const now = Date.parse('2026-08-29T05:00:10.000Z'); + const atTip = readStatusRail(capability(), DOGE, now).find((r) => r.id === 'lag'); + expect(atTip?.tone).toBe('proven'); + + const behind = readStatusRail( + capability({ lagBlocksAtomic: '42' }), + DOGE, + now + ).find((r) => r.id === 'lag'); + expect(behind?.tone).toBe('partial'); + expect(behind?.exact).toBe('42'); + }); + + it('says a chain without a pending set is not offering one, not that it failed', () => { + const reading = readStatusRail( + capability({ mempool: { supported: false, state: 'unavailable', completeness: 'unavailable', snapshotId: null, sequenceAtomic: null, observedAt: null } }), + DOGE, + Date.now() + ).find((r) => r.id === 'mempool'); + expect(reading?.tone).toBe('neutral'); + }); +}); + +describe('readStatusRail freshness', () => { + // "3 seconds ago" is the reading and the instant behind it was dropped, so + // the one figure on the rail that changes on its own could not be checked + // against anything. + it('carries the exact observation time behind the relative one', () => { + const reading = readStatusRail(capability(), DOGE, Date.now()).find( + (r) => r.id === 'freshness' + ); + expect(reading?.exact).toBe('2026-08-29T05:00:00.000Z'); + }); +}); + +describe('readHistoryCoverage', () => { + it('reports all three dimensions, in order', () => { + const readings = readHistoryCoverage(capability()); + expect(readings.map((r) => r.id)).toEqual([ + 'confirmedHistory', + 'addressHistory', + 'protocolHistory', + ]); + }); + + // The overview rendered `reads` and dropped `coverage`, so Dogecoin said + // address history was offered while the same document said it could not be + // read. Both statements are in the envelope and both have to be on the page. + it('says an unavailable history is unavailable while the read is still offered', () => { + const envelope = capability({ + ready: false, + coverage: { + confirmedHistory: 'unavailable', + addressHistory: 'unavailable', + protocolHistory: 'unavailable', + }, + }); + expect(readCapabilities(envelope).find((r) => r.id === 'address')?.state).toBe( + 'offered' + ); + const address = readHistoryCoverage(envelope).find( + (r) => r.id === 'addressHistory' + ); + expect(address?.tone).toBe('unavailable'); + expect(address?.stateLabel).not.toBe('Complete'); + }); + + it('states the dimensions rather than dropping them when nothing answered', () => { + const readings = readHistoryCoverage(null); + expect(readings).toHaveLength(3); + expect(readings.every((r) => r.tone === 'neutral')).toBe(true); + }); +}); + +describe('readSourceDetails', () => { + it('keeps the whole snapshot identifier for copying and shortens what is shown', () => { + const long = 'dogecoin-mainnet-9304b4b3a53e788898da63a1b52cd509'; + const detail = readSourceDetails( + capability({ + mempool: { + supported: true, + state: 'ready', + completeness: 'complete', + snapshotId: long, + sequenceAtomic: '10916', + observedAt: '2026-08-29T05:00:00.000Z', + }, + }) + ).find((entry) => entry.id === 'snapshot'); + expect(detail?.exact).toBe(long); + expect(detail?.display).not.toBe(long); + expect(detail?.display.length).toBeLessThan(long.length); + expect(detail?.truncated).toBe(true); + }); + + it('names which component the release identifier belongs to', () => { + const detail = readSourceDetails(capability()).find( + (entry) => entry.id === 'release' + ); + expect(detail?.exact).toBe('abc1234'); + expect(detail?.note).toContain('overlay'); + expect(detail?.truncated).toBe(false); + }); + + it('carries the exact observation time and the schema the document declares', () => { + const details = readSourceDetails(capability()); + expect(details.find((entry) => entry.id === 'observed')?.exact).toBe( + '2026-08-29T05:00:00.000Z' + ); + expect(details.find((entry) => entry.id === 'schema')?.display).toBe( + 'universe-chain-capability-v1' + ); + }); + + it('has nothing to file when no document was read', () => { + expect(readSourceDetails(null)).toEqual([]); + }); +}); + +describe('readCapabilities', () => { + it('lists every read, marking the ones the chain does not offer', () => { + const reads = readCapabilities(capability()); + expect(reads.map((r) => r.id)).toEqual([ + 'transaction', 'block', 'address', 'outpoint', 'feeEstimates', 'projectedBlocks', + ]); + expect(reads.find((r) => r.id === 'projectedBlocks')?.state).toBe('not-offered'); + expect(reads.find((r) => r.id === 'transaction')?.state).toBe('offered'); + }); + + it('says nothing was stated when there is no envelope, rather than nothing offered', () => { + const reads = readCapabilities(null); + expect(reads.every((r) => r.state === 'unknown')).toBe(true); + expect(reads.every((r) => r.tone === 'neutral')).toBe(true); + }); +}); + +describe('readProtocolCoverage', () => { + it('shows every protocol the navigation offers, even one the overlay stopped reporting', () => { + const readings = readProtocolCoverage(capability({ protocols: [] }), DOGE); + expect(readings.map((r) => r.routeId)).toEqual(['doginals', 'drc20', 'doge-tap']); + expect(readings[0].tone).toBe('neutral'); + }); + + it('matches a tab to the registry id the envelope actually uses', () => { + // Production reports tap_doge; the route serves it at doge-tap. Matching on + // the route id alone listed the protocol twice, once falsely as not stated. + const readings = readProtocolCoverage( + capability({ + protocols: [ + { protocolId: 'tap_doge', state: 'degraded', coverage: 'unavailable', updatedAt: null, lagBlocksAtomic: null, degradedReasons: [] }, + ], + }), + DOGE + ); + expect(readings).toHaveLength(3); + const tap = readings.find((r) => r.routeId === 'doge-tap'); + expect(tap?.tone).toBe('partial'); + expect(tap?.label).toBe('TAP on Doge'); + expect(readings.filter((r) => r.label === 'TAP on Doge')).toHaveLength(1); + }); + + it('lists a protocol it has no page for, and offers no route to it', () => { + // /dogecoin/protocols/dunes answers 404 and the API service throws before + // it asks, so a link there is a dead end. Hiding it would be a claim the + // chain did not make. + const readings = readProtocolCoverage( + capability({ + protocols: [ + { protocolId: 'dunes', state: 'unavailable', coverage: 'unavailable', updatedAt: null, lagBlocksAtomic: null, degradedReasons: [] }, + ], + }), + DOGE + ); + const dunes = readings.find((r) => r.protocolId === 'dunes'); + expect(dunes).toBeDefined(); + expect(dunes?.routeId).toBeNull(); + expect(dunes?.label).toBe('Dunes'); + }); + + it('carries the state, coverage and lag the overlay reported', () => { + const readings = readProtocolCoverage( + capability({ + protocols: [ + { protocolId: 'drc20', state: 'degraded', coverage: 'partial', updatedAt: null, lagBlocksAtomic: '17', degradedReasons: ['protocol-authority-stale'] }, + ], + }), + DOGE + ); + const drc20 = readings.find((r) => r.protocolId === 'drc20'); + expect(drc20?.tone).toBe('partial'); + expect(drc20?.lag?.display).toBe('17'); + expect(drc20?.reasons.map((reason) => reason.code)).toEqual(['protocol-authority-stale']); + expect(drc20?.historyLabel).toBe(historyLabel('partial')); + }); + + it('appends a protocol the overlay reports that the profile does not list', () => { + const readings = readProtocolCoverage( + capability({ + protocols: [ + { protocolId: 'newthing', state: 'ready', coverage: 'complete', updatedAt: null, lagBlocksAtomic: null, degradedReasons: [] }, + ], + }), + DOGE + ); + expect(readings.map((r) => r.protocolId)).toContain('newthing'); + expect(readings.find((r) => r.protocolId === 'newthing')?.routeId).toBeNull(); + }); +}); + +describe('classifyPayload', () => { + it('recognises each shape by the fields its contract guarantees', () => { + expect(classifyPayload(null)).toBe('empty'); + expect(classifyPayload({ schemaVersion: 'universe-transaction-v1', txid: TXID })).toBe('transaction'); + expect(classifyPayload({ block: { hash: BLOCK_HASH } })).toBe('block'); + expect(classifyPayload({ outpoint: { txid: TXID, voutAtomic: '0' } })).toBe('outpoint'); + expect(classifyPayload({ address: 'D7Zx' })).toBe('address'); + expect(classifyPayload({ items: [{ a: 1 }] })).toBe('collection'); + expect(classifyPayload({ state: 'unavailable' })).toBe('record'); + }); + + it('does not read a transaction envelope from a schema version alone', () => { + expect(classifyPayload({ schemaVersion: 'universe-transaction-v1' })).toBe('record'); + }); + + it('does not treat an empty array as a collection', () => { + expect(classifyPayload({ items: [] })).toBe('record'); + }); +}); + +describe('readTransaction', () => { + const envelope: ChainExplorerPayload = { + schemaVersion: 'universe-transaction-v1', + chain: 'zcash', + network: 'mainnet', + txid: TXID, + status: 'confirmed', + firstSeenAt: '2026-08-29T04:00:00.000Z', + confirmationsAtomic: '12', + sizeBytesAtomic: '1450', + block: { hash: BLOCK_HASH, heightAtomic: '2900001', time: '2026-08-29T04:10:00.000Z' }, + fee: { amountAtomic: '15000', rateDecimal: null, rateUnit: null, logicalActionsAtomic: '2' }, + conflicts: [], + replacement: null, + expiry: null, + transparent: { + inputs: [ + { indexAtomic: '0', previousOutpoint: `${TXID}:1`, address: 't1abc', valueAtomic: '200000000', coinbase: false }, + ], + outputs: [ + { indexAtomic: '0', address: 't1def', valueAtomic: '150000000', spent: false }, + { indexAtomic: '1', address: 't1ghi', valueAtomic: '49985000', spent: true }, + ], + }, + shielded: { + sproutJoinSplitsAtomic: '0', + saplingSpendsAtomic: '2', + saplingOutputsAtomic: '2', + orchardActionsAtomic: '0', + ironwoodActionsAtomic: '0', + valueBalanceAtomic: '-50000000', + privacyNotice: 'Shielded participants are not recorded on chain.', + }, + protocolActions: { + candidates: [{ eventId: 'e1', protocolId: 'zerdinals', state: 'candidate', actionType: 'inscribe', evidenceIds: [] }], + confirmed: [{ eventId: 'e2', protocolId: 'zrc20', state: 'confirmed-rejected', actionType: 'transfer', evidenceIds: [] }], + }, + evidenceIds: [], + completeness: 'complete', + }; + + it('reads the lifecycle state as a sentence with an evidence tone', () => { + const tx = readTransaction(envelope, ZEC); + expect(tx?.statusTone).toBe('proven'); + expect(tx?.statusLabel).toContain('block'); + }); + + it('totals the transparent sides exactly', () => { + const tx = readTransaction(envelope, ZEC); + expect(tx?.inputTotal?.display).toBe('2'); + expect(tx?.outputTotal?.display).toBe('1.99985'); + }); + + it('shows no total when an amount was not reported, rather than a short one', () => { + const partial = { + ...envelope, + transparent: { + inputs: [ + { indexAtomic: '0', address: 't1abc', valueAtomic: '200000000', coinbase: false }, + { indexAtomic: '1', address: null, valueAtomic: null, coinbase: false }, + ], + outputs: [], + }, + }; + expect(readTransaction(partial, ZEC)?.inputTotal).toBeNull(); + }); + + it('keeps the shielded structure without inferring participants', () => { + const tx = readTransaction(envelope, ZEC); + expect(tx?.shielded?.valueBalance?.display).toBe('-0.5'); + expect(tx?.shielded?.components).toHaveLength(5); + expect(tx?.shielded?.notice).toContain('not recorded'); + }); + + it('separates candidate actions from evaluated ones and tones them apart', () => { + const tx = readTransaction(envelope, ZEC); + expect(tx?.candidateActions[0].tone).toBe('pending'); + expect(tx?.confirmedActions[0].tone).toBe('unavailable'); + expect(tx?.confirmedActions[0].actionType).toBe('Transfer'); + }); + + it('names a protocol the way the navigation does, not by its wire id', () => { + const tx = readTransaction(envelope, ZEC); + expect(tx?.candidateActions[0].protocolLabel).toBe('Zerdinals'); + expect(tx?.confirmedActions[0].protocolLabel).toBe('ZRC-20'); + }); + + it('returns null for a payload that is not a transaction envelope', () => { + expect(readTransaction({ address: 'D7Zx' }, ZEC)).toBeNull(); + }); +}); + +describe('readBlock', () => { + const payload: ChainExplorerPayload = { + chain: 'dogecoin', + network: 'mainnet', + block: { + hash: BLOCK_HASH, + heightAtomic: '5123456', + confirmationsAtomic: '3', + sizeBytesAtomic: '28000', + time: '2026-08-29T04:00:00.000Z', + previousBlockHash: 'c'.repeat(64), + nextBlockHash: null, + merkleRoot: 'd'.repeat(64), + versionAtomic: '4', + nonce: '0', + bits: '1a', + difficulty: '12345.67', + transactionCountAtomic: '42', + }, + pagination: { pageAtomic: '1', totalPagesAtomic: '2', itemsOnPageAtomic: '50' }, + txids: [TXID], + }; + + it('reads the header facts and the paging', () => { + const block = readBlock(payload); + expect(block?.height?.display).toBe('5,123,456'); + expect(block?.transactionCount?.display).toBe('42'); + expect(block?.time?.display).toBe('29 Aug 2026, 04:00 UTC'); + expect(block?.paging).toEqual({ page: 1, totalPages: 2, previousPage: null, nextPage: 2 }); + expect(block?.txids).toEqual([TXID]); + }); + + it('accepts pagination that arrives as a JSON number', () => { + const block = readBlock({ ...payload, pagination: { page: 3, totalPages: 9 } }); + expect(block?.paging?.page).toBe(3); + expect(block?.paging?.nextPage).toBe(4); + }); + + it('leaves the next block absent rather than inventing one', () => { + expect(readBlock(payload)?.nextBlockHash).toBeNull(); + }); +}); + +describe('readAddress', () => { + const payload: ChainExplorerPayload = { + chain: 'dogecoin', + network: 'mainnet', + address: 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L', + balanceAtomic: '1234500000000', + totalReceivedAtomic: '5000000000000', + totalSentAtomic: '3765500000000', + unconfirmedBalanceAtomic: '0', + unconfirmedTransactionsAtomic: '0', + transactionCountAtomic: '87', + pagination: { pageAtomic: '1', totalPagesAtomic: '2', itemsOnPageAtomic: '50' }, + txids: [TXID], + utxos: [ + { txid: TXID, voutAtomic: '0', valueAtomic: '1234500000000', heightAtomic: '5123400', confirmationsAtomic: '56', address: 'DH5y', lockTimeAtomic: '0', coinbase: false }, + ], + }; + + it('shifts every balance into the ticker unit', () => { + const address = readAddress(payload, DOGE); + expect(address?.balance?.display).toBe('12,345'); + expect(address?.totalReceived?.display).toBe('50,000'); + expect(address?.transactionCount?.display).toBe('87'); + }); + + it('keeps a zero pending balance as zero rather than dropping it', () => { + expect(readAddress(payload, DOGE)?.unconfirmedBalance?.display).toBe('0'); + }); + + it('carries the paging so the rest of a long history is reachable', () => { + expect(readAddress(payload, DOGE)?.paging).toEqual({ + page: 1, totalPages: 2, previousPage: null, nextPage: 2, + }); + }); + + it('reads unspent outputs into routable parts', () => { + const [utxo] = readAddress(payload, DOGE)?.utxos ?? []; + expect(utxo.txid).toBe(TXID); + expect(utxo.vout).toBe('0'); + expect(utxo.amount?.display).toBe('12,345'); + }); +}); + +describe('readOutpoint', () => { + it('reads the output and its spent state', () => { + const out = readOutpoint( + { + chain: 'dogecoin', + network: 'mainnet', + outpoint: { txid: TXID, voutAtomic: '2' }, + output: { valueAtomic: '500000000', address: 'DH5y', spent: false }, + }, + DOGE + ); + expect(out?.vout).toBe('2'); + expect(out?.amount?.display).toBe('5'); + expect(out?.spent).toBe(false); + }); + + it('leaves the spent state unknown when the authority did not state it', () => { + const out = readOutpoint( + { chain: 'dogecoin', outpoint: { txid: TXID, voutAtomic: '0' }, output: { valueAtomic: '1' } }, + DOGE + ); + expect(out?.spent).toBeNull(); + }); +}); + +describe('readCollection', () => { + it('takes columns from the union of the rows, so a sparse row leaves a gap', () => { + const collection = readCollection( + { items: [{ tick: 'ABC', supplyAtomic: '100' }, { tick: 'DEF', holdersAtomic: '4' }] }, + DOGE + ); + expect(collection?.columns.map((c) => c.key)).toEqual(['tick', 'supplyAtomic', 'holdersAtomic']); + expect(collection?.rows[1][1].fact?.kind).toBe('absent'); + }); + + it('names the field the rows came from', () => { + const collection = readCollection({ holders: [{ address: 'DH5y' }] }, DOGE); + expect(collection?.sourceKey).toBe('holders'); + expect(collection?.title).toBe('Holders'); + }); + + it('picks the longest array when a payload carries several', () => { + const collection = readCollection({ txids: ['a'], events: [{ x: '1' }, { y: '2' }] }, DOGE); + expect(collection?.sourceKey).toBe('events'); + }); + + it('reports how many rows it is holding back', () => { + const rows = Array.from({ length: 120 }, (_, index) => ({ n: String(index) })); + const collection = readCollection({ items: rows }, DOGE); + expect(collection?.shownCount).toBe(100); + expect(collection?.totalCount).toBe(120); + }); + + it('wraps a list of plain values so it still has a column', () => { + const collection = readCollection({ items: ['one', 'two'] }, DOGE); + expect(collection?.columns.map((c) => c.key)).toEqual(['value']); + }); + + it('routes the identifiers the explorer has a page for', () => { + const collection = readCollection( + { transactions: [{ txid: TXID, address: 'DH5y', note: 'x' }] }, + DOGE + ); + const byKey = Object.fromEntries( + (collection?.rows[0] ?? []).map((cell) => [cell.key, cell.fact]) + ); + expect(byKey.txid?.link).toEqual(['/', 'dogecoin', 'tx', TXID]); + expect(byKey.address?.link).toEqual(['/', 'dogecoin', 'address', 'DH5y']); + expect(byKey.note?.link).toBeNull(); + }); + + it('names a coin amount column unit once, in the header', () => { + const collection = readCollection({ items: [{ feeAtomic: '1', sizeBytesAtomic: '2' }] }, DOGE); + const byKey = Object.fromEntries((collection?.columns ?? []).map((c) => [c.key, c])); + expect(byKey.feeAtomic.unit).toBe('DOGE'); + expect(byKey.sizeBytesAtomic.unit).toBeNull(); + }); + + it('names the structured fields a table cannot hold, rather than dropping them', () => { + // The live ZRC-20 list carries rulesets and divergence on every row. They + // were skipped in silence while hiddenColumnCount reported zero, so the + // page claimed it had held nothing back while holding back the ledger. + const collection = readCollection( + { + items: [ + { tick: 'ZERO', decimals: '18', rulesets: { zord: { holders: '2330' } }, divergence: { diverges: true } }, + { tick: 'ONE', decimals: '18', rulesets: { zord: { holders: '11' } }, divergence: { diverges: false } }, + ], + }, + DOGE + ); + expect(collection?.structuredFields).toEqual(['rulesets', 'divergence']); + expect(collection?.columns.map((c) => c.key)).toEqual(['tick']); + }); + + it('reports no structured fields when every value is a scalar', () => { + expect(readCollection({ items: [{ a: '1' }, { a: '2' }] }, DOGE)?.structuredFields).toEqual([]); + }); + + it('says how many fields per row the table left out', () => { + const wide = { a: '1', b: '2', c: '3', d: '4', e: '5', f: '6', g: '7', h: '8', i: '9' }; + const collection = readCollection({ items: [wide] }, DOGE); + expect(collection?.columns).toHaveLength(7); + expect(collection?.hiddenColumnCount).toBe(2); + }); + + it('holds nothing back when every field fits', () => { + expect(readCollection({ items: [{ a: '1', b: '2' }] }, DOGE)?.hiddenColumnCount).toBe(0); + }); + + it('returns nothing when there is no array to read', () => { + expect(readCollection({ state: 'unavailable' }, DOGE)).toBeNull(); + }); +}); + +describe('readEmptyList', () => { + it('calls a complete empty list none, and an incomplete one not established', () => { + // The live Zcash pending set is empty and complete, which is a proven none. + expect( + readEmptyList({ snapshot: { completeness: 'complete' }, transactions: [] }) + ).toEqual({ field: 'transactions', label: 'Transactions', proven: true }); + + expect( + readEmptyList({ snapshot: { completeness: 'partial' }, transactions: [] })?.proven + ).toBe(false); + + // No completeness stated at all is not a proven none either. + expect(readEmptyList({ transactions: [] })?.proven).toBe(false); + }); + + it('reads a top-level completeness as well as a nested one', () => { + expect(readEmptyList({ completeness: 'complete', items: [] })?.proven).toBe(true); + }); + + it('says nothing when there is a list to show instead', () => { + expect(readEmptyList({ transactions: [{ txid: 'a' }] })).toBeNull(); + expect(readEmptyList({ txids: [], transactions: [{ txid: 'a' }] })).toBeNull(); + }); + + it('says nothing when the payload has no list at all', () => { + expect(readEmptyList({ state: 'unavailable' })).toBeNull(); + expect(readEmptyList(null)).toBeNull(); + }); +}); + +describe('readRecordFacts', () => { + it('classifies amounts, counts, identifiers, flags and absences apart', () => { + const facts = readRecordFacts( + { + balanceAtomic: '100000000', + transactionCountAtomic: '12', + blockHash: BLOCK_HASH, + coinbase: true, + note: null, + }, + DOGE + ); + const byKey = Object.fromEntries(facts.map((fact) => [fact.key, fact])); + expect(byKey.balanceAtomic.kind).toBe('amount'); + expect(byKey.balanceAtomic.unit).toBe('DOGE'); + expect(byKey.transactionCountAtomic.kind).toBe('count'); + expect(byKey.blockHash.kind).toBe('identifier'); + expect(byKey.coinbase.kind).toBe('flag'); + expect(byKey.note.kind).toBe('absent'); + }); + + it('skips fields a purpose-built reading already presented', () => { + const keys = readRecordFacts({ chain: 'dogecoin', state: 'ready' }, DOGE, ['chain']).map((f) => f.key); + expect(keys).toEqual(['state']); + }); + + it('flattens one level of nesting so a nested scalar is not dropped', () => { + const facts = readRecordFacts({ status: { state: 'ready', lagBlocksAtomic: '3' } }, DOGE); + expect(facts.map((fact) => fact.label)).toEqual(['Status state', 'Status lag blocks']); + }); + + it('does not repeat a parent the child already names', () => { + // The live pending response nests snapshotId inside snapshot, which read + // as "Snapshot snapshot ID". + const facts = readRecordFacts( + { snapshot: { snapshotId: 'snap-4812', sequenceAtomic: '148201' } }, + DOGE + ); + expect(facts.map((fact) => fact.label)).toEqual(['Snapshot ID', 'Snapshot sequence']); + }); + + it('drops a nested chain and network, which only repeat the heading', () => { + const facts = readRecordFacts( + { snapshot: { chain: 'dogecoin', network: 'mainnet', completeness: 'complete' } }, + DOGE + ); + expect(facts.map((fact) => fact.key)).toEqual(['snapshot completeness']); + }); + + it('shifts only the fields that carry the chain own coin', () => { + // A DRC-20 supply is denominated in the token's units. Shifting it by the + // chain precision printed 100000000000 as 1,000 DOGE, which is not a + // formatting choice, it is a different number. + const facts = readRecordFacts( + { supplyAtomic: '100000000000', balanceAtomic: '100000000000' }, + DOGE + ); + const byKey = Object.fromEntries(facts.map((fact) => [fact.key, fact])); + expect(byKey.supplyAtomic.kind).toBe('count'); + expect(byKey.supplyAtomic.display).toBe('100,000,000,000'); + expect(byKey.supplyAtomic.unit).toBeNull(); + expect(byKey.balanceAtomic.kind).toBe('amount'); + expect(byKey.balanceAtomic.display).toBe('1,000'); + }); + + it('never drops a zero by treating it as absent', () => { + const [fact] = readRecordFacts({ balanceAtomic: '0' }, DOGE); + expect(fact.kind).toBe('amount'); + expect(fact.display).toBe('0'); + }); +}); + +describe('the chain reading on the status rail', () => { + it('does not report a chain as ready when the chain says it is not', () => { + // The node can be caught up while a protocol indexer this chain needs is + // not answering. That publishes sync.state ready and ready false, and the + // rail used to read the sync state alone: Ready, in the proven tone, on + // the reading a visitor trusts most, for a chain that had just said no. + const rail = readStatusRail( + capability({ ready: false, degradedReasons: ['protocol-history-unavailable'] }), + DOGE, + Date.parse('2026-08-29T05:00:10.000Z') + ); + const state = rail.find((reading) => reading.id === 'state'); + expect(state?.value).toBe('Degraded'); + expect(state?.tone).toBe('partial'); + }); + + it('keeps the worse reading when the node itself is the problem', () => { + const rail = readStatusRail( + capability({ ready: false, sync: { state: 'unavailable', initialBlockDownload: false, progressDecimal: null, updatedAt: null } }), + DOGE, + Date.parse('2026-08-29T05:00:10.000Z') + ); + expect(rail.find((reading) => reading.id === 'state')?.value).toBe('Unavailable'); + }); + + it('still reads ready when the chain says so', () => { + const rail = readStatusRail(capability(), DOGE, Date.parse('2026-08-29T05:00:10.000Z')); + const state = rail.find((reading) => reading.id === 'state'); + expect(state?.value).toBe('Ready'); + expect(state?.tone).toBe('proven'); + }); +}); + +describe('readNotReadyReasons', () => { + it('says nothing about a ready chain', () => { + expect(readNotReadyReasons(capability())).toBeNull(); + expect(readNotReadyReasons(null)).toBeNull(); + }); + + it('reads the reasons the chain gave', () => { + const reasons = readNotReadyReasons( + capability({ ready: false, degradedReasons: ['base-chain-authority-unavailable', 'protocol-history-unavailable'] }) + ); + expect(reasons).toHaveLength(2); + expect(reasons?.[0].text).toContain('did not answer'); + }); + + it('says a chain withheld readiness without explaining, rather than showing nothing', () => { + const reasons = readNotReadyReasons(capability({ ready: false, degradedReasons: [] })); + expect(reasons).toHaveLength(1); + expect(reasons?.[0].text).toContain('states no reason'); + }); +}); + +describe('readProtocolCoverage reasons', () => { + it('hands the page sentences rather than wire codes', () => { + const readings = readProtocolCoverage( + capability({ + protocols: [ + { protocolId: 'drc20', state: 'unavailable', coverage: 'unavailable', updatedAt: null, lagBlocksAtomic: null, degradedReasons: ['authority-capability-disabled'] }, + ], + }), + DOGE + ); + const drc20 = readings.find((reading) => reading.protocolId === 'drc20'); + expect(drc20?.reasons.map((reason) => reason.code)).toEqual(['authority-capability-disabled']); + expect(drc20?.reasons[0].text).not.toContain('-disabled'); + }); +}); diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.ts new file mode 100644 index 00000000000..d39a6999a11 --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.ts @@ -0,0 +1,1944 @@ +/** + * Presentation model for the Dogecoin and Zcash explorer pages. + * + * Pure functions, no Angular and no I/O, so every rule below is testable on + * its own and the component stays a template. + * + * The problem this solves: the chain overlay answers with several different + * envelopes, and the page used to print whichever one arrived as a grid of + * raw API key names, a table whose columns were the first eight keys of the + * first row, and the whole response as JSON. That renders, but it does not + * explain. A reader could not tell a koinu from a Dogecoin, an exact integer + * from a rounded one, or a fact the authority proved from a field it left + * null. + * + * Three rules hold the model together: + * + * 1. Numbers cross the API boundary as exact decimal strings and are never + * parsed into a JavaScript number here. Grouping and the decimal shift are + * string operations, and every formatted value keeps the exact source + * string beside it so a reader can copy the value the authority sent. + * + * 2. Absent and zero are different answers, and the model says which. A null + * amount reads as not reported; a zero amount reads as zero. + * + * 3. Shapes the model recognises get a purpose-built reading. Shapes it does + * not are still rendered, as a humanised record that says plainly it is + * the response as received. The page never silently drops a field it did + * not expect. + */ + +import { + ChainCapabilityEnvelope, + ChainExplorerPayload, + ExplorerChain, +} from '@app/universe/universe.types'; +import { explorerChainName } from '@app/universe/universe-chain-routing'; +import { + ChainReasonReading, + describeChainReasons, +} from '@app/universe/multichain-explorer/chain-reasons'; +import { + EvidenceTone, + formatAtomicAmount as formatAtomicDigits, + shortenIdentifier as shortenSymmetric, +} from '@app/universe/universe-evidence'; + +export interface ChainProtocolTab { + /** The path segment the chain API uses, and the route this explorer serves. */ + readonly id: string; + readonly label: string; + /** + * Every id the capability envelope might use for this same protocol. + * + * The route segment and the registry id are different namespaces and they do + * not always agree: the API serves TAP on Doge at `/protocols/doge-tap` + * while the registry calls it `tap_doge`. Matching on the route id alone + * listed the protocol twice on the overview, once from the tab reporting + * "not stated" and once appended from the envelope, and neither row was the + * whole truth. + */ + readonly registryIds: readonly string[]; +} + +export interface ChainProfile { + readonly chain: Exclude; + readonly name: string; + readonly ticker: string; + /** Name of one indivisible unit, as the chain's own documentation uses it. */ + readonly atomicUnit: string; + /** Decimal places between the atomic unit and the ticker unit. */ + readonly precision: number; + readonly protocols: readonly ChainProtocolTab[]; +} + +const DOGECOIN: ChainProfile = { + chain: 'dogecoin', + name: explorerChainName('dogecoin'), + ticker: 'DOGE', + atomicUnit: 'koinu', + precision: 8, + protocols: [ + { id: 'doginals', label: 'Doginals', registryIds: ['doginals'] }, + { id: 'drc20', label: 'DRC-20', registryIds: ['drc20'] }, + { id: 'doge-tap', label: 'TAP on Doge', registryIds: ['tap_doge', 'doge-tap', 'tap-on-doge', 'tap-doge'] }, + ], +}; + +const ZCASH: ChainProfile = { + chain: 'zcash', + name: explorerChainName('zcash'), + ticker: 'ZEC', + atomicUnit: 'zatoshi', + precision: 8, + protocols: [ + { id: 'zerdinals', label: 'Zerdinals', registryIds: ['zerdinals', 'universe-zerdinals'] }, + { id: 'zrunes', label: 'ZRunes', registryIds: ['zrunes'] }, + { id: 'zrc20', label: 'ZRC-20', registryIds: ['zrc20'] }, + ], +}; + +export function chainProfile( + chain: Exclude +): ChainProfile { + return chain === 'dogecoin' ? DOGECOIN : ZCASH; +} + +// --------------------------------------------------------------------------- +// Exact numeric presentation +// --------------------------------------------------------------------------- + +const INTEGER = /^-?(?:0|[1-9][0-9]*)$/; + +/** Digit grouping on the integer part only, applied to a string. */ +function groupDigits(digits: string): string { + return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +/** + * A number the page shows. `display` is grouped and shifted for reading; + * `exact` is the string the authority sent, so a reader who needs the precise + * value can copy it and a test can assert on it. + */ +export interface ExactNumber { + readonly display: string; + readonly exact: string; +} + +/** + * An exact integer, grouped for reading. Heights, counts, sizes and sequence + * numbers take this: they are whole things, not amounts, and shifting them by + * a precision would be wrong. + */ +export function formatExactInteger( + value: string | null | undefined +): ExactNumber | null { + if (typeof value !== 'string' || !INTEGER.test(value)) { + return null; + } + const negative = value.startsWith('-'); + const digits = negative ? value.slice(1) : value; + return { + display: (negative ? '-' : '') + groupDigits(digits), + exact: value, + }; +} + +/** + * An amount in the chain's atomic unit, shifted to the ticker unit by string + * arithmetic. 100000000 koinu becomes 1 DOGE with no floating point anywhere + * in the path, which matters because a Dogecoin balance can exceed the range + * where a double still counts single units. + * + * Trailing zeros in the fraction are dropped, because a balance of exactly one + * coin should read as 1 and not as 1.00000000, and a whole number keeps no + * decimal point at all. + */ +export function formatAtomicAmount( + value: string | null | undefined, + precision: number +): ExactNumber | null { + if (typeof value !== 'string') { + return null; + } + // The shift itself is the shared evidence vocabulary's, so a Dogecoin balance + // and a Bitcoin one are grouped and trimmed by one rule rather than two. + // What is added here is the exact source string beside the rendering. + const display = formatAtomicDigits(value, precision); + return display === '' ? null : { display, exact: value }; +} + +/** Shorten a hash for a dense column, keeping both ends so it stays checkable. */ +export function shortenIdentifier(value: string, keep = 8): string { + return shortenSymmetric(value, keep); +} + +/** + * How long ago an observation was made, in the coarsest unit that is still + * honest. An unparseable or future timestamp returns null rather than a + * reassuring "0s ago"; the caller then says the freshness is unknown. + */ +export function formatElapsed( + isoTimestamp: string | null | undefined, + now: number +): string | null { + if (typeof isoTimestamp !== 'string') { + return null; + } + const observed = Date.parse(isoTimestamp); + if (!Number.isFinite(observed)) { + return null; + } + const seconds = Math.floor((now - observed) / 1000); + if (seconds < 0) { + return null; + } + if (seconds < 60) { + return seconds === 1 + ? $localize`:@@universe.chain.elapsed-second:1 second ago` + : $localize`:@@universe.chain.elapsed-seconds:${seconds}:SECONDS: seconds ago`; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return minutes === 1 + ? $localize`:@@universe.chain.elapsed-minute:1 minute ago` + : $localize`:@@universe.chain.elapsed-minutes:${minutes}:MINUTES: minutes ago`; + } + const hours = Math.floor(minutes / 60); + if (hours < 48) { + return hours === 1 + ? $localize`:@@universe.chain.elapsed-hour:1 hour ago` + : $localize`:@@universe.chain.elapsed-hours:${hours}:HOURS: hours ago`; + } + const days = Math.floor(hours / 24); + return days === 1 + ? $localize`:@@universe.chain.elapsed-day:1 day ago` + : $localize`:@@universe.chain.elapsed-days:${days}:DAYS: days ago`; +} + +const MONTHS = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', +]; + +/** + * A timestamp a reader can take in at a glance, with the exact string beside + * it. The authority sends ISO-8601, which is precise and unreadable at speed: + * "2026-08-29T04:02:00.000Z" makes a reader parse punctuation to find the hour. + * + * The format is built by hand rather than through Intl, and it is always UTC. + * A block timestamp is a fact about the chain, not about where the reader is + * sitting, and a locale-dependent rendering would make two people describing + * the same block disagree about when it happened. + */ +export function formatTimestamp( + isoTimestamp: string | null | undefined +): ExactNumber | null { + if (typeof isoTimestamp !== 'string') { + return null; + } + const parsed = new Date(isoTimestamp); + const time = parsed.getTime(); + if (!Number.isFinite(time)) { + return null; + } + const pad = (value: number): string => String(value).padStart(2, '0'); + const display = + `${parsed.getUTCDate()} ${MONTHS[parsed.getUTCMonth()]} ` + + `${parsed.getUTCFullYear()}, ${pad(parsed.getUTCHours())}:` + + `${pad(parsed.getUTCMinutes())} UTC`; + return { display, exact: isoTimestamp }; +} + +/** + * A time stated as whole seconds since the epoch, which is how the Zcash + * source states a block time. Rendered in the same words and the same zone as + * every other time here, with the number it came from kept exactly. + * + * Seconds, never milliseconds: a value read in the wrong unit lands in 1970 or + * in the year 58000 and still renders, so the range is checked rather than + * assumed. + */ +export function formatUnixTimestamp( + seconds: string | null | undefined +): ExactNumber | null { + const digits = integerText(seconds); + if (digits === null) { + return null; + } + const value = Number(digits); + if (!Number.isSafeInteger(value) || value <= 0 || value > 4_102_444_800) { + return null; + } + const iso = new Date(value * 1000).toISOString(); + const formatted = formatTimestamp(iso); + return formatted ? { display: formatted.display, exact: digits } : null; +} + +// --------------------------------------------------------------------------- +// Field naming +// --------------------------------------------------------------------------- + +/** + * Words the generic sentence-case rule would get wrong. Everything else is + * derived, so a field the overlay adds later still reads as English without + * anyone having to add it here first. + */ +const KNOWN_WORDS: Record = { + txid: 'transaction id', + txids: 'transaction ids', + utxos: 'unspent outputs', + vout: 'output index', + vin: 'input index', + drc20: 'DRC-20', + zrc20: 'ZRC-20', + id: 'ID', + ids: 'IDs', + hash: 'hash', + ipfs: 'IPFS', + json: 'JSON', + url: 'URL', + uri: 'URI', + zip317: 'ZIP-317', +}; + +/** + * An API field name as a reader should see it. The `Atomic` suffix the + * contract uses to mark exact-string transport is a wire detail, not a label, + * so it is dropped here and the exactness is carried by the value instead. + */ +export function humanizeFieldName(key: string): string { + const withoutSuffix = key.replace(/Atomic$/, '').replace(/Decimal$/, ''); + const words = withoutSuffix + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .trim() + .toLowerCase() + .split(/\s+/) + .filter(Boolean) + .map((word) => KNOWN_WORDS[word] ?? word); + if (!words.length) { + return key; + } + const sentence = words.join(' '); + return sentence.charAt(0).toUpperCase() + sentence.slice(1); +} + +/** + * Fields that carry an amount of the chain's own coin, and may therefore be + * shifted by the chain's precision. + * + * This is an allowlist, not a pattern, and it is deliberately narrow. A + * pattern that matched "supply" printed a DRC-20 token supply of 100000000000 + * as 1,000 DOGE, because a token's units are its own and have nothing to do + * with koinu. Shifting an unknown quantity by eight places does not make it + * more readable, it makes it wrong, so anything not named here is shown as the + * exact integer the authority sent. + */ +const COIN_AMOUNT_FIELDS = new Set([ + 'valueAtomic', + 'valueBalanceAtomic', + 'saplingValueBalanceAtomic', + 'orchardValueBalanceAtomic', + 'ironwoodValueBalanceAtomic', + 'balanceAtomic', + 'unconfirmedBalanceAtomic', + 'totalReceivedAtomic', + 'totalSentAtomic', + 'amountAtomic', + 'feeAtomic', + 'rewardAtomic', +]); + +function isCoinAmount(key: string): boolean { + return COIN_AMOUNT_FIELDS.has(key.split(' ').pop() ?? key); +} +const IDENTIFIER_FIELD = /(hash|txid|^id$|root|address|outpoint|satpoint)/i; +const TIMESTAMP_FIELD = /(at|time|timestamp)$/i; + +export type FactKind = + | 'text' + | 'identifier' + | 'amount' + | 'count' + | 'timestamp' + | 'flag' + | 'absent'; + +/** One labelled value, classified so the template knows how to set it. */ +export interface Fact { + readonly key: string; + readonly label: string; + readonly kind: FactKind; + readonly display: string; + /** The exact source string, when the display form is a rendering of one. */ + readonly exact: string | null; + /** Unit shown beside the value, for amounts only. */ + readonly unit: string | null; + /** + * Router path to the page for this value, when the explorer has one. A list + * of transaction ids that cannot be clicked is a dead end, and that is what + * every generic table here used to be. + */ + readonly link: readonly string[] | null; +} + +const ABSENT = $localize`:@@universe.chain.not-reported:Not reported`; + +const TXID_FIELD = /(^|\s)(txid|txids|transactionid)$/i; +const ADDRESS_FIELD = /(^|\s)(address|owneraddress|holder)$/i; + +/** The page this value belongs to, when the explorer has one for it. */ +function routeFor( + key: string, + value: string, + profile: ChainProfile +): readonly string[] | null { + if (TXID_FIELD.test(key) && /^[0-9a-f]{64}$/.test(value)) { + return ['/', profile.chain, 'tx', value]; + } + if (ADDRESS_FIELD.test(key)) { + return ['/', profile.chain, 'address', value]; + } + return null; +} + +function factFrom( + key: string, + value: unknown, + profile: ChainProfile +): Fact | null { + const label = humanizeFieldName(key); + const base = { key, label, exact: null, unit: null, link: null } as const; + + if (value === null || value === undefined || value === '') { + return { ...base, kind: 'absent', display: ABSENT }; + } + if (typeof value === 'boolean') { + return { + ...base, + kind: 'flag', + display: value + ? $localize`:@@universe.chain.flag-yes:Yes` + : $localize`:@@universe.chain.flag-no:No`, + }; + } + if (typeof value === 'number') { + // The contract transports numbers as strings. A raw number means a field + // outside the contract, so it is shown as text rather than dressed up as + // an exact value it may not be. + return { ...base, kind: 'text', display: String(value) }; + } + if (typeof value !== 'string') { + return null; + } + + if (isCoinAmount(key) && INTEGER.test(value)) { + const amount = formatAtomicAmount(value, profile.precision); + return amount + ? { + key, + label, + kind: 'amount', + display: amount.display, + exact: amount.exact, + unit: profile.ticker, + link: null, + } + : { ...base, kind: 'text', display: value }; + } + if (INTEGER.test(value)) { + const count = formatExactInteger(value); + return count + ? { key, label, kind: 'count', display: count.display, exact: count.exact, unit: null, link: null } + : { ...base, kind: 'text', display: value }; + } + if (TIMESTAMP_FIELD.test(key)) { + const moment = formatTimestamp(value); + if (moment) { + return { + key, + label, + kind: 'timestamp', + display: moment.display, + exact: moment.exact, + unit: null, + link: null, + }; + } + } + if (IDENTIFIER_FIELD.test(key) || /^[0-9a-f]{64}$/.test(value)) { + return { + key, + label, + kind: 'identifier', + display: value, + exact: value, + unit: null, + link: routeFor(key, value, profile), + }; + } + return { ...base, kind: 'text', display: value }; +} + +/** + * Every scalar field of a response, humanised and classified, in the order the + * authority sent them. Nested objects and arrays are handled by the shape + * readers below or, when the shape is unrecognised, listed separately so the + * page can say how many rows it is not detailing rather than hiding them. + */ +export function readRecordFacts( + payload: ChainExplorerPayload | null, + profile: ChainProfile, + skip: readonly string[] = [] +): readonly Fact[] { + if (!payload) { + return []; + } + const skipped = new Set(skip); + const facts: Fact[] = []; + for (const [key, value] of Object.entries(payload)) { + if (skipped.has(key) || Array.isArray(value)) { + continue; + } + if (value !== null && typeof value === 'object') { + for (const [childKey, childValue] of Object.entries( + value as Record + )) { + if (childValue !== null && typeof childValue === 'object') { + continue; + } + // A nested chain or network repeats the page heading, the same way the + // top-level ones do, and the skip list only reaches the top level. + if (childKey === 'chain' || childKey === 'network') { + continue; + } + // "snapshot" plus "snapshotId" reads as "Snapshot snapshot ID". Where + // the child already names its parent, the parent is not repeated. + const prefixed = childKey.toLowerCase().startsWith(key.toLowerCase()); + const fact = factFrom( + prefixed ? childKey : `${key} ${childKey}`, + childValue, + profile + ); + if (fact) { + facts.push(fact); + } + } + continue; + } + const fact = factFrom(key, value, profile); + if (fact) { + facts.push(fact); + } + } + return facts; +} + +// --------------------------------------------------------------------------- +// Chain status +// --------------------------------------------------------------------------- + +export interface StatusReading { + readonly id: string; + readonly label: string; + readonly value: string; + readonly tone: EvidenceTone; + /** Present when the value is an exact figure worth copying. */ + readonly exact: string | null; +} + +function availabilityTone(state: string | null | undefined): EvidenceTone { + switch (state) { + case 'ready': + return 'proven'; + case 'degraded': + return 'partial'; + case 'unavailable': + return 'unavailable'; + default: + return 'neutral'; + } +} + +function completenessTone(state: string | null | undefined): EvidenceTone { + switch (state) { + case 'complete': + return 'proven'; + case 'partial': + return 'partial'; + case 'unavailable': + return 'unavailable'; + default: + return 'neutral'; + } +} + +export function completenessLabel(state: string | null | undefined): string { + switch (state) { + case 'complete': + return $localize`:@@universe.chain.coverage-complete:Complete`; + case 'partial': + return $localize`:@@universe.chain.coverage-partial:Partial`; + case 'unavailable': + return $localize`:@@universe.chain.coverage-unavailable:Unavailable`; + default: + return $localize`:@@universe.chain.coverage-unknown:Not stated`; + } +} + +/** + * Coverage as a phrase rather than a bare adjective. "History Complete" is not + * a sentence; the chip vocabulary and the prose vocabulary are different jobs. + */ +export function historyLabel(state: string | null | undefined): string { + switch (state) { + case 'complete': + return $localize`:@@universe.chain.history-complete:Complete history`; + case 'partial': + return $localize`:@@universe.chain.history-partial:Partial history`; + case 'unavailable': + return $localize`:@@universe.chain.history-unavailable:History unavailable`; + default: + return $localize`:@@universe.chain.history-unknown:History not stated`; + } +} + +export function availabilityLabel(state: string | null | undefined): string { + switch (state) { + case 'ready': + return $localize`:@@universe.chain.state-ready:Ready`; + case 'degraded': + return $localize`:@@universe.chain.state-degraded:Degraded`; + case 'unavailable': + return $localize`:@@universe.chain.state-unavailable:Unavailable`; + default: + return $localize`:@@universe.chain.state-unknown:Not stated`; + } +} + +/** + * The status rail. Five readings, always the same five in the same order, so a + * reader learns where to look once. A reading whose fact is missing says so in + * its own value rather than disappearing, because a rail with a hole in it + * reads as a page still loading. + */ +export function readStatusRail( + capability: ChainCapabilityEnvelope | null, + profile: ChainProfile, + now: number +): readonly StatusReading[] { + if (!capability) { + const unknown = $localize`:@@universe.chain.rail-unknown:Not available`; + return [ + { id: 'state', label: $localize`:@@universe.chain.rail-state:Chain`, value: $localize`:@@universe.chain.rail-no-status:Status unavailable`, tone: 'unavailable', exact: null }, + { id: 'tip', label: $localize`:@@universe.chain.rail-tip:Chain tip`, value: unknown, tone: 'neutral', exact: null }, + { id: 'lag', label: $localize`:@@universe.chain.rail-lag:Behind tip`, value: unknown, tone: 'neutral', exact: null }, + { id: 'freshness', label: $localize`:@@universe.chain.rail-observed:Last observed`, value: unknown, tone: 'neutral', exact: null }, + { id: 'mempool', label: $localize`:@@universe.chain.rail-pending:Pending coverage`, value: unknown, tone: 'neutral', exact: null }, + ]; + } + + const tip = formatExactInteger(capability.tip?.heightAtomic ?? null); + const lag = formatExactInteger(capability.lagBlocksAtomic); + const elapsed = formatElapsed(capability.updatedAt, now); + + return [ + { + id: 'state', + label: $localize`:@@universe.chain.rail-state:Chain`, + // `sync` is the node's own view of its blocks. `ready` is the chain's + // verdict on everything the explorer needs, and it is the stricter of + // the two: a chain whose node is caught up while a protocol indexer is + // not answering publishes sync.state ready and ready false. Reading the + // sync state alone therefore printed Ready, in the proven tone, on the + // one reading a visitor trusts most, for a chain that had just said it + // was not. When the two disagree the verdict wins, and the reasons + // beneath the rail say which part is missing. + value: capability.ready + ? availabilityLabel('ready') + : capability.sync.state === 'ready' + ? availabilityLabel('degraded') + : availabilityLabel(capability.sync.state), + tone: capability.ready + ? 'proven' + : capability.sync.state === 'ready' + ? availabilityTone('degraded') + : availabilityTone(capability.sync.state), + exact: null, + }, + { + id: 'tip', + label: $localize`:@@universe.chain.rail-tip:Chain tip`, + value: tip + ? $localize`:@@universe.chain.rail-tip-block:Block ${tip.display}:HEIGHT:` + : $localize`:@@universe.chain.rail-tip-none:No tip reported`, + tone: tip ? 'proven' : 'unavailable', + exact: tip?.exact ?? null, + }, + { + id: 'lag', + label: $localize`:@@universe.chain.rail-lag:Behind tip`, + value: lag + ? $localize`:@@universe.chain.rail-lag-blocks:${lag.display}:BLOCKS: blocks` + : $localize`:@@universe.chain.rail-lag-none:Not stated`, + // Any lag at all means the page is describing a past state of the chain, + // so it never reads as proven. Zero is the only reading that does. + tone: !lag ? 'neutral' : lag.exact === '0' ? 'proven' : 'partial', + exact: lag?.exact ?? null, + }, + { + id: 'freshness', + label: $localize`:@@universe.chain.rail-observed:Last observed`, + value: elapsed ?? $localize`:@@universe.chain.rail-observed-unknown:Time not stated`, + tone: elapsed ? 'neutral' : 'partial', + // "3 seconds ago" is the reading, and it is the one that changes while + // nobody is looking. The instant it was measured from is the fact behind + // it, and it was dropped: the rail carried a relative time with no way to + // recover the absolute one it was derived from. + exact: capability.updatedAt ?? null, + }, + { + id: 'mempool', + label: $localize`:@@universe.chain.rail-pending:Pending coverage`, + value: capability.mempool.supported + ? completenessLabel(capability.mempool.completeness) + : $localize`:@@universe.chain.rail-pending-unsupported:Not offered for ${profile.name}:CHAIN:`, + tone: capability.mempool.supported + ? completenessTone(capability.mempool.completeness) + : 'neutral', + exact: null, + }, + ]; +} + +/** + * Why a chain says it is not ready, in words, or null when it says it is. + * + * The capability document is the record of this and the reasons were never + * rendered anywhere, so the rail published a verdict and dropped every word of + * the evidence behind it. A chain that withholds readiness without stating a + * reason is itself worth saying out loud rather than showing an empty space + * where the explanation should be. + */ +export function readNotReadyReasons( + capability: ChainCapabilityEnvelope | null +): readonly ChainReasonReading[] | null { + if (!capability || capability.ready) { + return null; + } + const reasons = describeChainReasons(capability.degradedReasons ?? []); + if (reasons.length) { + return reasons; + } + return [ + { + code: '', + text: $localize`:@@universe.chain.not-ready-no-reason:This chain reports that it is not ready and states no reason for it.`, + kind: 'unstated', + }, + ]; +} + +/** + * How much history is readable right now, which is a different question from + * which lookups this explorer offers. + * + * `reads` is a static statement of the kinds of lookup that exist for a chain. + * `coverage` is the live one: how much of the chain each of them can currently + * answer over. The overview rendered the first and dropped the second, so + * Dogecoin read "Address history: Offered" while the same document said its + * address history was unavailable. Both statements were true and only one was + * on the page, which is the shape of turning unavailable into nothing at all. + * + * Three dimensions, always all three, in the order a reader needs them: the + * confirmed chain first, then addresses over it, then the protocols on top. + */ +export interface CoverageReading { + readonly id: string; + readonly label: string; + readonly stateLabel: string; + readonly tone: EvidenceTone; + /** What this dimension covers, in words, so the state is not read alone. */ + readonly detail: string; +} + +const COVERAGE_LABELS: readonly { + id: keyof ChainCapabilityEnvelope['coverage']; + label: string; + detail: string; +}[] = [ + { + id: 'confirmedHistory', + label: $localize`:@@universe.chain.coverage-confirmed:Confirmed history`, + detail: $localize`:@@universe.chain.coverage-confirmed-detail:Blocks and transactions the chain has already confirmed.`, + }, + { + id: 'addressHistory', + label: $localize`:@@universe.chain.coverage-address:Address history`, + detail: $localize`:@@universe.chain.coverage-address-detail:What an address received and spent over that confirmed history.`, + }, + { + id: 'protocolHistory', + label: $localize`:@@universe.chain.coverage-protocol:Protocol history`, + detail: $localize`:@@universe.chain.coverage-protocol-detail:Assets carried on this chain, as the protocol indexers below read them.`, + }, +]; + +export function readHistoryCoverage( + capability: ChainCapabilityEnvelope | null +): readonly CoverageReading[] { + return COVERAGE_LABELS.map(({ id, label, detail }) => { + const state = capability?.coverage?.[id]; + return { + id, + label, + stateLabel: capability + ? completenessLabel(state) + : $localize`:@@universe.chain.coverage-unknown:Not stated`, + tone: capability ? completenessTone(state) : ('neutral' as EvidenceTone), + detail, + }; + }); +} + +/** + * The evidence behind the page, at the level it belongs at. + * + * The snapshot identifier, the schema version and the overlay's own commit are + * how an operator checks a rendering against the document it came from. They + * are not what a visitor came to read, and the old overview put the whole + * opaque snapshot identifier in the primary status line, where it was the + * widest thing on the page and said nothing to almost everyone looking at it. + * + * So they are kept, exactly, in a disclosure that is closed by default. Values + * long enough to overflow are shortened for display and carry their exact form + * for copying, because a shortened identifier that cannot be recovered is + * evidence destroyed rather than evidence filed. + */ +export interface SourceDetail { + readonly id: string; + readonly label: string; + /** What is shown. Shortened when the exact value is too long to print. */ + readonly display: string; + /** The exact value, when there is one worth copying. */ + readonly exact: string | null; + /** + * True when the display is a shortening of the exact value. The page then + * carries the exact form for a screen reader as well, because a reader who + * cannot see the middle of an identifier has not been given it. + */ + readonly truncated: boolean; + /** Which component this identifies, when the value alone would not say. */ + readonly note: string | null; +} + +export function readSourceDetails( + capability: ChainCapabilityEnvelope | null +): readonly SourceDetail[] { + if (!capability) { + return []; + } + const details: SourceDetail[] = [ + { + id: 'observed', + label: $localize`:@@universe.chain.source-observed:Observed at`, + display: capability.updatedAt, + exact: capability.updatedAt, + truncated: false, + note: null, + }, + ]; + + const snapshotId = capability.mempool?.snapshotId ?? null; + if (snapshotId) { + details.push({ + id: 'snapshot', + label: $localize`:@@universe.chain.source-snapshot:Pending snapshot`, + display: shortenIdentifier(snapshotId, 10), + exact: snapshotId, + truncated: shortenIdentifier(snapshotId, 10) !== snapshotId, + note: $localize`:@@universe.chain.source-snapshot-note:Which reading of the pending set the figures above came from.`, + }); + } + + const sequence = formatExactInteger(capability.mempool?.sequenceAtomic ?? null); + if (sequence) { + details.push({ + id: 'sequence', + label: $localize`:@@universe.chain.source-sequence:Snapshot sequence`, + display: sequence.display, + exact: sequence.exact, + truncated: false, + note: null, + }); + } + + details.push({ + id: 'schema', + label: $localize`:@@universe.chain.source-schema:Document schema`, + display: capability.schemaVersion, + exact: capability.schemaVersion, + truncated: false, + note: null, + }); + + const releaseSha = capability.release?.sha ?? null; + if (releaseSha) { + details.push({ + id: 'release', + label: $localize`:@@universe.chain.source-release:Overlay release`, + display: releaseSha, + exact: releaseSha, + truncated: false, + // Three components carry their own commit, and this is one of them. An + // unqualified "Release" here reads as the version of the site, which it + // has never been. + note: $localize`:@@universe.chain.source-release-note:The commit of the protocol overlay that produced this document. The site and the explorer backend publish their own on the source page.`, + }); + } + + return details; +} + +export interface ReadCapability { + readonly id: string; + readonly label: string; + /** + * Three answers, not two. A chain that never answered has not declined a + * read, and printing "Not offered" for all six when the authority is simply + * unreachable states a fact nobody established. + */ + readonly state: 'offered' | 'not-offered' | 'unknown'; + readonly stateLabel: string; + readonly tone: EvidenceTone; +} + +const READ_LABELS: readonly { id: keyof ChainCapabilityEnvelope['reads']; label: string }[] = [ + { id: 'transaction', label: $localize`:@@universe.chain.read-transaction:Transaction lookup` }, + { id: 'block', label: $localize`:@@universe.chain.read-block:Block lookup` }, + { id: 'address', label: $localize`:@@universe.chain.read-address:Address history` }, + { id: 'outpoint', label: $localize`:@@universe.chain.read-outpoint:Outpoint lookup` }, + { id: 'feeEstimates', label: $localize`:@@universe.chain.read-fees:Fee estimates` }, + { id: 'projectedBlocks', label: $localize`:@@universe.chain.read-projected:Projected blocks` }, +]; + +/** + * What the chain can answer. This is the honest core of a chain overview: + * rather than showing empty sections for reads the overlay never claimed, the + * page states which questions it can answer at all, and says so separately + * from the case where it could not ask. + */ +export function readCapabilities( + capability: ChainCapabilityEnvelope | null +): readonly ReadCapability[] { + return READ_LABELS.map(({ id, label }) => { + if (!capability) { + return { + id, + label, + state: 'unknown' as const, + stateLabel: $localize`:@@universe.chain.read-unknown:Not stated`, + tone: 'neutral' as EvidenceTone, + }; + } + const offered = capability.reads?.[id] === true; + return { + id, + label, + state: offered ? ('offered' as const) : ('not-offered' as const), + stateLabel: offered + ? $localize`:@@universe.chain.read-offered:Offered` + : $localize`:@@universe.chain.read-not-offered:Not offered`, + tone: offered ? ('proven' as EvidenceTone) : ('unavailable' as EvidenceTone), + }; + }); +} + +export interface ProtocolReading { + readonly protocolId: string; + readonly label: string; + /** + * The route this protocol has, or null when the explorer serves none for it. + * A protocol the chain reports but this explorer cannot open is still listed, + * because hiding it would be a claim the chain did not make, but it is not + * linked: `/dogecoin/protocols/dunes` answers 404 and the frontend throws + * before it even asks. + */ + readonly routeId: string | null; + readonly stateLabel: string; + readonly tone: EvidenceTone; + readonly coverageLabel: string; + readonly historyLabel: string; + readonly lag: ExactNumber | null; + /** + * Why this reading is what it is, in words. On a ready authority these are + * the stated edges of its coverage rather than faults, which is why each one + * carries its own kind. + */ + readonly reasons: readonly ChainReasonReading[]; +} + +export function readProtocolCoverage( + capability: ChainCapabilityEnvelope | null, + profile: ChainProfile +): readonly ProtocolReading[] { + const declared = capability?.protocols ?? []; + const byId = new Map(declared.map((entry) => [entry.protocolId, entry])); + const claimed = new Set(); + + // The tabs first, in the order the navigation offers them, each matched to + // whichever id the envelope used for it. A tab the overlay stopped reporting + // still appears, marked as not stated, rather than vanishing from a page + // that links to it. + const readings: ProtocolReading[] = profile.protocols.map((tab) => { + const matchedId = tab.registryIds.find((id) => byId.has(id)); + if (matchedId) { + claimed.add(matchedId); + } + const entry = matchedId ? byId.get(matchedId) : undefined; + return { + protocolId: matchedId ?? tab.id, + label: tab.label, + routeId: tab.id, + stateLabel: availabilityLabel(entry?.state), + tone: availabilityTone(entry?.state), + coverageLabel: completenessLabel(entry?.coverage), + historyLabel: historyLabel(entry?.coverage), + lag: formatExactInteger(entry?.lagBlocksAtomic ?? null), + reasons: describeChainReasons(entry?.degradedReasons ?? []), + }; + }); + + // Then anything the chain reports that this explorer has no page for. It is + // listed, because the chain said it is there, and it is not linked, because + // the route would throw before it asked. + for (const entry of declared) { + if (claimed.has(entry.protocolId)) { + continue; + } + readings.push({ + protocolId: entry.protocolId, + label: humanizeFieldName(entry.protocolId), + routeId: null, + stateLabel: availabilityLabel(entry.state), + tone: availabilityTone(entry.state), + coverageLabel: completenessLabel(entry.coverage), + historyLabel: historyLabel(entry.coverage), + lag: formatExactInteger(entry.lagBlocksAtomic ?? null), + reasons: describeChainReasons(entry.degradedReasons ?? []), + }); + } + return readings; +} + +// --------------------------------------------------------------------------- +// Shape readers +// --------------------------------------------------------------------------- + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value !== '' ? value : null; +} + +/** + * A whole number that may arrive as an exact string or, outside the contract, + * as a JSON number. Pagination is the one place the overlay passes a source + * field straight through, so both forms have to be readable. + */ +function integerText(value: unknown): string | null { + if (typeof value === 'number' && Number.isSafeInteger(value)) { + return String(value); + } + return text(value); +} + +export type ChainShape = + | 'transaction' + | 'block' + | 'address' + | 'outpoint' + | 'collection' + | 'record' + | 'empty'; + +/** + * Which reading applies to a payload. Recognition is by the fields the + * contract guarantees, not by which page requested it, so a response that + * arrives in an unexpected shape falls through to the record reading rather + * than being rendered against the wrong template. + */ +export function classifyPayload(payload: ChainExplorerPayload | null): ChainShape { + if (!payload || !isRecord(payload)) { + return 'empty'; + } + if (payload.schemaVersion === 'universe-transaction-v1' && text(payload.txid)) { + return 'transaction'; + } + if (isRecord(payload.block) && text(payload.block.hash)) { + return 'block'; + } + // Zcash answers a block with its fields at the top level rather than under a + // `block` key, and with `prev_hash` and `tx_count` rather than the Dogecoin + // spellings. Nothing recognised it, so a real Zcash block page fell through + // to the generic field table and printed `schemaVersion`, a snake_case field + // list and a unix timestamp to a visitor, with none of its four + // transactions. `txid` is checked first so a transaction, which also carries + // a hash of its own, is never read as the block it is in. + if (!text(payload.txid) && text(payload.hash) && text(payload.height)) { + return 'block'; + } + if (isRecord(payload.outpoint) && text(payload.outpoint.txid)) { + return 'outpoint'; + } + if (text(payload.address)) { + return 'address'; + } + if (Object.values(payload).some((value) => Array.isArray(value) && value.length)) { + return 'collection'; + } + return 'record'; +} + +export interface TransactionPartyRow { + readonly index: string; + readonly address: string | null; + readonly amount: ExactNumber | null; + readonly reference: string | null; + readonly coinbase: boolean; + readonly spent: boolean | null; +} + +export interface ShieldedReading { + readonly components: readonly { label: string; count: ExactNumber | null }[]; + readonly valueBalance: ExactNumber | null; + readonly notice: string; +} + +export interface ProtocolActionRow { + readonly eventId: string; + readonly protocolId: string; + readonly protocolLabel: string; + readonly actionType: string; + readonly stateLabel: string; + readonly tone: EvidenceTone; +} + +export interface TransactionReading { + readonly txid: string; + readonly statusLabel: string; + readonly statusTone: EvidenceTone; + readonly confirmations: ExactNumber | null; + readonly block: { hash: string; height: ExactNumber | null; time: ExactNumber | null } | null; + readonly firstSeenAt: ExactNumber | null; + readonly size: ExactNumber | null; + readonly virtualSize: ExactNumber | null; + readonly feeAmount: ExactNumber | null; + readonly feeRate: string | null; + readonly feeRateUnit: string | null; + readonly logicalActions: ExactNumber | null; + readonly inputs: readonly TransactionPartyRow[]; + readonly outputs: readonly TransactionPartyRow[]; + readonly inputTotal: ExactNumber | null; + readonly outputTotal: ExactNumber | null; + readonly shielded: ShieldedReading | null; + readonly candidateActions: readonly ProtocolActionRow[]; + readonly confirmedActions: readonly ProtocolActionRow[]; + readonly replacedByTxid: string | null; + readonly conflicts: readonly { txid: string; reason: string }[]; + readonly expiry: { height: ExactNumber | null; state: string } | null; + readonly completenessLabel: string; + readonly completenessTone: EvidenceTone; +} + +const LIFECYCLE_TONE: Record = { + confirmed: 'proven', + observed: 'pending', + pending: 'pending', + replaced: 'partial', + conflicted: 'partial', + evicted: 'unavailable', + expired: 'unavailable', + reorged: 'unavailable', + 'unknown-removal': 'unavailable', +}; + +const LIFECYCLE_LABEL: Record = { + confirmed: $localize`:@@universe.chain.tx-confirmed:Confirmed in a block`, + observed: $localize`:@@universe.chain.tx-observed:Seen in the pending set`, + pending: $localize`:@@universe.chain.tx-pending:Waiting for a block`, + replaced: $localize`:@@universe.chain.tx-replaced:Replaced by another transaction`, + conflicted: $localize`:@@universe.chain.tx-conflicted:Conflicts with a confirmed transaction`, + evicted: $localize`:@@universe.chain.tx-evicted:Dropped from the pending set`, + expired: $localize`:@@universe.chain.tx-expired:Expired before confirming`, + reorged: $localize`:@@universe.chain.tx-reorged:Removed by a chain reorganisation`, + 'unknown-removal': $localize`:@@universe.chain.tx-removed:Removed for a reason the node did not state`, +}; + +const PROTOCOL_STATE_TONE: Record = { + 'confirmed-accepted': 'proven', + 'confirmed-rejected': 'unavailable', + candidate: 'pending', + unevaluated: 'neutral', + 'outside-coverage': 'neutral', + evicted: 'unavailable', + expired: 'unavailable', + reorged: 'unavailable', +}; + +const PROTOCOL_STATE_LABEL: Record = { + 'confirmed-accepted': $localize`:@@universe.chain.action-accepted:Accepted`, + 'confirmed-rejected': $localize`:@@universe.chain.action-rejected:Rejected by the protocol rules`, + candidate: $localize`:@@universe.chain.action-candidate:Candidate, not yet evaluated`, + unevaluated: $localize`:@@universe.chain.action-unevaluated:Not yet evaluated`, + 'outside-coverage': $localize`:@@universe.chain.action-outside:Outside the indexer's coverage`, + evicted: $localize`:@@universe.chain.action-evicted:Dropped before evaluation`, + expired: $localize`:@@universe.chain.action-expired:Expired before evaluation`, + reorged: $localize`:@@universe.chain.action-reorged:Removed by a chain reorganisation`, +}; + +function sumAtomic( + rows: readonly TransactionPartyRow[], + precision: number +): ExactNumber | null { + let total = 0n; + let counted = false; + for (const row of rows) { + if (row.amount) { + total += BigInt(row.amount.exact); + counted = true; + } + } + // A total that omitted an unreported input would be a smaller number + // presented as a fact. Only sum when every row carried an amount. + if (!counted || rows.some((row) => !row.amount)) { + return null; + } + return formatAtomicAmount(total.toString(), precision); +} + +function partyRows( + values: unknown, + profile: ChainProfile, + kind: 'input' | 'output' +): readonly TransactionPartyRow[] { + if (!Array.isArray(values)) { + return []; + } + return values.filter(isRecord).map((entry) => ({ + index: text(entry.indexAtomic) ?? '', + address: text(entry.address), + amount: formatAtomicAmount(text(entry.valueAtomic), profile.precision), + reference: kind === 'input' ? text(entry.previousOutpoint) : null, + coinbase: entry.coinbase === true, + spent: typeof entry.spent === 'boolean' ? entry.spent : null, + })); +} + +function actionRows( + values: unknown, + profile: ChainProfile +): readonly ProtocolActionRow[] { + if (!Array.isArray(values)) { + return []; + } + return values.filter(isRecord).map((entry) => { + const state = text(entry.state) ?? ''; + const protocolId = text(entry.protocolId) ?? ''; + return { + eventId: text(entry.eventId) ?? '', + protocolId, + protocolLabel: + profile.protocols.find((tab) => tab.id === protocolId)?.label ?? + humanizeFieldName(protocolId), + actionType: humanizeFieldName(text(entry.actionType) ?? ''), + stateLabel: PROTOCOL_STATE_LABEL[state] ?? availabilityLabel(state), + tone: PROTOCOL_STATE_TONE[state] ?? 'neutral', + }; + }); +} + +function shieldedReading(value: unknown, profile: ChainProfile): ShieldedReading | null { + if (!isRecord(value)) { + return null; + } + const components = [ + { label: $localize`:@@universe.chain.shielded-sprout:Sprout joinsplits`, count: formatExactInteger(text(value.sproutJoinSplitsAtomic)) }, + { label: $localize`:@@universe.chain.shielded-sapling-spends:Sapling spends`, count: formatExactInteger(text(value.saplingSpendsAtomic)) }, + { label: $localize`:@@universe.chain.shielded-sapling-outputs:Sapling outputs`, count: formatExactInteger(text(value.saplingOutputsAtomic)) }, + { label: $localize`:@@universe.chain.shielded-orchard:Orchard actions`, count: formatExactInteger(text(value.orchardActionsAtomic)) }, + { label: $localize`:@@universe.chain.shielded-ironwood:Ironwood actions`, count: formatExactInteger(text(value.ironwoodActionsAtomic)) }, + ]; + return { + components, + valueBalance: formatAtomicAmount(text(value.valueBalanceAtomic), profile.precision), + notice: + text(value.privacyNotice) ?? + $localize`:@@universe.chain.shielded-default-notice:Shielded senders, recipients, and amounts are not recorded in a form this explorer can read.`, + }; +} + +export function readTransaction( + payload: ChainExplorerPayload, + profile: ChainProfile +): TransactionReading | null { + if (classifyPayload(payload) !== 'transaction') { + return null; + } + const status = text(payload.status) ?? ''; + const transparent = isRecord(payload.transparent) ? payload.transparent : {}; + const inputs = partyRows(transparent.inputs, profile, 'input'); + const outputs = partyRows(transparent.outputs, profile, 'output'); + const fee = isRecord(payload.fee) ? payload.fee : {}; + const block = isRecord(payload.block) ? payload.block : null; + const replacement = isRecord(payload.replacement) ? payload.replacement : null; + const expiry = isRecord(payload.expiry) ? payload.expiry : null; + const actions = isRecord(payload.protocolActions) ? payload.protocolActions : {}; + const completeness = text(payload.completeness); + + return { + txid: text(payload.txid) ?? '', + statusLabel: LIFECYCLE_LABEL[status] ?? availabilityLabel(status), + statusTone: LIFECYCLE_TONE[status] ?? 'neutral', + confirmations: formatExactInteger(text(payload.confirmationsAtomic)), + block: block + ? { + hash: text(block.hash) ?? '', + height: formatExactInteger(text(block.heightAtomic)), + time: formatTimestamp(text(block.time)), + } + : null, + firstSeenAt: formatTimestamp(text(payload.firstSeenAt)), + size: formatExactInteger(text(payload.sizeBytesAtomic)), + virtualSize: formatExactInteger(text(payload.virtualSizeBytesAtomic)), + feeAmount: formatAtomicAmount(text(fee.amountAtomic), profile.precision), + feeRate: text(fee.rateDecimal), + feeRateUnit: text(fee.rateUnit), + logicalActions: formatExactInteger(text(fee.logicalActionsAtomic)), + inputs, + outputs, + inputTotal: sumAtomic(inputs, profile.precision), + outputTotal: sumAtomic(outputs, profile.precision), + shielded: shieldedReading(payload.shielded, profile), + candidateActions: actionRows(actions.candidates, profile), + confirmedActions: actionRows(actions.confirmed, profile), + replacedByTxid: replacement ? text(replacement.replacedByTxid) : null, + conflicts: Array.isArray(payload.conflicts) + ? payload.conflicts.filter(isRecord).map((entry) => ({ + txid: text(entry.txid) ?? '', + reason: humanizeFieldName(text(entry.reason) ?? ''), + })) + : [], + expiry: expiry + ? { + height: formatExactInteger(text(expiry.heightAtomic)), + state: text(expiry.state) ?? '', + } + : null, + completenessLabel: completenessLabel(completeness), + completenessTone: completenessTone(completeness), + }; +} + +export interface BlockReading { + readonly hash: string; + readonly height: ExactNumber | null; + readonly time: ExactNumber | null; + readonly transactionCount: ExactNumber | null; + readonly sizeBytes: ExactNumber | null; + readonly confirmations: ExactNumber | null; + readonly previousBlockHash: string | null; + readonly nextBlockHash: string | null; + readonly merkleRoot: string | null; + readonly difficulty: string | null; + readonly txids: readonly string[]; + readonly paging: Paging | null; + /** + * Lists this payload carries that this page has no reading for yet, with + * how many entries each holds. The generic fact reader drops every array + * without a word, so a Zcash block carrying Zerdinals inscriptions or ZRune + * events showed nothing about them, which is the false zero this product + * exists to avoid. + */ + readonly unlisted: readonly { label: string; count: number }[]; +} + +/** + * Where a list sits in a longer one, and where the next and previous parts of + * it are. A page that states "page 1 of 3" and offers no way to reach the other + * two is a dead end, and both the block and the address page were one. + */ +export interface Paging { + readonly page: number; + readonly totalPages: number; + readonly previousPage: number | null; + readonly nextPage: number | null; +} + +const MAX_PAGE = 100_000; + +export function readPaging(value: unknown): Paging | null { + if (!isRecord(value)) { + return null; + } + const page = Number(integerText(value.pageAtomic) ?? integerText(value.page)); + const totalPages = Number( + integerText(value.totalPagesAtomic) ?? integerText(value.totalPages) + ); + if ( + !Number.isSafeInteger(page) || + !Number.isSafeInteger(totalPages) || + page < 1 || + totalPages < 1 || + page > MAX_PAGE || + totalPages > MAX_PAGE + ) { + return null; + } + return { + page, + totalPages, + previousPage: page > 1 ? page - 1 : null, + nextPage: page < totalPages ? page + 1 : null, + }; +} + +export function readBlock(payload: ChainExplorerPayload): BlockReading | null { + if (classifyPayload(payload) !== 'block') { + return null; + } + if (!isRecord(payload.block)) { + return readFlatBlock(payload); + } + const block = payload.block; + const pagination = isRecord(payload.pagination) ? payload.pagination : {}; + return { + hash: text(block.hash) ?? '', + height: formatExactInteger(text(block.heightAtomic)), + time: formatTimestamp(text(block.time)), + transactionCount: formatExactInteger(text(block.transactionCountAtomic)), + sizeBytes: formatExactInteger(text(block.sizeBytesAtomic)), + confirmations: formatExactInteger(text(block.confirmationsAtomic)), + previousBlockHash: text(block.previousBlockHash), + nextBlockHash: text(block.nextBlockHash), + merkleRoot: text(block.merkleRoot), + difficulty: text(block.difficulty), + txids: Array.isArray(payload.txids) ? payload.txids.filter((id): id is string => typeof id === 'string') : [], + paging: readPaging(pagination), + unlisted: unlistedArrays(payload, ['txids']), + }; +} + +/** + * Arrays the payload carries that nothing on this page reads. Named and + * counted rather than dropped, because an array silently skipped and an array + * that was empty look identical to a reader, and on a protocol payload those + * are opposite answers. + */ +function unlistedArrays( + payload: ChainExplorerPayload, + read: readonly string[] +): readonly { label: string; count: number }[] { + const known = new Set(read); + const found: { label: string; count: number }[] = []; + for (const [key, value] of Object.entries(payload)) { + if (known.has(key) || !Array.isArray(value) || value.length === 0) { + continue; + } + found.push({ label: humanizeFieldName(key), count: value.length }); + } + return found; +} + +/** + * The Zcash shape: block fields at the top level, snake_case, with the + * transaction ids inside a `transactions` envelope that counts from an offset + * rather than a page number. + * + * Only the facts this payload actually carries are read. Size, difficulty, + * merkle root, confirmations and the next block are not in it, and they stay + * null rather than being invented or borrowed from elsewhere. + */ +function readFlatBlock(payload: ChainExplorerPayload): BlockReading | null { + const hash = text(payload.hash); + if (!hash) { + return null; + } + const list = isRecord(payload.transactions) ? payload.transactions : {}; + return { + hash, + height: formatExactInteger(text(payload.height)), + time: formatUnixTimestamp(text(payload.time)), + transactionCount: formatExactInteger(text(payload.tx_count)), + sizeBytes: null, + confirmations: null, + previousBlockHash: text(payload.prev_hash), + nextBlockHash: null, + merkleRoot: null, + difficulty: null, + txids: Array.isArray(list.items) + ? list.items.filter((id): id is string => typeof id === 'string') + : [], + paging: readOffsetPaging(list), + unlisted: unlistedArrays(payload, []), + }; +} + +/** + * Paging stated as an offset and a limit rather than as a page number. The + * arithmetic is done on safe integers only, and anything that does not divide + * into whole pages is reported as no paging rather than as a page number that + * would be wrong. + */ +export function readOffsetPaging(value: unknown): Paging | null { + if (!isRecord(value)) { + return null; + } + const total = Number(integerText(value.total)); + const offset = Number(integerText(value.offset)); + const limit = Number(integerText(value.limit)); + if ( + !Number.isSafeInteger(total) || + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(limit) || + total < 0 || + offset < 0 || + limit < 1 || + offset % limit !== 0 + ) { + return null; + } + const totalPages = Math.max(1, Math.ceil(total / limit)); + const page = offset / limit + 1; + if (page > totalPages || totalPages > MAX_PAGE) { + return null; + } + return { + page, + totalPages, + previousPage: page > 1 ? page - 1 : null, + nextPage: page < totalPages ? page + 1 : null, + }; +} + +/** + * The Zcash shape. Only what the payload states is read: it publishes the + * confirmed and the received balance and nothing about what has been sent or + * about anything unconfirmed, and those stay absent. Subtracting one published + * figure from the other would produce a total sent that is wrong the moment an + * output is immature or unconfirmed, and a wrong figure is worse here than a + * missing one. + */ +function readFlatAddress( + payload: ChainExplorerPayload, + profile: ChainProfile +): AddressReading | null { + const address = text(payload.address); + if (!address) { + return null; + } + const balance = isRecord(payload.balance) ? payload.balance : {}; + const history = isRecord(payload.transactions) ? payload.transactions : {}; + const utxos = Array.isArray(payload.utxos) ? payload.utxos.filter(isRecord) : []; + return { + address, + balance: formatAtomicAmount(text(balance.confirmed_zatoshis), profile.precision), + totalReceived: formatAtomicAmount(text(balance.received_zatoshis), profile.precision), + totalSent: null, + unconfirmedBalance: null, + transactionCount: formatExactInteger(integerText(history.total)), + unconfirmedCount: null, + txids: Array.isArray(history.items) + ? history.items.filter((id): id is string => typeof id === 'string') + : [], + paging: readOffsetPaging(history), + utxos: utxos.map((entry) => ({ + txid: text(entry.txid) ?? '', + vout: integerText(entry.vout) ?? '', + amount: formatAtomicAmount(text(entry.valueZatoshis), profile.precision), + // Not stated for any unspent output by this source, so nothing here says + // whether one is confirmed. + height: null, + confirmations: null, + pending: false, + })), + }; +} + +export interface UtxoRow { + readonly txid: string; + readonly vout: string; + readonly amount: ExactNumber | null; + readonly height: ExactNumber | null; + readonly confirmations: ExactNumber | null; + /** + * True when this source states a confirming block for its unspent outputs + * and did not state one here, which is what an output still in the mempool + * looks like. False when the source never states one, so the absence says + * nothing about whether the output is confirmed. + * + * Without the distinction a missing height read as "not in a block yet", and + * every unspent output on a Zcash address, all of them confirmed, was + * labelled pending. + */ + readonly pending: boolean; +} + +export interface AddressReading { + readonly address: string; + readonly balance: ExactNumber | null; + readonly totalReceived: ExactNumber | null; + readonly totalSent: ExactNumber | null; + readonly unconfirmedBalance: ExactNumber | null; + readonly transactionCount: ExactNumber | null; + readonly unconfirmedCount: ExactNumber | null; + readonly txids: readonly string[]; + readonly utxos: readonly UtxoRow[]; + readonly paging: Paging | null; +} + +export function readAddress( + payload: ChainExplorerPayload, + profile: ChainProfile +): AddressReading | null { + if (classifyPayload(payload) !== 'address') { + return null; + } + // Zcash states the same facts under different names: the balance nested and + // in snake_case, the history in an offset-counted envelope, an unspent + // output's index as a number rather than a decimal string. Read against the + // Dogecoin names alone, a real Zcash address rendered its identifier, no + // balance at all, and a table of unspent outputs with every amount blank, + // while every one of those figures was in the response. + if (isRecord(payload.balance) && !text(payload.balanceAtomic)) { + return readFlatAddress(payload, profile); + } + const utxos = Array.isArray(payload.utxos) ? payload.utxos.filter(isRecord) : []; + return { + address: text(payload.address) ?? '', + balance: formatAtomicAmount(text(payload.balanceAtomic), profile.precision), + totalReceived: formatAtomicAmount(text(payload.totalReceivedAtomic), profile.precision), + totalSent: formatAtomicAmount(text(payload.totalSentAtomic), profile.precision), + unconfirmedBalance: formatAtomicAmount(text(payload.unconfirmedBalanceAtomic), profile.precision), + transactionCount: formatExactInteger(text(payload.transactionCountAtomic)), + unconfirmedCount: formatExactInteger(text(payload.unconfirmedTransactionsAtomic)), + txids: Array.isArray(payload.txids) ? payload.txids.filter((id): id is string => typeof id === 'string') : [], + paging: readPaging(payload.pagination), + utxos: utxos.map((entry) => ({ + txid: text(entry.txid) ?? '', + vout: text(entry.voutAtomic) ?? '', + amount: formatAtomicAmount(text(entry.valueAtomic), profile.precision), + height: formatExactInteger(text(entry.heightAtomic)), + confirmations: formatExactInteger(text(entry.confirmationsAtomic)), + // This source states a height for a confirmed output, so the absence of + // one is a statement that the output is still pending. + pending: !text(entry.heightAtomic), + })), + }; +} + +export interface OutpointReading { + readonly txid: string; + readonly vout: string; + readonly amount: ExactNumber | null; + readonly address: string | null; + readonly spent: boolean | null; +} + +export function readOutpoint( + payload: ChainExplorerPayload, + profile: ChainProfile +): OutpointReading | null { + if (classifyPayload(payload) !== 'outpoint' || !isRecord(payload.outpoint)) { + return null; + } + const output = isRecord(payload.output) ? payload.output : {}; + return { + txid: text(payload.outpoint.txid) ?? '', + vout: text(payload.outpoint.voutAtomic) ?? '', + amount: formatAtomicAmount(text(output.valueAtomic), profile.precision), + address: text(output.address), + spent: typeof output.spent === 'boolean' ? output.spent : null, + }; +} + +// --------------------------------------------------------------------------- +// Collections +// --------------------------------------------------------------------------- + +export interface CollectionColumn { + readonly key: string; + readonly label: string; + readonly numeric: boolean; + /** Named once in the header rather than repeated in every cell. */ + readonly unit: string | null; +} + +export interface CollectionCell { + readonly key: string; + readonly fact: Fact | null; +} + +export interface CollectionReading { + /** The field the rows came from, so the page can name what it is listing. */ + readonly sourceKey: string; + readonly title: string; + readonly columns: readonly CollectionColumn[]; + readonly rows: readonly (readonly CollectionCell[])[]; + readonly shownCount: number; + readonly totalCount: number; + /** + * Fields present in the rows that the table did not have room for. A table + * that quietly drops a column reads as a complete answer, so the page says + * how many it is holding back and the full response stays one click away. + */ + readonly hiddenColumnCount: number; + /** Fields that were identical on every row, and so became no column at all. */ + readonly constantColumnCount: number; + /** + * Fields present in every row that a table cannot hold at all, because their + * value is a structure rather than a value. Named, not just counted. + * + * These were dropped in silence while `hiddenColumnCount` reported zero, so + * the page stated it had held nothing back at the moment it was holding back + * the most. On the live ZRC-20 list that is `rulesets`, the whole ledger for + * every token under two disagreeing rulesets, and `divergence`, which names + * the fields they disagree on and carries the authority's own prose about + * rules neither ruleset evaluates. + */ + readonly structuredFields: readonly string[]; +} + +/** How many rows a page renders before it says how many it is holding back. */ +export const COLLECTION_ROW_LIMIT = 100; +/** How many columns stay legible on a phone before the table stops helping. */ +const COLLECTION_COLUMN_LIMIT = 7; + +/** + * The longest array in the payload, read as a table. + * + * Columns are the union of the keys across the rows shown, in first-seen + * order, so a row that omits a field leaves a blank cell rather than shifting + * every column after it. That was the failure of taking the first row's first + * eight keys: two responses with slightly different rows produced two tables + * whose columns did not line up with their headers. + */ +export function readCollection( + payload: ChainExplorerPayload, + profile: ChainProfile +): CollectionReading | null { + if (!isRecord(payload)) { + return null; + } + let sourceKey = ''; + let source: unknown[] = []; + for (const [key, value] of Object.entries(payload)) { + if (Array.isArray(value) && value.length > source.length) { + sourceKey = key; + source = value; + } + } + if (!source.length) { + return null; + } + + const shown = source.slice(0, COLLECTION_ROW_LIMIT); + const scalarRows = shown.map((entry) => + isRecord(entry) ? entry : { value: entry } + ); + + const order: string[] = []; + const structured: string[] = []; + for (const row of scalarRows) { + for (const [key, value] of Object.entries(row)) { + if (value !== null && typeof value === 'object') { + if (!structured.includes(key)) { + structured.push(key); + } + continue; + } + if (!order.includes(key)) { + order.push(key); + } + } + } + + // A column whose value is the same on every row tells a reader nothing, and + // it costs one of the few slots a table has on a phone. The live mempool + // response is the case that showed it: its rows are whole transaction + // envelopes, so schemaVersion, chain and network were three of the seven + // columns and every cell in them was identical. They are still reported, + // once each, by the record reading beside the table. + const varying = + scalarRows.length > 1 + ? order.filter((key) => { + const first = scalarRows[0][key]; + return scalarRows.some((row) => row[key] !== first); + }) + : order; + const constantColumnCount = order.length - varying.length; + const hiddenColumnCount = Math.max(0, varying.length - COLLECTION_COLUMN_LIMIT); + const columns = varying.slice(0, COLLECTION_COLUMN_LIMIT).map((key) => ({ + key, + label: humanizeFieldName(key), + numeric: isCoinAmount(key) || /Atomic$/.test(key), + unit: isCoinAmount(key) ? profile.ticker : null, + })); + + return { + sourceKey, + title: humanizeFieldName(sourceKey), + columns, + rows: scalarRows.map((row) => + columns.map((column) => ({ + key: column.key, + fact: factFrom(column.key, row[column.key], profile), + })) + ), + shownCount: shown.length, + totalCount: source.length, + hiddenColumnCount, + constantColumnCount, + structuredFields: structured, + }; +} + +export interface TransactionListRow { + readonly txid: string; + readonly statusLabel: string; + readonly statusTone: EvidenceTone; + readonly firstSeenAt: ExactNumber | null; + readonly size: ExactNumber | null; + readonly fee: ExactNumber | null; + readonly logicalActions: ExactNumber | null; +} + +/** + * What the cost column can honestly be, decided by what the rows carry. + * + * Dogecoin reports a fee amount and no rate. Zcash reports no amount at all on + * a pending transaction, because a shielded transaction's fee is not derivable + * from its transparent side, and reports ZIP-317 logical actions instead. A + * fixed "Fee" column would have been empty on every row of the Zcash pending + * list, which is the page's most valuable column spent on nothing. + */ +export type TransactionCostColumn = 'amount' | 'logical-actions' | 'none'; + +export interface TransactionListReading { + readonly rows: readonly TransactionListRow[]; + readonly shownCount: number; + readonly totalCount: number; + readonly costColumn: TransactionCostColumn; +} + +/** + * A pending set, read as transactions rather than as a table of field names. + * + * The live mempool response carries whole transaction envelopes, one per row, + * not the summary rows the fixtures assumed. Rendered generically that produced + * seven columns of which three were the same on every row, and the two figures + * a reader actually wants from a pending list, what it pays and how big it is, + * were not among them. + */ +export function readTransactionList( + payload: ChainExplorerPayload | null, + profile: ChainProfile +): TransactionListReading | null { + if (!payload || !isRecord(payload)) { + return null; + } + const source = Object.values(payload).find( + (value): value is unknown[] => + Array.isArray(value) && + value.length > 0 && + value.every((entry) => classifyPayload(entry as ChainExplorerPayload) === 'transaction') + ); + if (!source) { + return null; + } + const shown = source.slice(0, COLLECTION_ROW_LIMIT).filter(isRecord); + const rows = shown.map((entry) => { + const status = text(entry.status) ?? ''; + const fee = isRecord(entry.fee) ? entry.fee : {}; + return { + txid: text(entry.txid) ?? '', + statusLabel: LIFECYCLE_LABEL[status] ?? availabilityLabel(status), + statusTone: LIFECYCLE_TONE[status] ?? 'neutral', + firstSeenAt: formatTimestamp(text(entry.firstSeenAt)), + size: formatExactInteger(text(entry.sizeBytesAtomic)), + fee: formatAtomicAmount(text(fee.amountAtomic), profile.precision), + logicalActions: formatExactInteger(text(fee.logicalActionsAtomic)), + }; + }); + + // The amount wins where any row has one, because it is the figure a reader + // is looking for. Logical actions are the fallback only when no row reports + // an amount at all, which is the shielded case rather than a gap. + const costColumn: TransactionCostColumn = rows.some((row) => row.fee) + ? 'amount' + : rows.some((row) => row.logicalActions) + ? 'logical-actions' + : 'none'; + + return { rows, shownCount: shown.length, totalCount: source.length, costColumn }; +} + +export interface EmptyListReading { + /** The field the list would have come from. */ + readonly field: string; + readonly label: string; + /** + * True when the authority also said its view is complete, which turns an + * empty list into a proven none rather than an absence of knowledge. + * + * The distinction is the product's whole claim, and this page was missing it: + * with no pending Zcash transactions, which is a real and common state, the + * page showed a status rail, a record of the snapshot, and nothing at all + * where the list would be. A reader could not tell "there are none" from + * "the list did not load". + */ + readonly proven: boolean; +} + +/** + * An empty list the authority actually returned. + * + * Only when there is no non-empty array to read instead, so a payload carrying + * both a full list and an empty one is described by the full one. + */ +export function readEmptyList( + payload: ChainExplorerPayload | null +): EmptyListReading | null { + if (!payload || !isRecord(payload)) { + return null; + } + const entries = Object.entries(payload).filter(([, value]) => + Array.isArray(value) + ); + if (!entries.length || entries.some(([, value]) => (value as unknown[]).length)) { + return null; + } + const [field] = entries[0]; + const snapshot = isRecord(payload.snapshot) ? payload.snapshot : {}; + const completeness = + text(payload.completeness) ?? text(snapshot.completeness); + return { + field, + label: humanizeFieldName(field), + proven: completeness === 'complete', + }; +} + +/** + * A list of plain identifier strings, which is how block transaction lists and + * address history arrive. Kept apart from {@link readCollection} because a + * column of hashes is a navigation list, not a table. + */ +export function readIdentifierList(values: unknown): readonly string[] { + return Array.isArray(values) + ? values.filter((value): value is string => typeof value === 'string') + : []; +} diff --git a/frontend/src/app/universe/multichain-explorer/zcash-address.spec.ts b/frontend/src/app/universe/multichain-explorer/zcash-address.spec.ts new file mode 100644 index 00000000000..7146ed814c0 --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/zcash-address.spec.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { ChainExplorerPayload } from '@app/universe/universe.types'; +import { + chainProfile, + classifyPayload, + readAddress, +} from '@app/universe/multichain-explorer/multichain-view'; + +/** + * `/api/v1/zcash/address/` as production returned it on + * 2026-08-29, with the unspent output list and the history trimmed to two + * entries each and nothing else changed. + */ +const ZCASH_ADDRESS = { + "schemaVersion": "zcash-metaprotocols-api-v1", + "network": "mainnet", + "checkpoint": { + "height": "3464734", + "hash": "0000000000645e18fd55f18a5b2681f0becdc180d14d35a730875c4dc66add1a" + }, + "coverage": { + "scannedHeight": "3464734", + "networkHeight": "3464734", + "blocksBehindNetwork": "0", + "nodeSynced": true, + "verificationProgress": 0.9999997113775545, + "chainComplete": true + }, + "address": "t1SEgZvXCu3ceE42qrq5pCeSq7HbLjX8NJv", + "address_type": "transparent", + "publicly_observable": true, + "balance": { + "confirmed_zatoshis": "1251345400", + "received_zatoshis": "920300177805" + }, + "utxos": [ + { + "txid": "e642bab049488871a307aff5a46be789b374858dfa7163e33ddff27c76e80438", + "vout": 0, + "valueZatoshis": "125522000", + "scriptPubKey": "76a9145bbd8cad40c669b9ef2a5b7b2fbedbe9f74d7bea88ac" + }, + { + "txid": "44b60d006a4197f81385da1718eeddb5be23fdfdaa45553f0cae97766f2e150e", + "vout": 0, + "valueZatoshis": "125015000", + "scriptPubKey": "76a9145bbd8cad40c669b9ef2a5b7b2fbedbe9f74d7bea88ac" + } + ], + "transactions": { + "total": "8945", + "offset": "0", + "limit": "50", + "items": [ + "73c47124313064d13e53b921857de4d57ac45e3e712b97233a2b38092f39ffbf", + "8dc93dfbc11a54d0a71488e6ba5193216018ef289a3e2f4ea8f115bab03011f6" + ], + "has_more": true + }, + "privacy_notice": "This page covers only publicly observable transparent activity and does not reveal shielded or Unified Address history.", + "chain": "zcash" + } as unknown as ChainExplorerPayload; + +const ZEC = chainProfile('zcash'); + +describe('a Zcash address, in the shape Zcash actually sends', () => { + it('shows the balance, which was in the response and on no page', () => { + // `balance.confirmed_zatoshis`, not `balanceAtomic`. Read against the + // Dogecoin name the page rendered an address and no figures at all. + const reading = readAddress(ZCASH_ADDRESS, ZEC); + expect(reading?.balance).toEqual({ display: '12.513454', exact: '1251345400' }); + expect(reading?.totalReceived).toEqual({ display: '9,203.00177805', exact: '920300177805' }); + }); + + it('does not derive a figure the chain did not state', () => { + // Received minus confirmed is not what has been sent: it is wrong the + // moment an output is unconfirmed or immature, and this source publishes + // neither figure. + const reading = readAddress(ZCASH_ADDRESS, ZEC); + expect(reading?.totalSent).toBeNull(); + expect(reading?.unconfirmedBalance).toBeNull(); + expect(reading?.unconfirmedCount).toBeNull(); + }); + + it('reads the history and its paging out of the envelope it arrives in', () => { + const reading = readAddress(ZCASH_ADDRESS, ZEC); + expect(reading?.transactionCount?.exact).toBe('8945'); + expect(reading?.txids).toHaveLength(2); + expect(reading?.paging?.page).toBe(1); + expect(reading?.paging?.nextPage).toBe(2); + }); + + it('reads an unspent output whose index is a number and whose amount is not called value', () => { + const utxo = readAddress(ZCASH_ADDRESS, ZEC)?.utxos[0]; + expect(utxo?.vout).toBe('0'); + expect(utxo?.amount).toEqual({ display: '1.25522', exact: '125522000' }); + // Neither height nor confirmation count is in this payload. + expect(utxo?.height).toBeNull(); + expect(utxo?.confirmations).toBeNull(); + // And that says nothing about whether the output is confirmed. Every + // unspent output on this address is confirmed, and a missing height read + // as "not in a block yet" labelled all of them pending. + expect(utxo?.pending).toBe(false); + }); + + it('still reads the Dogecoin shape', () => { + const dogecoin = { + chain: 'dogecoin', + address: 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L', + balanceAtomic: '1284460000000', + totalReceivedAtomic: '9930000000000', + totalSentAtomic: '8645540000000', + transactionCountAtomic: '318', + utxos: [{ txid: 'a'.repeat(64), voutAtomic: '1', valueAtomic: '74886600000', heightAtomic: '5623038', confirmationsAtomic: '3' }], + } as unknown as ChainExplorerPayload; + expect(classifyPayload(dogecoin)).toBe('address'); + const reading = readAddress(dogecoin, chainProfile('dogecoin')); + expect(reading?.balance?.exact).toBe('1284460000000'); + expect(reading?.totalSent?.exact).toBe('8645540000000'); + expect(reading?.utxos[0].vout).toBe('1'); + // This source does state a height, so an output without one is pending. + expect(reading?.utxos[0].pending).toBe(false); + const pendingUtxo = readAddress( + { ...dogecoin, utxos: [{ txid: 'b'.repeat(64), voutAtomic: '0', valueAtomic: '5' }] } as unknown as ChainExplorerPayload, + chainProfile('dogecoin') + ); + expect(pendingUtxo?.utxos[0].pending).toBe(true); + }); +}); diff --git a/frontend/src/app/universe/multichain-explorer/zcash-block.spec.ts b/frontend/src/app/universe/multichain-explorer/zcash-block.spec.ts new file mode 100644 index 00000000000..fdb3601ba74 --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/zcash-block.spec.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; +import { ChainExplorerPayload } from '@app/universe/universe.types'; +import { + classifyPayload, + readBlock, + readOffsetPaging, + formatUnixTimestamp, +} from '@app/universe/multichain-explorer/multichain-view'; + +/** + * The response `/api/v1/zcash/block/` actually returned from production + * on 2026-08-29, with the transaction list trimmed to two entries and nothing + * else changed. Written down rather than paraphrased, because every field + * spelling here is one this reader has to get right, and a fixture in the + * Dogecoin spelling is exactly how this page shipped as a field dump. + */ +const ZCASH_BLOCK = { + "schemaVersion": "zcash-metaprotocols-api-v1", + "network": "mainnet", + "checkpoint": { + "height": "3464717", + "hash": "00000000001a8027b33ea8f1972569b135eea78be68a381875307549ddf178e2" + }, + "coverage": { + "scannedHeight": "3464717", + "networkHeight": "3464717", + "blocksBehindNetwork": "0", + "nodeSynced": true, + "verificationProgress": 1, + "chainComplete": true + }, + "height": "3464717", + "hash": "00000000001a8027b33ea8f1972569b135eea78be68a381875307549ddf178e2", + "prev_hash": "00000000006c247a8c511e915a0370a4bdbae53d4cf4b10931b2df553d186277", + "time": "1788003830", + "tx_count": "4", + "transactions": { + "total": "4", + "offset": "0", + "limit": "50", + "items": [ + "e743ed36e0a10285fbfd4de57844f0f78bc929ad58b1d391fa294a540f9232bd", + "9ff76d00ba46f460a3d2cc536c5e9e82fdabf1ad0eea45fb32a554bbab1d4d59" + ], + "has_more": false + }, + "inscription_count": "0", + "inscriptions": [], + "envelopes": [], + "events": [], + "zrune_events": [], + "chain": "zcash" + } as unknown as ChainExplorerPayload; + +describe('a Zcash block, in the shape Zcash actually sends', () => { + it('is a block, not a record to be printed field by field', () => { + // It was 'record' before, so the page rendered `schemaVersion`, + // `prev_hash` and a unix timestamp in a generic two-column table. + expect(classifyPayload(ZCASH_BLOCK)).toBe('block'); + }); + + it('reads the facts the payload carries, in the words the rest of the product uses', () => { + const block = readBlock(ZCASH_BLOCK); + expect(block?.hash).toBe('00000000001a8027b33ea8f1972569b135eea78be68a381875307549ddf178e2'); + expect(block?.height).toEqual({ display: '3,464,717', exact: '3464717' }); + expect(block?.transactionCount).toEqual({ display: '4', exact: '4' }); + expect(block?.previousBlockHash).toBe('00000000006c247a8c511e915a0370a4bdbae53d4cf4b10931b2df553d186277'); + expect(block?.txids).toHaveLength(2); + }); + + it('reads a block time given in seconds, and keeps the seconds', () => { + const block = readBlock(ZCASH_BLOCK); + expect(block?.time?.display).toBe('29 Aug 2026, 11:43 UTC'); + expect(block?.time?.exact).toBe('1788003830'); + }); + + it('states nothing it was not told', () => { + // Size, difficulty, the merkle root, the confirmation count and the next + // block are not in this payload. None of them is invented. + const block = readBlock(ZCASH_BLOCK); + expect(block?.sizeBytes).toBeNull(); + expect(block?.difficulty).toBeNull(); + expect(block?.merkleRoot).toBeNull(); + expect(block?.confirmations).toBeNull(); + expect(block?.nextBlockHash).toBeNull(); + }); + + it('does not drop a protocol list without saying so', () => { + const withInscriptions = { + ...ZCASH_BLOCK, + inscriptions: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + zrune_events: [{ id: 'd' }], + } as unknown as ChainExplorerPayload; + expect(readBlock(withInscriptions)?.unlisted).toEqual([ + { label: 'Inscriptions', count: 3 }, + { label: 'Zrune events', count: 1 }, + ]); + // Empty arrays are a proven none and say nothing. + expect(readBlock(ZCASH_BLOCK)?.unlisted).toEqual([]); + }); + + it('does not read a transaction as the block it names', () => { + const transaction = { + schemaVersion: 'universe-transaction-v1', + txid: 'a'.repeat(64), + hash: 'b'.repeat(64), + height: '3464717', + } as unknown as ChainExplorerPayload; + expect(classifyPayload(transaction)).toBe('transaction'); + }); +}); + +describe('readOffsetPaging', () => { + it('turns an offset and a limit into a page number', () => { + expect(readOffsetPaging({ total: '120', offset: '50', limit: '50' })).toEqual({ + page: 2, totalPages: 3, previousPage: 1, nextPage: 3, + }); + }); + + it('reports no paging rather than a page number it had to round', () => { + expect(readOffsetPaging({ total: '120', offset: '30', limit: '50' })).toBeNull(); + expect(readOffsetPaging({ total: '120', offset: '0', limit: '0' })).toBeNull(); + expect(readOffsetPaging(null)).toBeNull(); + }); + + it('gives an empty list one page rather than none', () => { + expect(readOffsetPaging({ total: '0', offset: '0', limit: '50' })?.totalPages).toBe(1); + }); +}); + +describe('formatUnixTimestamp', () => { + it('refuses a value that is not a time in seconds', () => { + // Milliseconds pass every "is it a number" check and land in the year + // 58000, which renders perfectly well and is wrong. + expect(formatUnixTimestamp('1788003830000')).toBeNull(); + expect(formatUnixTimestamp('0')).toBeNull(); + expect(formatUnixTimestamp('not a time')).toBeNull(); + expect(formatUnixTimestamp(null)).toBeNull(); + }); +}); diff --git a/frontend/src/app/universe/outpoint/outpoint.component.html b/frontend/src/app/universe/outpoint/outpoint.component.html index 8ac9dbe2c19..f57fa9f3b4f 100644 --- a/frontend/src/app/universe/outpoint/outpoint.component.html +++ b/frontend/src/app/universe/outpoint/outpoint.component.html @@ -89,7 +89,7 @@

{{ position.asset?.assetKind }} - {{ position.state }} + {{ position.state }}
  • diff --git a/frontend/src/app/universe/outpoint/outpoint.component.scss b/frontend/src/app/universe/outpoint/outpoint.component.scss index cfeebed3979..72fbeac6ad3 100644 --- a/frontend/src/app/universe/outpoint/outpoint.component.scss +++ b/frontend/src/app/universe/outpoint/outpoint.component.scss @@ -138,6 +138,7 @@ .asset-meta { display: flex; + align-items: center; gap: 0.75rem; font-size: 0.8rem; color: var(--u-text-muted); diff --git a/frontend/src/app/universe/outpoint/outpoint.component.ts b/frontend/src/app/universe/outpoint/outpoint.component.ts index 439a0d2ca75..a3b45ba9b14 100644 --- a/frontend/src/app/universe/outpoint/outpoint.component.ts +++ b/frontend/src/app/universe/outpoint/outpoint.component.ts @@ -13,6 +13,7 @@ import { OutpointEnrichment, } from '@app/universe/universe.types'; import { + EvidenceTone, EvidenceView, ProtocolGroup, formatAtomicAmount, @@ -143,6 +144,29 @@ export class OutpointComponent implements OnInit, OnDestroy { return group.protocolId; } + /** + * The evidence tone for one attached asset. The verdict at the top of the + * page is for the whole output; a position can differ from it, and the word + * on its own carried no treatment at all. + */ + positionTone(state: string): EvidenceTone { + switch (state) { + case 'proven': + return 'proven'; + case 'partial': + case 'partially-proven': + return 'partial'; + case 'pending': + case 'mempool-candidate': + return 'pending'; + case 'unavailable': + case 'out-of-coverage': + return 'unavailable'; + default: + return 'neutral'; + } + } + trackByPosition(index: number, position: ExplorerOutpointPosition): string { return `${position.outpoint}:${position.asset?.assetId ?? index}`; } diff --git a/frontend/src/app/universe/rune/rune.component.html b/frontend/src/app/universe/rune/rune.component.html index 37769290784..a13f07f48a5 100644 --- a/frontend/src/app/universe/rune/rune.component.html +++ b/frontend/src/app/universe/rune/rune.component.html @@ -103,12 +103,12 @@

    Minting

    {{ mintExplanation(state.result.value) }}

    -
    -

{{ column.label }} {{ column.unit }}
- {{ key }}
{{ value(row[key]) }} +
+ {{ cell.fact.display }} + {{ cell.fact ? cell.fact.display : '' }}