diff --git a/.github/workflows/backend-integration.yml b/.github/workflows/backend-integration.yml index 89624cb77b..09685e52fa 100644 --- a/.github/workflows/backend-integration.yml +++ b/.github/workflows/backend-integration.yml @@ -15,7 +15,7 @@ jobs: matrix: node: ["24.13.0"] fail-fast: false - runs-on: mempool-ci + runs-on: runs-on=${{ github.run_id }}-backend_integration/runner=universe-hosted/cpu=2+8/env=production env: COMPOSE_PROJECT_NAME: mempool-integration-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} MEMPOOL_TEST_BASE_CONFIG_FILE: ${{ github.workspace }}/${{ matrix.node }}/integration/backend/mempool-config.test.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6656d41679..af5bf79d2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: node: ["24.13.0"] flavor: ["dev", "prod"] fail-fast: false - runs-on: mempool-ci + runs-on: runs-on=${{ github.run_id }}-backend/runner=universe-hosted/cpu=2+8/env=production name: Backend (${{ matrix.flavor }}) - node ${{ matrix.node }} steps: @@ -101,7 +101,7 @@ jobs: strategy: matrix: node: ["24.13.0"] - runs-on: mempool-ci + runs-on: runs-on=${{ github.run_id }}-cache/runner=universe-hosted/cpu=2+8/env=production steps: - name: Checkout uses: actions/checkout@v3 @@ -212,7 +212,7 @@ jobs: node: ["24.13.0"] flavor: ["dev", "prod"] fail-fast: false - runs-on: mempool-ci + runs-on: runs-on=${{ github.run_id }}-frontend/runner=universe-hosted/cpu=2+8/env=production name: Frontend (${{ matrix.flavor }}) - node ${{ matrix.node }} steps: @@ -309,7 +309,7 @@ jobs: e2e: if: "(github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')) || github.event_name == 'push'" - runs-on: mempool-ci + runs-on: runs-on=${{ github.run_id }}-e2e/runner=universe-hosted/cpu=2+8/env=production needs: frontend strategy: fail-fast: false @@ -453,7 +453,7 @@ jobs: CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }} validate_docker_json: if: "(github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')) || github.event_name == 'push'" - runs-on: mempool-ci + runs-on: runs-on=${{ github.run_id }}-validate_docker_json/runner=universe-hosted/cpu=2+8/env=production name: Validate generated backend Docker JSON steps: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f4abaf3192..c6eb44d834 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -20,7 +20,7 @@ permissions: jobs: test-images: # Always run on tag pushes and all pull requests - runs-on: mempool-ci + runs-on: runs-on=${{ github.run_id }}-test_images/runner=universe-hosted/cpu=2+8/env=production timeout-minutes: 30 name: Test built Docker images steps: @@ -233,7 +233,7 @@ jobs: service: - frontend - backend - runs-on: mempool-ci + runs-on: runs-on=${{ github.run_id }}-build/runner=universe-hosted/cpu=2+8/env=production timeout-minutes: 120 name: Build and push to DockerHub outputs: @@ -363,7 +363,7 @@ jobs: needs: build # Only for successful tag pushes (not PRs with docker-push label) and only for "plain" versions (no '-') if: ${{ needs.build.result == 'success' && github.event_name == 'push' && !contains(github.ref_name, '-') }} - runs-on: mempool-ci + runs-on: runs-on=${{ github.run_id }}-tag_latest/runner=universe-hosted/cpu=2+8/env=production timeout-minutes: 30 name: Tag release build as latest strategy: diff --git a/.github/workflows/e2e_parameterized.yml b/.github/workflows/e2e_parameterized.yml index ac4bca66d4..089adb407c 100644 --- a/.github/workflows/e2e_parameterized.yml +++ b/.github/workflows/e2e_parameterized.yml @@ -22,7 +22,7 @@ on: jobs: cache: name: "Cache assets for builds" - runs-on: mempool-ci + runs-on: runs-on=${{ github.run_id }}-cache/runner=universe-hosted/cpu=2+8/env=production steps: - name: Determine checkout ref id: determine-ref @@ -123,7 +123,7 @@ jobs: key: promo-video-assets-cache e2e: - runs-on: mempool-ci + runs-on: runs-on=${{ github.run_id }}-e2e/runner=universe-hosted/cpu=2+8/env=production needs: cache strategy: fail-fast: false diff --git a/.github/workflows/get_backend_block_height.yml b/.github/workflows/get_backend_block_height.yml index 73db6107ee..088f628199 100644 --- a/.github/workflows/get_backend_block_height.yml +++ b/.github/workflows/get_backend_block_height.yml @@ -4,7 +4,7 @@ on: [workflow_dispatch] jobs: print-backend-sha: - runs-on: mempool-ci + runs-on: runs-on=${{ github.run_id }}-print_backend_sha/runner=universe-hosted/cpu=2+8/env=production name: Get block height steps: - name: Checkout diff --git a/.github/workflows/get_backend_hash.yml b/.github/workflows/get_backend_hash.yml index d63860c5e8..0c4c3d28e5 100644 --- a/.github/workflows/get_backend_hash.yml +++ b/.github/workflows/get_backend_hash.yml @@ -4,7 +4,7 @@ on: [workflow_dispatch] jobs: print-backend-sha: - runs-on: mempool-ci + runs-on: runs-on=${{ github.run_id }}-print_backend_sha/runner=universe-hosted/cpu=2+8/env=production name: Print backend hashes steps: - name: Checkout diff --git a/.github/workflows/get_image_digest.yml b/.github/workflows/get_image_digest.yml index 3d86c860c1..d43bea0528 100644 --- a/.github/workflows/get_image_digest.yml +++ b/.github/workflows/get_image_digest.yml @@ -10,7 +10,7 @@ on: type: string jobs: print-images-sha: - runs-on: mempool-ci + runs-on: runs-on=${{ github.run_id }}-print_images_sha/runner=universe-hosted/cpu=2+8/env=production name: Print digest for images steps: - name: Checkout diff --git a/.github/workflows/project-review-status.yml b/.github/workflows/project-review-status.yml index 7e85b8eb34..3caa9a755b 100644 --- a/.github/workflows/project-review-status.yml +++ b/.github/workflows/project-review-status.yml @@ -12,7 +12,7 @@ on: jobs: manage-project-board: - runs-on: mempool-ci + runs-on: runs-on=${{ github.run_id }}-manage_project_board/runner=universe-hosted/cpu=2+8/env=production steps: - name: Update Project Board uses: actions/github-script@v7 diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index b04727686f..1abdec27e0 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -44,8 +44,18 @@ jobs: with: node-version: '24.19.0' - - name: Protocol coverage table matches the recorded manifest - run: node scripts/universe/generate-protocol-coverage.mjs --check + # The roster is owned by backend-apis. This holds the pinned copy to its + # schema and its provenance, holds this repository's own surfaces to the + # pinned roster, and refuses a roster that lost an entry. The gate it + # replaced rendered a table from the recorded JSON and compared it with + # the committed table, so it could only prove a file agreed with itself: + # dunes and zrc20 were added to the registry and served in production + # while the recorded roster here still said thirty-six, green throughout. + - name: The pinned protocol roster holds + run: node scripts/universe/protocol-contract.mjs --check + + - name: The protocol roster gate fires on the states it exists for + run: node --test scripts/universe/protocol-contract.test.mjs - name: No em dash anywhere run: node scripts/universe/check-text.mjs @@ -124,12 +134,12 @@ jobs: frontend: name: Frontend build runs-on: [self-hosted, linux-ultra] - # The deep visual pass measured 48 minutes once the gated route list grew - # from three routes to seven, which put the job over the hour it had and - # killed it three minutes from the end. A gate that cannot finish protects - # nothing, which is the same reason the matrix was split in two to begin - # with. Budget is for the pass that actually runs, with room to grow. - timeout-minutes: 100 + # This builds, gates the bundle, and walks every route once at a phone and + # a desktop width. The deep pass that used to follow it now runs beside it, + # sharded, because it stopped fitting: 71 minutes of a 100 minute cap on + # its own, and on develop it reached the cap and the required check died as + # a timeout. Budget here is for the work that is left, with room to grow. + timeout-minutes: 45 steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -185,6 +195,13 @@ jobs: working-directory: scripts/universe/visual-qa run: node --test chain-page-audit.test.mjs + # Structural facts of the multichain parity release: navigation not + # gated back to Bitcoin, the route registry whole, the dashboard + # requesting real data, cubes carrying heights, chain-native units. + - name: The parity regressions gate holds + working-directory: scripts/universe + run: node --test parity-regressions.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 @@ -207,64 +224,144 @@ jobs: # chart panel draws nothing, or when a failure state says nothing about # why. Those three states passed every other gate here and shipped. # - # Split into two passes that finish, rather than one sweep that does not. - # Every combination of thirteen routes, seven data states, three themes - # and seven widths is nineteen hundred page loads, which does not fit the - # job timeout, and a gate that cannot finish protects nothing. - # - # The broad pass walks every route at a phone and a desktop width with - # real data, which is where overflow, broken images and a page that never - # 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 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 + # The pass here is the broad one: every route, real data, a phone and a + # desktop width, which is where overflow, broken images and a page that + # never finishes show up. It measured 5m50s. # - # 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. + # The deep pass is the `visual` job below. It is routes multiplied by + # states multiplied by themes multiplied by widths, it grew to 71 minutes + # of this job's 100 minute cap, and on develop it reached the cap: run + # 33320958080 was killed at 1h41 with the deep pass still going, so the + # required check for the promotion had no verdict at all. The comment + # that used to sit here said to split it again rather than raise the cap, + # because a gate that cannot finish protects nothing. That is what the + # shards are. - name: Every route renders and finishes, at a phone and a desktop width working-directory: scripts/universe/visual-qa run: >- node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts-broad --states=populated --themes=default --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 + + # Kept when there is something to look at, which is when a gate failed. + # + # A green deep pass is five hundred screenshots nobody opens, and + # uploading them on every run filled the Actions artifact quota: run + # 33330398257 passed every gate in this job and then failed on + # "Artifact storage quota has been hit. Unable to upload any new + # artifacts", so a build with nothing wrong with it reported a failure. + # Usage is recalculated every six to twelve hours, so the way out is to + # stop producing it rather than to delete faster. Retention is capped at + # three days by the repository in any case. + - name: Keep the contrast report and the route screenshots + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: universe-visual-qa-broad + path: scripts/universe/visual-qa/artifacts-broad + if-no-files-found: ignore + retention-days: 3 + + # The deep visual pass, in three shards. + # + # Measured on run 33323262662, 2026-08-30, on universe-linux-ultra-04: seven + # gated routes across three widths, seven states and the themes they carry + # took 1h11m in one step. Ten minutes a route. One more route would have put + # the job past its cap; on develop it already had. + # + # The split is by route because the cost is per route, and the shards are + # sized from that measurement rather than by counting names. Each shard + # builds its own copy, the way the mobile gate does: it costs one production + # build and buys a gate that can be read, run and debugged on its own, and + # that cannot make the job beside it wait. + # + # charts-and-mining graphs, mining about 20 minutes + # protocols-and-home protocols, home about 20 minutes + # blocks-and-transactions blocks, tx, address about 30 minutes + # plus the chain failure states, 3m27s + visual: + name: Visual matrix (${{ matrix.shard }}) + runs-on: [self-hosted, linux-ultra] + timeout-minutes: 60 + strategy: + # One shard failing says nothing about the others, and finding out that + # two route groups are broken in one run beats finding out twice. + fail-fast: false + matrix: + include: + - shard: charts-and-mining + routes: graphs,mining + chainStates: 'no' + - shard: protocols-and-home + routes: protocols,home + chainStates: 'no' + - shard: blocks-and-transactions + routes: blocks,tx,address + chainStates: 'yes' + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24.19.0' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install + working-directory: frontend + run: npm ci + + - name: Production build + working-directory: frontend + run: npm run build:universe + + - name: Install the visual QA harness + working-directory: scripts/universe/visual-qa + run: npm ci + + # 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 + # when that other job ended. Each run now serves on its own free port and + # refuses to continue unless the process answering is the one it started. + - name: Serve the built frontend + run: | + GATEWAY_PORT=$(node -e "const s=require('net').createServer();s.listen(0,'127.0.0.1',()=>{console.log(s.address().port);s.close()})") + echo "GATEWAY_PORT=$GATEWAY_PORT" >> "$GITHUB_ENV" + UNIVERSE_GATEWAY_PORT=$GATEWAY_PORT UNIVERSE_GATEWAY_ROOT=frontend/dist/mempool/browser node scripts/universe/gateway.mjs & + GATEWAY_PID=$! + for i in $(seq 1 30); do + curl -fsS -o /dev/null "http://127.0.0.1:$GATEWAY_PORT/" && break + sleep 1 + done + kill -0 "$GATEWAY_PID" + curl -fsS "http://127.0.0.1:$GATEWAY_PORT/__gateway/health" + + # The routes this suite holds to a terminal state, taken 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. - name: The gated routes hold up across themes, widths and every failure state working-directory: scripts/universe/visual-qa run: >- 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. + --routes=${{ matrix.routes }} --viewports=320,768,1440 + + # The chain failure states are scoped to the chain routes, so the pass + # above never reaches them. Five states: 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 what the whole status + # vocabulary exists for, and for a long time nothing measured them. # - # 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. + # Two themes and two widths rather than three and three, because 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 + if: matrix.chainStates == 'yes' working-directory: scripts/universe/visual-qa run: >- node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts-chain-states @@ -272,20 +369,18 @@ jobs: --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 - + # Same reasoning as the broad pass above: this is the artifact that + # filled the quota, at about fifty megabytes a run. - name: Keep the contrast report and the route screenshots - if: always() + if: failure() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: universe-visual-qa + name: universe-visual-qa-${{ matrix.shard }} path: | scripts/universe/visual-qa/artifacts - scripts/universe/visual-qa/artifacts-broad scripts/universe/visual-qa/artifacts-chain-states - retention-days: 30 + if-no-files-found: ignore + retention-days: 3 # The mobile gate runs beside the visual matrix rather than inside it. # @@ -369,15 +464,20 @@ jobs: node mobile-perf.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts-perf + # Kept on failure only, for the reason in the frontend job above: the + # Actions artifact quota is full, and an upload attempt fails the step. + # Run 33333310466 passed every measurement in this job and then failed + # on this upload, which is a green gate reporting red. - name: Keep the mobile report - if: always() + if: failure() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: + if-no-files-found: ignore name: universe-mobile-qa path: | scripts/universe/visual-qa/artifacts-mobile scripts/universe/visual-qa/artifacts-perf - retention-days: 30 + retention-days: 3 # The two engines the fleet cannot run. # @@ -401,7 +501,17 @@ jobs: # two steps back in the one above, which is where they belong. mobile-engines: name: WebKit and Firefox - runs-on: ubuntu-latest + # RunsOn Spot, per the change on develop that replaced the GitHub-hosted + # label here. This was the one required check in this workflow that needed + # a GitHub-hosted runner, and on 2026-08-30 the organization's plan stopped + # providing them: every run of this job failed while the four self-hosted + # checks beside it kept passing. + # + # Worth revisiting: this job takes screenshots at seven widths, and the + # routing rule those changes introduced keeps screenshot and baseline work + # on the local fleet because its output depends on the host. `browser-heavy` + # carries a browser and the libraries these engines need, and costs nothing. + runs-on: runs-on=${{ github.run_id }}-mobile_engines/runner=universe-hosted/cpu=2+8/env=production timeout-minutes: 45 steps: - name: Checkout @@ -426,9 +536,15 @@ jobs: working-directory: scripts/universe/visual-qa run: npm ci + # `--with-deps` installs system libraries and needs root, which a + # self-hosted runner account may not have. These runners already carry + # what Chromium needs, which is very nearly the same set, so a refused + # dependency install falls back to fetching the engines alone rather + # than failing a gate over a permission. - name: Fetch the engines and what they need to start working-directory: scripts/universe/visual-qa - run: npx playwright install --with-deps webkit firefox + run: | + npx playwright install --with-deps webkit firefox || npx playwright install webkit firefox - name: Serve the built frontend run: | @@ -464,11 +580,12 @@ jobs: --out=./artifacts-mobile-firefox - name: Keep the engine reports - if: always() + if: failure() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: + if-no-files-found: ignore name: universe-mobile-engines path: | scripts/universe/visual-qa/artifacts-mobile-webkit scripts/universe/visual-qa/artifacts-mobile-firefox - retention-days: 30 + retention-days: 3 diff --git a/.github/workflows/universe-production-smoke.yml b/.github/workflows/universe-production-smoke.yml index 08d43340d1..444452b4e4 100644 --- a/.github/workflows/universe-production-smoke.yml +++ b/.github/workflows/universe-production-smoke.yml @@ -43,6 +43,14 @@ jobs: with: node-version: '24.19.0' + # The pinned roster is a copy of another repository's registry, and a copy + # is only worth anything while something compares it. This is that + # comparison, against what the deployment actually serves: a protocol + # added, dropped, renamed, re-authorised or moved between chains fails + # here rather than being discovered by a reader of a stale table. + - name: The served protocol roster matches the pinned one + run: node scripts/universe/protocol-contract.mjs --against "${{ github.event.inputs.origin || 'https://explorer.bitcoinuniverse.io' }}" + - 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' }}" @@ -78,10 +86,14 @@ jobs: node chain-page-smoke.mjs --origin="${{ github.event.inputs.origin || 'https://explorer.bitcoinuniverse.io' }}" + # On failure only. The Actions artifact quota is full, an upload attempt + # fails the step, and a scheduled check that reports red for its own + # screenshots teaches everyone to ignore it. - name: Keep what the chain pages looked like - if: always() + if: failure() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: + if-no-files-found: ignore name: universe-chain-page-smoke path: scripts/universe/visual-qa/artifacts-chain-smoke - retention-days: 30 + retention-days: 3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 27c99b202a..9888a496ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ Run these before opening a pull request. They are the same ones ```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/protocol-contract.mjs --check node scripts/universe/check-text.mjs node scripts/universe/check-colors.mjs node scripts/universe/check-palettes.mjs diff --git a/README.md b/README.md index 2cee2d30e2..3fe51e77fa 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,14 @@ 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. | -Readable today, backed by the first-party Ord 0.29 authority: **Ordinals**, -**Rare Sats**, **Runes**. + + +7 of the 38 protocols in the registry are readable today: + +- On bitcoin: **Ordinals**, **Rare Sats**, **Runes**, **OP Inscriptions**, from ord and index-opinscriptions. +- On zcash: **Zerdinals**, **ZRunes**, **ZRC-20**, from index-zcash-metaprotocols. + + 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 @@ -121,10 +127,15 @@ 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 and its release status. Run -`node scripts/universe/generate-protocol-coverage.mjs --check` to verify the -table still matches the recorded manifest. +The roster itself is owned by `bitcoinuniverseio/backend-apis` and served by +`/api/v1/universe/protocols`. This repository pins a copy of it in +`docs/protocols/PROTOCOL-COVERAGE.json`, carrying the schema, the registry +version, the repository that produced it and the commit it was produced from, +and `docs/protocols/PROTOCOL-COVERAGE.md` is the readable table. +`node scripts/universe/protocol-contract.mjs --check` holds the pinned roster +and this repository's own surfaces together, and +`node scripts/universe/protocol-contract.mjs --against https://explorer.bitcoinuniverse.io` +fails when a deployment serves a roster that differs from the pin. ## Architecture @@ -222,7 +233,7 @@ cd frontend && npm run build:universe # production build, no third-party fetch 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/protocol-contract.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 diff --git a/docs/operations/DEPLOYMENT.md b/docs/operations/DEPLOYMENT.md index 16b7d2613c..f970fb48b9 100644 --- a/docs/operations/DEPLOYMENT.md +++ b/docs/operations/DEPLOYMENT.md @@ -100,6 +100,46 @@ The overlay reads only Universe protocol authorities, configured through bearer tokens supplied by separate named variables. Credentials never appear in the JSON, in responses, or in logs. +Two things about a source entry are easy to get wrong, and both have already +produced an authority the overlay reported as unreachable while it was running +and ready. + +The scheme has to be the one the authority actually speaks. Most authorities +here are plain HTTP on loopback; `index-doge-tap` is an HTTPS listener, so its +entry names `https://doge-tap.internal.bitcoinuniverse.io:3013`. That host is in +the certificate's subject alternative names and resolves to `127.0.0.1` through +`/etc/hosts`, so verification passes and the traffic still stays on the loopback +interface rather than going out to the public address the certificate also +names. The certificate is trusted through `NODE_EXTRA_CA_CERTS`, never by +turning verification off. Point an entry at `http://` when the authority serves +TLS and every probe gets an empty reply, which the overlay can only report as +unreachable. + +The token has to be the one the endpoint being called checks. A service can +carry more than one: `index-doge-tap` authenticates its reader API with +`TOKEN_EXPLORER_BEARER_TOKEN` and its marketplace authority endpoints per +authority, against `DOGE_MARKETPLACE__AUTHORITY_BEARER_TOKEN`. The +readiness probe passing proves nothing about the checkpoint request, because +readiness is unauthenticated. + +The dashboard, mining, and chart families for Dogecoin and Zcash read two +node RPCs and one durable history store, all configured in the same +`overlay.env`: + +- `UNIVERSE_DOGECOIN_RPC_URL` with `UNIVERSE_DOGECOIN_RPC_USER` and + `UNIVERSE_DOGECOIN_RPC_PASSWORD` (already present for the mempool + collector) also feed the block collector, `estimatesmartfee`, and + `getmininginfo`. +- `UNIVERSE_ZCASH_RPC_URL` points at the Zebra JSON-RPC listener, plain + HTTP on loopback (`http://127.0.0.1:8232/`). Zebra runs without RPC + auth; set `UNIVERSE_ZCASH_RPC_AUTHORIZATION` only if that changes. +- `UNIVERSE_EXPLORER_HISTORY_PATH` names the SQLite file holding collected + block history and mempool samples + (`/var/lib/universe-explorer/chain-history.sqlite`). It is separate from + `UNIVERSE_EXPLORER_STATE_PATH` so history writes never contend with the + mempool collector's commits. Unset, the history features report + themselves unavailable and every other surface keeps working. + ## Configuration `/etc/universe-explorer/`, owned by root, group `universe-explorer`, mode 0640: @@ -111,6 +151,7 @@ the JSON, in responses, or in logs. | `gateway.env` | gateway ports and the static root | | `mysql.env` | explorer database name, user, and passwords | | `fulcrum.conf` | the Electrum index this deployment reads | +| `doge-tap-ca.pem` | the certificate `index-doge-tap` presents, trusted by the overlay | ## Build diff --git a/docs/protocols/PROTOCOL-COVERAGE.json b/docs/protocols/PROTOCOL-COVERAGE.json index 52c0383c6d..80246c0f78 100644 --- a/docs/protocols/PROTOCOL-COVERAGE.json +++ b/docs/protocols/PROTOCOL-COVERAGE.json @@ -1,6 +1,17 @@ { + "schemaVersion": "universe-explorer-protocol-manifest-v1", "registryVersion": "1.0.0", - "generatedAt": "2026-08-26T06:40:08.985Z", + "sourceRepository": "bitcoinuniverseio/backend-apis", + "sourceSha": "100c0ec0ccd3fd28c3f7c89bec7cb6b5575c8d96", + "generatedAt": "2026-08-30T20:52:56.287Z", + "primaryStrip": [ + "ORDINALS", + "RUNES", + "ALKANES", + "STAMPS", + "ATOMICALS", + "MORE" + ], "protocols": [ { "schemaVersion": "universe-explorer-protocol-v1", @@ -389,9 +400,9 @@ "visualToken": "protocol-op-inscriptions", "implementedReadOperations": [], "authorizedReadOperations": [], - "releaseStatus": "BLOCKED", + "releaseStatus": "VERIFIED READ ONLY", "indexerAuthority": "index-opinscriptions", - "coverage": "unknown" + "coverage": "complete" }, { "schemaVersion": "universe-explorer-protocol-v1", @@ -730,6 +741,25 @@ "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" + }, { "schemaVersion": "universe-explorer-protocol-v1", "id": "zerdinals", @@ -747,9 +777,9 @@ "visualToken": "protocol-zerdinals", "implementedReadOperations": [], "authorizedReadOperations": [], - "releaseStatus": "BLOCKED", + "releaseStatus": "VERIFIED READ ONLY", "indexerAuthority": "index-zcash-metaprotocols", - "coverage": "unknown" + "coverage": "complete" }, { "schemaVersion": "universe-explorer-protocol-v1", @@ -766,9 +796,28 @@ "visualToken": "protocol-zrunes", "implementedReadOperations": [], "authorizedReadOperations": [], - "releaseStatus": "BLOCKED", + "releaseStatus": "VERIFIED READ ONLY", "indexerAuthority": "index-zcash-metaprotocols", - "coverage": "unknown" + "coverage": "complete" + }, + { + "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": "VERIFIED READ ONLY", + "indexerAuthority": "index-zcash-metaprotocols", + "coverage": "complete" } ] } diff --git a/docs/protocols/PROTOCOL-COVERAGE.md b/docs/protocols/PROTOCOL-COVERAGE.md index 8b832095a5..29954e39fd 100644 --- a/docs/protocols/PROTOCOL-COVERAGE.md +++ b/docs/protocols/PROTOCOL-COVERAGE.md @@ -1,20 +1,30 @@ # Protocol coverage -Generated from the explorer protocol registry in backend-apis -(src/universe-explorer/registry/explorer-protocol-registry.ts), as served by -`/api/v1/universe/protocols`. Do not edit rows by hand: regenerate with +The roster is owned by `bitcoinuniverseio/backend-apis`, in +`src/universe-explorer/registry/explorer-protocol-registry.ts`, and served by +`/api/v1/universe/protocols`. This file and `PROTOCOL-COVERAGE.json` are the +copy this repository pins. Do not edit rows by hand: record a new manifest with ``` -node scripts/universe/generate-protocol-coverage.mjs --from +node scripts/universe/protocol-contract.mjs --record --from ``` -and verify with `node scripts/universe/generate-protocol-coverage.mjs --check`. +`node scripts/universe/protocol-contract.mjs --check` holds this repository's +own surfaces to the pinned roster, and +`node scripts/universe/protocol-contract.mjs --against ` fails when a +deployment serves a roster that differs from it. Release status semantics: every protocol starts BLOCKED and is upgraded only when its explorer integration is completed and verified against its Universe authority. -A protocol never silently disappears from this table. +A protocol never silently disappears from this table: `PROTOCOL-ROSTER.lock` +records every id that has been published, and the gate fails when one of them +stops appearing. -Registry version 1.0.0. 3 of 36 protocols are readable today; the rest are recorded here but not yet served. +Pinned from bitcoinuniverseio/backend-apis at commit 100c0ec0ccd3fd28c3f7c89bec7cb6b5575c8d96, +manifest schema universe-explorer-protocol-manifest-v1, registry version 1.0.0, +recorded 2026-08-30T20:52:56.287Z. + +7 of 38 protocols are readable today; the rest are recorded here but not yet served. | id | family | chain | authority | release status | coverage | |---|---|---|---|---|---| @@ -35,7 +45,7 @@ Registry version 1.0.0. 3 of 36 protocols are readable today; the rest are recor | arc20 | ATOMICALS | bitcoin | index-atomicals | BLOCKED | unknown | | op_return | OP DATA | bitcoin | index-op20 | BLOCKED | unknown | | op_names | OP DATA | bitcoin | index-op20 | BLOCKED | unknown | -| op_inscriptions | OP DATA | bitcoin | index-opinscriptions | BLOCKED | unknown | +| op_inscriptions | OP DATA | bitcoin | index-opinscriptions | VERIFIED READ ONLY | complete | | op_drop | OP DATA | bitcoin | index-drops-and-opdrop | BLOCKED | unknown | | drops | OP DATA | bitcoin | index-drops-and-opdrop | BLOCKED | unknown | | brc20 | OTHER | bitcoin | index-brc20 | BLOCKED | unknown | @@ -52,5 +62,7 @@ Registry version 1.0.0. 3 of 36 protocols are readable today; the rest are recor | doginals | OTHER | dogecoin | ord-dogecoin | BLOCKED | unknown | | drc20 | OTHER | dogecoin | ord-dogecoin | BLOCKED | unknown | | tap_doge | OTHER | dogecoin | index-doge-tap | BLOCKED | unknown | -| zerdinals | OTHER | zcash | index-zcash-metaprotocols | BLOCKED | unknown | -| zrunes | OTHER | zcash | index-zcash-metaprotocols | BLOCKED | unknown | +| dunes | OTHER | dogecoin | ord-dogecoin | BLOCKED | unknown | +| zerdinals | OTHER | zcash | index-zcash-metaprotocols | VERIFIED READ ONLY | complete | +| zrunes | OTHER | zcash | index-zcash-metaprotocols | VERIFIED READ ONLY | complete | +| zrc20 | OTHER | zcash | index-zcash-metaprotocols | VERIFIED READ ONLY | complete | diff --git a/docs/protocols/PROTOCOL-ROSTER.lock b/docs/protocols/PROTOCOL-ROSTER.lock new file mode 100644 index 0000000000..2ed72b2de7 --- /dev/null +++ b/docs/protocols/PROTOCOL-ROSTER.lock @@ -0,0 +1,41 @@ +# Every protocol id this explorer has published. +# The gate fails when one of these stops appearing in the pinned manifest, so a +# removal has to be a deliberate edit here rather than a quiet absence. +alkanes +arc20 +atomicals_nft +bitmap +block20 +brc20 +cat20 +chainbloom +dmt +doginals +drc20 +drops +dunes +dust20 +mezcal +names +op_drop +op_inscriptions +op_names +op_return +ordex +ordinals +patina +rare_sats +realms +runes +src101 +src20 +stamps +subrealms +tandem +tap +tap_doge +unat +witness_circles +zerdinals +zrc20 +zrunes 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 8051b04635..8ca085e4ea 100644 --- a/frontend/src/app/components/master-page/master-page.component.html +++ b/frontend/src/app/components/master-page/master-page.component.html @@ -85,8 +85,8 @@ Dashboard - - + - -
-

