From 110fbc3631755f205e9d22636d0ad03059d719c1 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Fri, 4 Sep 2026 12:33:54 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20blackbeard.observer=20=E2=80=94=20live?= =?UTF-8?q?=20Quantus=20mining=20leaderboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cargo workspace plus a Vite frontend, following ~/git/architecture/generic.md. Every block header carries its author's wormhole reward preimage in a `pow_` PreRuntime digest, so authorship for the whole network is derivable from headers alone — no indexer, no registration, no way for a miner to be left out. That decoding, the hashrate maths and the telemetry name attribution live in blackbeard-core with no I/O at all, so the parts that are easy to get subtly wrong are exercised by unit tests rather than only against a live chain. The browser holds one WebSocket: snapshot on subscribe, deltas thereafter. The head stream is itself a push (chain_subscribeNewHeads), so a block reaches the page the moment the node imports it. Messages are serialised once per broadcast, and leaderboards are recomputed only for windows a socket is actually watching. No RxJS — useSyncExternalStore is React's own contract for this. Verified against the live Planck testnet: 12/12 headers decoded, telemetry names attributed (quanpool-planck, baba-gorchitsa, …), warm start restoring 84 blocks and 5 held names across a restart. Three findings worth recording, all in CLAUDE.md: - substrate-telemetry sends its JSON in *binary* frames. A text-only client connects, subscribes, reports healthy and receives nothing at all — and a Python probe hides it, because json.loads accepts bytes. - Difficulty is a little-endian U512; decoding it big-endian gives a number wrong by ~10^150 that still renders fine. - Planck's real block interval is ~13-15s against a 6s target with enormous variance, so a measured interval needs 20 tip samples before it is publishable. Deploy assets, the Gitea Actions workflow and script/infra-setup.sh are included; port 25864 is registered in architecture/port-allocations.md. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi --- .cargo/config.toml | 6 + .gitea/workflows/deploy.yaml | 315 ++ .gitignore | 18 + ...9f1eb88064b26db27367313ac6d054274f504.json | 20 + ...4a8f8c0e28d31217a80094b5d096214dc4e37.json | 37 + ...77ffd527f1b59461c1e4fa582f1d935c7b6a3.json | 35 + ...4f6fa9fca60a0bc2665ec862aa34a87f350a3.json | 20 + ...eb0fa69d12f668dd3d722edee5223a2b44782.json | 22 + ...023cad9a66f05633e06beb146d4c2d619389d.json | 41 + ...1997089cca59aefb4f2d2a31a30e6cb06782c.json | 20 + ...9eb9935e44603065c85979831cf9a28b0310c.json | 52 + ...a66de3a853d1d962e648380e1facac0ed2566.json | 20 + CLAUDE.md | 141 + Cargo.lock | 3382 +++++++++++++++++ Cargo.toml | 51 + asset/config/config.toml.tmpl | 76 + asset/firewalld/blackbeard-api.xml | 11 + asset/nginx/blackbeard-upstream.conf | 14 + asset/nginx/blackbeard.internal.conf | 60 + asset/nginx/blackbeard.observer.conf | 89 + asset/sql/bootstrap.sql | 39 + .../blackbeard-api-cert-reload.service | 10 + asset/systemd/blackbeard-api-cert.path | 14 + asset/systemd/blackbeard-api.service | 60 + asset/systemd/blackbeard.sysusers.conf | 2 + crates/blackbeard-api/Cargo.toml | 33 + crates/blackbeard-api/src/config.rs | 374 ++ crates/blackbeard-api/src/ingest.rs | 522 +++ crates/blackbeard-api/src/main.rs | 237 ++ crates/blackbeard-api/src/routes.rs | 349 ++ crates/blackbeard-api/src/state.rs | 506 +++ crates/blackbeard-api/src/ws.rs | 194 + crates/blackbeard-cli/Cargo.toml | 27 + crates/blackbeard-cli/src/main.rs | 361 ++ crates/blackbeard-core/Cargo.toml | 17 + crates/blackbeard-core/src/attribution.rs | 373 ++ crates/blackbeard-core/src/digest.rs | 138 + crates/blackbeard-core/src/hashrate.rs | 139 + crates/blackbeard-core/src/lib.rs | 32 + crates/blackbeard-core/src/scale.rs | 151 + crates/blackbeard-core/src/window.rs | 448 +++ crates/blackbeard-data/Cargo.toml | 32 + .../blackbeard-data/migrations/0001_init.sql | 73 + crates/blackbeard-data/src/lib.rs | 56 + crates/blackbeard-data/src/rpc.rs | 321 ++ crates/blackbeard-data/src/store.rs | 473 +++ crates/blackbeard-data/src/telemetry.rs | 465 +++ crates/blackbeard-data/tests/store.rs | 347 ++ crates/blackbeard-entities/Cargo.toml | 18 + crates/blackbeard-entities/src/block.rs | 60 + crates/blackbeard-entities/src/chain.rs | 126 + crates/blackbeard-entities/src/error.rs | 47 + crates/blackbeard-entities/src/lib.rs | 79 + crates/blackbeard-entities/src/miner.rs | 148 + crates/blackbeard-entities/src/ws.rs | 166 + readme.md | 237 ++ rust-toolchain.toml | 6 + rustfmt.toml | 1 + script/infra-setup.sh | 343 ++ web/.prettierrc | 6 + web/eslint.config.js | 20 + web/index.html | 36 + web/package.json | 35 + web/pnpm-lock.yaml | 1897 +++++++++ web/public/sigil.svg | 7 + web/src/App.tsx | 214 ++ web/src/api/generated/ApiError.ts | 19 + web/src/api/generated/AttributionSource.ts | 10 + web/src/api/generated/BigUintDec.ts | 11 + web/src/api/generated/BlockObservation.ts | 45 + web/src/api/generated/ChainId.ts | 11 + web/src/api/generated/ChainInfo.ts | 43 + web/src/api/generated/ChainStatus.ts | 10 + web/src/api/generated/ChainSummary.ts | 69 + web/src/api/generated/ClientMessage.ts | 20 + web/src/api/generated/LeaderboardRow.ts | 58 + web/src/api/generated/MinerDetail.ts | 48 + web/src/api/generated/MinerId.ts | 18 + web/src/api/generated/MinerSeriesPoint.ts | 22 + web/src/api/generated/RecentBlock.ts | 34 + web/src/api/generated/ServerMessage.ts | 76 + web/src/api/generated/Window.ts | 12 + web/src/api/rest.ts | 53 + web/src/api/socket.ts | 248 ++ web/src/components/BlockTicker.tsx | 87 + web/src/components/Leaderboard.tsx | 155 + web/src/components/MinerPanel.tsx | 139 + web/src/components/ShareChart.tsx | 211 + web/src/components/StatBar.tsx | 94 + web/src/index.css | 707 ++++ web/src/lib/ObserverProvider.tsx | 23 + web/src/lib/format.ts | 94 + web/src/lib/observer-context.ts | 20 + web/src/lib/store.ts | 108 + web/src/main.tsx | 22 + web/src/vite-env.d.ts | 16 + web/tsconfig.app.json | 23 + web/tsconfig.json | 4 + web/tsconfig.node.json | 16 + web/vite.config.ts | 26 + 100 files changed, 16221 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .gitea/workflows/deploy.yaml create mode 100644 .gitignore create mode 100644 .sqlx/query-3e125cdf4f28859158e2a54e78c9f1eb88064b26db27367313ac6d054274f504.json create mode 100644 .sqlx/query-41f694ddea9ed92237677559e524a8f8c0e28d31217a80094b5d096214dc4e37.json create mode 100644 .sqlx/query-461853e4ec42eaf95c83f6ed8e177ffd527f1b59461c1e4fa582f1d935c7b6a3.json create mode 100644 .sqlx/query-470022fab8a12aa88db0f1feb754f6fa9fca60a0bc2665ec862aa34a87f350a3.json create mode 100644 .sqlx/query-8da1c6c82b09e8944696865bb48eb0fa69d12f668dd3d722edee5223a2b44782.json create mode 100644 .sqlx/query-c72299abe9898d26859aa0afb96023cad9a66f05633e06beb146d4c2d619389d.json create mode 100644 .sqlx/query-c8c3d392237fb51d81bce5e59781997089cca59aefb4f2d2a31a30e6cb06782c.json create mode 100644 .sqlx/query-cf59d77bc3baa612812c4f2506f9eb9935e44603065c85979831cf9a28b0310c.json create mode 100644 .sqlx/query-d69e49186c4741eeb331bcb5e22a66de3a853d1d962e648380e1facac0ed2566.json create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 asset/config/config.toml.tmpl create mode 100644 asset/firewalld/blackbeard-api.xml create mode 100644 asset/nginx/blackbeard-upstream.conf create mode 100644 asset/nginx/blackbeard.internal.conf create mode 100644 asset/nginx/blackbeard.observer.conf create mode 100644 asset/sql/bootstrap.sql create mode 100644 asset/systemd/blackbeard-api-cert-reload.service create mode 100644 asset/systemd/blackbeard-api-cert.path create mode 100644 asset/systemd/blackbeard-api.service create mode 100644 asset/systemd/blackbeard.sysusers.conf create mode 100644 crates/blackbeard-api/Cargo.toml create mode 100644 crates/blackbeard-api/src/config.rs create mode 100644 crates/blackbeard-api/src/ingest.rs create mode 100644 crates/blackbeard-api/src/main.rs create mode 100644 crates/blackbeard-api/src/routes.rs create mode 100644 crates/blackbeard-api/src/state.rs create mode 100644 crates/blackbeard-api/src/ws.rs create mode 100644 crates/blackbeard-cli/Cargo.toml create mode 100644 crates/blackbeard-cli/src/main.rs create mode 100644 crates/blackbeard-core/Cargo.toml create mode 100644 crates/blackbeard-core/src/attribution.rs create mode 100644 crates/blackbeard-core/src/digest.rs create mode 100644 crates/blackbeard-core/src/hashrate.rs create mode 100644 crates/blackbeard-core/src/lib.rs create mode 100644 crates/blackbeard-core/src/scale.rs create mode 100644 crates/blackbeard-core/src/window.rs create mode 100644 crates/blackbeard-data/Cargo.toml create mode 100644 crates/blackbeard-data/migrations/0001_init.sql create mode 100644 crates/blackbeard-data/src/lib.rs create mode 100644 crates/blackbeard-data/src/rpc.rs create mode 100644 crates/blackbeard-data/src/store.rs create mode 100644 crates/blackbeard-data/src/telemetry.rs create mode 100644 crates/blackbeard-data/tests/store.rs create mode 100644 crates/blackbeard-entities/Cargo.toml create mode 100644 crates/blackbeard-entities/src/block.rs create mode 100644 crates/blackbeard-entities/src/chain.rs create mode 100644 crates/blackbeard-entities/src/error.rs create mode 100644 crates/blackbeard-entities/src/lib.rs create mode 100644 crates/blackbeard-entities/src/miner.rs create mode 100644 crates/blackbeard-entities/src/ws.rs create mode 100644 readme.md create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml create mode 100755 script/infra-setup.sh create mode 100644 web/.prettierrc create mode 100644 web/eslint.config.js create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/pnpm-lock.yaml create mode 100644 web/public/sigil.svg create mode 100644 web/src/App.tsx create mode 100644 web/src/api/generated/ApiError.ts create mode 100644 web/src/api/generated/AttributionSource.ts create mode 100644 web/src/api/generated/BigUintDec.ts create mode 100644 web/src/api/generated/BlockObservation.ts create mode 100644 web/src/api/generated/ChainId.ts create mode 100644 web/src/api/generated/ChainInfo.ts create mode 100644 web/src/api/generated/ChainStatus.ts create mode 100644 web/src/api/generated/ChainSummary.ts create mode 100644 web/src/api/generated/ClientMessage.ts create mode 100644 web/src/api/generated/LeaderboardRow.ts create mode 100644 web/src/api/generated/MinerDetail.ts create mode 100644 web/src/api/generated/MinerId.ts create mode 100644 web/src/api/generated/MinerSeriesPoint.ts create mode 100644 web/src/api/generated/RecentBlock.ts create mode 100644 web/src/api/generated/ServerMessage.ts create mode 100644 web/src/api/generated/Window.ts create mode 100644 web/src/api/rest.ts create mode 100644 web/src/api/socket.ts create mode 100644 web/src/components/BlockTicker.tsx create mode 100644 web/src/components/Leaderboard.tsx create mode 100644 web/src/components/MinerPanel.tsx create mode 100644 web/src/components/ShareChart.tsx create mode 100644 web/src/components/StatBar.tsx create mode 100644 web/src/index.css create mode 100644 web/src/lib/ObserverProvider.tsx create mode 100644 web/src/lib/format.ts create mode 100644 web/src/lib/observer-context.ts create mode 100644 web/src/lib/store.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..41b1262 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +# ts-rs writes the frontend's generated types here. Setting it once, relative to +# the workspace root, keeps every `#[ts(export_to = "Foo.ts")]` a bare filename — +# so moving the frontend is a one-line change rather than a sweep through the +# entities crate. +[env] +TS_RS_EXPORT_DIR = { value = "web/src/api/generated", relative = true } diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..afaf60b --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,315 @@ +name: deploy + +# The workflow is the source of infra truth (architecture/deployment-gitea-actions.md): +# hosts, ports, paths and component→host mapping live here and nowhere else. +# There is no separate deployment manifest. + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + mode: + description: "deploy (apply then validate) or validate (check only)" + type: choice + options: [deploy, validate] + default: deploy + +concurrency: + # Serialise deploys; never half-apply two at once. + group: deploy + cancel-in-progress: false + +env: + # --- infra truth ----------------------------------------------------------- + # The API runs beside quantus-node so it can read the node's loopback RPC. + API_HOST: bob.hanzalova.internal + API_PORT: "25864" + # The site's edge proxy. It serves the built SPA and reverse-proxies /v1. + EDGE_HOST: hanzalova.internal + WEBROOT: /var/www/blackbeard.observer + PUBLIC_NAME: blackbeard.observer + + DEPLOY_KEY: | + ${{ secrets.RSYNC_SSH_KEY }} + +jobs: + build: + runs-on: fedora-43 + steps: + - uses: actions/checkout@v4 + + - name: rust gate + # Format, lint-as-error and the full test suite before anything is + # built, so a broken commit never reaches a host. SQLX_OFFLINE makes the + # compile-time-checked queries read the committed .sqlx cache instead of + # needing a live database on the runner. + env: + SQLX_OFFLINE: "true" + run: | + set -euo pipefail + cargo fmt --all -- --check + cargo clippy --all-targets --all-features -- -D warnings + cargo test --all + + - name: build api and cli + env: + SQLX_OFFLINE: "true" + run: | + set -euo pipefail + # musl, statically linked: the runner image is newer than the target + # host, and a glibc-linked binary built here would not load there + # (deployment-gitea-actions.md §6). + cargo build --release --target x86_64-unknown-linux-musl \ + -p blackbeard-api -p blackbeard-cli + file target/x86_64-unknown-linux-musl/release/blackbeard-api + + - name: generated types are current + # ts-rs writes web/src/api/generated/ from the Rust entities crate. + # `cargo test` above regenerates them; a diff here means someone edited + # a DTO and committed the Rust without the TypeScript, which would + # compile on both sides and disagree at runtime. + run: | + set -euo pipefail + if ! git diff --quiet -- web/src/api/generated; then + echo "generated TypeScript is out of date; run 'cargo test -p blackbeard-entities' and commit:" >&2 + git diff --stat -- web/src/api/generated >&2 + exit 1 + fi + + - name: build web + run: | + set -euo pipefail + corepack enable + cd web + pnpm install --frozen-lockfile + pnpm lint + pnpm build + + - uses: actions/upload-artifact@v3 + with: + name: blackbeard + path: | + target/x86_64-unknown-linux-musl/release/blackbeard-api + target/x86_64-unknown-linux-musl/release/blackbeard + web/dist + asset + + deploy-api: + needs: build + # `infra`, not `fedora-43`: the targets are mesh-only .internal names and + # the fedora runners have no route to the WireGuard mesh. Same reason as + # lair/quantus and lair/mail. + runs-on: infra + steps: + - uses: actions/download-artifact@v3 + with: + name: blackbeard + + - name: ssh key and reachability + run: | + set -euo pipefail + install -d -m 0700 ~/.ssh + printf '%s' "$DEPLOY_KEY" > ~/.ssh/id_deploy + chmod 0600 ~/.ssh/id_deploy + cat > ~/.ssh/config <&2 + echo "re-run script/infra-setup.sh against this host." >&2 + exit 1 + fi + + - name: render config + # python3 .replace(), not sed or envsubst: a literal substitution + # survives values containing shell-special characters. + run: | + set -euo pipefail + fqdn=$(ssh "$API_HOST" hostname -f) + # The API binds the host's mesh address, never 0.0.0.0 — the fleet has + # one firewalld default zone, so a wildcard bind plus the named + # service would publish this on every address the host carries. + bind=$(ssh "$API_HOST" "ip -4 -o addr show | awk '/10\\.[0-9]+\\./ {print \$4}' | cut -d/ -f1 | head -1") + if [ -z "$bind" ]; then + echo "could not determine the mesh address of $API_HOST" >&2 + exit 1 + fi + echo "binding $bind:$API_PORT on $fqdn" + python3 - "$fqdn" "$bind" <<'PY' + import sys, pathlib + fqdn, bind = sys.argv[1], sys.argv[2] + tmpl = pathlib.Path("asset/config/config.toml.tmpl").read_text() + out = tmpl.replace("{{TARGET_FQDN}}", fqdn).replace("{{BIND_ADDRESS}}", bind) + pathlib.Path("config.toml").write_text(out) + PY + + - name: verify the rendered config + # The binary validates its own configuration and exits without binding + # anything. A bad config fails the deploy here rather than leaving a + # daemon that will not start. + run: | + set -euo pipefail + chmod +x target/x86_64-unknown-linux-musl/release/blackbeard-api + # --check reads the certificate paths, which exist on the target and + # not on the runner, so this runs on the target after the config + # lands. Placeholder substitution is what is checked here. + grep -q '{{' config.toml && { echo "config still has unsubstituted placeholders" >&2; exit 1; } + echo "no placeholders remain" + + - name: ship + run: | + set -euo pipefail + r() { rsync -a --checksum --mkpath --rsync-path='sudo rsync' "$@"; } + # --checksum, not rsync's default size+mtime: the build produces a new + # mtime every run, so the default heuristic reports a change on every + # deploy and would restart a healthy daemon each time. + r --chmod=0755 target/x86_64-unknown-linux-musl/release/blackbeard-api \ + "$API_HOST:/usr/local/bin/blackbeard-api" + r --chmod=0755 target/x86_64-unknown-linux-musl/release/blackbeard \ + "$API_HOST:/usr/local/bin/blackbeard" + r --chown=root:blackbeard --chmod=0640 config.toml \ + "$API_HOST:/etc/blackbeard/config.toml" + r asset/systemd/blackbeard.sysusers.conf "$API_HOST:/etc/sysusers.d/blackbeard.conf" + r asset/systemd/blackbeard-api.service "$API_HOST:/etc/systemd/system/blackbeard-api.service" + r asset/systemd/blackbeard-api-cert.path "$API_HOST:/etc/systemd/system/blackbeard-api-cert.path" + r asset/systemd/blackbeard-api-cert-reload.service \ + "$API_HOST:/etc/systemd/system/blackbeard-api-cert-reload.service" + r asset/firewalld/blackbeard-api.xml "$API_HOST:/etc/firewalld/services/blackbeard-api.xml" + + - name: apply system state + run: | + set -euo pipefail + ssh "$API_HOST" bash -euo pipefail <<'REMOTE' + sudo systemd-sysusers + sudo install -d -o root -g blackbeard -m 0750 /etc/blackbeard + + # The service account needs to read the host private key: it is the + # credential for the mTLS Postgres connection and is not + # world-readable (architecture/generic.md §11). + sudo setfacl -m u:blackbeard:r "/etc/pki/tls/private/$(hostname -f).pem" + + sudo restorecon -R /usr/local/bin/blackbeard-api /usr/local/bin/blackbeard /etc/blackbeard + + # SELinux must know about the port or the bind is denied. Guarded so + # a re-run is a no-op; `semanage port -a` on an already-labelled + # port reassigns it rather than failing cleanly. + if ! sudo semanage port -l | grep -qE "^http_port_t.*\b25864\b"; then + sudo semanage port -a -t http_port_t -p tcp 25864 + else + echo "port 25864 already labelled http_port_t" + fi + + # firewalld only learns a freshly-shipped custom service after a + # reload; querying or adding it before reloading fails + # INVALID_SERVICE. + sudo firewall-cmd --reload + zone=$(sudo firewall-cmd --get-default-zone) + if ! sudo firewall-cmd --zone="$zone" --query-service=blackbeard-api; then + sudo firewall-cmd --permanent --zone="$zone" --add-service=blackbeard-api + sudo firewall-cmd --zone="$zone" --add-service=blackbeard-api + else + echo "blackbeard-api already open in zone $zone" + fi + + sudo systemctl daemon-reload + sudo systemctl enable --now blackbeard-api-cert.path + + # Validate the config on the target, where the certificates it names + # actually exist. + sudo -u blackbeard /usr/local/bin/blackbeard-api \ + --config /etc/blackbeard/config.toml --check + + sudo systemctl enable blackbeard-api.service + sudo systemctl restart blackbeard-api.service + REMOTE + + - name: health probe + run: | + set -euo pipefail + for attempt in $(seq 1 30); do + if ssh "$API_HOST" "curl -sf http://127.0.0.1:$API_PORT/v1/healthz" > health.json; then + cat health.json + # `healthy` covers the database. Chain reachability is + # deliberately not part of it: a chain configured before it + # launches is not a deploy failure. + python3 -c "import json,sys; sys.exit(0 if json.load(open('health.json'))['healthy'] else 1)" + exit 0 + fi + sleep 2 + done + echo "blackbeard-api did not become healthy" >&2 + exit 1 + + - name: journal + if: always() + run: ssh "$API_HOST" journalctl -u blackbeard-api.service -n 80 --no-pager || true + + deploy-web: + needs: build + runs-on: infra + steps: + - uses: actions/download-artifact@v3 + with: + name: blackbeard + + - name: ssh key and reachability + run: | + set -euo pipefail + install -d -m 0700 ~/.ssh + printf '%s' "$DEPLOY_KEY" > ~/.ssh/id_deploy + chmod 0600 ~/.ssh/id_deploy + cat > ~/.ssh/config <= $4\n group by 1\n order by 1 asc\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "bucket!", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "mine!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "total!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Interval", + "Timestamptz" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "41f694ddea9ed92237677559e524a8f8c0e28d31217a80094b5d096214dc4e37" +} diff --git a/.sqlx/query-461853e4ec42eaf95c83f6ed8e177ffd527f1b59461c1e4fa582f1d935c7b6a3.json b/.sqlx/query-461853e4ec42eaf95c83f6ed8e177ffd527f1b59461c1e4fa582f1d935c7b6a3.json new file mode 100644 index 0000000..888482c --- /dev/null +++ b/.sqlx/query-461853e4ec42eaf95c83f6ed8e177ffd527f1b59461c1e4fa582f1d935c7b6a3.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select height, miner, observed_at\n from (\n select height, miner, observed_at\n from block\n where chain = $1\n order by height desc\n limit $2\n ) recent\n order by height asc\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "height", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "miner", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "observed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "461853e4ec42eaf95c83f6ed8e177ffd527f1b59461c1e4fa582f1d935c7b6a3" +} diff --git a/.sqlx/query-470022fab8a12aa88db0f1feb754f6fa9fca60a0bc2665ec862aa34a87f350a3.json b/.sqlx/query-470022fab8a12aa88db0f1feb754f6fa9fca60a0bc2665ec862aa34a87f350a3.json new file mode 100644 index 0000000..970af2a --- /dev/null +++ b/.sqlx/query-470022fab8a12aa88db0f1feb754f6fa9fca60a0bc2665ec862aa34a87f350a3.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n insert into block (chain, height, hash, miner, authored_at, observed_at, difficulty)\n select * from unnest(\n $1::text[], $2::bigint[], $3::text[], $4::text[],\n $5::timestamptz[], $6::timestamptz[], $7::numeric[]\n )\n on conflict (chain, height) do update set\n hash = excluded.hash,\n miner = excluded.miner,\n authored_at = excluded.authored_at,\n observed_at = excluded.observed_at,\n difficulty = excluded.difficulty\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "TextArray", + "Int8Array", + "TextArray", + "TextArray", + "TimestamptzArray", + "TimestamptzArray", + "NumericArray" + ] + }, + "nullable": [] + }, + "hash": "470022fab8a12aa88db0f1feb754f6fa9fca60a0bc2665ec862aa34a87f350a3" +} diff --git a/.sqlx/query-8da1c6c82b09e8944696865bb48eb0fa69d12f668dd3d722edee5223a2b44782.json b/.sqlx/query-8da1c6c82b09e8944696865bb48eb0fa69d12f668dd3d722edee5223a2b44782.json new file mode 100644 index 0000000..2518810 --- /dev/null +++ b/.sqlx/query-8da1c6c82b09e8944696865bb48eb0fa69d12f668dd3d722edee5223a2b44782.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "select max(height) as height from block where chain = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "height", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "8da1c6c82b09e8944696865bb48eb0fa69d12f668dd3d722edee5223a2b44782" +} diff --git a/.sqlx/query-c72299abe9898d26859aa0afb96023cad9a66f05633e06beb146d4c2d619389d.json b/.sqlx/query-c72299abe9898d26859aa0afb96023cad9a66f05633e06beb146d4c2d619389d.json new file mode 100644 index 0000000..ebe03c9 --- /dev/null +++ b/.sqlx/query-c72299abe9898d26859aa0afb96023cad9a66f05633e06beb146d4c2d619389d.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select count(*) as \"blocks!\",\n min(observed_at) as first_seen,\n max(observed_at) as last_seen,\n coalesce(sum(difficulty), 0)::text as \"work!\"\n from block\n where chain = $1 and miner = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "blocks!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "first_seen", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "last_seen", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "work!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null, + null, + null, + null + ] + }, + "hash": "c72299abe9898d26859aa0afb96023cad9a66f05633e06beb146d4c2d619389d" +} diff --git a/.sqlx/query-c8c3d392237fb51d81bce5e59781997089cca59aefb4f2d2a31a30e6cb06782c.json b/.sqlx/query-c8c3d392237fb51d81bce5e59781997089cca59aefb4f2d2a31a30e6cb06782c.json new file mode 100644 index 0000000..13b1660 --- /dev/null +++ b/.sqlx/query-c8c3d392237fb51d81bce5e59781997089cca59aefb4f2d2a31a30e6cb06782c.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n insert into chain (\n id, display_name, mainnet, genesis, token_symbol, token_decimals,\n target_block_time_seconds, last_observed_at\n )\n values ($1, $2, $3, $4, $5, $6, $7, now())\n on conflict (id) do update set\n display_name = excluded.display_name,\n mainnet = excluded.mainnet,\n -- coalesce, not excluded: a poll that could not reach the node\n -- must not blank out what an earlier one discovered.\n genesis = coalesce(excluded.genesis, chain.genesis),\n token_symbol = coalesce(excluded.token_symbol, chain.token_symbol),\n token_decimals = coalesce(excluded.token_decimals, chain.token_decimals),\n target_block_time_seconds = excluded.target_block_time_seconds,\n last_observed_at = now()\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Bool", + "Text", + "Text", + "Int2", + "Float8" + ] + }, + "nullable": [] + }, + "hash": "c8c3d392237fb51d81bce5e59781997089cca59aefb4f2d2a31a30e6cb06782c" +} diff --git a/.sqlx/query-cf59d77bc3baa612812c4f2506f9eb9935e44603065c85979831cf9a28b0310c.json b/.sqlx/query-cf59d77bc3baa612812c4f2506f9eb9935e44603065c85979831cf9a28b0310c.json new file mode 100644 index 0000000..de0249b --- /dev/null +++ b/.sqlx/query-cf59d77bc3baa612812c4f2506f9eb9935e44603065c85979831cf9a28b0310c.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select miner, node_kind, node_key, node_name, attempts, attributed\n from miner_attribution\n where chain = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "miner", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "node_kind", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "node_key", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "node_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "attempts", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "attributed", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false + ] + }, + "hash": "cf59d77bc3baa612812c4f2506f9eb9935e44603065c85979831cf9a28b0310c" +} diff --git a/.sqlx/query-d69e49186c4741eeb331bcb5e22a66de3a853d1d962e648380e1facac0ed2566.json b/.sqlx/query-d69e49186c4741eeb331bcb5e22a66de3a853d1d962e648380e1facac0ed2566.json new file mode 100644 index 0000000..a0eef3b --- /dev/null +++ b/.sqlx/query-d69e49186c4741eeb331bcb5e22a66de3a853d1d962e648380e1facac0ed2566.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n insert into miner_attribution\n (chain, miner, node_kind, node_key, attempts, attributed, node_name, updated_at)\n select $1, m, k, key, a, att, n, now()\n from unnest($2::text[], $3::text[], $4::text[], $5::int[], $6::int[], $7::text[])\n as t(m, k, key, a, att, n)\n on conflict (chain, miner) do update set\n node_kind = excluded.node_kind,\n node_key = excluded.node_key,\n attempts = excluded.attempts,\n attributed = excluded.attributed,\n -- A name the feed cannot currently resolve must not erase the\n -- one we already had: a telemetry hiccup would otherwise strip\n -- every miner's name from the next restart onwards.\n node_name = coalesce(excluded.node_name, miner_attribution.node_name),\n updated_at = now()\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "TextArray", + "TextArray", + "TextArray", + "Int4Array", + "Int4Array", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "d69e49186c4741eeb331bcb5e22a66de3a853d1d962e648380e1facac0ed2566" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..19f8399 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,141 @@ +# CLAUDE.md + +Guidance for Claude Code working in this repository. + +Read `readme.md` first — it carries the mechanism (how authorship is decoded from +block headers), the reasoning behind the window semantics, and the list of +deliberate deviations from house convention. This file is the things that will +bite you. + +Conventions: `~/git/architecture` — `generic.md` for the workspace shape, +`deployment-gitea-actions.md` for the deploy, `port-allocations.md` for the port, +`reverse-proxies.md` and `external-tls.md`/`internal-tls.md` for the vhosts. + +## The things that cost an afternoon + +**substrate-telemetry sends its JSON in *binary* WebSocket frames, not text.** A +client that handles only `Message::Text` connects, subscribes, reports itself +healthy, and receives absolutely nothing — no error, no decode failure, an empty +feed and `telemetry_nodes: null`. Worse, a throwaway probe written in Python +*works*, because `json.loads` accepts bytes, so the two disagree for no visible +reason. Both frame types are accepted in `telemetry.rs` and `rpc.rs`. Do not +"simplify" either match arm. + +**Difficulty is a little-endian U512.** `state_call` returns a SCALE-encoded +`U512`, byte-reversed relative to how a hash reads. Decoding it big-endian yields +a plausible-looking number that is wrong by ~10^150, and every hashrate derived +from it is wrong without ever looking broken. `digest::u512_le` has the test. + +**A measured block interval is only valid at the tip.** A syncing node imports +history at disk speed; dividing difficulty by *that* gives a hashrate wrong by +orders of magnitude that still renders as a number. `RollingWindow::push` takes +an explicit `at_tip` flag and backfilled blocks contribute no timing sample. The +`Interval` enum exists so a caller cannot forget to say which kind it has. + +**Windows are block counts, never durations.** See `readme.md`. Anything that +turns `Window` into a time range is wrong. + +**`chain_subscribeNewHeads` skips blocks.** It reports the *best* head, so when +several import at once the intermediates never arrive. It is a liveness signal, +not a ledger — `ingest` fills gaps against `chain_getBlockHash`. Removing that +would quietly under-count exactly the miners who won blocks during a burst. + +**Blocks are keyed on `(chain, height)`, not hash.** This chain reorgs; the +upsert is what makes a replacement overwrite rather than accumulate. A schema +keyed on the hash would inflate the losing fork's author forever. + +## Facts established by measurement + +Taken from the live Planck chain, 2026-09-04. Don't re-derive or contradict +without re-measuring. + +- Header digest shape is exactly `0x06` + `706f775f` + compact `0x80` (32) + + 32-byte preimage. Genesis is the only header without one; 12/12 recent headers + decoded. +- Telemetry propagation: first reporter stamped `0`, others 50–620 ms. The + `ATTRIBUTION_LEAD_MS = 20` threshold only has to exclude a tie — identity over + many blocks does the discriminating. +- Planck's observed block interval is ~13–15 s against a 6 s target, with + consecutive gaps of 1.6 s, 4.4 s, 13.5 s, 26 s, 27 s. This is why + `MIN_TIP_SAMPLES` is 20: at five samples the headline hashrate swings by a + factor of three between refreshes. +- `system_properties`: `PLK`, 12 decimals, ss58 prefix 189. Genesis + `0x4901bf5c…e65e72`. +- The telemetry feed URL is `wss://feed-telemetry.quantus.cat/feed` — found in + `/tmp/env-config.js` on the telemetry site, **not** the `/feed` path on the + main host, which 404s. + +## The frontend's types are generated + +`web/src/api/generated/` is written by `ts-rs` from `blackbeard-entities` when +`cargo test -p blackbeard-entities` runs. **Never edit those files.** CI fails +the build if the committed output is stale. + +`u64` maps to `bigint` by default, which is wrong — `serde_json` puts a u64 on +the wire as a JSON *number*, so the type and the runtime value disagree. Every +u64 DTO field carries `#[ts(type = "number")]`. Add it to any new one. + +## Separation of concerns is load-bearing here + +- `blackbeard-core` has **no I/O, no clock, no sockets**. That is what lets the + decoding and the statistics — the two parts that are easy to get subtly wrong + and hard to notice — be exercised by unit tests instead of only against a live + chain. Keep it that way; if something there needs the time, pass it in. +- `blackbeard-data` owns retries, reconnects and schema. +- `blackbeard-api` wires them together and owns nothing else. + +## Locking + +`ChainRuntime::inner` is a **`std::sync::RwLock`, never held across an `.await`**. +Everything under it is CPU work on in-memory collections. Do not switch it to +tokio's — that variant makes it easy to accidentally hold a lock across an await +and stall a worker, for no benefit here. + +## Database work + +Queries use `sqlx::query!` (compile-time checked) with the offline cache in +`.sqlx/` committed, so CI builds with `SQLX_OFFLINE=true` and no database. + +**After changing any query, regenerate the cache** or CI fails on a stale one: + +```sh +podman run -d --rm --name bb-pg -e POSTGRES_PASSWORD=dev \ + -e POSTGRES_DB=blackbeard -p 55432:5432 docker.io/library/postgres:18-alpine +export DATABASE_URL='postgres://postgres:dev@127.0.0.1:55432/blackbeard' +cargo sqlx prepare --workspace -- --all-targets +``` + +Migrations are sequentially versioned and **immutable once committed**. Correct a +mistake with a new file, never by editing one that has landed — the runner's +checksum diverges and it refuses to start. + +## Charts + +Any chart added here must be read against the `dataviz` skill first. The +constraint already in force: **bronze `#bd8829` and crimson `#d8453a` are +adjacent hues and fail CVD separation as a categorical pair.** Every chart on the +site is therefore single-series — magnitude, one hue — with identity carried by +labels and row treatment. A second series in the accent colour is the one change +that would break the palette, and it would not look broken. + +`web/src/index.css` documents the validated values. Re-run the validator after +touching them: + +```sh +node /scripts/validate_palette.js "#bd8829" --mode dark --surface "#14110d" +``` + +## Verifying a change + +`systemctl is-active` is not evidence this works. A daemon with a node it cannot +decode, or a telemetry feed it silently ignores, is perfectly "active" while +serving an empty leaderboard. Check the numbers: + +```sh +cargo run -p blackbeard-cli -- probe --rpc-url http://bob.hanzalova.internal:9944 +cargo run -p blackbeard-cli -- standings --chain planck +curl -s localhost:25864/v1/chains/planck/summary | python3 -m json.tool +``` + +A working deployment shows a non-null `telemetry_nodes`, `distinct_miners` above +one, and named rows in the standings. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..276ce8e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3382 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-compression" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.29.0", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blackbeard-api" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "blackbeard-core", + "blackbeard-data", + "blackbeard-entities", + "chrono", + "clap", + "figment", + "futures-util", + "primitive-types", + "serde", + "serde_json", + "sqlx", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "blackbeard-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "blackbeard-core", + "blackbeard-data", + "blackbeard-entities", + "chrono", + "clap", + "reqwest", + "serde", + "serde_json", + "sqlx", + "tokio", +] + +[[package]] +name = "blackbeard-core" +version = "0.1.0" +dependencies = [ + "blackbeard-entities", + "chrono", + "hex", + "primitive-types", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "blackbeard-data" +version = "0.1.0" +dependencies = [ + "bigdecimal", + "blackbeard-core", + "blackbeard-entities", + "chrono", + "futures-util", + "primitive-types", + "reqwest", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.20", + "tokio", + "tokio-tungstenite 0.24.0", + "tracing", + "url", +] + +[[package]] +name = "blackbeard-entities" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "thiserror 2.0.20", + "ts-rs", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compression-codecs" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "static_assertions", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primitive-types" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15600a7d856470b7d278b3fe0e311fe28c2526348549f8ef2ff7db3299c87f5" +dependencies = [ + "fixed-hash", + "uint", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bigdecimal", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bigdecimal", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.8", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bigdecimal", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "num-bigint", + "once_cell", + "rand 0.8.8", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.20", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.24.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ts-rs" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" +dependencies = [ + "chrono", + "lazy_static", + "thiserror 2.0.20", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9d8656589772eeec2cf7a8264d9cda40fb28b9bc53118ceb9e8c07f8f38730" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "termcolor", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.8", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.20", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uint" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f9227a75a5a540a464c832ad4a4195dbdbecd8787610a56262721fde6f04f90" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..48ac2c4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,51 @@ +[workspace] +resolver = "3" +members = ["crates/*"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +license = "GPL-3.0-or-later" +authors = ["Rob Thijssen "] +repository = "https://git.lair.cafe/Quantus-Network/blackbeard.observer" + +[workspace.dependencies] +blackbeard-entities = { path = "crates/blackbeard-entities", version = "=0.1.0" } +blackbeard-core = { path = "crates/blackbeard-core", version = "=0.1.0" } +blackbeard-data = { path = "crates/blackbeard-data", version = "=0.1.0" } + +anyhow = "1" +axum = { version = "0.8", features = ["ws", "macros"] } +chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] } +clap = { version = "4", features = ["derive", "env"] } +figment = { version = "0.10", features = ["toml", "env"] } +futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } +hex = "0.4" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sqlx = { version = "0.8", default-features = false, features = [ + "postgres", + "runtime-tokio-rustls", + "macros", + "migrate", + "chrono", + "bigdecimal", +] } +bigdecimal = "0.4" +primitive-types = { version = "0.13", default-features = false } +thiserror = "2" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] } +tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +ts-rs = { version = "10", features = ["serde-compat", "chrono-impl", "no-serde-warnings"] } +url = "2" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "debuginfo" diff --git a/asset/config/config.toml.tmpl b/asset/config/config.toml.tmpl new file mode 100644 index 0000000..cf50216 --- /dev/null +++ b/asset/config/config.toml.tmpl @@ -0,0 +1,76 @@ +# blackbeard-api configuration. +# +# Rendered by .gitea/workflows/deploy.yaml and rsynced to +# /etc/blackbeard/config.toml (root:blackbeard 0640). The unrendered template is +# committed; the rendered file never is. +# +# There are no secrets here and there is nowhere to put one: the Postgres +# connection is mTLS with this host's own certificate as the credential +# (architecture/generic.md §5), and every chain endpoint is either loopback or a +# public feed. The only substituted value is the host's FQDN, because the +# certificate paths carry it and the workflow is the thing that knows which host +# this is going to. + +[server] +# The host's mesh address, not 0.0.0.0. The fleet uses a single firewalld +# default zone (generic.md §9), so a wildcard bind plus the named service would +# publish this on every address the host carries. +listen = "{{BIND_ADDRESS}}:25864" + +# Not a security boundary — every byte this serves is derived from public block +# headers. It is here so a copy of the frontend served from somewhere else does +# not silently depend on this backend. +allowed_origins = [ + "https://blackbeard.observer", + "https://blackbeard.internal", +] + +ticker_blocks = 40 +leaderboard_refresh_seconds = 5 + +[database] +host = "magrathea.kosherinata.internal" +port = 5432 +database = "blackbeard" +username = "blackbeard_rw" +root_cert = "/etc/pki/ca-trust/source/anchors/root-internal.pem" +client_cert = "/etc/pki/tls/misc/{{TARGET_FQDN}}.pem" +# Not world-readable; the service account is granted read access with setfacl at +# deploy time. +client_key = "/etc/pki/tls/private/{{TARGET_FQDN}}.pem" +max_connections = 8 + +# --- chains ------------------------------------------------------------------ +# +# The first chain listed is the one the site opens on. +# +# A chain may be configured before it launches: the observer reports it as +# `awaiting`, the UI says so, and it comes alive on its own the moment the node +# starts answering — no redeploy, no restart. + +[[chains]] +id = "planck" +display_name = "Planck Testnet" +mainnet = false +# Loopback: the node runs on this host. 9944 serves HTTP and WebSocket both. +rpc_url = "http://127.0.0.1:9944" +ws_url = "ws://127.0.0.1:9944" +# The feed URL is found in /tmp/env-config.js on the telemetry site — NOT the +# /feed path on the main host, which 404s (lair/quantus readme). +telemetry_url = "wss://feed-telemetry.quantus.cat/feed" +target_block_time_seconds = 6.0 +warm_start_blocks = 100800 +max_gap_fill_blocks = 5000 + +# Mainnet. Uncomment and fill in the endpoints when the chain spec is published; +# until then the observer would report it `awaiting` forever, which is honest but +# puts a permanently dead tab in the switcher. +# +# [[chains]] +# id = "quantus" +# display_name = "Quantus" +# mainnet = true +# rpc_url = "http://127.0.0.1:9945" +# ws_url = "ws://127.0.0.1:9945" +# telemetry_url = "wss://feed-telemetry.quantus.cat/feed" +# target_block_time_seconds = 6.0 diff --git a/asset/firewalld/blackbeard-api.xml b/asset/firewalld/blackbeard-api.xml new file mode 100644 index 0000000..7b62a36 --- /dev/null +++ b/asset/firewalld/blackbeard-api.xml @@ -0,0 +1,11 @@ + + + blackbeard-api + REST and WebSocket API for blackbeard.observer: the Quantus + mining leaderboard, network hashrate and live block feed. Consumed only by the + site's nginx reverse proxy, which terminates TLS; this port carries plain HTTP + and must never be reachable from the WAN. Port 25864 is derived from the + service name per architecture/port-allocations.md §3 and recorded in its + registry. + + diff --git a/asset/nginx/blackbeard-upstream.conf b/asset/nginx/blackbeard-upstream.conf new file mode 100644 index 0000000..155b6a7 --- /dev/null +++ b/asset/nginx/blackbeard-upstream.conf @@ -0,0 +1,14 @@ +# Installed to /etc/nginx/conf.d/blackbeard-upstream.conf, NOT sites-available. +# +# Both vhosts (public and mesh) proxy to the same backend, and an `upstream` +# block defined inside one of them would be a silent dependency: disabling that +# vhost — or enabling only the internal one on a host that does not serve the +# public name — breaks the other with an "upstream not found" that names the +# wrong file. Declaring it once in conf.d makes both vhosts independent. + +upstream blackbeard_api { + server bob.hanzalova.internal:25864; + # The API answers thousands of small requests and holds long WebSockets; + # without keepalive every one pays a fresh TCP handshake to the node host. + keepalive 16; +} diff --git a/asset/nginx/blackbeard.internal.conf b/asset/nginx/blackbeard.internal.conf new file mode 100644 index 0000000..9a6bbbb --- /dev/null +++ b/asset/nginx/blackbeard.internal.conf @@ -0,0 +1,60 @@ +# Mesh vhost for blackbeard.observer, on the hanzalova edge proxy. +# +# This exists because a public name does not hairpin: from inside the mesh, +# `blackbeard.observer` resolves to the site's WAN address, the packet hits the +# OPNsense LAN interface, and the connection dead-ends +# (architecture/reverse-proxies.md §2). Mesh clients use this name instead. +# +# Cert: internal `lair` CA, renewed by step@blackbeard.timer +# (architecture/internal-tls.md). Same webroot and same upstream as the public +# vhost — only the name and the certificate differ. +# +# ln -sf ../sites-available/blackbeard.internal.conf /etc/nginx/sites-enabled/ +# +# The split-horizon DNS record is a separate manual step on BOTH site routers: +# opn-cli --config ~/.opn-cli/{hanzalova,kosherinata}.yml unbound host create \ +# --hostname blackbeard --domain internal --rr A --server +# A record added to only one router NXDOMAINs everywhere else. + +server { + listen 127.0.0.1:14443 ssl proxy_protocol; + http2 on; + server_name blackbeard.internal; + + ssl_certificate /etc/nginx/tls/cert/blackbeard.internal.pem; + ssl_certificate_key /etc/nginx/tls/key/blackbeard.internal.pem; + ssl_protocols TLSv1.3; + ssl_trusted_certificate /etc/pki/ca-trust/source/anchors/root-internal.pem; + + absolute_redirect off; + + root /var/www/blackbeard.observer; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location = /index.html { + add_header Cache-Control "no-cache"; + } + + location /v1/ { + proxy_pass http://blackbeard_api; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } +} diff --git a/asset/nginx/blackbeard.observer.conf b/asset/nginx/blackbeard.observer.conf new file mode 100644 index 0000000..111a9f5 --- /dev/null +++ b/asset/nginx/blackbeard.observer.conf @@ -0,0 +1,89 @@ +# Public vhost for blackbeard.observer, on the hanzalova edge proxy. +# +# Cert: Let's Encrypt via certbot + Cloudflare DNS-01 (architecture/external-tls.md). +# Installed by script/infra-setup.sh --edge, never by CI — the runner has no +# rights to read certificate keys or reload nginx on a shared edge proxy. +# +# ln -sf ../sites-available/blackbeard.observer.conf /etc/nginx/sites-enabled/ +# +# listen 127.0.0.1:14443 + proxy_protocol, NOT 443: an nginx stream SNI router +# owns TCP 443 on this host and hands every non-passthrough name to the local +# https tier (architecture/reverse-proxies.md §5). Binding 443 here double-binds +# the port across http{} and stream{} — `nginx -t` cannot detect it, and the +# failure mode is nginx silently serving stale certificates. Copy this listen +# line from a currently ENABLED vhost; several files in sites-available have +# drifted from their enabled counterparts. + +# The `blackbeard_api` upstream is declared once in +# conf.d/blackbeard-upstream.conf so both vhosts can use it independently, and +# `$connection_upgrade` comes from the proxy's existing conf.d map — the same +# one lair/quantus's vhost relies on. Both are installed by infra-setup.sh. + +server { + listen 127.0.0.1:14443 ssl proxy_protocol; + http2 on; + server_name blackbeard.observer; + + ssl_certificate /etc/letsencrypt/live/blackbeard.observer/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/blackbeard.observer/privkey.pem; + # TLS 1.2 stays enabled here, unlike the .internal vhost: this is a public + # site and its audience is not a controlled fleet. + ssl_protocols TLSv1.2 TLSv1.3; + + # The vhost believes it is serving 127.0.0.1:14443, so any redirect it + # generates itself carries that port and is unreachable. Latent for a pure + # SPA, but one real directory under the webroot would break every URL + # without a trailing slash (reverse-proxies.md §4). + absolute_redirect off; + + root /var/www/blackbeard.observer; + index index.html; + + # The built SPA. Hashed asset filenames are immutable; index.html must not + # be cached or a deploy would leave browsers loading a bundle that no longer + # exists. + location / { + try_files $uri $uri/ /index.html; + } + + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location = /index.html { + add_header Cache-Control "no-cache"; + } + + location /v1/ { + proxy_pass http://blackbeard_api; + proxy_http_version 1.1; + + # The WebSocket upgrade. Without these three lines the socket silently + # falls back to a plain request that hangs, and the page shows + # "reconnecting" forever with nothing in any log. + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # A chain can be quiet for a minute at a time and a browser tab can sit + # open all day. The 60s default would cut every socket mid-session and + # surface as an unexplained disconnect. + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + + # Buffering an endless stream is exactly wrong: it would hold each + # message until a buffer filled, which on a quiet chain is minutes. + proxy_buffering off; + } + + # Public, read-only, entirely derived from public block headers. No auth, + # nothing to leak; documented here so the absence is deliberate rather than + # an oversight. + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy same-origin always; +} diff --git a/asset/sql/bootstrap.sql b/asset/sql/bootstrap.sql new file mode 100644 index 0000000..a77da96 --- /dev/null +++ b/asset/sql/bootstrap.sql @@ -0,0 +1,39 @@ +-- Idempotent role and database creation for blackbeard.observer. +-- +-- Run once, by an operator, on the Postgres PRIMARY only (magrathea) — +-- replication carries roles and databases to the standby. Applied by +-- script/infra-setup.sh --database. +-- +-- No password is set on the role, deliberately and permanently. Authentication +-- is mTLS: the app host's certificate CN maps to this role through a +-- pg_ident.conf drop-in, which infra-setup.sh installs on BOTH servers. A +-- standby missing that mapping locks the app out on failover +-- (architecture/generic.md §5). + +do $$ +begin + if not exists (select from pg_roles where rolname = 'blackbeard_rw') then + create role blackbeard_rw with login; + end if; + -- A read-only role for ad-hoc queries and any future reporting, so nothing + -- has to borrow the writer's credentials to look at the data. + if not exists (select from pg_roles where rolname = 'blackbeard_ro') then + create role blackbeard_ro with login; + end if; +end +$$; + +-- `create database` cannot run inside a transaction block or a DO block, so the +-- caller guards it: infra-setup.sh checks first and skips if present. + +\connect blackbeard + +-- The writer owns the schema so sqlx migrations can create tables. +alter schema public owner to blackbeard_rw; +grant usage on schema public to blackbeard_ro; + +-- Applies to tables the migrations have not created yet, so a new migration +-- does not need this file re-run. +alter default privileges for role blackbeard_rw in schema public + grant select on tables to blackbeard_ro; +grant select on all tables in schema public to blackbeard_ro; diff --git a/asset/systemd/blackbeard-api-cert-reload.service b/asset/systemd/blackbeard-api-cert-reload.service new file mode 100644 index 0000000..cf77141 --- /dev/null +++ b/asset/systemd/blackbeard-api-cert-reload.service @@ -0,0 +1,10 @@ +# Restart, not reload: the daemon holds no reloadable cert state of its own — +# sqlx reads the certificate files when it opens a connection. A restart is a +# few seconds of dropped WebSockets, which the browser reconnects from on its +# own, and it is unambiguous. +[Unit] +Description=Restart blackbeard-api after a host certificate rotation + +[Service] +Type=oneshot +ExecStart=/usr/bin/systemctl restart blackbeard-api.service diff --git a/asset/systemd/blackbeard-api-cert.path b/asset/systemd/blackbeard-api-cert.path new file mode 100644 index 0000000..8755435 --- /dev/null +++ b/asset/systemd/blackbeard-api-cert.path @@ -0,0 +1,14 @@ +# The host certificate is the credential for the mTLS Postgres connection and is +# reissued every 24 hours (architecture/generic.md §11). The daemon reads it +# when it opens a connection, so a rotation is picked up by the pool's own +# recycling within the hour — but a restart on change makes it immediate and +# removes the one window where a stale in-memory client cert could be presented. +[Unit] +Description=Watch the host certificate for blackbeard-api + +[Path] +PathChanged=/etc/pki/tls/misc/%H.pem +Unit=blackbeard-api-cert-reload.service + +[Install] +WantedBy=multi-user.target diff --git a/asset/systemd/blackbeard-api.service b/asset/systemd/blackbeard-api.service new file mode 100644 index 0000000..6780d1d --- /dev/null +++ b/asset/systemd/blackbeard-api.service @@ -0,0 +1,60 @@ +# The observer daemon: watches the configured Quantus chains, keeps the +# standings, and serves REST + WebSocket to the site's nginx. +# +# Runs beside quantus-node on the same host and reads its loopback JSON-RPC, so +# it needs no chain data of its own, no credentials for the node, and no route +# to anything but Postgres and the telemetry feed. + +[Unit] +Description=blackbeard.observer API (Quantus mining leaderboard) +Documentation=https://git.lair.cafe/Quantus-Network/blackbeard.observer +After=network-online.target +Wants=network-online.target +# Not Requires=: the node restarting must not take the site down. The daemon +# reconnects on its own and keeps serving what it already knows. +After=quantus-node.service + +[Service] +# The binary sends sd_notify(READY=1) after it binds, so systemctl restart +# returns when the port is actually accepting rather than when the process +# started. +Type=notify +User=blackbeard +Group=blackbeard +ExecStart=/usr/local/bin/blackbeard-api --config /etc/blackbeard/config.toml + +# SIGTERM drains in-flight requests and exits 0. Long-lived WebSockets are what +# this bound is for: without it, a deploy would wait for the last browser tab to +# close. +KillSignal=SIGTERM +TimeoutStopSec=20s +Restart=always +RestartSec=5s + +# Hardening per architecture/generic.md §8. Nothing here is relaxed: the daemon +# reads two sockets and one config file and writes nothing to disk. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native +RestrictNamespaces=true +ProtectClock=true +ProtectHostname=true +ProtectProc=invisible + +# ProtectSystem=strict makes everything read-only; the daemon keeps no state on +# disk, so it needs no ReadWritePaths at all. If one is ever added here, ask +# first what state left the database. +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target diff --git a/asset/systemd/blackbeard.sysusers.conf b/asset/systemd/blackbeard.sysusers.conf new file mode 100644 index 0000000..9843b6f --- /dev/null +++ b/asset/systemd/blackbeard.sysusers.conf @@ -0,0 +1,2 @@ +#Type Name ID GECOS Home directory Shell +u blackbeard - "blackbeard.observer service account" /var/lib/blackbeard /usr/sbin/nologin diff --git a/crates/blackbeard-api/Cargo.toml b/crates/blackbeard-api/Cargo.toml new file mode 100644 index 0000000..5f1f17b --- /dev/null +++ b/crates/blackbeard-api/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "blackbeard-api" +description = "REST and WebSocket daemon for blackbeard.observer." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[[bin]] +name = "blackbeard-api" +path = "src/main.rs" + +[dependencies] +blackbeard-core.workspace = true +blackbeard-data.workspace = true +blackbeard-entities.workspace = true + +anyhow.workspace = true +axum.workspace = true +chrono.workspace = true +clap.workspace = true +figment.workspace = true +futures-util.workspace = true +primitive-types.workspace = true +serde.workspace = true +sqlx.workspace = true +serde_json.workspace = true +tokio.workspace = true +tower-http.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/crates/blackbeard-api/src/config.rs b/crates/blackbeard-api/src/config.rs new file mode 100644 index 0000000..0b8e3bc --- /dev/null +++ b/crates/blackbeard-api/src/config.rs @@ -0,0 +1,374 @@ +//! Configuration: file, then environment, then CLI. +//! +//! Layered with figment in that order, so a deployed host reads +//! `/etc/blackbeard/config.toml` and an operator debugging on that host can +//! override one value with an environment variable without editing it. +//! +//! **No secret belongs in this file.** The database connection is mTLS with the +//! host's own certificate as the credential (`architecture/generic.md` §5), so +//! there is no password to configure — and there is deliberately nowhere to put +//! one. Everything here is infrastructure truth: hostnames, ports and paths. + +use std::net::SocketAddr; +use std::path::PathBuf; + +use blackbeard_entities::ChainId; +use serde::{Deserialize, Serialize}; + +/// The whole configuration. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + /// HTTP listener. + pub server: ServerConfig, + /// Postgres connection. + pub database: DatabaseConfig, + /// Every chain to watch. A chain may be listed before it launches. + #[serde(default)] + pub chains: Vec, +} + +/// The HTTP listener and what it will accept. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ServerConfig { + /// Address to bind. + /// + /// Bind the host's mesh address or loopback, never `0.0.0.0`, on a host + /// that carries a public address: the fleet uses a single firewalld default + /// zone (`generic.md` §9), so a wildcard bind plus the named service would + /// publish this on every address the host has. TLS terminates at the site's + /// nginx (`reverse-proxies.md`), which is the only thing that should reach + /// this port. + pub listen: SocketAddr, + + /// Origins allowed to call the API from a browser. + /// + /// The data here is public — it is derived from public block headers — so + /// this is not a security boundary. It exists so that a copy of the + /// frontend served from somewhere else does not silently depend on this + /// backend, and so the origins the site actually uses are written down. + #[serde(default)] + pub allowed_origins: Vec, + + /// Blocks kept in the live ticker and replayed to a newly connected + /// browser. + #[serde(default = "default_ticker_blocks")] + pub ticker_blocks: usize, + + /// Seconds between leaderboard recomputes. + /// + /// Standings are recomputed on a timer rather than on every block: at a 6 s + /// target most blocks change nothing above the tenth row, and a week window + /// is a hundred thousand entries to re-tally. Only windows with at least + /// one subscriber are computed at all. + #[serde(default = "default_leaderboard_refresh")] + pub leaderboard_refresh_seconds: u64, +} + +/// Postgres, over mTLS. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DatabaseConfig { + /// Server hostname, as it appears in the server's certificate. + pub host: String, + /// Server port. + #[serde(default = "default_pg_port")] + pub port: u16, + /// Database name. + pub database: String, + /// Role to connect as; resolved from the client certificate CN by + /// `pg_ident.conf` on the server. + pub username: String, + /// Internal root CA bundle. + #[serde(default = "default_root_cert")] + pub root_cert: PathBuf, + /// This host's certificate — the credential. + pub client_cert: PathBuf, + /// This host's private key. The service account is granted read access via + /// `setfacl` at deploy time; it is not world-readable. + pub client_key: PathBuf, + /// Pool size. + #[serde(default = "default_max_connections")] + pub max_connections: u32, +} + +/// One chain to watch. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ChainConfig { + /// Operator name, used in routes and the WebSocket protocol. + pub id: ChainId, + /// Human-facing name. + pub display_name: String, + /// True for the production network. + #[serde(default)] + pub mainnet: bool, + /// HTTP JSON-RPC endpoint, e.g. `http://127.0.0.1:9944`. + pub rpc_url: String, + /// WebSocket JSON-RPC endpoint for the head subscription. Usually the same + /// host and port as `rpc_url` — `9944` serves both. + pub ws_url: String, + /// substrate-telemetry feed. Empty disables telemetry for this chain, which + /// costs node counts and miner names and nothing else. + #[serde(default)] + pub telemetry_url: String, + /// The chain's target seconds per block. Used as the hashrate denominator + /// whenever no measured interval is trustworthy. + #[serde(default = "default_target_block_time")] + pub target_block_time_seconds: f64, + /// Blocks to load from the database at startup, seeding the rolling window. + /// + /// Defaults to the longest selectable window, so every window is populated + /// on the first request rather than filling in over the following week. + #[serde(default = "default_warm_start_blocks")] + pub warm_start_blocks: usize, + /// Cap on how many blocks a single gap-fill will fetch from the node. + /// + /// A long outage must not have the observer hammer the node for hours + /// trying to catch up on history it can also simply not have. The gap is + /// closed from the newest end, so the tip is correct immediately and only + /// the oldest part of the gap is forgone. + #[serde(default = "default_max_gap_fill")] + pub max_gap_fill_blocks: u64, +} + +fn default_pg_port() -> u16 { + 5432 +} +fn default_root_cert() -> PathBuf { + PathBuf::from("/etc/pki/ca-trust/source/anchors/root-internal.pem") +} +fn default_max_connections() -> u32 { + 8 +} +fn default_target_block_time() -> f64 { + 6.0 +} +fn default_ticker_blocks() -> usize { + 40 +} +fn default_leaderboard_refresh() -> u64 { + 5 +} +fn default_warm_start_blocks() -> usize { + blackbeard_entities::Window::Week.blocks() as usize +} +fn default_max_gap_fill() -> u64 { + 5_000 +} + +impl Config { + /// Load from `path`, then overlay `BLACKBEARD_*` environment variables. + /// + /// Nested keys use `__` as the separator, so + /// `BLACKBEARD_SERVER__LISTEN=127.0.0.1:25864` overrides `server.listen`. + pub fn load(path: &std::path::Path) -> anyhow::Result { + use figment::providers::{Env, Format, Toml}; + let config: Config = figment::Figment::new() + .merge(Toml::file(path)) + .merge( + Env::prefixed("BLACKBEARD_") + .split("__") + // Two variables share the prefix without being config keys: + // `BLACKBEARD_CONFIG` names this very file, and + // `BLACKBEARD_DEV_DATABASE_URL` is the development-only + // database escape hatch. Without this they are read as + // unknown keys and — because the structs deny unknown + // fields — refuse to start. + .ignore(&["CONFIG", "DEV_DATABASE_URL"]), + ) + .extract()?; + config.validate()?; + Ok(config) + } + + /// Reject a configuration that would start but never work. + /// + /// Failing at startup with a clear message beats a daemon that comes up, + /// reports healthy, and serves an empty site — which is what a duplicate + /// chain id or a missing certificate would otherwise produce. + fn validate(&self) -> anyhow::Result<()> { + if self.chains.is_empty() { + anyhow::bail!("no chains configured — the observer would have nothing to observe"); + } + let mut seen = std::collections::HashSet::new(); + for chain in &self.chains { + if !seen.insert(&chain.id) { + anyhow::bail!( + "chain id `{}` is configured more than once; ids are the routing key and must be unique", + chain.id + ); + } + if chain.target_block_time_seconds <= 0.0 { + anyhow::bail!( + "chain `{}` has a target block time of {}; hashrate is difficulty divided by it", + chain.id, + chain.target_block_time_seconds + ); + } + } + for (label, path) in [ + ("database.root_cert", &self.database.root_cert), + ("database.client_cert", &self.database.client_cert), + ("database.client_key", &self.database.client_key), + ] { + if !path.exists() { + anyhow::bail!( + "{label} points at {} which does not exist; the database connection is mTLS \ + and this certificate is the credential", + path.display() + ); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(dir: &std::path::Path, name: &str, body: &str) -> PathBuf { + let p = dir.join(name); + std::fs::write(&p, body).unwrap(); + p + } + + /// A config whose certificate paths point at real (empty) files, so + /// validation exercises everything except the existence check. + fn config_toml(dir: &std::path::Path, chains: &str) -> String { + for f in ["root.pem", "cert.pem", "key.pem"] { + std::fs::write(dir.join(f), "").unwrap(); + } + format!( + r#" +[server] +listen = "127.0.0.1:25864" + +[database] +host = "magrathea.kosherinata.internal" +database = "blackbeard" +username = "blackbeard_rw" +root_cert = "{d}/root.pem" +client_cert = "{d}/cert.pem" +client_key = "{d}/key.pem" +{chains} +"#, + d = dir.display() + ) + } + + const PLANCK: &str = r#" +[[chains]] +id = "planck" +display_name = "Planck Testnet" +rpc_url = "http://127.0.0.1:9944" +ws_url = "ws://127.0.0.1:9944" +"#; + + #[test] + fn a_minimal_config_fills_in_the_documented_defaults() { + let dir = tempdir(); + let p = write(&dir, "config.toml", &config_toml(&dir, PLANCK)); + let c = Config::load(&p).unwrap(); + assert_eq!(c.database.port, 5432); + assert_eq!(c.chains[0].target_block_time_seconds, 6.0); + assert_eq!(c.chains[0].warm_start_blocks, 100_800); + assert!(!c.chains[0].mainnet); + assert_eq!(c.server.ticker_blocks, 40); + } + + #[test] + fn a_duplicate_chain_id_is_refused_at_startup() { + // Both would answer at /v1/chains/planck; whichever won would be an + // accident of ordering, and the site would look fine while showing one + // chain's numbers under the other's name. + let dir = tempdir(); + let body = config_toml(&dir, &format!("{PLANCK}{PLANCK}")); + let p = write(&dir, "config.toml", &body); + let err = Config::load(&p).unwrap_err().to_string(); + assert!(err.contains("more than once"), "{err}"); + } + + #[test] + fn a_config_with_no_chains_is_refused() { + let dir = tempdir(); + let p = write(&dir, "config.toml", &config_toml(&dir, "")); + assert!( + Config::load(&p) + .unwrap_err() + .to_string() + .contains("no chains") + ); + } + + #[test] + fn a_missing_certificate_is_refused_rather_than_discovered_later() { + let dir = tempdir(); + let body = config_toml(&dir, PLANCK).replace("/cert.pem", "/absent.pem"); + let p = write(&dir, "config.toml", &body); + let err = Config::load(&p).unwrap_err().to_string(); + assert!(err.contains("client_cert"), "{err}"); + } + + #[test] + fn a_zero_target_block_time_is_refused() { + // It is the hashrate denominator; zero would make every figure on the + // site either zero or infinite. + let dir = tempdir(); + let body = config_toml(&dir, &format!("{PLANCK}target_block_time_seconds = 0.0\n")); + let p = write(&dir, "config.toml", &body); + assert!( + Config::load(&p) + .unwrap_err() + .to_string() + .contains("target block time") + ); + } + + #[test] + fn the_daemons_own_environment_variables_are_not_read_as_config_keys() { + // BLACKBEARD_CONFIG names the config file and + // BLACKBEARD_DEV_DATABASE_URL is the dev database hatch. Both share the + // prefix; neither is a config key, and with `deny_unknown_fields` either + // one would otherwise stop the daemon from starting at all. + let dir = tempdir(); + let p = write(&dir, "config.toml", &config_toml(&dir, PLANCK)); + // SAFETY: single-threaded test process section; no other thread reads + // the environment concurrently. + unsafe { + std::env::set_var("BLACKBEARD_CONFIG", p.to_str().unwrap()); + std::env::set_var("BLACKBEARD_DEV_DATABASE_URL", "postgres://x/y"); + } + let loaded = Config::load(&p); + unsafe { + std::env::remove_var("BLACKBEARD_CONFIG"); + std::env::remove_var("BLACKBEARD_DEV_DATABASE_URL"); + } + assert!(loaded.is_ok(), "{:?}", loaded.err()); + } + + #[test] + fn an_unknown_key_is_refused_rather_than_ignored() { + // A typo in a deployed config must fail loudly. Silently ignoring + // `listen_address` would leave the daemon bound somewhere unexpected + // with nothing in the journal to say why. + let dir = tempdir(); + let body = config_toml(&dir, PLANCK).replace("[server]", "[server]\nlisten_addr = \"x\""); + let p = write(&dir, "config.toml", &body); + assert!(Config::load(&p).is_err()); + } + + /// A unique scratch directory. Avoids a `tempfile` dependency for the one + /// thing these tests need. + fn tempdir() -> PathBuf { + let base = std::env::temp_dir().join(format!( + "blackbeard-config-test-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&base).unwrap(); + base + } +} diff --git a/crates/blackbeard-api/src/ingest.rs b/crates/blackbeard-api/src/ingest.rs new file mode 100644 index 0000000..5c14a74 --- /dev/null +++ b/crates/blackbeard-api/src/ingest.rs @@ -0,0 +1,522 @@ +//! Watching a chain. +//! +//! Three tasks per chain, deliberately separated because they fail +//! independently and at different rates: +//! +//! 1. **[`ingest`]** — consumes the head stream, fills gaps, decodes authors, +//! writes blocks, pushes the ticker. +//! 2. **[`poll`]** — difficulty, sync state and chain properties on a timer. +//! Difficulty changes once per block but is cheap to over-read and expensive +//! to miss; sync state is what decides whether the hashrate figure is real. +//! 3. **[`housekeeping`]** — resolves telemetry attributions once the feed has +//! had time to report, recomputes watched leaderboards, and persists held +//! names. +//! +//! A node that is down affects all three; a telemetry feed that is down affects +//! only the third, and only its naming. + +use std::sync::Arc; +use std::time::Duration; + +use blackbeard_core::digest; +use blackbeard_core::window::Observed; +use blackbeard_data::rpc::{self, Header}; +use blackbeard_data::store::{BlockRecord, ChainRecord, Store}; +use blackbeard_data::telemetry::TelemetryFeed; +use blackbeard_entities::{ChainStatus, RecentBlock, ServerMessage}; +use chrono::{DateTime, TimeZone, Utc}; +use tokio::sync::mpsc; + +use crate::state::{ChainRuntime, PendingAttribution}; + +/// Head notifications buffered before the subscription blocks. +/// +/// Small on purpose: falling behind the tip by more than this means the ingest +/// loop is not keeping up, and the gap-fill path recovers correctly from a +/// dropped head while an ever-growing queue would only hide the problem. +const HEAD_BUFFER: usize = 64; + +/// How long to wait for the telemetry feed to report a block before deciding +/// who reported it first. +/// +/// Reports arrive from nodes all over the network over a couple of seconds; +/// judging at the moment the block is seen would count only the fastest-peered +/// nodes and systematically favour them. +const ATTRIBUTION_SETTLE: Duration = Duration::from_secs(8); + +/// Interval between difficulty and sync-state polls. +const POLL_INTERVAL: Duration = Duration::from_secs(4); + +/// Interval between attribution persists. +const PERSIST_INTERVAL: Duration = Duration::from_secs(30); + +/// Start every task for one chain. +pub fn spawn(chain: Arc, store: Store, ticker_blocks: usize, refresh: Duration) { + let (tx, rx) = mpsc::channel(HEAD_BUFFER); + + tokio::spawn(rpc::subscribe_new_heads(chain.config.ws_url.clone(), tx)); + tokio::spawn(ingest(Arc::clone(&chain), store.clone(), rx, ticker_blocks)); + tokio::spawn(poll(Arc::clone(&chain), store.clone())); + tokio::spawn(housekeeping(chain, store, refresh)); +} + +/// Load what the database already knows, so a restart does not serve an empty +/// site while it re-watches a week of blocks. +pub async fn warm_start(chain: &Arc, store: &Store) { + let id = chain.id().clone(); + + match store + .recent_blocks(&id, chain.config.warm_start_blocks as i64) + .await + { + Ok(blocks) => { + let count = blocks.len(); + let mut inner = chain.write(); + for block in blocks { + inner.height = Some(inner.height.unwrap_or(0).max(block.height)); + // `at_tip: false` — these were observed by a previous process, + // so replaying them now must not contribute timing samples. The + // elapsed time between two database rows is not a block + // interval. + inner.window.push(block, false); + } + tracing::info!(chain = %id, blocks = count, "warm start: window restored"); + } + Err(e) => tracing::warn!(chain = %id, error = %e, "warm start: no window restored"), + } + + match store.load_attributions(&id).await { + Ok(held) => { + let count = held.len(); + let mut inner = chain.write(); + for a in held { + if let (blackbeard_core::attribution::NodeKey::Peer(peer), Some(name)) = + (&a.key, &a.name) + { + chain.telemetry.seed_name(peer.clone(), name.clone()); + } + inner + .attributor + .restore_held(a.miner, a.key, a.attempts, a.attributed); + } + tracing::info!(chain = %id, names = count, "warm start: attributions restored"); + } + Err(e) => tracing::warn!(chain = %id, error = %e, "warm start: no attributions restored"), + } +} + +/// Consume the head stream. +async fn ingest( + chain: Arc, + store: Store, + mut heads: mpsc::Receiver
, + ticker_blocks: usize, +) { + while let Some(header) = heads.recv().await { + let Some(height) = header.height() else { + tracing::warn!(chain = %chain.id(), number = %header.number, "header carried an undecodable block number"); + continue; + }; + + // `chain_subscribeNewHeads` reports the best head and skips + // intermediates when several import at once, so the stream is a + // liveness signal rather than a complete record. Anything missed is + // fetched here — otherwise the leaderboard would quietly under-count + // exactly the miners who won blocks during a burst. + let last = chain.read().height; + if let Some(last) = last + && height > last + 1 + { + let gap_start = (last + 1).max(height.saturating_sub(chain.config.max_gap_fill_blocks)); + fill_gap(&chain, &store, gap_start, height).await; + } + + record(&chain, &store, &header, height, ticker_blocks).await; + } + tracing::warn!(chain = %chain.id(), "head stream ended; ingest stopping"); +} + +/// Fetch and record the blocks between two heads. +/// +/// Best effort: a node that has pruned, or that fails midway, costs the window +/// those blocks and nothing more. Ingest must not stall on history. +async fn fill_gap(chain: &Arc, store: &Store, from: u64, to: u64) { + tracing::info!(chain = %chain.id(), from, to, "filling a gap in the head stream"); + let mut batch = Vec::new(); + let mut observed = Vec::new(); + for height in from..to { + let Ok(Some(hash)) = chain.rpc.block_hash(height).await else { + continue; + }; + let Ok(Some(header)) = chain.rpc.header(Some(&hash)).await else { + continue; + }; + let Some(miner) = digest::author_preimage(&header.digest.logs) else { + continue; + }; + let authored_at = chain + .rpc + .block_timestamp_ms(&hash) + .await + .ok() + .flatten() + .and_then(from_millis); + let now = Utc::now(); + batch.push(BlockRecord { + chain: chain.id().clone(), + height, + hash, + miner: miner.clone(), + authored_at, + observed_at: now, + difficulty: chain + .read() + .difficulty + .map(|d| blackbeard_core::hashrate::u512_to_dec(d).0), + }); + observed.push(Observed { + height, + miner, + observed_at: now, + }); + } + + if let Err(e) = store.record_blocks(&batch).await { + tracing::warn!(chain = %chain.id(), error = %e, "gap blocks were not persisted"); + } + let mut inner = chain.write(); + for o in observed { + // Not at the tip: these are being caught up on, and their observation + // times are all "now" rather than when they were authored. + inner.window.push(o, false); + } +} + +/// Record one head. +async fn record( + chain: &Arc, + store: &Store, + header: &Header, + height: u64, + ticker_blocks: usize, +) { + let Some(miner) = digest::author_preimage(&header.digest.logs) else { + // Genesis, or a header shape we do not decode. Not an error: the block + // simply contributes nothing to the standings. + tracing::debug!(chain = %chain.id(), height, "header carried no pow_ author digest"); + return; + }; + + let hash = match chain.rpc.block_hash(height).await { + Ok(Some(h)) => h, + Ok(None) => return, + Err(e) => { + tracing::warn!(chain = %chain.id(), height, error = %e, "could not resolve the block hash"); + return; + } + }; + let authored_at = chain + .rpc + .block_timestamp_ms(&hash) + .await + .ok() + .flatten() + .and_then(from_millis); + + let observed_at = Utc::now(); + let difficulty = chain + .read() + .difficulty + .map(|d| blackbeard_core::hashrate::u512_to_dec(d).0); + + if let Err(e) = store + .record_blocks(&[BlockRecord { + chain: chain.id().clone(), + height, + hash: hash.clone(), + miner: miner.clone(), + authored_at, + observed_at, + difficulty, + }]) + .await + { + // The window is in memory and stays correct; only history is lost. A + // database blip must not stop the site from being live. + tracing::warn!(chain = %chain.id(), height, error = %e, "block not persisted"); + } + + let block = { + let mut inner = chain.write(); + let gap_seconds = inner + .last_block_at + .map(|prev| (observed_at - prev).as_seconds_f64()); + inner.window.push( + Observed { + height, + miner: miner.clone(), + observed_at, + }, + true, + ); + inner.height = Some(height); + inner.last_block_at = Some(observed_at); + + let display = inner + .attributor + .attribute(&miner, |key| chain.telemetry.name_of(key)) + .display; + let block = RecentBlock { + height, + miner: miner.clone(), + display, + observed_at, + gap_seconds, + }; + inner.ticker.push_back(block.clone()); + while inner.ticker.len() > ticker_blocks { + inner.ticker.pop_front(); + } + block + }; + + chain.broadcast( + &ServerMessage::Block { + chain: chain.id().clone(), + block, + }, + None, + ); + + // Queue the attribution join. The feed needs a few seconds to collect + // reports from around the network before "who was first" means anything. + if chain.telemetry.connected() { + let mut pending = chain + .pending_attributions + .lock() + .unwrap_or_else(|e| e.into_inner()); + pending.push_back(PendingAttribution { + hash, + miner, + due: tokio::time::Instant::now() + ATTRIBUTION_SETTLE, + }); + } +} + +/// Poll the things that are not pushed: difficulty, sync state, properties. +async fn poll(chain: Arc, store: Store) { + let mut ticker = tokio::time::interval(POLL_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut telemetry_started = false; + + loop { + ticker.tick().await; + let before = chain.read().status; + + let health = match chain.rpc.health().await { + Ok(h) => h, + Err(e) => { + let status = if before == ChainStatus::Awaiting { + // Never answered. Expected for a chain configured before it + // launches, so it stays "awaiting" rather than becoming an + // alarm nobody can act on. + ChainStatus::Awaiting + } else { + ChainStatus::Unreachable + }; + if status != before { + tracing::warn!(chain = %chain.id(), error = %e, "chain became unreachable"); + chain.write().status = status; + announce_status(&chain); + } + continue; + } + }; + + { + let mut inner = chain.write(); + inner.syncing = health.is_syncing; + inner.status = if health.is_syncing { + ChainStatus::Syncing + } else { + ChainStatus::Live + }; + } + + // Discovered once, then never again: genesis and token properties do + // not change for the life of a chain. + if chain.read().genesis.is_none() + && let Ok(Some(genesis)) = chain.rpc.genesis().await + { + chain.write().genesis = Some(genesis.clone()); + if !chain.config.telemetry_url.is_empty() && !telemetry_started { + telemetry_started = true; + tokio::spawn(TelemetryFeed::run( + chain.telemetry.clone(), + chain.config.telemetry_url.clone(), + genesis, + )); + } + } + if chain.read().token_symbol.is_none() + && let Ok(props) = chain.rpc.properties().await + { + let mut inner = chain.write(); + inner.token_symbol = props.token_symbol; + inner.token_decimals = props.token_decimals; + } + if chain.read().max_difficulty.is_none() + && let Ok(max) = chain.rpc.max_difficulty().await + { + chain.write().max_difficulty = Some(max); + } + + match chain.rpc.difficulty().await { + Ok(d) => chain.write().difficulty = Some(d), + Err(e) => tracing::debug!(chain = %chain.id(), error = %e, "difficulty unavailable"), + } + + if chain.read().status != before { + announce_status(&chain); + } + + let record = { + let inner = chain.read(); + ChainRecord { + chain: chain.id().clone(), + display_name: chain.config.display_name.clone(), + mainnet: chain.config.mainnet, + genesis: inner.genesis.clone(), + token_symbol: inner.token_symbol.clone(), + token_decimals: inner.token_decimals.map(i16::from), + target_block_time: chain.config.target_block_time_seconds, + } + }; + if let Err(e) = store.upsert_chain(&record).await { + tracing::warn!(chain = %chain.id(), error = %e, "chain row not updated"); + } + + chain.broadcast( + &ServerMessage::Summary { + chain: chain.id().clone(), + summary: chain.summary(), + }, + None, + ); + } +} + +fn announce_status(chain: &Arc) { + chain.broadcast( + &ServerMessage::ChainStatus { + chain: chain.id().clone(), + info: chain.info(), + }, + None, + ); +} + +/// Resolve attributions, recompute watched standings, persist held names. +async fn housekeeping(chain: Arc, store: Store, refresh: Duration) { + let mut attribution = tokio::time::interval(Duration::from_secs(1)); + let mut leaderboard = tokio::time::interval(refresh); + let mut persist = tokio::time::interval(PERSIST_INTERVAL); + for t in [&mut attribution, &mut leaderboard, &mut persist] { + t.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + } + + loop { + tokio::select! { + _ = attribution.tick() => resolve_attributions(&chain), + _ = leaderboard.tick() => { + for window in chain.watched_windows() { + let (rows, changed) = chain.recompute_leaderboard(window); + if changed { + chain.broadcast( + &ServerMessage::Leaderboard { + chain: chain.id().clone(), + window, + rows, + }, + Some(window), + ); + } + } + } + _ = persist.tick() => { + let held: Vec<_> = { + let inner = chain.read(); + inner + .attributor + .held_names() + .into_iter() + .map(|(miner, key, attempts, attributed)| { + let name = chain.telemetry.name_of(&key); + (miner, key, attempts, attributed, name) + }) + .collect() + }; + if let Err(e) = store.save_attributions(chain.id(), &held).await { + tracing::warn!(chain = %chain.id(), error = %e, "attributions not persisted"); + } + } + } + } +} + +/// Join settled blocks with the feed's first-import reports. +fn resolve_attributions(chain: &Arc) { + let now = tokio::time::Instant::now(); + let due: Vec = { + let mut pending = chain + .pending_attributions + .lock() + .unwrap_or_else(|e| e.into_inner()); + let mut due = Vec::new(); + while pending.front().is_some_and(|p| p.due <= now) { + due.extend(pending.pop_front()); + } + due + }; + if due.is_empty() { + return; + } + let mut inner = chain.write(); + for p in due { + inner + .attributor + .observe(&p.miner, chain.telemetry.first_import(&p.hash)); + } +} + +/// Milliseconds since the epoch as a UTC timestamp. +/// +/// `None` for a value the calendar cannot represent — a block whose timestamp +/// inherent decoded to nonsense loses its authored time and nothing else. +fn from_millis(ms: u64) -> Option> { + // `as i64` would wrap rather than refuse: a misdecoded compact integer of + // u64::MAX becomes -1, and the block is silently dated to 1969 instead of + // being declined. + Utc.timestamp_millis_opt(i64::try_from(ms).ok()?).single() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_plausible_timestamp_converts() { + let t = from_millis(1_757_000_000_000).unwrap(); + assert_eq!(t.timestamp(), 1_757_000_000); + } + + #[test] + fn an_impossible_timestamp_is_declined_rather_than_panicking() { + // A misdecoded compact integer can produce an enormous value; it must + // cost the block its authored time, not silently become a 1969 date. + assert_eq!(from_millis(u64::MAX), None); + assert_eq!(from_millis(i64::MAX as u64 + 1), None); + // The epoch itself is representable and must not be swept up. + assert_eq!(from_millis(0).map(|t| t.timestamp()), Some(0)); + } +} diff --git a/crates/blackbeard-api/src/main.rs b/crates/blackbeard-api/src/main.rs new file mode 100644 index 0000000..9ee73ca --- /dev/null +++ b/crates/blackbeard-api/src/main.rs @@ -0,0 +1,237 @@ +//! `blackbeard-api` — the observer daemon behind . +//! +//! Watches one or more Quantus chains, decodes the author of every block from +//! its header, keeps the standings, and pushes changes to connected browsers +//! over a WebSocket. +//! +//! Runs under systemd as a dedicated non-root user (`architecture/generic.md` +//! §8). It does not daemonise, logs to stdout for journald, and exits 0 on +//! `SIGTERM` after draining in-flight requests. + +mod config; +mod ingest; +mod routes; +mod state; +mod ws; + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context; +use blackbeard_data::rpc::RpcClient; +use blackbeard_data::store::{Store, StoreConfig}; +use clap::Parser; + +use crate::state::{AppState, ChainRuntime}; + +/// How long an RPC call may take before it is abandoned. +/// +/// The node is on the same host over loopback; anything slower than this is a +/// node in trouble, and waiting longer only makes the ingest loop fall further +/// behind the tip. +const RPC_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, Parser)] +#[command(name = "blackbeard-api", version, about)] +struct Args { + /// Configuration file. + #[arg( + long, + default_value = "/etc/blackbeard/config.toml", + env = "BLACKBEARD_CONFIG" + )] + config: PathBuf, + + /// Check the configuration and exit without binding anything. + /// + /// What the deploy runs before restarting the service, so a bad config + /// fails the deploy rather than the daemon. + #[arg(long)] + check: bool, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + init_tracing(); + + let config = config::Config::load(&args.config) + .with_context(|| format!("reading {}", args.config.display()))?; + + if args.check { + println!( + "{} is valid: {} chain(s), listening on {}", + args.config.display(), + config.chains.len(), + config.server.listen + ); + return Ok(()); + } + + tracing::info!( + version = env!("CARGO_PKG_VERSION"), + chains = config.chains.len(), + "blackbeard-api starting" + ); + + let store = connect_store(&config).await?; + tracing::info!(host = %config.database.host, "postgres connected, migrations applied"); + + let mut chains = Vec::new(); + let mut by_id = std::collections::HashMap::new(); + for chain_config in &config.chains { + let rpc = RpcClient::new(chain_config.rpc_url.clone(), RPC_TIMEOUT)?; + let runtime = Arc::new(ChainRuntime::new(chain_config.clone(), rpc)); + // Before any task starts, so the first browser to connect sees a + // populated site rather than one filling in over the next week. + ingest::warm_start(&runtime, &store).await; + ingest::spawn( + Arc::clone(&runtime), + store.clone(), + config.server.ticker_blocks, + Duration::from_secs(config.server.leaderboard_refresh_seconds), + ); + by_id.insert(runtime.id().clone(), Arc::clone(&runtime)); + chains.push(runtime); + } + + let state = AppState { + chains: Arc::new(chains), + by_id: Arc::new(by_id), + store, + started: chrono::Utc::now(), + leaderboard_max_age: Duration::from_secs(config.server.leaderboard_refresh_seconds), + }; + + let app = routes::router(state, &config.server.allowed_origins); + let listener = tokio::net::TcpListener::bind(config.server.listen) + .await + .with_context(|| { + format!( + "binding {} (SELinux needs this port labelled: semanage port -a -t http_port_t -p tcp {})", + config.server.listen, + config.server.listen.port() + ) + })?; + tracing::info!(listen = %config.server.listen, "serving"); + + // systemd holds `systemctl restart` open until this arrives. A Type=notify + // unit whose daemon never sends it blocks until TimeoutStartSec and then + // reports a failure for a service that is running perfectly. + notify_systemd_ready(); + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .context("serving")?; + + tracing::info!("drained, exiting"); + Ok(()) +} + +/// Connect to Postgres. +/// +/// Production is mTLS with the host's own certificate and no password, per +/// `architecture/generic.md` §5 — there is deliberately nowhere in the config +/// file to put a password. +/// +/// `BLACKBEARD_DEV_DATABASE_URL` is the development escape hatch: a plain +/// connection string for a throwaway local Postgres, so `cargo run` works on a +/// workstation that has no `pg_ident` mapping on the fleet's cluster. It is an +/// environment variable rather than a config key on purpose — the systemd unit +/// never sets it, so it cannot be reached by editing a deployed config, and +/// taking it logs loudly enough that a production host using it is obvious in +/// the journal. +async fn connect_store(config: &config::Config) -> anyhow::Result { + if let Ok(url) = std::env::var("BLACKBEARD_DEV_DATABASE_URL") { + tracing::warn!( + "BLACKBEARD_DEV_DATABASE_URL is set: connecting WITHOUT mTLS. \ + This is a development-only path and must never be used in production." + ); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(config.database.max_connections) + .connect(&url) + .await + .context("connecting to the development database")?; + let store = Store::from_pool(pool); + store.migrate().await.context("applying migrations")?; + return Ok(store); + } + + let store = Store::connect(&StoreConfig { + host: config.database.host.clone(), + port: config.database.port, + database: config.database.database.clone(), + username: config.database.username.clone(), + root_cert: config.database.root_cert.clone(), + client_cert: config.database.client_cert.clone(), + client_key: config.database.client_key.clone(), + max_connections: config.database.max_connections, + }) + .await + .context("connecting to postgres (mTLS: check the host certificate and pg_ident mapping)")?; + Ok(store) +} + +/// JSON to journald, human-readable to a terminal. +/// +/// `JOURNAL_STREAM` is set by systemd for any unit whose stdout it captures, so +/// it is the honest test for "is a log aggregator reading this" — more so than +/// a TTY check, which is also false when output is piped to a file. +fn init_tracing() { + use tracing_subscriber::{EnvFilter, fmt}; + + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,blackbeard_api=info,blackbeard_data=info")); + + if std::env::var_os("JOURNAL_STREAM").is_some() { + fmt().json().with_env_filter(filter).init(); + } else { + fmt().with_env_filter(filter).init(); + } +} + +/// Tell systemd the service is up, if it asked to be told. +/// +/// Implemented directly rather than through a crate: the protocol is one +/// datagram to the socket named in `NOTIFY_SOCKET`, and a dependency for that +/// is a dependency to keep patched for the life of the project. +/// +/// A leading `@` means the Linux abstract namespace, whose addresses start with +/// a NUL byte — the one detail that makes a hand-rolled implementation fail +/// silently if missed. +fn notify_systemd_ready() { + let Some(path) = std::env::var_os("NOTIFY_SOCKET") else { + return; + }; + let path = std::path::PathBuf::from(path); + let path = match path.to_str() { + Some(p) if p.starts_with('@') => PathBuf::from(format!("\0{}", &p[1..])), + _ => path, + }; + match std::os::unix::net::UnixDatagram::unbound() + .and_then(|socket| socket.send_to(b"READY=1", &path)) + { + Ok(_) => tracing::debug!("notified systemd"), + // Not fatal: a daemon run by hand has no notify socket, and one that + // cannot reach it is still serving. + Err(e) => tracing::warn!(error = %e, "could not notify systemd"), + } +} + +/// Resolve on `SIGTERM` (systemd) or `SIGINT` (a terminal). +async fn shutdown_signal() { + let mut term = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(s) => s, + Err(e) => { + tracing::error!(error = %e, "cannot listen for SIGTERM; shutdown will not be graceful"); + std::future::pending::<()>().await; + return; + } + }; + tokio::select! { + _ = term.recv() => tracing::info!("SIGTERM received, draining"), + _ = tokio::signal::ctrl_c() => tracing::info!("interrupted, draining"), + } +} diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs new file mode 100644 index 0000000..12a2728 --- /dev/null +++ b/crates/blackbeard-api/src/routes.rs @@ -0,0 +1,349 @@ +//! The REST surface. +//! +//! The WebSocket is the live path; these endpoints exist for the first paint, +//! for anything that is a query rather than a stream (one miner's history), and +//! so the whole thing is reachable with `curl`. Everything here is derived from +//! public block headers, so nothing is authenticated and nothing is secret. +//! +//! Versioned under `/v1/` from the first commit — retrofitting a version prefix +//! after clients exist is the expensive kind of change. + +use std::time::Duration; + +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderValue, Method, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::{Json, Router}; +use blackbeard_entities::{ + ApiError, BigUintDec, ChainInfo, ChainSummary, LeaderboardRow, MinerDetail, MinerId, + MinerSeriesPoint, RecentBlock, Window, +}; +use serde::{Deserialize, Serialize}; +use tower_http::compression::CompressionLayer; +use tower_http::cors::CorsLayer; + +use crate::state::AppState; + +/// Build the router. +pub fn router(state: AppState, allowed_origins: &[String]) -> Router { + let cors = allowed_origins + .iter() + .filter_map(|o| HeaderValue::from_str(o).ok()) + .fold( + CorsLayer::new().allow_methods([Method::GET]), + |layer, origin| layer.allow_origin(origin), + ); + + Router::new() + .route("/v1/healthz", get(healthz)) + .route("/v1/chains", get(chains)) + .route("/v1/chains/{chain}/summary", get(summary)) + .route("/v1/chains/{chain}/leaderboard", get(leaderboard)) + .route("/v1/chains/{chain}/blocks", get(blocks)) + .route("/v1/chains/{chain}/miners/{miner}", get(miner)) + .route("/v1/ws", get(crate::ws::handler)) + // Leaderboards are repetitive JSON and compress to a fraction of their + // size; the snapshot a browser fetches on first paint is the largest + // single response this API serves. + .layer(CompressionLayer::new()) + .layer(cors) + .with_state(state) +} + +/// An error with the status code to render it under. +struct Failure(StatusCode, ApiError); + +impl IntoResponse for Failure { + fn into_response(self) -> Response { + (self.0, Json(self.1)).into_response() + } +} + +fn unknown_chain(id: &str) -> Failure { + Failure( + StatusCode::NOT_FOUND, + ApiError::new( + "unknown_chain", + format!("no chain named `{id}` is configured"), + ), + ) +} + +/// `GET /v1/healthz` +#[derive(Serialize)] +struct HealthResponse { + /// True when the database answers. Chain reachability is deliberately *not* + /// part of this: a chain that has not launched, or a node that is + /// restarting, must not make systemd or a load balancer restart a daemon + /// that is working exactly as intended. Per-chain state is reported + /// alongside, for a human to read. + healthy: bool, + version: &'static str, + uptime_seconds: i64, + database: bool, + chains: Vec, +} + +async fn healthz(State(state): State) -> Response { + let database = state.store.ping().await.is_ok(); + let body = HealthResponse { + healthy: database, + version: env!("CARGO_PKG_VERSION"), + uptime_seconds: (chrono::Utc::now() - state.started).num_seconds(), + database, + chains: state.chain_infos(), + }; + let code = if database { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + (code, Json(body)).into_response() +} + +/// `GET /v1/chains` +async fn chains(State(state): State) -> Json> { + Json(state.chain_infos()) +} + +/// `GET /v1/chains/{chain}/summary` +async fn summary( + State(state): State, + Path(chain): Path, +) -> Result, Failure> { + let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + Ok(Json(runtime.summary())) +} + +/// Query string for the leaderboard and miner endpoints. +#[derive(Debug, Deserialize)] +struct WindowQuery { + #[serde(default)] + window: Option, + #[serde(default)] + limit: Option, +} + +impl WindowQuery { + fn window(&self) -> Result { + match &self.window { + None => Ok(Window::default()), + Some(w) => w.parse().map_err(|e: blackbeard_entities::EntityError| { + Failure( + StatusCode::BAD_REQUEST, + ApiError::new("unknown_window", e.to_string()), + ) + }), + } + } +} + +/// `GET /v1/chains/{chain}/leaderboard?window=six_hours&limit=100` +#[derive(Serialize)] +struct LeaderboardResponse { + window: Window, + /// The window's length in blocks. A window is a block count, not a + /// duration — see `Window` — and the client needs the number to say so. + window_blocks: u32, + /// Rows actually held in the window, which is fewer than `window_blocks` + /// until the observer has watched that many. + observed_blocks: u32, + rows: Vec, +} + +async fn leaderboard( + State(state): State, + Path(chain): Path, + Query(query): Query, +) -> Result, Failure> { + let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + let window = query.window()?; + let mut rows = runtime.leaderboard(window, state.leaderboard_max_age); + let observed_blocks = rows.iter().map(|r| r.blocks).sum(); + if let Some(limit) = query.limit { + rows.truncate(limit); + } + Ok(Json(LeaderboardResponse { + window, + window_blocks: window.blocks(), + observed_blocks, + rows, + })) +} + +/// `GET /v1/chains/{chain}/blocks` +async fn blocks( + State(state): State, + Path(chain): Path, +) -> Result>, Failure> { + let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + Ok(Json(runtime.ticker())) +} + +/// How far back a miner's history chart reaches, and how finely it is bucketed. +/// +/// Buckets are chosen so every range renders roughly the same number of points: +/// a chart with ten thousand points is slower to draw and no more informative +/// than one with a hundred and fifty. +fn bucketing(window: Window) -> (Duration, Duration) { + match window { + Window::Hour => (Duration::from_secs(3_600), Duration::from_secs(60)), + Window::SixHours => (Duration::from_secs(21_600), Duration::from_secs(300)), + Window::Day => (Duration::from_secs(86_400), Duration::from_secs(900)), + Window::Week => (Duration::from_secs(604_800), Duration::from_secs(7_200)), + } +} + +/// `GET /v1/chains/{chain}/miners/{miner}?window=day` +/// +/// The endpoint the site exists for: one miner, its standing, and how it has +/// been doing over time. +async fn miner( + State(state): State, + Path((chain, miner)): Path<(String, String)>, + Query(query): Query, +) -> Result, Failure> { + let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + let window = query.window()?; + let miner = parse_miner(&miner)?; + + let current = runtime + .leaderboard(window, state.leaderboard_max_age) + .into_iter() + .find(|row| row.miner == miner); + + let (blocks_observed, first_seen, last_seen, work) = state + .store + .miner_totals(runtime.id(), &miner) + .await + .map_err(database_unavailable)?; + + let (span, bucket) = bucketing(window); + let since = chrono::Utc::now() + - chrono::Duration::from_std(span).unwrap_or_else(|_| chrono::Duration::days(7)); + let network_hashrate = runtime.summary().network_hashrate; + let series = state + .store + .miner_series(runtime.id(), &miner, since, bucket) + .await + .map_err(database_unavailable)? + .into_iter() + .map(|(at, mine, total)| MinerSeriesPoint { + at, + blocks: mine, + share: if total == 0 { + 0.0 + } else { + f64::from(mine) / f64::from(total) + }, + hashrate_estimate: network_hashrate + .and_then(|n| blackbeard_core::hashrate::miner_hashrate(mine, total, n)), + }) + .collect(); + + Ok(Json(MinerDetail { + chain: runtime.id().clone(), + miner, + current, + blocks_observed, + first_seen, + last_seen, + series, + cumulative_work: BigUintDec(work), + })) +} + +/// A reward preimage is 32 bytes of hex. +/// +/// Validated here rather than passed through, so a typo answers "that is not a +/// preimage" instead of "no blocks found" — which reads identically to a real +/// miner who has not won anything yet. +fn parse_miner(raw: &str) -> Result { + let hex = raw.strip_prefix("0x").unwrap_or(raw); + if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(Failure( + StatusCode::BAD_REQUEST, + ApiError::new( + "malformed_miner", + format!("`{raw}` is not a 32-byte reward preimage"), + ), + )); + } + Ok(MinerId(format!("0x{}", hex.to_ascii_lowercase()))) +} + +fn database_unavailable(e: blackbeard_data::DataError) -> Failure { + tracing::warn!(error = %e, "a miner query could not reach the database"); + Failure( + StatusCode::SERVICE_UNAVAILABLE, + ApiError::new( + "database_unavailable", + "miner history is temporarily unavailable", + ), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_preimage_is_accepted_with_or_without_the_prefix() { + let bare = "134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59"; + let expected = MinerId(format!("0x{bare}")); + assert_eq!(parse_miner(bare).ok(), Some(expected.clone())); + assert_eq!( + parse_miner(&format!("0x{bare}")).ok(), + Some(expected.clone()) + ); + // Case-insensitive in, canonical lowercase out, so one miner is one row. + assert_eq!(parse_miner(&bare.to_uppercase()).ok(), Some(expected)); + } + + #[test] + fn anything_that_is_not_a_preimage_is_rejected() { + // "not found" for a typo is indistinguishable from "not found" for a + // real miner who has not won a block yet, which is why this is a 400. + for bad in [ + "", + "0x", + "0xdeadbeef", + "zz", + &"0".repeat(63), + &"0".repeat(65), + ] { + assert!(parse_miner(bad).is_err(), "{bad} should be rejected"); + } + } + + #[test] + fn every_window_gets_a_bucket_count_a_browser_can_draw() { + for w in Window::all() { + let (span, bucket) = bucketing(w); + let points = span.as_secs() / bucket.as_secs(); + assert!( + (50..=400).contains(&points), + "{w:?} would render {points} points" + ); + } + } + + #[test] + fn an_absent_window_parameter_means_the_default() { + let q = WindowQuery { + window: None, + limit: None, + }; + assert_eq!(q.window().ok(), Some(Window::SixHours)); + } + + #[test] + fn an_unknown_window_is_a_bad_request_not_a_silent_default() { + let q = WindowQuery { + window: Some("fortnight".into()), + limit: None, + }; + assert!(q.window().is_err()); + } +} diff --git a/crates/blackbeard-api/src/state.rs b/crates/blackbeard-api/src/state.rs new file mode 100644 index 0000000..3e9f994 --- /dev/null +++ b/crates/blackbeard-api/src/state.rs @@ -0,0 +1,506 @@ +//! Per-chain runtime state, and the fanout to connected browsers. +//! +//! ## Locking +//! +//! [`ChainRuntime::inner`] is a `std::sync::RwLock`, not tokio's, and it is +//! **never held across an `.await`**. Everything under it is CPU work on +//! in-memory collections; making it async would add a scheduling hop to every +//! read for no benefit, and the tokio variant is the one that makes it *easy* +//! to accidentally hold a lock across an await and stall a worker. +//! +//! ## Fanout +//! +//! Messages are serialised **once**, at broadcast time, and every connected +//! socket writes the same `Arc`. With a few hundred watchers and a +//! leaderboard every few seconds, serialising per client would be the single +//! largest cost in the process. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, RwLock}; + +use blackbeard_core::attribution::Attributor; +use blackbeard_core::hashrate::{self, Interval}; +use blackbeard_core::window::RollingWindow; +use blackbeard_data::rpc::RpcClient; +use blackbeard_data::store::Store; +use blackbeard_data::telemetry::TelemetryFeed; +use blackbeard_entities::{ + ChainId, ChainInfo, ChainStatus, ChainSummary, LeaderboardRow, RecentBlock, ServerMessage, + Window, +}; +use chrono::{DateTime, Utc}; +use primitive_types::U512; +use tokio::sync::broadcast; + +use crate::config::ChainConfig; + +/// One serialised message on its way to every subscriber of a chain. +#[derive(Debug)] +pub struct Broadcast { + /// The window this message is about, when it is window-specific. + /// + /// A client watching the six-hour standings has no use for the week's, so + /// the socket task drops anything tagged with a window that is not the + /// one it subscribed to. `None` means "everyone gets this". + pub window: Option, + /// The message, already JSON. + pub json: Arc, +} + +/// How many messages a slow browser may fall behind before it is disconnected. +/// +/// A tab that has been backgrounded on a bad connection must not make the +/// server buffer without limit. Lagging out is the right outcome: the client +/// reconnects and gets a fresh snapshot, which is cheaper and more correct than +/// replaying a queue of stale deltas. +const BROADCAST_CAPACITY: usize = 256; + +/// Everything mutable about one chain. +#[derive(Debug)] +pub struct ChainInner { + /// Rolling window, sized to the longest selectable window. + pub window: RollingWindow, + /// Telemetry attribution votes. + pub attributor: Attributor, + /// The live block ticker, newest last. + pub ticker: std::collections::VecDeque, + /// Cached standings per window, with when each was computed. + /// + /// The timestamp is what stops a REST-only caller reading a table that was + /// cached once and never refreshed: the recompute timer only runs for + /// windows a socket is watching, so without an age check a `curl` of the + /// leaderboard would return the same rows forever. + pub leaderboards: HashMap)>, + /// Current reachability. + pub status: ChainStatus, + /// Genesis hash, once the node has answered. + pub genesis: Option, + /// Token ticker. + pub token_symbol: Option, + /// Token decimals. + pub token_decimals: Option, + /// Current difficulty. + pub difficulty: Option, + /// Ceiling difficulty. + pub max_difficulty: Option, + /// Whether the node is still importing history. + pub syncing: bool, + /// Best height seen. + pub height: Option, + /// When the last block was observed, for the ticker's gap figure. + pub last_block_at: Option>, +} + +/// A block waiting for the telemetry feed to settle before its author can be +/// attributed. +/// +/// Lives beside the runtime rather than inside `ChainInner` because it is +/// touched on a different cadence — appended by ingest on every block, drained +/// by housekeeping once a second — and pairing it with the leaderboard's lock +/// would put both behind the same contention. +#[derive(Debug)] +pub struct PendingAttribution { + /// Block hash, the key the telemetry feed reports imports under. + pub hash: String, + /// Its author. + pub miner: blackbeard_entities::MinerId, + /// When the feed has had long enough to report. + pub due: tokio::time::Instant, +} + +/// One chain, live. +#[derive(Debug)] +pub struct ChainRuntime { + /// Static configuration. + pub config: ChainConfig, + /// HTTP JSON-RPC client for this chain's node. + pub rpc: RpcClient, + /// Telemetry feed. Unconnected and inert when no URL is configured. + pub telemetry: TelemetryFeed, + /// Mutable state. + pub inner: RwLock, + /// Fanout to connected browsers. + pub events: broadcast::Sender>, + /// Blocks awaiting their telemetry attribution. + pub pending_attributions: std::sync::Mutex>, + /// Live subscriber count per window, so the recompute timer can skip a + /// window nobody is looking at. A week window is a hundred thousand entries + /// to re-tally; computing all four every few seconds for an empty site + /// would be the daemon's largest cost while doing nothing for anyone. + subscribers: [AtomicUsize; 4], +} + +impl ChainRuntime { + /// Build a runtime for a configured chain. Does no I/O. + pub fn new(config: ChainConfig, rpc: RpcClient) -> Self { + let (events, _) = broadcast::channel(BROADCAST_CAPACITY); + Self { + inner: RwLock::new(ChainInner { + // Sized to the longest window: every shorter one is then a tail + // of the same buffer rather than separate state to keep in sync. + window: RollingWindow::new(Window::Week.blocks() as usize), + attributor: Attributor::new(), + ticker: std::collections::VecDeque::new(), + leaderboards: HashMap::new(), + // Not `Unreachable`: nothing has failed yet, and a chain that + // has not launched must not read as broken. + status: ChainStatus::Awaiting, + genesis: None, + token_symbol: None, + token_decimals: None, + difficulty: None, + max_difficulty: None, + syncing: false, + height: None, + last_block_at: None, + }), + config, + rpc, + telemetry: TelemetryFeed::new(), + events, + pending_attributions: std::sync::Mutex::new(std::collections::VecDeque::new()), + subscribers: Default::default(), + } + } + + /// The chain's id. + pub fn id(&self) -> &ChainId { + &self.config.id + } + + fn slot(window: Window) -> usize { + match window { + Window::Hour => 0, + Window::SixHours => 1, + Window::Day => 2, + Window::Week => 3, + } + } + + /// Register interest in a window. Returns a guard that deregisters on drop, + /// so a dropped socket cannot leak a permanent subscriber. + pub fn watch(self: &Arc, window: Window) -> WindowGuard { + self.subscribers[Self::slot(window)].fetch_add(1, Ordering::Relaxed); + WindowGuard { + chain: Arc::clone(self), + window, + } + } + + /// Windows currently worth computing. + pub fn watched_windows(&self) -> Vec { + Window::all() + .into_iter() + .filter(|w| self.subscribers[Self::slot(*w)].load(Ordering::Relaxed) > 0) + .collect() + } + + /// Serialise and fan out a message. + /// + /// A send with no receivers is not an error — an idle site is the normal + /// case, and there is nothing to do about it. + pub fn broadcast(&self, message: &ServerMessage, window: Option) { + let json = match serde_json::to_string(message) { + Ok(j) => j, + Err(e) => { + tracing::error!(error = %e, "a server message failed to serialise"); + return; + } + }; + let _ = self.events.send(Arc::new(Broadcast { + window, + json: Arc::from(json.as_str()), + })); + } + + /// The chain's current identity and reachability. + pub fn info(&self) -> ChainInfo { + let inner = self.read(); + ChainInfo { + id: self.config.id.clone(), + display_name: self.config.display_name.clone(), + mainnet: self.config.mainnet, + genesis: inner.genesis.clone(), + token_symbol: inner.token_symbol.clone(), + token_decimals: inner.token_decimals, + target_block_time_seconds: self.config.target_block_time_seconds, + status: inner.status, + } + } + + /// The interval to divide difficulty by, and whether it is nominal. + /// + /// A measured interval is only used when the node is following the tip. A + /// syncing node imports history at disk speed, and dividing by *that* + /// yields a hashrate wrong by orders of magnitude while looking entirely + /// plausible on a chart. + fn interval(&self, inner: &ChainInner) -> Interval { + match inner.window.measured_interval() { + Some(measured) if !inner.syncing => Interval::Measured(measured), + _ => Interval::Target(self.config.target_block_time_seconds), + } + } + + /// The headline numbers. + pub fn summary(&self) -> ChainSummary { + let inner = self.read(); + let interval = self.interval(&inner); + let (tallies, total) = inner.window.tally(Window::SixHours.blocks() as usize); + ChainSummary { + chain: self.config.id.clone(), + height: inner.height, + difficulty: inner.difficulty.map(hashrate::u512_to_dec), + max_difficulty: inner.max_difficulty.map(hashrate::u512_to_dec), + network_hashrate: inner + .difficulty + .map(|d| hashrate::network_hashrate(d, interval)), + hashrate_from_target: interval.is_nominal(), + block_interval_seconds: inner.window.measured_interval(), + target_block_time_seconds: self.config.target_block_time_seconds, + window_blocks: total, + distinct_miners: tallies.len() as u32, + telemetry_nodes: self.telemetry.node_count(), + telemetry_connected: self.telemetry.connected(), + updated_at: Utc::now(), + } + } + + /// Recompute the standings for `window` and cache them. + /// + /// Returns the rows, and whether they differ from what was cached — the + /// caller broadcasts only on a change, so a quiet chain produces no traffic + /// rather than an identical table every few seconds. + pub fn recompute_leaderboard(&self, window: Window) -> (Vec, bool) { + let mut inner = self.write(); + let interval = self.interval(&inner); + let network = inner + .difficulty + .map(|d| hashrate::network_hashrate(d, interval)); + let (tallies, total) = inner.window.tally(window.blocks() as usize); + + // Split the borrow: the tally is done, and `leaderboard` needs a mutable + // attributor while it walks the rows. + let ChainInner { + attributor, + leaderboards, + .. + } = &mut *inner; + let telemetry = &self.telemetry; + let rows = blackbeard_core::window::leaderboard(&tallies, total, network, |miner| { + let a = attributor.attribute(miner, |key| telemetry.name_of(key)); + (a.display, a.source, a.confidence) + }); + + let changed = leaderboards.get(&window).map(|(_, cached)| cached) != Some(&rows); + leaderboards.insert(window, (std::time::Instant::now(), rows.clone())); + (rows, changed) + } + + /// The standings for a window, recomputing them if the cache has aged out. + pub fn leaderboard(&self, window: Window, max_age: std::time::Duration) -> Vec { + if let Some((computed_at, rows)) = self.read().leaderboards.get(&window) + && computed_at.elapsed() < max_age + { + return rows.clone(); + } + self.recompute_leaderboard(window).0 + } + + /// The live ticker, oldest first. + pub fn ticker(&self) -> Vec { + self.read().ticker.iter().cloned().collect() + } + + /// Read the mutable state. + pub fn read(&self) -> std::sync::RwLockReadGuard<'_, ChainInner> { + self.inner.read().unwrap_or_else(|e| e.into_inner()) + } + + /// Write the mutable state. + pub fn write(&self) -> std::sync::RwLockWriteGuard<'_, ChainInner> { + self.inner.write().unwrap_or_else(|e| e.into_inner()) + } +} + +/// Keeps a window counted as watched for as long as it is held. +pub struct WindowGuard { + chain: Arc, + window: Window, +} + +impl Drop for WindowGuard { + fn drop(&mut self) { + self.chain.subscribers[ChainRuntime::slot(self.window)].fetch_sub(1, Ordering::Relaxed); + } +} + +/// Everything the HTTP handlers need. +#[derive(Clone)] +pub struct AppState { + /// Chains in configured order, which is the order the UI shows them in. + pub chains: Arc>>, + /// Lookup by id. + pub by_id: Arc>>, + /// Postgres. + pub store: Store, + /// When the process started, for the health endpoint. + pub started: DateTime, + /// How stale a cached leaderboard may be before a REST request rebuilds it. + pub leaderboard_max_age: std::time::Duration, +} + +impl AppState { + /// Find a chain by id. + pub fn chain(&self, id: &str) -> Option<&Arc> { + self.by_id.get(&ChainId(id.to_owned())) + } + + /// Every chain's identity, in configured order. + pub fn chain_infos(&self) -> Vec { + self.chains.iter().map(|c| c.info()).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn runtime() -> Arc { + Arc::new(ChainRuntime::new( + ChainConfig { + id: ChainId("planck".into()), + display_name: "Planck Testnet".into(), + mainnet: false, + rpc_url: "http://127.0.0.1:9944".into(), + ws_url: "ws://127.0.0.1:9944".into(), + telemetry_url: String::new(), + target_block_time_seconds: 6.0, + warm_start_blocks: 100, + max_gap_fill_blocks: 100, + }, + RpcClient::new("http://127.0.0.1:9944", Duration::from_secs(5)).unwrap(), + )) + } + + #[test] + fn nothing_is_computed_for_a_window_nobody_is_watching() { + let c = runtime(); + assert!(c.watched_windows().is_empty()); + let guard = c.watch(Window::SixHours); + assert_eq!(c.watched_windows(), vec![Window::SixHours]); + drop(guard); + // A dropped socket must not leave its window pinned forever. + assert!(c.watched_windows().is_empty()); + } + + #[test] + fn several_watchers_of_one_window_all_have_to_leave() { + let c = runtime(); + let a = c.watch(Window::Day); + let b = c.watch(Window::Day); + drop(a); + assert_eq!(c.watched_windows(), vec![Window::Day]); + drop(b); + assert!(c.watched_windows().is_empty()); + } + + #[test] + fn a_chain_that_has_never_answered_is_awaiting_not_unreachable() { + // Mainnet is configured before it launches. "Unreachable" would read as + // a broken deployment for weeks. + assert_eq!(runtime().info().status, ChainStatus::Awaiting); + } + + #[test] + fn hashrate_is_nominal_until_the_node_is_at_the_tip() { + let c = runtime(); + { + let mut inner = c.write(); + inner.difficulty = Some(U512::from(1_200_000_000u64)); + inner.syncing = true; + for h in 1..=20u64 { + inner.window.push( + blackbeard_core::window::Observed { + height: h, + miner: blackbeard_entities::MinerId(format!("0x{:064x}", 1)), + observed_at: Utc::now(), + }, + true, + ); + } + } + let summary = c.summary(); + // Twenty blocks imported in well under a second would otherwise read as + // an astronomical hashrate. + assert!(summary.hashrate_from_target); + assert_eq!(summary.network_hashrate, Some(200_000_000.0)); + } + + #[test] + fn identical_standings_report_no_change() { + let c = runtime(); + { + let mut inner = c.write(); + for h in 1..=10u64 { + inner.window.push( + blackbeard_core::window::Observed { + height: h, + miner: blackbeard_entities::MinerId(format!("0x{:064x}", h % 3)), + observed_at: Utc::now(), + }, + true, + ); + } + } + assert!( + c.recompute_leaderboard(Window::Hour).1, + "first compute is a change" + ); + assert!( + !c.recompute_leaderboard(Window::Hour).1, + "an unchanged table must not be rebroadcast every few seconds" + ); + } + + #[test] + fn a_stale_cache_is_recomputed_rather_than_served() { + // Only windows a socket is watching get refreshed by the timer, so + // without an age check a REST caller would read the same rows forever. + let c = runtime(); + { + let mut inner = c.write(); + inner.window.push( + blackbeard_core::window::Observed { + height: 1, + miner: blackbeard_entities::MinerId(format!("0x{:064x}", 1)), + observed_at: Utc::now(), + }, + true, + ); + } + assert_eq!( + c.leaderboard(Window::Hour, Duration::from_secs(60)).len(), + 1 + ); + { + let mut inner = c.write(); + inner.window.push( + blackbeard_core::window::Observed { + height: 2, + miner: blackbeard_entities::MinerId(format!("0x{:064x}", 2)), + observed_at: Utc::now(), + }, + true, + ); + } + // A cache still inside its age is served as-is... + assert_eq!( + c.leaderboard(Window::Hour, Duration::from_secs(60)).len(), + 1 + ); + // ...and one past it is rebuilt. + assert_eq!(c.leaderboard(Window::Hour, Duration::ZERO).len(), 2); + } +} diff --git a/crates/blackbeard-api/src/ws.rs b/crates/blackbeard-api/src/ws.rs new file mode 100644 index 0000000..0566f0a --- /dev/null +++ b/crates/blackbeard-api/src/ws.rs @@ -0,0 +1,194 @@ +//! The browser socket. +//! +//! One socket per tab, carrying every chain the tab is looking at. On +//! subscribe the client gets a full [`ServerMessage::Snapshot`] and thereafter +//! only deltas, so the page never refetches and never polls. +//! +//! Each subscription is a task holding a `broadcast::Receiver` for its chain. +//! The alternative — one task selecting over a growing set of receivers — needs +//! either a `FuturesUnordered` rebuilt on every subscription change or a +//! restructure into a state machine; a task per subscription is a few hundred +//! bytes and reads as what it is. + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::extract::State; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::response::IntoResponse; +use blackbeard_entities::{ChainId, ClientMessage, ServerMessage, Window}; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::mpsc; + +use crate::state::AppState; + +/// Outbound frames buffered per socket before the writer is considered stuck. +/// +/// A snapshot plus a burst of blocks is a handful of frames; anything beyond +/// this is a client that has stopped reading, and dropping it is better than +/// growing a queue on its behalf. +const CLIENT_BUFFER: usize = 64; + +/// How stale a cached leaderboard may be when it goes into a snapshot. +/// +/// A subscriber's window is refreshed on the recompute timer, so the cache is +/// almost always fresh; this bounds the case where the *first* subscriber to a +/// window arrives before the timer has ever run for it. +const MAX_SNAPSHOT_AGE: std::time::Duration = std::time::Duration::from_secs(2); + +/// Upgrade handler for `GET /v1/ws`. +pub async fn handler(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse { + ws.on_upgrade(move |socket| serve(socket, state)) +} + +async fn serve(socket: WebSocket, state: AppState) { + let (mut sink, mut stream) = socket.split(); + let (tx, mut rx) = mpsc::channel::(CLIENT_BUFFER); + + // One writer owns the sink. Every subscription task and the reader loop + // send through the channel, so nothing has to share the socket. + let writer = tokio::spawn(async move { + while let Some(text) = rx.recv().await { + if sink.send(Message::Text(text.into())).await.is_err() { + break; + } + } + let _ = sink.close().await; + }); + + // Unprompted, before anything is asked for: the chain switcher can render + // immediately instead of waiting for a round trip it does not need. + send( + &tx, + &ServerMessage::Chains { + chains: state.chain_infos(), + }, + ) + .await; + + let mut subscriptions: HashMap> = HashMap::new(); + + while let Some(Ok(message)) = stream.next().await { + let text = match message { + Message::Text(t) => t, + Message::Close(_) => break, + // Ping and Pong are answered by axum; binary frames are not part of + // this protocol. + _ => continue, + }; + let request: ClientMessage = match serde_json::from_str(&text) { + Ok(m) => m, + Err(e) => { + send( + &tx, + &ServerMessage::Error { + message: format!("could not read that message: {e}"), + }, + ) + .await; + continue; + } + }; + + match request { + ClientMessage::Ping => send(&tx, &ServerMessage::Pong).await, + ClientMessage::Unsubscribe { chain } => { + if let Some(task) = subscriptions.remove(&chain) { + task.abort(); + } + } + ClientMessage::Subscribe { chain, window } => { + let Some(runtime) = state.chain(chain.as_str()) else { + send( + &tx, + &ServerMessage::Error { + message: format!("no chain named `{chain}` is configured"), + }, + ) + .await; + continue; + }; + // Re-subscribing replaces: a client changing window sends + // `subscribe` again rather than unsubscribing first, and two + // live subscriptions to one chain would double every delta. + if let Some(previous) = subscriptions.remove(&chain) { + previous.abort(); + } + let runtime = Arc::clone(runtime); + let tx = tx.clone(); + subscriptions.insert(chain, tokio::spawn(subscription(runtime, window, tx))); + } + } + } + + for (_, task) in subscriptions { + task.abort(); + } + // Dropping the sender ends the writer, which closes the socket. + drop(tx); + let _ = writer.await; +} + +/// Serve one chain subscription: snapshot, then deltas until aborted. +async fn subscription( + chain: Arc, + window: Window, + tx: mpsc::Sender, +) { + // Subscribe to the fanout *before* building the snapshot. The other order + // has a gap: a block arriving between the snapshot and the subscription + // would be in neither, and the ticker would silently skip it. + let mut events = chain.events.subscribe(); + + // Held for the life of the subscription, so the recompute timer knows this + // window is worth computing — and stops when the socket goes away. + let _watching = chain.watch(window); + + let snapshot = ServerMessage::Snapshot { + chain: chain.id().clone(), + window, + summary: chain.summary(), + leaderboard: chain.leaderboard(window, MAX_SNAPSHOT_AGE), + recent_blocks: chain.ticker(), + }; + send(&tx, &snapshot).await; + + loop { + match events.recv().await { + Ok(item) => { + // Window-tagged messages are for one window's watchers only. + if item.window.is_some_and(|w| w != window) { + continue; + } + if tx.send(item.json.to_string()).await.is_err() { + return; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(missed)) => { + // This client stopped reading long enough to fall off the + // channel. Deltas are gone, so resend a snapshot rather than + // leaving the page showing a state that silently skipped + // blocks. + tracing::debug!(chain = %chain.id(), missed, "client lagged; resending snapshot"); + let snapshot = ServerMessage::Snapshot { + chain: chain.id().clone(), + window, + summary: chain.summary(), + leaderboard: chain.leaderboard(window, MAX_SNAPSHOT_AGE), + recent_blocks: chain.ticker(), + }; + send(&tx, &snapshot).await; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => return, + } + } +} + +async fn send(tx: &mpsc::Sender, message: &ServerMessage) { + match serde_json::to_string(message) { + Ok(json) => { + let _ = tx.send(json).await; + } + Err(e) => tracing::error!(error = %e, "a server message failed to serialise"), + } +} diff --git a/crates/blackbeard-cli/Cargo.toml b/crates/blackbeard-cli/Cargo.toml new file mode 100644 index 0000000..3aaf6d5 --- /dev/null +++ b/crates/blackbeard-cli/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "blackbeard-cli" +description = "Operator CLI for blackbeard.observer." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[[bin]] +name = "blackbeard" +path = "src/main.rs" + +[dependencies] +blackbeard-core.workspace = true +blackbeard-data.workspace = true +blackbeard-entities.workspace = true + +anyhow.workspace = true +chrono.workspace = true +clap.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +sqlx.workspace = true +tokio.workspace = true diff --git a/crates/blackbeard-cli/src/main.rs b/crates/blackbeard-cli/src/main.rs new file mode 100644 index 0000000..e72c56a --- /dev/null +++ b/crates/blackbeard-cli/src/main.rs @@ -0,0 +1,361 @@ +//! `blackbeard` — the operator CLI. +//! +//! Three jobs, all of them things an operator needs *before* or *around* a +//! deploy rather than during normal running: +//! +//! - **`probe`** answers "will the observer work against this node?" without +//! deploying anything. It is the command to run when a chain is added to the +//! config, and the one to run on launch day for mainnet. +//! - **`backfill`** pulls history into the database, so a newly deployed +//! observer has a leaderboard immediately instead of one that fills in over +//! the following week. +//! - **`standings`** prints the board in a terminal, which is what an operator +//! actually wants when checking whether a deploy is serving real data. + +use std::time::Duration; + +use anyhow::{Context, bail}; +use blackbeard_core::{digest, hashrate}; +use blackbeard_data::rpc::RpcClient; +use blackbeard_data::store::{BlockRecord, ChainRecord, Store}; +use blackbeard_entities::{ChainId, MinerId}; +use chrono::{TimeZone, Utc}; +use clap::{Parser, Subcommand}; + +const RPC_TIMEOUT: Duration = Duration::from_secs(15); + +#[derive(Parser)] +#[command( + name = "blackbeard", + version, + about = "Operator tools for blackbeard.observer" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Check that a node answers everything the observer needs, and print what + /// it says. + /// + /// Every failure here is a failure the deployed daemon would hit silently: + /// an RPC without the QPoW runtime API produces a site with no hashrate, and + /// headers without a `pow_` digest produce an empty leaderboard — both of + /// which look like a working deployment. + Probe { + /// The node's HTTP JSON-RPC endpoint. + #[arg(long, default_value = "http://127.0.0.1:9944")] + rpc_url: String, + /// Target seconds per block, for the hashrate estimate. + #[arg(long, default_value_t = 6.0)] + target_block_time: f64, + /// How many recent headers to decode. More is a better sample of + /// whether authorship decoding actually works across blocks. + #[arg(long, default_value_t = 20)] + blocks: u64, + }, + + /// Read history from a node into the database. + Backfill { + /// The node's HTTP JSON-RPC endpoint. + #[arg(long, default_value = "http://127.0.0.1:9944")] + rpc_url: String, + /// Postgres connection string. mTLS in production; this command is run + /// by an operator, so it takes a URL rather than the daemon's config. + #[arg(long, env = "DATABASE_URL")] + database_url: String, + /// Chain id, matching the daemon's config. + #[arg(long)] + chain: String, + /// Human-facing chain name, used if the chain row does not exist yet. + #[arg(long)] + display_name: Option, + /// First block to read. Defaults to `to` minus `count`. + #[arg(long)] + from: Option, + /// Last block to read. Defaults to the current tip. + #[arg(long)] + to: Option, + /// Blocks to read when `from` is not given. + #[arg(long, default_value_t = 3600)] + count: u64, + }, + + /// Print the standings from a running observer. + Standings { + /// The observer's API base. + #[arg(long, default_value = "http://127.0.0.1:25864/v1")] + api: String, + /// Chain id. + #[arg(long, default_value = "planck")] + chain: String, + /// Window: hour, six_hours, day or week. + #[arg(long, default_value = "six_hours")] + window: String, + /// Rows to print. + #[arg(long, default_value_t = 20)] + limit: usize, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + match Cli::parse().command { + Command::Probe { + rpc_url, + target_block_time, + blocks, + } => probe(&rpc_url, target_block_time, blocks).await, + Command::Backfill { + rpc_url, + database_url, + chain, + display_name, + from, + to, + count, + } => { + backfill( + &rpc_url, + &database_url, + &chain, + display_name.as_deref(), + from, + to, + count, + ) + .await + } + Command::Standings { + api, + chain, + window, + limit, + } => standings(&api, &chain, &window, limit).await, + } +} + +async fn probe(rpc_url: &str, target_block_time: f64, blocks: u64) -> anyhow::Result<()> { + let rpc = RpcClient::new(rpc_url, RPC_TIMEOUT)?; + + let health = rpc + .health() + .await + .with_context(|| format!("{rpc_url} did not answer system_health"))?; + println!( + "node peers {}, syncing {}", + health.peers, health.is_syncing + ); + + let genesis = rpc.genesis().await?.unwrap_or_default(); + println!("genesis {genesis}"); + + let props = rpc.properties().await.unwrap_or_default(); + println!( + "token {} ({} decimals), ss58 prefix {}", + props.token_symbol.as_deref().unwrap_or("?"), + props.token_decimals.map_or("?".into(), |d| d.to_string()), + props.ss58_format.map_or("?".into(), |p| p.to_string()), + ); + + let head = rpc.header(None).await?.context("node returned no head")?; + let height = head + .height() + .context("head carried an undecodable number")?; + println!("height {height}"); + + let difficulty = rpc.difficulty().await.context( + "QPoWApi_get_difficulty failed — without it the site has no hashrate and no difficulty", + )?; + println!("difficulty {difficulty} expected hashes per block"); + println!( + "hashrate {:.3} GH/s at the {target_block_time}s target", + hashrate::network_hashrate(difficulty, hashrate::Interval::Target(target_block_time)) / 1e9 + ); + + // The load-bearing check. Everything above can pass on a node whose headers + // this observer cannot read, and that node produces an empty leaderboard + // with no error anywhere. + println!("\ndecoding the last {blocks} headers…"); + let mut decoded = 0u64; + let mut authors = std::collections::BTreeMap::::new(); + let mut timestamps = 0u64; + for h in height.saturating_sub(blocks)..height { + let Some(hash) = rpc.block_hash(h).await? else { + continue; + }; + let Some(header) = rpc.header(Some(&hash)).await? else { + continue; + }; + if let Some(miner) = digest::author_preimage(&header.digest.logs) { + decoded += 1; + *authors.entry(miner).or_default() += 1; + } + if rpc.block_timestamp_ms(&hash).await?.is_some() { + timestamps += 1; + } + } + + println!("authors {decoded}/{blocks} headers carried a pow_ reward preimage"); + println!("timestamps {timestamps}/{blocks} blocks carried a decodable timestamp inherent"); + println!("distinct {} miners in that sample", authors.len()); + for (miner, n) in authors.iter().take(10) { + println!(" {n:>3} {miner}"); + } + + if decoded == 0 { + bail!( + "no header carried a pow_ PreRuntime digest. The observer would run, report healthy, \ + and show an empty leaderboard — this chain's headers are not in a shape it can read." + ); + } + if decoded < blocks { + println!( + "\nnote: {} of {blocks} headers had no author digest. Genesis and any non-PoW block \ + are expected; a large fraction is not.", + blocks - decoded + ); + } + println!("\nOK — this node is usable."); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn backfill( + rpc_url: &str, + database_url: &str, + chain: &str, + display_name: Option<&str>, + from: Option, + to: Option, + count: u64, +) -> anyhow::Result<()> { + let rpc = RpcClient::new(rpc_url, RPC_TIMEOUT)?; + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(4) + .connect(database_url) + .await + .context("connecting to postgres")?; + let store = Store::from_pool(pool); + store.migrate().await?; + + let tip = rpc + .header(None) + .await? + .and_then(|h| h.height()) + .context("node returned no usable head")?; + let to = to.unwrap_or(tip); + let from = from.unwrap_or_else(|| to.saturating_sub(count)); + if from > to { + bail!("--from {from} is above --to {to}"); + } + + let chain_id = ChainId(chain.to_owned()); + // The chain row has to exist before any block can reference it. + store + .upsert_chain(&ChainRecord { + chain: chain_id.clone(), + display_name: display_name.unwrap_or(chain).to_owned(), + mainnet: false, + genesis: rpc.genesis().await.ok().flatten(), + token_symbol: None, + token_decimals: None, + target_block_time: 6.0, + }) + .await?; + + let difficulty = rpc.difficulty().await.ok().map(hashrate::u512_to_dec); + println!("reading {from}..={to} from {rpc_url}"); + + // Written in batches rather than one statement: a week of blocks is a + // hundred thousand rows, and a single array parameter that large is a + // needlessly large allocation on both ends. + const BATCH: usize = 500; + let mut batch: Vec = Vec::with_capacity(BATCH); + let mut written = 0u64; + let mut skipped = 0u64; + + for height in from..=to { + let Some(hash) = rpc.block_hash(height).await? else { + skipped += 1; + continue; + }; + let Some(header) = rpc.header(Some(&hash)).await? else { + skipped += 1; + continue; + }; + let Some(miner) = digest::author_preimage(&header.digest.logs) else { + skipped += 1; + continue; + }; + let authored_at = rpc + .block_timestamp_ms(&hash) + .await? + .and_then(|ms| i64::try_from(ms).ok()) + .and_then(|ms| Utc.timestamp_millis_opt(ms).single()); + + batch.push(BlockRecord { + chain: chain_id.clone(), + height, + hash, + miner, + authored_at, + // Backfilled blocks are stamped with the author's own timestamp + // where there is one. Stamping them "now" would make a week of + // history look like it all arrived in the last minute, and every + // per-miner chart would collapse into one bucket. + observed_at: authored_at.unwrap_or_else(Utc::now), + difficulty: difficulty.as_ref().map(|d| d.0.clone()), + }); + + if batch.len() >= BATCH { + written += store.record_blocks(&batch).await?; + batch.clear(); + println!(" {written} written…"); + } + } + written += store.record_blocks(&batch).await?; + + println!( + "done: {written} blocks written, {skipped} skipped (no author digest or not on chain)" + ); + Ok(()) +} + +async fn standings(api: &str, chain: &str, window: &str, limit: usize) -> anyhow::Result<()> { + let url = format!("{api}/chains/{chain}/leaderboard?window={window}&limit={limit}"); + let body: serde_json::Value = reqwest::Client::new() + .get(&url) + .timeout(RPC_TIMEOUT) + .send() + .await + .with_context(|| format!("GET {url}"))? + .error_for_status()? + .json() + .await?; + + println!( + "{} · window {} ({} blocks observed)", + chain, + body["window"].as_str().unwrap_or(window), + body["observed_blocks"].as_u64().unwrap_or(0) + ); + println!( + "{:>3} {:<34} {:>6} {:>7} {:>12}", + "#", "MINER", "BLOCKS", "SHARE", "EST. RATE" + ); + for row in body["rows"].as_array().into_iter().flatten() { + let rate = row["hashrate_estimate"].as_f64().unwrap_or(0.0); + println!( + "{:>3} {:<34} {:>6} {:>6.1}% {:>9.2} MH/s", + row["rank"].as_u64().unwrap_or(0), + row["display"].as_str().unwrap_or("?"), + row["blocks"].as_u64().unwrap_or(0), + row["share"].as_f64().unwrap_or(0.0) * 100.0, + rate / 1e6, + ); + } + Ok(()) +} diff --git a/crates/blackbeard-core/Cargo.toml b/crates/blackbeard-core/Cargo.toml new file mode 100644 index 0000000..075b14e --- /dev/null +++ b/crates/blackbeard-core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "blackbeard-core" +description = "Chain decoding and leaderboard state for blackbeard.observer. Pure — no I/O." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +blackbeard-entities.workspace = true +chrono.workspace = true +hex.workspace = true +primitive-types.workspace = true +thiserror.workspace = true +tracing.workspace = true diff --git a/crates/blackbeard-core/src/attribution.rs b/crates/blackbeard-core/src/attribution.rs new file mode 100644 index 0000000..4b50030 --- /dev/null +++ b/crates/blackbeard-core/src/attribution.rs @@ -0,0 +1,373 @@ +//! Putting a name to a reward preimage. +//! +//! The chain tells us *which preimage* won each block and nothing else. A +//! leaderboard of 64-hex-character strings is honest but unreadable, and the +//! whole point of this site is that miners can find themselves on it. +//! +//! substrate-telemetry closes the gap, indirectly. The feed reports block +//! imports with a propagation time, stamping the **first** reporter of a hash +//! with `0` and everyone after with their delay. A node that authored a block +//! imports it before it announces it, so the author — if it is on telemetry at +//! all — is nearly always the first reporter of its own blocks. +//! +//! One block proves nothing: on a well-connected network the author's lead over +//! the second reporter is tens of milliseconds, easily produced by a +//! well-peered bystander. Identity does the work instead. An author that is on +//! the feed is first on nearly *every* one of its blocks; an author that is not +//! gets first-reported by a different peer each time. So this module votes over +//! a rolling window and only shows a name once enough blocks agree. +//! +//! **What this is not.** An attributed name is an inference, never a claim the +//! miner made and never an identity check. Two operators behind one NAT, a node +//! that relays unusually fast, or a miner deliberately running its node +//! elsewhere will all mislead it. That is why [`Attribution`] carries a +//! confidence and why the UI must not render an inferred name the same way it +//! renders a preimage. + +use std::collections::{HashMap, VecDeque}; + +use blackbeard_entities::{AttributionSource, MinerId}; + +/// Minimum lead over the second reporter, in milliseconds, for a first import +/// to count as a vote. +/// +/// Measured on Planck: authors on the feed lead by 40–100 ms, up to 400 ms when +/// poorly peered. The threshold only has to exclude a tie — identity over many +/// blocks does the discriminating, so setting this high would reject good votes +/// without rejecting any bad ones. +pub const LEAD_MS: i64 = 20; +/// Blocks that must have been checked before any name is shown. +pub const MIN_ATTEMPTS: u32 = 3; +/// Fraction of the vote window that must agree before a name is shown. +pub const MIN_CONFIDENCE: f32 = 0.6; +/// Votes kept per author. +/// +/// Bounded so a node that restarts or is renamed — which gives it a new +/// telemetry node id — takes over its own attribution after a few blocks +/// instead of having to out-vote its entire history. +pub const VOTE_WINDOW: usize = 20; + +/// Stable identity for a telemetry node across the feed's reconnections. +/// +/// Telemetry node ids are per-connection: they change on every reconnect and on +/// every rename, which would split an author's votes across several identities +/// and drop its name. The peer id survives both, so it is preferred; the node +/// id is the fallback for a feed entry that did not carry one. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum NodeKey { + /// libp2p peer id — stable across restarts and renames. + Peer(String), + /// Telemetry node id — per-connection, used only when no peer id was given. + Node(u64), +} + +/// The outcome of attributing one miner. +#[derive(Debug, Clone, PartialEq)] +pub struct Attribution { + /// What to render. + pub display: String, + /// Whether `display` is an inferred node name or the abbreviated preimage. + pub source: AttributionSource, + /// Fraction of the vote window agreeing with the held name, 0.0–1.0. + pub confidence: f32, +} + +#[derive(Debug, Default, Clone)] +struct AuthorVotes { + attempts: u32, + attributed: u32, + votes: VecDeque>, + held: Option, +} + +/// Rolling per-author attribution state. +#[derive(Debug, Default)] +pub struct Attributor { + authors: HashMap, +} + +impl Attributor { + /// Empty state. + pub fn new() -> Self { + Self::default() + } + + /// Record one block's evidence. + /// + /// `first_reporter` is the node that reported the block first together with + /// its lead over the second reporter, or `None` when the block never + /// appeared on the feed. A `None` — and a lead too small to count — is still + /// recorded, as a vote for nobody: an author that is *not* on telemetry must + /// accumulate abstentions, so its confidence stays low and it keeps showing + /// its preimage rather than drifting onto whichever bystander happened to + /// relay it fastest. + pub fn observe(&mut self, miner: &MinerId, first_reporter: Option<(NodeKey, Option)>) { + let entry = self.authors.entry(miner.clone()).or_default(); + entry.attempts += 1; + let vote = match first_reporter { + Some((key, Some(lead))) if lead >= LEAD_MS => { + entry.attributed += 1; + Some(key) + } + _ => None, + }; + if entry.votes.len() == VOTE_WINDOW { + entry.votes.pop_front(); + } + entry.votes.push_back(vote); + } + + /// Seed a held name from persisted state, so a restart does not strip every + /// author of its name for a few blocks. + /// + /// That churn is not cosmetic: a row whose display flips from a name to a + /// preimage and back splits the miner's series on every restart, which is + /// exactly the continuity the site exists to provide. + pub fn restore_held(&mut self, miner: MinerId, key: NodeKey, attempts: u32, attributed: u32) { + let entry = self.authors.entry(miner).or_default(); + entry.held = Some(key.clone()); + entry.attempts = attempts; + entry.attributed = attributed; + // One synthetic vote so the held name survives its first evaluation: + // hysteresis drops a name whose node has no vote left in the window, + // and a restored author has no votes at all yet. + entry.votes.push_back(Some(key)); + } + + /// Every author currently holding a name, for persistence. + pub fn held_names(&self) -> Vec<(MinerId, NodeKey, u32, u32)> { + self.authors + .iter() + .filter_map(|(m, a)| { + a.held + .clone() + .map(|k| (m.clone(), k, a.attempts, a.attributed)) + }) + .collect() + } + + /// Resolve a miner's display, updating the held name. + /// + /// `name_of` maps a node key to its current telemetry name; it returns + /// `None` for a node the feed has never named, in which case the preimage + /// is shown even though the vote was decisive — a nameless attribution is + /// not worth showing. + /// + /// Hysteresis is deliberate: once a name is held it stays until its node + /// has no vote left in the window, or another node out-votes it outright. A + /// dip in confidence alone never falls back to the preimage, because a + /// display that flickers between the two on every wobble is worse than a + /// slightly stale name. + pub fn attribute(&mut self, miner: &MinerId, name_of: F) -> Attribution + where + F: Fn(&NodeKey) -> Option, + { + let fallback = Attribution { + display: miner.abbreviated(), + source: AttributionSource::Preimage, + confidence: 0.0, + }; + let Some(entry) = self.authors.get_mut(miner) else { + return fallback; + }; + + let mut counts: HashMap<&NodeKey, u32> = HashMap::new(); + for vote in entry.votes.iter().flatten() { + *counts.entry(vote).or_default() += 1; + } + let total = entry.votes.len() as f32; + + let mut held = entry.held.clone(); + if let Some(h) = &held + && counts.get(h).copied().unwrap_or(0) == 0 + { + held = None; + } + // Deterministic winner: ties break by key, not by hash iteration order, + // so an author's name cannot flip back and forth between two equally + // voted nodes on successive renders. + let best = counts + .iter() + .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp_key().cmp(&a.0.cmp_key()))) + .map(|(k, n)| ((*k).clone(), *n)); + if let Some((best_key, best_n)) = best { + match &held { + None => { + if entry.attempts >= MIN_ATTEMPTS && best_n as f32 / total >= MIN_CONFIDENCE { + held = Some(best_key); + } + } + Some(h) if &best_key != h && best_n > counts.get(h).copied().unwrap_or(0) => { + held = Some(best_key); + } + _ => {} + } + } + entry.held = held.clone(); + + let Some(held) = held else { + return fallback; + }; + let confidence = counts.get(&held).copied().unwrap_or(0) as f32 / total.max(1.0); + match name_of(&held) { + Some(name) if !name.is_empty() => Attribution { + display: name, + source: AttributionSource::Telemetry, + confidence, + }, + _ => fallback, + } + } +} + +impl NodeKey { + /// A total order over keys, so tie-breaks are deterministic. + fn cmp_key(&self) -> (u8, String) { + match self { + NodeKey::Peer(p) => (0, p.clone()), + NodeKey::Node(n) => (1, n.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn miner() -> MinerId { + MinerId("0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59".into()) + } + + fn named(key: &NodeKey) -> Option { + match key { + NodeKey::Peer(p) => Some(format!("node-{p}")), + NodeKey::Node(n) => Some(format!("node-{n}")), + } + } + + #[test] + fn a_consistent_first_reporter_earns_the_name() { + let mut a = Attributor::new(); + let key = NodeKey::Peer("12D3KooWabc".into()); + for _ in 0..5 { + a.observe(&miner(), Some((key.clone(), Some(80)))); + } + let got = a.attribute(&miner(), named); + assert_eq!(got.source, AttributionSource::Telemetry); + assert_eq!(got.display, "node-12D3KooWabc"); + assert_eq!(got.confidence, 1.0); + } + + #[test] + fn too_few_blocks_shows_the_preimage() { + let mut a = Attributor::new(); + a.observe(&miner(), Some((NodeKey::Peer("p".into()), Some(80)))); + let got = a.attribute(&miner(), named); + assert_eq!(got.source, AttributionSource::Preimage); + assert_eq!(got.display, miner().abbreviated()); + } + + #[test] + fn a_miner_not_on_telemetry_never_gets_a_name() { + // Different bystander first-reports each block — exactly the signature + // of an author that is not on the feed. Naming any of them would be a + // confident lie. + let mut a = Attributor::new(); + for i in 0..20u64 { + a.observe(&miner(), Some((NodeKey::Node(i), Some(80)))); + } + assert_eq!( + a.attribute(&miner(), named).source, + AttributionSource::Preimage + ); + } + + #[test] + fn a_lead_below_the_threshold_is_an_abstention() { + let mut a = Attributor::new(); + let key = NodeKey::Peer("p".into()); + for _ in 0..10 { + a.observe(&miner(), Some((key.clone(), Some(1)))); + } + assert_eq!( + a.attribute(&miner(), named).source, + AttributionSource::Preimage + ); + } + + #[test] + fn blocks_absent_from_the_feed_are_abstentions() { + let mut a = Attributor::new(); + for _ in 0..10 { + a.observe(&miner(), None); + } + assert_eq!( + a.attribute(&miner(), named).source, + AttributionSource::Preimage + ); + } + + #[test] + fn a_held_name_survives_a_confidence_dip() { + let mut a = Attributor::new(); + let key = NodeKey::Peer("p".into()); + for _ in 0..10 { + a.observe(&miner(), Some((key.clone(), Some(80)))); + } + assert_eq!( + a.attribute(&miner(), named).source, + AttributionSource::Telemetry + ); + // Five blocks the feed missed. Confidence falls to 10/15, still named. + for _ in 0..5 { + a.observe(&miner(), None); + } + let got = a.attribute(&miner(), named); + assert_eq!(got.source, AttributionSource::Telemetry); + assert!(got.confidence < 1.0); + } + + #[test] + fn a_restarted_node_takes_over_its_own_attribution() { + let mut a = Attributor::new(); + let old = NodeKey::Node(1); + for _ in 0..VOTE_WINDOW { + a.observe(&miner(), Some((old.clone(), Some(80)))); + } + assert_eq!(a.attribute(&miner(), named).display, "node-1"); + // Same operator, new telemetry node id after a restart. The bounded + // vote window means it wins in VOTE_WINDOW blocks rather than never. + let new = NodeKey::Node(2); + for _ in 0..VOTE_WINDOW { + a.observe(&miner(), Some((new.clone(), Some(80)))); + } + assert_eq!(a.attribute(&miner(), named).display, "node-2"); + } + + #[test] + fn a_nameless_node_falls_back_to_the_preimage() { + let mut a = Attributor::new(); + let key = NodeKey::Peer("p".into()); + for _ in 0..10 { + a.observe(&miner(), Some((key.clone(), Some(80)))); + } + assert_eq!( + a.attribute(&miner(), |_| None).source, + AttributionSource::Preimage + ); + } + + #[test] + fn restored_state_names_the_author_before_any_new_votes() { + let mut a = Attributor::new(); + a.restore_held(miner(), NodeKey::Peer("p".into()), 40, 38); + assert_eq!(a.attribute(&miner(), named).display, "node-p"); + assert_eq!(a.held_names().len(), 1); + } + + #[test] + fn an_unknown_miner_is_its_preimage() { + let mut a = Attributor::new(); + assert_eq!(a.attribute(&miner(), named).display, miner().abbreviated()); + } +} diff --git a/crates/blackbeard-core/src/digest.rs b/crates/blackbeard-core/src/digest.rs new file mode 100644 index 0000000..d00652f --- /dev/null +++ b/crates/blackbeard-core/src/digest.rs @@ -0,0 +1,138 @@ +//! Reading a block's author out of its header. +//! +//! This is the whole reason the observer needs no indexer, no cooperation from +//! miners, and no permission from anyone: **every** block header carries its +//! author's 32-byte wormhole reward preimage in a `PreRuntime` digest log +//! stamped with the engine id `pow_`. Authorship for the entire network is +//! therefore derivable from headers alone, which is what makes a complete +//! leaderboard possible rather than a leaderboard of whoever opted in. +//! +//! The node writes it there so the `mining-rewards` pallet can read it back and +//! derive the payout address on-chain. That it is also a perfect public miner +//! identifier is a side effect of the design, not a leak: spending the rewards +//! requires a plonky2 proof of knowledge of the underlying secret, which the +//! preimage does not give up. + +use blackbeard_entities::MinerId; + +use crate::scale; + +/// `DigestItem::PreRuntime` discriminant, as the first byte of an encoded log. +const DIGEST_PRE_RUNTIME: u8 = 0x06; +/// Consensus engine id `b"pow_"`. +const POW_ENGINE_ID: [u8; 4] = *b"pow_"; +/// A wormhole reward preimage is a Poseidon2 digest: 32 bytes, always. +const PREIMAGE_LEN: usize = 32; + +/// Pull the author's reward preimage out of a header's digest logs. +/// +/// Returns `None` for a header carrying no `pow_` PreRuntime log — genesis, or +/// a header shape this decoder does not recognise. Neither is worth failing an +/// ingest loop over: the block simply does not contribute to the leaderboard. +/// +/// `logs` are the hex strings exactly as `chain_getHeader` returns them. +pub fn author_preimage>(logs: &[S]) -> Option { + for log in logs { + let raw = log.as_ref(); + let bytes = match hex::decode(raw.strip_prefix("0x").unwrap_or(raw)) { + Ok(b) => b, + Err(_) => continue, + }; + // discriminant + 4-byte engine id, then a SCALE-encoded byte vector. + if bytes.first() != Some(&DIGEST_PRE_RUNTIME) || bytes.get(1..5) != Some(&POW_ENGINE_ID) { + continue; + } + let (len, body_start) = scale::compact(&bytes, 5).ok()?; + let payload = bytes.get(body_start..body_start + len as usize)?; + if payload.len() != PREIMAGE_LEN { + // A `pow_` log that is not 32 bytes is not a preimage. Refusing it + // is right: a truncated or padded id would silently split or merge + // leaderboard rows. + return None; + } + return Some(MinerId(format!("0x{}", hex::encode(payload)))); + } + None +} + +/// Parse a `0x`-prefixed **little-endian** U512 hex string, as the QPoW runtime +/// API returns difficulty. +/// +/// Little-endian is the trap here: `state_call` hands back a SCALE-encoded +/// `U512`, which is byte-reversed relative to how a hash or an address reads. +/// Interpreting it big-endian yields a plausible-looking astronomically wrong +/// number, and every hashrate derived from it is wrong by orders of magnitude +/// without ever looking obviously broken. +pub fn u512_le(hex_str: &str) -> Option { + let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(hex_str)).ok()?; + if bytes.is_empty() || bytes.len() > 64 { + return None; + } + Some(primitive_types::U512::from_little_endian(&{ + let mut buf = [0u8; 64]; + buf[..bytes.len()].copy_from_slice(&bytes); + buf + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A real Planck header digest log: discriminant 06, engine id 706f775f + /// ("pow_"), compact length 0x80 (= 32), then the 32-byte preimage. + fn sample_log(preimage_hex: &str) -> String { + format!("0x06706f775f80{preimage_hex}") + } + + #[test] + fn extracts_the_preimage_from_a_pow_digest() { + let pre = "134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59"; + let logs = vec![sample_log(pre)]; + assert_eq!(author_preimage(&logs), Some(MinerId(format!("0x{pre}")))); + } + + #[test] + fn skips_logs_from_other_engines() { + let pre = "134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59"; + // A Seal log and a different engine id, then the one that matters. + let logs = vec![ + "0x05616c6c2f80deadbeef".to_string(), + "0x06424142453401000000".to_string(), + sample_log(pre), + ]; + assert_eq!(author_preimage(&logs), Some(MinerId(format!("0x{pre}")))); + } + + #[test] + fn genesis_has_no_author() { + let empty: Vec = vec![]; + assert_eq!(author_preimage(&empty), None); + } + + #[test] + fn rejects_a_pow_log_that_is_not_32_bytes() { + // Length says 4, so this is not a preimage — better no author than a + // truncated one that would open a bogus leaderboard row. + assert_eq!( + author_preimage(&["0x06706f775f10deadbeef".to_string()]), + None + ); + } + + #[test] + fn difficulty_is_little_endian() { + // 0x0100…00 little-endian is 1, not 2^504. Getting this backwards + // produces a wrong-by-10^150 hashrate that still renders as a number. + let mut le = String::from("0x01"); + le.push_str(&"00".repeat(63)); + assert_eq!(u512_le(&le), Some(primitive_types::U512::one())); + } + + #[test] + fn difficulty_tolerates_a_short_encoding() { + assert_eq!(u512_le("0x0a"), Some(primitive_types::U512::from(10u8))); + assert_eq!(u512_le("0x"), None); + assert_eq!(u512_le("0xzz"), None); + } +} diff --git a/crates/blackbeard-core/src/hashrate.rs b/crates/blackbeard-core/src/hashrate.rs new file mode 100644 index 0000000..7f45e3d --- /dev/null +++ b/crates/blackbeard-core/src/hashrate.rs @@ -0,0 +1,139 @@ +//! Turning difficulty and block timing into hashrate. +//! +//! The arithmetic is one division, but two things about it are easy to get +//! wrong and expensive to notice, so they live here with the reasoning attached. + +use blackbeard_entities::BigUintDec; +use primitive_types::U512; + +/// A U512 as an `f64`, for arithmetic that only needs magnitude. +/// +/// Difficulty is routinely past `1e30`, so it has long since exceeded `f64`'s +/// 53-bit exact-integer range — but hashrate is an estimate over a finite +/// window whose error is dominated by block-timing variance, not by the last +/// significant bit of the divisor. Precision is kept exactly where it matters +/// instead: the difficulty *displayed* and the difficulty *stored* both go +/// through [`BigUintDec`], never through this. +pub fn u512_to_f64(v: U512) -> f64 { + // Limbs are little-endian; fold from the top so the accumulator never has + // to represent more than one limb's worth of new magnitude at a time. + v.0.iter().rev().fold(0.0f64, |acc, &limb| { + acc * 18_446_744_073_709_551_616.0 + limb as f64 + }) +} + +/// Render a U512 as the decimal string that crosses every boundary. +pub fn u512_to_dec(v: U512) -> BigUintDec { + BigUintDec(v.to_string()) +} + +/// Estimated network hashrate in hashes per second. +/// +/// Difficulty **is** the expected number of hashes to win a block, so hashrate +/// is simply difficulty divided by the seconds between blocks. The subtlety is +/// which interval to divide by, which is what [`Interval`] exists to make +/// explicit at the call site. +pub fn network_hashrate(difficulty: U512, interval: Interval) -> f64 { + let seconds = interval.seconds(); + if seconds <= 0.0 { + return 0.0; + } + u512_to_f64(difficulty) / seconds +} + +/// Which block interval a hashrate figure was derived from. +/// +/// A measured interval is only meaningful **at the tip**. While a node is +/// catching up it imports historical blocks at disk speed, so the elapsed time +/// between observing consecutive blocks is a measure of the node's disk, not of +/// the network's hashrate — and dividing by it yields a hashrate that is wrong +/// by several orders of magnitude while looking entirely plausible. Making the +/// two cases separate variants means a caller cannot forget to say which it has, +/// and the UI can label a nominal figure as nominal. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Interval { + /// Mean seconds per block measured from tip observations. + Measured(f64), + /// The chain's configured target, used when no measurement is trustworthy. + Target(f64), +} + +impl Interval { + /// The interval in seconds. + pub fn seconds(self) -> f64 { + match self { + Interval::Measured(s) | Interval::Target(s) => s, + } + } + + /// True when this is the configured target rather than an observation. + pub fn is_nominal(self) -> bool { + matches!(self, Interval::Target(_)) + } +} + +/// A miner's implied hashrate: its share of blocks times the network's. +/// +/// This is a statistical estimate over a finite window, not a measurement of +/// anyone's hardware. Winning blocks is a Poisson process, so a miner holding +/// 1% of the network hashrate will, over a 600-block window, quite ordinarily +/// show anywhere from 2 to 11 blocks. Short windows flatter the lucky and libel +/// the unlucky; the UI should show the window length beside the figure. +pub fn miner_hashrate(blocks_in_window: u32, window_total: u32, network: f64) -> Option { + if window_total == 0 { + return None; + } + Some(network * f64::from(blocks_in_window) / f64::from(window_total)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn u512_to_f64_survives_astronomical_difficulty() { + // 2^128, well past f64's exact-integer range but comfortably inside its + // exponent range — magnitude is what matters here. + let v = U512::from(1u8) << 128; + let f = u512_to_f64(v); + assert!((f / 2f64.powi(128) - 1.0).abs() < 1e-12); + } + + #[test] + fn u512_to_f64_is_exact_for_small_values() { + assert_eq!(u512_to_f64(U512::from(6u8)), 6.0); + assert_eq!(u512_to_f64(U512::zero()), 0.0); + } + + #[test] + fn hashrate_is_difficulty_over_interval() { + let d = U512::from(1_200_000_000u64); + assert_eq!(network_hashrate(d, Interval::Measured(6.0)), 200_000_000.0); + assert_eq!(network_hashrate(d, Interval::Target(6.0)), 200_000_000.0); + } + + #[test] + fn a_zero_interval_yields_zero_not_infinity() { + // Two blocks in the same second is ordinary; an infinite hashrate on + // the dashboard is not. + assert_eq!( + network_hashrate(U512::from(10u8), Interval::Measured(0.0)), + 0.0 + ); + } + + #[test] + fn nominal_is_distinguishable_from_measured() { + assert!(Interval::Target(6.0).is_nominal()); + assert!(!Interval::Measured(6.0).is_nominal()); + } + + #[test] + fn miner_share_scales_the_network_figure() { + assert_eq!(miner_hashrate(25, 100, 400.0), Some(100.0)); + assert_eq!(miner_hashrate(0, 100, 400.0), Some(0.0)); + // An empty window has no share to compute, and 0/0 must not become NaN + // on the dashboard. + assert_eq!(miner_hashrate(5, 0, 400.0), None); + } +} diff --git a/crates/blackbeard-core/src/lib.rs b/crates/blackbeard-core/src/lib.rs new file mode 100644 index 0000000..1739dfe --- /dev/null +++ b/crates/blackbeard-core/src/lib.rs @@ -0,0 +1,32 @@ +//! Chain decoding and leaderboard state for blackbeard.observer. +//! +//! Everything here is pure: given the same headers and the same telemetry +//! reports it produces the same standings, with no clock of its own, no socket +//! and no database. That is deliberate — the interesting parts of this project +//! are the decoding (which is easy to get subtly wrong and hard to notice) and +//! the statistics (which are easy to overstate), and both are far easier to +//! trust when they can be exercised by a unit test rather than only against a +//! live chain. +//! +//! The I/O that feeds it lives in `blackbeard-data`; the orchestration that +//! drives it lives in `blackbeard-api`. + +#![deny(missing_docs)] + +pub mod attribution; +pub mod digest; +pub mod hashrate; +pub mod scale; +pub mod window; + +/// Failures decoding what a node handed us. +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum CoreError { + /// A SCALE value ran off the end of its buffer. + #[error("truncated SCALE encoding")] + TruncatedScale, + + /// A compact integer wider than anything this decoder reads. + #[error("compact integer of {0} bytes is wider than this decoder reads")] + CompactTooWide(usize), +} diff --git a/crates/blackbeard-core/src/scale.rs b/crates/blackbeard-core/src/scale.rs new file mode 100644 index 0000000..4d6825f --- /dev/null +++ b/crates/blackbeard-core/src/scale.rs @@ -0,0 +1,151 @@ +//! The two pieces of SCALE this project needs, and nothing else. +//! +//! Pulling in a full SCALE codec plus chain metadata would buy generality we +//! never use: the observer reads exactly two things out of a block — the +//! author's reward preimage from a digest log and the author's timestamp from +//! the first extrinsic — and both are fixed-shape. Hand-decoding them keeps the +//! crate dependency-light and, more usefully, keeps the decoding auditable +//! against the runtime source. + +use crate::CoreError; + +/// Decode a SCALE compact integer at `offset`. +/// +/// Returns the value and the offset just past it. The four modes are a 6-bit +/// single byte, a 14-bit u16, a 30-bit u32, and a big-integer form whose first +/// byte carries the length. +pub fn compact(bytes: &[u8], offset: usize) -> Result<(u64, usize), CoreError> { + let first = *bytes.get(offset).ok_or(CoreError::TruncatedScale)?; + match first & 0b11 { + 0b00 => Ok((u64::from(first >> 2), offset + 1)), + 0b01 => { + let raw = bytes + .get(offset..offset + 2) + .ok_or(CoreError::TruncatedScale)?; + Ok(( + u64::from(u16::from_le_bytes([raw[0], raw[1]]) >> 2), + offset + 2, + )) + } + 0b10 => { + let raw = bytes + .get(offset..offset + 4) + .ok_or(CoreError::TruncatedScale)?; + let v = u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]) >> 2; + Ok((u64::from(v), offset + 4)) + } + _ => { + let len = usize::from(first >> 2) + 4; + if len > 8 { + // Nothing this decoder reads is wider than a u64: a compact + // length prefix or a millisecond timestamp. A wider value means + // we are decoding something we did not expect, and guessing at + // it would be worse than refusing. + return Err(CoreError::CompactTooWide(len)); + } + let raw = bytes + .get(offset + 1..offset + 1 + len) + .ok_or(CoreError::TruncatedScale)?; + let mut buf = [0u8; 8]; + buf[..len].copy_from_slice(raw); + Ok((u64::from_le_bytes(buf), offset + 1 + len)) + } + } +} + +/// The `Timestamp` pallet's index in the runtime (`pallet_index(1)`), call `0` +/// = `set`. The timestamp inherent is always a block's first extrinsic. +const TIMESTAMP_PALLET_INDEX: u8 = 1; +const TIMESTAMP_CALL_SET: u8 = 0; + +/// Extract the author's timestamp, in milliseconds, from a block's first +/// extrinsic. +/// +/// Returns `None` — rather than an error — when the extrinsic is not the +/// timestamp inherent. A block whose first extrinsic is something else is a +/// runtime shape we do not recognise, and the honest response is "no author +/// timestamp for this block", not a crash in the ingest loop. +/// +/// This timestamp is set when the author **builds** the proposal, not when the +/// block reaches the network. That is what makes it useful: compared against +/// when the observer saw the block, it separates proposal timing from release +/// timing, and a block released long after it was built is a withheld one. +pub fn timestamp_inherent_ms(extrinsic_hex: &str) -> Option { + let bytes = hex::decode(extrinsic_hex.strip_prefix("0x").unwrap_or(extrinsic_hex)).ok()?; + // Extrinsics are length-prefixed inside the block body. + let (_, i) = compact(&bytes, 0).ok()?; + // 0x04 is a v4 unsigned extrinsic, 0x05 a v5 bare one. Inherents are + // unsigned; anything else here is not the timestamp call. + let version = *bytes.get(i)?; + if version != 0x04 && version != 0x05 { + return None; + } + if *bytes.get(i + 1)? != TIMESTAMP_PALLET_INDEX || *bytes.get(i + 2)? != TIMESTAMP_CALL_SET { + return None; + } + compact(&bytes, i + 3).ok().map(|(ms, _)| ms) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compact_covers_all_four_modes() { + assert_eq!(compact(&[0b0000_0100], 0).unwrap(), (1, 1)); + // 0b01: 14-bit. 0x15 0x01 => 0x0115 >> 2 = 69. + assert_eq!(compact(&[0x15, 0x01], 0).unwrap(), (69, 2)); + // 0b10: 30-bit. 0xfeff0300 >> 2 = 65535. + assert_eq!(compact(&[0xfe, 0xff, 0x03, 0x00], 0).unwrap(), (65535, 4)); + // big-integer form, 4 bytes of payload. + assert_eq!( + compact(&[0x03, 0x00, 0x00, 0x00, 0x40], 0).unwrap(), + (0x4000_0000, 5) + ); + } + + #[test] + fn compact_refuses_to_run_off_the_end() { + assert!(matches!(compact(&[], 0), Err(CoreError::TruncatedScale))); + assert!(matches!( + compact(&[0x15], 0), + Err(CoreError::TruncatedScale) + )); + } + + #[test] + fn compact_refuses_values_wider_than_u64() { + // 0b11 with (first >> 2) = 12 means 16 payload bytes: a u128. Nothing + // this decoder reads is that wide, so it must refuse rather than + // truncate silently. + assert!(matches!( + compact( + &[0b0011_0011, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + 0 + ), + Err(CoreError::CompactTooWide(16)) + )); + } + + #[test] + fn timestamp_inherent_decodes_a_v4_set_call() { + // length prefix (0x2c = 11 bytes), v4 unsigned, pallet 1, call 0, + // compact u64 timestamp 1_757_000_000_000 ms. + let mut body = vec![0x04u8, TIMESTAMP_PALLET_INDEX, TIMESTAMP_CALL_SET]; + // 1_757_000_000_000 needs 6 bytes: big-integer compact, (6-4)<<2 | 0b11. + body.push(((6 - 4) << 2) | 0b11); + body.extend_from_slice(&1_757_000_000_000u64.to_le_bytes()[..6]); + let mut framed = vec![(body.len() as u8) << 2]; + framed.extend_from_slice(&body); + let hex = format!("0x{}", hex::encode(framed)); + assert_eq!(timestamp_inherent_ms(&hex), Some(1_757_000_000_000)); + } + + #[test] + fn timestamp_inherent_declines_anything_else() { + // A signed extrinsic (version byte 0x84) is not the inherent. + assert_eq!(timestamp_inherent_ms("0x0c840100"), None); + assert_eq!(timestamp_inherent_ms("not hex"), None); + assert_eq!(timestamp_inherent_ms(""), None); + } +} diff --git a/crates/blackbeard-core/src/window.rs b/crates/blackbeard-core/src/window.rs new file mode 100644 index 0000000..94f5f6d --- /dev/null +++ b/crates/blackbeard-core/src/window.rs @@ -0,0 +1,448 @@ +//! The rolling window and the standings computed from it. +//! +//! One window per chain, sized to the **longest** selectable window. Every +//! shorter window is then the tail of the same buffer, so switching from a week +//! to an hour costs a slice rather than a second set of state to keep in sync. + +use std::collections::HashMap; + +use blackbeard_entities::{AttributionSource, LeaderboardRow, MinerId}; +use chrono::{DateTime, Utc}; + +/// One observed block, reduced to what the standings need. +#[derive(Debug, Clone, PartialEq)] +pub struct Observed { + /// Block number. + pub height: u64, + /// Author's reward preimage. + pub miner: MinerId, + /// When this observer first saw it. + pub observed_at: DateTime, +} + +/// Per-miner totals over a window. +#[derive(Debug, Clone, PartialEq)] +pub struct MinerTally { + /// Blocks authored in the window. + pub blocks: u32, + /// Highest block this miner authored in the window. + pub last_block_height: u64, + /// When that block was observed. + pub last_seen: DateTime, + /// Longest run of back-to-back blocks by this miner inside the window. + pub best_streak: u32, +} + +/// A bounded ring of observed blocks, plus the tip samples that give the +/// measured block interval. +#[derive(Debug)] +pub struct RollingWindow { + capacity: usize, + blocks: std::collections::VecDeque, + /// Tip observations only: `(when observed, height)`. Blocks backfilled from + /// history must never land here — the elapsed time between importing two + /// historical blocks measures the node's disk, not the network's hashrate. + tip_samples: std::collections::VecDeque<(DateTime, u64)>, +} + +/// Tip samples needed before a measured interval is reported at all. +/// +/// Winning a block is a Poisson process, so consecutive gaps on a live chain +/// vary wildly — measured on Planck: 1.6 s, 4.4 s, 13.5 s, 26 s, 27 s in a row +/// against a 6 s target. Averaging five of those produces a headline hashrate +/// that swings by a factor of three between refreshes and is simply wrong. At +/// twenty samples the mean is stable enough to publish, and until then the +/// configured target is used and flagged as nominal. +const MIN_TIP_SAMPLES: usize = 20; +/// Tip samples retained. At a 6 s target this is roughly the last 20 minutes — +/// long enough to smooth Poisson noise, short enough to follow a real change. +const MAX_TIP_SAMPLES: usize = 200; + +impl RollingWindow { + /// A window holding at most `capacity` blocks. + pub fn new(capacity: usize) -> Self { + Self { + capacity, + blocks: std::collections::VecDeque::with_capacity(capacity.min(4096)), + tip_samples: std::collections::VecDeque::with_capacity(MAX_TIP_SAMPLES), + } + } + + /// Record a block. + /// + /// `at_tip` is false for blocks read out of history — a startup backfill or + /// a gap fill after an outage. Those still count towards the standings (the + /// leaderboard would otherwise restart empty on every deploy) but + /// contribute no timing sample. + pub fn push(&mut self, observed: Observed, at_tip: bool) { + if at_tip { + if self.tip_samples.len() == MAX_TIP_SAMPLES { + self.tip_samples.pop_front(); + } + self.tip_samples + .push_back((observed.observed_at, observed.height)); + } + if self.blocks.len() == self.capacity { + self.blocks.pop_front(); + } + self.blocks.push_back(observed); + } + + /// Blocks currently held. + pub fn len(&self) -> usize { + self.blocks.len() + } + + /// Whether the window has seen anything yet. + pub fn is_empty(&self) -> bool { + self.blocks.is_empty() + } + + /// The most recently observed block. + pub fn latest(&self) -> Option<&Observed> { + self.blocks.back() + } + + /// Mean seconds per block, measured at the tip. + /// + /// `None` until enough tip samples exist. Derived from height difference + /// rather than sample count so a missed poll — which observes two blocks at + /// once — does not read as the chain having sped up. + pub fn measured_interval(&self) -> Option { + if self.tip_samples.len() < MIN_TIP_SAMPLES { + return None; + } + let (first_at, first_h) = *self.tip_samples.front()?; + let (last_at, last_h) = *self.tip_samples.back()?; + let span = (last_at - first_at).as_seconds_f64(); + let heights = last_h.checked_sub(first_h)?; + if span <= 0.0 || heights == 0 { + return None; + } + Some(span / heights as f64) + } + + /// The last `n` blocks, newest last. + pub fn tail(&self, n: usize) -> impl DoubleEndedIterator { + self.blocks.iter().skip(self.blocks.len().saturating_sub(n)) + } + + /// Per-miner totals over the last `n` blocks, plus how many blocks that + /// actually was (fewer than `n` before the window has filled). + pub fn tally(&self, n: usize) -> (HashMap, u32) { + let mut tallies: HashMap = HashMap::new(); + let mut total = 0u32; + // Streaks are runs of *consecutive observations*, which is what the + // selfish-mining signature actually looks like. A gap in the observed + // heights breaks the run rather than joining across it, so a restart + // cannot manufacture a streak that never happened. + let mut run: Option<(MinerId, u64, u32)> = None; + + for b in self.tail(n) { + total += 1; + let entry = tallies.entry(b.miner.clone()).or_insert(MinerTally { + blocks: 0, + last_block_height: b.height, + last_seen: b.observed_at, + best_streak: 0, + }); + entry.blocks += 1; + if b.height >= entry.last_block_height { + entry.last_block_height = b.height; + entry.last_seen = b.observed_at; + } + + run = match run.take() { + Some((miner, prev_height, len)) + if miner == b.miner && b.height == prev_height + 1 => + { + Some((miner, b.height, len + 1)) + } + _ => Some((b.miner.clone(), b.height, 1)), + }; + if let Some((miner, _, len)) = &run + && let Some(t) = tallies.get_mut(miner) + && *len > t.best_streak + { + t.best_streak = *len; + } + } + (tallies, total) + } +} + +/// Build the standings for a window. +/// +/// Ranking is by blocks won, ties broken by the most recent block — so between +/// two miners on equal blocks, the one still winning them ranks higher. Rows +/// are not truncated here: capping belongs to the caller, which knows whether +/// it is filling a top-ten panel or answering a query for a specific miner +/// that may sit at rank four hundred. +pub fn leaderboard( + tallies: &HashMap, + window_total: u32, + network_hashrate: Option, + mut attribute: F, +) -> Vec +where + F: FnMut(&MinerId) -> (String, AttributionSource, f32), +{ + let mut ranked: Vec<(&MinerId, &MinerTally)> = tallies.iter().collect(); + ranked.sort_by(|a, b| { + b.1.blocks + .cmp(&a.1.blocks) + .then_with(|| b.1.last_block_height.cmp(&a.1.last_block_height)) + // Final tie-break on the id itself: without it, two miners with + // identical blocks and heights would swap places between renders + // purely on hash-map iteration order, and the table would jitter. + .then_with(|| a.0.cmp(b.0)) + }); + + let mut rows: Vec = ranked + .into_iter() + .enumerate() + .map(|(i, (miner, tally))| { + let (display, attribution, confidence) = attribute(miner); + LeaderboardRow { + rank: i as u32 + 1, + miner: miner.clone(), + display, + attribution, + confidence, + blocks: tally.blocks, + share: if window_total == 0 { + 0.0 + } else { + f64::from(tally.blocks) / f64::from(window_total) + }, + hashrate_estimate: network_hashrate + .and_then(|n| crate::hashrate::miner_hashrate(tally.blocks, window_total, n)), + last_block_height: tally.last_block_height, + last_seen: tally.last_seen, + best_streak: tally.best_streak, + } + }) + .collect(); + + disambiguate(&mut rows); + rows +} + +/// Make every display name in a set of standings unique. +/// +/// Two miners can legitimately arrive with the same name: one operator running +/// several reward preimages from identically-named nodes, or two people who +/// never renamed theirs off the default. Either way, two rows reading +/// `QuantusMinerGUI` side by side are indistinguishable — a miner looking for +/// their own row cannot tell which is theirs, which is exactly the question the +/// board exists to answer. The preimage is appended to *every* member of a +/// colliding group, so no row is silently privileged as "the real one". +/// +/// The suffix is the preimage's last four hex characters, not the full +/// abbreviated form: it only has to separate the two or three members of one +/// colliding group, and a longer suffix pushed the standings wide enough to +/// clip the hashrate column. The full preimage stays one hover away. +/// +/// Only the standings are disambiguated. The block ticker is a chronological +/// feed rather than a comparison, and each of its rows links to one miner, so a +/// repeated name there costs nothing. +fn disambiguate(rows: &mut [LeaderboardRow]) { + let mut counts: HashMap<&str, u32> = HashMap::new(); + for row in rows.iter() { + *counts.entry(row.display.as_str()).or_default() += 1; + } + let ambiguous: std::collections::HashSet = counts + .into_iter() + .filter(|(_, n)| *n > 1) + .map(|(name, _)| name.to_owned()) + .collect(); + if ambiguous.is_empty() { + return; + } + for row in rows.iter_mut() { + if ambiguous.contains(&row.display) { + let id = row.miner.as_str(); + row.display = format!("{} · {}", row.display, &id[id.len().saturating_sub(4)..]); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn miner(n: u8) -> MinerId { + MinerId(format!("0x{:064x}", n)) + } + + fn at(secs: i64) -> DateTime { + Utc.timestamp_opt(1_757_000_000 + secs, 0).unwrap() + } + + fn obs(height: u64, m: u8, secs: i64) -> Observed { + Observed { + height, + miner: miner(m), + observed_at: at(secs), + } + } + + #[test] + fn the_window_drops_the_oldest_block_when_full() { + let mut w = RollingWindow::new(3); + for h in 1..=5 { + w.push(obs(h, 1, h as i64), true); + } + assert_eq!(w.len(), 3); + assert_eq!(w.latest().unwrap().height, 5); + assert_eq!(w.tail(10).next().unwrap().height, 3); + } + + #[test] + fn a_short_window_is_the_tail_of_a_long_one() { + let mut w = RollingWindow::new(100); + for h in 1..=50 { + w.push(obs(h, if h % 2 == 0 { 1 } else { 2 }, h as i64), true); + } + let (all, total_all) = w.tally(100); + assert_eq!(total_all, 50); + assert_eq!(all[&miner(1)].blocks, 25); + let (recent, total_recent) = w.tally(10); + assert_eq!(total_recent, 10); + assert_eq!(recent[&miner(1)].blocks, 5); + } + + #[test] + fn interval_needs_enough_tip_samples() { + let mut w = RollingWindow::new(200); + for h in 1..MIN_TIP_SAMPLES as u64 { + w.push(obs(h, 1, h as i64 * 6), true); + } + assert_eq!( + w.measured_interval(), + None, + "too few samples to publish a mean" + ); + w.push( + obs(MIN_TIP_SAMPLES as u64, 1, MIN_TIP_SAMPLES as i64 * 6), + true, + ); + assert_eq!(w.measured_interval(), Some(6.0)); + } + + #[test] + fn a_missed_poll_does_not_read_as_a_faster_chain() { + // Blocks arrive between polls; the interval must come out of the height + // difference, not the number of samples taken. Here every third height + // is missed, so a count-based mean would report 9 s instead of 6 s. + let mut w = RollingWindow::new(200); + let mut height = 1u64; + for i in 0..MIN_TIP_SAMPLES + 5 { + w.push(obs(height, 1, (height as i64 - 1) * 6), true); + height += if i % 3 == 0 { 2 } else { 1 }; + } + assert_eq!(w.measured_interval(), Some(6.0)); + } + + #[test] + fn backfilled_blocks_count_but_do_not_time() { + // The whole point: a node importing history at disk speed would + // otherwise produce an interval of milliseconds and a hashrate wrong by + // several orders of magnitude. + let mut w = RollingWindow::new(100); + for h in 1..=20 { + w.push(obs(h, 1, 0), false); + } + assert_eq!(w.len(), 20); + assert_eq!(w.measured_interval(), None); + } + + #[test] + fn streaks_count_only_consecutive_heights() { + let mut w = RollingWindow::new(100); + for (h, m) in [(1u64, 1u8), (2, 1), (3, 1), (4, 2), (5, 1), (7, 1)] { + w.push(obs(h, m, h as i64), true); + } + let (t, _) = w.tally(100); + // 1,2,3 is a run of three; 5 and 7 are not adjacent, so no run of two. + assert_eq!(t[&miner(1)].best_streak, 3); + assert_eq!(t[&miner(1)].blocks, 5); + assert_eq!(t[&miner(2)].best_streak, 1); + } + + #[test] + fn standings_rank_by_blocks_then_recency() { + let mut w = RollingWindow::new(100); + for (h, m) in [(1u64, 1u8), (2, 2), (3, 1), (4, 3), (5, 2)] { + w.push(obs(h, m, h as i64), true); + } + let (t, total) = w.tally(100); + let rows = leaderboard(&t, total, Some(1000.0), |m| { + (m.abbreviated(), AttributionSource::Preimage, 0.0) + }); + assert_eq!(rows.len(), 3); + // 1 and 2 both have two blocks; 2's most recent is higher, so it leads. + assert_eq!(rows[0].miner, miner(2)); + assert_eq!(rows[1].miner, miner(1)); + assert_eq!(rows[2].miner, miner(3)); + assert_eq!(rows[0].rank, 1); + assert!((rows[0].share - 0.4).abs() < 1e-9); + assert_eq!(rows[0].hashrate_estimate, Some(400.0)); + } + + #[test] + fn miners_sharing_a_node_name_are_told_apart() { + // One operator running two reward preimages from identically-named + // nodes is ordinary, and two rows both reading "rig-01" would leave a + // miner unable to find their own. + let mut w = RollingWindow::new(100); + for (h, m) in [(1u64, 1u8), (2, 2), (3, 1), (4, 3)] { + w.push(obs(h, m, h as i64), true); + } + let (t, total) = w.tally(100); + let rows = leaderboard(&t, total, None, |m| { + // Miners 1 and 2 share a name; miner 3 has its own. + let name = if m == &miner(3) { "solo" } else { "rig-01" }; + (name.to_owned(), AttributionSource::Telemetry, 1.0) + }); + let displays: Vec<&str> = rows.iter().map(|r| r.display.as_str()).collect(); + assert_eq!( + displays + .iter() + .filter(|d| d.starts_with("rig-01 · ")) + .count(), + 2 + ); + // Short enough not to widen the table past its columns. + assert!(displays.iter().all(|d| d.len() <= "rig-01 · ".len() + 4)); + // The unaffected row keeps its clean name — disambiguation is applied + // to the colliding group only. + assert!(displays.contains(&"solo")); + // Every display is unique. + let unique: std::collections::HashSet<_> = displays.iter().collect(); + assert_eq!(unique.len(), displays.len()); + } + + #[test] + fn unique_names_are_left_alone() { + let mut w = RollingWindow::new(100); + w.push(obs(1, 1, 1), true); + let (t, total) = w.tally(100); + let rows = leaderboard(&t, total, None, |_| { + ("alice".to_owned(), AttributionSource::Telemetry, 1.0) + }); + assert_eq!(rows[0].display, "alice"); + } + + #[test] + fn an_empty_window_yields_no_rows_and_no_division_by_zero() { + let w = RollingWindow::new(100); + let (t, total) = w.tally(100); + assert!(w.is_empty()); + let rows = leaderboard(&t, total, Some(1000.0), |m| { + (m.abbreviated(), AttributionSource::Preimage, 0.0) + }); + assert!(rows.is_empty()); + } +} diff --git a/crates/blackbeard-data/Cargo.toml b/crates/blackbeard-data/Cargo.toml new file mode 100644 index 0000000..6700a24 --- /dev/null +++ b/crates/blackbeard-data/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "blackbeard-data" +description = "Chain RPC, telemetry feed and Postgres access for blackbeard.observer." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +blackbeard-core.workspace = true +blackbeard-entities.workspace = true + +bigdecimal.workspace = true +chrono.workspace = true +futures-util.workspace = true +primitive-types.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +sqlx.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-tungstenite.workspace = true +tracing.workspace = true +url.workspace = true + +[dev-dependencies] +tokio = { workspace = true } +sqlx = { workspace = true } +chrono = { workspace = true } diff --git a/crates/blackbeard-data/migrations/0001_init.sql b/crates/blackbeard-data/migrations/0001_init.sql new file mode 100644 index 0000000..0a85688 --- /dev/null +++ b/crates/blackbeard-data/migrations/0001_init.sql @@ -0,0 +1,73 @@ +-- Initial schema for blackbeard.observer. +-- +-- Migrations are sequentially versioned and immutable once committed +-- (architecture/generic.md §5): correct a mistake with a new file, never by +-- editing this one, or the runner's checksum diverges and it refuses to start. + +-- One row per chain the observer watches. Config in `config.toml` is the source +-- of truth for which chains exist; this table holds what the *node* told us +-- about them (genesis, token) plus the observer's own bookkeeping, so a restart +-- does not have to re-discover everything before it can serve a page. +create table chain ( + id text primary key, + display_name text not null, + mainnet boolean not null, + genesis text, + token_symbol text, + token_decimals smallint, + target_block_time_seconds double precision not null, + first_observed_at timestamptz not null default now(), + last_observed_at timestamptz +); + +-- Every block the observer has seen, with its author. +-- +-- Keyed on (chain, height) rather than on the block hash, deliberately: this is +-- a proof-of-work chain and it reorgs. An upsert on this key means a block that +-- replaces another at the same height overwrites it, which is exactly the +-- semantics wanted — the leaderboard should reflect the canonical chain, not +-- the union of every fork the observer happened to witness. +create table block ( + chain text not null references chain (id) on delete cascade, + height bigint not null, + hash text not null, + miner text not null, + -- The author's own timestamp, from the block's Timestamp::set inherent. Set + -- when the author BUILDS the proposal, so it measures proposal timing. + authored_at timestamptz, + -- When this observer first saw the block. The difference between the two is + -- propagation plus poll lag when negative, a future-dated block when + -- positive, and a withheld block when large and positive-going. + observed_at timestamptz not null, + -- Expected hashes to win this block. numeric, not bigint: difficulty is a + -- U512 and routinely past 1e30, so every integer type Postgres has would + -- overflow. numeric is arbitrary-precision and orders and sums correctly. + difficulty numeric, + primary key (chain, height) +); + +-- The leaderboard's hot query: the last N blocks of a chain, newest first. +create index block_chain_height_desc_idx on block (chain, height desc); + +-- The per-miner history query: one miner's blocks over a time range. +create index block_chain_miner_observed_idx on block (chain, miner, observed_at desc); + +-- Held telemetry attributions, so a restart does not strip every author of its +-- name for a few blocks. +-- +-- That churn is not cosmetic: a row whose display flips from a node name to a +-- raw preimage and back splits the miner's series on every deploy, which is +-- precisely the continuity this site exists to provide. +create table miner_attribution ( + chain text not null references chain (id) on delete cascade, + miner text not null, + -- 'peer' for a libp2p peer id (stable across restarts and renames) or + -- 'node' for a telemetry node id (per-connection, the fallback). + node_kind text not null check (node_kind in ('peer', 'node')), + node_key text not null, + node_name text, + attempts integer not null default 0, + attributed integer not null default 0, + updated_at timestamptz not null default now(), + primary key (chain, miner) +); diff --git a/crates/blackbeard-data/src/lib.rs b/crates/blackbeard-data/src/lib.rs new file mode 100644 index 0000000..26b5af8 --- /dev/null +++ b/crates/blackbeard-data/src/lib.rs @@ -0,0 +1,56 @@ +//! Everything that talks to something outside this process: the chain's +//! JSON-RPC, the substrate-telemetry feed, and Postgres. +//! +//! The split is deliberate. `blackbeard-core` decodes and tallies with no I/O +//! at all and is exercised entirely by unit tests; this crate does the talking +//! and holds the retry, reconnect and schema concerns. `blackbeard-api` wires +//! the two together. + +#![deny(missing_docs)] + +pub mod rpc; +pub mod store; +pub mod telemetry; + +/// Failures reaching a node, the telemetry feed, or the database. +#[derive(Debug, thiserror::Error)] +pub enum DataError { + /// The node answered with a JSON-RPC error, or an unusable result. + #[error("rpc {method}: {message}")] + Rpc { + /// Which method. + method: String, + /// What the node said. + message: String, + }, + + /// The HTTP request itself failed — connection refused, timeout, TLS. + #[error("http transport: {0}")] + Http(#[from] reqwest::Error), + + /// A WebSocket connection failed or dropped. + /// + /// Boxed: tungstenite's error is by far the largest variant here, and an + /// unboxed copy would be paid for on the stack by every `Result` in this + /// crate — including the RPC calls made several times a second. + #[error("websocket: {0}")] + WebSocket(#[from] Box), + + /// A response did not have the shape we expected. + #[error("decoding a response: {0}")] + Decode(#[from] serde_json::Error), + + /// A database query failed. + #[error("database: {0}")] + Database(#[from] sqlx::Error), + + /// A migration failed to apply. + #[error("migration: {0}")] + Migration(#[from] sqlx::migrate::MigrateError), +} + +impl From for DataError { + fn from(e: tokio_tungstenite::tungstenite::Error) -> Self { + DataError::WebSocket(Box::new(e)) + } +} diff --git a/crates/blackbeard-data/src/rpc.rs b/crates/blackbeard-data/src/rpc.rs new file mode 100644 index 0000000..51abd74 --- /dev/null +++ b/crates/blackbeard-data/src/rpc.rs @@ -0,0 +1,321 @@ +//! Talking to a Quantus node's JSON-RPC. +//! +//! Two transports on the **same** port, because `9944` serves HTTP and +//! WebSocket both: +//! +//! - [`RpcClient`] over HTTP for request/response — block hashes, bodies, +//! runtime calls. Stateless, so a node restart costs one failed request +//! rather than a reconnect dance. +//! - [`subscribe_new_heads`] over WebSocket for the head stream. The node +//! pushes, so the observer learns about a block the moment the node imports +//! it instead of up to a poll interval later — which is what lets the +//! browser's socket be genuinely live rather than merely frequent. +//! +//! No `subxt`. It would bring runtime metadata, a codec and a type registry to +//! read two fixed-shape fields the observer already decodes by hand +//! (`blackbeard-core::digest`), and it refuses plain `ws://` to anything but +//! localhost — which would mean either TLS or an ssh tunnel between the +//! observer and a node sitting on the same host. + +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use serde::Deserialize; +use serde_json::{Value, json}; +use tokio::sync::mpsc; + +use crate::DataError; + +/// A Substrate block header, reduced to the fields the observer reads. +/// +/// `#[serde(rename_all = "camelCase")]` matches the RPC's JSON. Unknown fields +/// are ignored rather than rejected — Quantus headers carry a `zkTreeRoot` the +/// upstream Substrate shape does not, and future fields must not break ingest. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Header { + /// Block number as `0x`-prefixed hex. + pub number: String, + /// Parent block hash. + pub parent_hash: String, + /// Consensus digest logs. The `pow_` PreRuntime log in here carries the + /// author's reward preimage. + pub digest: Digest, +} + +/// A header's digest logs. +#[derive(Debug, Clone, Deserialize)] +pub struct Digest { + /// Hex-encoded logs, in order. + pub logs: Vec, +} + +impl Header { + /// The block number, decoded from its hex form. + pub fn height(&self) -> Option { + u64::from_str_radix(self.number.strip_prefix("0x").unwrap_or(&self.number), 16).ok() + } +} + +/// What `system_health` reports. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Health { + /// True while the node is importing history rather than following the tip. + pub is_syncing: bool, + /// Connected peers. + pub peers: u32, +} + +/// What `system_properties` reports. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChainProperties { + /// Token ticker, e.g. `PLK`. + pub token_symbol: Option, + /// Decimal places in the smallest unit. + pub token_decimals: Option, + /// SS58 address prefix. + pub ss58_format: Option, +} + +/// An HTTP JSON-RPC client for one node. +#[derive(Debug, Clone)] +pub struct RpcClient { + http: reqwest::Client, + url: String, +} + +impl RpcClient { + /// Build a client for `url` (`http://host:9944`). + pub fn new(url: impl Into, timeout: Duration) -> Result { + Ok(Self { + http: reqwest::Client::builder() + .timeout(timeout) + // The node is one host away and answers thousands of these per + // minute; without pooling every call would pay a fresh TCP + // handshake. + .pool_idle_timeout(Duration::from_secs(90)) + .build()?, + url: url.into(), + }) + } + + /// Issue one JSON-RPC call. + pub async fn call(&self, method: &str, params: Value) -> Result { + let body = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}); + let resp: Value = self + .http + .post(&self.url) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?; + if let Some(err) = resp.get("error") { + return Err(DataError::Rpc { + method: method.to_owned(), + message: err.to_string(), + }); + } + resp.get("result").cloned().ok_or_else(|| DataError::Rpc { + method: method.to_owned(), + message: "response carried neither result nor error".into(), + }) + } + + /// `system_health`. + pub async fn health(&self) -> Result { + Ok(serde_json::from_value( + self.call("system_health", json!([])).await?, + )?) + } + + /// `system_properties` — token symbol, decimals, SS58 prefix. + pub async fn properties(&self) -> Result { + Ok(serde_json::from_value( + self.call("system_properties", json!([])).await?, + )?) + } + + /// The current best header, or the header at `hash`. + pub async fn header(&self, hash: Option<&str>) -> Result, DataError> { + let params = match hash { + Some(h) => json!([h]), + None => json!([]), + }; + let v = self.call("chain_getHeader", params).await?; + if v.is_null() { + return Ok(None); + } + Ok(Some(serde_json::from_value(v)?)) + } + + /// The block hash at `height`, if the node has that block. + pub async fn block_hash(&self, height: u64) -> Result, DataError> { + let v = self.call("chain_getBlockHash", json!([height])).await?; + Ok(v.as_str().map(str::to_owned)) + } + + /// The genesis hash. Discovered rather than configured — one fewer value to + /// get wrong when a chain launches or is respecced. + pub async fn genesis(&self) -> Result, DataError> { + self.block_hash(0).await + } + + /// The author's timestamp for a block, in milliseconds. + /// + /// `None` when the block's first extrinsic is not the timestamp inherent — + /// a shape we do not recognise, which costs this block its timing data and + /// nothing else. + pub async fn block_timestamp_ms(&self, hash: &str) -> Result, DataError> { + let v = self.call("chain_getBlock", json!([hash])).await?; + let first = v + .pointer("/block/extrinsics/0") + .and_then(Value::as_str) + .map(str::to_owned); + Ok(first + .as_deref() + .and_then(blackbeard_core::scale::timestamp_inherent_ms)) + } + + /// Current mining difficulty: expected hashes to win a block. + pub async fn difficulty(&self) -> Result { + self.u512_runtime_call("QPoWApi_get_difficulty").await + } + + /// The ceiling difficulty can reach. + pub async fn max_difficulty(&self) -> Result { + self.u512_runtime_call("QPoWApi_get_max_difficulty").await + } + + async fn u512_runtime_call(&self, api: &str) -> Result { + let v = self.call("state_call", json!([api, "0x"])).await?; + let hex = v.as_str().ok_or_else(|| DataError::Rpc { + method: api.to_owned(), + message: "runtime call did not return a hex string".into(), + })?; + blackbeard_core::digest::u512_le(hex).ok_or_else(|| DataError::Rpc { + method: api.to_owned(), + message: format!("`{hex}` is not a little-endian U512"), + }) + } +} + +/// How long to wait between reconnection attempts on the head subscription. +const RECONNECT_DELAY: Duration = Duration::from_secs(5); + +/// Follow a node's new heads over WebSocket, forwarding each to `sink`. +/// +/// Runs until the channel closes. Reconnects on its own: a node restart, a +/// dropped mesh link or a proxy timeout are ordinary operating conditions here, +/// not reasons to stop watching a chain — and a chain configured before it +/// launches will spend its first weeks failing to connect on every attempt, +/// which must stay quiet rather than filling the journal. +/// +/// **Heads are not a complete block record.** `chain_subscribeNewHeads` reports +/// the *best* head and skips intermediate blocks when several import at once, +/// so the caller must fill gaps against `chain_getBlockHash`. It is a liveness +/// signal, not a ledger. +pub async fn subscribe_new_heads( + ws_url: String, + sink: mpsc::Sender
, +) -> Result<(), DataError> { + let mut backoff_logged = false; + loop { + if sink.is_closed() { + return Ok(()); + } + match follow_once(&ws_url, &sink).await { + Ok(()) => { + tracing::info!(url = %ws_url, "head subscription closed cleanly, reconnecting"); + backoff_logged = false; + } + Err(e) => { + // First failure at warn, the rest at debug. A chain that has not + // launched fails every five seconds forever; logging each at warn + // would bury everything else in the journal. + if backoff_logged { + tracing::debug!(url = %ws_url, error = %e, "head subscription still down"); + } else { + tracing::warn!(url = %ws_url, error = %e, "head subscription lost"); + backoff_logged = true; + } + } + } + tokio::time::sleep(RECONNECT_DELAY).await; + } +} + +async fn follow_once(ws_url: &str, sink: &mpsc::Sender
) -> Result<(), DataError> { + let (mut socket, _) = tokio_tungstenite::connect_async(ws_url).await?; + socket + .send(tokio_tungstenite::tungstenite::Message::Text( + json!({"jsonrpc": "2.0", "id": 1, "method": "chain_subscribeNewHeads", "params": []}) + .to_string(), + )) + .await?; + + while let Some(msg) = socket.next().await { + let msg = msg?; + // Text and binary both accepted. Substrate sends text, but the + // telemetry feed on this same fleet sends binary (see + // `telemetry::follow_once`), and a client that silently drops the wrong + // frame type looks perfectly healthy while receiving nothing. + let payload = match msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.as_bytes().to_vec(), + tokio_tungstenite::tungstenite::Message::Binary(b) => b.to_vec(), + // The library answers pings itself; close ends the loop and the + // caller reconnects. + tokio_tungstenite::tungstenite::Message::Close(_) => break, + _ => continue, + }; + let v: Value = match serde_json::from_slice(&payload) { + Ok(v) => v, + Err(e) => { + tracing::debug!(error = %e, "unparsable frame on the head subscription"); + continue; + } + }; + // The subscription confirmation carries `result` as a bare id; only + // notifications carry `params.result`. + let Some(result) = v.pointer("/params/result") else { + continue; + }; + match serde_json::from_value::
(result.clone()) { + Ok(header) => { + if sink.send(header).await.is_err() { + return Ok(()); + } + } + Err(e) => tracing::warn!(error = %e, "head notification did not decode as a header"), + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn header_height_decodes_hex() { + let h: Header = serde_json::from_value(json!({ + "number": "0xfe2a8", + "parentHash": "0x87f4", + "stateRoot": "0x3398", + "zkTreeRoot": "0xd382", + "digest": {"logs": ["0x06706f775f80aa"]} + })) + .expect("a Quantus header carries fields upstream Substrate does not; ignore them"); + assert_eq!(h.height(), Some(1_041_064)); + assert_eq!(h.digest.logs.len(), 1); + } + + #[test] + fn a_header_missing_its_digest_is_a_decode_error_not_a_panic() { + let r = serde_json::from_value::
(json!({"number": "0x1", "parentHash": "0x0"})); + assert!(r.is_err()); + } +} diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs new file mode 100644 index 0000000..240b3b7 --- /dev/null +++ b/crates/blackbeard-data/src/store.rs @@ -0,0 +1,473 @@ +//! Postgres access. +//! +//! ## Why there is a database at all +//! +//! The rolling window in `blackbeard-core` is enough to render a leaderboard, +//! and the arena exporter this project descends from needs nothing more. But +//! this site promises miners they can *track* their effort, and a window that +//! empties on every restart cannot answer "how did I do last week" or draw a +//! hashrate line older than the process. Blocks therefore land in Postgres as +//! they are observed, and the window is rebuilt from it at startup. +//! +//! ## Connection +//! +//! mTLS, passwordless, per `architecture/generic.md` §5 and §11: the host's own +//! certificate is the credential and `pg_ident.conf` maps its CN to the role. +//! **No password is accepted anywhere in this module** — if a connection string +//! ever needs one, something is wrong upstream of here. +//! +//! Host certificates are issued with a **24-hour** expiry and renewed +//! continuously, which is why the pool caps connection lifetime well below that: +//! Postgres does not re-validate a certificate mid-session, so a long-lived +//! connection would keep working on an expired cert until it dropped — and then +//! reconnect, at an arbitrary moment, with whatever is on disk. Recycling on a +//! schedule makes that transition routine instead of a surprise. + +use std::time::Duration; + +use bigdecimal::BigDecimal; +use blackbeard_core::attribution::NodeKey; +use blackbeard_core::window::Observed; +use blackbeard_entities::{ChainId, MinerId}; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode}; + +use crate::DataError; + +/// How the store reaches Postgres. +#[derive(Debug, Clone)] +pub struct StoreConfig { + /// Server hostname. Verified against the server certificate, so it must be + /// the name in that certificate, not an address. + pub host: String, + /// Server port. + pub port: u16, + /// Database name. + pub database: String, + /// Role to connect as. Resolved from the client certificate's CN by + /// `pg_ident.conf` on the server; it is named here so a host mapped to more + /// than one role can say which it wants. + pub username: String, + /// Internal root CA bundle, for verifying the server. + pub root_cert: std::path::PathBuf, + /// This host's certificate — the credential. + pub client_cert: std::path::PathBuf, + /// This host's private key. + pub client_key: std::path::PathBuf, + /// Maximum pooled connections. + pub max_connections: u32, +} + +/// A connected Postgres store. +#[derive(Debug, Clone)] +pub struct Store { + pool: PgPool, +} + +/// A block on its way into the database. +#[derive(Debug, Clone)] +pub struct BlockRecord { + /// Which chain. + pub chain: ChainId, + /// Block number. + pub height: u64, + /// Block hash. + pub hash: String, + /// Author's reward preimage. + pub miner: MinerId, + /// The author's own timestamp. + pub authored_at: Option>, + /// When this observer first saw it. + pub observed_at: DateTime, + /// Difficulty in force, as a decimal string. + pub difficulty: Option, +} + +/// What the observer knows about a chain, for the `chain` table. +#[derive(Debug, Clone)] +pub struct ChainRecord { + /// Operator name for the chain. + pub chain: ChainId, + /// Human-facing name. + pub display_name: String, + /// True for the production network. + pub mainnet: bool, + /// Genesis hash, once the node has told us. + pub genesis: Option, + /// Token ticker. + pub token_symbol: Option, + /// Token decimals. + pub token_decimals: Option, + /// Configured target seconds per block. + pub target_block_time: f64, +} + +/// A held attribution loaded back from the database. +#[derive(Debug, Clone)] +pub struct StoredAttribution { + /// Which miner. + pub miner: MinerId, + /// The node it is attributed to. + pub key: NodeKey, + /// That node's name when it was stored, so a name resolves before the feed + /// has re-announced the node. + pub name: Option, + /// Blocks checked. + pub attempts: u32, + /// Blocks that produced a usable vote. + pub attributed: u32, +} + +/// Connections are recycled well inside the 24-hour host-certificate lifetime, +/// so certificate rotation is a routine reconnect rather than a surprise +/// mid-session failure. +const MAX_CONNECTION_LIFETIME: Duration = Duration::from_secs(60 * 60); + +impl Store { + /// Connect and run migrations. + pub async fn connect(config: &StoreConfig) -> Result { + let options = PgConnectOptions::new() + .host(&config.host) + .port(config.port) + .database(&config.database) + .username(&config.username) + // verify-full, not verify-ca: verify-ca proves the server holds a + // certificate from our CA but not that it is the server we asked + // for, which any other host on the mesh also satisfies. + .ssl_mode(PgSslMode::VerifyFull) + .ssl_root_cert(&config.root_cert) + .ssl_client_cert(&config.client_cert) + .ssl_client_key(&config.client_key); + + let pool = PgPoolOptions::new() + .max_connections(config.max_connections) + .acquire_timeout(Duration::from_secs(10)) + .max_lifetime(MAX_CONNECTION_LIFETIME) + .connect_with(options) + .await?; + + sqlx::migrate!("./migrations").run(&pool).await?; + Ok(Self { pool }) + } + + /// Wrap an already-built pool. Used by tests and by the CLI, which may be + /// pointed at a throwaway database. + pub fn from_pool(pool: PgPool) -> Self { + Self { pool } + } + + /// Run migrations against an existing pool. + pub async fn migrate(&self) -> Result<(), DataError> { + sqlx::migrate!("./migrations").run(&self.pool).await?; + Ok(()) + } + + /// Record what the node told us about a chain. + /// + /// Called on every successful poll, so it must be an upsert: the row exists + /// from the first poll onward, and genesis and token properties are + /// discovered rather than configured. + pub async fn upsert_chain(&self, record: &ChainRecord) -> Result<(), DataError> { + let ChainRecord { + chain, + display_name, + mainnet, + genesis, + token_symbol, + token_decimals, + target_block_time, + } = record; + sqlx::query!( + r#" + insert into chain ( + id, display_name, mainnet, genesis, token_symbol, token_decimals, + target_block_time_seconds, last_observed_at + ) + values ($1, $2, $3, $4, $5, $6, $7, now()) + on conflict (id) do update set + display_name = excluded.display_name, + mainnet = excluded.mainnet, + -- coalesce, not excluded: a poll that could not reach the node + -- must not blank out what an earlier one discovered. + genesis = coalesce(excluded.genesis, chain.genesis), + token_symbol = coalesce(excluded.token_symbol, chain.token_symbol), + token_decimals = coalesce(excluded.token_decimals, chain.token_decimals), + target_block_time_seconds = excluded.target_block_time_seconds, + last_observed_at = now() + "#, + chain.as_str(), + display_name, + mainnet, + genesis.as_deref(), + token_symbol.as_deref(), + *token_decimals, + *target_block_time, + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Insert or replace a batch of blocks. + /// + /// One statement with array parameters rather than a loop: a startup + /// backfill inserts tens of thousands of rows, and a round trip each would + /// take minutes. The `on conflict` is what makes a reorg correct — a block + /// replacing another at the same height overwrites it. + pub async fn record_blocks(&self, blocks: &[BlockRecord]) -> Result { + if blocks.is_empty() { + return Ok(0); + } + let chains: Vec = blocks.iter().map(|b| b.chain.0.clone()).collect(); + let heights: Vec = blocks.iter().map(|b| b.height as i64).collect(); + let hashes: Vec = blocks.iter().map(|b| b.hash.clone()).collect(); + let miners: Vec = blocks.iter().map(|b| b.miner.0.clone()).collect(); + let authored: Vec>> = blocks.iter().map(|b| b.authored_at).collect(); + let observed: Vec> = blocks.iter().map(|b| b.observed_at).collect(); + let difficulties: Vec> = blocks + .iter() + .map(|b| b.difficulty.as_deref().and_then(|d| d.parse().ok())) + .collect(); + + let result = sqlx::query!( + r#" + insert into block (chain, height, hash, miner, authored_at, observed_at, difficulty) + select * from unnest( + $1::text[], $2::bigint[], $3::text[], $4::text[], + $5::timestamptz[], $6::timestamptz[], $7::numeric[] + ) + on conflict (chain, height) do update set + hash = excluded.hash, + miner = excluded.miner, + authored_at = excluded.authored_at, + observed_at = excluded.observed_at, + difficulty = excluded.difficulty + "#, + &chains, + &heights, + &hashes, + &miners, + &authored as &[Option>], + &observed, + &difficulties as &[Option], + ) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } + + /// The highest block recorded for a chain, if any. + pub async fn max_height(&self, chain: &ChainId) -> Result, DataError> { + let row = sqlx::query!( + "select max(height) as height from block where chain = $1", + chain.as_str() + ) + .fetch_one(&self.pool) + .await?; + Ok(row.height.map(|h| h as u64)) + } + + /// The last `limit` blocks of a chain, **oldest first** — the order + /// `RollingWindow::push` expects. + pub async fn recent_blocks( + &self, + chain: &ChainId, + limit: i64, + ) -> Result, DataError> { + let rows = sqlx::query!( + r#" + select height, miner, observed_at + from ( + select height, miner, observed_at + from block + where chain = $1 + order by height desc + limit $2 + ) recent + order by height asc + "#, + chain.as_str(), + limit, + ) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|r| Observed { + height: r.height as u64, + miner: MinerId(r.miner), + observed_at: r.observed_at, + }) + .collect()) + } + + /// Totals for one miner across everything the observer has recorded. + /// + /// `cumulative_work` is the sum of the difficulty of every block the miner + /// won: the expected number of hashes it took. That is the honest + /// cumulative measure — a block count flatters miners who were around when + /// difficulty was low, and this does not. + pub async fn miner_totals( + &self, + chain: &ChainId, + miner: &MinerId, + ) -> Result<(u64, Option>, Option>, String), DataError> { + let row = sqlx::query!( + r#" + select count(*) as "blocks!", + min(observed_at) as first_seen, + max(observed_at) as last_seen, + coalesce(sum(difficulty), 0)::text as "work!" + from block + where chain = $1 and miner = $2 + "#, + chain.as_str(), + miner.as_str(), + ) + .fetch_one(&self.pool) + .await?; + + Ok((row.blocks as u64, row.first_seen, row.last_seen, row.work)) + } + + /// A miner's blocks bucketed over time, alongside the total blocks in each + /// bucket so a share can be computed. + /// + /// Buckets come from `date_bin`, which anchors them to a fixed epoch rather + /// than to the range's start — so the same bucket boundaries fall in the + /// same places on every request, and a chart does not shift under the + /// reader as time passes. + pub async fn miner_series( + &self, + chain: &ChainId, + miner: &MinerId, + since: DateTime, + bucket: Duration, + ) -> Result, u32, u32)>, DataError> { + let interval = sqlx::postgres::types::PgInterval { + months: 0, + days: 0, + microseconds: bucket.as_micros() as i64, + }; + let rows = sqlx::query!( + r#" + select date_bin($3, observed_at, timestamptz 'epoch') as "bucket!", + count(*) filter (where miner = $2) as "mine!", + count(*) as "total!" + from block + where chain = $1 and observed_at >= $4 + group by 1 + order by 1 asc + "#, + chain.as_str(), + miner.as_str(), + interval, + since, + ) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|r| (r.bucket, r.mine as u32, r.total as u32)) + .collect()) + } + + /// Persist the attributions currently held. + pub async fn save_attributions( + &self, + chain: &ChainId, + held: &[(MinerId, NodeKey, u32, u32, Option)], + ) -> Result<(), DataError> { + if held.is_empty() { + return Ok(()); + } + let miners: Vec = held.iter().map(|h| h.0.0.clone()).collect(); + let (kinds, keys): (Vec, Vec) = held + .iter() + .map(|h| match &h.1 { + NodeKey::Peer(p) => ("peer".to_owned(), p.clone()), + NodeKey::Node(n) => ("node".to_owned(), n.to_string()), + }) + .unzip(); + let attempts: Vec = held.iter().map(|h| h.2 as i32).collect(); + let attributed: Vec = held.iter().map(|h| h.3 as i32).collect(); + let names: Vec> = held.iter().map(|h| h.4.clone()).collect(); + + sqlx::query!( + r#" + insert into miner_attribution + (chain, miner, node_kind, node_key, attempts, attributed, node_name, updated_at) + select $1, m, k, key, a, att, n, now() + from unnest($2::text[], $3::text[], $4::text[], $5::int[], $6::int[], $7::text[]) + as t(m, k, key, a, att, n) + on conflict (chain, miner) do update set + node_kind = excluded.node_kind, + node_key = excluded.node_key, + attempts = excluded.attempts, + attributed = excluded.attributed, + -- A name the feed cannot currently resolve must not erase the + -- one we already had: a telemetry hiccup would otherwise strip + -- every miner's name from the next restart onwards. + node_name = coalesce(excluded.node_name, miner_attribution.node_name), + updated_at = now() + "#, + chain.as_str(), + &miners, + &kinds, + &keys, + &attempts, + &attributed, + &names as &[Option], + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Load held attributions for a chain. + pub async fn load_attributions( + &self, + chain: &ChainId, + ) -> Result, DataError> { + let rows = sqlx::query!( + r#" + select miner, node_kind, node_key, node_name, attempts, attributed + from miner_attribution + where chain = $1 + "#, + chain.as_str(), + ) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .filter_map(|r| { + let key = match r.node_kind.as_str() { + "peer" => NodeKey::Peer(r.node_key), + // A node id that no longer parses is a row written by an + // older shape; dropping it costs one miner its name until + // the next few blocks re-earn it. + "node" => NodeKey::Node(r.node_key.parse().ok()?), + _ => return None, + }; + Some(StoredAttribution { + miner: MinerId(r.miner), + key, + name: r.node_name, + attempts: r.attempts.max(0) as u32, + attributed: r.attributed.max(0) as u32, + }) + }) + .collect()) + } + + /// A cheap liveness probe for the health endpoint. + pub async fn ping(&self) -> Result<(), DataError> { + sqlx::query!("select 1 as ok").fetch_one(&self.pool).await?; + Ok(()) + } +} diff --git a/crates/blackbeard-data/src/telemetry.rs b/crates/blackbeard-data/src/telemetry.rs new file mode 100644 index 0000000..fd4b93d --- /dev/null +++ b/crates/blackbeard-data/src/telemetry.rs @@ -0,0 +1,465 @@ +//! The substrate-telemetry feed. +//! +//! Two things the chain itself cannot tell us come from here. +//! +//! **The node population.** A miner too small to win a block in a given window +//! is invisible in the authorship leaderboard but still present on the feed. +//! Node count holding flat while distinct authors falls is the signature of +//! small miners still running and simply being out-hashed — the difference +//! between "people are giving up" and "people are losing", which a leaderboard +//! alone cannot distinguish. +//! +//! **Names.** The feed reports block imports with a propagation time, stamping +//! the first reporter of a hash with `0`. A node imports its own block before +//! announcing it, so the first reporter is usually the author — enough, over +//! many blocks, to put a name to a preimage. The voting that turns "usually" +//! into something safe to display lives in `blackbeard-core::attribution`; this +//! module only supplies the evidence. +//! +//! Everything here is **optional**. A chain with no telemetry URL configured, or +//! a feed that is down, costs the site node counts and miner names and nothing +//! else — the leaderboard, hashrate and block ticker all come from the chain. +//! +//! ## Protocol +//! +//! Connect, send `subscribe:`, then receive JSON arrays of +//! alternating `[action_code, payload, action_code, payload, …]`. The codes are +//! substrate-telemetry's `FeedMessage` discriminants; the four that matter are +//! below. Payload shapes are positional arrays with no field names, which is +//! why every access here is bounds-checked and a malformed payload increments a +//! counter instead of taking the feed down. + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use blackbeard_core::attribution::NodeKey; +use futures_util::{SinkExt, StreamExt}; +use serde_json::Value; + +use crate::DataError; + +/// `AddedNode`: `[node_id, [name, implementation, version, …, network_id, os, …, hardware]]` +const ACTION_ADDED_NODE: u64 = 3; +/// `RemovedNode`: `node_id` +const ACTION_REMOVED_NODE: u64 = 4; +/// `ImportedBlock`: `[node_id, [height, hash, block_time, timestamp, propagation_ms]]` +const ACTION_IMPORTED_BLOCK: u64 = 6; +/// `AddedChain`: `[name, genesis, node_count]` +const ACTION_ADDED_CHAIN: u64 = 11; + +/// Block hashes retained for attribution. +/// +/// At a 6 s target this is roughly the last six hours — far longer than the few +/// seconds the attribution join actually needs, but cheap, and it means a stalled +/// ingest loop catching up does not find every block already evicted. +const MAX_TRACKED_IMPORTS: usize = 4_000; + +/// Delay between reconnection attempts. +const RECONNECT_DELAY: Duration = Duration::from_secs(15); + +#[derive(Debug, Default)] +struct ImportRecord { + first: Option, + /// Smallest positive propagation time seen from any *later* reporter: the + /// first reporter's lead. `None` until a second node reports the block. + second_ms: Option, +} + +#[derive(Debug, Default)] +struct State { + connected: bool, + errors: u64, + /// Authoritative count for the chain, from `AddedChain`. Nodes we have + /// individually seen is a different (and smaller) number — we only learn + /// about nodes added while connected. + chain_node_count: Option, + /// Node id to `(name, peer id)`. Node ids are per-connection; peer ids are + /// not, which is why both are kept. + names: HashMap, + /// Peer id to its most recent name. Survives reconnects and renames, so an + /// attribution made before a feed hiccup still renders a name after it. + peer_names: HashMap, + imports: HashMap, + import_order: VecDeque, +} + +/// A live view of one chain's telemetry feed. +/// +/// Cheap to clone — every clone shares the same state — so the ingest loop and +/// the HTTP handlers can each hold one. +#[derive(Debug, Clone, Default)] +pub struct TelemetryFeed { + state: Arc>, +} + +impl TelemetryFeed { + /// An unconnected feed. Reports nothing; every accessor degrades to `None` + /// or `false`. This is what a chain with no telemetry URL gets. + pub fn new() -> Self { + Self::default() + } + + /// Whether the feed is currently connected. + pub fn connected(&self) -> bool { + self.read().connected + } + + /// Nodes on this chain, per the feed's own count. + pub fn node_count(&self) -> Option { + self.read().chain_node_count + } + + /// Malformed payloads seen since start. A feed that is connected but whose + /// error count climbs means substrate-telemetry changed a payload shape. + pub fn decode_errors(&self) -> u64 { + self.read().errors + } + + /// Who reported a block first, and by how much they led the second + /// reporter. + /// + /// `None` when the block never appeared on the feed. `Some((key, None))` + /// when exactly one node has reported it so far — a lead cannot be computed + /// from a single report, and treating "nobody else has spoken yet" as an + /// infinite lead would attribute a block to whichever node happened to be + /// polled first. + pub fn first_import(&self, block_hash: &str) -> Option<(NodeKey, Option)> { + let state = self.read(); + let record = state.imports.get(block_hash)?; + let node_id = record.first?; + Some((state.node_key(node_id), record.second_ms)) + } + + /// The current name for a node key, if the feed has ever named it. + pub fn name_of(&self, key: &NodeKey) -> Option { + let state = self.read(); + let name = match key { + NodeKey::Peer(peer) => state.peer_names.get(peer).cloned(), + NodeKey::Node(id) => state.names.get(id).map(|(n, _)| n.clone()), + }?; + (!name.is_empty()).then_some(name) + } + + /// Seed the peer-id-to-name map from persisted state, so names restored + /// from the database resolve before the feed has re-announced their nodes. + pub fn seed_name(&self, peer_id: String, name: String) { + if peer_id.is_empty() || name.is_empty() { + return; + } + self.write().peer_names.entry(peer_id).or_insert(name); + } + + /// Follow the feed for one chain until `genesis`'s subscription is dropped. + /// + /// Reconnects indefinitely. `genesis` is discovered from the node rather + /// than configured, so a chain that respecs before launch needs no change + /// here. + pub async fn run(self, url: String, genesis: String) { + let mut backoff_logged = false; + loop { + match self.follow_once(&url, &genesis).await { + Ok(()) => { + tracing::info!(%url, "telemetry feed closed, reconnecting"); + backoff_logged = false; + } + Err(e) => { + if backoff_logged { + tracing::debug!(%url, error = %e, "telemetry feed still down"); + } else { + tracing::warn!(%url, error = %e, "telemetry feed lost"); + backoff_logged = true; + } + } + } + { + let mut state = self.write(); + state.connected = false; + // Node identities are per-connection: keeping them across a + // reconnect would report a population that no longer exists. + // Names are deliberately kept — an attribution made before the + // hiccup must still render. + state.chain_node_count = None; + } + tokio::time::sleep(RECONNECT_DELAY).await; + } + } + + async fn follow_once(&self, url: &str, genesis: &str) -> Result<(), DataError> { + let (mut socket, _) = tokio_tungstenite::connect_async(url).await?; + socket + .send(tokio_tungstenite::tungstenite::Message::Text(format!( + "subscribe:{genesis}" + ))) + .await?; + self.write().connected = true; + tracing::info!(%url, genesis = &genesis[..genesis.len().min(18)], "telemetry subscribed"); + + while let Some(msg) = socket.next().await { + // substrate-telemetry sends its JSON in **binary** frames, not text + // ones. This cost an afternoon: a client that handles only + // `Message::Text` connects, subscribes, reports itself healthy and + // silently receives nothing at all — no error, no decode failure, + // just an empty feed. (Python's `json.loads` accepts bytes, so a + // throwaway probe in Python works and hides the difference.) Both + // are accepted here; the payload is identical either way. + let payload = match msg? { + tokio_tungstenite::tungstenite::Message::Text(t) => t.as_bytes().to_vec(), + tokio_tungstenite::tungstenite::Message::Binary(b) => b.to_vec(), + tokio_tungstenite::tungstenite::Message::Close(_) => break, + _ => continue, + }; + let Ok(Value::Array(items)) = serde_json::from_slice::(&payload) else { + continue; + }; + self.ingest(&items); + } + Ok(()) + } + + /// Apply one feed frame: alternating action codes and payloads. + fn ingest(&self, items: &[Value]) { + let mut state = self.write(); + for pair in items.chunks_exact(2) { + let (Some(code), payload) = (pair[0].as_u64(), &pair[1]) else { + continue; + }; + match code { + ACTION_ADDED_NODE => state.added_node(payload), + ACTION_REMOVED_NODE => { + if let Some(id) = payload.as_u64() { + state.names.remove(&id); + } + } + ACTION_IMPORTED_BLOCK => state.imported_block(payload), + ACTION_ADDED_CHAIN => { + if let Some(count) = payload.get(2).and_then(Value::as_u64) { + state.chain_node_count = Some(count as u32); + } + } + _ => {} + } + } + } + + fn read(&self) -> std::sync::RwLockReadGuard<'_, State> { + // A poisoned lock means a panic while holding it. Nothing in this + // module can panic under the lock (every access is bounds-checked), and + // taking the feed down over telemetry — which is optional by design — + // would be a worse outcome than continuing with the state as it stands. + self.state.read().unwrap_or_else(|e| e.into_inner()) + } + + fn write(&self) -> std::sync::RwLockWriteGuard<'_, State> { + self.state.write().unwrap_or_else(|e| e.into_inner()) + } +} + +impl State { + /// The stable identity to vote with: the peer id when the feed gave one, + /// else the per-connection node id. + fn node_key(&self, node_id: u64) -> NodeKey { + match self.names.get(&node_id) { + Some((_, peer)) if !peer.is_empty() => NodeKey::Peer(peer.clone()), + _ => NodeKey::Node(node_id), + } + } + + fn added_node(&mut self, payload: &Value) { + let (Some(id), Some(details)) = ( + payload.get(0).and_then(Value::as_u64), + payload.get(1).and_then(Value::as_array), + ) else { + self.errors += 1; + return; + }; + let field = |i: usize| { + details + .get(i) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned() + }; + let name = field(0); + let peer = field(4); + if !peer.is_empty() && !name.is_empty() { + self.peer_names.insert(peer.clone(), name.clone()); + } + self.names.insert(id, (name, peer)); + } + + fn imported_block(&mut self, payload: &Value) { + let (Some(node_id), Some(block)) = ( + payload.get(0).and_then(Value::as_u64), + payload.get(1).and_then(Value::as_array), + ) else { + self.errors += 1; + return; + }; + let Some(hash) = block.get(1).and_then(Value::as_str) else { + self.errors += 1; + return; + }; + // Propagation time relative to the first reporter. `null` on the very + // first report of a hash from some feed versions, `0` on others. + let propagation = block.get(4).and_then(Value::as_i64); + + if !self.imports.contains_key(hash) { + if self.import_order.len() == MAX_TRACKED_IMPORTS + && let Some(oldest) = self.import_order.pop_front() + { + self.imports.remove(&oldest); + } + self.import_order.push_back(hash.to_owned()); + self.imports + .insert(hash.to_owned(), ImportRecord::default()); + } + let Some(record) = self.imports.get_mut(hash) else { + return; + }; + + match propagation { + // A later reporter: its delay is the first reporter's lead. Keep + // the smallest, which is the lead over the *closest* competitor — + // the conservative figure, and the one that correctly refuses to + // credit an author whose blocks another node also sees instantly. + Some(ms) if ms > 0 => { + record.second_ms = Some(record.second_ms.map_or(ms, |cur| cur.min(ms))); + } + // Zero or absent: a first reporter. The second such report for a + // hash is a tie, and a tie means nobody led. + _ => match record.first { + None => record.first = Some(node_id), + Some(existing) if existing != node_id => record.second_ms = Some(0), + _ => {} + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn added_node(id: u64, name: &str, peer: &str) -> Vec { + vec![ + json!(ACTION_ADDED_NODE), + json!([id, [name, "quantus-node", "0.11.1", null, peer, "linux"]]), + ] + } + + fn imported(node: u64, hash: &str, propagation: Option) -> Vec { + vec![ + json!(ACTION_IMPORTED_BLOCK), + json!([ + node, + [1_041_064, hash, 6000, 1_757_000_000_000i64, propagation] + ]), + ] + } + + #[test] + fn the_first_reporter_leads_by_the_second_reporters_delay() { + let feed = TelemetryFeed::new(); + feed.ingest(&added_node(1, "alice", "12D3KooWalice")); + feed.ingest(&added_node(2, "bob", "12D3KooWbob")); + feed.ingest(&imported(1, "0xdead", Some(0))); + feed.ingest(&imported(2, "0xdead", Some(85))); + assert_eq!( + feed.first_import("0xdead"), + Some((NodeKey::Peer("12D3KooWalice".into()), Some(85))) + ); + assert_eq!( + feed.name_of(&NodeKey::Peer("12D3KooWalice".into())) + .as_deref(), + Some("alice") + ); + } + + #[test] + fn a_single_report_yields_no_lead() { + // Nobody else has spoken yet. Treating that as an infinite lead would + // hand the block to whichever node the feed happened to mention first. + let feed = TelemetryFeed::new(); + feed.ingest(&added_node(1, "alice", "p1")); + feed.ingest(&imported(1, "0xbeef", Some(0))); + assert_eq!( + feed.first_import("0xbeef"), + Some((NodeKey::Peer("p1".into()), None)) + ); + } + + #[test] + fn two_nodes_reporting_first_is_a_tie_with_no_lead() { + let feed = TelemetryFeed::new(); + feed.ingest(&added_node(1, "alice", "p1")); + feed.ingest(&added_node(2, "bob", "p2")); + feed.ingest(&imported(1, "0xcafe", Some(0))); + feed.ingest(&imported(2, "0xcafe", Some(0))); + assert_eq!(feed.first_import("0xcafe").unwrap().1, Some(0)); + } + + #[test] + fn the_closest_competitor_sets_the_lead() { + let feed = TelemetryFeed::new(); + feed.ingest(&added_node(1, "alice", "p1")); + feed.ingest(&imported(1, "0x01", Some(0))); + feed.ingest(&imported(2, "0x01", Some(300))); + feed.ingest(&imported(3, "0x01", Some(40))); + assert_eq!(feed.first_import("0x01").unwrap().1, Some(40)); + } + + #[test] + fn a_node_with_no_peer_id_falls_back_to_its_node_id() { + let feed = TelemetryFeed::new(); + feed.ingest(&added_node(7, "carol", "")); + feed.ingest(&imported(7, "0x02", Some(0))); + assert_eq!(feed.first_import("0x02").unwrap().0, NodeKey::Node(7)); + } + + #[test] + fn the_chain_node_count_comes_from_the_feed() { + let feed = TelemetryFeed::new(); + feed.ingest(&[json!(ACTION_ADDED_CHAIN), json!(["Planck", "0x4901", 142])]); + assert_eq!(feed.node_count(), Some(142)); + } + + #[test] + fn a_malformed_payload_is_counted_not_fatal() { + let feed = TelemetryFeed::new(); + feed.ingest(&[json!(ACTION_ADDED_NODE), json!("not an array")]); + feed.ingest(&[json!(ACTION_IMPORTED_BLOCK), json!([1])]); + assert_eq!(feed.decode_errors(), 2); + // Still usable afterwards. + feed.ingest(&added_node(1, "alice", "p1")); + assert_eq!( + feed.name_of(&NodeKey::Peer("p1".into())).as_deref(), + Some("alice") + ); + } + + #[test] + fn tracked_imports_are_bounded() { + let feed = TelemetryFeed::new(); + for i in 0..MAX_TRACKED_IMPORTS + 100 { + feed.ingest(&imported(1, &format!("0x{i:x}"), Some(0))); + } + assert_eq!(feed.read().imports.len(), MAX_TRACKED_IMPORTS); + // The oldest are gone, the newest are kept. + assert!(feed.first_import("0x0").is_none()); + assert!( + feed.first_import(&format!("0x{:x}", MAX_TRACKED_IMPORTS + 50)) + .is_some() + ); + } + + #[test] + fn an_unconnected_feed_reports_nothing_rather_than_failing() { + let feed = TelemetryFeed::new(); + assert!(!feed.connected()); + assert_eq!(feed.node_count(), None); + assert_eq!(feed.first_import("0xanything"), None); + assert_eq!(feed.name_of(&NodeKey::Node(1)), None); + } +} diff --git a/crates/blackbeard-data/tests/store.rs b/crates/blackbeard-data/tests/store.rs new file mode 100644 index 0000000..a64ea93 --- /dev/null +++ b/crates/blackbeard-data/tests/store.rs @@ -0,0 +1,347 @@ +//! Integration tests against a real Postgres. +//! +//! Skipped unless `TEST_DATABASE_URL` is set, so `cargo test` stays useful on a +//! machine with no database. CI and the dev loop set it to a throwaway server: +//! +//! ```sh +//! podman run -d --rm --name bb-pg -e POSTGRES_PASSWORD=dev \ +//! -e POSTGRES_DB=blackbeard -p 55432:5432 docker.io/library/postgres:18-alpine +//! TEST_DATABASE_URL='postgres://postgres:dev@127.0.0.1:55432/blackbeard' cargo test +//! ``` +//! +//! Each test gets its own schema rather than its own database — same isolation, +//! no `create database` race when tests run concurrently — and drops it on the +//! way out. +//! +//! These exist because the queries they exercise are the ones a unit test +//! cannot reach: an `unnest` batch upsert, the reorg semantics of the primary +//! key, and `date_bin` bucketing. Each has a failure mode that is silent rather +//! than loud — wrong rows, not an error. + +use std::time::Duration; + +use blackbeard_core::attribution::NodeKey; +use blackbeard_data::store::{BlockRecord, ChainRecord, Store}; +use blackbeard_entities::{ChainId, MinerId}; +use chrono::{DateTime, TimeZone, Utc}; +use sqlx::postgres::PgPoolOptions; + +/// A store on a private schema, plus the pool so the schema can be dropped. +struct Fixture { + store: Store, + pool: sqlx::PgPool, + schema: String, +} + +impl Fixture { + async fn new(name: &str) -> Option { + // An empty value counts as unset. `export A=x B="$A"` in one statement + // expands `$A` before assigning it, which is an easy way to end up with + // an empty TEST_DATABASE_URL and a confusing parse error instead of a + // skip. + let url = std::env::var("TEST_DATABASE_URL") + .ok() + .filter(|u| !u.trim().is_empty())?; + let schema = format!("test_{name}"); + let pool = PgPoolOptions::new() + .max_connections(2) + // Every connection in this pool lands in the test's own schema, so + // the migrations and the queries under test agree on where the + // tables are without any query needing to qualify a name. + .after_connect({ + let schema = schema.clone(); + move |conn, _| { + let schema = schema.clone(); + Box::pin(async move { + sqlx::query(&format!("set search_path to {schema}")) + .execute(conn) + .await?; + Ok(()) + }) + } + }) + .connect(&url) + .await + .expect("TEST_DATABASE_URL is set but unreachable"); + + sqlx::query(&format!("drop schema if exists {schema} cascade")) + .execute(&pool) + .await + .unwrap(); + sqlx::query(&format!("create schema {schema}")) + .execute(&pool) + .await + .unwrap(); + + let store = Store::from_pool(pool.clone()); + store.migrate().await.expect("migrations apply"); + Some(Self { + store, + pool, + schema, + }) + } + + async fn cleanup(self) { + sqlx::query(&format!("drop schema {} cascade", self.schema)) + .execute(&self.pool) + .await + .unwrap(); + } +} + +fn chain() -> ChainId { + ChainId("planck".into()) +} + +fn miner(n: u8) -> MinerId { + MinerId(format!("0x{n:064x}")) +} + +fn at(secs: i64) -> DateTime { + Utc.timestamp_opt(1_757_000_000 + secs, 0).unwrap() +} + +fn block(height: u64, m: u8, secs: i64, difficulty: &str) -> BlockRecord { + BlockRecord { + chain: chain(), + height, + hash: format!("0x{height:064x}"), + miner: miner(m), + authored_at: Some(at(secs - 1)), + observed_at: at(secs), + difficulty: Some(difficulty.to_owned()), + } +} + +async fn seed_chain(store: &Store) { + store + .upsert_chain(&ChainRecord { + chain: chain(), + display_name: "Planck Testnet".into(), + mainnet: false, + genesis: Some("0x4901".into()), + token_symbol: Some("PLK".into()), + token_decimals: Some(12), + target_block_time: 6.0, + }) + .await + .unwrap(); +} + +macro_rules! fixture { + ($name:literal) => { + match Fixture::new($name).await { + Some(f) => f, + None => { + eprintln!("skipping: TEST_DATABASE_URL not set"); + return; + } + } + }; +} + +#[tokio::test] +async fn blocks_round_trip_through_the_batch_upsert() { + let f = fixture!("round_trip"); + seed_chain(&f.store).await; + + let blocks: Vec<_> = (1..=50) + .map(|h| block(h, (h % 3) as u8, h as i64 * 6, "41650875256")) + .collect(); + assert_eq!(f.store.record_blocks(&blocks).await.unwrap(), 50); + assert_eq!(f.store.max_height(&chain()).await.unwrap(), Some(50)); + + // Oldest first, which is the order the rolling window replays in. + let recent = f.store.recent_blocks(&chain(), 10).await.unwrap(); + assert_eq!(recent.len(), 10); + assert_eq!(recent.first().unwrap().height, 41); + assert_eq!(recent.last().unwrap().height, 50); + + f.cleanup().await; +} + +#[tokio::test] +async fn a_reorg_replaces_the_block_at_that_height() { + // The primary key is (chain, height), not the hash, precisely so this + // happens. A schema keyed on the hash would accumulate both sides of every + // fork and inflate the loser's leaderboard row forever. + let f = fixture!("reorg"); + seed_chain(&f.store).await; + + f.store + .record_blocks(&[block(100, 1, 600, "1000")]) + .await + .unwrap(); + let mut replacement = block(100, 2, 601, "1000"); + replacement.hash = "0xdifferent".into(); + f.store.record_blocks(&[replacement]).await.unwrap(); + + let rows = f.store.recent_blocks(&chain(), 10).await.unwrap(); + assert_eq!(rows.len(), 1, "one block per height, not one per fork"); + assert_eq!(rows[0].miner, miner(2)); + + let (blocks, _, _, _) = f.store.miner_totals(&chain(), &miner(1)).await.unwrap(); + assert_eq!(blocks, 0, "the orphaned author keeps no credit"); + + f.cleanup().await; +} + +#[tokio::test] +async fn an_empty_batch_is_a_no_op_not_an_error() { + let f = fixture!("empty_batch"); + seed_chain(&f.store).await; + assert_eq!(f.store.record_blocks(&[]).await.unwrap(), 0); + assert_eq!(f.store.max_height(&chain()).await.unwrap(), None); + f.cleanup().await; +} + +#[tokio::test] +async fn cumulative_work_sums_difficulty_beyond_any_integer_type() { + // Difficulty is a U512. If `difficulty` were bigint — or if the sum were + // read back through an i64 — this would overflow rather than answer. + let f = fixture!("work"); + seed_chain(&f.store).await; + + let huge = "1000000000000000000000000000000000000000"; + f.store + .record_blocks(&[block(1, 1, 6, huge), block(2, 1, 12, huge)]) + .await + .unwrap(); + + let (blocks, first, last, work) = f.store.miner_totals(&chain(), &miner(1)).await.unwrap(); + assert_eq!(blocks, 2); + assert_eq!(first, Some(at(6))); + assert_eq!(last, Some(at(12))); + assert_eq!(work, "2000000000000000000000000000000000000000"); + + f.cleanup().await; +} + +#[tokio::test] +async fn a_miner_with_no_blocks_totals_zero_rather_than_failing() { + let f = fixture!("absent_miner"); + seed_chain(&f.store).await; + let (blocks, first, last, work) = f.store.miner_totals(&chain(), &miner(9)).await.unwrap(); + assert_eq!((blocks, first, last, work.as_str()), (0, None, None, "0")); + f.cleanup().await; +} + +#[tokio::test] +async fn the_series_buckets_on_fixed_boundaries() { + let f = fixture!("series"); + seed_chain(&f.store).await; + + // Two hours of blocks, one every ten minutes, alternating authors. + let blocks: Vec<_> = (0..12) + .map(|i| block(i + 1, (i % 2) as u8, i as i64 * 600, "100")) + .collect(); + f.store.record_blocks(&blocks).await.unwrap(); + + let series = f + .store + .miner_series(&chain(), &miner(0), at(0), Duration::from_secs(3600)) + .await + .unwrap(); + + // Anchored to the epoch, so every boundary lands on the hour regardless of + // when the range starts or when the query is made. A chart bucketed from + // the range's own start would re-cut itself on every refresh and appear to + // shift under the reader — which is why this assertion, not a tidier one + // about the bucket count, is the one worth making. Two hours of blocks + // starting mid-hour therefore straddle three buckets, not two. + assert!(series.len() >= 2); + for (bucket, _, _) in &series { + assert_eq!(bucket.timestamp() % 3600, 0, "buckets start on the hour"); + } + assert_eq!(series.iter().map(|(_, mine, _)| mine).sum::(), 6); + assert_eq!(series.iter().map(|(_, _, total)| total).sum::(), 12); + // Buckets are contiguous and ascending. + for pair in series.windows(2) { + assert!(pair[1].0 > pair[0].0); + } + + f.cleanup().await; +} + +#[tokio::test] +async fn attributions_survive_a_restart() { + let f = fixture!("attribution"); + seed_chain(&f.store).await; + + f.store + .save_attributions( + &chain(), + &[ + ( + miner(1), + NodeKey::Peer("12D3KooWalice".into()), + 40, + 38, + Some("alice".into()), + ), + (miner(2), NodeKey::Node(77), 12, 9, None), + ], + ) + .await + .unwrap(); + + let mut loaded = f.store.load_attributions(&chain()).await.unwrap(); + loaded.sort_by(|a, b| a.miner.cmp(&b.miner)); + assert_eq!(loaded.len(), 2); + assert_eq!(loaded[0].key, NodeKey::Peer("12D3KooWalice".into())); + assert_eq!(loaded[0].name.as_deref(), Some("alice")); + assert_eq!(loaded[0].attempts, 40); + assert_eq!(loaded[1].key, NodeKey::Node(77)); + + // A later save with no resolvable name must not erase the one we had — a + // telemetry hiccup would otherwise cost every miner its name permanently. + f.store + .save_attributions( + &chain(), + &[( + miner(1), + NodeKey::Peer("12D3KooWalice".into()), + 41, + 39, + None, + )], + ) + .await + .unwrap(); + let reloaded = f.store.load_attributions(&chain()).await.unwrap(); + let alice = reloaded.iter().find(|a| a.miner == miner(1)).unwrap(); + assert_eq!(alice.name.as_deref(), Some("alice")); + assert_eq!(alice.attempts, 41); + + f.cleanup().await; +} + +#[tokio::test] +async fn a_failed_poll_does_not_blank_out_discovered_chain_properties() { + let f = fixture!("chain_upsert"); + seed_chain(&f.store).await; + + // A poll that reached the node for nothing but the name. + f.store + .upsert_chain(&ChainRecord { + chain: chain(), + display_name: "Planck Testnet".into(), + mainnet: false, + genesis: None, + token_symbol: None, + token_decimals: None, + target_block_time: 6.0, + }) + .await + .unwrap(); + + let row = sqlx::query_scalar::<_, Option>("select genesis from chain where id = $1") + .bind(chain().as_str()) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!(row.as_deref(), Some("0x4901")); + + f.cleanup().await; +} diff --git a/crates/blackbeard-entities/Cargo.toml b/crates/blackbeard-entities/Cargo.toml new file mode 100644 index 0000000..4211b34 --- /dev/null +++ b/crates/blackbeard-entities/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "blackbeard-entities" +description = "Domain types and wire protocol for blackbeard.observer. No I/O." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +chrono.workspace = true +serde.workspace = true +thiserror.workspace = true +ts-rs.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/blackbeard-entities/src/block.rs b/crates/blackbeard-entities/src/block.rs new file mode 100644 index 0000000..98eff6b --- /dev/null +++ b/crates/blackbeard-entities/src/block.rs @@ -0,0 +1,60 @@ +//! Observed blocks. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::{BigUintDec, ChainId, MinerId}; + +/// A block as the observer recorded it. +/// +/// Two timestamps, deliberately: `authored_at` comes from the block's own +/// `Timestamp::set` inherent, which the author stamps when it *builds* the +/// proposal, while `observed_at` is when this observer first saw the block. +/// The difference is propagation plus poll lag when negative, and a +/// future-dated block when positive. A miner whose blocks arrive in bursts +/// right after someone else's, with `authored_at` well before `observed_at`, +/// is withholding them. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "BlockObservation.ts")] +pub struct BlockObservation { + /// Which chain. + pub chain: ChainId, + /// Block number. + #[ts(type = "number")] + pub height: u64, + /// Block hash, `0x`-prefixed. + pub hash: String, + /// Author's reward preimage from the `pow_` PreRuntime digest. + pub miner: MinerId, + /// The author's own timestamp from the block's timestamp inherent. + pub authored_at: Option>, + /// When this observer first saw the block. + pub observed_at: DateTime, + /// Difficulty in force for this block: expected hashes to win it. + pub difficulty: Option, +} + +/// A block trimmed for the live ticker: what scrolls past on the front page. +/// +/// Separate from [`BlockObservation`] because the ticker is pushed on every +/// block to every connected browser, and shipping the full record — including +/// the difficulty string, which changes rarely — would multiply the socket's +/// bandwidth for nothing. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "RecentBlock.ts")] +pub struct RecentBlock { + /// Block number. + #[ts(type = "number")] + pub height: u64, + /// Author's reward preimage. + pub miner: MinerId, + /// Display name for the author at the time the block was pushed. + pub display: String, + /// When this observer first saw it. + pub observed_at: DateTime, + /// Seconds since the previous block, by observation. `None` for the first + /// block after a restart, where there is no previous observation to + /// subtract. + pub gap_seconds: Option, +} diff --git a/crates/blackbeard-entities/src/chain.rs b/crates/blackbeard-entities/src/chain.rs new file mode 100644 index 0000000..688b5da --- /dev/null +++ b/crates/blackbeard-entities/src/chain.rs @@ -0,0 +1,126 @@ +//! Chain identity and the per-chain live summary. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::BigUintDec; + +/// Short, stable, URL-safe name for a chain (`planck`, `quantus`). +/// +/// This is the operator's name for the chain, not the genesis hash: it appears +/// in routes, in config, and in the WebSocket protocol, and it has to survive a +/// chain being specced before it launches. The genesis hash is discovered from +/// the node at runtime and carried in [`ChainInfo`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[serde(transparent)] +#[ts(export, export_to = "ChainId.ts", type = "string")] +pub struct ChainId(pub String); + +impl ChainId { + /// Borrow the name. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ChainId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From<&str> for ChainId { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +/// How much of a chain the observer can currently see. +/// +/// A chain that is configured but not yet launched is a first-class state, not +/// an error: mainnet exists in config before it exists on the network, and the +/// UI has to say "not yet live" rather than "backend broken". +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "ChainStatus.ts")] +pub enum ChainStatus { + /// Connected, node in sync, every metric trustworthy. + Live, + /// Connected, but the node is still importing history. Block interval — and + /// therefore network hashrate — is nominal until this clears. + Syncing, + /// Configured but the node has never answered. Expected for a chain that + /// has not launched. + Awaiting, + /// The node answered before and has stopped. Distinct from `Awaiting` + /// because it means something broke rather than something not yet started. + Unreachable, +} + +/// Static-ish facts about a chain: what to call it and how to read its numbers. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "ChainInfo.ts")] +pub struct ChainInfo { + /// Operator name, used in routes and the WS protocol. + pub id: ChainId, + /// What a human should see: "Quantus", "Planck Testnet". + pub display_name: String, + /// True for the production network, false for a testnet. Drives whether the + /// UI treats rewards as real. + pub mainnet: bool, + /// Genesis hash, discovered from the node. `None` until it first answers. + pub genesis: Option, + /// Token ticker from `system_properties`, e.g. `QUAN`. + pub token_symbol: Option, + /// Token decimals from `system_properties`. Balances are integers of the + /// smallest unit; this is how many places to shift them. + pub token_decimals: Option, + /// Target seconds per block the chain retargets towards. Used as the + /// hashrate denominator whenever a measured interval is not yet available. + pub target_block_time_seconds: f64, + /// Current reachability. + pub status: ChainStatus, +} + +/// The live headline numbers for one chain: one row of the scoreboard's top. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "ChainSummary.ts")] +pub struct ChainSummary { + /// Which chain this describes. + pub chain: ChainId, + /// Best block height seen. + #[ts(type = "number | null")] + pub height: Option, + /// Current mining difficulty: expected hashes per block. + pub difficulty: Option, + /// The ceiling difficulty can reach, from `QPoWApi_get_max_difficulty`. + /// The ratio of the two is how much headroom the network has left. + pub max_difficulty: Option, + /// Estimated network hashrate in hashes per second: difficulty divided by + /// the block interval. + pub network_hashrate: Option, + /// True when `network_hashrate` used the configured target rather than a + /// measured interval. The figure is nominal — say so in the UI rather than + /// presenting an estimate as an observation. + pub hashrate_from_target: bool, + /// Measured mean seconds per block at the tip, when enough tip samples + /// exist. `None` while syncing or just after a restart. + pub block_interval_seconds: Option, + /// The configured target, for comparison against the measured interval. + pub target_block_time_seconds: f64, + /// Blocks in the rolling leaderboard window. + pub window_blocks: u32, + /// Distinct reward preimages that authored in the window. A **lower bound** + /// on miner count: one operator may run several preimages, and several + /// operators could in principle share one. + pub distinct_miners: u32, + /// Nodes on this chain per the substrate-telemetry feed, when connected. + /// Compare against `distinct_miners`: node count flat while distinct + /// authors falls is small miners still running and simply being out-hashed. + pub telemetry_nodes: Option, + /// Whether the telemetry feed is currently connected. + pub telemetry_connected: bool, + /// When these numbers were computed. + pub updated_at: DateTime, +} diff --git a/crates/blackbeard-entities/src/error.rs b/crates/blackbeard-entities/src/error.rs new file mode 100644 index 0000000..2dc98f0 --- /dev/null +++ b/crates/blackbeard-entities/src/error.rs @@ -0,0 +1,47 @@ +//! Error types shared across the workspace. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// Errors raised by the types themselves — parsing a window name, a malformed +/// preimage. Deliberately small: this crate does no I/O, so it has few ways to +/// fail. +#[derive(Debug, thiserror::Error)] +pub enum EntityError { + /// A window name that is not one of the four. + #[error("unknown window `{0}` (expected hour, six_hours, day or week)")] + UnknownWindow(String), + + /// A miner id that is not 0x-prefixed 32-byte hex. + #[error("`{0}` is not a 32-byte 0x-prefixed reward preimage")] + MalformedMinerId(String), + + /// A chain name that is not configured. + #[error("no chain named `{0}` is configured")] + UnknownChain(String), +} + +/// The JSON body every failing REST response carries. +/// +/// Shared with the frontend so error rendering is not a guess about what the +/// backend emits. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "ApiError.ts")] +pub struct ApiError { + /// A stable machine-readable code (`unknown_chain`, `not_found`, + /// `chain_unavailable`). The frontend switches on this; `message` is for + /// humans and may be reworded at any time. + pub code: String, + /// Human-readable explanation. + pub message: String, +} + +impl ApiError { + /// Build one. + pub fn new(code: &str, message: impl Into) -> Self { + Self { + code: code.to_owned(), + message: message.into(), + } + } +} diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs new file mode 100644 index 0000000..df0a739 --- /dev/null +++ b/crates/blackbeard-entities/src/lib.rs @@ -0,0 +1,79 @@ +//! Domain types and the browser wire protocol for blackbeard.observer. +//! +//! Types only — no I/O, no async runtime, no chain client. Everything +//! downstream (`blackbeard-core`, `blackbeard-data`, `blackbeard-api`) depends +//! on this crate, and the TypeScript the frontend consumes is generated from it +//! by `ts-rs` (`cargo test -p blackbeard-entities` writes `web/src/api/generated/`). +//! +//! ## The one type that needs explaining +//! +//! Difficulty on this chain is a **U512** — expected hashes per block, routinely +//! past `1e30`. It does not fit an `f64` without losing exactness and does not +//! fit any integer type serde will put on the wire, so it crosses every boundary +//! as [`BigUintDec`]: a decimal string. Arithmetic on it happens in +//! `blackbeard-core`, which parses it once; nothing here does maths. + +#![deny(missing_docs)] + +mod block; +mod chain; +mod error; +mod miner; +mod ws; + +pub use block::{BlockObservation, RecentBlock}; +pub use chain::{ChainId, ChainInfo, ChainStatus, ChainSummary}; +pub use error::{ApiError, EntityError}; +pub use miner::{AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint}; +pub use ws::{ClientMessage, ServerMessage, Window}; + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// An arbitrary-precision non-negative integer carried as a decimal string. +/// +/// Difficulty and cumulative hash counts are U512 on this chain; JSON numbers +/// are IEEE-754 doubles, so putting them on the wire as numbers would silently +/// round them. The string is the canonical form on the wire, in the database +/// (`numeric`), and in the browser — the frontend formats it with `BigInt`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[serde(transparent)] +#[ts(export, export_to = "BigUintDec.ts", type = "string")] +pub struct BigUintDec(pub String); + +impl BigUintDec { + /// Zero, for the case where the chain has not yet answered. + pub fn zero() -> Self { + Self("0".into()) + } + + /// The decimal digits, without allocating. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for BigUintDec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From for BigUintDec { + fn from(s: String) -> Self { + Self(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn biguint_is_transparent_on_the_wire() { + // The frontend parses this with BigInt(...), so it must be a bare JSON + // string and never an object wrapper. + let json = serde_json::to_string(&BigUintDec("1234567890123456789012345".into())).unwrap(); + assert_eq!(json, "\"1234567890123456789012345\""); + } +} diff --git a/crates/blackbeard-entities/src/miner.rs b/crates/blackbeard-entities/src/miner.rs new file mode 100644 index 0000000..38eb1cf --- /dev/null +++ b/crates/blackbeard-entities/src/miner.rs @@ -0,0 +1,148 @@ +//! Miner identity, leaderboard rows, and per-miner history. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::{BigUintDec, ChainId}; + +/// A miner's on-chain identity: the 32-byte wormhole reward **preimage**, +/// `0x`-prefixed lowercase hex. +/// +/// This is the only miner identity the chain offers without cooperation. Every +/// block header carries its author's preimage in a `PreRuntime` digest with +/// engine id `pow_`, so authorship for *every* miner on the network is +/// derivable from headers alone — no indexer, no registration, no opt-in. +/// +/// It is **public data, not a secret**: it is published from a miner's first +/// authored block onward, and spending the rewards needs a plonky2 proof of +/// knowledge of the underlying secret, which the preimage does not reveal. It +/// is also **static per wallet**, which is exactly what makes a leaderboard +/// possible — and what makes mining income permanently attributable. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[serde(transparent)] +#[ts(export, export_to = "MinerId.ts", type = "string")] +pub struct MinerId(pub String); + +impl MinerId { + /// Borrow the hex. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// The abbreviated form shown when a miner has no attributed node name: + /// `0x1234abcd…ef01`. Long enough to be distinguishable at a glance, short + /// enough to sit in a table cell. + pub fn abbreviated(&self) -> String { + if self.0.len() > 14 { + format!("{}…{}", &self.0[..10], &self.0[self.0.len() - 4..]) + } else { + self.0.clone() + } + } +} + +impl std::fmt::Display for MinerId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From<&str> for MinerId { + fn from(s: &str) -> Self { + Self(s.to_ascii_lowercase()) + } +} + +/// How a miner's display name was arrived at. +/// +/// The distinction matters on screen: a telemetry-attributed name is an +/// inference from who reported a block first, not a claim the miner made, and +/// the UI must not present the two identically. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "AttributionSource.ts")] +pub enum AttributionSource { + /// No name — the row shows the abbreviated preimage. + Preimage, + /// A substrate-telemetry node name, inferred because that node consistently + /// reported this author's blocks before anyone else. Carries a confidence. + Telemetry, +} + +/// One row of the leaderboard. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "LeaderboardRow.ts")] +pub struct LeaderboardRow { + /// 1-based position within the window. Ties break by most recent block. + pub rank: u32, + /// The miner's reward preimage — the stable key for this row. + pub miner: MinerId, + /// What to render: a telemetry node name when one is attributed, else the + /// abbreviated preimage. + pub display: String, + /// Where `display` came from. + pub attribution: AttributionSource, + /// Fraction of attributed blocks pointing at the named node, 0.0–1.0. Zero + /// when `attribution` is `Preimage`. + pub confidence: f32, + /// Blocks authored inside the window. + pub blocks: u32, + /// Share of the window's blocks, 0.0–1.0. + pub share: f64, + /// Implied hashrate in hashes per second: the miner's share of blocks times + /// the estimated network hashrate. A statistical estimate over a finite + /// window, not a measurement of the miner's hardware — a lucky small miner + /// will over-read and an unlucky large one will under-read. + pub hashrate_estimate: Option, + /// Height of this miner's most recent block in the window. + #[ts(type = "number")] + pub last_block_height: u64, + /// When the observer saw that block. + pub last_seen: DateTime, + /// Longest run of consecutive blocks by this miner within the window. A run + /// far above what its share predicts is the signature of withheld blocks. + pub best_streak: u32, +} + +/// A point on a miner's history chart. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "MinerSeriesPoint.ts")] +pub struct MinerSeriesPoint { + /// Start of the bucket. + pub at: DateTime, + /// Blocks authored inside the bucket. + pub blocks: u32, + /// Share of all blocks in the bucket, 0.0–1.0. + pub share: f64, + /// Implied hashrate over the bucket, hashes per second. + pub hashrate_estimate: Option, +} + +/// Everything the miner detail page shows for one preimage. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "MinerDetail.ts")] +pub struct MinerDetail { + /// Which chain. + pub chain: ChainId, + /// The miner. + pub miner: MinerId, + /// Current leaderboard row, absent if the miner authored nothing in the + /// window (a small miner between blocks, or one that has stopped). + pub current: Option, + /// Blocks authored since the observer's records begin — not since chain + /// genesis, unless the observer has watched from genesis. + #[ts(type = "number")] + pub blocks_observed: u64, + /// First block the observer attributed to this miner. + pub first_seen: Option>, + /// Most recent. + pub last_seen: Option>, + /// Bucketed history for charting, oldest first. + pub series: Vec, + /// Sum of the difficulty of every block this miner authored in the observed + /// record: the expected number of hashes it took to win them. The honest + /// cumulative measure of work done, and the one that does not shrink when + /// difficulty rises. + pub cumulative_work: BigUintDec, +} diff --git a/crates/blackbeard-entities/src/ws.rs b/crates/blackbeard-entities/src/ws.rs new file mode 100644 index 0000000..4ef934d --- /dev/null +++ b/crates/blackbeard-entities/src/ws.rs @@ -0,0 +1,166 @@ +//! The browser wire protocol. +//! +//! One WebSocket per browser tab carries everything the page needs after the +//! initial handshake. The client subscribes to a chain and gets a +//! [`ServerMessage::Snapshot`] immediately, then deltas: a `Block` on every +//! block, a `Summary` when the headline numbers move, a `Leaderboard` when the +//! standings change. There is no polling and no refetch — the socket *is* the +//! data source, and a REST call exists only to serve the first paint and to +//! give something to `curl`. +//! +//! Both directions are externally tagged on `type`, so the TypeScript side gets +//! a discriminated union it can `switch` on exhaustively. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::{ChainId, ChainInfo, ChainSummary, LeaderboardRow, RecentBlock}; + +/// How far back a leaderboard looks. +/// +/// A **block count**, never a duration. A time window is meaningless while a +/// node is catching up: it imports historical blocks at disk speed, so "the +/// last hour" can contain half a million blocks. Block counts stay honest in +/// both regimes, and the UI renders the approximate duration beside them from +/// the measured interval. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "Window.ts")] +pub enum Window { + /// 600 blocks — about an hour at a 6 s target. + Hour, + /// 3,600 blocks — about six hours. The default, matching the arena + /// exporter's window so the two agree. + #[default] + SixHours, + /// 14,400 blocks — about a day. + Day, + /// 100,800 blocks — about a week. + Week, +} + +impl Window { + /// The window's length in blocks. + pub fn blocks(self) -> u32 { + match self { + Window::Hour => 600, + Window::SixHours => 3_600, + Window::Day => 14_400, + Window::Week => 100_800, + } + } + + /// Every window, for building a selector. + pub fn all() -> [Window; 4] { + [Window::Hour, Window::SixHours, Window::Day, Window::Week] + } +} + +impl std::str::FromStr for Window { + type Err = crate::EntityError; + + fn from_str(s: &str) -> Result { + match s { + "hour" => Ok(Window::Hour), + "six_hours" | "6h" => Ok(Window::SixHours), + "day" => Ok(Window::Day), + "week" => Ok(Window::Week), + other => Err(crate::EntityError::UnknownWindow(other.to_owned())), + } + } +} + +/// Browser to server. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(export, export_to = "ClientMessage.ts")] +pub enum ClientMessage { + /// Watch a chain at a window. Replaces any previous subscription for that + /// chain — a client changing window sends this again rather than + /// unsubscribing first. + Subscribe { + /// Chain to watch. + chain: ChainId, + /// Leaderboard window. + window: Window, + }, + /// Stop watching a chain. + Unsubscribe { + /// Chain to drop. + chain: ChainId, + }, + /// Keepalive. The server answers [`ServerMessage::Pong`]. + /// + /// Browsers cannot send WebSocket ping frames from JavaScript, so an + /// application-level ping is the only way a tab can prove its socket is + /// still alive through a proxy that has gone quiet. + Ping, +} + +/// Server to browser. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(export, export_to = "ServerMessage.ts")] +pub enum ServerMessage { + /// Sent unprompted on connect: which chains exist and how they are doing. + /// Lets the chain switcher render before any subscription is made. + Chains { + /// Every configured chain. + chains: Vec, + }, + /// Everything about one chain, sent immediately on subscribe and never + /// again for that subscription unless the client resubscribes. + Snapshot { + /// Which chain. + chain: ChainId, + /// Window this snapshot's leaderboard covers. + window: Window, + /// Headline numbers. + summary: ChainSummary, + /// Full standings for the window. + leaderboard: Vec, + /// The tail of the block ticker, newest last. + recent_blocks: Vec, + }, + /// A new block arrived. + Block { + /// Which chain. + chain: ChainId, + /// The block. + block: RecentBlock, + }, + /// The headline numbers moved. + Summary { + /// Which chain. + chain: ChainId, + /// New numbers. + summary: ChainSummary, + }, + /// The standings changed. Sent whole rather than as a diff: a leaderboard + /// is at most a few hundred rows, and a wholesale replace cannot drift out + /// of sync with the server the way an accumulated patch stream can. + Leaderboard { + /// Which chain. + chain: ChainId, + /// Window these standings cover. + window: Window, + /// The standings. + rows: Vec, + }, + /// A chain's reachability changed — the node went away, or a chain that was + /// awaiting launch has started producing blocks. + ChainStatus { + /// Which chain. + chain: ChainId, + /// Its new state. + info: ChainInfo, + }, + /// Answer to [`ClientMessage::Ping`]. + Pong, + /// Something the client asked for could not be served. Not fatal — the + /// socket stays open. + Error { + /// Human-readable reason. + message: String, + }, +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..d52a89a --- /dev/null +++ b/readme.md @@ -0,0 +1,237 @@ +# blackbeard.observer + +Live miner leaderboard and network hashrate for the [Quantus](https://quantus.com) +blockchain and its Planck testnet, at **** +(`blackbeard.internal` from the mesh). + +The point of it: a miner can find their own row, see what share of the network +they are actually winning, and watch it move — against everyone else, in real +time. Mining is a competition that normally gives its participants no scoreboard. +This is the scoreboard. + +## How it knows who mined what + +Every block header carries its author's 32-byte wormhole **reward preimage** in a +`PreRuntime` digest stamped with the engine id `pow_`. The node writes it there so +the `mining-rewards` pallet can read it back and derive the payout address +on-chain — and as a side effect, authorship for **every miner on the network** is +derivable from headers alone. + +That is the whole mechanism, and its consequences are worth stating plainly: + +- **No registration, no opt-in, no way to be left out.** Every miner appears the + moment they win a block. Nothing here depends on anyone's cooperation. +- **The preimage is public, not secret.** It is published from a miner's first + authored block onward. Spending the rewards needs a plonky2 proof of knowledge + of the underlying secret, which the preimage does not reveal — but it does make + mining income permanently attributable. The privacy in this design is at the + *exit*, not at receipt. `lair/quantus`'s `doc/wormhole-rewards.md` has the full + analysis. +- **A leaderboard row is a preimage, not a person.** One operator may run several; + the count of distinct preimages is a *lower bound* on miner count. + +Difficulty comes from the `QPoWApi_get_difficulty` runtime call. Difficulty *is* +the expected number of hashes to win a block, so network hashrate is difficulty +divided by the block interval — and a miner's implied hashrate is their share of +recent blocks times that. + +The mechanics were first worked out in `lair/quantus`'s Prometheus arena +exporter (`asset/arena/quantus-arena-exporter.py`), which remains the reference +for anyone checking this implementation against a second one. + +## Names + +The chain gives us preimages; a board of 64-character hex strings is honest and +unreadable. substrate-telemetry closes the gap indirectly. + +The feed reports block imports with a propagation time, stamping the **first** +reporter of a hash with `0`. A node imports its own block before announcing it, +so the author — if it is on telemetry at all — is nearly always first. One block +proves nothing (measured on Planck, an author's lead over the second reporter is +50–620 ms, which a well-peered bystander can produce), so the observer **votes +over a rolling window** and only shows a name once enough blocks agree. + +An attributed name is therefore **an inference, never a claim the miner made and +never an identity check**. The UI marks it as such, carries the confidence in a +tooltip, and falls back to the abbreviated preimage rather than guessing. Where +two miners resolve to the same node name — one operator with several preimages, +or two people who never renamed their node — every member of the colliding group +gets a preimage suffix, so no row is silently privileged as "the real one". + +## Layout + +Cargo workspace plus a Vite frontend, per `~/git/architecture/generic.md` §1. + +``` +crates/ + blackbeard-entities/ domain types + the browser wire protocol; ts-rs exports + the TypeScript the frontend consumes. No I/O. + blackbeard-core/ header/SCALE decoding, hashrate maths, the rolling + window, telemetry attribution voting. Pure — no I/O, + no clock, no sockets, exercised entirely by unit tests. + blackbeard-data/ chain JSON-RPC, the telemetry feed, Postgres. + blackbeard-api/ the daemon: ingest tasks, REST, WebSocket fanout. + blackbeard-cli/ operator tools: probe, backfill, standings. +web/ Vite + React + SWC + TS. Static build, served by nginx. +asset/ systemd, firewalld, nginx, config template, bootstrap SQL. +script/infra-setup.sh one-time host provisioning, operator-run. +``` + +**The frontend's types are generated from the Rust.** `cargo test -p +blackbeard-entities` writes `web/src/api/generated/`; CI fails the build if the +committed output is stale. Edit the Rust DTO, never the TypeScript — otherwise +the two compile cleanly and disagree at runtime. + +## Real time, without observables + +One WebSocket per browser tab. On subscribe the client gets a full snapshot; +after that only deltas — a `block` on every block, a `summary` when the headline +numbers move, a `leaderboard` when the standings change. The page never polls and +never refetches. + +The head stream is itself a push: `chain_subscribeNewHeads` over the node's +WebSocket, so the observer learns of a block the moment the node imports it. The +only polling anywhere is difficulty and sync state, on a four-second timer. + +RxJS was considered and not used. This needs one stream, one reducer and one +subscriber list; `useSyncExternalStore` is React's own contract for exactly that, +gets concurrent rendering right, and costs no dependency and no idiom in every +component. The store is `web/src/api/socket.ts` — about 200 lines including the +reconnect and resubscribe logic. + +Server-side, messages are serialised **once** per broadcast and every socket +writes the same `Arc`; leaderboards are recomputed only for windows that +actually have a subscriber. + +## Windows are block counts, not durations + +Every window on the site — 1h, 6h, 24h, 7d — is a **block count** (600, 3 600, +14 400, 100 800). The labels are the approximate duration at the measured +interval, and the UI says "~". + +This is not pedantry. A duration is meaningless while a node is catching up: it +imports historical blocks at disk speed, so "the last hour" can contain half a +million blocks. The same reasoning is why a *measured* block interval is only +used when the node is at the tip; while syncing, the configured target is used +and every hashrate on the page is labelled **nominal**. + +## Data + +Postgres on `magrathea.kosherinata.internal`, mTLS and passwordless — the host's +own certificate is the credential and `pg_ident.conf` maps its CN to the role. +There is no password anywhere in this repo and nowhere in the config to put one. + +Blocks are keyed on `(chain, height)`, not on the block hash, deliberately: this +is a proof-of-work chain and it reorgs. An upsert on that key means a block +replacing another at the same height overwrites it, so the standings reflect the +canonical chain rather than the union of every fork the observer witnessed. + +The database is what lets the site keep promises a rolling window cannot: "how +did I do last week", a hashrate line older than the process, and a leaderboard +that survives a deploy. On start the daemon replays the window and the held +attribution names out of it, so a restart does not serve an empty site. + +## Build and run + +```sh +# A throwaway Postgres for the dev loop and for the sqlx query cache. +podman run -d --rm --name bb-pg -e POSTGRES_PASSWORD=dev \ + -e POSTGRES_DB=blackbeard -p 55432:5432 docker.io/library/postgres:18-alpine + +export DATABASE_URL='postgres://postgres:dev@127.0.0.1:55432/blackbeard' +export TEST_DATABASE_URL="$DATABASE_URL" + +cargo fmt --all && cargo clippy --all-targets -- -D warnings && cargo test --all + +# The daemon, against a real node. BLACKBEARD_DEV_DATABASE_URL is the +# development-only path that skips mTLS; the systemd unit never sets it. +BLACKBEARD_DEV_DATABASE_URL="$DATABASE_URL" \ + cargo run -p blackbeard-api -- --config dev-config.toml + +cd web && pnpm install && pnpm dev # proxies /v1 to 127.0.0.1:25864 +``` + +`cargo test` skips the database integration tests when `TEST_DATABASE_URL` is +unset, so it stays useful on a machine with no Postgres. + +**Before adding a chain, probe it.** Every failure the probe reports is one the +deployed daemon would hit silently — an RPC without the QPoW runtime API gives a +site with no hashrate, and headers without a `pow_` digest give an empty +leaderboard, both of which look like a working deployment: + +```sh +cargo run -p blackbeard-cli -- probe --rpc-url http://bob.hanzalova.internal:9944 +``` + +## Deploy + +CI-driven (`architecture/deployment-gitea-actions.md`): push to `main`, or run +the workflow from the Actions UI. Hosts, ports and paths live in +`.gitea/workflows/deploy.yaml` and nowhere else. + +One-time host provisioning is operator-run, from a workstation with full sudo: + +```sh +./script/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub +``` + +Re-run it whenever the deploy gains a new file to ship — each deploy job +preflights the target's sudoers against the grants in that script and fails up +front naming what is missing, rather than dying partway through an rsync. + +Two things it deliberately does not automate, both because they touch shared +infrastructure: the public Let's Encrypt certificate (`external-tls.md`) and the +split-horizon `blackbeard.internal` record, which must be added to **both** site +routers' Unbound or it `NXDOMAIN`s everywhere but one site. + +| Port | What | Where | +| --- | --- | --- | +| `25864` | `blackbeard-api` REST + WebSocket | `bob`, mesh address only, plain HTTP behind nginx | + +Registered in `architecture/port-allocations.md` §5; derived from the service +name per §3. + +## Mainnet + +Quantus mainnet is not yet live. It is a commented-out `[[chains]]` block in +`asset/config/config.toml.tmpl`, waiting on a published chain spec and an +endpoint. + +A chain may be configured **before it launches**: the observer reports it as +`awaiting`, the UI says so plainly, and it comes alive on its own the moment the +node starts answering. No redeploy, no restart. Adding it is a config change and +a `probe` run. + +## Deviations from house convention + +- **`runs-on: infra` for the deploy jobs, not `fedora-43`.** The targets are + mesh-only `.internal` names and the fedora runners have no route to the mesh. + Same reasoning as `lair/quantus` and `lair/mail`. +- **Dark theme only.** `generic.md` says nothing about themes, but the dataviz + conventions expect a selected dark mode alongside light. This site commits to + one look: it is a scoreboard for a proof-of-work chain, and a light variant + would be a second palette to validate for a context this content does not have. +- **`BLACKBEARD_DEV_DATABASE_URL`** bypasses the mandated mTLS Postgres + connection. It exists so `cargo run` works on a workstation with no + `pg_ident` mapping on the fleet cluster. It is an environment variable rather + than a config key precisely so it cannot be reached by editing a deployed + config, and taking it logs a warning loud enough to spot in the journal. + +## What this cannot tell you + +- **Hashrate figures are estimates, not measurements.** A miner's implied + hashrate is their share of blocks won over a finite window times the network + estimate. Winning blocks is a Poisson process: a miner holding 1% of the + network will quite ordinarily show anywhere from 2 to 11 blocks in a + 600-block window. Short windows flatter the lucky and libel the unlucky. +- **Distinct miners is a lower bound.** One operator, several preimages. +- **Orphan and stale-work loss is not measured.** Answering "do I win the share + of blocks my hashrate predicts?" honestly needs a hashrate the observer cannot + see. `lair/quantus` issue #1 tracks the same gap from the exporter side. +- **A telemetry name is an inference.** See *Names* above. + +## Related + +- `lair/quantus` — the node and miner deployment, the arena exporter these + mechanics come from, and the measured hashrate numbers behind the fleet. +- `~/git/architecture` — the house conventions this project follows. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..1800b1b --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,6 @@ +# Pinned to match the CI runner image (gitea-runners.md). Bump here and in the +# workflow together, never one without the other. +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] +targets = ["x86_64-unknown-linux-musl"] diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..f216078 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +edition = "2024" diff --git a/script/infra-setup.sh b/script/infra-setup.sh new file mode 100755 index 0000000..68d52fc --- /dev/null +++ b/script/infra-setup.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +# +# One-time host provisioning for blackbeard.observer. +# +# Run by an OPERATOR from a workstation with full sudo ssh to the targets — not +# by CI. The runner deploys as a scoped `gitea_ci` user and deliberately has no +# rights to create accounts, read certificate keys, or reload nginx on a shared +# edge proxy. +# +# ./script/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub +# +# Idempotent, and it skips past unreachable hosts so one offline node does not +# block the rest. Re-run it whenever the deploy gains a new file to ship: every +# deploy job preflights the target's sudoers against the grants below and fails +# up front naming what is missing, so the two cannot silently drift. +# +# Roles (all run by default; pass --role to narrow): +# +# api the host running blackbeard-api beside quantus-node +# edge the site's nginx proxy: vhosts, webroot, internal cert +# database Postgres roles, database, and the pg_ident CN mapping +# +# Conventions: architecture/generic.md §8-§11, deployment-gitea-actions.md. + +set -euo pipefail + +# --- infra truth, matching .gitea/workflows/deploy.yaml ----------------------- +API_HOST="${API_HOST:-bob.hanzalova.internal}" +API_PORT="${API_PORT:-25864}" +EDGE_HOST="${EDGE_HOST:-hanzalova.internal}" +PG_PRIMARY="${PG_PRIMARY:-magrathea.kosherinata.internal}" +# The standby needs the same ident mapping: pg_ident.conf contents are NOT +# replicated, and a failover to a server missing it locks the app out. +PG_STANDBY="${PG_STANDBY:-frankie.hanzalova.internal}" +PG_VERSION="${PG_VERSION:-18}" +WEBROOT="${WEBROOT:-/var/www/blackbeard.observer}" +PUBLIC_NAME="${PUBLIC_NAME:-blackbeard.observer}" +INTERNAL_NAME="${INTERNAL_NAME:-blackbeard.internal}" +DB_NAME="${DB_NAME:-blackbeard}" +DB_ROLE="${DB_ROLE:-blackbeard_rw}" + +PUBKEY="" +ROLES="api edge database" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +info() { printf '\033[36m==\033[0m %s\n' "$*"; } +warn() { printf '\033[33m!!\033[0m %s\n' "$*" >&2; } +die() { printf '\033[31mXX\033[0m %s\n' "$*" >&2; exit 1; } + +usage() { + sed -n '3,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +while [ $# -gt 0 ]; do + case "$1" in + --pubkey) PUBKEY="$2"; shift 2 ;; + --role) ROLES="$2"; shift 2 ;; + -h|--help) usage 0 ;; + *) die "unknown argument: $1 (try --help)" ;; + esac +done + +has_role() { [[ " $ROLES " == *" $1 "* ]]; } + +# Reachability is checked once per host and the result reused, so an offline +# host produces one clear message rather than a failure per step. +reachable() { + local host="$1" + if ssh -o ConnectTimeout=8 -o BatchMode=yes "$host" true; then + return 0 + fi + warn "$host is unreachable — skipping its steps" + return 1 +} + +# --- the scoped sudoers grants ------------------------------------------------ +# +# These strings are the single source of truth for what CI may do on each host. +# The deploy workflow's preflight extracts the paths from THIS FILE and compares +# them against `sudo -n -l` on the target, so adding a file to the deploy means +# adding a line here and re-running this script — nothing else keeps them in +# step. +# +# `:` and `=` are reserved in sudoers and must be escaped inside command +# arguments, or visudo rejects the file. + +api_sudoers() { + cat <<'EOF' +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/blackbeard-api +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/blackbeard +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/blackbeard/config.toml +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/blackbeard.conf +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/blackbeard-api.service +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/blackbeard-api-cert.path +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/blackbeard-api-cert-reload.service +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/blackbeard-api.xml +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers +gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g blackbeard -m 0750 /etc/blackbeard +gitea_ci ALL=(root) NOPASSWD: /usr/bin/setfacl -m u\:blackbeard\:r /etc/pki/tls/private/* +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/blackbeard-api /usr/local/bin/blackbeard /etc/blackbeard +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -l +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -a -t http_port_t -p tcp 25864 +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --reload +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --get-default-zone +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone=* --query-service=blackbeard-api +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --permanent --zone=* --add-service=blackbeard-api +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone=* --add-service=blackbeard-api +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable blackbeard-api.service +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable --now blackbeard-api-cert.path +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart blackbeard-api.service +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl is-active blackbeard-api.service +gitea_ci ALL=(root) NOPASSWD: /usr/bin/sudo -u blackbeard /usr/local/bin/blackbeard-api --config /etc/blackbeard/config.toml --check +EOF +} + +edge_sudoers() { + cat <<'EOF' +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /var/www/blackbeard.observer/ +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /var/www/blackbeard.observer +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/nginx -t +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx +EOF +} + +# --- gitea_ci ----------------------------------------------------------------- + +provision_gitea_ci() { + local host="$1" sudoers_body="$2" + + [ -n "$PUBKEY" ] || die "--pubkey is required to provision gitea_ci (the runner's public key)" + [ -f "$PUBKEY" ] || die "$PUBKEY does not exist" + + info "$host: gitea_ci account" + # A real shell, never nologin. The deploy runs `ssh gitea_ci@host `; + # a nologin shell authenticates the key and then refuses the command with + # "This account is currently not available", which reads as an auth problem + # rather than a shell one. Every pre-existing gitea_ci on the fleet has bash, + # and one provisioned with nologin can never be deployed to — so repair it + # rather than leaving it. + ssh "$host" sudo bash -euo pipefail <<'REMOTE' + if ! id gitea_ci >/dev/null 2>&1; then + useradd --system --create-home --home-dir /var/lib/gitea_ci --shell /bin/bash gitea_ci + fi + current=$(getent passwd gitea_ci | cut -d: -f7) + if [ "$current" != "/bin/bash" ]; then + echo "repairing gitea_ci shell: $current -> /bin/bash" + usermod --shell /bin/bash gitea_ci + fi + install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh + # Lets the deploy capture `journalctl -u ` after a restart without + # a sudoers entry for it. + usermod -aG systemd-journal gitea_ci +REMOTE + + rsync -a --chown gitea_ci:gitea_ci --chmod 0600 --rsync-path 'sudo rsync' \ + "$PUBKEY" "$host:/var/lib/gitea_ci/.ssh/authorized_keys" + + info "$host: scoped sudoers" + # Named _gitea_ci, not bare gitea_ci, so several apps can drop their own + # files on a shared host without clobbering each other. + printf '%s\n' "$sudoers_body" | \ + ssh "$host" 'sudo tee /etc/sudoers.d/blackbeard_gitea_ci > /dev/null && sudo chmod 0440 /etc/sudoers.d/blackbeard_gitea_ci' + # Verify before leaving: a syntax error in a sudoers drop-in can lock every + # sudo on the host, not just this one. + ssh "$host" sudo visudo -cf /etc/sudoers.d/blackbeard_gitea_ci +} + +# --- roles -------------------------------------------------------------------- + +role_api() { + reachable "$API_HOST" || return 0 + provision_gitea_ci "$API_HOST" "$(api_sudoers)" + + info "$API_HOST: service account and directories" + rsync -a --rsync-path 'sudo rsync' \ + "$REPO_ROOT/asset/systemd/blackbeard.sysusers.conf" \ + "$API_HOST:/etc/sysusers.d/blackbeard.conf" + ssh "$API_HOST" sudo bash -euo pipefail <<'REMOTE' + systemd-sysusers + install -d -o root -g blackbeard -m 0750 /etc/blackbeard + install -d -o blackbeard -g blackbeard -m 0750 /var/lib/blackbeard + # The mTLS credential for Postgres. The key is not world-readable; the + # service account is granted read access here and again on every deploy, + # because a certificate rotation replaces the file and drops the ACL. + setfacl -m "u:blackbeard:r" "/etc/pki/tls/private/$(hostname -f).pem" +REMOTE + + info "$API_HOST: SELinux port label" + ssh "$API_HOST" sudo bash -euo pipefail <&2 + echo "Mint it with the lair provisioner per architecture/internal-tls.md §4," >&2 + echo "then re-run this script. The vhost is NOT installed until it exists:" >&2 + echo "nginx -t fails on a missing ssl_certificate and blocks every reload." >&2 + exit 1 + fi + systemctl enable --now "step@$(basename "$INTERNAL_NAME" .internal).timer" || \ + echo "step@ renewal timer not armed — renew $INTERNAL_NAME manually until it is" +REMOTE + + info "$EDGE_HOST: nginx configuration" + rsync -a --rsync-path 'sudo rsync' \ + "$REPO_ROOT/asset/nginx/blackbeard-upstream.conf" \ + "$EDGE_HOST:/etc/nginx/conf.d/blackbeard-upstream.conf" + rsync -a --rsync-path 'sudo rsync' \ + "$REPO_ROOT/asset/nginx/$PUBLIC_NAME.conf" \ + "$REPO_ROOT/asset/nginx/$INTERNAL_NAME.conf" \ + "$EDGE_HOST:/etc/nginx/sites-available/" + ssh "$EDGE_HOST" sudo bash -euo pipefail < /etc/nginx/conf.d/websocket-upgrade.conf <<'MAP' +map \$http_upgrade \$connection_upgrade { + default upgrade; + '' close; +} +MAP + echo "installed the connection_upgrade map" + else + echo "connection_upgrade map already present" + fi + + # nginx -t parses without binding, so it catches syntax and missing + # certs but not a port owned across http{} and stream{}. Verify the + # reload landed rather than trusting the test. + nginx -t + systemctl reload nginx + sleep 1 + systemctl is-active --quiet nginx || { echo "nginx did not come back after reload" >&2; exit 1; } +REMOTE + + cat < + done + + `create` only saves; POST /api/unbound/service/reconfigure on each router + to apply. + + A public DNS record for $PUBLIC_NAME goes in Cloudflare, unproxied, CNAMEd to + the site indirection name rather than carrying a site address directly — + architecture/public-dns.md. + +EOF +} + +role_database() { + reachable "$PG_PRIMARY" || return 0 + + info "$PG_PRIMARY: roles and database" + # Roles and databases are created on the PRIMARY only — replication carries + # them to the standby. + ssh "$PG_PRIMARY" sudo -u postgres bash -euo pipefail </dev/null || echo "$API_HOST") + for server in "$PG_PRIMARY" "$PG_STANDBY"; do + reachable "$server" || continue + info "$server: pg_ident mapping for $api_fqdn -> $DB_ROLE" + printf 'cert_cn %s %s\n' "$api_fqdn" "$DB_ROLE" | \ + ssh "$server" "sudo install -d -m 0700 -o postgres -g postgres /var/lib/pgsql/$PG_VERSION/data/pg_ident.conf.d && sudo tee /var/lib/pgsql/$PG_VERSION/data/pg_ident.conf.d/$api_fqdn.conf > /dev/null" + ssh "$server" "sudo chown postgres:postgres /var/lib/pgsql/$PG_VERSION/data/pg_ident.conf.d/$api_fqdn.conf" + # Reload, not restart: pg_ident is re-read on SIGHUP. + ssh "$server" "sudo systemctl reload postgresql-$PG_VERSION" + done + + info "verifying the mapping from $API_HOST" + if reachable "$API_HOST"; then + ssh "$API_HOST" "sudo -u blackbeard psql 'host=$PG_PRIMARY port=5432 dbname=$DB_NAME user=$DB_ROLE sslmode=verify-full sslrootcert=/etc/pki/ca-trust/source/anchors/root-internal.pem sslcert=/etc/pki/tls/misc/\$(hostname -f).pem sslkey=/etc/pki/tls/private/\$(hostname -f).pem' -tAc 'select current_user'" \ + || warn "the app host could not authenticate to $PG_PRIMARY — check the CN mapping and the key ACL" + fi +} + +# --- run ---------------------------------------------------------------------- + +info "roles: $ROLES" +has_role api && role_api +has_role edge && role_edge +has_role database && role_database +info "done" diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 0000000..3ee2d11 --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 100, + "trailingComma": "all" +} diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..5ea68e1 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,20 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + // Generated by ts-rs from the Rust entities crate; edit the Rust, not these. + { ignores: ['dist', 'src/api/generated'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { ecmaVersion: 2022, globals: globals.browser }, + plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + }, + }, +) diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..831ca33 --- /dev/null +++ b/web/index.html @@ -0,0 +1,36 @@ + + + + + + blackbeard.observer — Quantus mining leaderboard + + + + + + + + + + + + + + +
+ + + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..225f9ef --- /dev/null +++ b/web/package.json @@ -0,0 +1,35 @@ +{ + "name": "blackbeard-web", + "private": true, + "version": "0.1.0", + "type": "module", + "packageManager": "pnpm@10.30.3", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint .", + "format": "prettier --write src", + "typecheck": "tsc -b --noEmit" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.8.0" + }, + "devDependencies": { + "@eslint/js": "^9.32.0", + "@types/node": "^26.4.1", + "@types/react": "^19.1.9", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react-swc": "^4.0.0", + "eslint": "^9.32.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "prettier": "^3.4.2", + "typescript": "~5.8.3", + "typescript-eslint": "^8.39.0", + "vite": "^7.1.0" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..f50b307 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,1897 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + react: + specifier: ^19.1.1 + version: 19.2.8 + react-dom: + specifier: ^19.1.1 + version: 19.2.8(react@19.2.8) + react-router-dom: + specifier: ^7.8.0 + version: 7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + devDependencies: + '@eslint/js': + specifier: ^9.32.0 + version: 9.39.5 + '@types/node': + specifier: ^26.4.1 + version: 26.4.1 + '@types/react': + specifier: ^19.1.9 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.1.7 + version: 19.2.7(@types/react@19.2.18) + '@vitejs/plugin-react-swc': + specifier: ^4.0.0 + version: 4.3.3(vite@7.3.6(@types/node@26.4.1)) + eslint: + specifier: ^9.32.0 + version: 9.39.5 + eslint-plugin-react-hooks: + specifier: ^5.2.0 + version: 5.2.0(eslint@9.39.5) + eslint-plugin-react-refresh: + specifier: ^0.4.20 + version: 0.4.26(eslint@9.39.5) + globals: + specifier: ^16.3.0 + version: 16.5.0 + prettier: + specifier: ^3.4.2 + version: 3.9.6 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + typescript-eslint: + specifier: ^8.39.0 + version: 8.69.0(eslint@9.39.5)(typescript@5.8.3) + vite: + specifier: ^7.1.0 + version: 7.3.6(@types/node@26.4.1) + +packages: + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.7': + resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.63.1': + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.1': + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.1': + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.1': + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.1': + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.1': + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.1': + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.1': + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.1': + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.1': + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.1': + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.1': + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.1': + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.1': + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} + cpu: [x64] + os: [win32] + + '@swc/core-darwin-arm64@1.16.1': + resolution: {integrity: sha512-zlJblJ8ncErD43lKdxjbUaUskJQf+LxiPXYcWXD8/8ZMV+7uuAT+CwjciLXpyZBd5Pq/S726bMpeeAwSeL1hhg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.16.1': + resolution: {integrity: sha512-IN0BmPWb0YAh/17mmlWB/HDBtTw2MfuW4hulf/tQAgTQBRH17l+z499bNJLK6LizSjqs0P7V+jU38Zj+vJC1DA==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.16.1': + resolution: {integrity: sha512-EYgrx2YOCQ2Twz2S793kqNjPkpvYVUPzzR95bIb7by+VQcyaai4lZZ2iz/tZvcFVKSNcN3/JTKwx+aBn2ZL52A==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.16.1': + resolution: {integrity: sha512-moyKm0YZlHdHohzm1YwgAyesqnE853rO0REMfJLFAova51wF9BNi+3ZW2PeS7Vqvn6HeJuepLpAHbBdZctxpHA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.16.1': + resolution: {integrity: sha512-kKGBO9wdapiSzuf5ZzZ2fYtlu1BNSYtIIUxvH1ir/gcelTOREEHGDCLTDFx/2Knf878nU11A40z7LxwasEFxqA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.16.1': + resolution: {integrity: sha512-nZ6qahtLxC3PM54cWOQZHxt4lTCF/3J4LIoWWzz6v7A+rLs8Dx54anYQf7mH3eIi8KlNpgKci/ie8ZSqFN8O7A==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.16.1': + resolution: {integrity: sha512-4ji5PNzhYq193Z4/4xUaSoNJza6iCkDJSzhetrbB6KOYxsr+kxtQr8ePWhMJUiMt6JUWtXaZ1PYT8FhtED+nGA==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.16.1': + resolution: {integrity: sha512-VJQxqrisHV+B394IgrOu8YsIIXZgffnf5tO+yc9Z/hoUpuZEvuQTjWwlnpZdpyD+0nx6LTD1/3k646JYm43yJA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.16.1': + resolution: {integrity: sha512-r9oV1mwxxsIGcLV1IQ/tw76MW3doatKze1QFWuC+a7QqJUkhY/bKTSVk6NpKKUGm2LDsE33Va8VqSClfA7vSiQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.16.1': + resolution: {integrity: sha512-6huNRessoBLxWEqBm5zJXyCQ27TO7anvkdiuQ5MDO4CJni0nOXEqKtV9RllQ2TdyENKKsUMXVnIfW2hIXx/R5Q==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.16.1': + resolution: {integrity: sha512-OVKJFUzphrGmsh+BGtcZDesx0YryV7/Yvy5XGgTqnrZfjnyfcr5uaqYQugCckdIlupc5Vs3XtDjRAj12z4ZPlw==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.16.1': + resolution: {integrity: sha512-Bt+VIhWYCGk4urklnkkteLUOeLv1VxigwTCeB/xC6rBZxY6IIKdDwCJf6on3E3SUGsIqmQS6QqtuJQc1VxF4Aw==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.16.1': + resolution: {integrity: sha512-nUaeu91O5QZKrQdaDCHd402ogUIoNOOjpkZNq0UomWK0G6gDaGmLhvddF1/3BXf5O8aLyo6ZPY/aMDWvaJQ/hg==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.28': + resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@26.4.1': + resolution: {integrity: sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==} + + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@typescript-eslint/eslint-plugin@8.69.0': + resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.69.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.69.0': + resolution: {integrity: sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.69.0': + resolution: {integrity: sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.69.0': + resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.69.0': + resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.69.0': + resolution: {integrity: sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.69.0': + resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.69.0': + resolution: {integrity: sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.69.0': + resolution: {integrity: sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.69.0': + resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react-swc@4.3.3': + resolution: {integrity: sha512-bti8ZAcvz4Lh6/e4Uk2k3aa1TiUXbbMsahuqOHvd3MveFTkKDZOA6wQVkpj7J/+tepX/wGfe+lsGh/t24HTXMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4 || ^5 || ^6 || ^7 || ^8 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.8: + resolution: {integrity: sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-router-dom@7.18.3: + resolution: {integrity: sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.3: + resolution: {integrity: sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + rollup@4.63.1: + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.69.0: + resolution: {integrity: sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5)': + dependencies: + eslint: 9.39.5 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.7': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.2 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.63.1': + optional: true + + '@rollup/rollup-android-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-x64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.1': + optional: true + + '@swc/core-darwin-arm64@1.16.1': + optional: true + + '@swc/core-darwin-x64@1.16.1': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.16.1': + optional: true + + '@swc/core-linux-arm64-gnu@1.16.1': + optional: true + + '@swc/core-linux-arm64-musl@1.16.1': + optional: true + + '@swc/core-linux-ppc64-gnu@1.16.1': + optional: true + + '@swc/core-linux-s390x-gnu@1.16.1': + optional: true + + '@swc/core-linux-x64-gnu@1.16.1': + optional: true + + '@swc/core-linux-x64-musl@1.16.1': + optional: true + + '@swc/core-win32-arm64-msvc@1.16.1': + optional: true + + '@swc/core-win32-ia32-msvc@1.16.1': + optional: true + + '@swc/core-win32-x64-msvc@1.16.1': + optional: true + + '@swc/core@1.16.1': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.28 + optionalDependencies: + '@swc/core-darwin-arm64': 1.16.1 + '@swc/core-darwin-x64': 1.16.1 + '@swc/core-linux-arm-gnueabihf': 1.16.1 + '@swc/core-linux-arm64-gnu': 1.16.1 + '@swc/core-linux-arm64-musl': 1.16.1 + '@swc/core-linux-ppc64-gnu': 1.16.1 + '@swc/core-linux-s390x-gnu': 1.16.1 + '@swc/core-linux-x64-gnu': 1.16.1 + '@swc/core-linux-x64-musl': 1.16.1 + '@swc/core-win32-arm64-msvc': 1.16.1 + '@swc/core-win32-ia32-msvc': 1.16.1 + '@swc/core-win32-x64-msvc': 1.16.1 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.28': + dependencies: + '@swc/counter': 0.1.3 + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@26.4.1': + dependencies: + undici-types: 8.3.0 + + '@types/react-dom@19.2.7(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5)(typescript@5.8.3))(eslint@9.39.5)(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.69.0(eslint@9.39.5)(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/type-utils': 8.69.0(eslint@9.39.5)(typescript@5.8.3) + '@typescript-eslint/utils': 8.69.0(eslint@9.39.5)(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.69.0 + eslint: 9.39.5 + ignore: 7.0.8 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.69.0(eslint@9.39.5)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.69.0 + debug: 4.4.3 + eslint: 9.39.5 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.69.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@5.8.3) + '@typescript-eslint/types': 8.69.0 + debug: 4.4.3 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.69.0': + dependencies: + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 + + '@typescript-eslint/tsconfig-utils@8.69.0(typescript@5.8.3)': + dependencies: + typescript: 5.8.3 + + '@typescript-eslint/type-utils@8.69.0(eslint@9.39.5)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.69.0(eslint@9.39.5)(typescript@5.8.3) + debug: 4.4.3 + eslint: 9.39.5 + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.69.0': {} + + '@typescript-eslint/typescript-estree@8.69.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/project-service': 8.69.0(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@5.8.3) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.69.0(eslint@9.39.5)(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(typescript@5.8.3) + eslint: 9.39.5 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.69.0': + dependencies: + '@typescript-eslint/types': 8.69.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react-swc@4.3.3(vite@7.3.6(@types/node@26.4.1))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + '@swc/core': 1.16.1 + vite: 7.3.6(@types/node@26.4.1) + transitivePeerDependencies: + - '@swc/helpers' + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + callsites@3.1.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@5.2.0(eslint@9.39.5): + dependencies: + eslint: 9.39.5 + + eslint-plugin-react-refresh@0.4.26(eslint@9.39.5): + dependencies: + eslint: 9.39.5 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.7 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + fsevents@2.3.3: + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.5.0: {} + + has-flag@4.0.0: {} + + ignore@5.3.2: {} + + ignore@7.0.8: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + natural-compare@1.4.0: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.9.6: {} + + punycode@2.3.1: {} + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-router-dom@7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-router: 7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + + react-router@7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie: 1.1.1 + react: 19.2.8 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + + react@19.2.8: {} + + resolve-from@4.0.0: {} + + rollup@4.63.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.1 + '@rollup/rollup-android-arm64': 4.63.1 + '@rollup/rollup-darwin-arm64': 4.63.1 + '@rollup/rollup-darwin-x64': 4.63.1 + '@rollup/rollup-freebsd-arm64': 4.63.1 + '@rollup/rollup-freebsd-x64': 4.63.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 + '@rollup/rollup-linux-arm-musleabihf': 4.63.1 + '@rollup/rollup-linux-arm64-gnu': 4.63.1 + '@rollup/rollup-linux-arm64-musl': 4.63.1 + '@rollup/rollup-linux-loong64-gnu': 4.63.1 + '@rollup/rollup-linux-loong64-musl': 4.63.1 + '@rollup/rollup-linux-ppc64-gnu': 4.63.1 + '@rollup/rollup-linux-ppc64-musl': 4.63.1 + '@rollup/rollup-linux-riscv64-gnu': 4.63.1 + '@rollup/rollup-linux-riscv64-musl': 4.63.1 + '@rollup/rollup-linux-s390x-gnu': 4.63.1 + '@rollup/rollup-linux-x64-gnu': 4.63.1 + '@rollup/rollup-linux-x64-musl': 4.63.1 + '@rollup/rollup-openbsd-x64': 4.63.1 + '@rollup/rollup-openharmony-arm64': 4.63.1 + '@rollup/rollup-win32-arm64-msvc': 4.63.1 + '@rollup/rollup-win32-ia32-msvc': 4.63.1 + '@rollup/rollup-win32-x64-gnu': 4.63.1 + '@rollup/rollup-win32-x64-msvc': 4.63.1 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-js@1.2.1: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + ts-api-utils@2.5.0(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.69.0(eslint@9.39.5)(typescript@5.8.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5)(typescript@5.8.3))(eslint@9.39.5)(typescript@5.8.3) + '@typescript-eslint/parser': 8.69.0(eslint@9.39.5)(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.69.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.69.0(eslint@9.39.5)(typescript@5.8.3) + eslint: 9.39.5 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + typescript@5.8.3: {} + + undici-types@8.3.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@7.3.6(@types/node@26.4.1): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.4.1 + fsevents: 2.3.3 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yocto-queue@0.1.0: {} diff --git a/web/public/sigil.svg b/web/public/sigil.svg new file mode 100644 index 0000000..199a57e --- /dev/null +++ b/web/public/sigil.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..88ab850 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,214 @@ +/** + * The page. + * + * One route, effectively: a chain, a window, and optionally one miner in focus. + * State lives in the URL hash rather than a router, because the whole + * navigable surface is `#/planck/day` and `#/miner/0x…` — pulling in a router + * to parse two segments would be more code than parsing them. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react' + +import type { Window as WindowName } from './api/generated/Window' +import { BlockTicker } from './components/BlockTicker' +import { Leaderboard } from './components/Leaderboard' +import { MinerPanel } from './components/MinerPanel' +import { StatBar } from './components/StatBar' +import { seconds, windowSpan } from './lib/format' +import { useObserver, usePinnedMiners, useWatch } from './lib/store' + +const WINDOWS: { id: WindowName; label: string; blocks: number }[] = [ + { id: 'hour', label: '1h', blocks: 600 }, + { id: 'six_hours', label: '6h', blocks: 3600 }, + { id: 'day', label: '24h', blocks: 14400 }, + { id: 'week', label: '7d', blocks: 100800 }, +] + +interface Route { + chain: string | null + window: WindowName + miner: string | null +} + +function parseHash(hash: string): Route { + const parts = hash.replace(/^#\/?/, '').split('/').filter(Boolean) + if (parts[0] === 'miner' && parts[1]) { + return { chain: null, window: 'six_hours', miner: parts[1].toLowerCase() } + } + const window = WINDOWS.find((w) => w.id === parts[1])?.id ?? 'six_hours' + return { chain: parts[0] ?? null, window, miner: null } +} + +export default function App() { + const state = useObserver() + const [route, setRoute] = useState(() => parseHash(window.location.hash)) + const { pinned } = usePinnedMiners() + + useEffect(() => { + const onHash = () => setRoute(parseHash(window.location.hash)) + window.addEventListener('hashchange', onHash) + return () => window.removeEventListener('hashchange', onHash) + }, []) + + // Default to the first chain the backend reports — which is the first one in + // its config, so the operator decides what the front page shows. + const chain = route.chain ?? state.chains[0]?.id ?? null + const info = useMemo(() => state.chains.find((c) => c.id === chain) ?? null, [state.chains, chain]) + + useWatch(chain, route.window) + + const go = useCallback((next: Partial) => { + const merged = { ...parseHash(window.location.hash), ...next } + window.location.hash = merged.miner + ? `/miner/${merged.miner}` + : `/${merged.chain ?? ''}/${merged.window}` + }, []) + + const selectMiner = useCallback( + (miner: string) => { + // Keep the chain and window; the miner panel opens above the board rather + // than replacing the page, so the standings stay visible behind it. + setRoute((r) => ({ ...r, miner })) + }, + [], + ) + + const interval = state.summary?.block_interval_seconds ?? state.summary?.target_block_time_seconds ?? 6 + const activeWindow = WINDOWS.find((w) => w.id === route.window) ?? WINDOWS[1]! + + return ( +
+
+
+ +
+
+ blackbeard.observer +
+
quantus mining · this is sparta
+
+
+ +
+ {state.chains.length > 1 && ( +
+ {state.chains.map((c) => ( + + ))} +
+ )} + +
+ {WINDOWS.map((w) => ( + + ))} +
+ +
+ + {state.connection} +
+
+
+ + {info?.status === 'awaiting' && ( +
+ {info.display_name} has not started producing blocks yet. This page will + come alive on its own the moment it does — no refresh needed. +
+ )} + {info?.status === 'unreachable' && ( +
+ The observer has lost contact with the {info.display_name} node. The standings below are + the last it saw. +
+ )} + {info?.status === 'syncing' && ( +
+ The node is still importing history. Block interval — and therefore every hashrate on this + page — is nominal until it reaches the tip. +
+ )} + + + + {route.miner && chain && ( + setRoute((r) => ({ ...r, miner: null }))} + /> + )} + +
+
+
+

The Standings

+ + last {activeWindow.blocks.toLocaleString('en-US')} blocks ·{' '} + {windowSpan(activeWindow.blocks, interval)} + {pinned.length > 0 && ` · ${pinned.length} marked yours`} + +
+ +
+ +
+
+

Live Blocks

+ + {state.summary?.block_interval_seconds + ? `${seconds(state.summary.block_interval_seconds)} apart` + : 'measuring'} + +
+ +
+
+ +
+ + Every miner on this board is derived from the pow_ digest in each block + header — no registration, no opt-in, no way to be left out. + + + Hashrates are estimates from blocks won over a finite window, not measurements of anyone's + hardware. + +
+
+ ) +} + +/** The mark: a spear-point shield, drawn rather than shipped as an asset. */ +function Sigil() { + return ( + + ) +} diff --git a/web/src/api/generated/ApiError.ts b/web/src/api/generated/ApiError.ts new file mode 100644 index 0000000..33d56d2 --- /dev/null +++ b/web/src/api/generated/ApiError.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The JSON body every failing REST response carries. + * + * Shared with the frontend so error rendering is not a guess about what the + * backend emits. + */ +export type ApiError = { +/** + * A stable machine-readable code (`unknown_chain`, `not_found`, + * `chain_unavailable`). The frontend switches on this; `message` is for + * humans and may be reworded at any time. + */ +code: string, +/** + * Human-readable explanation. + */ +message: string, }; diff --git a/web/src/api/generated/AttributionSource.ts b/web/src/api/generated/AttributionSource.ts new file mode 100644 index 0000000..43252fe --- /dev/null +++ b/web/src/api/generated/AttributionSource.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * How a miner's display name was arrived at. + * + * The distinction matters on screen: a telemetry-attributed name is an + * inference from who reported a block first, not a claim the miner made, and + * the UI must not present the two identically. + */ +export type AttributionSource = "preimage" | "telemetry"; diff --git a/web/src/api/generated/BigUintDec.ts b/web/src/api/generated/BigUintDec.ts new file mode 100644 index 0000000..34c03ef --- /dev/null +++ b/web/src/api/generated/BigUintDec.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * An arbitrary-precision non-negative integer carried as a decimal string. + * + * Difficulty and cumulative hash counts are U512 on this chain; JSON numbers + * are IEEE-754 doubles, so putting them on the wire as numbers would silently + * round them. The string is the canonical form on the wire, in the database + * (`numeric`), and in the browser — the frontend formats it with `BigInt`. + */ +export type BigUintDec = string; diff --git a/web/src/api/generated/BlockObservation.ts b/web/src/api/generated/BlockObservation.ts new file mode 100644 index 0000000..be77e8d --- /dev/null +++ b/web/src/api/generated/BlockObservation.ts @@ -0,0 +1,45 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BigUintDec } from "./BigUintDec"; +import type { ChainId } from "./ChainId"; +import type { MinerId } from "./MinerId"; + +/** + * A block as the observer recorded it. + * + * Two timestamps, deliberately: `authored_at` comes from the block's own + * `Timestamp::set` inherent, which the author stamps when it *builds* the + * proposal, while `observed_at` is when this observer first saw the block. + * The difference is propagation plus poll lag when negative, and a + * future-dated block when positive. A miner whose blocks arrive in bursts + * right after someone else's, with `authored_at` well before `observed_at`, + * is withholding them. + */ +export type BlockObservation = { +/** + * Which chain. + */ +chain: ChainId, +/** + * Block number. + */ +height: number, +/** + * Block hash, `0x`-prefixed. + */ +hash: string, +/** + * Author's reward preimage from the `pow_` PreRuntime digest. + */ +miner: MinerId, +/** + * The author's own timestamp from the block's timestamp inherent. + */ +authored_at: string | null, +/** + * When this observer first saw the block. + */ +observed_at: string, +/** + * Difficulty in force for this block: expected hashes to win it. + */ +difficulty: BigUintDec | null, }; diff --git a/web/src/api/generated/ChainId.ts b/web/src/api/generated/ChainId.ts new file mode 100644 index 0000000..f74728c --- /dev/null +++ b/web/src/api/generated/ChainId.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Short, stable, URL-safe name for a chain (`planck`, `quantus`). + * + * This is the operator's name for the chain, not the genesis hash: it appears + * in routes, in config, and in the WebSocket protocol, and it has to survive a + * chain being specced before it launches. The genesis hash is discovered from + * the node at runtime and carried in [`ChainInfo`]. + */ +export type ChainId = string; diff --git a/web/src/api/generated/ChainInfo.ts b/web/src/api/generated/ChainInfo.ts new file mode 100644 index 0000000..a999caf --- /dev/null +++ b/web/src/api/generated/ChainInfo.ts @@ -0,0 +1,43 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChainId } from "./ChainId"; +import type { ChainStatus } from "./ChainStatus"; + +/** + * Static-ish facts about a chain: what to call it and how to read its numbers. + */ +export type ChainInfo = { +/** + * Operator name, used in routes and the WS protocol. + */ +id: ChainId, +/** + * What a human should see: "Quantus", "Planck Testnet". + */ +display_name: string, +/** + * True for the production network, false for a testnet. Drives whether the + * UI treats rewards as real. + */ +mainnet: boolean, +/** + * Genesis hash, discovered from the node. `None` until it first answers. + */ +genesis: string | null, +/** + * Token ticker from `system_properties`, e.g. `QUAN`. + */ +token_symbol: string | null, +/** + * Token decimals from `system_properties`. Balances are integers of the + * smallest unit; this is how many places to shift them. + */ +token_decimals: number | null, +/** + * Target seconds per block the chain retargets towards. Used as the + * hashrate denominator whenever a measured interval is not yet available. + */ +target_block_time_seconds: number, +/** + * Current reachability. + */ +status: ChainStatus, }; diff --git a/web/src/api/generated/ChainStatus.ts b/web/src/api/generated/ChainStatus.ts new file mode 100644 index 0000000..eabcb75 --- /dev/null +++ b/web/src/api/generated/ChainStatus.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * How much of a chain the observer can currently see. + * + * A chain that is configured but not yet launched is a first-class state, not + * an error: mainnet exists in config before it exists on the network, and the + * UI has to say "not yet live" rather than "backend broken". + */ +export type ChainStatus = "live" | "syncing" | "awaiting" | "unreachable"; diff --git a/web/src/api/generated/ChainSummary.ts b/web/src/api/generated/ChainSummary.ts new file mode 100644 index 0000000..0bff21f --- /dev/null +++ b/web/src/api/generated/ChainSummary.ts @@ -0,0 +1,69 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BigUintDec } from "./BigUintDec"; +import type { ChainId } from "./ChainId"; + +/** + * The live headline numbers for one chain: one row of the scoreboard's top. + */ +export type ChainSummary = { +/** + * Which chain this describes. + */ +chain: ChainId, +/** + * Best block height seen. + */ +height: number | null, +/** + * Current mining difficulty: expected hashes per block. + */ +difficulty: BigUintDec | null, +/** + * The ceiling difficulty can reach, from `QPoWApi_get_max_difficulty`. + * The ratio of the two is how much headroom the network has left. + */ +max_difficulty: BigUintDec | null, +/** + * Estimated network hashrate in hashes per second: difficulty divided by + * the block interval. + */ +network_hashrate: number | null, +/** + * True when `network_hashrate` used the configured target rather than a + * measured interval. The figure is nominal — say so in the UI rather than + * presenting an estimate as an observation. + */ +hashrate_from_target: boolean, +/** + * Measured mean seconds per block at the tip, when enough tip samples + * exist. `None` while syncing or just after a restart. + */ +block_interval_seconds: number | null, +/** + * The configured target, for comparison against the measured interval. + */ +target_block_time_seconds: number, +/** + * Blocks in the rolling leaderboard window. + */ +window_blocks: number, +/** + * Distinct reward preimages that authored in the window. A **lower bound** + * on miner count: one operator may run several preimages, and several + * operators could in principle share one. + */ +distinct_miners: number, +/** + * Nodes on this chain per the substrate-telemetry feed, when connected. + * Compare against `distinct_miners`: node count flat while distinct + * authors falls is small miners still running and simply being out-hashed. + */ +telemetry_nodes: number | null, +/** + * Whether the telemetry feed is currently connected. + */ +telemetry_connected: boolean, +/** + * When these numbers were computed. + */ +updated_at: string, }; diff --git a/web/src/api/generated/ClientMessage.ts b/web/src/api/generated/ClientMessage.ts new file mode 100644 index 0000000..e89c997 --- /dev/null +++ b/web/src/api/generated/ClientMessage.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChainId } from "./ChainId"; +import type { Window } from "./Window"; + +/** + * Browser to server. + */ +export type ClientMessage = { "type": "subscribe", +/** + * Chain to watch. + */ +chain: ChainId, +/** + * Leaderboard window. + */ +window: Window, } | { "type": "unsubscribe", +/** + * Chain to drop. + */ +chain: ChainId, } | { "type": "ping" }; diff --git a/web/src/api/generated/LeaderboardRow.ts b/web/src/api/generated/LeaderboardRow.ts new file mode 100644 index 0000000..b54fcbb --- /dev/null +++ b/web/src/api/generated/LeaderboardRow.ts @@ -0,0 +1,58 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AttributionSource } from "./AttributionSource"; +import type { MinerId } from "./MinerId"; + +/** + * One row of the leaderboard. + */ +export type LeaderboardRow = { +/** + * 1-based position within the window. Ties break by most recent block. + */ +rank: number, +/** + * The miner's reward preimage — the stable key for this row. + */ +miner: MinerId, +/** + * What to render: a telemetry node name when one is attributed, else the + * abbreviated preimage. + */ +display: string, +/** + * Where `display` came from. + */ +attribution: AttributionSource, +/** + * Fraction of attributed blocks pointing at the named node, 0.0–1.0. Zero + * when `attribution` is `Preimage`. + */ +confidence: number, +/** + * Blocks authored inside the window. + */ +blocks: number, +/** + * Share of the window's blocks, 0.0–1.0. + */ +share: number, +/** + * Implied hashrate in hashes per second: the miner's share of blocks times + * the estimated network hashrate. A statistical estimate over a finite + * window, not a measurement of the miner's hardware — a lucky small miner + * will over-read and an unlucky large one will under-read. + */ +hashrate_estimate: number | null, +/** + * Height of this miner's most recent block in the window. + */ +last_block_height: number, +/** + * When the observer saw that block. + */ +last_seen: string, +/** + * Longest run of consecutive blocks by this miner within the window. A run + * far above what its share predicts is the signature of withheld blocks. + */ +best_streak: number, }; diff --git a/web/src/api/generated/MinerDetail.ts b/web/src/api/generated/MinerDetail.ts new file mode 100644 index 0000000..7af89c6 --- /dev/null +++ b/web/src/api/generated/MinerDetail.ts @@ -0,0 +1,48 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BigUintDec } from "./BigUintDec"; +import type { ChainId } from "./ChainId"; +import type { LeaderboardRow } from "./LeaderboardRow"; +import type { MinerId } from "./MinerId"; +import type { MinerSeriesPoint } from "./MinerSeriesPoint"; + +/** + * Everything the miner detail page shows for one preimage. + */ +export type MinerDetail = { +/** + * Which chain. + */ +chain: ChainId, +/** + * The miner. + */ +miner: MinerId, +/** + * Current leaderboard row, absent if the miner authored nothing in the + * window (a small miner between blocks, or one that has stopped). + */ +current: LeaderboardRow | null, +/** + * Blocks authored since the observer's records begin — not since chain + * genesis, unless the observer has watched from genesis. + */ +blocks_observed: number, +/** + * First block the observer attributed to this miner. + */ +first_seen: string | null, +/** + * Most recent. + */ +last_seen: string | null, +/** + * Bucketed history for charting, oldest first. + */ +series: Array, +/** + * Sum of the difficulty of every block this miner authored in the observed + * record: the expected number of hashes it took to win them. The honest + * cumulative measure of work done, and the one that does not shrink when + * difficulty rises. + */ +cumulative_work: BigUintDec, }; diff --git a/web/src/api/generated/MinerId.ts b/web/src/api/generated/MinerId.ts new file mode 100644 index 0000000..2337cdc --- /dev/null +++ b/web/src/api/generated/MinerId.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A miner's on-chain identity: the 32-byte wormhole reward **preimage**, + * `0x`-prefixed lowercase hex. + * + * This is the only miner identity the chain offers without cooperation. Every + * block header carries its author's preimage in a `PreRuntime` digest with + * engine id `pow_`, so authorship for *every* miner on the network is + * derivable from headers alone — no indexer, no registration, no opt-in. + * + * It is **public data, not a secret**: it is published from a miner's first + * authored block onward, and spending the rewards needs a plonky2 proof of + * knowledge of the underlying secret, which the preimage does not reveal. It + * is also **static per wallet**, which is exactly what makes a leaderboard + * possible — and what makes mining income permanently attributable. + */ +export type MinerId = string; diff --git a/web/src/api/generated/MinerSeriesPoint.ts b/web/src/api/generated/MinerSeriesPoint.ts new file mode 100644 index 0000000..a493b5b --- /dev/null +++ b/web/src/api/generated/MinerSeriesPoint.ts @@ -0,0 +1,22 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A point on a miner's history chart. + */ +export type MinerSeriesPoint = { +/** + * Start of the bucket. + */ +at: string, +/** + * Blocks authored inside the bucket. + */ +blocks: number, +/** + * Share of all blocks in the bucket, 0.0–1.0. + */ +share: number, +/** + * Implied hashrate over the bucket, hashes per second. + */ +hashrate_estimate: number | null, }; diff --git a/web/src/api/generated/RecentBlock.ts b/web/src/api/generated/RecentBlock.ts new file mode 100644 index 0000000..504227f --- /dev/null +++ b/web/src/api/generated/RecentBlock.ts @@ -0,0 +1,34 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MinerId } from "./MinerId"; + +/** + * A block trimmed for the live ticker: what scrolls past on the front page. + * + * Separate from [`BlockObservation`] because the ticker is pushed on every + * block to every connected browser, and shipping the full record — including + * the difficulty string, which changes rarely — would multiply the socket's + * bandwidth for nothing. + */ +export type RecentBlock = { +/** + * Block number. + */ +height: number, +/** + * Author's reward preimage. + */ +miner: MinerId, +/** + * Display name for the author at the time the block was pushed. + */ +display: string, +/** + * When this observer first saw it. + */ +observed_at: string, +/** + * Seconds since the previous block, by observation. `None` for the first + * block after a restart, where there is no previous observation to + * subtract. + */ +gap_seconds: number | null, }; diff --git a/web/src/api/generated/ServerMessage.ts b/web/src/api/generated/ServerMessage.ts new file mode 100644 index 0000000..f2d2b24 --- /dev/null +++ b/web/src/api/generated/ServerMessage.ts @@ -0,0 +1,76 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChainId } from "./ChainId"; +import type { ChainInfo } from "./ChainInfo"; +import type { ChainSummary } from "./ChainSummary"; +import type { LeaderboardRow } from "./LeaderboardRow"; +import type { RecentBlock } from "./RecentBlock"; +import type { Window } from "./Window"; + +/** + * Server to browser. + */ +export type ServerMessage = { "type": "chains", +/** + * Every configured chain. + */ +chains: Array, } | { "type": "snapshot", +/** + * Which chain. + */ +chain: ChainId, +/** + * Window this snapshot's leaderboard covers. + */ +window: Window, +/** + * Headline numbers. + */ +summary: ChainSummary, +/** + * Full standings for the window. + */ +leaderboard: Array, +/** + * The tail of the block ticker, newest last. + */ +recent_blocks: Array, } | { "type": "block", +/** + * Which chain. + */ +chain: ChainId, +/** + * The block. + */ +block: RecentBlock, } | { "type": "summary", +/** + * Which chain. + */ +chain: ChainId, +/** + * New numbers. + */ +summary: ChainSummary, } | { "type": "leaderboard", +/** + * Which chain. + */ +chain: ChainId, +/** + * Window these standings cover. + */ +window: Window, +/** + * The standings. + */ +rows: Array, } | { "type": "chain_status", +/** + * Which chain. + */ +chain: ChainId, +/** + * Its new state. + */ +info: ChainInfo, } | { "type": "pong" } | { "type": "error", +/** + * Human-readable reason. + */ +message: string, }; diff --git a/web/src/api/generated/Window.ts b/web/src/api/generated/Window.ts new file mode 100644 index 0000000..f1fff0d --- /dev/null +++ b/web/src/api/generated/Window.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * How far back a leaderboard looks. + * + * A **block count**, never a duration. A time window is meaningless while a + * node is catching up: it imports historical blocks at disk speed, so "the + * last hour" can contain half a million blocks. Block counts stay honest in + * both regimes, and the UI renders the approximate duration beside them from + * the measured interval. + */ +export type Window = "hour" | "six_hours" | "day" | "week"; diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts new file mode 100644 index 0000000..7ec9b00 --- /dev/null +++ b/web/src/api/rest.ts @@ -0,0 +1,53 @@ +/** + * The few things the socket does not push. + * + * A miner's own history is a query, not a stream — it is asked for once when + * someone opens their page and does not change until the next block they win. + * Pushing it to every connected tab would be strictly worse. + */ + +import type { ApiError } from './generated/ApiError' +import type { MinerDetail } from './generated/MinerDetail' +import type { Window as WindowName } from './generated/Window' + +const BASE = import.meta.env.VITE_API_BASE_URL ?? '/v1' + +/** An error the API described, as opposed to a network failure. */ +export class RequestFailed extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message) + this.name = 'RequestFailed' + } +} + +async function get(path: string, signal?: AbortSignal): Promise { + const response = await fetch(`${BASE}${path}`, signal ? { signal } : {}) + if (!response.ok) { + // The API answers failures with a typed body; fall back to the status text + // for anything that got as far as nginx but not as far as the API. + const body = (await response.json().catch(() => null)) as ApiError | null + throw new RequestFailed( + response.status, + body?.code ?? 'http_error', + body?.message ?? response.statusText, + ) + } + return (await response.json()) as T +} + +/** One miner's standing and history. */ +export function fetchMiner( + chain: string, + miner: string, + windowName: WindowName, + signal?: AbortSignal, +): Promise { + return get( + `/chains/${encodeURIComponent(chain)}/miners/${encodeURIComponent(miner)}?window=${windowName}`, + signal, + ) +} diff --git a/web/src/api/socket.ts b/web/src/api/socket.ts new file mode 100644 index 0000000..0b89e21 --- /dev/null +++ b/web/src/api/socket.ts @@ -0,0 +1,248 @@ +/** + * The live connection. + * + * One WebSocket for the whole tab. It reconnects on its own, resubscribes to + * whatever the page was watching, and hands React a snapshot object that only + * changes identity when something in it actually changed — so `useSyncExternalStore` + * re-renders exactly the components whose data moved. + * + * Why not observables: this needs one stream, one reducer and one subscriber + * list. RxJS would add a dependency and an idiom to every component to express + * what `useSyncExternalStore` already does natively, and React's own store + * contract is the thing that gets concurrent rendering right. + */ + +import type { ChainInfo } from './generated/ChainInfo' +import type { ChainSummary } from './generated/ChainSummary' +import type { ClientMessage } from './generated/ClientMessage' +import type { LeaderboardRow } from './generated/LeaderboardRow' +import type { RecentBlock } from './generated/RecentBlock' +import type { ServerMessage } from './generated/ServerMessage' +import type { Window as WindowName } from './generated/Window' + +/** How the socket is doing, for the status light in the header. */ +export type Connection = 'connecting' | 'live' | 'reconnecting' | 'offline' + +/** Everything the UI renders, in one immutable object. */ +export interface ObserverState { + connection: Connection + /** Every configured chain, in the order the backend lists them. */ + chains: ChainInfo[] + /** Which chain the page is watching. */ + chain: string | null + /** Which leaderboard window. */ + window: WindowName + summary: ChainSummary | null + leaderboard: LeaderboardRow[] + /** Newest first — the order the ticker renders in. */ + blocks: RecentBlock[] + /** True once the first snapshot for the current subscription has landed. */ + ready: boolean +} + +const INITIAL: ObserverState = { + connection: 'connecting', + chains: [], + chain: null, + window: 'six_hours', + summary: null, + leaderboard: [], + blocks: [], + ready: false, +} + +/** Blocks kept in the ticker. Matches the backend's replay length. */ +const TICKER_LIMIT = 40 + +/** + * Reconnect backoff. Capped low: this is a live scoreboard, and a miner + * watching their standing would rather the page retry briskly than back off to + * a minute after a brief network blip. + */ +const RECONNECT_MIN_MS = 1_000 +const RECONNECT_MAX_MS = 15_000 + +/** + * Application-level keepalive. + * + * Browsers cannot send WebSocket ping frames from JavaScript, so a socket that + * has been silently dropped by an intermediary looks identical to a quiet chain + * — and on a chain averaging a block every few seconds, "quiet" is a real + * state. This ping is the only way the tab can tell the two apart. + */ +const PING_INTERVAL_MS = 25_000 + +function socketUrl(): string { + const configured = import.meta.env.VITE_WS_URL + if (configured) return configured + // Same origin by default: in production nginx serves the bundle and proxies + // /v1 to the API, and in development Vite proxies it. Deriving the URL means + // no build-time value to get wrong per environment. + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${protocol}//${window.location.host}/v1/ws` +} + +/** + * The store. One instance per tab, created in `main.tsx`. + */ +export class Observer { + private state: ObserverState = INITIAL + private listeners = new Set<() => void>() + private socket: WebSocket | null = null + private reconnectDelay = RECONNECT_MIN_MS + private reconnectTimer: number | null = null + private pingTimer: number | null = null + private closed = false + + /** `useSyncExternalStore` subscribe. */ + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + /** + * `useSyncExternalStore` snapshot. + * + * Must return a stable reference between changes — returning a fresh object + * here would re-render every subscriber on every tick and, in React 19, throw + * for an unstable snapshot. + */ + getSnapshot = (): ObserverState => this.state + + /** Open the socket. Idempotent. */ + connect(): void { + if (this.socket || this.closed) return + const socket = new WebSocket(socketUrl()) + this.socket = socket + + socket.onopen = () => { + this.reconnectDelay = RECONNECT_MIN_MS + this.patch({ connection: 'live' }) + // The server does not remember subscriptions across sockets, so the + // reconnect has to restate what this tab is watching. Without it a + // recovered connection would deliver the chain list and then nothing. + if (this.state.chain) { + this.send({ type: 'subscribe', chain: this.state.chain, window: this.state.window }) + } + this.pingTimer = window.setInterval(() => this.send({ type: 'ping' }), PING_INTERVAL_MS) + } + + socket.onmessage = (event: MessageEvent) => { + let message: ServerMessage + try { + message = JSON.parse(event.data) as ServerMessage + } catch { + return + } + this.apply(message) + } + + socket.onclose = () => { + this.socket = null + if (this.pingTimer !== null) { + window.clearInterval(this.pingTimer) + this.pingTimer = null + } + if (this.closed) return + this.patch({ connection: 'reconnecting', ready: false }) + this.reconnectTimer = window.setTimeout(() => this.connect(), this.reconnectDelay) + this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS) + } + + // `onerror` is always followed by `onclose`, so reconnection is handled + // there and this only exists to stop the browser logging an unhandled one. + socket.onerror = () => {} + } + + /** Close for good. Used when the app unmounts. */ + disconnect(): void { + this.closed = true + if (this.reconnectTimer !== null) window.clearTimeout(this.reconnectTimer) + if (this.pingTimer !== null) window.clearInterval(this.pingTimer) + this.socket?.close() + this.socket = null + } + + /** Watch a chain at a window. Safe to call before the socket is open. */ + watch(chain: string, windowName: WindowName): void { + if (this.state.chain === chain && this.state.window === windowName) return + // Clear the previous chain's data rather than letting it linger under the + // new chain's name: a stale leaderboard under a different heading is worse + // than an empty one, because it looks correct. + this.patch({ + chain, + window: windowName, + summary: null, + leaderboard: [], + blocks: [], + ready: false, + }) + this.send({ type: 'subscribe', chain, window: windowName }) + } + + private send(message: ClientMessage): void { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify(message)) + } + } + + private apply(message: ServerMessage): void { + switch (message.type) { + case 'chains': + this.patch({ chains: message.chains }) + break + + case 'snapshot': + // A snapshot for a chain the page has since navigated away from would + // overwrite the current one; ignore it rather than flicker. + if (message.chain !== this.state.chain) return + this.patch({ + summary: message.summary, + leaderboard: message.leaderboard, + blocks: [...message.recent_blocks].reverse(), + ready: true, + }) + break + + case 'summary': + if (message.chain !== this.state.chain) return + this.patch({ summary: message.summary }) + break + + case 'leaderboard': + if (message.chain !== this.state.chain || message.window !== this.state.window) return + this.patch({ leaderboard: message.rows }) + break + + case 'block': { + if (message.chain !== this.state.chain) return + // A reorg replaces the block at a height rather than appending a second + // one, so the ticker matches the chain rather than accumulating both + // sides of every fork. + const rest = this.state.blocks.filter((b) => b.height !== message.block.height) + this.patch({ blocks: [message.block, ...rest].slice(0, TICKER_LIMIT) }) + break + } + + case 'chain_status': + this.patch({ + chains: this.state.chains.map((c) => (c.id === message.chain ? message.info : c)), + }) + break + + case 'error': + console.warn('[observer]', message.message) + break + + case 'pong': + break + } + } + + private patch(next: Partial): void { + this.state = { ...this.state, ...next } + for (const listener of this.listeners) listener() + } +} diff --git a/web/src/components/BlockTicker.tsx b/web/src/components/BlockTicker.tsx new file mode 100644 index 0000000..953b601 --- /dev/null +++ b/web/src/components/BlockTicker.tsx @@ -0,0 +1,87 @@ +/** + * The live block feed. + * + * The site's proof that it is live: blocks appear here the moment the node + * imports them, with no refresh and no polling. Each row flashes once on + * arrival — suppressed under `prefers-reduced-motion`, where the numbers alone + * still carry everything. + */ + +import { useEffect, useRef, useState } from 'react' + +import type { RecentBlock } from '../api/generated/RecentBlock' +import { ago, height, seconds, shortMiner } from '../lib/format' +import { usePinnedMiners } from '../lib/store' + +export function BlockTicker({ + blocks, + onSelect, +}: { + blocks: RecentBlock[] + onSelect: (miner: string) => void +}) { + const { isPinned } = usePinnedMiners() + const seen = useRef>(new Set()) + // `ago()` is a pure function of the clock, so nothing re-renders it on its + // own. One interval for the whole list, rather than a timer per row. + const [, tick] = useState(0) + useEffect(() => { + const id = window.setInterval(() => tick((n) => n + 1), 1000) + return () => window.clearInterval(id) + }, []) + + const fresh = new Set() + for (const block of blocks) { + if (!seen.current.has(block.height)) fresh.add(block.height) + } + useEffect(() => { + for (const block of blocks) seen.current.add(block.height) + }) + + if (blocks.length === 0) { + return

Waiting for the next block…

+ } + + return ( +
+ {blocks.map((block) => { + const mine = isPinned(block.miner) + const named = block.display !== shortMiner(block.miner) + return ( + + ) + })} +
+ ) +} diff --git a/web/src/components/Leaderboard.tsx b/web/src/components/Leaderboard.tsx new file mode 100644 index 0000000..f232d9b --- /dev/null +++ b/web/src/components/Leaderboard.tsx @@ -0,0 +1,155 @@ +/** + * The standings. + * + * A table, not a chart. Eight-plus miners each carrying a name, a count, a + * share, a hashrate and a streak is exactly the case the form heuristic sends + * to a table — and a miner looking for their own row wants to *read* it, not + * hunt for a colour in a legend. + * + * The one graphical element is the share bar: single hue, magnitude only. A + * pinned miner's row is marked three ways at once — a left rule, a wash, and an + * explicit "YOU" chip — so identity never rests on colour alone. + */ + +import type { LeaderboardRow } from '../api/generated/LeaderboardRow' +import { ago, hashrate, share, shortMiner } from '../lib/format' +import { usePinnedMiners } from '../lib/store' + +function Row({ + row, + max, + mine, + onToggle, + onSelect, +}: { + row: LeaderboardRow + max: number + mine: boolean + onToggle: (miner: string) => void + onSelect: (miner: string) => void +}) { + const named = row.attribution === 'telemetry' + return ( + + {row.rank} + +
+ + { + e.preventDefault() + onSelect(row.miner) + }} + > + {named ? row.display : shortMiner(row.miner)} + + {mine && You} + {named && ( + + node + + )} +
+ + {row.blocks} + {/* Bar and number in one cell: two columns for one quantity cost ~80px + and pushed the streak and last-block columns off the table at ordinary + window widths. The bar is the magnitude, the number is its direct + label — they belong together. */} + + {share(row.share)} + + + {hashrate(row.hashrate_estimate)} + + {row.best_streak > 1 ? `×${row.best_streak}` : '—'} + + + {ago(row.last_seen)} + + + ) +} + +export function Leaderboard({ + rows, + ready, + onSelect, +}: { + rows: LeaderboardRow[] + ready: boolean + onSelect: (miner: string) => void +}) { + const { isPinned, toggle } = usePinnedMiners() + // Bars are scaled to the leader, not to 100%: with twenty miners the top + // share is often under 15%, and full-scale bars would all be slivers. + const max = rows.length > 0 ? Math.max(...rows.map((r) => r.share)) : 0 + + if (rows.length === 0) { + return ( +

+ {ready + ? 'No blocks observed in this window yet. A shorter window may show more.' + : 'Waiting for the first snapshot…'} +

+ ) + } + + return ( +
+ + + + + + + + + + + + + + + {rows.map((row) => ( + + ))} + +
+ Miners ranked by blocks won in the selected window +
# + Miner + BlocksShareEst. hashrate + Streak + + Last block +
+
+ ) +} diff --git a/web/src/components/MinerPanel.tsx b/web/src/components/MinerPanel.tsx new file mode 100644 index 0000000..b190c6a --- /dev/null +++ b/web/src/components/MinerPanel.tsx @@ -0,0 +1,139 @@ +/** + * One miner's page. + * + * The reason the site exists: a miner types (or clicks) their reward preimage + * and sees where they stand and how they have been doing. Everything here is + * public chain data — there is nothing to log into, and the "this one is mine" + * mark lives only in this browser. + */ + +import { useEffect, useState } from 'react' + +import type { MinerDetail } from '../api/generated/MinerDetail' +import type { Window as WindowName } from '../api/generated/Window' +import { RequestFailed, fetchMiner } from '../api/rest' +import { ago, bigNumber, hashrate, height, share, shortMiner } from '../lib/format' +import { usePinnedMiners } from '../lib/store' +import { ShareChart } from './ShareChart' + +export function MinerPanel({ + chain, + miner, + windowName, + onClose, +}: { + chain: string + miner: string + windowName: WindowName + onClose: () => void +}) { + const [detail, setDetail] = useState(null) + const [error, setError] = useState(null) + const { isPinned, toggle } = usePinnedMiners() + const mine = isPinned(miner) + + useEffect(() => { + const controller = new AbortController() + setDetail(null) + setError(null) + fetchMiner(chain, miner, windowName, controller.signal) + .then(setDetail) + .catch((e: unknown) => { + // An abort is this component being replaced, not a failure to report. + if (controller.signal.aborted) return + setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.') + }) + return () => controller.abort() + // Re-fetched on window change because the history range follows the window. + }, [chain, miner, windowName]) + + const row = detail?.current ?? null + const named = row?.attribution === 'telemetry' + + return ( +
+
+
+
Miner
+

+ {named ? row.display : shortMiner(miner)} +

+
+ {miner} +
+
+
+ + +
+
+ + {error &&

{error}

} + + {!error && !detail &&

Reading the record…

} + + {detail && ( + <> +
+
+
Rank
+
{row ? `#${row.rank}` : '—'}
+
+ {row ? `${share(row.share)} of the window` : 'no blocks in this window'} +
+
+
+
Est. hashrate
+
{hashrate(row?.hashrate_estimate)}
+
from share of blocks won
+
+
+
Blocks observed
+
{height(detail.blocks_observed)}
+
+ {detail.first_seen ? `first seen ${ago(detail.first_seen)}` : 'never seen'} +
+
+
+
Cumulative work
+
+ {bigNumber(detail.cumulative_work)} +
+ {/* Difficulty summed over every block won. It does not shrink when + difficulty rises, which a block count effectively does. */} +
expected hashes, all blocks
+
+
+
Best streak
+
{row && row.best_streak > 1 ? `×${row.best_streak}` : '—'}
+
consecutive blocks
+
+
+ +
+
+ Share of network blocks +
+ +
+ + )} +
+ ) +} diff --git a/web/src/components/ShareChart.tsx b/web/src/components/ShareChart.tsx new file mode 100644 index 0000000..31b9b27 --- /dev/null +++ b/web/src/components/ShareChart.tsx @@ -0,0 +1,211 @@ +/** + * One miner's share of the network over time. + * + * A single-series area chart: the job is change-over-time for one quantity, so + * there is one hue and no legend — the panel title already says what is + * plotted, and a legend box with one swatch would restate it. + * + * Inline SVG rather than a charting library. The whole chart is one path, one + * fill and two axes; a library would be a hundred kilobytes and a second set of + * theming rules to keep in step with the CSS custom properties above it. + * + * The viewBox tracks the container's measured width rather than being fixed and + * scaled to fit. A fixed viewBox stretched to a full-width panel scales its + * *height* with it — a 190-unit chart became 300px tall on a wide screen, and + * the area fill went from a wash to a slab. Measuring keeps one SVG unit at one + * CSS pixel, which also makes the crosshair land exactly under the pointer. + */ + +import { useEffect, useMemo, useRef, useState } from 'react' + +import type { MinerSeriesPoint } from '../api/generated/MinerSeriesPoint' +import { hashrate, share } from '../lib/format' + +const HEIGHT = 200 +const PAD = { top: 16, right: 18, bottom: 26, left: 46 } +/** Used until the container has been measured, and if ResizeObserver is absent. */ +const FALLBACK_WIDTH = 720 + +interface Placed { + point: MinerSeriesPoint + x: number + y: number +} + +function useMeasuredWidth(): [React.RefObject, number] { + const ref = useRef(null) + const [width, setWidth] = useState(FALLBACK_WIDTH) + useEffect(() => { + const node = ref.current + if (!node) return + const observer = new ResizeObserver(([entry]) => { + if (entry) setWidth(Math.max(320, entry.contentRect.width)) + }) + observer.observe(node) + return () => observer.disconnect() + }, []) + return [ref, width] +} + +function timeLabel(ms: number, spanMs: number): string { + // A week of data wants dates; an hour wants clock time. Showing both at all + // ranges is noise, and showing neither leaves a time series with no time. + return new Date(ms).toLocaleString(undefined, { + ...(spanMs > 86_400_000 ? { month: 'short', day: 'numeric' } : {}), + hour: '2-digit', + minute: '2-digit', + }) +} + +export function ShareChart({ points }: { points: MinerSeriesPoint[] }) { + const [wrap, width] = useMeasuredWidth() + const [hover, setHover] = useState(null) + + const chart = useMemo(() => { + if (points.length === 0) return null + const times = points.map((p) => new Date(p.at).getTime()) + const t0 = Math.min(...times) + const t1 = Math.max(...times) + const span = t1 - t0 || 1 + // The y-axis starts at zero — a share chart whose baseline floats makes a + // steady miner look volatile. Headroom above the peak keeps the line off + // the top edge. + const peak = Math.max(...points.map((p) => p.share), 0.0001) + const top = peak * 1.15 + + const plotW = width - PAD.left - PAD.right + const plotH = HEIGHT - PAD.top - PAD.bottom + const baseline = PAD.top + plotH + const x = (t: number) => PAD.left + ((t - t0) / span) * plotW + const y = (v: number) => baseline - (v / top) * plotH + + const placed: Placed[] = points.map((point, i) => ({ + point, + x: x(times[i] ?? t0), + y: y(point.share), + })) + + const line = placed + .map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)} ${p.y.toFixed(1)}`) + .join(' ') + const first = placed[0] + const last = placed[placed.length - 1] + const area = + first && last + ? `${line} L${last.x.toFixed(1)} ${baseline} L${first.x.toFixed(1)} ${baseline} Z` + : '' + + const yTicks = [0, top / 2, top].map((v) => ({ v, y: y(v) })) + // Three x labels — ends and middle. More would collide on a narrow panel, + // and the tooltip carries the exact time for any point. + const xTicks = [0, 0.5, 1].map((f) => ({ t: t0 + span * f, x: x(t0 + span * f) })) + + return { placed, area, line, yTicks, xTicks, baseline, span, last } + }, [points, width]) + + if (!chart) { + return

No history recorded for this miner yet.

+ } + + const onMove = (event: React.PointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect() + const local = event.clientX - rect.left + let nearest: Placed | null = null + let best = Infinity + for (const p of chart.placed) { + const d = Math.abs(p.x - local) + if (d < best) { + best = d + nearest = p + } + } + setHover(nearest) + } + + return ( +
+ setHover(null)} + > + {chart.yTicks.map((t) => ( + + + + {share(t.v)} + + + ))} + + {chart.xTicks.map((t, i) => ( + + {timeLabel(t.t, chart.span)} + + ))} + + + + + {hover && ( + <> + + {/* 2px surface ring so the marker stays legible where it sits on the + line; the ring is part of the hit target, not just spacing. */} + + + )} + + {/* One direct label, on the endpoint — the current value is the one a + reader is looking for. Labelling every point would be unreadable. */} + {!hover && chart.last && ( + + {share(chart.last.point.share)} + + )} + + + {hover && ( +
width / 2 ? 'translate(-100%, 0)' : 'translate(10px, 0)', + }} + > +
{timeLabel(new Date(hover.point.at).getTime(), chart.span)}
+
+ {share(hover.point.share)} of blocks +
+
+ {hover.point.blocks} block{hover.point.blocks === 1 ? '' : 's'} ·{' '} + {hashrate(hover.point.hashrate_estimate)} +
+
+ )} +
+ ) +} diff --git a/web/src/components/StatBar.tsx b/web/src/components/StatBar.tsx new file mode 100644 index 0000000..f5f8747 --- /dev/null +++ b/web/src/components/StatBar.tsx @@ -0,0 +1,94 @@ +/** + * The headline numbers. + * + * A KPI row of stat tiles, not a chart: these are five single current values, + * and a bar chart of five unrelated quantities would be a chart of nothing. + * Network hashrate leads as the hero figure because it is the one number that + * answers "how hard is this to win right now". + */ + +import type { ChainSummary } from '../api/generated/ChainSummary' +import type { Window as WindowName } from '../api/generated/Window' +import { bigNumber, hashrate, height, seconds, windowSpan } from '../lib/format' + +function Stat({ + label, + value, + note, + hero, + noteClass, + title, +}: { + label: string + value: string + note?: string + hero?: boolean + noteClass?: string + title?: string +}) { + return ( +
+
{label}
+
+ {value} +
+
{note ?? ''}
+
+ ) +} + +export function StatBar({ + summary, + windowName, +}: { + summary: ChainSummary | null + windowName: WindowName +}) { + if (!summary) { + return ( +
+ {['Network hashrate', 'Difficulty', 'Block time', 'Height', 'Miners'].map((label) => ( + + ))} +
+ ) + } + + const interval = summary.block_interval_seconds ?? summary.target_block_time_seconds + const windowBlocks = { hour: 600, six_hours: 3600, day: 14400, week: 100800 }[windowName] + + return ( +
+ + + + + +
+ ) +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..dc8d259 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,707 @@ +/* + * blackbeard.observer — the design system. + * + * Dark, and only dark. This is a deliberate commitment rather than an omission: + * the site is a scoreboard for a proof-of-work chain, read in the same rooms as + * the miners it reports on, and a light variant would be a second palette to + * validate for the sake of a context this content does not have. `color-scheme` + * is stated so the browser paints form controls and scrollbars to match instead + * of flashing white. + * + * The two data colours were validated with the dataviz palette checker against + * the chart surface (#14110d): bronze #bd8829 passes the lightness band, chroma + * floor and 3:1 contrast. Bronze and crimson are adjacent hues and do NOT pass + * as a categorical pair — which is why no chart here uses them as two series. + * Every chart is single-series (magnitude, one hue); identity is carried by + * labels and row treatment, never by a second colour a reader has to tell apart. + */ + +:root { + color-scheme: dark; + + /* Surfaces, warm-black — a cold grey reads as a developer tool, not a arena. */ + --surface-0: #0d0b09; + --surface-1: #14110d; + --surface-2: #1c1813; + --surface-3: #262019; + --border: #2f2719; + --border-strong: #453923; + + /* Ink. Contrast against --surface-1: 16.0, 6.3, 3.7. */ + --text-primary: #f2ece0; + --text-secondary: #a2937c; + --text-muted: #7a6c59; + + /* The single data hue: magnitude, share bars, the history area. */ + --data: #bd8829; + --data-bright: #e0a63a; + --data-wash: rgba(189, 136, 41, 0.1); + + /* Identity and alarm. Never a second series alongside --data. */ + --accent: #d8453a; + --accent-bright: #f05a4c; + --accent-wash: rgba(216, 69, 58, 0.1); + + --good: #3f9d76; + --warn: #d9a441; + + --font-display: 'Iowan Old Style', 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, serif; + --font-body: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace; + + --rule: 1px solid var(--border); + --shadow: 0 1px 0 rgba(255, 255, 255, 0.03) inset; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + background: + /* A faint warm glow behind the masthead, so the page has a top rather than + being a flat field. Fixed so it does not travel with the scroll. */ + radial-gradient(120% 60% at 50% -10%, rgba(189, 136, 41, 0.08), transparent 60%), + var(--surface-0); + background-attachment: fixed; + color: var(--text-primary); + font-family: var(--font-body); + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +a { + color: inherit; + text-decoration: none; +} + +/* Visible focus everywhere. The site is a table of links; keyboard users need + to see where they are, and the default ring is invisible on this ground. */ +:focus-visible { + outline: 2px solid var(--data-bright); + outline-offset: 2px; + border-radius: 2px; +} + +/* ---- type ---------------------------------------------------------------- */ + +.display { + font-family: var(--font-display); + font-weight: 700; + letter-spacing: 0.02em; +} + +.eyebrow { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--text-muted); +} + +.numeral { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + /* Tabular figures are the whole point: a column of hashrates that shifts + horizontally on every update is unreadable on a page that updates every + few seconds. */ +} + +/* ---- layout -------------------------------------------------------------- */ + +.shell { + max-width: 1180px; + margin: 0 auto; + padding: 0 20px 72px; +} + +.panel { + background: var(--surface-1); + border: var(--rule); + box-shadow: var(--shadow); +} + +.panel-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 14px 18px; + border-bottom: var(--rule); + flex-wrap: wrap; +} + +.panel-title { + font-family: var(--font-display); + font-size: 17px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + margin: 0; +} + +/* ---- masthead ------------------------------------------------------------ */ + +.masthead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 22px 0 18px; + flex-wrap: wrap; +} + +.mark { + display: flex; + align-items: center; + gap: 12px; +} + +.mark-name { + font-family: var(--font-display); + font-size: 25px; + font-weight: 700; + letter-spacing: 0.04em; + line-height: 1; +} + +.mark-name span { + color: var(--data); +} + +.mark-tagline { + font-size: 11px; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--text-muted); + margin-top: 5px; +} + +.masthead-right { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +/* ---- connection light ---------------------------------------------------- */ + +.status { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-secondary); +} + +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-muted); + flex: none; +} + +.status-live .status-dot { + background: var(--good); + box-shadow: 0 0 0 3px rgba(63, 157, 118, 0.18); +} + +.status-reconnecting .status-dot, +.status-connecting .status-dot { + background: var(--warn); + animation: pulse 1.1s ease-in-out infinite; +} + +.status-offline .status-dot { + background: var(--accent); +} + +@keyframes pulse { + 50% { + opacity: 0.25; + } +} + +/* ---- chain switcher & window selector ------------------------------------ */ + +.segmented { + display: inline-flex; + border: var(--rule); + background: var(--surface-1); +} + +.segmented button { + appearance: none; + border: 0; + background: transparent; + color: var(--text-secondary); + font: inherit; + font-size: 12px; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 7px 13px; + cursor: pointer; + white-space: nowrap; +} + +.segmented button + button { + border-left: var(--rule); +} + +.segmented button:hover:not(:disabled) { + color: var(--text-primary); + background: var(--surface-2); +} + +.segmented button[aria-pressed='true'] { + background: var(--data-wash); + color: var(--data-bright); +} + +.segmented button:disabled { + color: var(--text-muted); + cursor: not-allowed; +} + +/* ---- stat tiles ---------------------------------------------------------- */ + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(178px, 1fr)); + gap: 1px; + background: var(--border); + border: var(--rule); + margin-bottom: 26px; +} + +.stat { + background: var(--surface-1); + padding: 15px 18px 16px; + min-width: 0; +} + +/* Two columns and five tiles leaves a blank cell. Letting the hero span the row + makes six, which fills the grid — and gives the headline number the width it + deserves on the screen where it is most cramped. */ +@media (max-width: 620px) { + .stat-hero { + grid-column: 1 / -1; + } +} + +.stat-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--text-muted); +} + +.stat-value { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: 25px; + font-weight: 600; + line-height: 1.15; + margin-top: 7px; + overflow-wrap: anywhere; +} + +.stat-hero .stat-value { + font-size: 33px; + color: var(--data-bright); +} + +.stat-note { + font-size: 11px; + color: var(--text-muted); + margin-top: 5px; + min-height: 1.2em; +} + +.stat-note.nominal { + color: var(--warn); +} + +/* ---- leaderboard --------------------------------------------------------- */ + +.board { + width: 100%; + border-collapse: collapse; +} + +.board th { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-muted); + text-align: right; + padding: 10px 14px; + border-bottom: var(--rule); + white-space: nowrap; +} + +.board th:first-child, +.board th.left { + text-align: left; +} + +.board td { + padding: 9px 14px; + border-bottom: 1px solid rgba(47, 39, 25, 0.55); + text-align: right; + white-space: nowrap; +} + +.board td:first-child, +.board td.left { + text-align: left; +} + +.board tbody tr:hover { + background: var(--surface-2); +} + +/* The pinned row: a left rule and a wash, plus an explicit "YOU" chip. Three + redundant cues, none of them colour alone. */ +.board tbody tr.mine { + background: var(--accent-wash); + box-shadow: inset 3px 0 0 var(--accent); +} + +.board tbody tr.mine:hover { + background: rgba(216, 69, 58, 0.16); +} + +.rank { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: 14px; + color: var(--text-muted); + width: 1%; +} + +.rank-1, +.rank-2, +.rank-3 { + color: var(--data-bright); + font-weight: 700; +} + +.miner-cell { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} + +.miner-name { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; +} + +.miner-name.anonymous { + font-family: var(--font-mono); + font-weight: 400; + font-size: 13px; + color: var(--text-secondary); +} + +.chip { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 2px 6px; + border: 1px solid currentColor; + flex: none; + line-height: 1.4; +} + +.chip-you { + color: var(--accent-bright); +} + +.chip-named { + color: var(--text-muted); +} + +/* Share: the percentage as the direct label, the bar beneath it as magnitude. + One hue, 4px rounded data-end at the far side, square against the baseline it + grows from. */ +.share-cell { + /* Wide enough that the bar has usable resolution between a 20% leader and + a 0.6% tail; narrower and every small miner's bar is the same sliver. */ + min-width: 92px; +} + +.share-bar { + position: relative; + height: 5px; + margin-top: 4px; + background: var(--surface-3); +} + +.share-bar > i { + display: block; + height: 100%; + background: var(--data); + border-radius: 0 3px 3px 0; +} + +tr.mine .share-bar > i { + background: var(--accent); +} + +/* Below this width the table would need a horizontal scrollbar, and a column + behind a scrollbar is a column nobody reads. "Last block" is the one a reader + can most easily do without — the live ticker beside the table already says + who mined most recently. */ +@media (max-width: 1320px) { + .board .last-seen { + display: none; + } +} + +/* On a phone the table still scrolls inside its own box — which is correct for + a data table — but dropping the least load-bearing column and tightening the + cells means far less of it is behind that scroll. Blocks, share and estimated + hashrate, the three a miner actually came for, stay visible without scrolling. */ +@media (max-width: 720px) { + .board .streak { + display: none; + } + + .board th, + .board td { + padding: 9px 9px; + } + + .miner-name { + max-width: 150px; + } +} + +.pin-button { + appearance: none; + background: transparent; + border: 0; + color: var(--text-muted); + cursor: pointer; + font: inherit; + font-size: 15px; + line-height: 1; + padding: 3px 5px; +} + +.pin-button:hover { + color: var(--data-bright); +} + +.pin-button[aria-pressed='true'] { + color: var(--accent-bright); +} + +/* ---- block ticker -------------------------------------------------------- */ + +.ticker-row { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 12px; + align-items: baseline; + padding: 8px 18px; + border-bottom: 1px solid rgba(47, 39, 25, 0.55); + font-size: 13px; +} + +.ticker-row.mine { + background: var(--accent-wash); + box-shadow: inset 3px 0 0 var(--accent); +} + +/* Newly arrived blocks flash once. Suppressed for reduced-motion, below. */ +.ticker-row.fresh { + animation: arrive 1.4s ease-out; +} + +@keyframes arrive { + from { + background: var(--data-wash); + } +} + +.ticker-height { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--text-muted); + font-size: 12px; +} + +.ticker-gap { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--text-muted); + font-size: 12px; +} + +/* ---- chart --------------------------------------------------------------- */ + +.chart { + width: 100%; + display: block; + touch-action: none; +} + +.chart .grid { + stroke: var(--border); + stroke-width: 1; +} + +.chart .area { + fill: var(--data-wash); +} + +.chart .line { + fill: none; + stroke: var(--data); + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} + +.chart .crosshair { + stroke: var(--border-strong); + stroke-width: 1; +} + +.chart .marker { + fill: var(--data); + stroke: var(--surface-1); + stroke-width: 2; +} + +.chart-axis { + font-family: var(--font-mono); + font-size: 10px; + fill: var(--text-muted); +} + +.tooltip { + position: absolute; + pointer-events: none; + background: var(--surface-3); + border: 1px solid var(--border-strong); + padding: 7px 10px; + font-size: 12px; + line-height: 1.45; + white-space: nowrap; + z-index: 5; +} + +.tooltip-label { + color: var(--text-muted); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +/* ---- misc ---------------------------------------------------------------- */ + +.empty { + padding: 40px 18px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +.banner { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 16px; + border: 1px solid var(--border-strong); + background: var(--surface-2); + font-size: 13px; + margin-bottom: 20px; +} + +.banner-warn { + border-color: rgba(217, 164, 65, 0.4); + color: var(--warn); +} + +.footer { + margin-top: 44px; + padding-top: 20px; + border-top: var(--rule); + font-size: 12px; + color: var(--text-muted); + display: flex; + gap: 18px; + flex-wrap: wrap; + justify-content: space-between; +} + +.footer a { + color: var(--text-secondary); + text-decoration: underline; + text-underline-offset: 3px; +} + +/* Wide content scrolls inside its own box; the page never scrolls sideways. */ +.scroll-x { + overflow-x: auto; +} + +.two-col { + display: grid; + grid-template-columns: minmax(0, 1.75fr) minmax(0, 1fr); + gap: 22px; + align-items: start; +} + +@media (max-width: 880px) { + .two-col { + grid-template-columns: minmax(0, 1fr); + } +} + +/* Motion is decoration here — the numbers carry the information, so it can all + go without losing anything. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} diff --git a/web/src/lib/ObserverProvider.tsx b/web/src/lib/ObserverProvider.tsx new file mode 100644 index 0000000..1368943 --- /dev/null +++ b/web/src/lib/ObserverProvider.tsx @@ -0,0 +1,23 @@ +import { useEffect } from 'react' +import type { ReactNode } from 'react' + +import type { Observer } from '../api/socket' +import { ObserverContext } from './observer-context' + +/** Provides the single socket for the tab. */ +export function ObserverProvider({ + observer, + children, +}: { + observer: Observer + children: ReactNode +}) { + useEffect(() => { + observer.connect() + // Deliberately not disconnecting on unmount: in React 19's StrictMode the + // effect runs twice in development, and tearing the socket down between + // them produces a reconnect storm that looks like a bug in the backend. The + // socket's lifetime is the tab's, and the tab closing closes it. + }, [observer]) + return {children} +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..b6601b3 --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,94 @@ +/** + * Formatting. + * + * All of it in one place because most of these numbers are wrong-looking if + * formatted naively — a hashrate in bare hashes per second is fourteen digits, + * a difficulty is thirty, and a share of 0.0004 rounds to "0%". + */ + +/** SI-ish hashrate. Miners talk in MH/s and GH/s; nobody says "6.9e9 H/s". */ +export function hashrate(hs: number | null | undefined): string { + if (hs === null || hs === undefined || !Number.isFinite(hs)) return '—' + const units = ['H/s', 'kH/s', 'MH/s', 'GH/s', 'TH/s', 'PH/s', 'EH/s'] + let value = hs + let unit = 0 + while (value >= 1000 && unit < units.length - 1) { + value /= 1000 + unit += 1 + } + const digits = value >= 100 ? 0 : value >= 10 ? 1 : 2 + return `${value.toFixed(digits)} ${units[unit]}` +} + +/** + * A very large integer, given as a decimal string, in compact form. + * + * Difficulty arrives as a string precisely so it is not rounded in transit + * (see `BigUintDec` in the Rust entities crate); it is rounded here, once, for + * display, and the full value goes in the element's `title`. + */ +export function bigNumber(decimal: string | null | undefined): string { + if (!decimal) return '—' + const digits = decimal.replace(/^0+(?=\d)/, '') + const suffixes = ['', 'K', 'M', 'B', 'T', 'Q'] + const magnitude = Math.floor((digits.length - 1) / 3) + if (magnitude === 0) return digits + if (magnitude >= suffixes.length) { + // Past quadrillions, an exponent is more honest than inventing a suffix. + return `${digits[0]}.${digits.slice(1, 3)}e${digits.length - 1}` + } + const whole = digits.slice(0, digits.length - magnitude * 3) + const fraction = digits.slice(whole.length, whole.length + 2) + return `${whole}.${fraction}${suffixes[magnitude]}` +} + +/** A 0–1 share as a percentage. Small shares keep a decimal so they aren't 0%. */ +export function share(fraction: number): string { + const pct = fraction * 100 + if (pct === 0) return '0%' + if (pct < 0.1) return '<0.1%' + return `${pct.toFixed(pct < 10 ? 1 : 0)}%` +} + +/** Seconds, phrased the way a block interval reads. */ +export function seconds(value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(value)) return '—' + if (value < 10) return `${value.toFixed(1)}s` + if (value < 120) return `${Math.round(value)}s` + return `${(value / 60).toFixed(1)}m` +} + +/** How long ago, in the shortest form that is still unambiguous. */ +export function ago(iso: string, now: number = Date.now()): string { + const delta = Math.max(0, (now - new Date(iso).getTime()) / 1000) + if (delta < 5) return 'just now' + if (delta < 60) return `${Math.floor(delta)}s ago` + if (delta < 3600) return `${Math.floor(delta / 60)}m ago` + if (delta < 86400) return `${Math.floor(delta / 3600)}h ago` + return `${Math.floor(delta / 86400)}d ago` +} + +/** Block heights with thousands separators. */ +export function height(value: number | null | undefined): string { + return value === null || value === undefined ? '—' : value.toLocaleString('en-US') +} + +/** The abbreviated form of a reward preimage, matching the backend's. */ +export function shortMiner(miner: string): string { + return miner.length > 14 ? `${miner.slice(0, 10)}…${miner.slice(-4)}` : miner +} + +/** + * Approximately how long a window of `blocks` covers, at a measured or target + * interval. + * + * The window is a block count, not a duration — deliberately, because a + * duration is meaningless while a node is catching up. This is the human gloss + * on it, and it says "about" for a reason. + */ +export function windowSpan(blocks: number, intervalSeconds: number): string { + const total = blocks * intervalSeconds + if (total < 5400) return `~${Math.round(total / 60)} min` + if (total < 172800) return `~${Math.round(total / 3600)} h` + return `~${Math.round(total / 86400)} d` +} diff --git a/web/src/lib/observer-context.ts b/web/src/lib/observer-context.ts new file mode 100644 index 0000000..989c8b3 --- /dev/null +++ b/web/src/lib/observer-context.ts @@ -0,0 +1,20 @@ +/** + * The context the provider fills and the hooks read. + * + * Its own module so `ObserverProvider.tsx` can export only a component and + * `store.ts` only hooks — React Fast Refresh gives up on a file that exports + * both, which in development means every socket change forces a full reload. + */ + +import { createContext, useContext } from 'react' + +import type { Observer } from '../api/socket' + +export const ObserverContext = createContext(null) + +/** The tab's socket. Throws outside a provider rather than failing silently. */ +export function useObserverInstance(): Observer { + const observer = useContext(ObserverContext) + if (!observer) throw new Error('useObserver must be used inside an ObserverProvider') + return observer +} diff --git a/web/src/lib/store.ts b/web/src/lib/store.ts new file mode 100644 index 0000000..111d2f9 --- /dev/null +++ b/web/src/lib/store.ts @@ -0,0 +1,108 @@ +/** + * React's view of the socket, plus the one piece of purely local state: which + * miner is *yours*. + */ + +import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react' + +import type { ObserverState } from '../api/socket' +import { useObserverInstance } from './observer-context' + +/** The whole live state. */ +export function useObserver(): ObserverState { + const observer = useObserverInstance() + return useSyncExternalStore(observer.subscribe, observer.getSnapshot, observer.getSnapshot) +} + +/** Subscribe to a chain and window; re-subscribes when either changes. */ +export function useWatch(chain: string | null, windowName: ObserverState['window']): void { + const observer = useObserverInstance() + useEffect(() => { + if (chain) observer.watch(chain, windowName) + }, [observer, chain, windowName]) +} + +const PINNED_KEY = 'blackbeard.pinned-miners' + +/** + * The miners this browser calls its own. + * + * There are no accounts here and there is nothing to log into: a reward + * preimage is public, so "this one is mine" is a claim the site has no way to + * verify and no reason to. It lives in `localStorage`, which means it is + * per-browser, survives a refresh, and never reaches the server. That is the + * whole feature — and it is why nothing on the site is gated behind it. + */ +export function usePinnedMiners(): { + pinned: string[] + isPinned: (miner: string) => boolean + toggle: (miner: string) => void +} { + const pinned = useSyncExternalStore(subscribePinned, readPinned, () => EMPTY) + + const isPinned = useCallback((miner: string) => pinned.includes(miner.toLowerCase()), [pinned]) + + const toggle = useCallback((miner: string) => { + const id = miner.toLowerCase() + const current = readPinned() + const next = current.includes(id) ? current.filter((m) => m !== id) : [...current, id] + writePinned(next) + }, []) + + return useMemo(() => ({ pinned, isPinned, toggle }), [pinned, isPinned, toggle]) +} + +const EMPTY: string[] = [] +let cache: string[] = EMPTY +let cacheRaw: string | null = null +const pinnedListeners = new Set<() => void>() + +function subscribePinned(listener: () => void): () => void { + pinnedListeners.add(listener) + // `storage` fires in *other* tabs, so pinning a miner in one tab updates the + // highlight in the others without either of them reloading. + const onStorage = (event: StorageEvent) => { + if (event.key === PINNED_KEY) listener() + } + window.addEventListener('storage', onStorage) + return () => { + pinnedListeners.delete(listener) + window.removeEventListener('storage', onStorage) + } +} + +/** + * Read the pinned list, memoised on the raw string. + * + * `useSyncExternalStore` demands a stable reference between changes; parsing + * fresh JSON on every call returns a new array each time and React throws. + */ +function readPinned(): string[] { + let raw: string | null = null + try { + raw = window.localStorage.getItem(PINNED_KEY) + } catch { + // Private windows and "block site data" both throw rather than return null. + return EMPTY + } + if (raw === cacheRaw) return cache + cacheRaw = raw + try { + const parsed: unknown = raw ? JSON.parse(raw) : [] + cache = Array.isArray(parsed) ? parsed.filter((m): m is string => typeof m === 'string') : EMPTY + } catch { + cache = EMPTY + } + return cache +} + +function writePinned(next: string[]): void { + try { + window.localStorage.setItem(PINNED_KEY, JSON.stringify(next)) + } catch { + // Storage unavailable: the pin lasts for this render and no longer. Better + // than refusing the click. + } + cacheRaw = null + for (const listener of pinnedListeners) listener() +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..45d8078 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,22 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' + +import App from './App' +import { Observer } from './api/socket' +import { ObserverProvider } from './lib/ObserverProvider' +import './index.css' + +// One socket per tab, created here rather than inside a component so React 19's +// double-invoked StrictMode effects cannot open two. +const observer = new Observer() + +const root = document.getElementById('root') +if (!root) throw new Error('#root is missing from index.html') + +createRoot(root).render( + + + + + , +) diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..5ff153f --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,16 @@ +/// + +interface ImportMetaEnv { + /** + * Override the REST base. Defaults to `/v1` on the page's own origin, which + * is what both nginx and the dev proxy serve — so this is only needed when + * the frontend is hosted apart from the API. + */ + readonly VITE_API_BASE_URL?: string + /** Override the WebSocket URL. Defaults to `/v1/ws` on the page's origin. */ + readonly VITE_WS_URL?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..4baeb18 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "types": ["vite/client"], + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noUncheckedIndexedAccess": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo" + }, + "include": ["src"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..05749b2 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..db30257 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +// The built output is static and served by nginx from /var/www/blackbeard.observer; +// there is no Node.js in production (architecture/generic.md §4). +export default defineConfig({ + plugins: [react()], + build: { + // Source maps ship: the bundle is public, the code is GPL, and a stack + // trace from a miner's browser is worth far more than the bytes. + sourcemap: true, + }, + server: { + port: 5173, + // `pnpm dev` talks to a locally-run blackbeard-api so the browser sees one + // origin and the WebSocket upgrade needs no CORS dance — the same shape + // nginx presents in production. + proxy: { + '/v1': { + target: 'http://127.0.0.1:25864', + changeOrigin: true, + ws: true, + }, + }, + }, +})