How much history is readable now

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

Waiting for a block

- - - - - -
-

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 }} -
-
-
-
- -
- @@ -324,6 +218,7 @@

Why this tran

Transparent value

+

@@ -332,16 +227,19 @@

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

@@ -358,14 +256,21 @@

  1. - - {{ short(party.address, 12) }} - No standard address - Spent - - {{ party.amount.display }} - not reported +
    + + {{ short(party.address, 12) }} + No standard address + #{{ party.index }} + Spent + + {{ party.amount.display }} + not reported +
    +
@@ -393,13 +298,29 @@

Shielded st aria-labelledby="tx-actions-heading">

Protocol actions

+ + {{ action.protocolLabel }} + {{ action.actionType }} + + {{ action.assetName.length > 24 ? short(action.assetName, 8) : action.assetName }} + + {{ action.assetName.length > 24 ? short(action.assetName, 8) : action.assetName }} + + + + {{ action.quantity.display }} {{ action.assetTicker }} + + {{ short(op, 6) }} + {{ action.stateLabel }} + +

Evaluated

  • - {{ action.protocolLabel }} - {{ action.actionType }} - {{ action.stateLabel }} +
@@ -411,9 +332,7 @@

Candidates

  • - {{ action.protocolLabel }} - {{ action.actionType }} - {{ action.stateLabel }} +
@@ -520,6 +439,83 @@

Balance

+
+

Asset holdings

+ +

{{ hold.privacyNotice }}

+

{{ hold.completenessNote }}

+ + +

Held on unspent outputs

+
+ + + + + + + + + + + + + + + + + +
ProtocolAssetQuantityOutputs
{{ row.protocolLabel }} + {{ row.name.length > 24 ? short(row.name, 8) : row.name }} + {{ row.name.length > 24 ? short(row.name, 8) : row.name }} + {{ row.ticker }} + + {{ row.quantity.display }} + not summable + {{ row.utxoCount?.display }}
+
+
+ + +

Address-level balances

+

+ These protocols keep their ledger per address rather than per output, so these balances belong to the address as a whole and are never assigned to any single unspent output above. +

+
+ + + + + + + + + + + + + + + + + + + +
ProtocolAssetBalanceAvailableTransferable
{{ row.protocolLabel }} + {{ row.name.length > 24 ? short(row.name, 8) : row.name }} + {{ row.name.length > 24 ? short(row.name, 8) : row.name }} + {{ row.quantity?.display }}{{ row.available?.display }}{{ row.transferable?.display }}
+
+
+ +

+ Every unspent output was checked against the protocol authorities and none carries a protocol asset. +

+
+

Unspent outputs

@@ -529,6 +525,7 @@

Unspent o Outpoint Amount Block + Assets @@ -549,6 +546,9 @@

Unspent o i18n="universe.chain.utxo-height-unstated">not reported + + + @@ -1057,7 +1057,7 @@

{{ empty.label }}

-

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

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 6c6c7e9f37..35b45959e0 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss @@ -11,6 +11,11 @@ @use '../universe-tokens' as u; +@use '../chain-page' as page; +// The cube grammar shared with the dashboard timeline, so the bucket row +// and the timeline cannot drift apart visually. +@use '../chain-cubes'; + :host { @include u.universe-protocol-tokens; @include u.universe-state-tokens; @@ -18,10 +23,10 @@ display: block; } -.chain-page { - padding: 1.25rem 0 3rem; - color: var(--u-text-primary); -} +// The generic chain-page grammar (head, rail, panels, facts, tables) is the +// shared mixin, so this page and the dashboard, mining, charts, and docs +// pages stay one design. +@include page.universe-chain-page; // --------------------------------------------------------------------------- // Head @@ -32,32 +37,6 @@ // 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); @@ -86,99 +65,6 @@ // 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; - flex-direction: column; - gap: 0.3rem; - min-width: 0; -} - -.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; -} - -.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 // --------------------------------------------------------------------------- @@ -186,36 +72,8 @@ // 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); -} +// The fault panel and its raw-code line are in the shared chain-page +// partial, so every chain page states a fault the same way. // --------------------------------------------------------------------------- // Readiness band @@ -308,53 +166,6 @@ } } -.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); } @@ -615,42 +426,6 @@ // Figures // --------------------------------------------------------------------------- -.figure-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); - gap: var(--u-space-4); -} - -.figure { - display: flex; - flex-direction: column; - gap: 0.2rem; - min-width: 0; -} - -.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; -} - -.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; -} - -.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 { @@ -730,149 +505,10 @@ } } -// 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 { - width: 100%; - border-collapse: collapse; - font-size: var(--u-text-sm); -} - -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; -} - -tbody tr:last-child td { - border-bottom: 0; -} - // --------------------------------------------------------------------------- // Candidate buckets // --------------------------------------------------------------------------- -// The same cube grammar the Bitcoin projection uses: a face carrying the -// figures, a skewed top and side for depth, the projected-block palette. -// What differs is the claim, which the copy above the row states. -.cube-row { - display: flex; - gap: calc(var(--u-space-6) + 20px); - overflow-x: auto; - padding: calc(0.24 * var(--cube-size)) var(--u-space-2) var(--u-space-2) - calc(0.16 * var(--cube-size) + var(--u-space-2)); - --cube-size: 125px; - - &:focus-visible { - outline: 2px solid var(--u-focus-ring); - outline-offset: 2px; - } -} - -.candidate-cube { - flex: 0 0 auto; - width: var(--cube-size); -} - -.cube-block { - position: relative; - width: var(--cube-size); - height: var(--cube-size); - background: var(--u-block-projected-empty); -} - -.cube-block::after { - content: ''; - width: var(--cube-size); - height: calc(0.192 * var(--cube-size)); - position: absolute; - top: calc(-0.192 * var(--cube-size)); - left: calc(-0.16 * var(--cube-size)); - background-color: var(--u-block-projected-top); - transform: skew(40deg); - transform-origin: top; -} - -.cube-block::before { - content: ''; - width: calc(0.16 * var(--cube-size)); - height: var(--cube-size); - position: absolute; - top: calc(-0.096 * var(--cube-size)); - left: calc(-0.16 * var(--cube-size)); - background-color: var(--u-block-projected-side); - transform: skewY(50deg); - transform-origin: top; -} - -.cube-body { - position: relative; - height: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--u-space-1); - text-align: center; - padding: var(--u-space-2); - color: var(--u-block-ink); -} - -.cube-median { - font-size: var(--u-text-md); - font-weight: var(--u-weight-bold); - overflow-wrap: anywhere; -} - -.cube-unit { - font-size: var(--u-text-2xs); - font-weight: var(--u-weight-regular); -} - -.cube-span, -.cube-count, -.cube-size { - font-size: var(--u-text-2xs); - color: var(--u-block-ink-muted); - overflow-wrap: anywhere; -} - -.cube-caption { - margin-top: var(--u-space-2); - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--u-space-1); - font-size: var(--u-text-2xs); -} - .bucket-facts { margin-top: var(--u-space-3); } @@ -1188,3 +824,61 @@ tbody tr:last-child td { margin-top: var(--u-space-2); } } + +/* ------------------------------------------------------------------------ */ +/* Per-outpoint asset chips and the address holdings panel */ +/* ------------------------------------------------------------------------ */ + +/* A party row grew a second line for its assets, so the two-column grid + moved from the list item onto the first line. */ +.party-list li { + display: block; +} + +.party-main { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + align-items: baseline; + gap: var(--u-space-3); +} + +.party-assets { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--u-space-2); + margin-top: var(--u-space-1); +} + +.asset-chip { + display: inline-flex; + align-items: baseline; + gap: var(--u-space-1); + min-width: 0; +} + +.asset-name { + overflow-wrap: anywhere; +} + +.asset-quantity, +.action-quantity { + font-family: var(--u-font-data); + font-variant-numeric: tabular-nums; +} + +.action-asset { + overflow-wrap: anywhere; +} + +.assets-cell { + max-width: 28rem; +} + +.assets-cell .party-assets { + margin-top: 0; +} + +.holdings h3 { + margin-top: var(--u-space-4); +} 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 0fc63cc67a..2c64e10b0b 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -52,12 +52,15 @@ import { readNotReadyReasons, readOutpoint, readProtocolCoverage, + readAddressHoldings, readRecordFacts, readSourceDetails, readStatusRail, readTransaction, readTransactionList, shortenIdentifier, + AddressHoldingsReading, + OutpointAssetsReading, } from '@app/universe/multichain-explorer/multichain-view'; import { ChainReasonReading } from '@app/universe/multichain-explorer/chain-reasons'; import { @@ -98,7 +101,6 @@ import { } from 'rxjs'; type MultichainPage = - | 'dashboard' | 'mempool' | 'block' | 'transaction' @@ -126,6 +128,8 @@ interface ExplorerViewModel { readonly transaction: TransactionReading | null; readonly block: BlockReading | null; readonly address: AddressReading | null; + /** The address's protocol asset holdings, served beside the base view. */ + readonly holdings: AddressHoldingsReading | null; readonly outpoint: OutpointReading | null; readonly collection: CollectionReading | null; readonly transactionList: TransactionListReading | null; @@ -184,6 +188,8 @@ const PRESENTED_FIELDS: Partial> = { 'transparent', 'shielded', 'protocolActions', 'replacement', 'expiry', 'confirmationsAtomic', 'sizeBytesAtomic', 'virtualSizeBytesAtomic', 'firstSeenAt', 'completeness', + // The per-outpoint asset readings own this summary block. + 'assetFlow', ], block: [ 'chain', 'network', 'block', 'pagination', @@ -225,7 +231,7 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { readonly chainName: string; readonly ticker: string; readonly protocolIds: readonly string[]; - page: MultichainPage = 'dashboard'; + page: MultichainPage = 'mempool'; reference = ''; saved = false; switchedFrom: string | null = null; @@ -296,15 +302,29 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { catchError(() => of(null)) ) : of(null), + // The asset holdings beside the address view. Same rule: a + // protocol authority that cannot answer costs the asset + // sections, never the address page. + context.page === 'address' && this.reference + ? this.api + .getChainAddressHoldings$( + this.chain, + this.reference, + LIST_PAGE_SIZE, + (context.listPage - 1) * LIST_PAGE_SIZE + ) + .pipe(catchError(() => of(null))) + : of(null), ]) ), - map(([status, result, bucketsPayload]): ExplorerViewModel => + map(([status, result, bucketsPayload, holdingsPayload]): ExplorerViewModel => this.viewModel( status.capability, status.error, result.payload, result.error, - bucketsPayload + bucketsPayload, + holdingsPayload ) ) ); @@ -323,7 +343,8 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { capabilityError: string | null, payload: ChainExplorerPayload | null, payloadError: string | null, - bucketsPayload: ChainExplorerPayload | null = null + bucketsPayload: ChainExplorerPayload | null = null, + holdingsPayload: ChainExplorerPayload | null = null ): ExplorerViewModel { const shape = classifyPayload(payload); const transaction = payload && shape === 'transaction' ? readTransaction(payload, this.profile) : null; @@ -388,6 +409,9 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { transaction, block, address, + holdings: holdingsPayload + ? readAddressHoldings(holdingsPayload, this.profile) + : null, outpoint, collection, transactionList, @@ -436,8 +460,6 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { 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': @@ -466,8 +488,6 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { /** 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': @@ -490,6 +510,25 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { return this.route.snapshot.paramMap.get('protocol') ?? ''; } + /** + * The asset reading for one base-view unspent output, joined from the + * holdings view by outpoint. Null when the holdings page does not cover + * this output, which renders as no claim rather than as empty. + */ + holdingsAssetsFor( + holdings: AddressHoldingsReading | null, + txid: string, + vout: string + ): OutpointAssetsReading | null { + if (!holdings) { + return null; + } + return ( + holdings.utxos.find((utxo) => utxo.txid === txid && utxo.vout === vout) + ?.assets ?? null + ); + } + short(value: string, lead = 8): string { return shortenIdentifier(value, lead); } @@ -629,8 +668,6 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { const reference = context.params.get('reference') ?? ''; const protocol = context.params.get('protocol') ?? ''; switch (context.page) { - case 'dashboard': - return of(null); case 'mempool': return this.api.getChainMempool$(this.chain); case 'transaction': @@ -744,8 +781,6 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { 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': diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.module.ts b/frontend/src/app/universe/multichain-explorer/multichain-explorer.module.ts index 4c0fb0a89a..275d9dedd4 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.module.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.module.ts @@ -1,13 +1,34 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { MultichainExplorerComponent } from '@app/universe/multichain-explorer/multichain-explorer.component'; +import { ChainDashboardComponent } from '@app/universe/chain-dashboard/chain-dashboard.component'; +import { ChainMiningComponent } from '@app/universe/chain-dashboard/chain-mining.component'; const routes: Routes = [ { path: '', - component: MultichainExplorerComponent, + component: ChainDashboardComponent, data: { page: 'dashboard' }, }, + { + path: 'mining', + component: ChainMiningComponent, + data: { page: 'mining' }, + }, + { + path: 'graphs', + loadChildren: () => + import('@app/universe/chain-graphs/chain-graphs.module').then( + (m) => m.ChainGraphsModule + ), + }, + { + path: 'docs', + loadChildren: () => + import('@app/universe/chain-docs/chain-docs.module').then( + (m) => m.ChainDocsModule + ), + }, { path: 'mempool', component: MultichainExplorerComponent, @@ -61,6 +82,11 @@ const routes: Routes = [ ]; @NgModule({ - imports: [MultichainExplorerComponent, RouterModule.forChild(routes)], + imports: [ + MultichainExplorerComponent, + ChainDashboardComponent, + ChainMiningComponent, + RouterModule.forChild(routes), + ], }) export class MultichainExplorerModule {} diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts index 33530a4f62..e4162a4716 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts @@ -19,6 +19,7 @@ import { readEmptyList, readHistoryCoverage, readNotReadyReasons, + readAddressHoldings, readOutpoint, readPaging, readProtocolCoverage, @@ -932,3 +933,289 @@ describe('readProtocolCoverage reasons', () => { expect(drc20?.reasons[0].text).not.toContain('-disabled'); }); }); + +describe('per-outpoint asset readings', () => { + const duneTx = (): ChainExplorerPayload => ({ + schemaVersion: 'universe-transaction-v1', + txid: TXID, + status: 'confirmed', + transparent: { + inputs: [ + { + indexAtomic: '0', + previousOutpoint: `${'c'.repeat(64)}:1`, + address: 'D8input', + valueAtomic: '100000000', + coinbase: false, + assets: { positions: [], coverage: 'out-of-coverage', coveredProtocolIds: [] }, + }, + ], + outputs: [ + { + indexAtomic: '0', + address: 'D8output', + valueAtomic: '100000', + spent: false, + assets: { + positions: [ + { + outpoint: `${TXID}:0`, + vout: 0, + valueSatsAtomic: '100000', + asset: { + protocolId: 'dunes', + assetId: 'WOW SUCH DUNE', + displayName: 'WOW SUCH DUNE', + ticker: 'W', + assetKind: 'fungible', + decimals: 8, + }, + quantityAtomic: '150000000', + state: 'active', + }, + { + outpoint: `${TXID}:0`, + vout: 0, + valueSatsAtomic: '100000', + asset: { + protocolId: 'doginals', + assetId: `${'d'.repeat(64)}i0`, + assetKind: 'inscription', + }, + state: 'active', + }, + ], + coverage: 'complete', + coveredProtocolIds: ['doginals', 'dunes'], + }, + }, + { + indexAtomic: '1', + address: 'D8empty', + valueAtomic: '5000', + spent: false, + assets: { positions: [], coverage: 'proven-empty', coveredProtocolIds: ['doginals', 'dunes'] }, + }, + ], + }, + protocolActions: { + candidates: [], + confirmed: [ + { + eventId: 'e1', + protocolId: 'dunes', + state: 'confirmed-accepted', + actionType: 'etch', + evidenceIds: [], + asset: { + protocolId: 'dunes', + assetId: 'WOW SUCH DUNE', + displayName: 'WOW SUCH DUNE', + ticker: 'W', + assetKind: 'fungible', + decimals: 8, + }, + quantityAtomic: '150000000', + outputOutpoints: [`${TXID}:0`], + }, + ], + }, + }); + + it('reads asset chips on outputs with quantities shifted by the asset decimals', () => { + const reading = readTransaction(duneTx(), DOGE); + const output = reading?.outputs[0]; + expect(output?.outpoint).toBe(`${TXID}:0`); + const dune = output?.assets?.chips[0]; + expect(dune?.protocolLabel).toBe('Dunes'); + expect(dune?.name).toBe('WOW SUCH DUNE'); + // 150000000 shifted by the dune's own 8 decimals, not the chain's. + expect(dune?.quantity?.display).toBe('1.5'); + expect(dune?.quantity?.exact).toBe('150000000'); + expect(dune?.link).toEqual(['/', 'dogecoin', 'protocols', 'dunes', 'WOW SUCH DUNE']); + const doginal = output?.assets?.chips[1]; + expect(doginal?.protocolLabel).toBe('Doginals'); + // The asset id is the mandated fallback name, never a blank label. + expect(doginal?.name).toBe(`${'d'.repeat(64)}i0`); + expect(doginal?.quantity).toBeNull(); + }); + + it('keeps proven emptiness and out-of-coverage distinguishable', () => { + const reading = readTransaction(duneTx(), DOGE); + const empty = reading?.outputs[1].assets; + expect(empty?.provenEmpty).toBe(true); + expect(empty?.coverageLabel).toBeNull(); + const input = reading?.inputs[0].assets; + expect(input?.provenEmpty).toBe(false); + expect(input?.coverageLabel).not.toBeNull(); + expect(input?.coverageTone).toBe('neutral'); + }); + + it('carries asset identity, exact quantity and outpoints on actions', () => { + const reading = readTransaction(duneTx(), DOGE); + const action = reading?.confirmedActions[0]; + expect(action?.assetName).toBe('WOW SUCH DUNE'); + expect(action?.assetTicker).toBe('W'); + expect(action?.quantity?.display).toBe('1.5'); + expect(action?.quantity?.exact).toBe('150000000'); + expect(action?.outputOutpoints).toEqual([`${TXID}:0`]); + expect(action?.assetLink).toEqual(['/', 'dogecoin', 'protocols', 'dunes', 'WOW SUCH DUNE']); + }); + + it('reads actions without assets exactly as before', () => { + const payload = duneTx(); + (payload.protocolActions as Record).confirmed = [ + { eventId: 'e2', protocolId: 'drc20', state: 'confirmed-rejected', actionType: 'transfer', evidenceIds: [] }, + ]; + const action = readTransaction(payload, DOGE)?.confirmedActions[0]; + expect(action?.assetName).toBeNull(); + expect(action?.quantity).toBeNull(); + expect(action?.inputOutpoints).toEqual([]); + }); +}); + +describe('readAddressHoldings', () => { + const view = (overrides: Record = {}): ChainExplorerPayload => ({ + schemaVersion: 'universe-address-holdings-v1', + chain: 'zcash', + network: 'mainnet', + address: 't1Example', + utxos: [ + { + outpoint: `${TXID}:0`, + txid: TXID, + vout: 0, + valueAtomic: '250000000', + assets: { + positions: [ + { + outpoint: `${TXID}:0`, + vout: 0, + valueSatsAtomic: '250000000', + asset: { + protocolId: 'zrunes', + assetId: '10:1', + displayName: 'ZRUNE ONE', + ticker: 'Z', + assetKind: 'fungible', + decimals: 2, + }, + quantityAtomic: '12345', + state: 'active', + }, + ], + coverage: 'complete', + coveredProtocolIds: ['zerdinals', 'zrunes'], + }, + }, + ], + addressLevelBalances: [ + { + asset: { + protocolId: 'zrc20', + assetId: 'zord:zero', + ticker: 'ZERO', + assetKind: 'fungible', + decimals: 8, + }, + quantityAtomic: '900000000', + availableAtomic: '400000000', + transferableAtomic: '500000000', + decimals: 8, + semantics: 'zrc20-zord-ledger', + }, + ], + aggregateHoldings: [ + { + asset: { + protocolId: 'zrunes', + assetId: '10:1', + displayName: 'ZRUNE ONE', + ticker: 'Z', + assetKind: 'fungible', + decimals: 2, + }, + quantityAtomic: '12345', + utxoCountAtomic: '1', + source: 'utxo-bound', + }, + ], + paging: { + limitAtomic: '50', + offsetAtomic: '0', + returnedAtomic: '1', + totalUtxoCountAtomic: '1', + hasMore: false, + }, + checkpoint: { chain: 'zcash', network: 'mainnet', heightAtomic: '3131000', blockHash: BLOCK_HASH, reorgEpoch: '0', observedAt: '2026-08-30T00:00:00.000Z' }, + sourceEvidence: [], + complete: true, + unknownAttachmentCount: 0, + outOfCoverageCount: 0, + ...overrides, + }); + + it('reads the complete holdings view exactly', () => { + const reading = readAddressHoldings(view(), ZEC); + expect(reading?.address).toBe('t1Example'); + expect(reading?.utxos[0].outpoint).toBe(`${TXID}:0`); + expect(reading?.utxos[0].amount?.display).toBe('2.5'); + expect(reading?.utxos[0].assets?.chips[0].quantity?.display).toBe('123.45'); + expect(reading?.aggregates[0].quantity?.exact).toBe('12345'); + expect(reading?.aggregates[0].utxoCount?.display).toBe('1'); + expect(reading?.addressLevel[0].quantity?.display).toBe('9'); + expect(reading?.addressLevel[0].available?.display).toBe('4'); + expect(reading?.addressLevel[0].transferable?.display).toBe('5'); + expect(reading?.complete).toBe(true); + expect(reading?.completenessNote).toBeNull(); + expect(reading?.privacyNotice).toBeNull(); + expect(reading?.checkpointHeight?.exact).toBe('3131000'); + }); + + it('never renders a partial reading as complete', () => { + const reading = readAddressHoldings( + view({ complete: false, unknownAttachmentCount: 2 }), + ZEC + ); + expect(reading?.complete).toBe(false); + expect(reading?.completenessNote).not.toBeNull(); + }); + + it('passes the privacy boundary through verbatim instead of a fabricated empty account', () => { + const reading = readAddressHoldings( + view({ + utxos: [], + aggregateHoldings: [], + addressLevelBalances: [], + privacy: { publiclyObservable: false, notice: 'Shielded activity is not publicly observable.' }, + }), + ZEC + ); + expect(reading?.privacyNotice).toBe('Shielded activity is not publicly observable.'); + expect(reading?.utxos).toEqual([]); + expect(reading?.completenessNote).toBeNull(); + }); + + it('refuses payloads that are not the holdings contract', () => { + expect(readAddressHoldings({ schemaVersion: 'other', address: 'x' }, ZEC)).toBeNull(); + expect(readAddressHoldings(null, ZEC)).toBeNull(); + }); + + it('never publishes a partial aggregate sum', () => { + const reading = readAddressHoldings( + view({ + aggregateHoldings: [ + { + asset: { protocolId: 'zrunes', assetId: '10:1', assetKind: 'fungible' }, + quantityAtomic: null, + utxoCountAtomic: '2', + source: 'utxo-bound', + }, + ], + }), + ZEC + ); + expect(reading?.aggregates[0].quantity).toBeNull(); + expect(reading?.aggregates[0].utxoCount?.exact).toBe('2'); + }); +}); diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.ts index e740137edf..44e4aa5e6c 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.ts @@ -1105,6 +1105,118 @@ export function classifyPayload(payload: ChainExplorerPayload | null): ChainShap return 'record'; } +/** + * One protocol asset attached to an outpoint, ready to render: protocol + * label from the chain profile, the asset's own name with the id as the + * mandated fallback, and the exact quantity shifted by the asset's own + * decimals rather than the chain's precision. + */ +export interface AssetChipRow { + readonly protocolId: string; + readonly protocolLabel: string; + readonly name: string; + readonly assetId: string; + readonly ticker: string | null; + readonly quantity: ExactNumber | null; + readonly kind: string; + /** Route to the asset's detail page, when this explorer serves one. */ + readonly link: readonly string[] | null; +} + +/** + * The asset reading for one outpoint. An empty chip list means nothing by + * itself: `coverageLabel` says whether emptiness was proven, unscanned, + * outside the authority's coverage, or simply unavailable, and only a + * proven empty renders as the quiet compact row. + */ +export interface OutpointAssetsReading { + readonly chips: readonly AssetChipRow[]; + readonly coverageLabel: string | null; + readonly coverageTone: EvidenceTone; + readonly provenEmpty: boolean; +} + +const ASSET_COVERAGE_LABEL: Record = { + partial: $localize`:@@universe.chain.assets-partial:Partially checked`, + unscanned: $localize`:@@universe.chain.assets-unscanned:Not scanned yet`, + 'out-of-coverage': $localize`:@@universe.chain.assets-out-of-coverage:Outside the authority's coverage`, + unavailable: $localize`:@@universe.chain.assets-unavailable:Asset authority unavailable`, +}; + +const ASSET_COVERAGE_TONE: Record = { + complete: 'proven', + 'proven-empty': 'proven', + partial: 'partial', + unscanned: 'neutral', + 'out-of-coverage': 'neutral', + unavailable: 'unavailable', +}; + +function protocolTabFor( + profile: ChainProfile, + protocolId: string +): ChainProtocolTab | null { + return ( + profile.protocols.find( + (tab) => tab.id === protocolId || tab.registryIds.includes(protocolId) + ) ?? null + ); +} + +function assetChip(entry: Record, profile: ChainProfile): AssetChipRow | null { + const asset = isRecord(entry.asset) ? entry.asset : null; + if (!asset) { + return null; + } + const protocolId = text(asset.protocolId) ?? ''; + const assetId = text(asset.assetId) ?? ''; + if (!protocolId || !assetId) { + return null; + } + const tab = protocolTabFor(profile, protocolId); + const decimals = + typeof asset.decimals === 'number' && + Number.isSafeInteger(asset.decimals) && + asset.decimals >= 0 && + asset.decimals <= 77 + ? asset.decimals + : 0; + return { + protocolId, + protocolLabel: tab?.label ?? humanizeFieldName(protocolId), + // A missing display name falls back to the asset id, never to a blank. + name: text(asset.displayName) ?? assetId, + assetId, + ticker: text(asset.ticker), + quantity: formatAtomicAmount(text(entry.quantityAtomic), decimals), + kind: text(asset.assetKind) ?? 'unknown', + link: tab + ? ['/', profile.chain, 'protocols', tab.id, assetId] + : null, + }; +} + +/** Reads the additive per-outpoint `assets` field a transaction view carries. */ +export function readOutpointAssets( + value: unknown, + profile: ChainProfile +): OutpointAssetsReading | null { + if (!isRecord(value) || !Array.isArray(value.positions)) { + return null; + } + const chips = value.positions + .filter(isRecord) + .map((entry) => assetChip(entry, profile)) + .filter((chip): chip is AssetChipRow => chip !== null); + const coverage = text(value.coverage) ?? ''; + return { + chips, + coverageLabel: ASSET_COVERAGE_LABEL[coverage] ?? null, + coverageTone: ASSET_COVERAGE_TONE[coverage] ?? 'neutral', + provenEmpty: coverage === 'proven-empty' && chips.length === 0, + }; +} + export interface TransactionPartyRow { readonly index: string; readonly address: string | null; @@ -1112,6 +1224,9 @@ export interface TransactionPartyRow { readonly reference: string | null; readonly coinbase: boolean; readonly spent: boolean | null; + /** The outpoint this row is, when it can be stated. */ + readonly outpoint: string | null; + readonly assets: OutpointAssetsReading | null; } export interface ShieldedReading { @@ -1127,6 +1242,15 @@ export interface ProtocolActionRow { readonly actionType: string; readonly stateLabel: string; readonly tone: EvidenceTone; + /** The asset the action concerns, when the authority named one. */ + readonly assetName: string | null; + readonly assetTicker: string | null; + readonly assetLink: readonly string[] | null; + /** Exact quantity in the asset's atomic unit, shifted by its decimals. */ + readonly quantity: ExactNumber | null; + /** Outpoints the action consumed and produced, for linking. */ + readonly inputOutpoints: readonly string[]; + readonly outputOutpoints: readonly string[]; } export interface TransactionReading { @@ -1225,19 +1349,41 @@ function sumAtomic( function partyRows( values: unknown, profile: ChainProfile, - kind: 'input' | 'output' + kind: 'input' | 'output', + txid: string ): 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, - })); + return values.filter(isRecord).map((entry) => { + const index = text(entry.indexAtomic) ?? ''; + const reference = kind === 'input' ? text(entry.previousOutpoint) : null; + return { + index, + address: text(entry.address), + amount: formatAtomicAmount(text(entry.valueAtomic), profile.precision), + reference, + coinbase: entry.coinbase === true, + spent: typeof entry.spent === 'boolean' ? entry.spent : null, + outpoint: + kind === 'output' + ? txid && index !== '' + ? `${txid}:${index}` + : null + : reference, + assets: readOutpointAssets(entry.assets, profile), + }; + }); +} + +function outpointList(value: unknown): readonly string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter( + (entry): entry is string => + typeof entry === 'string' && /^[0-9a-f]{64}:\d{1,10}$/.test(entry) + ); } function actionRows( @@ -1250,15 +1396,33 @@ function actionRows( return values.filter(isRecord).map((entry) => { const state = text(entry.state) ?? ''; const protocolId = text(entry.protocolId) ?? ''; + const asset = isRecord(entry.asset) ? entry.asset : null; + const assetId = asset ? text(asset.assetId) : null; + const tab = protocolTabFor(profile, protocolId); + const decimals = + asset && + typeof asset.decimals === 'number' && + Number.isSafeInteger(asset.decimals) && + asset.decimals >= 0 && + asset.decimals <= 77 + ? asset.decimals + : 0; return { eventId: text(entry.eventId) ?? '', protocolId, - protocolLabel: - profile.protocols.find((tab) => tab.id === protocolId)?.label ?? - humanizeFieldName(protocolId), + protocolLabel: tab?.label ?? humanizeFieldName(protocolId), actionType: humanizeFieldName(text(entry.actionType) ?? ''), stateLabel: PROTOCOL_STATE_LABEL[state] ?? availabilityLabel(state), tone: PROTOCOL_STATE_TONE[state] ?? 'neutral', + assetName: asset ? (text(asset.displayName) ?? assetId) : null, + assetTicker: asset ? text(asset.ticker) : null, + assetLink: + tab && assetId + ? ['/', profile.chain, 'protocols', tab.id, assetId] + : null, + quantity: formatAtomicAmount(text(entry.quantityAtomic), decimals), + inputOutpoints: outpointList(entry.inputOutpoints), + outputOutpoints: outpointList(entry.outputOutpoints), }; }); } @@ -1291,9 +1455,10 @@ export function readTransaction( return null; } const status = text(payload.status) ?? ''; + const txid = text(payload.txid) ?? ''; const transparent = isRecord(payload.transparent) ? payload.transparent : {}; - const inputs = partyRows(transparent.inputs, profile, 'input'); - const outputs = partyRows(transparent.outputs, profile, 'output'); + const inputs = partyRows(transparent.inputs, profile, 'input', txid); + const outputs = partyRows(transparent.outputs, profile, 'output', txid); const fee = isRecord(payload.fee) ? payload.fee : {}; const block = isRecord(payload.block) ? payload.block : null; const replacement = isRecord(payload.replacement) ? payload.replacement : null; @@ -1642,6 +1807,160 @@ export function readAddress( }; } +/** One unspent output from the holdings view, with its asset reading. */ +export interface EnrichedUtxoRow { + readonly txid: string; + readonly vout: string; + readonly outpoint: string; + readonly amount: ExactNumber | null; + readonly height: ExactNumber | null; + readonly assets: OutpointAssetsReading | null; +} + +/** One asset holding row: an aggregate or an address-level balance. */ +export interface HoldingRow { + readonly protocolId: string; + readonly protocolLabel: string; + readonly name: string; + readonly assetId: string; + readonly ticker: string | null; + readonly quantity: ExactNumber | null; + readonly utxoCount: ExactNumber | null; + readonly available: ExactNumber | null; + readonly transferable: ExactNumber | null; + readonly link: readonly string[] | null; +} + +/** + * The address asset-holdings view: the paginated unspent outputs with + * their attached assets, aggregates by asset, and the balances the + * protocol defines at address level, kept apart so the same quantity is + * never counted in both sections. + */ +export interface AddressHoldingsReading { + readonly address: string; + readonly utxos: readonly EnrichedUtxoRow[]; + readonly aggregates: readonly HoldingRow[]; + readonly addressLevel: readonly HoldingRow[]; + readonly hasMore: boolean; + readonly totalUtxos: ExactNumber | null; + readonly complete: boolean; + /** Set when the reading is incomplete; says what kind of hole exists. */ + readonly completenessNote: string | null; + readonly privacyNotice: string | null; + readonly checkpointHeight: ExactNumber | null; +} + +function holdingRow( + entry: Record, + profile: ChainProfile +): HoldingRow | null { + const asset = isRecord(entry.asset) ? entry.asset : null; + if (!asset) { + return null; + } + const protocolId = text(asset.protocolId) ?? ''; + const assetId = text(asset.assetId) ?? ''; + if (!protocolId || !assetId) { + return null; + } + const tab = protocolTabFor(profile, protocolId); + const decimals = + typeof asset.decimals === 'number' && + Number.isSafeInteger(asset.decimals) && + asset.decimals >= 0 && + asset.decimals <= 77 + ? asset.decimals + : 0; + return { + protocolId, + protocolLabel: tab?.label ?? humanizeFieldName(protocolId), + name: text(asset.displayName) ?? assetId, + assetId, + ticker: text(asset.ticker), + quantity: formatAtomicAmount(text(entry.quantityAtomic), decimals), + utxoCount: formatExactInteger(text(entry.utxoCountAtomic)), + available: formatAtomicAmount(text(entry.availableAtomic), decimals), + transferable: formatAtomicAmount(text(entry.transferableAtomic), decimals), + link: tab ? ['/', profile.chain, 'protocols', tab.id, assetId] : null, + }; +} + +/** Reads the address-holdings endpoint's `universe-address-holdings-v1` view. */ +export function readAddressHoldings( + payload: unknown, + profile: ChainProfile +): AddressHoldingsReading | null { + if ( + !isRecord(payload) || + payload.schemaVersion !== 'universe-address-holdings-v1' + ) { + return null; + } + const address = text(payload.address); + if (!address) { + return null; + } + const utxos = Array.isArray(payload.utxos) + ? payload.utxos.filter(isRecord) + : []; + const paging = isRecord(payload.paging) ? payload.paging : {}; + const privacy = isRecord(payload.privacy) ? payload.privacy : null; + const checkpoint = isRecord(payload.checkpoint) ? payload.checkpoint : null; + const rows = (value: unknown): readonly HoldingRow[] => + Array.isArray(value) + ? value + .filter(isRecord) + .map((entry) => holdingRow(entry, profile)) + .filter((row): row is HoldingRow => row !== null) + : []; + const unknownCount = Number(text(payload.unknownAttachmentCount) ?? payload.unknownAttachmentCount ?? 0); + const outOfCoverageCount = Number(text(payload.outOfCoverageCount) ?? payload.outOfCoverageCount ?? 0); + const complete = payload.complete === true; + let completenessNote: string | null = null; + if (!complete && privacy?.publiclyObservable !== false) { + if (Number.isSafeInteger(unknownCount) && unknownCount > 0) { + completenessNote = $localize`:@@universe.chain.holdings-unknown:Some outputs could not be checked against every protocol authority.`; + } else if (Number.isSafeInteger(outOfCoverageCount) && outOfCoverageCount > 0) { + completenessNote = $localize`:@@universe.chain.holdings-out-of-coverage:Some outputs are outside the protocol authorities' coverage.`; + } else { + completenessNote = $localize`:@@universe.chain.holdings-partial:This reading is not complete.`; + } + } + return { + address, + utxos: utxos.map((entry) => { + const txid = text(entry.txid) ?? ''; + const vout = + typeof entry.vout === 'number' && Number.isSafeInteger(entry.vout) + ? String(entry.vout) + : (text(entry.vout) ?? ''); + return { + txid, + vout, + outpoint: text(entry.outpoint) ?? `${txid}:${vout}`, + amount: formatAtomicAmount(text(entry.valueAtomic), profile.precision), + height: formatExactInteger(text(entry.heightAtomic)), + assets: readOutpointAssets(entry.assets, profile), + }; + }), + aggregates: rows(payload.aggregateHoldings), + addressLevel: rows(payload.addressLevelBalances), + hasMore: paging.hasMore === true, + totalUtxos: formatExactInteger(text(paging.totalUtxoCountAtomic)), + complete, + completenessNote, + privacyNotice: + privacy && privacy.publiclyObservable === false + ? (text(privacy.notice) ?? + $localize`:@@universe.chain.holdings-privacy:This address's holdings are not publicly observable.`) + : null, + checkpointHeight: checkpoint + ? formatExactInteger(text(checkpoint.heightAtomic)) + : null, + }; +} + export interface OutpointReading { readonly txid: string; readonly vout: string; diff --git a/frontend/src/app/universe/universe-api.service.spec.ts b/frontend/src/app/universe/universe-api.service.spec.ts index f76cd6c7e6..8e3925f72d 100644 --- a/frontend/src/app/universe/universe-api.service.spec.ts +++ b/frontend/src/app/universe/universe-api.service.spec.ts @@ -90,6 +90,31 @@ describe('UniverseApiService addressing', () => { }); }); +describe('UniverseApiService pending-set bounds', () => { + // Zcash refuses a limit above 200 as a bad request rather than trimming it, + // so a shared default emptied its lens and its arrivals list in production + // while every fixture answered whatever it was asked. + it('never asks a chain for more pending transactions than it allows', () => { + const { service, urls } = build(true); + service.getChainMempool$('zcash', 400).subscribe(); + service.getChainMempool$('dogecoin', 400).subscribe(); + expect(urls[0]).toContain('limit=200'); + expect(urls[1]).toContain('limit=400'); + }); + + it('keeps a request for fewer than the ceiling exactly as asked', () => { + const { service, urls } = build(true); + service.getChainMempool$('zcash', 50).subscribe(); + expect(urls[0]).toContain('limit=50'); + }); + + it('refuses a limit below one', () => { + const { service, urls } = build(true); + service.getChainMempool$('dogecoin', 0).subscribe(); + expect(urls[0]).toContain('limit=1'); + }); +}); + describe('UniverseApiService protocol registry cache', () => { it('fetches the registry once and replays it', () => { const get = vi.fn(() => of({ registryVersion: '1.0.0' })); diff --git a/frontend/src/app/universe/universe-api.service.ts b/frontend/src/app/universe/universe-api.service.ts index 5f1c16540b..6a610bf51d 100644 --- a/frontend/src/app/universe/universe-api.service.ts +++ b/frontend/src/app/universe/universe-api.service.ts @@ -17,8 +17,14 @@ import { OrdRuneView, OrdSatView, ChainCapabilityEnvelope, + ChainDashboardView, ChainExplorerPayload, + ChartSeriesView, ExplorerChain, + FeeRecommendationsView, + MiningPoolsView, + MiningSummaryView, + RecentBlocksView, UniverseSearchResponse, } from '@app/universe/universe.types'; @@ -26,6 +32,20 @@ import { export const UNIVERSE_OUTPOINT_BATCH_LIMIT = 50; export const UNIVERSE_TRANSACTION_BATCH_LIMIT = 25; +/** + * How many pending transactions each chain will return in one request. + * + * The two chains do not share a ceiling, and asking for more than a chain + * allows is refused as a bad request rather than trimmed. Asking Zcash for + * four hundred emptied its lens and its arrivals list in production while + * every fixture answered whatever it was asked, so the bound is enforced + * here, once, where the request is built. + */ +export const CHAIN_MEMPOOL_LIMIT: Record, number> = { + dogecoin: 1000, + zcash: 200, +}; + @Injectable({ providedIn: 'root' }) @@ -160,7 +180,8 @@ export class UniverseApiService { getChainMempool$(chain: Exclude, limit = 100): Observable { return this.httpClient.get( - this.apiBaseUrl + '/api/v1/' + chain + '/mempool?network=mainnet&limit=' + limit + this.apiBaseUrl + '/api/v1/' + chain + '/mempool?network=mainnet&limit=' + + Math.min(Math.max(1, Math.floor(limit)), CHAIN_MEMPOOL_LIMIT[chain]) ); } @@ -170,6 +191,44 @@ export class UniverseApiService { ); } + /** The one-call dashboard aggregate: blocks, buckets, fees, mempool, mining. */ + getChainDashboard$(chain: Exclude): Observable { + return this.httpClient.get( + this.apiBaseUrl + '/api/v1/' + chain + '/dashboard?network=mainnet' + ); + } + + getChainRecentBlocks$(chain: Exclude, limit = 15): Observable { + return this.httpClient.get( + this.apiBaseUrl + '/api/v1/' + chain + '/blocks/recent?network=mainnet&limit=' + limit + ); + } + + getChainFees$(chain: Exclude): Observable { + return this.httpClient.get( + this.apiBaseUrl + '/api/v1/' + chain + '/fees?network=mainnet' + ); + } + + getChainMining$(chain: Exclude): Observable { + return this.httpClient.get( + this.apiBaseUrl + '/api/v1/' + chain + '/mining?network=mainnet' + ); + } + + getChainMiningPools$(chain: Exclude, window = '1w'): Observable { + return this.httpClient.get( + this.apiBaseUrl + '/api/v1/' + chain + '/mining/pools?network=mainnet&window=' + encodeURIComponent(window) + ); + } + + getChainChartSeries$(chain: Exclude, seriesId: string, range = '1w'): Observable { + return this.httpClient.get( + this.apiBaseUrl + '/api/v1/' + chain + '/charts/' + encodeURIComponent(seriesId) + + '?network=mainnet&range=' + encodeURIComponent(range) + ); + } + getChainTransaction$(chain: Exclude, txid: string): Observable { return this.httpClient.get( this.apiBaseUrl + '/api/v1/' + chain + '/tx/' + encodeURIComponent(txid) + '?network=mainnet' @@ -194,6 +253,25 @@ export class UniverseApiService { ); } + /** + * The address asset-holdings view for a chain: every paginated unspent + * output with its attached protocol assets, address-level balances, and + * exact aggregates. Served beside the base address view so a failure here + * degrades the asset sections without taking the address page down. + */ + getChainAddressHoldings$(chain: Exclude, address: string, limit = 50, offset = 0): Observable { + return this.httpClient.get( + this.apiBaseUrl + '/api/v1/' + chain + '/address/' + encodeURIComponent(address) + '/holdings?network=mainnet&limit=' + limit + '&offset=' + offset + ); + } + + /** The Bitcoin address asset-holdings view from the universe overlay. */ + getAddressHoldings$(address: string, limit = 100, offset = 0): Observable { + return this.httpClient.get( + this.apiBaseUrl + '/api/v1/universe/addresses/' + encodeURIComponent(address) + '/holdings?limit=' + limit + '&offset=' + offset + ); + } + getChainOutpoint$(chain: Exclude, txid: string, vout: string): Observable { return this.httpClient.get( this.apiBaseUrl + '/api/v1/' + chain + '/outpoint/' + encodeURIComponent(txid) + '/' + encodeURIComponent(vout) + '?network=mainnet' diff --git a/frontend/src/app/universe/universe-chain-routing.spec.ts b/frontend/src/app/universe/universe-chain-routing.spec.ts index dcff36fe66..8f4903f167 100644 --- a/frontend/src/app/universe/universe-chain-routing.spec.ts +++ b/frontend/src/app/universe/universe-chain-routing.spec.ts @@ -1,4 +1,5 @@ import { + ExplorerRouteCategory, explorerChainFromUrl, explorerRouteCategory, explorerSectionRoute, @@ -33,4 +34,75 @@ describe('Universe chain routing', () => { droppedObject: true, }); }); + + // The route category type is the registry every chain's navigation is built + // from. Losing a section here silently removes it from every chain's menu, + // so the full set is pinned. + it('keeps every product section in the category registry', () => { + const sections: ExplorerRouteCategory[] = [ + 'dashboard', 'mining', 'mempool', 'protocols', 'graphs', 'docs', + ]; + for (const section of sections) { + expect(explorerSectionRoute('bitcoin', section as Exclude)).toBeTruthy(); + expect(explorerSectionRoute('dogecoin', section as Exclude)).toContain('/dogecoin'); + expect(explorerSectionRoute('zcash', section as Exclude)).toContain('/zcash'); + } + }); + + it('classifies mining, graphs, and docs URLs as sections, not objects', () => { + expect(explorerRouteCategory('/mining')).toBe('mining'); + expect(explorerRouteCategory('/dogecoin/mining')).toBe('mining'); + expect(explorerRouteCategory('/zcash/graphs')).toBe('graphs'); + expect(explorerRouteCategory('/graphs/mining/pools')).toBe('graphs'); + expect(explorerRouteCategory('/dogecoin/docs')).toBe('docs'); + expect(explorerRouteCategory('/docs/api')).toBe('docs'); + // A pool page names one pool, which another chain does not have. + expect(explorerRouteCategory('/mining/pool/foundry')).toBe('object'); + }); + + it('preserves the section when switching chains', () => { + expect(explorerSwitchTarget('/mining', 'dogecoin')).toEqual({ + path: '/dogecoin/mining', + droppedObject: false, + }); + expect(explorerSwitchTarget('/dogecoin/mining', 'zcash')).toEqual({ + path: '/zcash/mining', + droppedObject: false, + }); + expect(explorerSwitchTarget('/zcash/docs', 'bitcoin')).toEqual({ + path: '/docs', + droppedObject: false, + }); + }); + + it('preserves a chart child route across chains when an equivalent exists', () => { + expect(explorerSwitchTarget('/graphs/mining/pools', 'dogecoin')).toEqual({ + path: '/dogecoin/graphs/mining/pools', + droppedObject: false, + }); + expect( + explorerSwitchTarget('/dogecoin/graphs/mining/hashrate-difficulty', 'zcash') + ).toEqual({ + path: '/zcash/graphs/mining/hashrate-difficulty', + droppedObject: false, + }); + // Sizes and weights exist together only on Bitcoin; the others chart sizes. + expect( + explorerSwitchTarget('/graphs/mining/block-sizes-weights', 'zcash') + ).toEqual({ + path: '/zcash/graphs/mining/block-sizes', + droppedObject: false, + }); + expect( + explorerSwitchTarget('/dogecoin/graphs/mining/block-sizes', 'bitcoin') + ).toEqual({ + path: '/graphs/mining/block-sizes-weights', + droppedObject: false, + }); + // The fiat price chart has no self-hosted equivalent on the other chains. + expect(explorerSwitchTarget('/graphs/price', 'dogecoin')).toEqual({ + path: '/dogecoin/graphs', + droppedObject: false, + }); + }); }); diff --git a/frontend/src/app/universe/universe-chain-routing.ts b/frontend/src/app/universe/universe-chain-routing.ts index 4d5ae2d485..df13dfec41 100644 --- a/frontend/src/app/universe/universe-chain-routing.ts +++ b/frontend/src/app/universe/universe-chain-routing.ts @@ -1,7 +1,8 @@ import { ExplorerChain } from '@app/universe/universe.types'; +import { equivalentGraphChild } from '@app/universe/universe-chart-registry'; export type ExplorerRouteCategory = - 'dashboard' | 'mempool' | 'protocols' | 'object'; + 'dashboard' | 'mining' | 'mempool' | 'protocols' | 'graphs' | 'docs' | 'object'; /** * How each chain is written when a person reads it. The wire uses lowercase @@ -40,6 +41,19 @@ export function explorerRouteCategory(url: string): ExplorerRouteCategory { if (segments.length === 1 && segments[0] === 'protocols') { return 'protocols'; } + // The mining dashboard is a section; a pool page under it addresses one + // pool, which does not exist on another chain, so it stays an object. + if (segments.length === 1 && segments[0] === 'mining') { + return 'mining'; + } + // Every chart child stays inside the graphs section: the switcher decides + // per chart whether the destination chain has an equivalent. + if (segments[0] === 'graphs') { + return 'graphs'; + } + if (segments[0] === 'docs') { + return 'docs'; + } return 'object'; } @@ -51,26 +65,46 @@ export function explorerSectionRoute( if (category === 'mempool') { return '/pulse'; } - if (category === 'protocols') { - return '/protocols'; - } - return '/'; + return category === 'dashboard' ? '/' : `/${category}`; } return category === 'dashboard' ? `/${chain}` : `/${chain}/${category}`; } +/** The chart child path after `graphs/`, or null when the URL is not one. */ +function graphChildFrom(url: string): string | null { + const segments = url.split(/[?#]/, 1)[0].split('/').filter(Boolean); + if (segments[0] === 'dogecoin' || segments[0] === 'zcash') { + segments.shift(); + } + if (segments[0] !== 'graphs' || segments.length < 2) { + return null; + } + return segments.slice(1).join('/'); +} + export function explorerSwitchTarget( currentUrl: string, targetChain: ExplorerChain ): { readonly path: string; readonly droppedObject: boolean } { const category = explorerRouteCategory(currentUrl); - return category === 'object' - ? { - path: explorerSectionRoute(targetChain, 'dashboard'), - droppedObject: true, - } - : { - path: explorerSectionRoute(targetChain, category), - droppedObject: false, - }; + if (category === 'object') { + return { + path: explorerSectionRoute(targetChain, 'dashboard'), + droppedObject: true, + }; + } + if (category === 'graphs') { + const child = graphChildFrom(currentUrl); + const equivalent = child ? equivalentGraphChild(child, targetChain) : null; + return { + path: equivalent + ? `${explorerSectionRoute(targetChain, 'graphs')}/${equivalent}` + : explorerSectionRoute(targetChain, 'graphs'), + droppedObject: false, + }; + } + return { + path: explorerSectionRoute(targetChain, category), + droppedObject: false, + }; } diff --git a/frontend/src/app/universe/universe-chart-registry.ts b/frontend/src/app/universe/universe-chart-registry.ts new file mode 100644 index 0000000000..a3089639b0 --- /dev/null +++ b/frontend/src/app/universe/universe-chart-registry.ts @@ -0,0 +1,94 @@ +import { ExplorerChain } from '@app/universe/universe.types'; + +/** + * The chart child routes each chain offers under its graphs section. + * + * One table, used by the chain switcher to decide whether a chart survives a + * chain switch and by the chain graphs page to build its navigation. A chart + * missing from a chain's list is not hidden by accident: it either has a + * different spelling there (see {@link GRAPH_ROUTE_EQUIVALENTS}) or no honest + * equivalent exists on that chain. + */ +export const CHAIN_GRAPH_CHILDREN: Record = { + bitcoin: [ + 'mempool', + 'mining/hashrate-difficulty', + 'mining/pools-dominance', + 'mining/pools', + 'mining/block-fees', + 'mining/block-fees-subsidy', + 'mining/block-rewards', + 'mining/block-fee-rates', + 'mining/block-sizes-weights', + 'mining/block-health', + 'price', + ], + dogecoin: [ + 'mempool', + 'mining/hashrate-difficulty', + 'mining/pools-dominance', + 'mining/pools', + 'mining/block-fees', + 'mining/block-fees-subsidy', + 'mining/block-rewards', + 'mining/block-fee-rates', + 'mining/block-sizes', + 'mining/block-interval', + ], + zcash: [ + 'mempool', + 'mining/hashrate-difficulty', + 'mining/pools-dominance', + 'mining/pools', + 'mining/block-fees', + 'mining/block-fees-subsidy', + 'mining/block-rewards', + 'mining/block-fee-rates', + 'mining/block-sizes', + 'mining/block-interval', + ], +}; + +/** + * Where a chart keeps its meaning across chains under a different spelling. + * + * Bitcoin plots sizes and weights together because segwit gave it two + * capacity measures; the other chains have one, so their chart is sizes + * alone. Block health grades Bitcoin blocks against this explorer's own + * projection, which the other chains honestly cannot have yet, so their + * nearest reliability reading is the observed against target interval. + */ +const GRAPH_ROUTE_EQUIVALENTS: Record> = { + 'mining/block-sizes-weights': { + dogecoin: 'mining/block-sizes', + zcash: 'mining/block-sizes', + }, + 'mining/block-sizes': { + bitcoin: 'mining/block-sizes-weights', + }, + 'mining/block-health': { + dogecoin: 'mining/block-interval', + zcash: 'mining/block-interval', + }, + 'mining/block-interval': { + bitcoin: 'mining/block-health', + }, +}; + +/** + * The chart child a switch to `targetChain` should land on, or null when the + * target chain has no equivalent and the switch should land on its graphs + * landing page instead. + */ +export function equivalentGraphChild( + child: string, + targetChain: ExplorerChain +): string | null { + if (CHAIN_GRAPH_CHILDREN[targetChain].includes(child)) { + return child; + } + const mapped = GRAPH_ROUTE_EQUIVALENTS[child]?.[targetChain]; + return mapped && CHAIN_GRAPH_CHILDREN[targetChain].includes(mapped) + ? mapped + : null; +} diff --git a/frontend/src/app/universe/universe-protocol-copy.ts b/frontend/src/app/universe/universe-protocol-copy.ts index d0bcd2a1ba..4ba7c7ca57 100644 --- a/frontend/src/app/universe/universe-protocol-copy.ts +++ b/frontend/src/app/universe/universe-protocol-copy.ts @@ -109,11 +109,31 @@ const PROTOCOL_COPY: Record = { actions: ['deploy', 'mint', 'transfer'], }, doginals: { - summary: $localize`:@@universe.copy.doginals:Doginals apply ordinal theory to Dogecoin. They are indexed on a different chain than the one this explorer serves.`, + summary: $localize`:@@universe.copy.doginals:Doginals apply ordinal theory to Dogecoin: content inscribed on individual koinu and carried by the unspent output that holds them.`, actions: ['inscribe', 'transfer'], }, drc20: { - summary: $localize`:@@universe.copy.drc20:DRC-20 is the Dogecoin counterpart of inscription-based fungible tokens, indexed on a different chain than the one this explorer serves.`, + summary: $localize`:@@universe.copy.drc20:DRC-20 is the Dogecoin counterpart of inscription-based fungible tokens. Balances belong to an address; a transfer inscription moves an amount onto one unspent output until it is spent.`, + actions: ['deploy', 'mint', 'transfer'], + }, + dunes: { + summary: $localize`:@@universe.copy.dunes:Dunes are Dogecoin's runestone-style fungible tokens: balances live directly on unspent outputs and move by edicts in the spending transaction.`, + actions: ['etch', 'mint', 'transfer'], + }, + tap_doge: { + summary: $localize`:@@universe.copy.tap-doge:TAP on Dogecoin keeps token balances in an address-level ledger derived from inscriptions, so holdings belong to the address rather than to any single output.`, + actions: ['deploy', 'mint', 'transfer'], + }, + zerdinals: { + summary: $localize`:@@universe.copy.zerdinals:Zerdinals inscribe content into Zcash transparent transactions, and each inscription is carried by the unspent output that currently holds it.`, + actions: ['inscribe', 'transfer'], + }, + zrunes: { + summary: $localize`:@@universe.copy.zrunes:ZRunes are Zcash's runestone-style fungible tokens: balances live on transparent unspent outputs and move by edicts in the spending transaction.`, + actions: ['etch', 'mint', 'transfer'], + }, + zrc20: { + summary: $localize`:@@universe.copy.zrc20:ZRC-20 tokens keep an address-level ledger on Zcash, read under two published rulesets. Where the rulesets disagree this explorer shows both readings.`, actions: ['deploy', 'mint', 'transfer'], }, }; diff --git a/frontend/src/app/universe/universe.types.ts b/frontend/src/app/universe/universe.types.ts index 56e77e6378..cc3403f12a 100644 --- a/frontend/src/app/universe/universe.types.ts +++ b/frontend/src/app/universe/universe.types.ts @@ -365,6 +365,196 @@ export interface ChainCapabilityEnvelope { export type ChainExplorerPayload = Record; +// --- Chain dashboard, mining, fees, and chart views --- +// Mirrors backend-apis src/universe-explorer/contracts/chain-dashboard.ts. +// Quantities are exact decimal strings; unknown facts are null, never zero. + +export type PoolAttributionEvidence = + | 'coinbase-tag' + | 'payout-address' + | 'auxpow-parent-tag'; + +export interface BlockMinerView { + poolId: string | null; + name: string | null; + evidence: PoolAttributionEvidence | null; +} + +export interface ChainBlockSummary { + heightAtomic: string; + hash: string; + time: string; + txCountAtomic: string; + sizeBytesAtomic: string | null; + feesAtomic: string | null; + subsidyAtomic: string | null; + rewardAtomic: string | null; + medianFeeRateDecimal: string | null; + difficultyDecimal: string | null; + intervalSecondsAtomic: string | null; + miner: BlockMinerView; +} + +export interface RecentBlocksView { + schemaVersion: string; + chain: ExplorerChain; + network: ExplorerNetwork; + tip: SourceCheckpoint | null; + blocks: ChainBlockSummary[]; + coverage: { + fromHeightAtomic: string | null; + toHeightAtomic: string | null; + complete: boolean; + }; + observedAt: string; +} + +export type FeeRecommendationBasis = + | 'node-estimate' + | 'mempool-quantile' + | 'recent-blocks' + | 'relay-floor' + | 'zip317-conventional'; + +export interface FeeRecommendationLevel { + id: string; + amountDecimal: string; + basis: FeeRecommendationBasis; +} + +export type FeeRecommendationsView = + | { + schemaVersion: string; + chain: ExplorerChain; + network: ExplorerNetwork; + kind: 'fee-per-kilobyte'; + unit: 'koinu/kB'; + levels: FeeRecommendationLevel[]; + minRelayFeeAtomicPerKb: string; + tip: SourceCheckpoint | null; + observedAt: string; + } + | { + schemaVersion: string; + chain: ExplorerChain; + network: ExplorerNetwork; + kind: 'zip-317'; + unit: 'zatoshi'; + marginalFeeAtomic: string; + graceActionsAtomic: string; + typicalConventionalFeeAtomic: string; + paidShareDecimal: string | null; + basis: FeeRecommendationBasis; + tip: SourceCheckpoint | null; + observedAt: string; + }; + +export type HashrateUnit = 'hashes-per-second' | 'solutions-per-second'; + +export interface MiningSummaryView { + schemaVersion: string; + chain: ExplorerChain; + network: ExplorerNetwork; + tip: SourceCheckpoint | null; + difficultyDecimal: string | null; + networkRateDecimal: string | null; + hashrateUnit: HashrateUnit; + algorithm: string; + targetBlockSecondsAtomic: string; + observedIntervalSecondsDecimal: string | null; + windowBlocksAtomic: string | null; + subsidyAtomic: string | null; + meanRewardAtomic: string | null; + meanFeesAtomic: string | null; + mergedMining: { + supported: boolean; + noticeId: string | null; + }; + observedAt: string; +} + +export interface MiningPoolShare { + poolId: string; + name: string; + blocksAtomic: string; + shareDecimal: string; + evidence: PoolAttributionEvidence[]; +} + +export interface MiningPoolsView { + schemaVersion: string; + chain: ExplorerChain; + network: ExplorerNetwork; + windowId: string; + windowBlocksAtomic: string; + pools: MiningPoolShare[]; + attributionDatasetVersion: string; + coverageComplete: boolean; + observedAt: string; +} + +export interface ChartSeriesLine { + key: string; + unit: string; + points: [string, string | null][]; +} + +export interface ChartSeriesView { + schemaVersion: string; + chain: ExplorerChain; + network: ExplorerNetwork; + seriesId: string; + rangeId: string; + lines: ChartSeriesLine[]; + aggregation: string; + bucketSecondsAtomic: string | null; + coverage: { + fromAtomic: string | null; + toAtomic: string | null; + complete: boolean; + earliestAtomic: string | null; + }; + sourceHeightAtomic: string | null; + observedAt: string; +} + +export interface ChainMempoolSummary { + txCountAtomic: string | null; + totalSizeBytesAtomic: string | null; + totalFeesAtomic: string | null; + arrivalRatePerSecondDecimal: string | null; + observedAt: string | null; +} + +export type ChainSubsystemId = + | 'core-node' + | 'confirmed-history' + | 'address-history' + | 'mempool' + | 'mining-analytics' + | 'historical-statistics' + | 'protocol-indexers'; + +export interface ChainSubsystemHealth { + id: ChainSubsystemId; + state: 'ready' | 'degraded' | 'unavailable'; + reasonIds: string[]; +} + +export interface ChainDashboardView { + schemaVersion: string; + chain: ExplorerChain; + network: ExplorerNetwork; + tip: SourceCheckpoint | null; + recentBlocks: RecentBlocksView | null; + buckets: ChainExplorerPayload | null; + fees: FeeRecommendationsView | null; + mempool: ChainMempoolSummary | null; + mining: MiningSummaryView | null; + subsystems: ChainSubsystemHealth[]; + observedAt: string; +} + export interface UniverseSearchResult { chain: ExplorerChain; network: 'mainnet'; diff --git a/scripts/universe/generate-protocol-coverage.mjs b/scripts/universe/generate-protocol-coverage.mjs deleted file mode 100644 index f96a27ae07..0000000000 --- a/scripts/universe/generate-protocol-coverage.mjs +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env node -/** - * Generates the protocol coverage documentation from the explorer protocol - * manifest. - * - * The manifest is whatever `/api/v1/universe/protocols` serves, which is the - * same contract the explorer frontend consumes. `docs/protocols/ - * PROTOCOL-COVERAGE.json` is the recorded manifest and `PROTOCOL-COVERAGE.md` - * is its human-readable table, so the table can never drift from the recorded - * manifest without `--check` failing. - * - * node scripts/universe/generate-protocol-coverage.mjs --from - * node scripts/universe/generate-protocol-coverage.mjs --check - * - * `--from` records a fresh manifest and rewrites both files. `--check` takes no - * source: it re-derives the table from the recorded manifest and fails if the - * committed table differs, which is the gate CI runs. - */ - -import { readFile, writeFile } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; - -const REPOSITORY_ROOT = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..', - '..', -); -const JSON_PATH = path.join( - REPOSITORY_ROOT, - 'docs', - 'protocols', - 'PROTOCOL-COVERAGE.json', -); -const MARKDOWN_PATH = path.join( - REPOSITORY_ROOT, - 'docs', - 'protocols', - 'PROTOCOL-COVERAGE.md', -); - -function usage(message) { - process.stderr.write(`${message}\n\n`); - process.stderr.write( - 'Usage:\n' + - ' generate-protocol-coverage.mjs --from record a manifest and rewrite the docs\n' + - ' generate-protocol-coverage.mjs --check verify the table matches the recorded manifest\n', - ); - process.exit(2); -} - -async function loadManifest(source) { - if (/^https?:\/\//i.test(source)) { - const response = await fetch(source, { - headers: { accept: 'application/json' }, - redirect: 'error', - signal: AbortSignal.timeout(30_000), - }); - if (!response.ok) { - throw new Error(`The manifest source answered HTTP ${response.status}.`); - } - return response.json(); - } - return JSON.parse(await readFile(path.resolve(source), 'utf8')); -} - -/** Rejects anything that is not a protocol manifest before it is recorded. */ -function validateManifest(manifest) { - if ( - typeof manifest !== 'object' || - manifest === null || - typeof manifest.registryVersion !== 'string' || - !Array.isArray(manifest.protocols) || - manifest.protocols.length === 0 - ) { - throw new Error( - 'The manifest must be an object with a registryVersion and a non-empty protocols array.', - ); - } - for (const protocol of manifest.protocols) { - for (const field of ['id', 'family', 'chain', 'releaseStatus']) { - if (typeof protocol?.[field] !== 'string' || !protocol[field]) { - throw new Error( - `Protocol ${protocol?.id ?? ''} is missing ${field}.`, - ); - } - } - } -} - -function coverageCell(coverage) { - if (coverage === null || coverage === undefined || coverage === '') { - return 'unknown'; - } - if (typeof coverage === 'string') return coverage; - return typeof coverage.state === 'string' ? coverage.state : 'unknown'; -} - -/** Escapes the pipe so an authority id can never break the table. */ -function cell(value) { - return String(value ?? '').replace(/\|/g, '\\|'); -} - -function renderMarkdown(manifest) { - const live = manifest.protocols.filter( - (protocol) => - protocol.releaseStatus === 'VERIFIED READ ONLY' || - protocol.releaseStatus === 'PRODUCTION VERIFIED', - ).length; - const rows = manifest.protocols.map( - (protocol) => - `| ${cell(protocol.id)} | ${cell(protocol.family)} | ${cell(protocol.chain)} | ` + - `${cell(protocol.indexerAuthority ?? 'none')} | ${cell(protocol.releaseStatus)} | ` + - `${cell(coverageCell(protocol.coverage))} |`, - ); - return `# Protocol coverage - -Generated from the explorer protocol registry in backend-apis -(src/universe-explorer/registry/explorer-protocol-registry.ts), as served by -\`/api/v1/universe/protocols\`. Do not edit rows by hand: regenerate with - -\`\`\` -node scripts/universe/generate-protocol-coverage.mjs --from -\`\`\` - -and verify with \`node scripts/universe/generate-protocol-coverage.mjs --check\`. - -Release status semantics: every protocol starts BLOCKED and is upgraded only when -its explorer integration is completed and verified against its Universe authority. -A protocol never silently disappears from this table. - -Registry version ${cell(manifest.registryVersion)}. ${live} of ${manifest.protocols.length} protocols are readable today; the rest are recorded here but not yet served. - -| id | family | chain | authority | release status | coverage | -|---|---|---|---|---|---| -${rows.join('\n')} -`; -} - -async function main() { - const argv = process.argv.slice(2); - const check = argv.includes('--check'); - const fromIndex = argv.indexOf('--from'); - const source = fromIndex === -1 ? null : argv[fromIndex + 1]; - - if (check && source) { - usage('Pass either --check or --from, not both.'); - } - if (!check && !source) { - usage('A manifest source is required.'); - } - - if (check) { - const manifest = JSON.parse(await readFile(JSON_PATH, 'utf8')); - validateManifest(manifest); - // Compare content, not line endings. This repository is developed on a - // Windows host where the working copy is checked out with CRLF, while the - // renderer emits LF, so a byte comparison reported every clean tree as - // stale and only ever passed on the Linux runner. - const normalise = (text) => text.split('\r\n').join('\n'); - const expected = normalise(renderMarkdown(manifest)); - const actual = normalise(await readFile(MARKDOWN_PATH, 'utf8')); - if (expected !== actual) { - process.stderr.write( - 'PROTOCOL-COVERAGE.md does not match PROTOCOL-COVERAGE.json.\n' + - 'Regenerate it with --from and commit the result.\n', - ); - process.exit(1); - } - process.stdout.write( - `Protocol coverage table matches the recorded manifest (${manifest.protocols.length} protocols).\n`, - ); - return; - } - - const manifest = await loadManifest(source); - validateManifest(manifest); - const recorded = { - registryVersion: manifest.registryVersion, - generatedAt: new Date().toISOString(), - protocols: manifest.protocols, - }; - await writeFile(JSON_PATH, `${JSON.stringify(recorded, null, 2)}\n`, 'utf8'); - await writeFile(MARKDOWN_PATH, renderMarkdown(recorded), 'utf8'); - process.stdout.write( - `Recorded ${recorded.protocols.length} protocols at registry version ${recorded.registryVersion}.\n`, - ); -} - -main().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : error}\n`); - process.exit(1); -}); diff --git a/scripts/universe/parity-regressions.test.mjs b/scripts/universe/parity-regressions.test.mjs new file mode 100644 index 0000000000..508e5c7ba7 --- /dev/null +++ b/scripts/universe/parity-regressions.test.mjs @@ -0,0 +1,128 @@ +/** + * Regression gate for the multichain parity release. + * + * Every case here pins a structural fact that once failed in production or + * that a cleanup could plausibly reintroduce: a navigation item gated back + * to Bitcoin, a section dropped from the route registry, a dashboard whose + * data request went back to answering nothing, cubes losing their heights, + * or Bitcoin units wearing another chain's name. These read the source + * rather than a build, so they run in seconds and fail with the file named. + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +function read(path) { + return readFileSync(join(root, path), 'utf8'); +} + +test('Mining and Charts navigation are not gated to Bitcoin', () => { + const nav = read('frontend/src/app/components/master-page/master-page.component.html'); + const miningItem = nav.match(/