diff --git a/.env.example b/.env.example index e9636c46..268bd178 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,16 @@ AZTEC_INDEXER_BASE_URL="http://localhost:8000" # Optional: API key for authentication (if required by your indexer) # AZTEC_INDEXER_API_KEY="" +# Monero RPC (self-hosted at rpc.monero.citizenweb3.com). +# Bearer token used by server/tools/chains/monero/rpc-client.ts. Obtain from project owner. +MONERO_RPC_TOKEN="" + +# Monero Indexer API (server-side only, no NEXT_PUBLIC_ prefix). +# External indexer service we control: provides blocks/transactions list and detail endpoints. +# Mirrors the Aztec indexer pattern (typed HTTP client + Bearer auth). +MONERO_INDEXER_BASE_URL="http://localhost:8100" +MONERO_INDEXER_API_TOKEN="" + # ============================================ # OpenTelemetry Configuration # ============================================ diff --git a/.gitignore b/.gitignore index 86d53ae4..d1b4aebd 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,13 @@ next-env.d.ts # Vertex AI service account (second layer of defense — primary is secrets/.gitignore) /secrets/*.json -/.agent-reviews \ No newline at end of file +/.agent-reviews + +# agent tooling / local artifacts +/graphify-out +/.playwright-mcp +/.tasks +.claude/settings.json + +# design-iteration screenshots (root-level scratch) +/*.png \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 2aaefad4..01738c86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,16 +25,16 @@ Examples: Use clawmem when you don't know exact names and need to discover relevant code. -### Code Relationships & Impact (GitNexus) +### Code Relationships & Impact (Graphify) -When you need to understand how code connects — use GitNexus MCP tools: +When you need to understand how code connects — use the Graphify CLI: -- `gitnexus_query({query: "concept"})` — find execution flows by concept -- `gitnexus_context({name: "symbolName"})` — 360° view: callers, callees, processes -- `gitnexus_impact({target: "functionName", direction: "upstream"})` — blast radius before editing -- `gitnexus_detect_changes()` — pre-commit scope check +- `graphify query "concept"` — find code/flows by concept +- `graphify explain "symbolName"` — 360° view: neighbors, callers, file:line +- `graphify affected "functionName"` — blast radius before editing +- `graphify update .` — incremental graph re-extraction after edits -Use GitNexus when you need to understand relationships, what will break, or trace execution flows. +Use Graphify when you need to understand relationships, what will break, or trace execution flows. ### Library Documentation (Context7) @@ -55,8 +55,8 @@ Use Context7 for: | Need | Tool | |------|------------------------------------| | Semantic search by meaning | clawmem (`find_similar`) | -| Code relationships / execution flows | GitNexus (`query`, `context`) | -| Impact before changes | GitNexus (`impact`, `detect_changes`) | +| Code relationships / execution flows | Graphify (`query`, `explain`, `path`) | +| Impact before changes | Graphify (`affected`) | | Library docs / examples | Context7 | | Exact string match | grep | | Project architecture | Read CLAUDE.md and AGENTS.md files | @@ -372,53 +372,52 @@ Follow these rules when you write code: - Develop modules, functions, classes, and components in accordance with the SOLID principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion. - Don't use BIGINT for PK autoincrement IDs in Prisma schema: use INT or STRING as ciud - -# GitNexus — Code Intelligence + +# Graphify — Code Intelligence -This project is indexed by GitNexus as **validatorinfo** (3542 symbols, 9796 relationships, 223 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by Graphify as **validatorinfo** (5360 nodes, 10373 edges). The graph is a local, deterministic Tree-sitter AST graph in `graphify-out/graph.json` (zero tokens, no API key). Use the `graphify` CLI to understand code, assess impact, and navigate safely. -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. +> If results look stale after edits, run `graphify update .` in terminal to re-extract (incremental, no LLM). ## Always Do -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `graphify affected "symbolName"` and report the blast radius (direct callers/importers, transitive deps, depth) to the user. +- **MUST re-check scope before committing**: run `graphify update .`, then `graphify affected` on each changed symbol to verify only expected symbols are touched. +- **MUST warn the user** if `affected` shows a wide blast radius (many d=1 dependents) before proceeding with edits. +- When exploring unfamiliar code, use `graphify query "concept"` (BFS traversal) to find execution flows instead of grepping. +- When you need full context on a specific symbol — neighbors, callers, file:line — use `graphify explain "symbolName"`. ## When Debugging -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/validatorinfo/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed +1. `graphify query ""` — find flows related to the issue +2. `graphify explain ""` — see neighbors, callers, file:line +3. `graphify path "A" "B"` — trace how two symbols connect +4. For regressions: `graphify affected ""` — see what your change reaches ## When Refactoring -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. +- **Renaming**: Graphify has no coordinated rename. First run `graphify affected "old"` to enumerate every caller/importer, edit each explicitly, then `graphify update .`. NEVER blind find-and-replace. +- **Extracting/Splitting**: run `graphify explain "target"` for incoming/outgoing refs, then `graphify affected "target"` to find all external callers before moving code. +- After any refactor: run `graphify update .` and re-check `affected` on the touched symbols. ## Never Do -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. +- NEVER edit a function, class, or method without first running `graphify affected` on it. +- NEVER ignore a wide blast radius (many direct dependents) without telling the user. +- NEVER rename symbols with find-and-replace — enumerate callers via `graphify affected` first. +- NEVER commit without re-running `graphify update .` + `affected` to check scope. ## Tools Quick Reference -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | +| Command | When to use | +|---------|-------------| +| `graphify query ""` | Find code by concept (BFS) | +| `graphify explain "X"` | 360-degree view of one symbol | +| `graphify affected "X"` | Blast radius before editing (reverse impact) | +| `graphify path "A" "B"` | Shortest path between two symbols | +| `graphify update .` | Incremental re-extract after edits (no LLM) | -## Impact Risk Levels +## Impact Depth (`graphify affected "X" --depth N`) | Depth | Meaning | Action | |-------|---------|--------| @@ -426,30 +425,21 @@ This project is indexed by GitNexus as **validatorinfo** (3542 symbols, 9796 rel | d=2 | LIKELY AFFECTED — indirect deps | Should test | | d=3 | MAY NEED TESTING — transitive | Test if critical path | -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/validatorinfo/context` | Codebase overview, check index freshness | -| `gitnexus://repo/validatorinfo/clusters` | All functional areas | -| `gitnexus://repo/validatorinfo/processes` | All execution flows | -| `gitnexus://repo/validatorinfo/process/{name}` | Step-by-step execution trace | - ## Self-Check Before Finishing Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope +1. `graphify affected` was run for all modified symbols +2. No wide blast radius was left unreported +3. `graphify update .` ran and the graph reflects the edits 4. All d=1 (WILL BREAK) dependents were updated ## CLI -- Re-index: `npx gitnexus analyze` -- Check freshness: `npx gitnexus status` -- Generate docs: `npx gitnexus wiki` +- Re-index (incremental): `graphify update .` +- Check freshness: `graphify check-update .` +- Architecture/call-flow HTML: `graphify export callflow-html` - + --- diff --git a/CLAUDE.md b/CLAUDE.md index 4e10f183..b8522912 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,24 +13,22 @@ If an `AGENTS.md` file exists in the target directory: ## Code Search & Documentation -### Finding Code & Understanding Architecture (GitNexus) +### Finding Code & Understanding Architecture (Graphify) -**MANDATORY:** Use GitNexus MCP tools for code understanding. GitNexus provides a knowledge graph with execution flows, impact analysis, and functional clustering. +**MANDATORY:** Use the Graphify CLI for code understanding. Graphify provides a local Tree-sitter AST graph with code relationships, impact analysis, and path traversal. -**Tools (7):** -- `query` — search for execution flows by concept (e.g. "delegation staking") -- `context` — 360° view of a symbol (callers, callees, processes, community) -- `impact` — blast radius analysis before changing code (WILL BREAK / LIKELY AFFECTED / MAY NEED TESTING) -- `detect_changes` — map git diff to affected execution flows -- `rename` — coordinated multi-file rename with confidence scoring -- `cypher` — raw Cypher queries against the code graph -- `list_repos` — list indexed repositories +**Tools:** +- `graphify query "concept"` — search for code/flows by concept +- `graphify explain "symbolName"` — 360-degree view of a symbol (neighbors, callers, file:line) +- `graphify affected "symbolName"` — blast radius analysis before changing code +- `graphify path "A" "B"` — shortest path between two symbols +- `graphify update .` — incremental graph re-extraction after edits **Rules:** -1. Before modifying any function/class: run `impact` to check blast radius -2. Before creating a PR: run `detect_changes` to assess risk -3. After implementing changes: reindex with `gitnexus analyze` in terminal -4. Prefer `query` over grep for conceptual searches (returns execution flows, not just files) +1. Before modifying any function/class: run `graphify affected "symbolName"` to check blast radius +2. Before creating a PR: run `graphify update .`, then `graphify affected` on changed symbols to assess risk +3. After implementing changes: reindex with `graphify update .` in terminal +4. Prefer `graphify query` over grep for conceptual searches **Reindex after:** adding new files, renaming functions, refactoring modules, or any structural change. @@ -53,8 +51,8 @@ Use Context7 for: | Need | Tool | |------|------------------------------------| | Semantic search by meaning | clawmem (`find_similar`) | -| Code relationships / execution flows | GitNexus (`query`, `context`) | -| Impact before changes | GitNexus (`impact`, `detect_changes`) | +| Code relationships / execution flows | Graphify (`query`, `explain`, `path`) | +| Impact before changes | Graphify (`affected`) | | Library docs / examples | Context7 | | Exact string match | grep | | Project architecture | Read CLAUDE.md and AGENTS.md files | @@ -440,53 +438,52 @@ For debugging indexer jobs, chain data, and database issues — use the `validat - Run `yarn lint` before committing - Run `yarn build` before pushing - -# GitNexus — Code Intelligence + +# Graphify — Code Intelligence -This project is indexed by GitNexus as **validatorinfo** (3542 symbols, 9796 relationships, 223 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by Graphify as **validatorinfo** (5360 nodes, 10373 edges). The graph is a local, deterministic Tree-sitter AST graph in `graphify-out/graph.json` (zero tokens, no API key). Use the `graphify` CLI to understand code, assess impact, and navigate safely. -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. +> If results look stale after edits, run `graphify update .` in terminal to re-extract (incremental, no LLM). ## Always Do -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `graphify affected "symbolName"` and report the blast radius (direct callers/importers, transitive deps, depth) to the user. +- **MUST re-check scope before committing**: run `graphify update .`, then `graphify affected` on each changed symbol to verify only expected symbols are touched. +- **MUST warn the user** if `affected` shows a wide blast radius (many d=1 dependents) before proceeding with edits. +- When exploring unfamiliar code, use `graphify query "concept"` (BFS traversal) to find execution flows instead of grepping. +- When you need full context on a specific symbol — neighbors, callers, file:line — use `graphify explain "symbolName"`. ## When Debugging -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/validatorinfo/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed +1. `graphify query ""` — find flows related to the issue +2. `graphify explain ""` — see neighbors, callers, file:line +3. `graphify path "A" "B"` — trace how two symbols connect +4. For regressions: `graphify affected ""` — see what your change reaches ## When Refactoring -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. +- **Renaming**: Graphify has no coordinated rename. First run `graphify affected "old"` to enumerate every caller/importer, edit each explicitly, then `graphify update .`. NEVER blind find-and-replace. +- **Extracting/Splitting**: run `graphify explain "target"` for incoming/outgoing refs, then `graphify affected "target"` to find all external callers before moving code. +- After any refactor: run `graphify update .` and re-check `affected` on the touched symbols. ## Never Do -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. +- NEVER edit a function, class, or method without first running `graphify affected` on it. +- NEVER ignore a wide blast radius (many direct dependents) without telling the user. +- NEVER rename symbols with find-and-replace — enumerate callers via `graphify affected` first. +- NEVER commit without re-running `graphify update .` + `affected` to check scope. ## Tools Quick Reference -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | +| Command | When to use | +|---------|-------------| +| `graphify query ""` | Find code by concept (BFS) | +| `graphify explain "X"` | 360-degree view of one symbol | +| `graphify affected "X"` | Blast radius before editing (reverse impact) | +| `graphify path "A" "B"` | Shortest path between two symbols | +| `graphify update .` | Incremental re-extract after edits (no LLM) | -## Impact Risk Levels +## Impact Depth (`graphify affected "X" --depth N`) | Depth | Meaning | Action | |-------|---------|--------| @@ -494,30 +491,21 @@ This project is indexed by GitNexus as **validatorinfo** (3542 symbols, 9796 rel | d=2 | LIKELY AFFECTED — indirect deps | Should test | | d=3 | MAY NEED TESTING — transitive | Test if critical path | -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/validatorinfo/context` | Codebase overview, check index freshness | -| `gitnexus://repo/validatorinfo/clusters` | All functional areas | -| `gitnexus://repo/validatorinfo/processes` | All execution flows | -| `gitnexus://repo/validatorinfo/process/{name}` | Step-by-step execution trace | - ## Self-Check Before Finishing Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope +1. `graphify affected` was run for all modified symbols +2. No wide blast radius was left unreported +3. `graphify update .` ran and the graph reflects the edits 4. All d=1 (WILL BREAK) dependents were updated ## CLI -- Re-index: `npx gitnexus analyze` -- Check freshness: `npx gitnexus status` -- Generate docs: `npx gitnexus wiki` +- Re-index (incremental): `graphify update .` +- Check freshness: `graphify check-update .` +- Architecture/call-flow HTML: `graphify export callflow-html` - + - Run `yarn build` before pushing --- diff --git a/docs/plans/2026-06-16-monero-indexer-coinbase-extra-task.md b/docs/plans/2026-06-16-monero-indexer-coinbase-extra-task.md new file mode 100644 index 00000000..6753dace --- /dev/null +++ b/docs/plans/2026-06-16-monero-indexer-coinbase-extra-task.md @@ -0,0 +1,152 @@ +# Задача: добавить `coinbase_extra_hex` в Monero Indexer API + +**Репозиторий:** `citizenweb3/chain-data-indexer` +**Ветка:** `monero-indexer` +**Тип:** аддитивное, обратносовместимое изменение API (новое поле в block-DTO). + +**Зачем:** ValidatorInfo идентифицирует майнинг-пулы по «хвосту» (`tx_extra`) +coinbase-транзакции каждого блока. Сейчас API отдаёт только `extra_size` (число) — +этого недостаточно. Сырой `extra` уже лежит в БД, но наружу не выставлен. +Нужно выставить его как hex-строку. + +--- + +## Контекст: данные уже есть, переиндексация НЕ нужна + +Indexer кладёт полный распарсенный блок в `monero_blocks.raw` (JSONB). +Coinbase-`extra` доступен по пути: + +``` +monero_blocks.raw -> 'parsed_json' -> 'miner_tx' -> 'extra' +``` + +Это **массив байт-чисел** `[1, 234, 17, ...]` (значения 0–255). Типы уже описаны +в `src/types.d.ts`: +- `IndexedMoneroBlock.parsedBlock: MoneroBlockJson` +- `MoneroBlockJson.miner_tx: MoneroTxJson` +- `MoneroTxJson.extra?: unknown[]` + +⚠️ **Важно:** coinbase (miner_tx) — это **НЕ** строка в таблице `monero_transactions` +(там только обычные транзакции). Источник — **только** +`monero_blocks.raw.parsed_json.miner_tx.extra`. Не искать в tx-таблице. + +--- + +## Что отдавать + +Новое поле в block-DTO: + +- **Имя:** `coinbase_extra_hex` (snake_case, в ряд с `difficulty_hex`, `miner_tx_hash`). +- **Тип:** `string | null`. +- **Значение:** весь массив `miner_tx.extra` → lowercase-hex, каждый байт в + 2 символа без разделителей. Пример: `[1, 171, 0]` → `"01ab00"`. +- **`null`**, если `miner_tx.extra` отсутствует / пустой / не массив. +- **Сырой, без разбора TLV.** Не вырезать pubkey / nonce / merge-mining теги — + ValidatorInfo сам парсит TLV и берёт остаток. Indexer отдаёт полный extra как есть. + +Кодировщик (валидировать байты 0–255, иначе → `null`): + +```ts +// src/txDecode.ts (или src/utils) +export function extraToHex(extra: unknown): string | null { + if (!Array.isArray(extra) || extra.length === 0) return null; + let out = ''; + for (const b of extra) { + if (typeof b !== 'number' || !Number.isInteger(b) || b < 0 || b > 255) return null; + out += b.toString(16).padStart(2, '0'); + } + return out; +} +``` + +--- + +## Изменения по файлам + +### 1. Схема — персистентная колонка + +`initdb/` (новый файл миграции, напр. `004-coinbase-extra.sql`): + +```sql +ALTER TABLE monero_blocks ADD COLUMN IF NOT EXISTS coinbase_extra_hex TEXT; +``` + +Колонку выбрали (а не JSONB-экстракт в SELECT), потому что `blockSummary()` +прокидывает колонки строки напрямую, а list-эндпоинт (`handleBlocks`) `raw` +**не** селектит — тянуть полный `raw` на каждую строку страницы (до 1000) дорого. +Колонка дешевле и попадает в существующий паттерн. + +### 2. Sink — писать колонку при инжесте + +`src/sink/postgres.ts`, функция вставки блоков (рядом с `blockRaw`, +в `INSERT INTO monero_blocks (...)`): +- посчитать `extraToHex(entry.parsedBlock.miner_tx?.extra)`; +- добавить в список колонок `coinbase_extra_hex` и в массив значений (рядом с `raws`). + +### 3. Backfill существующих блоков (~3.68М) + +Одноразовый скрипт (напр. `src/runner/backfillCoinbaseExtra.ts`), батчами (5–10k), +переиспользуя `extraToHex`: + +```sql +-- читать пачку +SELECT hash, raw->'parsed_json'->'miner_tx'->'extra' AS extra +FROM monero_blocks +WHERE coinbase_extra_hex IS NULL +ORDER BY height +LIMIT 5000; +-- на каждый: UPDATE monero_blocks SET coinbase_extra_hex = $1 WHERE hash = $2; +``` + +(JSONB→hex чисто в SQL делать не надо — проще в Node через `extraToHex`, +тот же кодировщик, что и в sink.) + +### 4. API DTO + +`src/api.ts`: +- в тип `BlockApiRow` добавить `coinbase_extra_hex: string | null`; +- в **обоих** SELECT — `handleBlocks` (список) и `handleBlockById` (деталь) — + добавить колонку `coinbase_extra_hex` в список полей; +- в `blockSummary(row)` добавить строку `coinbase_extra_hex: row.coinbase_extra_hex` + (как прокидывается `difficulty_hex`). + +Так поле появится **и в списке** (`/api/v1/blocks`), **и в детали** +(`/api/v1/blocks/{id}`) — VI'шным джобам нужен именно список (они идут страницами +по `listBlocks` и гоняют `identifyPool` на каждом блоке). + +### 5. OpenAPI + доки + +- `src/openapi.ts` — добавить `coinbase_extra_hex` (`type: string, nullable: true`) + в схему блока, с описанием «Hex of coinbase (miner_tx) tx_extra; null if empty». +- `docs/indexer-api.md`, `docs/api.md` — упомянуть поле. + +--- + +## Edge-cases + +- Блоки без extra / genesis / битый extra → `coinbase_extra_hex = null`. + Потребитель (VI) это уже корректно обрабатывает (пустой fingerprint). +- Реорг / несеттленные блоки: колонка пишется так же, как `raw` — отдельной + логики не нужно. +- Не менять формат остальных полей — изменение аддитивное, обратносовместимое. + +--- + +## Критерий приёмки (проверка) + +1. `coinbase_extra_hex` присутствует в ответах `/api/v1/blocks` и `/api/v1/blocks/{id}`. +2. Для settled-блока значение непустое и валидный hex (чётная длина, `^[0-9a-f]+$`). +3. Backfill: `SELECT count(*) FROM monero_blocks WHERE coinbase_extra_hex IS NULL` + стремится к нулю (кроме реально пустых extra). +4. Sanity: на свежих блоках известных пулов (SupportXMR / MoneroOcean) hex содержит + ASCII-метку — `echo | xxd -r -p` показывает `supportxmr.com` / `MoneroOcean` + в хвосте. Это и есть то, что VI ловит детектором. + +--- + +## Чего НЕ делать + +- Не парсить / не резать TLV на стороне индексера — отдавать сырой полный extra. +- Не трогать существующие поля и пути (`/api/v1/*`, `{data:[...]}`-конверт, + snake_case) — VI-клиент подгоняется под текущий контракт + это новое поле. +- Не лезть в `monero_transactions` за coinbase — его там нет. diff --git a/docs/plans/2026-06-19-monero-indexer-ordering-fix-task.md b/docs/plans/2026-06-19-monero-indexer-ordering-fix-task.md new file mode 100644 index 00000000..f57575ef --- /dev/null +++ b/docs/plans/2026-06-19-monero-indexer-ordering-fix-task.md @@ -0,0 +1,116 @@ +# Задача: блок-лист индексера возвращает неверный порядок (height сортируется как строка) + +**Репозиторий:** `citizenweb3/chain-data-indexer` +**Ветка:** `monero-indexer` (HEAD `21a6678f`) +**Тип:** баг продакшена. Блокирует потребителя (ValidatorInfo). + +--- + +## Симптом (живой прод) + +`GET https://indexer.monero.citizenweb3.com/api/v1/blocks`: + +``` +?order=desc&limit=3 → [999999, 999998, 999997] # ожидался tip ~3 699 5xx +?order=asc&limit=3 → [0, 1, 10] # ожидался [0, 1, 2] +``` + +`[0, 1, 10]` и `[999999, …]` — однозначная подпись **строковой** сортировки +(`"10" < "2"`, `"999999" > "3699529"` т.к. `'9' > '3'`). Реальный tip +(`3699529`, достаётся по id `/api/v1/blocks/3699529`) через список **недостижим**. + +## Влияние + +ValidatorInfo читает tip и «последние» через list-пагинацию (`order=desc`): +network hashrate (tip-блок), `/api/v1/supply` (последний чекпойнт), агрегаты +пулов. Со строковым порядком всё это берёт мусорный «tip» = 999999. Пока баг +жив — VI держит Monero-джобы за флагом OFF. + +## Диагноз + +Код в ветке **корректен**: `src/api.ts` сортирует по голой колонке +`height` / `tx.block_height` (`ORDER BY height ${direction}` — стр. 313, 373, 523; +`ORDER BY tx.block_height …` — стр. 466), а `initdb/001-schema.sql` объявляет +`height BIGINT NOT NULL`. По `bigint` Postgres сортирует числом — всегда. + +Раз прод сортирует строкой при корректном коде — **расходится живое состояние**. +Причём в индексере **нет механизма смены типа колонки**: схема накатывается +`CREATE TABLE IF NOT EXISTS` + `ALTER … ADD COLUMN IF NOT EXISTS` (так приехал +`coinbase_extra_hex`, `005-coinbase-extra.sql`). Поменять ТИП существующей +колонки нечем — `CREATE IF NOT EXISTS` живую таблицу не трогает. + +Две возможные причины (различить — одной командой): + +1. **Живая колонка `height` фактически `TEXT`** (БД создана раньше/иначе, тип + так и не сконвертили). +2. **В задеплоенном бинаре свой незакоммиченный `ORDER BY … ::text`** (в ветке + такого нет; деплой мог собраться с грязного дерева). + +--- + +## ШАГ 1 — проверить (определяет причину) + +На боевой БД: + +```sql +\d monero_blocks +\d monero_transactions +\d monero_supply_checkpoints -- или как называется supply-таблица +``` + +Смотрим тип колонок `height` (и `block_height` в transactions). + +- `height | text` → **Причина 1** → ШАГ 2A. +- `height | bigint` → **Причина 2** → ШАГ 2B. + +--- + +## ШАГ 2A — если колонка TEXT: конвертить тип + +```sql +ALTER TABLE monero_blocks + ALTER COLUMN height TYPE BIGINT USING height::bigint; + +ALTER TABLE monero_transactions + ALTER COLUMN block_height TYPE BIGINT USING block_height::bigint; + +-- если в supply-таблице height тоже text: +ALTER TABLE monero_supply_checkpoints + ALTER COLUMN height TYPE BIGINT USING height::bigint; +``` + +После `ALTER` существующие индексы (`monero_blocks_height_idx ON (height DESC)` +и т.д.) снова работают, **код менять не надо**. + +⚠️ Чтобы свежие деплои больше не словили это — добавить idempotent-миграцию +файлом `initdb/006-height-bigint.sql` с теми же `ALTER … TYPE BIGINT` (они +безопасно no-op, если колонка уже bigint), и закоммитить в `monero-indexer`. + +## ШАГ 2B — если колонка BIGINT: баг в задеплоенном коде + +Значит running-бинарь ≠ ветка. Найти в деплое реальный `ORDER BY` для +`/api/v1/blocks` / `/transactions` / `/supply` — почти наверняка там +`ORDER BY height::text` или сортировка по text-выражению. Привести к +`ORDER BY height` (bigint), закоммитить в `monero-indexer`, пересобрать/передеплоить. + +--- + +## ШАГ 3 — проверка (после фикса) + +``` +curl -s -H "Authorization: Bearer " \ + "https://indexer.monero.citizenweb3.com/api/v1/blocks?order=desc&limit=3" +# ожидаем первые heights = реальный tip (~3 699 5xx), убывают по числу + +curl ... "?order=asc&limit=3" +# ожидаем [0, 1, 2] +``` + +Готово, когда `desc` начинается с настоящего tip, а `asc` даёт `[0,1,2]`. + +--- + +## Заметки +- coinbase_extra_hex и синк — ОК (`/health` lag ~6). Это единственный остаток. +- Менять формат/пути API НЕ нужно — фикс только про порядок (тип колонки или + одно `ORDER BY`). diff --git a/docs/plans/2026-06-19-monero-pow-redesign-design.md b/docs/plans/2026-06-19-monero-pow-redesign-design.md new file mode 100644 index 00000000..b3f2d75b --- /dev/null +++ b/docs/plans/2026-06-19-monero-pow-redesign-design.md @@ -0,0 +1,397 @@ +# Monero PoW Integration — Design Revision (2026-06-19) + +**Status:** Revised after review (workflow 3-lens panel + Codex via agent-review). +See §12 for review resolutions. +**Supersedes:** §3 (data sources) and §5 (pool identification) of +`docs/plans/2026-04-29-monero-integration-design.md`. Other sections of the +original design remain valid unless contradicted here. +**Branch target:** fresh branch off `dev` (cherry-pick wip commit `90d83a5`). + +--- + +## 1. Why this revision + +The original design (2026-04-29) was implemented as a single wip commit +(`90d83a5`, branch `feat/monero-integration`) but never reviewed/merged. Live +investigation (2026-06-17…19) against the deployed indexer +(`indexer.monero.citizenweb3.com`) and the self-hosted monerod surfaced four +facts that invalidate parts of the original plan: + +1. **Coinbase-fingerprint pool identification is non-viable for live + attribution.** Evidence: 390 recent blocks (250 contiguous from tip + 140 + spread across ~250k depth) → `0` pool ASCII vanities, `0` non-empty residue + after standard TLV tags; ~⅔ carry only a merge-mining tag (p2pool). Strongest + test: the exact blocks SupportXMR and MoneroOcean **themselves** claim (via + their pool APIs) still carry `0` tags in coinbase — even former tagging-era + pools no longer mark `tx_extra`. Legacy ASCII tags (e.g. `/Heathcliff/`, + `supportxmr`) DO exist on historical blocks (pre-~2021, visible in the + indexer's `raw` JSONB) but not the recent operating window, so they are + useless for forward attribution. The L1/L3 fingerprint machinery (`discover` + + `cluster`) matches a signal that does not exist on current blocks → permanent + "unknown". The one live on-chain pool signal is the merge-mining tag → p2pool + fallback only (§3.3). +2. **Monero is private by design.** Stealth addresses + ring signatures + RingCT + → no visible sender/receiver, no tx→pool-wallet linkage. Address attribution + is impossible. +3. **Real indexer contract differs from the wip client.** `/api/v1/*`, + `{data,pagination}` envelope, snake_case, `difficulty_hex` (hex string), + offset pagination. The wip `indexer-client.ts` guessed all of these wrong. +4. **The indexer covers network metrics.** `/api/v1/supply` (cumulative + emission — used for total supply; fees reported separately and are + analytics-only, never summed into supply, see §6) and per-block + `difficulty_hex`. No direct monerod RPC needed. + +Pivot: pool attribution moves from **inferring the pool from block content** +(dead) to **asking pools which blocks they found** (authoritative, +on-chain-verified). VI collapses to a **single data source: the indexer**. + +--- + +## 2. Decisions + +| # | Decision | Choice | +|---|---|---| +| 1 | Scope | Both halves in one release | +| 2 | Data source | Single-source on the indexer; delete `rpc-client.ts` (verified safe — only 2 importers, both rewritten; distinct from shared `server/utils/json-rpc-client.ts`) | +| 3 | Pool share | On-chain block counting; pool `/stats` as supplementary live info | +| 4 | Pool coverage | Registry-driven, **verified-working** pools. v1 seeds **6 end-to-end-verified** (supportxmr, moneroocean, hashvault, c3pool, nanopool, p2pool — each pool's claimed block hash confirmed present + canonical in the indexer, via the **same Node `fetch` the job uses**). Adapters: generic cryptonote + nanopool (block_number/date) + p2pool.observer; per-pool isolated, dropped pools logged. Verification rigor: a `curl` 200 is NOT enough — herominers/2miners return data to `curl` but are Cloudflare-TLS-blocked / non-JSON to Node `fetch`, so they were dropped; gntl returned wrong-chain heights. More added data-only as APIs are Node-`fetch`-verified | +| 5 | History/windows | Persist per-block attribution + backfill; windows 24h/7d/30d/all | +| 6 | Unknown bucket | Shown as an explicit row, clamped ≥ 0 | +| 7 | p2pool | `p2pool.observer` API **only**. No on-chain merge-mining fallback (it would require coinbase consumption, contradicting #11). During an observer outage, p2pool blocks fall to the `unknown` bucket until it recovers | +| 8 | UI | Keep wip UI; rewire to the new data model (more than `monero-service.ts` — see §7) | +| 9 | Branch | Fresh branch off `dev`, cherry-pick `90d83a5` | +| 10 | Block match key | Match by **hash** (dedup + precision). Orphaned / non-canonical blocks are **excluded** from counts, not "caught" | +| 11 | Coinbase in VI | VI does **not** consume `coinbase_extra_hex` at all (no fingerprint, no merge-mining tag). Field stays in the indexer for future use | + +--- + +## 3. Data sources (revised §3) + +### 3.1 Indexer (`MONERO_INDEXER_BASE_URL`, Bearer `MONERO_INDEXER_API_TOKEN`) + +| Need | Endpoint | Notes | +|---|---|---| +| Block list / detail | `GET /api/v1/blocks`, `/blocks/{id}` | `{data,pagination}`, `difficulty_hex`, `is_canonical`, `is_settled`, on-chain `timestamp` | +| Transactions | `GET /api/v1/transactions`, `/transactions/{id}` | tx-by-block, blocks UI | +| Supply | `GET /api/v1/supply` | `cumulative_emission_atomic` (+ `cumulative_fee_atomic` for analytics only) | +| Health | `GET /health` | `status`, `last_height`, `node_height`, `lag_blocks` — used for the runtime sanity guard (§5.2 `monero-network-info` / §8.1) | + +Pagination: `limit` + `offset` + `order`. Envelope +`{ data:[...], pagination:{ limit, offset, order, has_more } }`. + +### 3.2 Pool APIs (registry-driven) + +Most pools run forks of `cryptonote-nodejs-pool` → shared shape: +`GET /api/pool/blocks` (`{height,hash,ts,...}`), `GET /api/pool/stats` +(`{pool_statistics:{hashRate,miners,...}}`). One generic adapter covers them; +non-standard pools get small custom adapters behind the same interface. **Each +pool fetch is isolated — one failure never breaks others; dropped pools are +logged (no silent truncation).** + +### 3.3 p2pool + +`p2pool.observer` API for the authoritative list of p2pool-found blocks +(normalized to `{height,hash,timestamp}`). **No on-chain fallback** — during an +observer outage, p2pool blocks are simply unattributed (→ `unknown`) until it +recovers. The merge-mining tag is the only live on-chain p2pool signal, but +reading it requires coinbase consumption, which decision 11 excludes. + +### 3.4 Removed + +Direct monerod JSON-RPC and `rpc-client.ts` deleted. Supply → `/api/v1/supply`; +difficulty/hashrate → tip block. + +--- + +## 4. Client layer (`server/tools/chains/monero/`) + +| File | Action | +|---|---| +| `indexer-client.ts` | **Rewrite**: `/api/v1/*`, unwrap `{data}`, offset pagination, snake→camel DTO, drop nonexistent `/header`,`/detail` | +| `rpc-client.ts` | **Delete** | +| `pool-apis.json` | **Expand** to verified-working pools (v1: 6 end-to-end-verified; more added as APIs are verified) | +| `pool-client.ts` | **New** generic cryptonote + p2pool.observer adapters → `{height,hash,timestamp}`; per-pool failures isolated | +| `identify-pool.ts` | **Delete** (no coinbase consumption; merge-mining fallback dropped) | + +### 4.1 DTO mapping (indexer block → VI) + +``` +hash→hash height→height(number) timestamp→timestamp(unix s, ON-CHAIN) +num_txes→txCount reward_atomic→reward(string) block_size→size block_weight→weight +miner_tx_hash→minerTxHash is_canonical→isCanonical is_settled→isSettled +difficulty_hex → difficulty (BigInt) // see precision rule below +``` + +**Precision (H5):** `difficulty_hex` exceeds 2^53. Parse with +`BigInt(normalizedHex)` end-to-end — **never** `Number`/`parseInt`. Normalize +0x-prefix; if missing/empty → skip (do not write 0/NaN). Unit test with a real +tip-difficulty hex asserting no precision loss. + +--- + +## 5. Pool attribution pipeline (revised §5) + +### 5.1 Schema additions (Prisma) + +Keep `MiningPool`, `MiningPoolStats`, `ChainHashrateSnapshot`, +`Tokenomics.totalSupply`. Add the per-block backbone **with named inverse +relations** (house style — `prisma validate` fails without them): + +```prisma +model MoneroBlockAttribution { + id Int @id @default(autoincrement()) + chainId Int @map("chain_id") + height Int + blockHash String @map("block_hash") + poolId Int @map("pool_id") + source String @db.VarChar(32) // AttributionSource union: pool_api | p2pool_observer + blockTimestamp DateTime @map("block_timestamp") @db.Timestamptz(6) // ON-CHAIN canonical ts → window membership + poolReportedAt DateTime? @map("pool_reported_at") @db.Timestamptz(6) // pool-API ts, provenance only + isCanonical Boolean @default(true) @map("is_canonical") // re-verified each run + isConflicted Boolean @default(false) @map("is_conflicted") // 2+ pools claim this hash → excluded from named counts + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + + chain Chain @relation("chain_monero_block_attributions", fields: [chainId], references: [id]) + pool MiningPool @relation("mining_pool_attributions", fields: [poolId], references: [id]) + + @@unique([chainId, blockHash]) + @@index([chainId, blockTimestamp]) + @@index([chainId, poolId, blockTimestamp]) + @@index([chainId, isCanonical, isConflicted, blockTimestamp]) + @@map("monero_block_attribution") +} +``` + +Inverse fields to add: `Chain.moneroBlockAttributions MoneroBlockAttribution[] +@relation("chain_monero_block_attributions")` and +`MiningPool.attributions MoneroBlockAttribution[] @relation("mining_pool_attributions")`. + +Why a table: pool APIs are **ephemeral** (recent-N only). Long windows +(7d/30d/all) are only computable from attribution captured at poll time. The +per-block grain is required for reorg invalidation (§5.2), which settles the +"aggregates instead?" question — aggregates cannot un-count an orphaned block. + +`MiningPool.identificationMethod` ∈ `pool_api | p2pool_observer | unknown`. The +allowed `source` / `identificationMethod` values are defined once as a shared TS +union (`AttributionSource`) used by both the attribution upsert and the pool seed +— not free-form strings — to prevent silent mis-bucketing typos. +`fingerprint`/`detectorJson` columns + `@@index([chainId, fingerprint])` are +retained as **legacy/unused**: leave the Prisma fields **uncommented** (only +annotate with a `//` legacy note) — commenting them out would make +`migrate dev` emit a `DROP COLUMN`/`DROP INDEX` (the CLAUDE.md hazard). Dropping +them is a separate, explicit migration, deferred. + +### 5.2 Jobs (`server/jobs/`) + +| Job | Action | Cadence | +|---|---|---| +| `monero-pool-discover` | **Delete** | — | +| `monero-pool-cluster` | **Delete** | — | +| `monero-pool-identify` | **Delete** (merge-mining fallback dropped; observer covers p2pool) | — | +| `monero-pool-attribution` | **New.** Per registry pool + observer: fetch recent found blocks `{height,hash}`. Batch-confirm against the indexer by **paging the block list once per height range into an in-memory `hash → {isCanonical, blockTimestamp}` map** (not N single lookups). Upsert `MoneroBlockAttribution` with `blockTimestamp` = indexer canonical block ts, `poolReportedAt` = pool ts, `isCanonical` from indexer. **Re-verify the most recent N=500 rows each run** using a **dedicated** canonical map (paged over those rows' own height span — independent of what pools reported, so the depth is a fixed N, not data-dependent) and set `isCanonical` **both ways** (re-canonicalize on chain switch-back); rows older than the N-row span are treated as settled. **Conflict rule (Codex F1):** if `(chainId, blockHash)` already exists with a *different* `poolId`, do **not** create a second row and do **not** overwrite `poolId`; set the **persistent** `isConflicted=true` flag on the existing row (keep its original `poolId` for provenance) and log. `isConflicted=true` rows are excluded from every named-pool count, so the block falls into `unknown` via the residual (§5.2 pool-stats). External API noise can never silently move a block between pools. First run: backfill to each API's depth (budgeted). Same-run conflicts (2+ pools claim one new hash) are created `isConflicted=true`. DB writes are **batched** (one existence `findMany`, then `createMany` + grouped `updateMany` — not N round-trips). Also upsert `MiningPool` from registry metadata. (Live `/stats` via `fetchPoolStats` is supplementary and **not persisted** — no schema sink; consumed by a service/UI on demand.) | every 10 min | +| `monero-pool-stats` | **Rewrite.** Window membership by **block HEIGHT** — one clock for numerator and denominator (Codex/stats-H1). `lowerHeight` = `tipHeight − {720, 5040, 21600}` for 24h/7d/30d. `networkBlocks = tipHeight − lowerHeight` is the **EXACT** canonical count (Monero heights are contiguous — one canonical block per height — so this is not an estimate). `poolBlocks` (per named pool) = `MoneroBlockAttribution` count with `isCanonical=true AND isConflicted=false AND height ∈ [lowerHeight, tipHeight]`. `share = poolBlocks/networkBlocks`. `unknown = max(0, networkBlocks − Σ poolBlocks)` (clamp + **log if it would have gone negative**) — this residual absorbs both unattributed and `isConflicted` blocks. `hashrateEstimate = share × AVG(ChainHashrateSnapshot.hashrate over the window)` (hourly snapshots; 24h may use latest; for `all` write `hashrateEstimate='0'` — the field is non-null — and **hide it in the UI**, never show an all-time hashrate). `all` window: `lowerHeight` = earliest attribution height ("since tracking start"), NOT the whole chain. **Every** named pool is upserted each run (including 0 blocks) so a pool going quiet can't leave a stale row (Codex/stats-H2). Idempotently upsert the `unknown` pool row; write `hashrateEstimate='0'` when no snapshot exists yet. | every hour | +| `monero-network-info` | **Rewrite.** Tip block `difficulty_hex` → `hashrate=(difficulty/120n)` (BigInt) → `ChainHashrateSnapshot`. Supply → `Tokenomics.totalSupply` (see §6). **Runtime sanity guard:** if tip height is implausible vs `/health.node_height` (e.g. the 999999 string-sort artifact), log-and-skip — never persist a bogus tip/supply. No RPC. | every hour | + +### 5.3 Unknown bucket + +Reserved `MiningPool` (`slug='unknown'`, `identificationMethod='unknown'`, +`isVerified=false`), idempotently upserted at the start of each +`monero-pool-stats` run. Its `MiningPoolStats` row uses the same +`windowStart`/`windowEnd` as real pools so shares sum to 100%. + +--- + +## 6. Network metrics half + +- Hashrate = tip `difficulty / 120n` (BigInt; Monero target 120 s) → + `ChainHashrateSnapshot` hourly. +- **Supply (H1):** `Tokenomics.totalSupply = cumulative_emission_atomic` — + **raw atomic (piconero) string, emission-only.** + - **Do NOT** divide by `1e12`: the app divides `totalSupply` by + `10 ** coinDecimals` (=12) at read time (`server/jobs/update-fdv.ts`, + `network-overview.tsx`). Storing XMR would divide twice. + - **Do NOT** add `cumulative_fee_atomic`. Verified against the indexer source + (`chain-data-indexer` `src/runner/supplyBackfill.ts`): + `cumulative_emission_atomic = Σ monerod get_coinbase_tx_sum.emission_amount` + (base block rewards = newly minted coins); `cumulative_fee_atomic = Σ fee_amount` + is stored **separately**. Circulating XMR supply = cumulative base emission. + Fees add **no** new supply — they are existing coins transferred from spenders + to the miner (spent as inputs, re-output in the coinbase; net-zero). Summing + them overstates supply by the cumulative fee total (this is the bug in the wip + `monero-network-info.ts`, which summed both). `cumulative_fee_atomic` stays + analytics-only. + - **Hard pre-merge gate** (not deferred): `stored_piconero / 1e12` must match a + current explorer XMR circulating supply within tight tolerance — this is the + only empirical disambiguation, since emission-only vs emission+fee differ by + exactly the cumulative fee total. **✅ Confirmed 2026-06-19:** the live latest + supply checkpoint = `18766842146869265440` piconero ÷ 1e12 ≈ **18.77M XMR**, + matching real Monero circulating supply — emission-only validated. +- Blocks UI reads indexer `/api/v1/blocks` (paged) via `monero-service.ts`. + +--- + +## 7. UI (rewire) + +Keep wip components, but the rewire is **more than `monero-service.ts`**: in the +wip commit, `src/app/[locale]/networks/[name]/blocks/pow-blocks.tsx` and +`src/app/[locale]/mining-pools/[poolSlug]/page.tsx` import `identifyPool` / +`listMoneroBlocks` **directly**, bypassing the service. Route all Monero data +through `monero-service.ts` and rewire those components, or they keep calling the +deleted/old paths and render unknowns. Add unknown-row ordering, null-logo +handling, 100% summation. + +**i18n (M4):** the wip is already out of parity (en=20 keys, pt=ru=19). +Reconcile en/pt/ru to identical key sets and add new labels (unknown row, window +labels) to all three. "Identical key count across locales" is a hard acceptance +check. + +--- + +## 8. Indexer-side dependencies (separate ticket → indexer dev) + +`chain-data-indexer`, branch `monero-indexer`: + +1. **BLOCKER — list `ORDER BY height` numeric (BIGINT), not text.** `order=desc` + returns 999999 (string sort) not the real tip. Breaks tip/latest paging for + `network-info` and `/supply`. VI adds a runtime guard (§6) so a not-yet-fixed + indexer cannot poison snapshots, but correct data needs this fix. + **✅ RESOLVED 2026-06-19** (commits `b5a270c8` + `a6bd42fe`): root cause was a + `height::text` SELECT alias shadowing the BIGINT column in `ORDER BY`; fixed by + table-qualifying the sort + a defensive BIGINT migration (`006-height-bigint.sql`). + Verified live: `desc`=tip, `asc`=[0,1,2]. (A get_info `height` vs `height-1` + crash bug was fixed in the same pass.) Keep the VI runtime guard regardless. +2. `coinbase_extra_hex` — done (off VI's critical path now). +3. `/api/v1/stats` hangs (proxies live to node) — optional; VI derives from tip. +4. Push deployed code to branch `monero-indexer` (deploy diverged). + +--- + +## 9. Implementation stages + +1. **Branch + schema.** Fresh branch off `dev`. Cherry-pick `90d83a5` + **including `prisma/migrations/20260429154100_add_monero_pow_models/`**, then + verify the dir is present and `prisma migrate status` is clean (else Prisma + diffs against an empty baseline and regenerates the whole Monero base, + corrupting history). Only then add `MoneroBlockAttribution` (+ named inverse + relations on Chain/MiningPool) and seed the `unknown` pool. Leave + `MiningPool.fingerprint`/`detectorJson` + their index **uncommented** + (legacy `//` annotation only). `prisma validate` → `migrate dev` + (additive-only; human-review SQL for unexpected DROPs and stray vector-index + drops) → `prisma generate`. +2. **Client.** Capture & commit a redacted real `/api/v1/blocks` + + `/api/v1/supply` fixture. Rewrite `indexer-client.ts` (BigInt difficulty, + `{data,pagination}` envelope, offset paging) with a snake→camel DTO unit test + against the fixture. Delete `rpc-client.ts`. Add `pool-client.ts` + expanded + `pool-apis.json` + the shared `AttributionSource` union. +3. **Jobs.** Delete `discover`/`cluster`/`identify`; add `monero-pool-attribution` + (batch confirm + reorg re-verify + conflict→unknown); rewrite + `monero-pool-stats` (canonical-only, clamped unknown, bounded `all`, + window-avg hashrate, `'0'` hashrate for `all`) + `monero-network-info` (BigInt, + atomic emission-only supply, sanity guard). Register the three Monero jobs + (`monero-network-info`, `monero-pool-attribution`, `monero-pool-stats`) in + `server/indexer.ts` `specialTasks` + `task-worker.ts` dispatch. No env gate — + the §8.1 ordering fix is deployed, so they run alongside the rest. +4. **UI rewire + docs.** Repoint `monero-service.ts` AND the two direct-import + components (§7). Reconcile i18n ×3. Update + `server/tools/chains/monero/AGENTS.md` to the single-source contract (deleted + rpc-client, new attribution job, removed fingerprint/identify). +5. **Verify.** Stage-1/2 offline gate (§11) first; then `yarn lint`, `yarn + build`, run jobs against the live indexer (after §8.1), sanity SQL, supply vs + known explorer checkpoint. + +--- + +## 10. Risks / limitations + +- **Pool API fragility.** Several external pool APIs (v1: 6 verified). Mitigation: + generic adapter + per-pool isolation + logged drops; only end-to-end-verified + pools are registered (unverified/wrong-chain URLs dropped). +- **Window fill-in.** 7d/30d/all accurate only after attribution accumulates; + `all` = "since tracking start" (bounded numerator+denominator, §5.2). Backfill + reduces but cannot eliminate. +- **Reorg invalidation.** Handled: `isCanonical` re-verified each run; stats + count canonical only; `unknown` clamped ≥ 0. +- **p2pool depends solely on `p2pool.observer`.** No on-chain fallback; an + observer outage temporarily routes p2pool blocks to `unknown` (self-heals on + the next successful poll). +- **Conflicting pool claims** on one `blockHash` are routed to `unknown` (not + silently overwritten) — see §5.2 conflict rule. +- **Offset pagination over a growing/reorg set** can skip/dup at the boundary; + dedup is by `@@unique([chainId, blockHash])`. +- **Hard dependency on indexer ordering fix** (§8.1) for correct tip-anchored + data; runtime guard prevents bad persistence meanwhile. +- **Supply correctness is money-sensitive** — verify vs a known checkpoint. + +--- + +## 11. Acceptance criteria + +- Monero in `/networks`; overview shows live hashrate + supply; blocks table + paginates real indexer data. +- `mining-pools` lists pools with `share% / blocks` per window (+ + `hashrateEstimate` for 24h/7d/30d; omitted/hidden for `all`) + an `unknown` + row; shares sum to 100%; `unknown ≥ 0`. +- `Tokenomics.totalSupply` stored as **raw atomic emission-only**; FDV/supply UI + render correctly (no double 1e12); supply checkpoint matches an explorer figure. +- `MoneroBlockAttribution` populates from ≥2 source types (`pool_api` + + `p2pool_observer`); `@@unique([chainId, blockHash])` enforced; conflicting + claims routed to `unknown`; reorged blocks flipped non-canonical and excluded. +- `prisma validate` passes; `yarn lint` + `yarn build` clean; i18n identical key + counts across en/pt/ru. +- No direct monerod RPC remains. `server/tools/chains/monero/AGENTS.md` updated + to the single-source contract. +- **Stage 1–2 offline gate** (verifiable before §8.1 lands): `prisma validate` + + additive-only migrate SQL (human-reviewed, no unexpected DROP) + `prisma + generate` clean + DTO/BigInt unit test passes against a committed + `/api/v1/blocks` + `/api/v1/supply` fixture + `rpc-client.ts` deleted + `yarn + build` green. End-to-end acceptance is evaluated **after** the §8.1 ordering + fix is deployed (done) — the Monero jobs then run alongside the rest. + +--- + +## 12. Review resolutions (2026-06-19) + +Workflow panel verdicts: feasibility `ship-with-changes`, data-model +`needs-rework`, scope `ship-with-changes`. Codex (agent-review) F1–F5. All +HIGH/MEDIUM findings folded above. + +| Finding (source) | Resolution | +|---|---| +| Supply units double-1e12 + fee double-count (Codex F1, data-model) | §6: raw atomic, emission-only; verify checkpoint | +| Reorg/orphan never invalidated; foundAt≠on-chain ts (data-model, Codex F3, feasibility) | §5.1 `isCanonical`+`blockTimestamp`; §5.2 re-verify + canonical-only + clamp | +| `all` window denominator = whole chain (data-model, feasibility) | §5.2 bound `all` to earliest attribution ts | +| Prisma missing inverse relations (all lenses, Codex F2) | §5.1 named inverse fields on Chain/MiningPool | +| difficulty precision >2^53 + ordering guard (feasibility, data-model) | §4.1 BigInt; §6 runtime sanity guard | +| hashrateEstimate dimensional (data-model, Codex F4) | §5.2 window-average; omit for `all` | +| UI rewire > monero-service.ts (Codex F5, feasibility) | §7 rewire pow-blocks + pool detail | +| Confirm-step batching (feasibility) | §5.2 paged in-memory hash→canonical map | +| i18n parity drift 20-vs-19 (scope) | §7 reconcile + hard acceptance check | +| blockHash uniqueness scope (data-model) | §5.1 `@@unique([chainId, blockHash])` | +| Unknown pool seed + no-snapshot fallback (data-model, scope) | §5.2/§5.3 idempotent upsert + '0' fallback | +| Dead fingerprint code/columns (scope) | §5.1 jobs deleted; columns kept **uncommented** with legacy `//` note (commenting would DROP) | +| Dependency gate (scope) | §9 jobs flag-off until §8.1 confirmed | +| 10+ pools YAGNI (scope) | Resolved in Stage 2: registry is verified-driven — 6 pools pass an end-to-end hash↔indexer attribution check via Node `fetch`; curl-only-OK / wrong-chain URLs dropped. More added data-only as verified | +| rpc-client.ts deletion safe (scope) | Confirmed: 2 importers, distinct from shared client | + +### Round 2 — final review (workflow + Codex), all folded + +Workflow verdicts: coherence / correctness / readiness all `ship-with-changes`. +Codex F1–F3. + +| Finding (source) | Resolution | +|---|---| +| **Supply emission-only justification wrong + unverified** (correctness HIGH) | §6: verified from indexer source (`supplyBackfill.ts` → `cumulative_emission_atomic = Σ emission_amount` = base reward; fee stored separately). Emission-only **confirmed correct**; justification rewritten; checkpoint made a hard pre-merge gate | +| **Decision 7 vs 11 contradiction** — merge-mining fallback needs coinbase, but VI doesn't consume it (coherence HIGH) | Dropped the merge-mining fallback. p2pool = observer-only; `identify-pool.ts` deleted; `merge_mining` enum removed; §3.3/§4/§5.1/§5.2/§10 updated | +| **Conflicting pool claims overwrite silently** (Codex F1 HIGH) | §5.1 persistent `isConflicted` flag added; §5.2 conflict rule sets it on the existing row (no overwrite); pool-stats counts named pools as `isCanonical AND NOT isConflicted` → conflicted blocks fall into `unknown`. Re-verified after Codex round-2 needs-work | +| hashrateEstimate `all`-window contradictory (Codex F2, coherence MED) | §5.2 write `'0'` + hide in UI; §11 acceptance excludes `all` | +| `≥3 sources` impossible (merge_mining down-only) (coherence MED) | §11 → `≥2 source types` (pool_api + observer) | +| Stale `monero/AGENTS.md` (Codex F3 MED) | §9 stage 4 adds AGENTS.md update task | +| Migration base = full dir `…_add_monero_pow_models`; cherry-pick must include it before `migrate dev` (readiness MED) | §9 stage 1 explicit ordering + status check | +| `MiningPool` legacy columns: "commented" would DROP (readiness MED) | §5.1/§9: keep fields uncommented, annotate only | +| Reorg re-verify depth unscoped (correctness LOW) | §5.2 bounded N ≥ deepest practical reorg; older rows settled | +| Sanity-guard pointer §6 → actually §5.2/§8.1 (coherence LOW) | §3.1 repointed | +| `is_settled` mapped but unused (coherence LOW) | display/future-use; counting uses `isCanonical` only | +| Source enum free-form drift (readiness LOW) | §5.1/§9 shared `AttributionSource` TS union | +| No stage-1/2 done-definition (readiness LOW) | §11 offline gate added | +| Commit a real response fixture for DTO test (readiness LOW) | §9 stage 2 captures `/blocks`+`/supply` fixture | diff --git a/messages/en.json b/messages/en.json index 10200d67..8a324e22 100644 --- a/messages/en.json +++ b/messages/en.json @@ -360,6 +360,7 @@ "Blocks": "Blocks", "Transactions": "Transactions", "Validators": "Validators", + "Mining Pools": "Mining Pools", "Nodes": "Nodes", "Apps": "Apps", "temperature tooltip": "Shows the chain health status based on various verifiable metrics", @@ -375,7 +376,11 @@ "distribution tooltip": "Distribution", "validator map tooltip": "{chainName} Validator Map", "Links": "Links", - "Tags": "Tags" + "Tags": "Tags", + "tip height": "tip height", + "network hashrate": "network hashrate", + "active pools": "active pools", + "block time target": "block time target" }, "NetworkPassport": { "title": "Blockchain Network Overview: A Web3 Starting Point", @@ -387,9 +392,12 @@ "total amount of epochs": "Total Amount of Epochs", "total supply": "Total Supply", "Historical Trend": "Historical Trend", + "Network Hashrate": "Network Hashrate", "Network Overview": "Network Overview", "active validators": "Active Validators", "Validator Count": "Validator Count", + "Hashrate": "Hashrate", + "Mining Pools": "Mining Pools", "unbonding time": "Unbonding Time", "community tax": "Community Tax", "proposal creation cost": "Proposal Creation Cost", @@ -403,7 +411,15 @@ "committee size": "Committee Size", "indexer mode": "Indexer Mode", "active": "Active", - "inactive": "inactive" + "inactive": "inactive", + "tip height": "Tip Height", + "network hashrate": "Network Hashrate", + "current difficulty": "Current Difficulty", + "last block time": "Last Block Time", + "ago": "ago", + "no snapshot": "No snapshot yet — indexer warming up", + "active pools": "Active Identified Pools", + "block time target": "Block Time Target" }, "NetworkStatistics": { "title": "Blockchain Statistics Dashboard: Live Network Metrics", @@ -827,6 +843,7 @@ "Validators": "Validators", "Nodes": "Nodes", "Networks": "Networks", + "Mining Pools": "Mining Pools", "Ecosystems": "Ecosystems", "Metrics": "Metrics" }, @@ -1437,6 +1454,24 @@ } }, "TxInformationPage": { + "extra size": "Extra Size", + "in pool": "In Pool", + "is canonical": "Canonical", + "is settled": "Settled", + "indexed at": "Indexed At", + "yes": "Yes", + "no": "No", + "block hash": "Block Hash", + "type": "Type", + "inputs": "Inputs", + "outputs": "Outputs", + "size": "Size", + "ring version": "Ring Version", + "unlock time": "Unlock Time", + "confirmations": "Confirmations", + "amounts hidden": "Amounts and addresses are hidden by Monero privacy (RingCT/stealth) — only counts, fee and size are public.", + "coinbase": "Coinbase", + "regular": "Regular", "title": "Blockchain Transaction Details: In-Depth Information of the Transaction", "description": "The Transaction Details page is laid out with everything you need to know about this specific transaction—its unique hash, JSON data, sender and receiver addresses, amount transferred, status, timestamp, and fees. It's like a detailed receipt for your blockchain activity, giving you full transparency and peace of mind. Need to explore more? Check out our Transaction History Explorer for advanced insights!", "show all transactions": "Show All Transactions", @@ -1505,6 +1540,33 @@ "items count": "{count, plural, one {# item} other {# items}}" }, "BlockInformationPage": { + "cumulative difficulty": "Cumulative Difficulty", + "long term weight": "Long-Term Weight", + "coinbase extra": "Coinbase Extra", + "is canonical": "Canonical", + "is settled": "Settled", + "orphan status": "Orphan", + "yes": "Yes", + "no": "No", + "mining pool": "Mining Pool", + "block reward": "Block Reward", + "size": "Size", + "weight": "Weight", + "difficulty": "Difficulty", + "version": "Version", + "nonce": "Nonce", + "previous block": "Previous Block", + "miner tx hash": "Miner Tx Hash", + "block transactions": "Block Transactions", + "amounts hidden": "Monero is private (RingCT/stealth) — only counts, fee and size are public, never amounts or addresses.", + "no txs in block": "No transactions in this block.", + "tx hash": "Tx Hash", + "type": "Type", + "fee": "Fee", + "in out": "In / Out", + "coinbase": "Coinbase", + "regular": "Regular", + "unknown pool": "Unknown / Solo", "title": "Blockchain Block Details: In-Depth Information of the Block", "description": "The Block Details page provides everything you need to know about this specific block—its unique hash, block height, finalization status, transaction count, gas fees, timestamps, and cryptographic state trees. It's like a detailed snapshot of the blockchain at this specific moment, giving you full transparency into the network's operations.", "show all blocks": "Show All Blocks", @@ -1850,5 +1912,124 @@ "AI not configured": "AI assistant is not available at the moment", "Request timed out": "Request timed out. Please try again.", "New chat": "New chat" + }, + "MiningPoolsList": { + "title": "Mining Pools", + "description": "Multi-chain directory of mining pools. Each pool is identified from on-chain block attribution; per-network performance lives on the network stats pages.", + "Table": { + "Pool": { "name": "Pool", "hint": "" }, + "Links": { "name": "Links", "hint": "" }, + "Networks": { "name": "Networks", "hint": "" } + } + }, + "MiningPoolDetail": { + "statsNoData": "No stats for this window", + "metaTitle": "Mining Pool", + "networksTitle": "Mining Pool Profile: Networks", + "blocksTitle": "Mining Pool Profile: Blocks", + "window24h": "24h", + "window7d": "7d", + "window30d": "30d", + "windowAll": "All-time", + "recentBlocksTitle": "Recent Blocks", + "recentBlocksDescription": "Recent blocks attributed to this pool, matched on-chain by hash.", + "recentBlocksEmpty": "No recent blocks attributed to this pool yet." + }, + "MiningPoolProfileHeader": { + "Tabs": { + "Revenue": "Revenue", + "Metrics": "Metrics", + "Network Table": "Networks", + "Public Goods": "Projects", + "Blocks": "Blocks" + }, + "story": "{name} is a verified mining pool on the {chain} network. The stats below are derived from on-chain block attribution.", + "Table": { + "Network": { "name": "Network", "hint": "Network the pool mines on" }, + "Blocks": { "name": "Blocks Found", "hint": "Blocks attributed to this pool in the selected window" }, + "Share": { "name": "Dominance", "hint": "Share of network blocks found in the selected window" }, + "Hashrate": { "name": "Estimated Hashrate", "hint": "Estimated hashrate from the pool's block share" }, + "Fee": { "name": "Fee", "hint": "Pool fee as declared by the pool" }, + "Height": { "name": "Height", "hint": "Block height" }, + "Block Hash": { "name": "Block Hash", "hint": "Block hash" }, + "Timestamp": { "name": "Timestamp", "hint": "Block timestamp" } + } + }, + "MiningPoolsBadges": { + "verified": "verified", + "unverified": "unverified", + "merge_mining_tag": "merge-mining tag", + "ascii_vanity": "ASCII vanity", + "fingerprint": "fingerprint", + "unknown": "unknown" + }, + "NetworkMiningPoolsPage": { + "title": "Mining Pools", + "description": "Mining pools active on {networkName}, ranked by on-chain block attribution. Blocks, share and estimated hashrate are computed over the selected window; pool fee comes from the pool's own API.", + "empty": "No mining-pool data for this network.", + "window24h": "24h", + "window7d": "7d", + "window30d": "30d", + "windowAll": "All-time", + "Table": { + "Pool": { "name": "Pool", "hint": "Mining pool identified from on-chain block attribution" }, + "Blocks": { "name": "Blocks", "hint": "Blocks attributed to this pool in the selected window" }, + "Share": { "name": "Dominance", "hint": "Percentage of network blocks found in the selected window" }, + "Hashrate": { "name": "Hashrate", "hint": "Estimated hashrate from the pool's block share" }, + "Fee": { "name": "Fee", "hint": "Pool fee as declared by the pool" } + } + }, + "PowNetworkStats": { + "hashrateHistory": "Network Hashrate History", + "poolDistribution": "Pool Distribution", + "window24h": "24h", + "window7d": "7d", + "window30d": "30d", + "windowAll": "All-time", + "noHashrateData": "No hashrate snapshots yet — backfill in progress", + "noPoolData": "No pool stats yet — backfill in progress" + }, + "OffchainGovernanceInfo": { + "title": "Governance", + "infoTitle": "Off-chain Governance", + "infoBodyMonero": "Monero has no on-chain governance. Hard forks are coordinated through off-chain rough consensus driven by the Monero Research Lab and contributors.", + "infoBodyGeneric": "This network has no on-chain governance. Protocol changes are coordinated off-chain through rough consensus among its contributors and community.", + "channelsTitle": "Coordination Channels" + }, + "PowTxs": { + "indexerDisabled": "Transactions unavailable", + "indexerDisabledBody": "The Monero indexer is not configured.", + "noTxs": "No transactions yet.", + "fetchError": "Failed to load transactions.", + "amountsHidden": "Monero is private — only counts, fee and size are public, never amounts or addresses.", + "txHash": "Tx Hash", + "block": "Block", + "type": "Type", + "fee": "Fee", + "size": "Size", + "inOut": "In / Out", + "coinbase": "Coinbase", + "regular": "Regular", + "older": "Older" + }, + "PowBlocks": { + "hash": "Block Hash", + "title": "Recent Blocks", + "description": "Latest blocks from the Monero network. Mining pool attribution comes from the pools' own block lists, matched to on-chain blocks by hash.", + "height": "Height", + "time": "Timestamp", + "pool": "Mined By", + "txCount": "Tx Count", + "reward": "Reward", + "size": "Size", + "difficulty": "Difficulty", + "older": "Older", + "indexerDisabled": "Recent blocks unavailable", + "indexerDisabledBody": "The Monero indexer is not configured for this environment.", + "noBlocks": "No blocks available", + "fetchError": "Failed to load blocks.", + "unknownPool": "Unidentified / Solo", + "minedBy": "Mined By", + "unverified": "unverified" } } diff --git a/messages/pt.json b/messages/pt.json index 666d376a..b85b1b7b 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -360,6 +360,7 @@ "Transactions": "Transacções", "Blocks": "Blocos", "Validators": "Validadores", + "Mining Pools": "Pools de Mineração", "Nodes": "Nós", "Apps": "Apps", "temperature tooltip": "Mostra o estado de saúde da cadeia com base em várias métricas verificáveis", @@ -375,7 +376,11 @@ "distribution tooltip": "Distribuição", "validator map tooltip": "{chainName} Mapa de Validadores", "Links": "Links", - "Tags": "Tags" + "Tags": "Tags", + "tip height": "altura da ponta", + "network hashrate": "hashrate da rede", + "active pools": "pools ativos", + "block time target": "meta de tempo de bloco" }, "NetworkPassport": { "title": "Visão Geral da Rede Blockchain: Ponto de Partida para o Web3", @@ -387,9 +392,12 @@ "total amount of epochs": "Quantidade total de Epocas", "total supply": "Fornecimento Total", "Historical Trend": "Tendência Histórica", + "Network Hashrate": "Hashrate da Rede", "Network Overview": "Visão Geral da Rede", "active validators": "Validadores Activos", "Validator Count": "Contagem de Validadores", + "Hashrate": "Hashrate", + "Mining Pools": "Pools de Mineração", "unbonding time": "Tempo de Unbonding", "community tax": "Taxa Comunitária", "proposal creation cost": "Custo de Criação da Proposta", @@ -403,7 +411,15 @@ "committee size": "Dimensão da Comissão", "indexer mode": "Modo Indexador", "active": "Ativo", - "inactive": "inativo" + "inactive": "inativo", + "tip height": "Altura da Cadeia", + "network hashrate": "Taxa de Hash da Rede", + "current difficulty": "Dificuldade Atual", + "last block time": "Tempo do Último Bloco", + "ago": "atrás", + "no snapshot": "Sem snapshot ainda — indexador aquecendo", + "active pools": "Pools Identificados Ativos", + "block time target": "Meta de Tempo de Bloco" }, "NetworkStatistics": { "title": "Painel de Estatísticas Blockchain: Métricas ao Vivo da Rede", @@ -826,6 +842,7 @@ "Tabs": { "Validators": "Validadores", "Networks": "Redes", + "Mining Pools": "Pools de Mineração", "Metrics": "Métricos", "Ecosystems": "Ecossistemas", "Nodes": "Nodes" @@ -1465,6 +1482,24 @@ } }, "TxInformationPage": { + "extra size": "Tamanho Extra", + "in pool": "No Pool", + "is canonical": "Canônico", + "is settled": "Liquidado", + "indexed at": "Indexado Em", + "yes": "Sim", + "no": "Não", + "block hash": "Hash do Bloco", + "type": "Tipo", + "inputs": "Entradas", + "outputs": "Saídas", + "size": "Tamanho", + "ring version": "Versão do Ring", + "unlock time": "Tempo de Desbloqueio", + "confirmations": "Confirmações", + "amounts hidden": "Valores e endereços são ocultos pela privacidade do Monero (RingCT/stealth) — apenas contagens, taxa e tamanho são públicos.", + "coinbase": "Coinbase", + "regular": "Regular", "title": "Detalhes da Transação Blockchain: Informações Detalhadas da Transação", "description": "A página de Detalhes da Transação apresenta tudo o que você precisa saber sobre esta transação específica — seu hash único, dados em JSON, endereços do remetente e do destinatário, valor transferido, status, timestamp e taxas. É como um recibo detalhado da sua atividade no blockchain, oferecendo total transparência e tranquilidade. Quer explorar mais? Confira nosso Explorador de Histórico de Transações para obter insights avançados!", "show all transactions": "Mostrar Todas as Transacções", @@ -1534,6 +1569,33 @@ "items count": "{count, plural, one {# item} other {# itens}}" }, "BlockInformationPage": { + "cumulative difficulty": "Dificuldade Cumulativa", + "long term weight": "Peso de Longo Prazo", + "coinbase extra": "Coinbase Extra", + "is canonical": "Canônico", + "is settled": "Liquidado", + "orphan status": "Órfão", + "yes": "Sim", + "no": "Não", + "mining pool": "Pool de Mineração", + "block reward": "Recompensa do Bloco", + "size": "Tamanho", + "weight": "Peso", + "difficulty": "Dificuldade", + "version": "Versão", + "nonce": "Nonce", + "previous block": "Bloco Anterior", + "miner tx hash": "Hash da Tx do Minerador", + "block transactions": "Transações do Bloco", + "amounts hidden": "Monero é privado (RingCT/stealth) — apenas contagens, taxa e tamanho são públicos, nunca valores ou endereços.", + "no txs in block": "Sem transações neste bloco.", + "tx hash": "Hash da Tx", + "type": "Tipo", + "fee": "Taxa", + "in out": "Ent. / Saí.", + "coinbase": "Coinbase", + "regular": "Regular", + "unknown pool": "Desconhecido / Solo", "title": "Detalhes do Bloco Blockchain: Informações Detalhadas do Bloco", "description": "A página de Detalhes do Bloco fornece tudo o que você precisa saber sobre este bloco específico — seu hash único, altura do bloco, status de finalização, contagem de transações, taxas de gás, timestamps e árvores de estado criptográficas. É como um instantâneo detalhado do blockchain neste momento específico, oferecendo total transparência nas operações da rede.", "show all blocks": "Mostrar Todos os Blocos", @@ -1851,5 +1913,124 @@ "AI not configured": "Assistente AI não está disponível no momento", "Request timed out": "Tempo limite excedido. Por favor, tente novamente.", "New chat": "Novo chat" + }, + "MiningPoolsList": { + "title": "Pools de Mineração", + "description": "Diretório multi-chain de pools de mineração. Cada pool é identificada a partir da atribuição on-chain de blocos; o desempenho por rede fica nas páginas de estatísticas da rede.", + "Table": { + "Pool": { "name": "Pool", "hint": "" }, + "Links": { "name": "Links", "hint": "" }, + "Networks": { "name": "Redes", "hint": "" } + } + }, + "MiningPoolDetail": { + "statsNoData": "Sem estatísticas para esta janela", + "metaTitle": "Pool de Mineração", + "networksTitle": "Perfil da Pool de Mineração: Redes", + "blocksTitle": "Perfil da Pool de Mineração: Blocos", + "window24h": "24h", + "window7d": "7d", + "window30d": "30d", + "windowAll": "Todo o tempo", + "recentBlocksTitle": "Blocos recentes", + "recentBlocksDescription": "Blocos recentes atribuídos a esta pool, casados on-chain por hash.", + "recentBlocksEmpty": "Nenhum bloco recente atribuído a esta pool ainda." + }, + "MiningPoolProfileHeader": { + "Tabs": { + "Revenue": "Receita", + "Metrics": "Métricas", + "Network Table": "Redes", + "Public Goods": "Projetos", + "Blocks": "Blocos" + }, + "story": "{name} é uma pool de mineração verificada na rede {chain}. As estatísticas abaixo derivam da atribuição on-chain de blocos.", + "Table": { + "Network": { "name": "Rede", "hint": "Rede em que a pool minera" }, + "Blocks": { "name": "Blocos encontrados", "hint": "Blocos atribuídos a esta pool na janela selecionada" }, + "Share": { "name": "Dominância", "hint": "Participação nos blocos da rede encontrados na janela selecionada" }, + "Hashrate": { "name": "Hashrate estimado", "hint": "Hashrate estimado a partir da participação de blocos da pool" }, + "Fee": { "name": "Taxa", "hint": "Taxa da pool conforme declarada pela pool" }, + "Height": { "name": "Altura", "hint": "Altura do bloco" }, + "Block Hash": { "name": "Hash do Bloco", "hint": "Hash do bloco" }, + "Timestamp": { "name": "Timestamp", "hint": "Carimbo de data/hora do bloco" } + } + }, + "MiningPoolsBadges": { + "verified": "verificada", + "unverified": "não verificada", + "merge_mining_tag": "tag de merge-mining", + "ascii_vanity": "vanity ASCII", + "fingerprint": "impressão digital", + "unknown": "desconhecido" + }, + "NetworkMiningPoolsPage": { + "title": "Pools de Mineração", + "description": "Pools de mineração ativas em {networkName}, classificadas pela atribuição on-chain de blocos. Blocos, participação e hashrate estimada são calculados na janela selecionada; a taxa da pool vem da própria API da pool.", + "empty": "Sem dados de pools de mineração para esta rede.", + "window24h": "24h", + "window7d": "7d", + "window30d": "30d", + "windowAll": "Todo o tempo", + "Table": { + "Pool": { "name": "Pool", "hint": "Pool de mineração identificada pela atribuição on-chain de blocos" }, + "Blocks": { "name": "Blocos", "hint": "Blocos atribuídos a esta pool na janela selecionada" }, + "Share": { "name": "Dominância", "hint": "Percentual de blocos da rede encontrados na janela selecionada" }, + "Hashrate": { "name": "Hashrate", "hint": "Hashrate estimada a partir da participação de blocos da pool" }, + "Fee": { "name": "Taxa", "hint": "Taxa da pool conforme declarada pela pool" } + } + }, + "PowNetworkStats": { + "hashrateHistory": "Histórico do Hashrate", + "poolDistribution": "Distribuição de Pools", + "window24h": "24h", + "window7d": "7d", + "window30d": "30d", + "windowAll": "Todo o período", + "noHashrateData": "Sem snapshots de hashrate ainda — reabastecimento em andamento", + "noPoolData": "Sem estatísticas de pools ainda — reabastecimento em andamento" + }, + "OffchainGovernanceInfo": { + "title": "Governança", + "infoTitle": "Governança Off-chain", + "infoBodyMonero": "Monero não possui governança on-chain. Hard forks são coordenados através de consenso aproximado off-chain conduzido pelo Monero Research Lab e contribuidores.", + "infoBodyGeneric": "Esta rede não possui governança on-chain. As mudanças de protocolo são coordenadas off-chain por consenso aproximado entre seus contribuidores e a comunidade.", + "channelsTitle": "Canais de Coordenação" + }, + "PowTxs": { + "indexerDisabled": "Transações indisponíveis", + "indexerDisabledBody": "O indexador Monero não está configurado.", + "noTxs": "Ainda sem transações.", + "fetchError": "Falha ao carregar transações.", + "amountsHidden": "Monero é privado — apenas contagens, taxa e tamanho são públicos, nunca valores ou endereços.", + "txHash": "Hash da Tx", + "block": "Bloco", + "type": "Tipo", + "fee": "Taxa", + "size": "Tamanho", + "inOut": "Ent. / Saí.", + "coinbase": "Coinbase", + "regular": "Regular", + "older": "Mais antigas" + }, + "PowBlocks": { + "hash": "Hash do Bloco", + "title": "Blocos Recentes", + "description": "Últimos blocos da rede Monero. A atribuição de pool vem das listas de blocos dos próprios pools, casadas com os blocos on-chain por hash.", + "height": "Altura", + "time": "Timestamp", + "pool": "Minerado Por", + "txCount": "Nº de Tx", + "reward": "Recompensa", + "size": "Tamanho", + "difficulty": "Dificuldade", + "older": "Mais antigos", + "indexerDisabled": "Blocos recentes indisponíveis", + "indexerDisabledBody": "O indexador Monero não está configurado para este ambiente.", + "noBlocks": "Nenhum bloco disponível", + "fetchError": "Falha ao carregar blocos.", + "unknownPool": "Unidentified / Solo", + "minedBy": "Minerado Por", + "unverified": "não verificado" } } diff --git a/messages/ru.json b/messages/ru.json index d39a5a3b..9e0cd7d4 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -360,6 +360,7 @@ "Blocks": "Блоки", "Transactions": "Транзакции", "Validators": "Валидаторов", + "Mining Pools": "Майнинг-пулы", "Nodes": "Ноды", "Apps": "Apps", "temperature tooltip": "Показывает состояние здоровья цепи на основе различных измеряемых показателей", @@ -375,7 +376,11 @@ "distribution tooltip": "Распределение", "validator map tooltip": "{chainName} Карта Валидаторов", "Links": "Ссылки", - "Tags": "Теги" + "Tags": "Теги", + "tip height": "высота вершины", + "network hashrate": "хешрейт сети", + "active pools": "активные пулы", + "block time target": "целевое время блока" }, "NetworkPassport": { "title": "обзор блокчейн-сети: Отправная точка для Web3", @@ -387,9 +392,12 @@ "total amount of epochs": "Общее количество эпох", "total supply": "Общее предложение", "Historical Trend": "Историческая динамика", + "Network Hashrate": "Хешрейт сети", "Network Overview": "Обзор Сети", "active validators": "Активные Валидаторы", "Validator Count": "Кол-во валидаторов", + "Hashrate": "Хэшрейт", + "Mining Pools": "Майнинг-пулы", "unbonding time": "Время Анбондинга", "community tax": "Общественные Налоги", "proposal creation cost": "Стоимость Создания Пропозала", @@ -403,7 +411,15 @@ "committee size": "Размер комитета", "indexer mode": "Режим индексатора", "active": "Активен", - "inactive": "неактивен" + "inactive": "неактивен", + "tip height": "Высота сети", + "network hashrate": "Хешрейт сети", + "current difficulty": "Текущая сложность", + "last block time": "Время последнего блока", + "ago": "назад", + "no snapshot": "Снапшот ещё не готов — индексер прогревается", + "active pools": "Активные распознанные пулы", + "block time target": "Целевое время блока" }, "NetworkStatistics": { "title": "Панель статистики блокчейна: Живые показатели сети", @@ -826,6 +842,7 @@ "Tabs": { "Validators": "Валидаторы", "Networks": "Сети", + "Mining Pools": "Майнинг-пулы", "Metrics": "Метрики", "Ecosystems": "Экосистемы", "Nodes": "Ноды" @@ -1437,6 +1454,24 @@ } }, "TxInformationPage": { + "extra size": "Размер extra", + "in pool": "В пуле", + "is canonical": "Канонический", + "is settled": "Финализирован", + "indexed at": "Проиндексирован", + "yes": "Да", + "no": "Нет", + "block hash": "Хеш блока", + "type": "Тип", + "inputs": "Входы", + "outputs": "Выходы", + "size": "Размер", + "ring version": "Версия Ring", + "unlock time": "Время разблокировки", + "confirmations": "Подтверждения", + "amounts hidden": "Суммы и адреса скрыты приватностью Monero (RingCT/stealth) — публичны только счётчики, комиссия и размер.", + "coinbase": "Coinbase", + "regular": "Обычная", "title": "Детали транзакции в блокчейне: Подробная информация о транзакции", "description": "Страница с деталями транзакции содержит всю необходимую информацию об этой конкретной транзакции — её уникальный хеш, данные в формате JSON, адреса отправителя и получателя, сумму перевода, статус, временную метку и комиссии. Это как подробный чек вашей блокчейн-активности, обеспечивающий полную прозрачность и спокойствие. Хотите узнать больше? Ознакомьтесь с нашим проводником по истории транзакций для получения продвинутых аналитических данных!", "show all transactions": "Показать Все Транзакции", @@ -1506,6 +1541,33 @@ "items count": "{count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элементов}}" }, "BlockInformationPage": { + "cumulative difficulty": "Кумулятивная сложность", + "long term weight": "Долгосрочный вес", + "coinbase extra": "Coinbase Extra", + "is canonical": "Канонический", + "is settled": "Финализирован", + "orphan status": "Сирота", + "yes": "Да", + "no": "Нет", + "mining pool": "Майнинг-пул", + "block reward": "Награда за блок", + "size": "Размер", + "weight": "Вес", + "difficulty": "Сложность", + "version": "Версия", + "nonce": "Nonce", + "previous block": "Предыдущий блок", + "miner tx hash": "Хеш tx майнера", + "block transactions": "Транзакции блока", + "amounts hidden": "Monero приватен (RingCT/stealth) — публичны только счётчики, комиссия и размер, никогда суммы или адреса.", + "no txs in block": "В этом блоке нет транзакций.", + "tx hash": "Хеш tx", + "type": "Тип", + "fee": "Комиссия", + "in out": "Вх. / Вых.", + "coinbase": "Coinbase", + "regular": "Обычная", + "unknown pool": "Неизвестный / Solo", "title": "Детали блока в блокчейне: Подробная информация о блоке", "description": "Страница с деталями блока предоставляет всю необходимую информацию об этом конкретном блоке — его уникальный хеш, высоту блока, статус финализации, количество транзакций, комиссии за газ, временные метки и криптографические деревья состояния. Это как детальный снимок блокчейна в конкретный момент времени, обеспечивающий полную прозрачность операций сети.", "show all blocks": "Показать Все Блоки", @@ -1851,5 +1913,124 @@ "AI not configured": "AI ассистент сейчас недоступен", "Request timed out": "Превышено время ожидания. Попробуйте ещё раз.", "New chat": "Новый чат" + }, + "MiningPoolsList": { + "title": "Майнинг-пулы", + "description": "Мультичейн-каталог майнинг-пулов. Каждый пул определяется по ончейн-атрибуции блоков; показатели по сетям — на страницах статистики сети.", + "Table": { + "Pool": { "name": "Пул", "hint": "" }, + "Links": { "name": "Ссылки", "hint": "" }, + "Networks": { "name": "Сети", "hint": "" } + } + }, + "MiningPoolDetail": { + "statsNoData": "Нет статистики для выбранного окна", + "metaTitle": "Майнинг-пул", + "networksTitle": "Профиль майнинг-пула: Сети", + "blocksTitle": "Профиль майнинг-пула: Блоки", + "window24h": "24ч", + "window7d": "7д", + "window30d": "30д", + "windowAll": "За всё время", + "recentBlocksTitle": "Последние блоки", + "recentBlocksDescription": "Последние блоки, привязанные к этому пулу, сопоставленные on-chain по хешу.", + "recentBlocksEmpty": "Пока нет блоков, привязанных к этому пулу." + }, + "MiningPoolProfileHeader": { + "Tabs": { + "Revenue": "Доход", + "Metrics": "Метрики", + "Network Table": "Сети", + "Public Goods": "Проекты", + "Blocks": "Блоки" + }, + "story": "{name} — проверенный майнинг-пул в сети {chain}. Статистика ниже получена из ончейн-атрибуции блоков.", + "Table": { + "Network": { "name": "Сеть", "hint": "Сеть, которую майнит пул" }, + "Blocks": { "name": "Блоков найдено", "hint": "Блоки, атрибутированные пулу за выбранное окно" }, + "Share": { "name": "Доминирование", "hint": "Доля блоков сети, найденных за выбранное окно" }, + "Hashrate": { "name": "Расчётный хешрейт", "hint": "Расчётный хешрейт по доле блоков пула" }, + "Fee": { "name": "Комиссия", "hint": "Комиссия пула согласно заявленной пулом" }, + "Height": { "name": "Высота", "hint": "Высота блока" }, + "Block Hash": { "name": "Хеш блока", "hint": "Хеш блока" }, + "Timestamp": { "name": "Timestamp", "hint": "Время блока" } + } + }, + "MiningPoolsBadges": { + "verified": "проверен", + "unverified": "не проверен", + "merge_mining_tag": "merge-mining тег", + "ascii_vanity": "ASCII vanity", + "fingerprint": "fingerprint", + "unknown": "неизвестно" + }, + "NetworkMiningPoolsPage": { + "title": "Майнинг-пулы", + "description": "Майнинг-пулы, активные в сети {networkName}, ранжированы по ончейн-атрибуции блоков. Блоки, доля и оценочный хешрейт рассчитаны за выбранное окно; комиссия пула берётся из API самого пула.", + "empty": "Нет данных по майнинг-пулам для этой сети.", + "window24h": "24ч", + "window7d": "7д", + "window30d": "30д", + "windowAll": "За всё время", + "Table": { + "Pool": { "name": "Пул", "hint": "Майнинг-пул, определённый по ончейн-атрибуции блоков" }, + "Blocks": { "name": "Блоки", "hint": "Блоки, атрибутированные пулу за выбранное окно" }, + "Share": { "name": "Доминирование", "hint": "Процент блоков сети, найденных за выбранное окно" }, + "Hashrate": { "name": "Хешрейт", "hint": "Оценочный хешрейт по доле блоков пула" }, + "Fee": { "name": "Комиссия", "hint": "Комиссия пула согласно заявленной пулом" } + } + }, + "PowNetworkStats": { + "hashrateHistory": "История хешрейта", + "poolDistribution": "Распределение пулов", + "window24h": "24ч", + "window7d": "7д", + "window30d": "30д", + "windowAll": "Всё время", + "noHashrateData": "Снимков хешрейта пока нет — идёт наполнение", + "noPoolData": "Статистики пулов пока нет — идёт наполнение" + }, + "OffchainGovernanceInfo": { + "title": "Управление", + "infoTitle": "Off-chain управление", + "infoBodyMonero": "В Monero нет on-chain управления. Хард-форки координируются через off-chain rough consensus при участии Monero Research Lab и сообщества.", + "infoBodyGeneric": "В этой сети нет on-chain управления. Изменения протокола координируются off-chain через rough consensus при участии контрибьюторов и сообщества.", + "channelsTitle": "Каналы координации" + }, + "PowTxs": { + "indexerDisabled": "Транзакции недоступны", + "indexerDisabledBody": "Индексер Monero не настроен.", + "noTxs": "Транзакций пока нет.", + "fetchError": "Не удалось загрузить транзакции.", + "amountsHidden": "Monero приватен — публичны только счётчики, комиссия и размер, никогда суммы или адреса.", + "txHash": "Хеш tx", + "block": "Блок", + "type": "Тип", + "fee": "Комиссия", + "size": "Размер", + "inOut": "Вх. / Вых.", + "coinbase": "Coinbase", + "regular": "Обычная", + "older": "Старее" + }, + "PowBlocks": { + "hash": "Хеш блока", + "title": "Последние блоки", + "description": "Последние блоки сети Monero. Принадлежность пулу берётся из списков блоков самих пулов, сопоставленных с on-chain блоками по хешу.", + "height": "Высота", + "time": "Timestamp", + "pool": "Майнер", + "txCount": "Кол-во tx", + "reward": "Награда", + "size": "Размер", + "difficulty": "Сложность", + "older": "Старее", + "indexerDisabled": "Последние блоки недоступны", + "indexerDisabledBody": "Monero indexer не настроен для этого окружения.", + "noBlocks": "Нет доступных блоков", + "fetchError": "Не удалось загрузить блоки.", + "unknownPool": "Unidentified / Solo", + "minedBy": "Кем добыт", + "unverified": "не проверено" } } diff --git a/prisma/migrations/20260429154100_add_monero_pow_models/migration.sql b/prisma/migrations/20260429154100_add_monero_pow_models/migration.sql new file mode 100644 index 00000000..068b77ad --- /dev/null +++ b/prisma/migrations/20260429154100_add_monero_pow_models/migration.sql @@ -0,0 +1,84 @@ +-- AlterTable +ALTER TABLE "chain_params" ADD COLUMN "hard_fork_timeline_json" JSONB; + +-- AlterTable +ALTER TABLE "chains" ADD COLUMN "consensus_type" VARCHAR(32), +ADD COLUMN "hashrate_unit" VARCHAR(16); + +-- CreateTable +CREATE TABLE "mining_pools" ( + "id" SERIAL NOT NULL, + "chain_id" INTEGER NOT NULL, + "slug" VARCHAR(256) NOT NULL, + "name" VARCHAR(256) NOT NULL, + "logo_url" TEXT, + "website" TEXT, + "payment_scheme" VARCHAR(64), + "fee_percent" DOUBLE PRECISION, + "identification_method" VARCHAR(64) NOT NULL, + "detector_json" JSONB, + "fingerprint" TEXT, + "is_verified" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "mining_pools_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "mining_pool_stats" ( + "id" SERIAL NOT NULL, + "chain_id" INTEGER NOT NULL, + "pool_id" INTEGER NOT NULL, + "window_kind" VARCHAR(16) NOT NULL, + "blocks_found" INTEGER NOT NULL, + "share_percent" DOUBLE PRECISION NOT NULL, + "hashrate_estimate" TEXT NOT NULL, + "window_start" TIMESTAMPTZ(6) NOT NULL, + "window_end" TIMESTAMPTZ(6) NOT NULL, + "updated_at" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "mining_pool_stats_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "chain_hashrate_snapshots" ( + "id" SERIAL NOT NULL, + "chain_id" INTEGER NOT NULL, + "snapshot_at" TIMESTAMPTZ(6) NOT NULL, + "height" INTEGER NOT NULL, + "hashrate" TEXT NOT NULL, + "difficulty" TEXT NOT NULL, + + CONSTRAINT "chain_hashrate_snapshots_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "mining_pools_chain_id_fingerprint_idx" ON "mining_pools"("chain_id", "fingerprint"); + +-- CreateIndex +CREATE UNIQUE INDEX "mining_pools_chain_id_slug_key" ON "mining_pools"("chain_id", "slug"); + +-- CreateIndex +CREATE INDEX "mining_pool_stats_chain_id_window_kind_share_percent_idx" ON "mining_pool_stats"("chain_id", "window_kind", "share_percent"); + +-- CreateIndex +CREATE UNIQUE INDEX "mining_pool_stats_chain_id_pool_id_window_kind_key" ON "mining_pool_stats"("chain_id", "pool_id", "window_kind"); + +-- CreateIndex +CREATE INDEX "chain_hashrate_snapshots_chain_id_height_idx" ON "chain_hashrate_snapshots"("chain_id", "height"); + +-- CreateIndex +CREATE UNIQUE INDEX "chain_hashrate_snapshots_chain_id_snapshot_at_key" ON "chain_hashrate_snapshots"("chain_id", "snapshot_at"); + +-- AddForeignKey +ALTER TABLE "mining_pools" ADD CONSTRAINT "mining_pools_chain_id_fkey" FOREIGN KEY ("chain_id") REFERENCES "chains"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "mining_pool_stats" ADD CONSTRAINT "mining_pool_stats_chain_id_fkey" FOREIGN KEY ("chain_id") REFERENCES "chains"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "mining_pool_stats" ADD CONSTRAINT "mining_pool_stats_pool_id_fkey" FOREIGN KEY ("pool_id") REFERENCES "mining_pools"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "chain_hashrate_snapshots" ADD CONSTRAINT "chain_hashrate_snapshots_chain_id_fkey" FOREIGN KEY ("chain_id") REFERENCES "chains"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260619091412_add_monero_block_attribution/migration.sql b/prisma/migrations/20260619091412_add_monero_block_attribution/migration.sql new file mode 100644 index 00000000..9d08e9e9 --- /dev/null +++ b/prisma/migrations/20260619091412_add_monero_block_attribution/migration.sql @@ -0,0 +1,36 @@ +-- CreateTable +CREATE TABLE "monero_block_attribution" ( + "id" SERIAL NOT NULL, + "chain_id" INTEGER NOT NULL, + "height" INTEGER NOT NULL, + "block_hash" TEXT NOT NULL, + "pool_id" INTEGER NOT NULL, + "source" VARCHAR(32) NOT NULL, + "block_timestamp" TIMESTAMPTZ(6) NOT NULL, + "pool_reported_at" TIMESTAMPTZ(6), + "is_canonical" BOOLEAN NOT NULL DEFAULT true, + "is_conflicted" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "monero_block_attribution_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "monero_block_attribution_chain_id_block_timestamp_idx" ON "monero_block_attribution"("chain_id", "block_timestamp"); + +-- CreateIndex +CREATE INDEX "monero_block_attribution_chain_id_pool_id_block_timestamp_idx" ON "monero_block_attribution"("chain_id", "pool_id", "block_timestamp"); + +-- CreateIndex +CREATE INDEX "monero_block_attribution_chain_id_is_canonical_is_conflicte_idx" ON "monero_block_attribution"("chain_id", "is_canonical", "is_conflicted", "block_timestamp"); + +-- CreateIndex +CREATE UNIQUE INDEX "monero_block_attribution_chain_id_block_hash_key" ON "monero_block_attribution"("chain_id", "block_hash"); + +-- AddForeignKey +ALTER TABLE "monero_block_attribution" ADD CONSTRAINT "monero_block_attribution_chain_id_fkey" FOREIGN KEY ("chain_id") REFERENCES "chains"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "monero_block_attribution" ADD CONSTRAINT "monero_block_attribution_pool_id_fkey" FOREIGN KEY ("pool_id") REFERENCES "mining_pools"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/prisma/migrations/20260620160000_add_mining_pool_socials/migration.sql b/prisma/migrations/20260620160000_add_mining_pool_socials/migration.sql new file mode 100644 index 00000000..0f7c6839 --- /dev/null +++ b/prisma/migrations/20260620160000_add_mining_pool_socials/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "mining_pools" ADD COLUMN "github" TEXT, +ADD COLUMN "twitter" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e0261337..c9746798 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -81,6 +81,9 @@ model ChainParams { // Aztec-specific governance configuration (JSON) aztecGovernanceConfigAdditional Json? @map("aztec_governance_config_additional") + // PoW-specific: cached hard fork timeline (height ↔ version pairs) + hardForkTimelineJson Json? @map("hard_fork_timeline_json") + chain Chain @relation("chain_params", fields: [chainId], references: [id]) @@map("chain_params") @@ -109,10 +112,13 @@ model Chain { tags String[] @default([]) supported Boolean @default(true) @map("supported") + consensusType String? @map("consensus_type") @db.VarChar(32) + hashrateUnit String? @map("hashrate_unit") @db.VarChar(16) + chainEcosystem Ecosystem @relation("chain_ecosystem", fields: [ecosystem], references: [name]) - chainNodes ChainNode[] @relation("chain_node") - aprs Apr[] @relation("chain_apr") + chainNodes ChainNode[] @relation("chain_node") + aprs Apr[] @relation("chain_apr") prices Price[] @relation("chain_price") priceHistory PriceHistory[] @relation("chain_price_history") nodes Node[] @relation("chain_validator_node") @@ -120,18 +126,23 @@ model Chain { tokenomics Tokenomics? @relation("chain_tokenomics") params ChainParams? @relation("chain_params") - validators Validator[] @relation("chain_validators") - addresses Address[] @relation("chain_addresses") - nodeVotes NodeVote[] @relation("node_vote_chain") - proposals Proposal[] @relation("chain_proposals") - githubRepositories GithubRepository[] @relation("chain_github_repositories") - tvsHistory ChainTvsHistory[] @relation("chain_tvs_history") - aprHistory ChainAprHistory[] @relation("chain_apr_history") - validatorsHistory ChainValidatorsHistory[] @relation("chain_validators_history") + validators Validator[] @relation("chain_validators") + addresses Address[] @relation("chain_addresses") + nodeVotes NodeVote[] @relation("node_vote_chain") + proposals Proposal[] @relation("chain_proposals") + githubRepositories GithubRepository[] @relation("chain_github_repositories") + tvsHistory ChainTvsHistory[] @relation("chain_tvs_history") + aprHistory ChainAprHistory[] @relation("chain_apr_history") + validatorsHistory ChainValidatorsHistory[] @relation("chain_validators_history") aztecNodeDistributionHistory AztecNodeDistributionHistory[] @relation("chain_aztec_node_distribution_history") - txMetrics ChainTxMetrics? @relation("chain_tx_metrics") - txDailySnapshots ChainTxDailySnapshot[] @relation("chain_tx_daily_snapshots") - podcastEpisodes PodcastEpisode[] @relation("PodcastChain") + txMetrics ChainTxMetrics? @relation("chain_tx_metrics") + txDailySnapshots ChainTxDailySnapshot[] @relation("chain_tx_daily_snapshots") + podcastEpisodes PodcastEpisode[] @relation("PodcastChain") + + miningPools MiningPool[] @relation("chain_mining_pools") + miningPoolStats MiningPoolStats[] @relation("chain_mining_pool_stats") + hashrateSnapshots ChainHashrateSnapshot[] @relation("chain_hashrate_snapshots") + moneroBlockAttributions MoneroBlockAttribution[] @relation("chain_monero_block_attributions") proposalsTotal Int @default(0) @map("proposals_total") proposalsLive Int @default(0) @map("proposals_live") @@ -205,7 +216,7 @@ model Node { minSelfDelegation String @map("min_self_delegation") @db.VarChar(256) missedBlocks Int? @map("missed_blocks") outstandingRewards String? @map("outstanding_rewards") - totalEarnedRewards String? @map("total_earned_rewards") + totalEarnedRewards String? @map("total_earned_rewards") outstandingCommissions String? @map("outstanding_commissions") delegatorsAmount Int? @map("delegators_amount") @@ -310,10 +321,10 @@ model Proposal { content Json title String description String - fullText String? @map("full_text") - metadataUrl String? @map("metadata_url") - fullTextAttempts Int @default(0) @map("full_text_attempts") - aiSummary Json? @map("ai_summary") + fullText String? @map("full_text") + metadataUrl String? @map("metadata_url") + fullTextAttempts Int @default(0) @map("full_text_attempts") + aiSummary Json? @map("ai_summary") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@ -525,29 +536,29 @@ model ChainTxDailySnapshot { } model PodcastEpisode { - id Int @id @default(autoincrement()) - slug String @unique - title String - description String? @db.Text - publicationDate String? @map("publication_date") - duration String? - episodeUrl String @map("episode_url") - playerUrl String? @map("player_url") - guestName String @map("guest_name") - speakerLabel String? @map("speaker_label") - summary String? @db.Text - chainName String? @map("chain_name") - identity String? - moniker String? - primaryProject String? @map("primary_project") + id Int @id @default(autoincrement()) + slug String @unique + title String + description String? @db.Text + publicationDate String? @map("publication_date") + duration String? + episodeUrl String @map("episode_url") + playerUrl String? @map("player_url") + guestName String @map("guest_name") + speakerLabel String? @map("speaker_label") + summary String? @db.Text + chainName String? @map("chain_name") + identity String? + moniker String? + primaryProject String? @map("primary_project") mentionedEntities String[] @map("mentioned_entities") - chainId Int? @map("chain_id") - chain Chain? @relation("PodcastChain", fields: [chainId], references: [id], onDelete: SetNull) + chainId Int? @map("chain_id") + chain Chain? @relation("PodcastChain", fields: [chainId], references: [id], onDelete: SetNull) chunks PodcastChunk[] - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) @@index([chainId]) @@index([identity]) @@ -558,20 +569,106 @@ model PodcastEpisode { } model PodcastChunk { - id Int @id @default(autoincrement()) - episodeId Int @map("episode_id") - episode PodcastEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade) - speakerRole String @map("speaker_role") - speakerName String? @map("speaker_name") - question String? @db.Text - content String @db.Text - contextPrefix String? @db.Text @map("context_prefix") - chunkIndex Int @map("chunk_index") + id Int @id @default(autoincrement()) + episodeId Int @map("episode_id") + episode PodcastEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade) + speakerRole String @map("speaker_role") + speakerName String? @map("speaker_name") + question String? @db.Text + content String @db.Text + contextPrefix String? @map("context_prefix") @db.Text + chunkIndex Int @map("chunk_index") embedding Unsupported("vector(768)") - embeddingModel String? @map("embedding_model") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + embeddingModel String? @map("embedding_model") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) @@index([episodeId]) @@map("podcast_chunks") } + +model MiningPool { + id Int @id @default(autoincrement()) + chainId Int @map("chain_id") + slug String @db.VarChar(256) + name String @db.VarChar(256) + logoUrl String? @map("logo_url") + website String? + github String? + twitter String? + paymentScheme String? @map("payment_scheme") @db.VarChar(64) + feePercent Float? @map("fee_percent") + identificationMethod String @map("identification_method") @db.VarChar(64) + detectorJson Json? @map("detector_json") + fingerprint String? + isVerified Boolean @default(false) @map("is_verified") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + + chain Chain @relation("chain_mining_pools", fields: [chainId], references: [id]) + stats MiningPoolStats[] @relation("mining_pool_stats") + attributions MoneroBlockAttribution[] @relation("mining_pool_attributions") + + @@unique([chainId, slug]) + @@index([chainId, fingerprint]) + @@map("mining_pools") +} + +model MiningPoolStats { + id Int @id @default(autoincrement()) + chainId Int @map("chain_id") + poolId Int @map("pool_id") + windowKind String @map("window_kind") @db.VarChar(16) + blocksFound Int @map("blocks_found") + sharePercent Float @map("share_percent") + hashrateEstimate String @map("hashrate_estimate") + windowStart DateTime @map("window_start") @db.Timestamptz(6) + windowEnd DateTime @map("window_end") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + + chain Chain @relation("chain_mining_pool_stats", fields: [chainId], references: [id]) + pool MiningPool @relation("mining_pool_stats", fields: [poolId], references: [id]) + + @@unique([chainId, poolId, windowKind]) + @@index([chainId, windowKind, sharePercent]) + @@map("mining_pool_stats") +} + +model ChainHashrateSnapshot { + id Int @id @default(autoincrement()) + chainId Int @map("chain_id") + snapshotAt DateTime @map("snapshot_at") @db.Timestamptz(6) + height Int + hashrate String + difficulty String + + chain Chain @relation("chain_hashrate_snapshots", fields: [chainId], references: [id]) + + @@unique([chainId, snapshotAt]) + @@index([chainId, height]) + @@map("chain_hashrate_snapshots") +} + +model MoneroBlockAttribution { + id Int @id @default(autoincrement()) + chainId Int @map("chain_id") + height Int + blockHash String @map("block_hash") + poolId Int @map("pool_id") + source String @db.VarChar(32) // AttributionSource: pool_api | p2pool_observer + blockTimestamp DateTime @map("block_timestamp") @db.Timestamptz(6) + poolReportedAt DateTime? @map("pool_reported_at") @db.Timestamptz(6) + isCanonical Boolean @default(true) @map("is_canonical") + isConflicted Boolean @default(false) @map("is_conflicted") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + + chain Chain @relation("chain_monero_block_attributions", fields: [chainId], references: [id]) + pool MiningPool @relation("mining_pool_attributions", fields: [poolId], references: [id]) + + @@unique([chainId, blockHash]) + @@index([chainId, blockTimestamp]) + @@index([chainId, poolId, blockTimestamp]) + @@index([chainId, isCanonical, isConflicted, blockTimestamp]) + @@map("monero_block_attribution") +} diff --git a/server/indexer.ts b/server/indexer.ts index dde9e533..c23bafb9 100755 --- a/server/indexer.ts +++ b/server/indexer.ts @@ -160,6 +160,9 @@ const runServer = async () => { { name: 'update-tx-metrics', schedule: dailyAt(21, 7) }, { name: 'update-proposal-texts', schedule: timers.every10mins }, { name: 'update-validator-ranks', schedule: timers.every6hours }, + { name: 'monero-network-info', schedule: timers.every5mins }, + { name: 'monero-pool-attribution', schedule: timers.every10mins }, + { name: 'monero-pool-stats', schedule: timers.everyHour }, ]; specialTasks.forEach(({ name, schedule }) => { diff --git a/server/jobs/get-coingecko-data.ts b/server/jobs/get-coingecko-data.ts index 459d69dd..f23846ab 100644 --- a/server/jobs/get-coingecko-data.ts +++ b/server/jobs/get-coingecko-data.ts @@ -3,6 +3,7 @@ import { sleep } from '@cosmjs/utils'; import db from '@/db'; import logger from '@/logger'; import { AddChainProps } from '@/server/tools/chains/chain-indexer'; +import chainNames from '@/server/tools/chains/chains'; import { chainParamsArray } from '@/server/tools/chains/params'; const { logInfo, logError } = logger('get-tokenomics'); @@ -91,7 +92,9 @@ const processChainWithRetry = async (chain: AddChainProps, retries = RETRIES) => }; export const getCoingeckoData = async () => { - const chains = chainParamsArray.filter((c) => c.coinGeckoId); + const chains = chainParamsArray.filter( + (c) => c.coinGeckoId && chainNames.includes(c.name), + ); for (const chain of chains) { await processChainWithRetry(chain); diff --git a/server/jobs/get-price-history.ts b/server/jobs/get-price-history.ts index 5448acf0..df285451 100644 --- a/server/jobs/get-price-history.ts +++ b/server/jobs/get-price-history.ts @@ -2,6 +2,7 @@ import { sleep } from '@cosmjs/utils'; import db from '@/db'; import logger from '@/logger'; +import chainNames from '@/server/tools/chains/chains'; import { chainParamsArray } from '@/server/tools/chains/params'; const { logInfo, logError } = logger('get-price-history'); @@ -129,7 +130,9 @@ const processChainWithRetry = async (chain: { chainId: string; coinGeckoId: stri }; export const getPriceHistory = async () => { - const chains = chainParamsArray.filter((c) => c.coinGeckoId); + const chains = chainParamsArray.filter( + (c) => c.coinGeckoId && chainNames.includes(c.name), +); for (const chain of chains) { await processChainWithRetry(chain); diff --git a/server/jobs/get-prices.ts b/server/jobs/get-prices.ts index 856d2a40..a2f2fafe 100644 --- a/server/jobs/get-prices.ts +++ b/server/jobs/get-prices.ts @@ -1,5 +1,6 @@ import db from '@/db'; import logger from '@/logger'; +import chainNames from '@/server/tools/chains/chains'; import { chainParamsArray } from '@/server/tools/chains/params'; import priceService from '@/services/price-service'; @@ -7,7 +8,9 @@ const { logInfo, logError } = logger('get-prices'); export const getPrices = async () => { try { - const chainsForPrices = chainParamsArray.filter((c) => c.coinGeckoId); + const chainsForPrices = chainParamsArray.filter( + (c) => c.coinGeckoId && chainNames.includes(c.name), + ); const req = 'https://api.coingecko.com/api/v3/simple/price?ids=' + chainsForPrices.map((chain) => chain.coinGeckoId).join(',') + diff --git a/server/jobs/monero-coinbase-attribution.ts b/server/jobs/monero-coinbase-attribution.ts new file mode 100644 index 00000000..97f9704d --- /dev/null +++ b/server/jobs/monero-coinbase-attribution.ts @@ -0,0 +1,210 @@ +import fs from 'fs'; +import path from 'path'; + +import db from '@/db'; +import logger from '@/logger'; +import { + extractAsciiRuns, + extractUrls, + nameFromUrl, + normalizeUrl, + slugFromUrl, +} from '@/server/tools/chains/monero/coinbase-parse'; +import { listMoneroBlocks } from '@/server/tools/chains/monero/indexer-client'; + +const { logInfo, logError, logWarn } = logger('monero-coinbase-attribution'); + +interface CoinbaseSeed { + slug: string; + name: string; + tag: string; + website: string | null; + github: string | null; + twitter: string | null; + logoUrl: string | null; +} + +const SCAN_BLOCKS = 5000; +const PAGE = 100; + +// Auto-discovered pools live in their own slug namespace so an attacker-chosen coinbase URL can never +// produce a slug that collides with (and overwrites) a curated/API/verified pool record. +const AUTO_SLUG_PREFIX = 'cb_'; + +const loadSeed = (): CoinbaseSeed[] => { + const file = path.join(process.cwd(), 'server', 'tools', 'chains', 'monero', 'coinbase-pools.json'); + return JSON.parse(fs.readFileSync(file, 'utf8')) as CoinbaseSeed[]; +}; + +// Attribute the rare self-identifying blocks (solo-miner vanity tags + pool URLs in coinbase) that have +// no public API. Fills ONLY the "unknown" gap — never overrides pool-API attribution. Auto-discovers new +// http(s) pool URLs as unverified pools (promote to coinbase-pools.json manually once vetted). +const updateMoneroCoinbaseAttribution = async (): Promise => { + logInfo('Starting Monero coinbase self-ID attribution'); + + const dbChain = await db.chain.findFirst({ where: { name: 'monero' } }); + if (!dbChain) { + logWarn('Chain "monero" not found, skipping'); + return; + } + const chainId = dbChain.id; + + // 1. Upsert curated pools (verified) + build known-tag maps. + const urlTagToSlug = new Map(); // normalized URL → slug + const stringTagToSlug = new Map(); // literal vanity → slug + + for (const s of loadSeed()) { + const fields = { + name: s.name, + website: s.website, + github: s.github, + twitter: s.twitter, + logoUrl: s.logoUrl, + identificationMethod: 'coinbase_selfid', + fingerprint: s.tag, + isVerified: true, + }; + await db.miningPool.upsert({ + where: { chainId_slug: { chainId, slug: s.slug } }, + update: fields, + create: { chainId, slug: s.slug, ...fields }, + }); + const norm = normalizeUrl(s.tag); + if (norm) urlTagToSlug.set(norm, s.slug); + else stringTagToSlug.set(s.tag, s.slug); + } + + // Re-load previously auto-discovered coinbase pools (their URL tag lives in `fingerprint`). + const autoPools = await db.miningPool.findMany({ + where: { chainId, identificationMethod: 'coinbase_url_auto', fingerprint: { not: null } }, + select: { slug: true, fingerprint: true }, + }); + for (const ap of autoPools) { + const norm = ap.fingerprint ? normalizeUrl(ap.fingerprint) : null; + if (norm) urlTagToSlug.set(norm, ap.slug); + } + + // 2. Scan recent canonical blocks; match known tags + auto-discover new URLs. + const matches: Array<{ height: number; hash: string; ts: number; slug: string; auto: boolean }> = []; + const newAutoPools = new Map(); // normalized URL → meta + let scanned = 0; + + for (let off = 0; off < SCAN_BLOCKS; off += PAGE) { + let res; + try { + res = await listMoneroBlocks({ limit: PAGE, offset: off, order: 'desc' }); + } catch (e) { + logError(`block fetch failed at offset ${off}: ${(e as Error).message}`); + break; + } + if (!res.items.length) break; + for (const b of res.items) { + scanned++; + if (!b.isCanonical) continue; + const runs = extractAsciiRuns(b.coinbaseExtraHex); + if (!runs.length) continue; + + // 2a. literal vanity tags (e.g. /Heathcliff/) + let slug: string | null = null; + for (const [tag, s] of Array.from(stringTagToSlug.entries())) { + if (runs.some((r) => r.includes(tag))) { + slug = s; + break; + } + } + let auto = false; + + // 2b. embedded URLs (known → that pool; new → auto-discover once) + if (!slug) { + for (const url of extractUrls(runs)) { + const known = urlTagToSlug.get(url); + if (known) { + slug = known; + break; + } + let meta = newAutoPools.get(url); + if (!meta) { + meta = { slug: `${AUTO_SLUG_PREFIX}${slugFromUrl(url)}`, url }; + newAutoPools.set(url, meta); + urlTagToSlug.set(url, meta.slug); + } + slug = meta.slug; + auto = true; + break; + } + } + + if (slug) matches.push({ height: b.height, hash: b.hash, ts: b.timestamp, slug, auto }); + } + if (!res.hasMore) break; + } + + // 3. Persist newly-discovered auto pools (unverified — promote to JSON after vetting). + // Defense in depth: never touch a pool we did not create (curated/API/verified) even if a slug collides. + const protectedSlugs = new Set( + ( + await db.miningPool.findMany({ + where: { chainId, identificationMethod: { not: 'coinbase_url_auto' } }, + select: { slug: true }, + }) + ).map((p) => p.slug), + ); + + for (const { slug, url } of Array.from(newAutoPools.values())) { + if (protectedSlugs.has(slug)) { + logWarn(`auto-pool slug '${slug}' collides with a curated/verified pool — skipping (${url})`); + continue; + } + const fields = { + name: nameFromUrl(url), + website: url, + github: null, + twitter: null, + logoUrl: null, + identificationMethod: 'coinbase_url_auto', + fingerprint: url, + isVerified: false, + }; + await db.miningPool.upsert({ + where: { chainId_slug: { chainId, slug } }, + update: fields, + create: { chainId, slug, ...fields }, + }); + logInfo(`auto-discovered coinbase pool: ${slug} (${url})`); + } + + // 4. Create attributions only where none exists yet (skipDuplicates keeps pool-API attribution authoritative). + const slugToId = new Map( + (await db.miningPool.findMany({ where: { chainId }, select: { id: true, slug: true } })).map((p) => [p.slug, p.id]), + ); + + const creates = matches + .map((m) => { + const poolId = slugToId.get(m.slug); + if (poolId === undefined) return null; + return { + chainId, + height: m.height, + blockHash: m.hash, + poolId, + source: m.auto ? 'coinbase_url_auto' : 'coinbase_selfid', + blockTimestamp: new Date(m.ts * 1000), + poolReportedAt: null, + isCanonical: true, + isConflicted: false, + }; + }) + .filter((x): x is NonNullable => x !== null); + + let created = 0; + if (creates.length) { + const r = await db.moneroBlockAttribution.createMany({ data: creates, skipDuplicates: true }); + created = r.count; + } + + logInfo( + `coinbase self-ID done: scanned=${scanned}, matched=${matches.length}, new-attributions=${created}, auto-pools=${newAutoPools.size}`, + ); +}; + +export default updateMoneroCoinbaseAttribution; diff --git a/server/jobs/monero-network-info.ts b/server/jobs/monero-network-info.ts new file mode 100644 index 00000000..f3305a9f --- /dev/null +++ b/server/jobs/monero-network-info.ts @@ -0,0 +1,121 @@ +import db from '@/db'; +import logger from '@/logger'; +import { MONERO_BLOCK_TIME_SECONDS } from '@/server/tools/chains/monero/constants'; +import { getHealth, getLatestSupply, getTipBlock } from '@/server/tools/chains/monero/indexer-client'; + +const { logInfo, logError, logWarn } = logger('monero-network-info'); + +// Real Monero tip has been > 1M since 2016; the order=desc string-sort artifact +// (§8.1) returns height 999999. Anything below this floor is an artifact. +const MIN_PLAUSIBLE_HEIGHT = 1_000_000; +// Generous slack for indexer lag behind the node; the artifact is ~2.7M off. +const NODE_HEIGHT_TOLERANCE = 100_000; + +/** + * Monero network-info job (design §6) — single-source on the indexer. + * + * - Hashrate snapshot: tip-block difficulty / 120s (BigInt end-to-end). + * - Total supply: latest /api/v1/supply cumulative_emission_atomic — RAW ATOMIC, + * EMISSION-ONLY (the app divides by 10^coinDecimals at read; fees are NOT + * added — they add no new supply). + * + * Sanity guard: never persist a bogus snapshot. Skip if difficulty is null/ + * malformed, or if the tip height is implausible vs the node height from /health + * (defends against a regressed indexer). No direct monerod RPC. + */ +const updateMoneroNetworkInfo = async (): Promise => { + logInfo('Starting Monero network-info update'); + + try { + const dbChain = await db.chain.findFirst({ where: { name: 'monero' } }); + if (!dbChain) { + logWarn('Chain "monero" not found in database, skipping'); + return; + } + + const tip = await getTipBlock(); + if (!tip) { + logWarn('No tip block returned by indexer, skipping'); + return; + } + + // Sanity guards (§6) — never write a bogus 0/NaN snapshot. + if (tip.difficulty === null) { + logWarn(`Tip difficulty missing/malformed at height ${tip.height} — skipping snapshot`); + return; + } + // Hard floor — catches the order=desc string-sort artifact (height 999999) + // even when /health is unavailable. + if (tip.height < MIN_PLAUSIBLE_HEIGHT) { + logWarn(`Tip height ${tip.height} below plausibility floor (ordering artifact?) — skipping`); + return; + } + // Two-sided cross-check vs the node height (catches both a stale-low and a + // runaway-high tip). + const health = await getHealth(); + if (health?.nodeHeight != null) { + if (Math.abs(tip.height - health.nodeHeight) > NODE_HEIGHT_TOLERANCE) { + logWarn(`Tip height ${tip.height} implausible vs node_height ${health.nodeHeight} — skipping`); + return; + } + } else { + logWarn('No /health node_height — proceeding on the floor guard only'); + } + + const hashrate = (tip.difficulty / BigInt(MONERO_BLOCK_TIME_SECONDS)).toString(); + const difficulty = tip.difficulty.toString(); + const snapshotAt = new Date(Math.floor(Date.now() / 60_000) * 60_000); + + await db.chainHashrateSnapshot.upsert({ + where: { chainId_snapshotAt: { chainId: dbChain.id, snapshotAt } }, + update: { height: tip.height, hashrate, difficulty }, + create: { chainId: dbChain.id, snapshotAt, height: tip.height, hashrate, difficulty }, + }); + + logInfo(`monero: tip=${tip.height} difficulty=${difficulty} hashrate=${hashrate} H/s`); + + // Supply — emission-only, raw atomic (design §6). Never add fee, never /1e12. + // Guarded: skip an artifact checkpoint (low height) or a non-monotonic value + // (cumulative emission can only grow) so one bad indexer response can't poison + // money-sensitive supply. + const supply = await getLatestSupply(); + if (!supply) { + logWarn('No supply checkpoint returned by indexer — supply not updated'); + } else if (supply.height < MIN_PLAUSIBLE_HEIGHT) { + logWarn(`Supply checkpoint height ${supply.height} below floor (artifact?) — supply not updated`); + } else { + let newEmission: bigint; + try { + newEmission = BigInt(supply.cumulativeEmissionAtomic || '0'); + } catch { + logWarn(`Malformed supply emission "${supply.cumulativeEmissionAtomic}" — supply not updated`); + newEmission = BigInt(-1); + } + if (newEmission >= BigInt(0)) { + const existing = await db.tokenomics.findUnique({ where: { chainId: dbChain.id }, select: { totalSupply: true } }); + let prev = BigInt(0); + try { + prev = BigInt(existing?.totalSupply || '0'); + } catch { + prev = BigInt(0); + } + if (newEmission < prev) { + logWarn(`Supply would decrease (${newEmission} < ${prev}) — likely a bad checkpoint, supply not updated`); + } else { + await db.tokenomics.upsert({ + where: { chainId: dbChain.id }, + update: { totalSupply: supply.cumulativeEmissionAtomic }, + create: { chainId: dbChain.id, totalSupply: supply.cumulativeEmissionAtomic }, + }); + logInfo(`monero: totalSupply (piconero, emission-only)=${supply.cumulativeEmissionAtomic}`); + } + } + } + + logInfo('Monero network-info update completed'); + } catch (e: any) { + logError(`Monero network-info update failed: ${e?.message ?? String(e)}`, e); + } +}; + +export default updateMoneroNetworkInfo; diff --git a/server/jobs/monero-pool-attribution.ts b/server/jobs/monero-pool-attribution.ts new file mode 100644 index 00000000..d73286a1 --- /dev/null +++ b/server/jobs/monero-pool-attribution.ts @@ -0,0 +1,247 @@ +import db from '@/db'; +import logger from '@/logger'; +import { MONERO_INDEXER_PAGE_SIZE } from '@/server/tools/chains/monero/constants'; +import { listMoneroBlocks } from '@/server/tools/chains/monero/indexer-client'; +import { + UNKNOWN_POOL_NAME, + UNKNOWN_POOL_SLUG, +} from '@/server/tools/chains/monero/attribution-source'; +import { + fetchPoolBlocks, + getPoolRegistry, + sourceForPool, +} from '@/server/tools/chains/monero/pool-client'; + +const { logInfo, logError, logWarn } = logger('monero-pool-attribution'); + +const MAX_PAGES = 4; // tip-ward scan cap per confirm/reorg pass +const REORG_RECHECK = 500; // newest attributions re-verified for canonicality each run + +interface IndexerBlockInfo { + height: number; + timestamp: number; // on-chain unix seconds + isCanonical: boolean; +} + +// Page the indexer block list (desc) from tip down to `floorHeight`, into a +// hash -> {height, timestamp, isCanonical} map. Bounded by MAX_PAGES. The list +// is canonical-filtered by the indexer (canonical=true default), so an orphaned +// block is ABSENT, not present-with-isCanonical=false — `lowestHeight` lets the +// caller tell "absent because orphaned (within span)" from "absent because below +// the scanned depth". Returns the lowest height actually covered. +const buildCanonicalMap = async ( + floorHeight: number, +): Promise<{ map: Map; lowestHeight: number }> => { + const map = new Map(); + let offset = 0; + let lowestHeight = Number.POSITIVE_INFINITY; + for (let pages = 0; pages < MAX_PAGES; pages++) { + const page = await listMoneroBlocks({ limit: MONERO_INDEXER_PAGE_SIZE, offset, order: 'desc' }); + for (const blk of page.items) { + map.set(blk.hash, { height: blk.height, timestamp: blk.timestamp, isCanonical: blk.isCanonical }); + if (blk.height < lowestHeight) lowestHeight = blk.height; + } + const lowest = page.items.length ? page.items[page.items.length - 1].height : 0; + if (!page.hasMore || lowest <= floorHeight) break; + offset = page.nextOffset; + } + return { map, lowestHeight: lowestHeight === Number.POSITIVE_INFINITY ? 0 : lowestHeight }; +}; + +/** + * Monero pool-attribution job (design §5.2). Pools list the blocks they found; + * we confirm each against the indexer's canonical set (by hash) and persist a + * per-block attribution. Conflicting claims on one hash are flagged (never + * silently moved). Per-pool failures are isolated. DB writes are batched. + */ +const updateMoneroPoolAttribution = async (): Promise => { + logInfo('Starting Monero pool-attribution'); + + try { + const dbChain = await db.chain.findFirst({ where: { name: 'monero' } }); + if (!dbChain) { + logWarn('Chain "monero" not found, skipping'); + return; + } + const chainId = dbChain.id; + + await db.miningPool.upsert({ + where: { chainId_slug: { chainId, slug: UNKNOWN_POOL_SLUG } }, + update: { name: UNKNOWN_POOL_NAME }, + create: { chainId, slug: UNKNOWN_POOL_SLUG, name: UNKNOWN_POOL_NAME, identificationMethod: 'unknown', isVerified: false }, + }); + + // 1. Poll each pool (isolated) — upsert its MiningPool + collect found blocks. + const collected: Array<{ poolKey: string; source: string; blocks: Array<{ height: number; hash: string; timestamp: number }> }> = []; + let minHeight = Number.POSITIVE_INFINITY; + + for (const pool of getPoolRegistry()) { + try { + const blocks = await fetchPoolBlocks(pool); + const poolFields = { + name: pool.name, + logoUrl: pool.logoUrl, + website: pool.website, + github: pool.github, + twitter: pool.twitter, + paymentScheme: pool.paymentScheme, + feePercent: pool.feePercent, + identificationMethod: sourceForPool(pool), + isVerified: true, + }; + await db.miningPool.upsert({ + where: { chainId_slug: { chainId, slug: pool.key } }, + update: poolFields, + create: { chainId, slug: pool.key, ...poolFields }, + }); + if (blocks.length) { + collected.push({ poolKey: pool.key, source: sourceForPool(pool), blocks }); + for (const b of blocks) minHeight = Math.min(minHeight, b.height); + } + logInfo(`pool=${pool.key}: ${blocks.length} found blocks`); + } catch (e: any) { + logError(`pool=${pool.key} fetch failed (isolated): ${e?.message ?? String(e)}`); + } + } + if (collected.length === 0) { + logInfo('No pool blocks collected, skipping'); + return; + } + + // 2. Confirm against canonical indexer set (batched map). + const { map: confirmMap } = await buildCanonicalMap(minHeight); + + const pools = await db.miningPool.findMany({ where: { chainId }, select: { id: true, slug: true } }); + const slugToId = new Map(pools.map((p) => [p.slug, p.id])); + + // 3. Fold all claims by hash (detect same-run conflicts: 2+ distinct pools on one hash). + interface Claim { + info: IndexerBlockInfo; + poolIds: Set; + firstPoolId: number; + source: string; + poolReportedAt: Date | null; + } + const byHash = new Map(); + for (const { poolKey, source, blocks } of collected) { + const poolId = slugToId.get(poolKey); + if (poolId === undefined) continue; + for (const b of blocks) { + const info = confirmMap.get(b.hash); + if (!info || !info.isCanonical) continue; // not indexed yet / orphan → skip + const existing = byHash.get(b.hash); + if (existing) { + existing.poolIds.add(poolId); + } else { + byHash.set(b.hash, { + info, + poolIds: new Set([poolId]), + firstPoolId: poolId, + source, + poolReportedAt: b.timestamp ? new Date(b.timestamp * 1000) : null, + }); + } + } + } + if (byHash.size === 0) { + logInfo('No canonical pool blocks to attribute'); + return; + } + + // 4. Batch existence read, then partition into create / conflict / refresh. + const hashes = Array.from(byHash.keys()); + const existingRows = await db.moneroBlockAttribution.findMany({ + where: { chainId, blockHash: { in: hashes } }, + select: { blockHash: true, poolId: true, isConflicted: true }, + }); + const existingMap = new Map(existingRows.map((r) => [r.blockHash, r])); + + const creates: Array<{ + chainId: number; height: number; blockHash: string; poolId: number; source: string; + blockTimestamp: Date; poolReportedAt: Date | null; isCanonical: boolean; isConflicted: boolean; + }> = []; + const conflictHashes: string[] = []; + const refreshHashes: string[] = []; + + for (const [hash, claim] of Array.from(byHash.entries())) { + const existing = existingMap.get(hash); + const blockTimestamp = new Date(claim.info.timestamp * 1000); + if (!existing) { + const sameRunConflict = claim.poolIds.size > 1; + creates.push({ + chainId, + height: claim.info.height, + blockHash: hash, + poolId: claim.firstPoolId, + source: claim.source, + blockTimestamp, + poolReportedAt: claim.poolReportedAt, + isCanonical: true, + isConflicted: sameRunConflict, + }); + } else if (!existing.isConflicted && Array.from(claim.poolIds).some((id) => id !== existing.poolId)) { + conflictHashes.push(hash); // a different pool now also claims it → flag + } else { + refreshHashes.push(hash); // same pool / already conflicted → keep canonical + } + } + + if (creates.length) { + await db.moneroBlockAttribution.createMany({ data: creates, skipDuplicates: true }); + } + if (conflictHashes.length) { + await db.moneroBlockAttribution.updateMany({ + where: { chainId, blockHash: { in: conflictHashes } }, + data: { isConflicted: true }, + }); + } + if (refreshHashes.length) { + await db.moneroBlockAttribution.updateMany({ + where: { chainId, blockHash: { in: refreshHashes } }, + data: { isCanonical: true }, + }); + } + + // 5. Bounded reorg re-verify (two-way), depth fixed at REORG_RECHECK rows — + // independent of what pools reported. Uses its own canonical map. + let reorged = 0; + const recent = await db.moneroBlockAttribution.findMany({ + where: { chainId }, + orderBy: { blockTimestamp: 'desc' }, + take: REORG_RECHECK, + select: { blockHash: true, height: true, isCanonical: true }, + }); + if (recent.length) { + const minRecent = recent.reduce((m, r) => Math.min(m, r.height), Number.POSITIVE_INFINITY); + const { map: reorgMap, lowestHeight: reorgLowest } = await buildCanonicalMap(minRecent); + const setFalse: string[] = []; + const setTrue: string[] = []; + for (const r of recent) { + if (reorgMap.has(r.blockHash)) { + // Present in the canonical list → block is canonical; restore if previously flipped. + if (!r.isCanonical) setTrue.push(r.blockHash); + } else if (r.height >= reorgLowest && r.isCanonical) { + // Absent BUT within the scanned canonical span → the block was orphaned/ + // replaced (the list dropped it) → flip non-canonical. (Below the scan + // depth we can't tell, so leave as settled.) + setFalse.push(r.blockHash); + } + } + if (setFalse.length) { + await db.moneroBlockAttribution.updateMany({ where: { chainId, blockHash: { in: setFalse } }, data: { isCanonical: false } }); + } + if (setTrue.length) { + await db.moneroBlockAttribution.updateMany({ where: { chainId, blockHash: { in: setTrue } }, data: { isCanonical: true } }); + } + reorged = setFalse.length + setTrue.length; + } + + logInfo( + `monero-pool-attribution: pools=${collected.length} created=${creates.length} conflicts=${conflictHashes.length} refreshed=${refreshHashes.length} reorged=${reorged}`, + ); + } catch (e: any) { + logError(`Monero pool-attribution failed: ${e?.message ?? String(e)}`, e); + } +}; + +export default updateMoneroPoolAttribution; diff --git a/server/jobs/monero-pool-stats.ts b/server/jobs/monero-pool-stats.ts new file mode 100644 index 00000000..64be3b75 --- /dev/null +++ b/server/jobs/monero-pool-stats.ts @@ -0,0 +1,175 @@ +import db from '@/db'; +import logger from '@/logger'; +import { MONERO_BLOCK_TIME_SECONDS } from '@/server/tools/chains/monero/constants'; +import { + UNKNOWN_POOL_NAME, + UNKNOWN_POOL_SLUG, +} from '@/server/tools/chains/monero/attribution-source'; +import { getTipBlock } from '@/server/tools/chains/monero/indexer-client'; + +const { logInfo, logError, logWarn } = logger('monero-pool-stats'); + +type WindowKind = '24h' | '7d' | '30d' | 'all'; +const WINDOW_SECONDS: Record, number> = { + '24h': 86_400, + '7d': 604_800, + '30d': 2_592_000, +}; +const WINDOWS: WindowKind[] = ['24h', '7d', '30d', 'all']; + +/** + * Monero pool-stats job (design §5.2). + * + * Counting is by BLOCK HEIGHT, not wall-clock, so numerator and denominator + * share one clock (Codex/stats-H1). Monero heights are contiguous (exactly one + * canonical block per height), so `networkBlocks = tipHeight − lowerHeight` is + * the EXACT count of canonical network blocks in the window — not an estimate. + * `poolBlocks` counts canonical, non-conflicted attributions in the same height + * range. The residual is the honest unknown/solo bucket (clamped ≥ 0). + * + * Every named pool is upserted every run (including 0) so stale rows can never + * survive a pool going quiet (stats-H2). hashrateEstimate uses the window-average + * hashrate (hourly snapshots); omitted ('0') for the all window. + */ +const updateMoneroPoolStats = async (): Promise => { + logInfo('Starting Monero pool-stats update'); + + try { + const dbChain = await db.chain.findFirst({ where: { name: 'monero' } }); + if (!dbChain) { + logWarn('Chain "monero" not found, skipping'); + return; + } + const chainId = dbChain.id; + + const tip = await getTipBlock(); + if (!tip || tip.height <= 0) { + logWarn('No usable tip block, skipping'); + return; + } + const tipHeight = tip.height; + + const pools = await db.miningPool.findMany({ where: { chainId }, select: { id: true, slug: true } }); + let unknownId = pools.find((p) => p.slug === UNKNOWN_POOL_SLUG)?.id; + if (unknownId === undefined) { + const u = await db.miningPool.upsert({ + where: { chainId_slug: { chainId, slug: UNKNOWN_POOL_SLUG } }, + update: {}, + create: { chainId, slug: UNKNOWN_POOL_SLUG, name: UNKNOWN_POOL_NAME, identificationMethod: 'unknown', isVerified: false }, + select: { id: true }, + }); + unknownId = u.id; + } + const namedPools = pools.filter((p) => p.slug !== UNKNOWN_POOL_SLUG); + + const now = new Date(); + + for (const window of WINDOWS) { + // Height-based window bound + timestamps for the stored row. + let lowerHeight: number; + let windowStart: Date; + const windowEnd = now; + + if (window === 'all') { + const earliest = await db.moneroBlockAttribution.findFirst({ + where: { chainId }, + orderBy: { height: 'asc' }, + select: { height: true, blockTimestamp: true }, + }); + if (!earliest) { + logInfo('all: no attribution yet, skipping window'); + continue; + } + lowerHeight = earliest.height; + windowStart = earliest.blockTimestamp; + } else { + const seconds = WINDOW_SECONDS[window]; + lowerHeight = Math.max(0, tipHeight - Math.round(seconds / MONERO_BLOCK_TIME_SECONDS)); + windowStart = new Date(windowEnd.getTime() - seconds * 1000); + } + + // EXACT canonical-block count in the INCLUSIVE range [lowerHeight, tipHeight] + // (contiguous heights). +1 so it matches the `gte lowerHeight / lte tipHeight` + // poolBlocks query below — same interval on both sides (Codex F1). + const networkBlocks = Math.max(1, tipHeight - lowerHeight + 1); + + // Window-average network hashrate (hourly snapshots, by time). '0' for all. + let avgHashrate = BigInt(0); + if (window !== 'all') { + const snaps = await db.chainHashrateSnapshot.findMany({ + where: { chainId, snapshotAt: { gte: windowStart, lte: windowEnd } }, + select: { hashrate: true }, + }); + if (snaps.length) { + let sum = BigInt(0); + for (const s of snaps) { + try { + sum += BigInt(s.hashrate || '0'); + } catch { + /* skip malformed */ + } + } + avgHashrate = sum / BigInt(snaps.length); + } + } + + const estimate = (blocks: number): string => { + if (window === 'all' || avgHashrate <= BigInt(0)) return '0'; + return ((avgHashrate * BigInt(blocks)) / BigInt(networkBlocks)).toString(); + }; + + const upsertStats = (poolId: number, blocks: number) => + db.miningPoolStats.upsert({ + where: { chainId_poolId_windowKind: { chainId, poolId, windowKind: window } }, + update: { + blocksFound: blocks, + sharePercent: (blocks / networkBlocks) * 100, + hashrateEstimate: estimate(blocks), + windowStart, + windowEnd, + }, + create: { + chainId, + poolId, + windowKind: window, + blocksFound: blocks, + sharePercent: (blocks / networkBlocks) * 100, + hashrateEstimate: estimate(blocks), + windowStart, + windowEnd, + }, + }); + + // Count + upsert every named pool (incl. 0 — overwrites stale rows). + let sumPool = 0; + for (const pool of namedPools) { + const poolBlocks = await db.moneroBlockAttribution.count({ + where: { + chainId, + poolId: pool.id, + isCanonical: true, + isConflicted: false, + height: { gte: lowerHeight, lte: tipHeight }, + }, + }); + sumPool += poolBlocks; + await upsertStats(pool.id, poolBlocks); + } + + // Unknown / solo bucket — residual, clamped ≥ 0. + if (networkBlocks - sumPool < 0) { + logWarn(`${window}: Σ poolBlocks (${sumPool}) > networkBlocks (${networkBlocks}) — over-attribution; clamped unknown to 0`); + } + const unknownBlocks = Math.max(0, networkBlocks - sumPool); + await upsertStats(unknownId, unknownBlocks); + + logInfo(`monero-pool-stats[${window}]: networkBlocks=${networkBlocks} named=${sumPool} unknown=${unknownBlocks}`); + } + + logInfo('Monero pool-stats update completed'); + } catch (e: any) { + logError(`Monero pool-stats update failed: ${e?.message ?? String(e)}`, e); + } +}; + +export default updateMoneroPoolStats; diff --git a/server/task-worker.ts b/server/task-worker.ts index c6dc3f69..f6196b13 100755 --- a/server/task-worker.ts +++ b/server/task-worker.ts @@ -37,6 +37,9 @@ import updateDelegatorsAmount from '@/server/jobs/update-delegators-amount'; import updateFdv from '@/server/jobs/update-fdv'; import updateGithubRepositories from '@/server/jobs/update-github-repositories'; import updateInflationRate from '@/server/jobs/update-inflation-rate'; +import updateMoneroNetworkInfo from '@/server/jobs/monero-network-info'; +import updateMoneroPoolAttribution from '@/server/jobs/monero-pool-attribution'; +import updateMoneroPoolStats from '@/server/jobs/monero-pool-stats'; import updateNodesCommissions from '@/server/jobs/update-nodes-commissions'; import updateNodesRewards from '@/server/jobs/update-nodes-rewards'; import updateNodesVotes from '@/server/jobs/update-nodes-votes'; @@ -213,6 +216,15 @@ async function runTask() { case 'update-proposal-texts': await updateProposalTexts(); break; + case 'monero-network-info': + await updateMoneroNetworkInfo(); + break; + case 'monero-pool-attribution': + await updateMoneroPoolAttribution(); + break; + case 'monero-pool-stats': + await updateMoneroPoolStats(); + break; default: throw new Error(`Unknown task: ${taskName}`); } diff --git a/server/tools/chains/chain-indexer.ts b/server/tools/chains/chain-indexer.ts index 516f83d0..d4918c90 100755 --- a/server/tools/chains/chain-indexer.ts +++ b/server/tools/chains/chain-indexer.ts @@ -71,6 +71,8 @@ export interface AddChainProps { tags?: string[]; telegramUrl?: string; discordInviteCode?: string; + consensusType?: string; + hashrateUnit?: string; } export type ResultProposalItem = Omit; diff --git a/server/tools/chains/methods.ts b/server/tools/chains/methods.ts index a653f1dc..1148e7d3 100644 --- a/server/tools/chains/methods.ts +++ b/server/tools/chains/methods.ts @@ -21,6 +21,7 @@ import symphonyChainMethods from '@/server/tools/chains/symphony-testnet/methods import aztecChainMethods from '@/server/tools/chains/aztec/methods'; import logosTestnetChainMethods from '@/server/tools/chains/logos-testnet/methods'; import midenTestnetChainMethods from '@/server/tools/chains/miden-testnet/methods'; +import moneroChainMethods from '@/server/tools/chains/monero/methods'; const chainMethods: Record = { namada: namadaChainMethods, @@ -52,6 +53,7 @@ const chainMethods: Record = { polkadot: polkadotChainMethods, ethereum: ethereumChainMethods, aztec: aztecChainMethods, + monero: moneroChainMethods, 'namada-testnet': namadaChainMethods, 'neutron-testnet': neutronChainMethods, diff --git a/server/tools/chains/monero/AGENTS.md b/server/tools/chains/monero/AGENTS.md index e6b2a9cd..e8df5bcb 100644 --- a/server/tools/chains/monero/AGENTS.md +++ b/server/tools/chains/monero/AGENTS.md @@ -2,64 +2,72 @@ ## Purpose -Интеграция Monero (PoW, mainnet) в ValidatorInfo. Ecosystem: `monero`. `hasValidators: false` — у Monero нет stake/validators по дизайну (PoW). Цель — отображать сетевые метрики (height, hashrate, difficulty, supply) и активность mining pools. +Monero (PoW, mainnet) integration. Ecosystem `monero`, `hasValidators: false` — +no stake/validators by design. Surfaces network metrics (height, hashrate, +difficulty, supply) and **mining-pool share analytics**. -## Why this module is small +> Authoritative design: `docs/plans/2026-06-19-monero-pow-redesign-design.md`. +> The older `2026-04-29-monero-integration-*` docs are superseded. -PoW chain. Стандартная Cosmos-shape `ChainMethods` (validators, staking params, slashing, governance, votes, rewards) неприменима — нечего возвращать. Реализация = network-info job + помощник идентификации pools. Все validator/staking методы — null/empty stubs. +## Architecture (single-source on the indexer) -## Data sources +VI talks to **one** infra dependency for chain data — the deployed indexer at +`MONERO_INDEXER_BASE_URL` (`/api/v1/*`, Bearer `MONERO_INDEXER_API_TOKEN`) — plus +the pools' own public APIs for attribution. **There is no direct monerod RPC** +(`rpc-client.ts` was removed); supply, blocks and difficulty all come from the +indexer. -- **Self-hosted Monero RPC** (`https://rpc.monero.citizenweb3.com/json_rpc`) - - Auth: `Authorization: Bearer ${MONERO_RPC_TOKEN}` (env) - - Single-tenant — нет client-side rate-limit; burst protection — задача демона - - **Особенность**: `get_coinbase_tx_sum(0, tipHeight)` обходит весь chain (~3.6M блоков); сервер enforces ~180s rate-limit на этот метод - - Methods: `get_info`, `get_last_block_header`, `get_block_header_by_height`, `get_block`, `get_coinbase_tx_sum` -- **citizenweb3 indexer** (`indexer-client.ts`) — для tx-метрик и pool activity (отдельный endpoint, тоже Bearer auth) -- **Pool discovery**: `pool-apis.json` — статический реестр публичных pool API (XMRPool, MineXMR, etc.) для `identify-pool.ts` - -URL'ы — в `params.ts` через `nodes` массив. Токен — `MONERO_RPC_TOKEN` env. +**Pool identification does NOT use coinbase `tx_extra`.** That signal is dead on +modern blocks (verified: 0 ASCII/residue across hundreds of blocks, incl. pools' +own claimed blocks). Instead: poll each pool's API for the blocks IT found, match +by hash against the indexer's canonical set, and count per-pool share. VI does +**not** consume `coinbase_extra_hex` at all (design decision 11). ## Files | File | Purpose | |---|---| -| `constants.ts` | `MONERO_BLOCK_TIME_SECONDS = 120` (target block interval, для расчёта hashrate = difficulty / 120s) | -| `rpc-client.ts` | JSON-RPC client с retry (3 попытки, backoff 250/500/1000ms) и AbortController-таймаутами. `TIMEOUT_MS = 240_000` дефолт; `COINBASE_TX_SUM_TIMEOUT_MS = 240_000` для тяжёлого метода. Retry на network/AbortError/HTTP 5xx; 4xx и JSON-RPC errors — non-retryable | -| `indexer-client.ts` | Клиент к citizenweb3 indexer для tx-метрик (если включается в methods.ts через `nullTxMetrics` — то stubbed) | -| `pool-apis.json` | Реестр публичных Monero mining pool APIs (URL + match-pattern) | -| `identify-pool.ts` | Резолв coinbase tx `extra` / minerTxHash в pool id по `pool-apis.json` | -| `methods.ts` | `ChainMethods`: spread `nullTxMetrics` + 22 null/empty stubs (никаких validators/staking/governance) | +| `constants.ts` | `MONERO_BLOCK_TIME_SECONDS = 120`, `MONERO_INDEXER_PAGE_SIZE = 1000`, `MONERO_BACKFILL_START_HEIGHT` | +| `indexer-client.ts` | Typed client for `/api/v1/*`: `listMoneroBlocks({limit,offset,order})` (→ `{items,hasMore,nextOffset}`), `getMoneroBlock`, `getTipBlock` (first canonical), `listMoneroSupply`/`getLatestSupply`, `getHealth`, `parseDifficultyHex` (→ `bigint \| null`, never Number). `{data,pagination}` envelope, snake→camel DTO, retry/timeout | +| `pool-parse.ts` | Pure parsers (unit-tested): `parseCryptonoteBlocks`, `parseNanopoolBlocks`, `parseObserverBlocks` → `{height,hash,timestamp(s)}`; throws on all-unparseable (no silent truncation) | +| `pool-client.ts` | `getPoolRegistry`, `fetchPoolBlocks` (dispatch by type, throws→caller isolates), `fetchPoolStats` (best-effort, type-aware), `sourceForPool` | +| `pool-apis.json` | Registry of **end-to-end-verified** pools (v1: supportxmr, moneroocean, hashvault, c3pool, nanopool, p2pool). Each verified via Node `fetch` + hash↔indexer cross-check | +| `attribution-source.ts` | Shared `AttributionSource`/`IdentificationMethod` unions + `UNKNOWN_POOL_SLUG/NAME` | +| `methods.ts` | `ChainMethods`: `...nullTxMetrics` + null/empty stubs (no validators/staking/governance) | +| `__fixtures__/` | Real captured API responses + `dto.check.ts` (`npx tsx …/dto.check.ts`) | + +## Indexer jobs (`server/jobs/`) + +| Job | Schedule | What it does | +|---|---|---| +| `monero-network-info` | hourly | Tip-block `difficulty/120n` (BigInt) → `ChainHashrateSnapshot`; latest `/supply` `cumulative_emission_atomic` (RAW ATOMIC, **emission-only**, no `/1e12`, no `+fee`) → `Tokenomics.totalSupply`. Guards: skip on null difficulty, height floor (`< 1M` = ordering artifact), `abs(tip − node_height)` band, supply monotonic | +| `monero-pool-attribution` | every 10 min | Poll each registry pool + p2pool.observer (isolated) → batch-confirm hashes against the indexer canonical set → upsert `MoneroBlockAttribution`. Conflicting claims on one hash → `isConflicted=true` (kept, excluded from named counts). Bounded two-way reorg re-verify. Batched DB writes | +| `monero-pool-stats` | hourly | Per window {24h,7d,30d,all}: `networkBlocks = tipHeight − lowerHeight + 1` (EXACT — heights contiguous), `poolBlocks` = canonical, non-conflicted attributions in the same height range; `share`, window-avg `hashrateEstimate` (`'0'` for all). Unknown/solo = clamped residual. Upserts every named pool each run | -## Indexer jobs (server/jobs/) +Deleted (do NOT re-add): `monero-pool-discover`, `monero-pool-cluster`, +`monero-pool-identify`, `rpc-client.ts`, `identify-pool.ts` — all coinbase- +fingerprint machinery, proven non-viable. -| Job | Schedule | What it writes | -|---|---|---| -| `monero-network-info` | every 5 min | `ChainHashrateSnapshot` (height, hashrate, difficulty по минуте) + `Tokenomics.totalSupply` (через `get_coinbase_tx_sum(0, tip)`) | -| `monero-pool-discover` | периодически | Сканирует coinbase outputs против `pool-apis.json` → находит новые pools, регистрирует в DB | -| `monero-pool-identify` | per block / batch | Резолв конкретного блока (coinbase / minerTxHash) → pool id. Дёргается из `identify-pool.ts` | -| `monero-pool-stats` | rolling window | Агрегирует per-pool block count + hashrate share за окно | -| `monero-pool-cluster` | реже | Кластеризует pools в operator-группы по общим payout-адресам (multi-pool операторы) | +## Data model -Failure policy: outer try/catch — любая ошибка swallowed + logged; следующий тик cron = natural retry. Snapshot пишется ДО supply update — даже если `get_coinbase_tx_sum` упал, hashrate-метрики уже в DB. +`ChainHashrateSnapshot` (hashrate/difficulty time series), `Tokenomics.totalSupply` +(raw atomic, emission-only — UI divides by `10^coinDecimals`=12), `MiningPool`, +`MiningPoolStats` (windowed share), `MoneroBlockAttribution` (per-block +hash→pool, `isCanonical`/`isConflicted`). UI reads these via `monero-service.ts`. ## Constraints -- НЕ заполнять `Validator` table — у Monero нет валидаторов (PoW). -- НЕ дёргать публичные `xmr.io` / community RPC без auth — наш self-hosted single-tenant даёт стабильный доступ. -- `get_coinbase_tx_sum(0, N)` — медленный (≥180с на full chain), демон ограничивает rate-limit. НЕ вызывать в hot-path; только из cron job с budgeted timeout. -- `totalSupply` хранится в `Tokenomics` (не `ChainParams`) — schema-specific. `dbChain` ищется по `name: 'monero'`, не по `chainId`. -- `snapshotAt` округляется до минуты — для idempotent upsert по `[chainId, snapshotAt]` unique index. +- Never fill `Validator` (no validators — PoW). +- `totalSupply` is **raw atomic piconero, emission-only** — never add fees, never + pre-divide. FDV/UI divide by `10^coinDecimals`. +- Difficulty must be parsed with `BigInt` (cumulative exceeds 2^53), never Number. +- Pool registry holds only Node-`fetch`-verified endpoints (a `curl` 200 is not + enough — some pools are Cloudflare-TLS-blocked to undici). ## Testing -- Прямой curl (см. `MONERO_RPC_TOKEN` в `.env`): - ```bash - curl -X POST https://rpc.monero.citizenweb3.com/json_rpc \ - -H "Authorization: Bearer $MONERO_RPC_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","id":"0","method":"get_info"}' \ - --max-time 240 - ``` -- Запуск job вручную в indexer-контейнере: см. `validatorinfo-indexer-testing` skill (workflow для monero-network-info). -- DB checks: `chain_hashrate_snapshots WHERE chain_id = (SELECT id FROM chains WHERE name = 'monero')`, `tokenomics WHERE chain_id = ...`. +- DTO/parser check: `npx tsx server/tools/chains/monero/__fixtures__/dto.check.ts` +- Indexer smoke: `GET {MONERO_INDEXER_BASE_URL}/api/v1/blocks?order=desc&limit=1` (Bearer) +- DB checks: `chain_hashrate_snapshots`, `tokenomics`, `monero_block_attribution`, + `mining_pool_stats` `WHERE chain_id = (SELECT id FROM chains WHERE name='monero')` +- Run a job manually: `validatorinfo-indexer-testing` skill. diff --git a/server/tools/chains/monero/__fixtures__/block-detail.sample.json b/server/tools/chains/monero/__fixtures__/block-detail.sample.json new file mode 100644 index 00000000..42c9afdd --- /dev/null +++ b/server/tools/chains/monero/__fixtures__/block-detail.sample.json @@ -0,0 +1,861 @@ +{ + "hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "prev_hash": "fb6786507fff9b806371455f255a355d144361de973e9600b4c76c5484083d76", + "height": 3699644, + "timestamp": 1781862189, + "major_version": 16, + "minor_version": 16, + "nonce": 19026, + "block_size": 70297, + "block_weight": 70297, + "long_term_weight": 176470, + "num_txes": 23, + "miner_tx_hash": "63f14bf356841180248c038a33064a3ac234292278030c342670b038e1127474", + "reward_atomic": "602233660000", + "difficulty_hex": "0x9c8bb76226", + "cumulative_difficulty_hex": "0x8ffd8ae5558c59a", + "coinbase_extra_hex": "01d248c75cfddf6be5c6913fbf19ffba72a92fb6bab6616e737c31991a99c5049a0212000f427a466636436e760000000000000000", + "orphan_status": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "transactions": [ + { + "hash": "08ed3b9cad1811a2ea907e7db303ba3f2c832fae4fa580e229aa556caefbbe43", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 0, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "492480000", + "size": 1539, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "492480000" + } + }, + { + "hash": "2c8584dd646570bab4f4981c5a05e5a5a94873467dac1f7259782e3e5f390d5f", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 1, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "122640000", + "size": 1533, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "122640000" + } + }, + { + "hash": "6cebce0510cf442417a2161b744666671556790e62a74c3152a386c47abe6684", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 2, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "122880000", + "size": 1536, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "122880000" + } + }, + { + "hash": "51008151db7c61e8f9807e3150c39ca28574631701ec641cefd9f32a744062f1", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 3, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "122880000", + "size": 1536, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "122880000" + } + }, + { + "hash": "f50c4c75560f37d0f5447886b4407efb1001e4408ccef7879375b17c660fce11", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 4, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "122480000", + "size": 1531, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "122480000" + } + }, + { + "hash": "539a8f054b922a2eaf94dbce079387418b1475e65fd6fd7874988af8f5be0b18", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 5, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 12, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "181760000", + "size": 9088, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 12, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "181760000" + } + }, + { + "hash": "9e47dbfa46fa75d697f400ff9f7f5b5806db511a4f891f7211a0f950b68b1255", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 6, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 14, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "209320000", + "size": 10466, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 14, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "209320000" + } + }, + { + "hash": "9496815a006d12ceccb63d162c9494f9df55e3a68b5bc70861e7aac0577ccf45", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 7, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "30700000", + "size": 1535, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "30700000" + } + }, + { + "hash": "9656023d3a58e4453762b8a8fe2eade74fa0d8621db565ff6dee04e2c35b3485", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 8, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 2, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "44500000", + "size": 2225, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 2, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "44500000" + } + }, + { + "hash": "e4566953af0ac0715ba0b4ab88c3904fe7b683add2ec3c8dc995176b3f648158", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 9, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "30620000", + "size": 1531, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "30620000" + } + }, + { + "hash": "225491a1c5f42ffb7b6df8553ff1a1cab98396f5f5e411409b986f53d481a841", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 10, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 2, + "outputs_count": 11, + "extra_size": 387, + "fee_atomic": "137080000", + "size": 3424, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 2, + "outputs_count": 11, + "extra_length": 387, + "fee_atomic": "137080000" + } + }, + { + "hash": "3c641857a0fc6598b89b0a61e1bc7593f7ae0f43087a3074f3a492449c9aa4ac", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 11, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 2, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "44500000", + "size": 2225, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 2, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "44500000" + } + }, + { + "hash": "b16b28b75ac5a5d5c87baf2ef243a525032f8f1fb95e06b05912b184c6e0e438", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 12, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 16, + "extra_size": 547, + "fee_atomic": "134140000", + "size": 3277, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 16, + "extra_length": 547, + "fee_atomic": "134140000" + } + }, + { + "hash": "ac698d90d8bdc1423dc3c50b8ad35874ee3167c7c2c89705a1abb4bcd0556c82", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 13, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 16, + "extra_size": 547, + "fee_atomic": "134220000", + "size": 3281, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 16, + "extra_length": 547, + "fee_atomic": "134220000" + } + }, + { + "hash": "ef8e47bd20fa0b479c0f76e1073f7bbee9b6e145118081bf4146948ebf04e6fb", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 14, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "30700000", + "size": 1535, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "30700000" + } + }, + { + "hash": "2b2d821c39e413791918687899674fc901c4a3e23d3061af33fd2e2f1d0273b0", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 15, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 2, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "44480000", + "size": 2224, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 2, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "44480000" + } + }, + { + "hash": "e90809b4ab803f0e4bdbb5d6df14af250190290aa686019ce8a7e006f38fb388", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 16, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "30640000", + "size": 1532, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "30640000" + } + }, + { + "hash": "8728a43c881617a96851f60eebfe9d566dc33f647cd88ec37ba54e2d756d48d2", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 17, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "30640000", + "size": 1532, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "30640000" + } + }, + { + "hash": "ce14f13e702c62ee9fec704fe020ebd973b80d2286412ccb4ba0745076635b43", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 18, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "30680000", + "size": 1534, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "30680000" + } + }, + { + "hash": "bfa68eec7bf4634208a05a686aa6e54000e1b0c5f9d37645f0ed568c279b4be1", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 19, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "30660000", + "size": 1533, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "30660000" + } + }, + { + "hash": "4762fb18e36ff90039337f46147365701ab22ea4d81d1a9c22b421f6d7950577", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 20, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "30600000", + "size": 1530, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "30600000" + } + }, + { + "hash": "6b1395a36d363b9f5781d37fac1577265177b18c6e93d953a00023ff74733bd7", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 21, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 2, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "44420000", + "size": 2221, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 2, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "44420000" + } + }, + { + "hash": "afe1639863978907e1b23184a056f8bf4bdea80a09603d06d5070a6912cb0f0b", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 22, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "30640000", + "size": 1532, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "30640000" + } + } + ], + "raw": { + "blob": "1010ad9ed4d106fb6786507fff9b806371455f255a355d144361de973e9600b4c76c5484083d76524a000002f8e7e10101ffbce7e10101e0c4b1bfc311034d276f1e8452380045c1fdae9166a5145717e1edd0e74dc20e0961136446ea1a9a3501d248c75cfddf6be5c6913fbf19ffba72a92fb6bab6616e737c31991a99c5049a0212000f427a466636436e760000000000000000001708ed3b9cad1811a2ea907e7db303ba3f2c832fae4fa580e229aa556caefbbe432c8584dd646570bab4f4981c5a05e5a5a94873467dac1f7259782e3e5f390d5f6cebce0510cf442417a2161b744666671556790e62a74c3152a386c47abe668451008151db7c61e8f9807e3150c39ca28574631701ec641cefd9f32a744062f1f50c4c75560f37d0f5447886b4407efb1001e4408ccef7879375b17c660fce11539a8f054b922a2eaf94dbce079387418b1475e65fd6fd7874988af8f5be0b189e47dbfa46fa75d697f400ff9f7f5b5806db511a4f891f7211a0f950b68b12559496815a006d12ceccb63d162c9494f9df55e3a68b5bc70861e7aac0577ccf459656023d3a58e4453762b8a8fe2eade74fa0d8621db565ff6dee04e2c35b3485e4566953af0ac0715ba0b4ab88c3904fe7b683add2ec3c8dc995176b3f648158225491a1c5f42ffb7b6df8553ff1a1cab98396f5f5e411409b986f53d481a8413c641857a0fc6598b89b0a61e1bc7593f7ae0f43087a3074f3a492449c9aa4acb16b28b75ac5a5d5c87baf2ef243a525032f8f1fb95e06b05912b184c6e0e438ac698d90d8bdc1423dc3c50b8ad35874ee3167c7c2c89705a1abb4bcd0556c82ef8e47bd20fa0b479c0f76e1073f7bbee9b6e145118081bf4146948ebf04e6fb2b2d821c39e413791918687899674fc901c4a3e23d3061af33fd2e2f1d0273b0e90809b4ab803f0e4bdbb5d6df14af250190290aa686019ce8a7e006f38fb3888728a43c881617a96851f60eebfe9d566dc33f647cd88ec37ba54e2d756d48d2ce14f13e702c62ee9fec704fe020ebd973b80d2286412ccb4ba0745076635b43bfa68eec7bf4634208a05a686aa6e54000e1b0c5f9d37645f0ed568c279b4be14762fb18e36ff90039337f46147365701ab22ea4d81d1a9c22b421f6d79505776b1395a36d363b9f5781d37fac1577265177b18c6e93d953a00023ff74733bd7afe1639863978907e1b23184a056f8bf4bdea80a09603d06d5070a6912cb0f0b", + "json": "{\n \"major_version\": 16, \n \"minor_version\": 16, \n \"timestamp\": 1781862189, \n \"prev_id\": \"fb6786507fff9b806371455f255a355d144361de973e9600b4c76c5484083d76\", \n \"nonce\": 19026, \n \"miner_tx\": {\n \"version\": 2, \n \"unlock_time\": 3699704, \n \"vin\": [ {\n \"gen\": {\n \"height\": 3699644\n }\n }\n ], \n \"vout\": [ {\n \"amount\": 602233660000, \n \"target\": {\n \"tagged_key\": {\n \"key\": \"4d276f1e8452380045c1fdae9166a5145717e1edd0e74dc20e0961136446ea1a\", \n \"view_tag\": \"9a\"\n }\n }\n }\n ], \n \"extra\": [ 1, 210, 72, 199, 92, 253, 223, 107, 229, 198, 145, 63, 191, 25, 255, 186, 114, 169, 47, 182, 186, 182, 97, 110, 115, 124, 49, 153, 26, 153, 197, 4, 154, 2, 18, 0, 15, 66, 122, 70, 102, 54, 67, 110, 118, 0, 0, 0, 0, 0, 0, 0, 0\n ], \n \"rct_signatures\": {\n \"type\": 0\n }\n }, \n \"tx_hashes\": [ \"08ed3b9cad1811a2ea907e7db303ba3f2c832fae4fa580e229aa556caefbbe43\", \"2c8584dd646570bab4f4981c5a05e5a5a94873467dac1f7259782e3e5f390d5f\", \"6cebce0510cf442417a2161b744666671556790e62a74c3152a386c47abe6684\", \"51008151db7c61e8f9807e3150c39ca28574631701ec641cefd9f32a744062f1\", \"f50c4c75560f37d0f5447886b4407efb1001e4408ccef7879375b17c660fce11\", \"539a8f054b922a2eaf94dbce079387418b1475e65fd6fd7874988af8f5be0b18\", \"9e47dbfa46fa75d697f400ff9f7f5b5806db511a4f891f7211a0f950b68b1255\", \"9496815a006d12ceccb63d162c9494f9df55e3a68b5bc70861e7aac0577ccf45\", \"9656023d3a58e4453762b8a8fe2eade74fa0d8621db565ff6dee04e2c35b3485\", \"e4566953af0ac0715ba0b4ab88c3904fe7b683add2ec3c8dc995176b3f648158\", \"225491a1c5f42ffb7b6df8553ff1a1cab98396f5f5e411409b986f53d481a841\", \"3c641857a0fc6598b89b0a61e1bc7593f7ae0f43087a3074f3a492449c9aa4ac\", \"b16b28b75ac5a5d5c87baf2ef243a525032f8f1fb95e06b05912b184c6e0e438\", \"ac698d90d8bdc1423dc3c50b8ad35874ee3167c7c2c89705a1abb4bcd0556c82\", \"ef8e47bd20fa0b479c0f76e1073f7bbee9b6e145118081bf4146948ebf04e6fb\", \"2b2d821c39e413791918687899674fc901c4a3e23d3061af33fd2e2f1d0273b0\", \"e90809b4ab803f0e4bdbb5d6df14af250190290aa686019ce8a7e006f38fb388\", \"8728a43c881617a96851f60eebfe9d566dc33f647cd88ec37ba54e2d756d48d2\", \"ce14f13e702c62ee9fec704fe020ebd973b80d2286412ccb4ba0745076635b43\", \"bfa68eec7bf4634208a05a686aa6e54000e1b0c5f9d37645f0ed568c279b4be1\", \"4762fb18e36ff90039337f46147365701ab22ea4d81d1a9c22b421f6d7950577\", \"6b1395a36d363b9f5781d37fac1577265177b18c6e93d953a00023ff74733bd7\", \"afe1639863978907e1b23184a056f8bf4bdea80a09603d06d5070a6912cb0f0b\"\n ]\n}", + "status": "OK", + "credits": 0, + "top_hash": "", + "tx_hashes": [ + "08ed3b9cad1811a2ea907e7db303ba3f2c832fae4fa580e229aa556caefbbe43", + "2c8584dd646570bab4f4981c5a05e5a5a94873467dac1f7259782e3e5f390d5f", + "6cebce0510cf442417a2161b744666671556790e62a74c3152a386c47abe6684", + "51008151db7c61e8f9807e3150c39ca28574631701ec641cefd9f32a744062f1", + "f50c4c75560f37d0f5447886b4407efb1001e4408ccef7879375b17c660fce11", + "539a8f054b922a2eaf94dbce079387418b1475e65fd6fd7874988af8f5be0b18", + "9e47dbfa46fa75d697f400ff9f7f5b5806db511a4f891f7211a0f950b68b1255", + "9496815a006d12ceccb63d162c9494f9df55e3a68b5bc70861e7aac0577ccf45", + "9656023d3a58e4453762b8a8fe2eade74fa0d8621db565ff6dee04e2c35b3485", + "e4566953af0ac0715ba0b4ab88c3904fe7b683add2ec3c8dc995176b3f648158", + "225491a1c5f42ffb7b6df8553ff1a1cab98396f5f5e411409b986f53d481a841", + "3c641857a0fc6598b89b0a61e1bc7593f7ae0f43087a3074f3a492449c9aa4ac", + "b16b28b75ac5a5d5c87baf2ef243a525032f8f1fb95e06b05912b184c6e0e438", + "ac698d90d8bdc1423dc3c50b8ad35874ee3167c7c2c89705a1abb4bcd0556c82", + "ef8e47bd20fa0b479c0f76e1073f7bbee9b6e145118081bf4146948ebf04e6fb", + "2b2d821c39e413791918687899674fc901c4a3e23d3061af33fd2e2f1d0273b0", + "e90809b4ab803f0e4bdbb5d6df14af250190290aa686019ce8a7e006f38fb388", + "8728a43c881617a96851f60eebfe9d566dc33f647cd88ec37ba54e2d756d48d2", + "ce14f13e702c62ee9fec704fe020ebd973b80d2286412ccb4ba0745076635b43", + "bfa68eec7bf4634208a05a686aa6e54000e1b0c5f9d37645f0ed568c279b4be1", + "4762fb18e36ff90039337f46147365701ab22ea4d81d1a9c22b421f6d7950577", + "6b1395a36d363b9f5781d37fac1577265177b18c6e93d953a00023ff74733bd7", + "afe1639863978907e1b23184a056f8bf4bdea80a09603d06d5070a6912cb0f0b" + ], + "untrusted": false, + "parsed_json": { + "nonce": 19026, + "prev_id": "fb6786507fff9b806371455f255a355d144361de973e9600b4c76c5484083d76", + "miner_tx": { + "vin": [ + { + "gen": { + "height": 3699644 + } + } + ], + "vout": [ + { + "amount": 602233660000, + "target": { + "tagged_key": { + "key": "4d276f1e8452380045c1fdae9166a5145717e1edd0e74dc20e0961136446ea1a", + "view_tag": "9a" + } + } + } + ], + "extra": [ + 1, + 210, + 72, + 199, + 92, + 253, + 223, + 107, + 229, + 198, + 145, + 63, + 191, + 25, + 255, + 186, + 114, + 169, + 47, + 182, + 186, + 182, + 97, + 110, + 115, + 124, + 49, + 153, + 26, + 153, + 197, + 4, + 154, + 2, + 18, + 0, + 15, + 66, + 122, + 70, + 102, + 54, + 67, + 110, + 118, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "version": 2, + "unlock_time": 3699704, + "rct_signatures": { + "type": 0 + } + }, + "timestamp": 1781862189, + "tx_hashes": [ + "08ed3b9cad1811a2ea907e7db303ba3f2c832fae4fa580e229aa556caefbbe43", + "2c8584dd646570bab4f4981c5a05e5a5a94873467dac1f7259782e3e5f390d5f", + "6cebce0510cf442417a2161b744666671556790e62a74c3152a386c47abe6684", + "51008151db7c61e8f9807e3150c39ca28574631701ec641cefd9f32a744062f1", + "f50c4c75560f37d0f5447886b4407efb1001e4408ccef7879375b17c660fce11", + "539a8f054b922a2eaf94dbce079387418b1475e65fd6fd7874988af8f5be0b18", + "9e47dbfa46fa75d697f400ff9f7f5b5806db511a4f891f7211a0f950b68b1255", + "9496815a006d12ceccb63d162c9494f9df55e3a68b5bc70861e7aac0577ccf45", + "9656023d3a58e4453762b8a8fe2eade74fa0d8621db565ff6dee04e2c35b3485", + "e4566953af0ac0715ba0b4ab88c3904fe7b683add2ec3c8dc995176b3f648158", + "225491a1c5f42ffb7b6df8553ff1a1cab98396f5f5e411409b986f53d481a841", + "3c641857a0fc6598b89b0a61e1bc7593f7ae0f43087a3074f3a492449c9aa4ac", + "b16b28b75ac5a5d5c87baf2ef243a525032f8f1fb95e06b05912b184c6e0e438", + "ac698d90d8bdc1423dc3c50b8ad35874ee3167c7c2c89705a1abb4bcd0556c82", + "ef8e47bd20fa0b479c0f76e1073f7bbee9b6e145118081bf4146948ebf04e6fb", + "2b2d821c39e413791918687899674fc901c4a3e23d3061af33fd2e2f1d0273b0", + "e90809b4ab803f0e4bdbb5d6df14af250190290aa686019ce8a7e006f38fb388", + "8728a43c881617a96851f60eebfe9d566dc33f647cd88ec37ba54e2d756d48d2", + "ce14f13e702c62ee9fec704fe020ebd973b80d2286412ccb4ba0745076635b43", + "bfa68eec7bf4634208a05a686aa6e54000e1b0c5f9d37645f0ed568c279b4be1", + "4762fb18e36ff90039337f46147365701ab22ea4d81d1a9c22b421f6d7950577", + "6b1395a36d363b9f5781d37fac1577265177b18c6e93d953a00023ff74733bd7", + "afe1639863978907e1b23184a056f8bf4bdea80a09603d06d5070a6912cb0f0b" + ], + "major_version": 16, + "minor_version": 16 + }, + "block_header": { + "hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "depth": 0, + "nonce": 19026, + "height": 3699644, + "reward": 602233660000, + "num_txes": 23, + "pow_hash": "", + "prev_hash": "fb6786507fff9b806371455f255a355d144361de973e9600b4c76c5484083d76", + "timestamp": 1781862189, + "block_size": 70297, + "difficulty": 672358949414, + "block_weight": 70297, + "major_version": 16, + "miner_tx_hash": "63f14bf356841180248c038a33064a3ac234292278030c342670b038e1127474", + "minor_version": 16, + "orphan_status": false, + "wide_difficulty": "0x9c8bb76226", + "difficulty_top64": 0, + "long_term_weight": 176470, + "cumulative_difficulty": "648475114632431002", + "wide_cumulative_difficulty": "0x8ffd8ae5558c59a", + "cumulative_difficulty_top64": 0 + }, + "miner_tx_hash": "63f14bf356841180248c038a33064a3ac234292278030c342670b038e1127474" + } +} diff --git a/server/tools/chains/monero/__fixtures__/blocks.sample.json b/server/tools/chains/monero/__fixtures__/blocks.sample.json new file mode 100644 index 00000000..bb54ce82 --- /dev/null +++ b/server/tools/chains/monero/__fixtures__/blocks.sample.json @@ -0,0 +1,54 @@ +{ + "data": [ + { + "hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "prev_hash": "fb6786507fff9b806371455f255a355d144361de973e9600b4c76c5484083d76", + "height": 3699644, + "timestamp": 1781862189, + "major_version": 16, + "minor_version": 16, + "nonce": 19026, + "block_size": 70297, + "block_weight": 70297, + "long_term_weight": 176470, + "num_txes": 23, + "miner_tx_hash": "63f14bf356841180248c038a33064a3ac234292278030c342670b038e1127474", + "reward_atomic": "602233660000", + "difficulty_hex": "0x9c8bb76226", + "cumulative_difficulty_hex": "0x8ffd8ae5558c59a", + "coinbase_extra_hex": "01d248c75cfddf6be5c6913fbf19ffba72a92fb6bab6616e737c31991a99c5049a0212000f427a466636436e760000000000000000", + "orphan_status": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z" + }, + { + "hash": "fb6786507fff9b806371455f255a355d144361de973e9600b4c76c5484083d76", + "prev_hash": "dded6aecd670db45aa55af4926a0a93b0bbe14800282b75521197d88181e154a", + "height": 3699643, + "timestamp": 1781862170, + "major_version": 16, + "minor_version": 16, + "nonce": 420098601, + "block_size": 301896, + "block_weight": 301896, + "long_term_weight": 301896, + "num_txes": 147, + "miner_tx_hash": "13ae2fca34ea2d465d96d1bfbbef51acbe34369a5b9c846c91c60a2d1d848831", + "reward_atomic": "637658674560", + "difficulty_hex": "0x9bce8ff073", + "cumulative_difficulty_hex": "0x8ffd811c9a16374", + "coinbase_extra_hex": "032100d33fa06dbb6e2d92a70371bd40166a6f348739b28732c2d9ed95040555cb0b2e0180cfb6a658867d313602f8b144293762f76947ca8833885fc3d93b6ba378fea8022000000000000000006f7329645600000000000000000000000000000000000000", + "orphan_status": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:16.529Z" + } + ], + "pagination": { + "limit": 2, + "offset": 0, + "order": "desc", + "has_more": true + } +} diff --git a/server/tools/chains/monero/__fixtures__/dto.check.ts b/server/tools/chains/monero/__fixtures__/dto.check.ts new file mode 100644 index 00000000..972fadff --- /dev/null +++ b/server/tools/chains/monero/__fixtures__/dto.check.ts @@ -0,0 +1,97 @@ +/** + * Lightweight DTO verification for the Monero indexer client (design §9 stage 2). + * No test framework in this project — run standalone from the repo root: + * + * npx tsx server/tools/chains/monero/__fixtures__/dto.check.ts + * + * Verifies the real captured fixture against the client's parsing: envelope + * shape, snake_case wire fields, and BigInt difficulty with no precision loss. + */ +import assert from 'node:assert'; +import { readFileSync } from 'node:fs'; + +import { parseDifficultyHex, toBlock, toSupply } from '../indexer-client'; +import { parseCryptonoteBlocks, parseNanopoolBlocks, parseObserverBlocks } from '../pool-parse'; + +const FIX = 'server/tools/chains/monero/__fixtures__/blocks.sample.json'; +const env = JSON.parse(readFileSync(FIX, 'utf8')) as { + data: Array>; + pagination: { limit: number; offset: number; order: string; has_more: boolean }; +}; + +// 1. Envelope shape +assert.ok(Array.isArray(env.data) && env.data.length > 0, 'envelope.data is a non-empty array'); +assert.ok(env.pagination && typeof env.pagination.has_more === 'boolean', 'pagination.has_more present'); +assert.ok(typeof env.pagination.offset === 'number', 'pagination.offset present (offset paging)'); + +// 2. snake_case wire fields the DTO maps from +const b = env.data[0]; +for (const k of [ + 'hash', 'prev_hash', 'height', 'timestamp', 'major_version', 'block_size', 'block_weight', + 'num_txes', 'miner_tx_hash', 'reward_atomic', 'difficulty_hex', 'is_canonical', 'is_settled', +]) { + assert.ok(k in b, `wire field "${k}" present`); +} + +// 3. BigInt difficulty parsing (no Number coercion) + precision preservation. +// Per-block difficulty (~6e11 today) currently fits in Number, but BigInt is +// correct/future-proof; cumulative_difficulty_hex DOES exceed 2^53 and proves +// the BigInt path preserves precision with no loss. +const hex = String(b.difficulty_hex); +const diff = parseDifficultyHex(hex); +assert.ok(diff !== null, 'valid difficulty parsed (non-null bigint)'); +assert.strictEqual(diff, BigInt(hex.startsWith('0x') ? hex : `0x${hex}`), 'difficulty matches canonical BigInt parse'); + +const cumHex = String(b.cumulative_difficulty_hex); +const cum = parseDifficultyHex(cumHex); +assert.ok(cum !== null && cum > BigInt(Number.MAX_SAFE_INTEGER), 'cumulative difficulty exceeds 2^53'); +assert.strictEqual(cum, BigInt(cumHex.startsWith('0x') ? cumHex : `0x${cumHex}`), 'cumulative round-trips via BigInt (no precision loss)'); + +// invalid/missing → null so the caller SKIPS (never persists a bogus 0 — F1 / §4.1) +assert.strictEqual(parseDifficultyHex(''), null, 'empty difficulty_hex → null'); +assert.strictEqual(parseDifficultyHex(null), null, 'null difficulty_hex → null'); +assert.strictEqual(parseDifficultyHex('0xZZ'), null, 'malformed difficulty_hex → null'); + +// 4. toBlock mapper — camelCase output equals wire values (the §9 DTO acceptance) +const blk = toBlock(b as never); +assert.strictEqual(blk.height, b.height, 'toBlock.height'); +assert.strictEqual(blk.hash, b.hash, 'toBlock.hash'); +assert.strictEqual(blk.prevHash, b.prev_hash, 'toBlock.prevHash←prev_hash'); +assert.strictEqual(blk.txCount, b.num_txes, 'toBlock.txCount←num_txes'); +assert.strictEqual(blk.reward, String(b.reward_atomic), 'toBlock.reward←reward_atomic'); +assert.strictEqual(blk.size, b.block_size, 'toBlock.size←block_size'); +assert.strictEqual(blk.weight, b.block_weight, 'toBlock.weight←block_weight'); +assert.strictEqual(blk.minerTxHash, b.miner_tx_hash, 'toBlock.minerTxHash←miner_tx_hash'); +assert.strictEqual(blk.isCanonical, b.is_canonical, 'toBlock.isCanonical←is_canonical'); +assert.strictEqual(blk.difficulty, diff, 'toBlock.difficulty is BigInt'); + +// 5. toSupply mapper — emission and fee kept SEPARATE (never summed, design §6) +const supplyEnv = JSON.parse( + readFileSync('server/tools/chains/monero/__fixtures__/supply.sample.json', 'utf8'), +) as { data: Array> }; +const s = supplyEnv.data[0]; +const sup = toSupply(s as never); +assert.strictEqual(sup.cumulativeEmissionAtomic, String(s.cumulative_emission_atomic), 'toSupply emission'); +assert.strictEqual(sup.cumulativeFeeAtomic, String(s.cumulative_fee_atomic), 'toSupply fee (separate)'); + +// 6. Pool parsers against real captured responses → { height, hash, timestamp(seconds) } +const sx = parseCryptonoteBlocks( + JSON.parse(readFileSync('server/tools/chains/monero/__fixtures__/pool-cryptonote.supportxmr.json', 'utf8')), +); +assert.ok(sx.length > 0 && sx[0].height > 0 && sx[0].hash.length === 64, 'cryptonote parser → blocks'); +assert.ok(sx[0].timestamp > 1_600_000_000 && sx[0].timestamp < 2_000_000_000, 'cryptonote ts normalized ms→s'); +const p2 = parseObserverBlocks( + JSON.parse(readFileSync('server/tools/chains/monero/__fixtures__/pool-observer.p2pool.json', 'utf8')), +); +assert.ok(p2.length > 0 && p2[0].height > 0 && p2[0].hash.length === 64, 'observer parser → blocks'); + +const np = parseNanopoolBlocks( + JSON.parse(readFileSync('server/tools/chains/monero/__fixtures__/pool-nanopool.json', 'utf8')), +); +assert.ok(np.length > 0 && np[0].height > 0 && np[0].hash.length === 64, 'nanopool parser → blocks (block_number→height)'); +assert.ok(np[0].timestamp > 1_600_000_000 && np[0].timestamp < 2_000_000_000, 'nanopool ts (date, seconds)'); + +console.log( + `DTO check OK — envelope, mappers, BigInt cum=${cum.toString()}, ` + + `pool parsers (cryptonote=${sx.length}, p2pool=${p2.length}, nanopool=${np.length})`, +); diff --git a/server/tools/chains/monero/__fixtures__/pool-cryptonote.gntl.json b/server/tools/chains/monero/__fixtures__/pool-cryptonote.gntl.json new file mode 100644 index 00000000..0b3b8a9c --- /dev/null +++ b/server/tools/chains/monero/__fixtures__/pool-cryptonote.gntl.json @@ -0,0 +1,35 @@ +[ + { + "ts": 1781864191233, + "hash": "001d5b2c4c55c73ea682bd443f2eac7423b4bf2a92fe6a6677b23b0123d4b1fe", + "diff": 79300137, + "shares": 252325410, + "height": 1383574, + "valid": true, + "unlocked": false, + "pool_type": "pplns", + "value": 19607147300 + }, + { + "ts": 1781863808366, + "hash": "ed069f7a707793d366f42fca2eb6a8764625c9b27938b5c266b0b786f1b19536", + "diff": 79000140, + "shares": 18334760, + "height": 1383572, + "valid": true, + "unlocked": false, + "pool_type": "pplns", + "value": 19607184698 + }, + { + "ts": 1781863744417, + "hash": "425eb27a3538ac0188032e94b1de15753644c8dab7d5af897bfb3c7b5b0aaad4", + "diff": 81800117, + "shares": 97963985, + "height": 1383571, + "valid": true, + "unlocked": false, + "pool_type": "pplns", + "value": 19607203397 + } +] diff --git a/server/tools/chains/monero/__fixtures__/pool-cryptonote.supportxmr.json b/server/tools/chains/monero/__fixtures__/pool-cryptonote.supportxmr.json new file mode 100644 index 00000000..ed366592 --- /dev/null +++ b/server/tools/chains/monero/__fixtures__/pool-cryptonote.supportxmr.json @@ -0,0 +1,38 @@ +[ + { + "ts": "1781862188916", + "hash": "fb6786507fff9b806371455f255a355d144361de973e9600b4c76c5484083d76", + "diff": "669185470579", + "shares": "1675992970042", + "height": 3699643, + "valid": true, + "unlocked": false, + "pool_type": "pplns", + "value": "637658674560", + "finder": "Being Implemented" + }, + { + "ts": "1781861382778", + "hash": "6fe30bdc68d3d4e9830366e77839d199a43f75410ad7df116a6eb808d40fd423", + "diff": "669318949902", + "shares": "2443852499636", + "height": 3699640, + "valid": true, + "unlocked": false, + "pool_type": "pplns", + "value": "622586190000", + "finder": "Being Implemented" + }, + { + "ts": "1781860201816", + "hash": "8732aa1683519b574f107c4684b23acdce88754003bf3e3335dd856a0a99e38b", + "diff": "671582364202", + "shares": "429381753993", + "height": 3699633, + "valid": true, + "unlocked": false, + "pool_type": "pplns", + "value": "614816600000", + "finder": "Being Implemented" + } +] diff --git a/server/tools/chains/monero/__fixtures__/pool-nanopool.json b/server/tools/chains/monero/__fixtures__/pool-nanopool.json new file mode 100644 index 00000000..0cf08df1 --- /dev/null +++ b/server/tools/chains/monero/__fixtures__/pool-nanopool.json @@ -0,0 +1,29 @@ +{ + "status": true, + "data": [ + { + "block_number": 3699656, + "hash": "430cd5397f70bac826f27219e518ada9b41938d42c0bcd8f9bd0ac3d6a86c3ab", + "date": 1781863805, + "value": 0.61434902, + "status": 0, + "miner": "89q5DE8bLbC7Aeq8zQeqR42mbYVEJ1Cp9fetDCQHRPZrJU7rHjtJwu1buBghi6nQs7PZHbtG2qC2ECqw4QB9a9vBNzyMiu4" + }, + { + "block_number": 3699655, + "hash": "0f2a8a5b985611b2c729198190384ea7e6e6236178282ac43724f6ce9bf658a6", + "date": 1781863597, + "value": 0.61232234, + "status": 0, + "miner": "48hAR9kExF8ZAc1SbiLuoPZD7M9tMUNAeGsWtVA7FwnRErP2AmZgpbmBH5X9LDdaLQKp4994kaiA5ZEPk53vvgLe93bQtrF" + }, + { + "block_number": 3699645, + "hash": "2297327afa1bc028e2377305fb230fdf7e83f92949c12fde25a8e48dfbb5e896", + "date": 1781862557, + "value": 0.6279935, + "status": 1, + "miner": "88WZKLJjNkbD8jJybJ1ZS8iLjhW4KjnY94Yk3JfqDXA98MT8izc6VL3DbHnSeWmd7kC5XK13Pem4ZRScqCZsVvFN7fVYW4X" + } + ] +} diff --git a/server/tools/chains/monero/__fixtures__/pool-observer.p2pool.json b/server/tools/chains/monero/__fixtures__/pool-observer.p2pool.json new file mode 100644 index 00000000..afbde5fc --- /dev/null +++ b/server/tools/chains/monero/__fixtures__/pool-observer.p2pool.json @@ -0,0 +1,72 @@ +[ + { + "main_block": { + "id": "a1d07de32552c46ec5b3f129aea69bc264c7c170ab6ecce9e8d62a116ad0ed00", + "height": 3699631, + "timestamp": 1781859695, + "reward": 610558820000, + "coinbase_id": "d762a26391b0bdeb2e0b1f3359ab3c7202c780b625babb3f5368136e4cdd7036", + "difficulty": 668593715383, + "side_template_id": "299bd0c12babbbd97e85befd5519a67ffe6946b731eddffc063687aab0ad2e31", + "root_hash": "55d49b49418c590438737c5389628c07de932a337b65c7552c89b195154ae175", + "coinbase_private_key": "80143d56ed5da80f333815108637f34c50fe0f9710c04139ac19f57da996b70c" + }, + "side_height": 14504660, + "miner": 8957, + "effective_height": 14504660, + "window_depth": 540, + "window_outputs": 35, + "transaction_count": 20, + "difficulty": 2470789055, + "cumulative_difficulty": 28576795126711259, + "inclusion": 1, + "miner_address": "47KmpbWYHvG6VyCQcfzXC555Y53ogtTskNYFbBj3m5Ji3uTk48Ug9HP2EGMQa2khnyaD8BekfyTuQC2PSUTknxixFVbH7HL" + }, + { + "main_block": { + "id": "c0190c0876554b1f6bbbaf6084596a8ecc0929129a18ff88945939770f94189f", + "height": 3699624, + "timestamp": 1781858954, + "reward": 609861860000, + "coinbase_id": "c6aa39d57863d86327ca0740a3050d3edde11d2bafd578abe13a896bf82d2018", + "difficulty": 665583039176, + "side_template_id": "020d6d45db80ef7c1e1afaf7e4648d03a07e9d293890916981c49e6438996645", + "root_hash": "8db8552e7cb93d86d844471761efd137e5c43df07587054c2005bacc49871342", + "coinbase_private_key": "40b5633a86167c93b3a56046790308d270e6c5c2ea7a3668b5fc857ea4fea00c" + }, + "side_height": 14504587, + "miner": 3, + "effective_height": 14504587, + "window_depth": 540, + "window_outputs": 36, + "transaction_count": 21, + "difficulty": 2450130820, + "cumulative_difficulty": 28576598205158138, + "inclusion": 1, + "miner_address": "47ab14EokGgCTX7RYoVhrNMjVA7GfW1jyMAmL7qBQz9fa4RZ6ZsBUgeRGuPWjqeM1wLptSJH5xuX2H4mAepMYvu6JqWMsGw", + "miner_alias": "p2pool" + }, + { + "main_block": { + "id": "7b256b368a58489da3ff565117d070423c023f1587cc819ad15730740bacfd23", + "height": 3699603, + "timestamp": 1781857382, + "reward": 601145260000, + "coinbase_id": "32058360287afa5c83744f072b7c427a4ce670cf0544292553dc5661814e242e", + "difficulty": 658752021255, + "side_template_id": "b2f985d442688118b984b2c9035e8ffd1768c12987ce95c50cb650ed3c7b9cfb", + "root_hash": "87fd8c69d344b71ab38d52736dafa8b7045255093f958e0ae8b3d9dc42bd8772", + "coinbase_private_key": "fb72e05b7f719f6948ce941ad0e398f82c5d54a02ef4444e5290611866b5740f" + }, + "side_height": 14504449, + "miner": 10525, + "effective_height": 14504449, + "window_depth": 536, + "window_outputs": 40, + "transaction_count": 11, + "difficulty": 2509161497, + "cumulative_difficulty": 28576244788903235, + "inclusion": 1, + "miner_address": "47LiU29nx8k6Jryp89tG1PcMuvrtGuZfhfAUUUaYxNJ254h5oX9fr3vUuMiHMDLa9QPNThm1Zb9yGPqHVYc2moVsLyAN96N" + } +] diff --git a/server/tools/chains/monero/__fixtures__/supply.sample.json b/server/tools/chains/monero/__fixtures__/supply.sample.json new file mode 100644 index 00000000..c6b04e6a --- /dev/null +++ b/server/tools/chains/monero/__fixtures__/supply.sample.json @@ -0,0 +1,19 @@ +{ + "data": [ + { + "height": 3699441, + "block_hash": "33bd68dcc561f51a87f2eb2faf950c12bd828c1360acf7fd209013c3e82bc156", + "block_timestamp": 1781840841, + "cumulative_emission_atomic": "18766842146869265440", + "cumulative_fee_atomic": "110489014124287421", + "source_method": "rpc:get_coinbase_tx_sum:incremental", + "computed_at": "2026-06-19T06:15:03.232Z" + } + ], + "pagination": { + "limit": 1, + "offset": 0, + "order": "desc", + "has_more": true + } +} diff --git a/server/tools/chains/monero/__fixtures__/transactions.sample.json b/server/tools/chains/monero/__fixtures__/transactions.sample.json new file mode 100644 index 00000000..b05166a0 --- /dev/null +++ b/server/tools/chains/monero/__fixtures__/transactions.sample.json @@ -0,0 +1,68 @@ +{ + "data": [ + { + "hash": "afe1639863978907e1b23184a056f8bf4bdea80a09603d06d5070a6912cb0f0b", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 22, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "30640000", + "size": 1532, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 1, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "30640000" + } + }, + { + "hash": "6b1395a36d363b9f5781d37fac1577265177b18c6e93d953a00023ff74733bd7", + "block_hash": "7cf34d76d3c92fa859fa533fcf26e66f01b808204c48d75e2b21fbb78167eef6", + "block_height": 3699644, + "position": 21, + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 2, + "outputs_count": 2, + "extra_size": 44, + "fee_atomic": "44420000", + "size": 2221, + "confirmations": 1, + "in_pool": false, + "is_canonical": true, + "is_settled": false, + "indexed_at": "2026-06-19T09:43:47.103Z", + "safe_decode": { + "format": "safe-monero-explorer-v1", + "version": 2, + "unlock_time": "0", + "is_coinbase": false, + "inputs_count": 2, + "outputs_count": 2, + "extra_length": 44, + "fee_atomic": "44420000" + } + } + ], + "pagination": { + "limit": 2, + "offset": 0, + "order": "desc", + "has_more": true + } +} diff --git a/server/tools/chains/monero/attribution-source.ts b/server/tools/chains/monero/attribution-source.ts new file mode 100644 index 00000000..d21cdf59 --- /dev/null +++ b/server/tools/chains/monero/attribution-source.ts @@ -0,0 +1,27 @@ +/** + * Single source of truth for Monero pool-attribution provenance values + * (design §5.1). Used by both the attribution upsert and the MiningPool seed — + * never free-form strings, to prevent silent mis-bucketing typos. + */ + +/** + * Where a block→pool attribution came from. Stored in `MoneroBlockAttribution.source`. + * `coinbase_selfid` / `coinbase_url_auto` = narrow exact-string self-ID path (curated vanity tag / pool + * URL embedded in coinbase), distinct from the removed heuristic fingerprint. + */ +export const ATTRIBUTION_SOURCES = ['pool_api', 'p2pool_observer', 'coinbase_selfid', 'coinbase_url_auto'] as const; +export type AttributionSource = (typeof ATTRIBUTION_SOURCES)[number]; + +/** How a `MiningPool` row was identified. Stored in `MiningPool.identificationMethod`. */ +export const IDENTIFICATION_METHODS = [ + 'pool_api', + 'p2pool_observer', + 'unknown', + 'coinbase_selfid', + 'coinbase_url_auto', +] as const; +export type IdentificationMethod = (typeof IDENTIFICATION_METHODS)[number]; + +/** Reserved slug for the "Unidentified / Solo" bucket (design §5.3). */ +export const UNKNOWN_POOL_SLUG = 'unknown'; +export const UNKNOWN_POOL_NAME = 'Unidentified / Solo'; diff --git a/server/tools/chains/monero/coinbase-parse.ts b/server/tools/chains/monero/coinbase-parse.ts new file mode 100644 index 00000000..0a506efd --- /dev/null +++ b/server/tools/chains/monero/coinbase-parse.ts @@ -0,0 +1,81 @@ +/** + * Pure helpers for the coinbase self-ID attribution path. + * + * Most Monero blocks carry no identifying string in coinbase `tx_extra` (verified empirically), + * but a rare few self-identify — a solo-miner vanity tag (e.g. `/Heathcliff/`) or a pool URL + * (e.g. `https://xmr.tokyo/`). This is a NARROW, exact-string allowlist match against canonical + * blocks — NOT the heuristic family-fingerprint that was proven non-viable and removed. + */ + +// Decode coinbase_extra hex into printable-ASCII runs of >= minLen chars. +export const extractAsciiRuns = (hex: string | null | undefined, minLen = 5): string[] => { + if (!hex) return []; + let buf: Buffer; + try { + buf = Buffer.from(hex.replace(/^0x/, ''), 'hex'); + } catch { + return []; + } + const runs: string[] = []; + let run = ''; + for (let i = 0; i < buf.length; i++) { + const byte = buf[i]; + if (byte >= 0x20 && byte <= 0x7e) { + run += String.fromCharCode(byte); + } else { + if (run.length >= minLen) runs.push(run); + run = ''; + } + } + if (run.length >= minLen) runs.push(run); + return runs; +}; + +// Normalize a URL for stable comparison/storage (lowercase host, single trailing slash, no query). +export const normalizeUrl = (raw: string): string | null => { + try { + const u = new URL(raw.trim()); + if (u.protocol !== 'https:' && u.protocol !== 'http:') return null; + const host = u.host.toLowerCase(); + const pathPart = u.pathname.replace(/\/+$/, ''); + return `${u.protocol}//${host}${pathPart}/`; + } catch { + return null; + } +}; + +// Extract distinct normalized http(s) URLs embedded in the ASCII runs. +export const extractUrls = (runs: string[]): string[] => { + const out = new Set(); + for (const r of runs) { + const m = r.match(/https?:\/\/[^\s'"<>]+/i); + if (!m) continue; + const norm = normalizeUrl(m[0]); + if (norm) out.add(norm); + } + return Array.from(out); +}; + +// Derive a stable slug from a URL's host (xmr.tokyo → xmrtokyo). +export const slugFromUrl = (url: string): string => { + let host = url; + try { + host = new URL(url).host; + } catch { + /* fall through to raw */ + } + return host + .replace(/^www\./, '') + .replace(/[^a-z0-9]/gi, '') + .toLowerCase() + .slice(0, 60); +}; + +// A friendly display name from a URL's host (xmr.tokyo → xmr.tokyo). +export const nameFromUrl = (url: string): string => { + try { + return new URL(url).host.replace(/^www\./, ''); + } catch { + return url; + } +}; diff --git a/server/tools/chains/monero/coinbase-pools.json b/server/tools/chains/monero/coinbase-pools.json new file mode 100644 index 00000000..9606e182 --- /dev/null +++ b/server/tools/chains/monero/coinbase-pools.json @@ -0,0 +1,20 @@ +[ + { + "slug": "xmrtokyo", + "name": "xmr.tokyo", + "tag": "https://xmr.tokyo/", + "website": "https://xmr.tokyo/", + "github": null, + "twitter": null, + "logoUrl": null + }, + { + "slug": "heathcliff", + "name": "Heathcliff", + "tag": "/Heathcliff/", + "website": null, + "github": null, + "twitter": null, + "logoUrl": null + } +] diff --git a/server/tools/chains/monero/constants.ts b/server/tools/chains/monero/constants.ts new file mode 100644 index 00000000..3818d69d --- /dev/null +++ b/server/tools/chains/monero/constants.ts @@ -0,0 +1,3 @@ +export const MONERO_BACKFILL_START_HEIGHT = 2_470_000; +export const MONERO_INDEXER_PAGE_SIZE = 1000; +export const MONERO_BLOCK_TIME_SECONDS = 120; diff --git a/server/tools/chains/monero/indexer-client.ts b/server/tools/chains/monero/indexer-client.ts new file mode 100644 index 00000000..8b1da85b --- /dev/null +++ b/server/tools/chains/monero/indexer-client.ts @@ -0,0 +1,426 @@ +/** + * Monero Indexer API client (single source of truth for VI — design §3.1/§4). + * + * Talks to the deployed indexer at `MONERO_INDEXER_BASE_URL` over `/api/v1/*` + * with `Authorization: Bearer ${MONERO_INDEXER_API_TOKEN}`. + * + * Real contract (verified live): + * - list endpoints return `{ data: [...], pagination: { limit, offset, order, has_more } }` + * - fields are snake_case; `difficulty_hex` is a 0x-prefixed hex string > 2^53 + * - pagination is `limit` + `offset` + `order` (asc|desc) + * + * VI does NOT consume `coinbase_extra_hex` (design decision 11) — it is mapped + * through as an optional field but never used for attribution. + * + * Resilience: per-attempt timeout via AbortController, up to 3 attempts with + * backoff. Retries on network errors and HTTP 5xx; 4xx is non-retryable. + */ + +const TIMEOUT_MS = 20_000; +const MAX_ATTEMPTS = 3; +const BACKOFF_SCHEDULE_MS = [250, 500, 1000] as const; + +const baseUrl = (): string => { + const base = process.env.MONERO_INDEXER_BASE_URL; + if (!base) { + throw new Error('MONERO_INDEXER_BASE_URL is not set'); + } + return base.replace(/\/$/, ''); +}; + +const authToken = (): string => process.env.MONERO_INDEXER_API_TOKEN ?? ''; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const isRetryableHttp = (status: number): boolean => status >= 500 && status < 600; + +const isAbortError = (err: unknown): boolean => + err instanceof Error && (err.name === 'AbortError' || /aborted/i.test(err.message)); + +// Returns null on HTTP 404 (missing resource); throws on other non-2xx after retries. +const jsonGet = async (path: string): Promise => { + let lastErr: unknown; + + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + + try { + const res = await fetch(`${baseUrl()}${path}`, { + headers: { Authorization: `Bearer ${authToken()}` }, + signal: ctrl.signal, + }); + clearTimeout(timer); + + if (res.status === 404) { + return null; + } + if (!res.ok) { + if (isRetryableHttp(res.status) && attempt < MAX_ATTEMPTS - 1) { + lastErr = new Error(`Monero indexer HTTP ${res.status} ${res.statusText}`); + await sleep(BACKOFF_SCHEDULE_MS[attempt]); + continue; + } + throw new Error(`Monero indexer HTTP ${res.status} ${res.statusText}`); + } + + return (await res.json()) as T; + } catch (err) { + clearTimeout(timer); + + if (err instanceof Error && /HTTP 4\d\d/.test(err.message)) { + throw err; + } + lastErr = err; + const retryable = isAbortError(err) || err instanceof TypeError; + if (retryable && attempt < MAX_ATTEMPTS - 1) { + await sleep(BACKOFF_SCHEDULE_MS[attempt]); + continue; + } + if (!retryable) throw err; + } + } + + if (lastErr instanceof Error) throw lastErr; + throw new Error(`Monero indexer request failed after ${MAX_ATTEMPTS} attempts: ${path}`); +}; + +// ---- Wire (snake_case) shapes ---- + +interface Pagination { + limit: number; + offset: number; + order: 'asc' | 'desc'; + has_more: boolean; +} + +interface Envelope { + data: T[]; + pagination: Pagination; +} + +interface RawBlock { + hash: string; + prev_hash: string; + height: number; + timestamp: number; + major_version: number; + minor_version: number; + nonce: number; + block_size: number; + block_weight: number; + long_term_weight: number; + num_txes: number; + miner_tx_hash: string; + reward_atomic: string; + difficulty_hex: string; + cumulative_difficulty_hex: string; + coinbase_extra_hex: string | null; + orphan_status: boolean; + is_canonical: boolean; + is_settled: boolean; + indexed_at: string; +} + +interface RawSupplyCheckpoint { + height: number; + block_hash: string; + block_timestamp: number; + cumulative_emission_atomic: string; + cumulative_fee_atomic: string; +} + +interface RawTransaction { + hash: string; + block_hash: string; + block_height: number; + position: number; + version: number; + unlock_time: string; + is_coinbase: boolean; + inputs_count: number; + outputs_count: number; + extra_size: number; + fee_atomic: string; + size: number; + confirmations: number; + in_pool: boolean; + is_canonical: boolean; + is_settled: boolean; + indexed_at: string; +} + +// The single-block endpoint (`/api/v1/blocks/{hash}`) is richer than the list +// rows: it embeds the block's full transactions array (+ a `raw` blob VI ignores). +interface RawBlockDetail extends RawBlock { + transactions: RawTransaction[]; +} + +// ---- Public (camelCase) DTOs ---- + +export interface MoneroBlock { + hash: string; + prevHash: string; + height: number; + timestamp: number; // on-chain unix seconds + majorVersion: number; + minorVersion: number; + nonce: number; + size: number; + weight: number; + longTermWeight: number; + txCount: number; + minerTxHash: string; + reward: string; // atomic (piconero) + difficulty: bigint | null; // from difficulty_hex; null if missing/malformed → caller skips (no bogus 0) + cumulativeDifficulty: bigint | null; + isCanonical: boolean; + isSettled: boolean; + orphanStatus: boolean; + indexedAt: string; + /** Present but NOT consumed by VI (decision 11). */ + coinbaseExtraHex: string | null; +} + +export interface MoneroSupplyCheckpoint { + height: number; + blockHash: string; + blockTimestamp: number; + /** Base block emission — this IS circulating supply (design §6). */ + cumulativeEmissionAtomic: string; + /** Analytics only — NEVER summed into supply (design §6). */ + cumulativeFeeAtomic: string; +} + +export interface MoneroTransaction { + hash: string; + blockHash: string; + blockHeight: number; + position: number; + version: number; + unlockTime: string; + isCoinbase: boolean; + inputsCount: number; + outputsCount: number; + extraSize: number; + /** atomic (piconero). Amounts are hidden by RingCT — only the fee is public. */ + fee: string; + size: number; + confirmations: number; + inPool: boolean; + isCanonical: boolean; + isSettled: boolean; + indexedAt: string; +} + +export interface MoneroBlockDetail extends MoneroBlock { + /** The block's transactions (coinbase + regular), from the single-block endpoint. */ + transactions: MoneroTransaction[]; +} + +export interface MoneroListResult { + items: T[]; + hasMore: boolean; + nextOffset: number; +} + +export interface ListOpts { + limit: number; + offset?: number; + order?: 'asc' | 'desc'; +} + +/** + * Parse `difficulty_hex` with BigInt — never Number (cumulative difficulty + * exceeds 2^53; per-block is future-proofed). Returns `null` for missing/empty/ + * malformed input so the caller can SKIP rather than persist a bogus `0` + * (design §4.1 / Codex F1) — invalid difficulty must be distinguishable from a + * genuine value. + */ +export const parseDifficultyHex = (hex: string | null | undefined): bigint | null => { + if (typeof hex !== 'string' || hex.length === 0) return null; + const normalized = hex.startsWith('0x') || hex.startsWith('0X') ? hex : `0x${hex}`; + try { + return BigInt(normalized); + } catch { + return null; + } +}; + +export const toBlock = (r: RawBlock): MoneroBlock => ({ + hash: r.hash, + prevHash: r.prev_hash, + height: Number(r.height), + timestamp: Number(r.timestamp), + majorVersion: Number(r.major_version), + minorVersion: Number(r.minor_version), + nonce: Number(r.nonce), + size: Number(r.block_size), + weight: Number(r.block_weight), + longTermWeight: Number(r.long_term_weight), + txCount: Number(r.num_txes), + minerTxHash: r.miner_tx_hash, + reward: String(r.reward_atomic ?? '0'), + difficulty: parseDifficultyHex(r.difficulty_hex), + cumulativeDifficulty: parseDifficultyHex(r.cumulative_difficulty_hex), + isCanonical: Boolean(r.is_canonical), + isSettled: Boolean(r.is_settled), + orphanStatus: Boolean(r.orphan_status), + indexedAt: String(r.indexed_at ?? ''), + coinbaseExtraHex: r.coinbase_extra_hex ?? null, +}); + +export const toSupply = (r: RawSupplyCheckpoint): MoneroSupplyCheckpoint => ({ + height: Number(r.height), + blockHash: r.block_hash, + blockTimestamp: Number(r.block_timestamp), + cumulativeEmissionAtomic: String(r.cumulative_emission_atomic ?? '0'), + cumulativeFeeAtomic: String(r.cumulative_fee_atomic ?? '0'), +}); + +export const toTransaction = (r: RawTransaction): MoneroTransaction => ({ + hash: r.hash, + blockHash: r.block_hash, + blockHeight: Number(r.block_height), + position: Number(r.position), + version: Number(r.version), + unlockTime: String(r.unlock_time ?? '0'), + isCoinbase: Boolean(r.is_coinbase), + inputsCount: Number(r.inputs_count), + outputsCount: Number(r.outputs_count), + extraSize: Number(r.extra_size), + fee: String(r.fee_atomic ?? '0'), + size: Number(r.size), + confirmations: Number(r.confirmations), + inPool: Boolean(r.in_pool), + isCanonical: Boolean(r.is_canonical), + isSettled: Boolean(r.is_settled), + indexedAt: String(r.indexed_at ?? ''), +}); + +export const toBlockDetail = (r: RawBlockDetail): MoneroBlockDetail => ({ + ...toBlock(r), + transactions: (r.transactions ?? []).map(toTransaction), +}); + +const buildQuery = (opts: ListOpts): string => { + const qs = new URLSearchParams({ limit: String(opts.limit) }); + if (opts.offset !== undefined) qs.set('offset', String(opts.offset)); + if (opts.order !== undefined) qs.set('order', opts.order); + return qs.toString(); +}; + +const unwrap = ( + env: Envelope | null, + map: (r: Raw) => Out, + opts: ListOpts, +): MoneroListResult => { + const items = (env?.data ?? []).map(map); + // Guard the nested pagination object too — a partial 200 (data but no + // pagination) must fall back to opts defaults, not throw a TypeError. + const pg = env?.pagination; + const offset = pg?.offset ?? opts.offset ?? 0; + const limit = pg?.limit ?? opts.limit; + return { + items, + hasMore: pg?.has_more ?? false, + nextOffset: offset + limit, + }; +}; + +// ---- Blocks ---- + +export const listMoneroBlocks = async (opts: ListOpts): Promise> => { + const env = await jsonGet>(`/api/v1/blocks?${buildQuery(opts)}`); + return unwrap(env, toBlock, opts); +}; + +export const getMoneroBlock = async (idOrHash: number | string): Promise => { + const raw = await jsonGet(`/api/v1/blocks/${idOrHash}`); + return raw ? toBlock(raw) : null; +}; + +// Single-block fetch INCLUDING the block's transactions (for the block detail page). +export const getMoneroBlockDetail = async (idOrHash: number | string): Promise => { + const raw = await jsonGet(`/api/v1/blocks/${idOrHash}`); + return raw ? toBlockDetail(raw) : null; +}; + +/** + * Newest CANONICAL block (tip) — used for hashrate/difficulty (design §6). + * Scans a small page and returns the first `isCanonical` block, enforcing the + * canonical contract explicitly rather than trusting position (Codex F3). + */ +export const getTipBlock = async (): Promise => { + const { items } = await listMoneroBlocks({ limit: 5, order: 'desc' }); + return items.find((b) => b.isCanonical) ?? null; +}; + +// ---- Transactions ---- + +export const listMoneroTransactions = async (opts: ListOpts): Promise> => { + const env = await jsonGet>(`/api/v1/transactions?${buildQuery(opts)}`); + return unwrap(env, toTransaction, opts); +}; + +export const getMoneroTransaction = async (hash: string): Promise => { + const raw = await jsonGet(`/api/v1/transactions/${hash}`); + return raw ? toTransaction(raw) : null; +}; + +// ---- Supply ---- + +export const listMoneroSupply = async (opts: ListOpts): Promise> => { + const env = await jsonGet>(`/api/v1/supply?${buildQuery(opts)}`); + return unwrap(env, toSupply, opts); +}; + +/** Latest supply checkpoint (highest height) — used for total supply (design §6). */ +export const getLatestSupply = async (): Promise => { + const { items } = await listMoneroSupply({ limit: 1, order: 'desc' }); + return items[0] ?? null; +}; + +// ---- Health (sanity guard, design §6) ---- + +interface RawHealth { + status?: string; + last_height?: number | null; + node_height?: number | null; + lag_blocks?: number | null; +} + +export interface MoneroHealth { + status: string; + lastHeight: number | null; + nodeHeight: number | null; + lagBlocks: number | null; +} + +/** + * GET /health — note: at the host root, NOT under /api/v1. Returns 200 (ok) or + * 503 (degraded), both carrying the JSON body, so we read the body regardless + * of status instead of routing through jsonGet (which would throw on 503). + * Returns null on network failure (caller proceeds without the guard). + */ +export const getHealth = async (): Promise => { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const res = await fetch(`${baseUrl()}/health`, { + headers: { Authorization: `Bearer ${authToken()}` }, + signal: ctrl.signal, + }); + const r = (await res.json()) as RawHealth; + return { + status: String(r.status ?? ''), + lastHeight: r.last_height ?? null, + nodeHeight: r.node_height ?? null, + lagBlocks: r.lag_blocks ?? null, + }; + } catch { + return null; + } finally { + clearTimeout(timer); + } +}; diff --git a/server/tools/chains/monero/methods.ts b/server/tools/chains/monero/methods.ts new file mode 100644 index 00000000..e4f6528c --- /dev/null +++ b/server/tools/chains/monero/methods.ts @@ -0,0 +1,44 @@ +import { ChainMethods } from '@/server/tools/chains/chain-indexer'; +import nullTxMetrics from '@/server/tools/chains/null-tx-metrics'; + +const chainMethods: ChainMethods = { + ...nullTxMetrics, + getNodes: async () => [], + getApr: async () => null, + getTvs: async () => null, + getStakingParams: async () => ({ unbondingTime: null, maxValidators: null }), + getNodeParams: async () => ({ + peers: null, + seeds: null, + daemonName: null, + nodeHome: null, + keyAlgos: null, + binaries: null, + genesis: null, + }), + getProposals: async () => ({ proposals: [], total: 0, live: 0, passed: 0 }), + getSlashingParams: async () => ({ blocksWindow: null, jailedDuration: null }), + getMissedBlocks: async () => [], + getNodesVotes: async () => [], + getCommTax: async () => null, + getWalletsAmount: async () => null, + getProposalParams: async () => ({ + creationCost: null, + votingPeriod: null, + participationRate: null, + quorumThreshold: null, + }), + getNodeRewards: async () => [], + getNodeCommissions: async () => [], + getCommunityPool: async () => null, + getActiveSetMinAmount: async () => null, + getInflationRate: async () => null, + getCirculatingTokensOnchain: async () => null, + getCirculatingTokensPublic: async () => null, + getDelegatorsAmount: async () => [], + getUnbondingTokens: async () => null, + getChainUptime: async () => null, + getRewardAddress: async () => [], +}; + +export default chainMethods; diff --git a/server/tools/chains/monero/pool-apis.json b/server/tools/chains/monero/pool-apis.json new file mode 100644 index 00000000..c1c627db --- /dev/null +++ b/server/tools/chains/monero/pool-apis.json @@ -0,0 +1,93 @@ +[ + { + "key": "supportxmr", + "name": "SupportXMR", + "type": "cryptonote", + "website": "https://www.supportxmr.com", + "logoUrl": null, + "github": null, + "twitter": "https://x.com/SupportXMR", + "paymentScheme": "PPLNS", + "feePercent": 0.6, + "blocksUrl": "https://www.supportxmr.com/api/pool/blocks", + "statsUrl": "https://www.supportxmr.com/api/pool/stats" + }, + { + "key": "moneroocean", + "name": "MoneroOcean", + "type": "cryptonote", + "website": "https://moneroocean.stream", + "logoUrl": "https://github.com/MoneroOcean.png?size=128", + "github": "https://github.com/MoneroOcean", + "twitter": "https://x.com/moneroocean", + "paymentScheme": "PPLNS", + "feePercent": 0, + "blocksUrl": "https://api.moneroocean.stream/pool/blocks", + "statsUrl": "https://api.moneroocean.stream/pool/stats" + }, + { + "key": "hashvault", + "name": "HashVault", + "type": "cryptonote", + "website": "https://monero.hashvault.pro", + "logoUrl": "https://github.com/HashVault.png?size=128", + "github": "https://github.com/HashVault", + "twitter": "https://x.com/HashVaultPro", + "paymentScheme": "PPLNS", + "feePercent": 0.9, + "blocksUrl": "https://api.hashvault.pro/v3/monero/pool/blocks?page=0&limit=100", + "statsUrl": "https://api.hashvault.pro/v3/monero/pool/stats" + }, + { + "key": "herominers", + "name": "HeroMiners", + "type": "cryptonote_flat", + "website": "https://monero.herominers.com", + "logoUrl": null, + "github": null, + "twitter": null, + "paymentScheme": "PROP", + "feePercent": 0.9, + "blocksUrl": "https://monero.herominers.com/api/get_blocks?height=99999999", + "statsUrl": "https://monero.herominers.com/api/stats" + }, + { + "key": "c3pool", + "name": "C3Pool", + "type": "cryptonote", + "website": "https://www.c3pool.com", + "logoUrl": "https://github.com/C3Pool.png?size=128", + "github": "https://github.com/C3Pool", + "twitter": "https://x.com/C3Pool", + "paymentScheme": "PROP", + "feePercent": 0, + "blocksUrl": "https://api.c3pool.com/pool/blocks", + "statsUrl": "https://api.c3pool.com/pool/stats" + }, + { + "key": "nanopool", + "name": "Nanopool", + "type": "nanopool", + "website": "https://xmr.nanopool.org", + "logoUrl": "https://github.com/nanopool.png?size=128", + "github": "https://github.com/nanopool", + "twitter": "https://x.com/nanopool", + "paymentScheme": "PROP", + "feePercent": 1, + "blocksUrl": "https://xmr.nanopool.org/api/v1/pool/blocks/0/100", + "statsUrl": null + }, + { + "key": "p2pool", + "name": "P2Pool", + "type": "p2pool_observer", + "website": "https://p2pool.io", + "logoUrl": "https://github.com/SChernykh.png?size=128", + "github": "https://github.com/SChernykh/p2pool", + "twitter": null, + "paymentScheme": "PPLNS", + "feePercent": 0, + "blocksUrl": "https://p2pool.observer/api/found_blocks?limit=100", + "statsUrl": "https://p2pool.observer/api/pool_info" + } +] diff --git a/server/tools/chains/monero/pool-client.ts b/server/tools/chains/monero/pool-client.ts new file mode 100644 index 00000000..fe969353 --- /dev/null +++ b/server/tools/chains/monero/pool-client.ts @@ -0,0 +1,119 @@ +/** + * Monero mining-pool API client (design §3.2/§3.3). + * + * Pools authoritatively list the blocks THEY found; VI cross-references those + * against on-chain indexer blocks (by hash) to attribute share. Most pools run + * a `cryptonote-nodejs-pool` fork (`/api/pool/blocks` → objects with + * height/hash/ts); p2pool is decentralized and uses the `p2pool.observer` API. + * + * Pure response parsing lives in `pool-parse.ts` (unit-tested standalone). Every + * call here is isolated — one pool's failure never breaks the others. + */ + +import logger from '@/logger'; +import { AttributionSource } from '@/server/tools/chains/monero/attribution-source'; +import poolRegistry from '@/server/tools/chains/monero/pool-apis.json'; +import { + PoolFoundBlock, + parseCryptonoteBlocks, + parseCryptonoteFlatBlocks, + parseNanopoolBlocks, + parseObserverBlocks, +} from '@/server/tools/chains/monero/pool-parse'; + +export type { PoolFoundBlock }; + +const { logWarn } = logger('monero-pool-client'); + +const FETCH_TIMEOUT_MS = 12_000; + +export interface PoolRegistryEntry { + key: string; + name: string; + type: 'cryptonote' | 'cryptonote_flat' | 'p2pool_observer' | 'nanopool'; + website: string | null; + logoUrl: string | null; + paymentScheme: string | null; + feePercent: number | null; + github: string | null; + twitter: string | null; + blocksUrl: string; + statsUrl: string | null; +} + +export interface PoolLiveStats { + hashRate: number | null; + miners: number | null; + totalBlocksFound: number | null; +} + +export const getPoolRegistry = (): PoolRegistryEntry[] => poolRegistry as PoolRegistryEntry[]; + +export const sourceForPool = (pool: PoolRegistryEntry): AttributionSource => + pool.type === 'p2pool_observer' ? 'p2pool_observer' : 'pool_api'; + +const fetchJson = async (url: string): Promise => { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { headers: { 'User-Agent': 'validatorinfo' }, signal: ctrl.signal }); + if (!res.ok) { + throw new Error(`HTTP ${res.status} ${res.statusText}`); + } + return await res.json(); + } finally { + clearTimeout(timer); + } +}; + +/** Fetch a pool's recently-found blocks. Throws on failure — caller isolates. */ +export const fetchPoolBlocks = async (pool: PoolRegistryEntry): Promise => { + const body = await fetchJson(pool.blocksUrl); + if (pool.type === 'p2pool_observer') return parseObserverBlocks(body); + if (pool.type === 'nanopool') return parseNanopoolBlocks(body); + if (pool.type === 'cryptonote_flat') return parseCryptonoteFlatBlocks(body); + return parseCryptonoteBlocks(body); +}; + +const numFrom = (obj: Record | undefined, ...keys: string[]): number | null => { + if (!obj) return null; + for (const k of keys) { + const v = Number(obj[k]); + if (Number.isFinite(v) && v > 0) return v; + } + return null; +}; + +/** + * Best-effort live stats (supplementary, design §3.2 / Decision 3). Returns null + * on any failure. Stats live in different places per pool software (Codex F3): + * - p2pool.observer /api/pool_info → `sidechain.{miners,found}` + * - HashVault → `pool_statistics.collective.{hashRate,miners,totalBlocksFound}` + * - SupportXMR/MoneroOcean → `pool_statistics.{...}` + */ +export const fetchPoolStats = async (pool: PoolRegistryEntry): Promise => { + if (!pool.statsUrl) return null; + try { + const body = (await fetchJson(pool.statsUrl)) as Record; + + if (pool.type === 'p2pool_observer') { + const sc = body?.sidechain as Record | undefined; + return { + hashRate: null, // not directly exposed by the observer pool_info endpoint + miners: numFrom(sc, 'miners', 'workers'), + totalBlocksFound: numFrom(sc, 'found', 'blocks_found'), + }; + } + + const ps = (body?.pool_statistics ?? body?.pool ?? body) as Record | undefined; + const coll = (ps?.collective ?? ps) as Record | undefined; // HashVault nests under `collective` + return { + hashRate: numFrom(coll, 'hashRate', 'hashrate', 'poolHashrate'), + miners: numFrom(coll, 'miners', 'minerCount', 'workerCount'), + totalBlocksFound: numFrom(coll, 'totalBlocksFound', 'totalBlocks'), + }; + } catch (e) { + logWarn(`pool=${pool.key} stats fetch failed: ${e instanceof Error ? e.message : String(e)}`); + return null; + } +}; diff --git a/server/tools/chains/monero/pool-parse.ts b/server/tools/chains/monero/pool-parse.ts new file mode 100644 index 00000000..bd84aedd --- /dev/null +++ b/server/tools/chains/monero/pool-parse.ts @@ -0,0 +1,120 @@ +/** + * Pure parsers for pool-API "found blocks" responses (design §3.2/§3.3). + * No framework / alias deps — unit-testable standalone (see __fixtures__/dto.check.ts). + */ + +export interface PoolFoundBlock { + height: number; + hash: string; + /** Pool-reported discovery time (unix seconds). Provenance only — window + * membership uses the indexer's canonical block timestamp (design §5.2). */ + timestamp: number; +} + +// Pool APIs report timestamps in seconds OR milliseconds — normalize to seconds. +export const normalizeTs = (raw: unknown): number => { + const n = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isFinite(n) || n <= 0) return 0; + return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n); +}; + +const toFoundBlock = (height: unknown, hash: unknown, ts: unknown): PoolFoundBlock | null => { + const h = typeof height === 'number' ? height : Number(height); + const hsh = typeof hash === 'string' ? hash : ''; + if (!Number.isFinite(h) || h <= 0 || !hsh) return null; + return { height: h, hash: hsh, timestamp: normalizeTs(ts) }; +}; + +// cryptonote-nodejs-pool family: array of objects (or { blocks: [...] } / { data: [...] }). +// Verified object shapes (supportxmr/moneroocean/hashvault): { height, hash, ts(ms), ... }. +export const parseCryptonoteBlocks = (body: unknown): PoolFoundBlock[] => { + const raw = Array.isArray(body) + ? body + : Array.isArray((body as { blocks?: unknown[] } | null)?.blocks) + ? (body as { blocks: unknown[] }).blocks + : Array.isArray((body as { data?: unknown[] } | null)?.data) + ? (body as { data: unknown[] }).data + : null; + if (!raw) { + throw new Error('unexpected pool-blocks shape (expected array or { blocks: [] } / { data: [] })'); + } + const out: PoolFoundBlock[] = []; + for (const item of raw) { + if (!item || typeof item !== 'object') continue; + const o = item as Record; + const fb = toFoundBlock(o.height, o.hash, o.ts ?? o.timestamp ?? o.time); + if (fb) out.push(fb); + } + // No silent truncation (design §3.2): a non-empty response that yields zero + // parsed blocks is an unknown shape — throw so the caller logs + isolates. + if (raw.length > 0 && out.length === 0) { + throw new Error(`pool-blocks: ${raw.length} items but none parseable (unknown shape — needs a custom adapter)`); + } + return out; +}; + +// cryptonote-nodejs-pool `/api/get_blocks` FLAT shape (e.g. HeroMiners): a single array that alternates +// [blockString, height, blockString, height, ...]. Each blockString is colon-joined +// `hash:timestamp:difficulty:...:scheme`; the height is the bare number that follows it. +export const parseCryptonoteFlatBlocks = (body: unknown): PoolFoundBlock[] => { + const raw = Array.isArray(body) + ? body + : Array.isArray((body as { blocks?: unknown[] } | null)?.blocks) + ? (body as { blocks: unknown[] }).blocks + : null; + if (!raw) { + throw new Error('unexpected flat cryptonote shape (expected array or { blocks: [] })'); + } + const out: PoolFoundBlock[] = []; + for (let i = 0; i + 1 < raw.length; i += 2) { + const blockStr = raw[i]; + const height = raw[i + 1]; + if (typeof blockStr !== 'string') continue; + const parts = blockStr.split(':'); + const hash = parts[0]; + if (!/^[0-9a-f]{64}$/i.test(hash)) continue; + const fb = toFoundBlock(height, hash, parts[1]); + if (fb) out.push(fb); + } + if (raw.length > 0 && out.length === 0) { + throw new Error(`flat cryptonote: ${raw.length} items but none parseable (unknown shape)`); + } + return out; +}; + +// p2pool.observer /api/found_blocks: array of { main_block: { id, height, timestamp } }. +export const parseObserverBlocks = (body: unknown): PoolFoundBlock[] => { + if (!Array.isArray(body)) { + throw new Error('unexpected p2pool.observer shape (expected array)'); + } + const out: PoolFoundBlock[] = []; + for (const item of body) { + const mb = (item as { main_block?: Record } | null)?.main_block; + if (!mb) continue; + const fb = toFoundBlock(mb.height, mb.id, mb.timestamp); + if (fb) out.push(fb); + } + if (body.length > 0 && out.length === 0) { + throw new Error(`p2pool.observer: ${body.length} items but none parseable (unknown shape)`); + } + return out; +}; + +// nanopool: { status, data: [{ block_number, hash, date(unix seconds), ... }] }. +export const parseNanopoolBlocks = (body: unknown): PoolFoundBlock[] => { + const data = (body as { data?: unknown[] } | null)?.data; + if (!Array.isArray(data)) { + throw new Error('unexpected nanopool shape (expected { data: [] })'); + } + const out: PoolFoundBlock[] = []; + for (const item of data) { + if (!item || typeof item !== 'object') continue; + const o = item as Record; + const fb = toFoundBlock(o.block_number, o.hash, o.date); + if (fb) out.push(fb); + } + if (data.length > 0 && out.length === 0) { + throw new Error(`nanopool: ${data.length} items but none parseable`); + } + return out; +}; diff --git a/server/tools/chains/params.ts b/server/tools/chains/params.ts index 51115b32..75f0a310 100755 --- a/server/tools/chains/params.ts +++ b/server/tools/chains/params.ts @@ -61,6 +61,12 @@ export const ecosystemParams = [ logoUrl: 'https://raw.githubusercontent.com/citizenweb3/staking/refs/heads/chain-images/miden/miden.svg', tags: ['Privacy', 'zkVM', 'STARK', 'Client-side Proving'], }, + { + name: 'monero', + prettyName: 'Monero', + logoUrl: 'https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/color/xmr.svg', + tags: ['Privacy', 'PoW', 'L1', 'RandomX'], + }, ]; const chainParams: Record = { @@ -1309,6 +1315,35 @@ const chainParams: Record = { tags: ['Miden Ecosystem', 'Testnet', 'zkVM', 'Privacy', 'STARK', 'Client-side Proving'], }, + monero: { + rang: 2, + ecosystem: 'monero', + consensusType: 'pow', + hashrateUnit: 'H/s', + hasValidators: false, + name: 'monero', + prettyName: 'Monero', + shortDescription: 'Privacy-first PoW L1 with RandomX, ring signatures, RingCT, and stealth addresses', + chainId: 'mainnet', + bech32Prefix: '', + coinDecimals: 12, + coinGeckoId: 'monero', + coinType: 128, + denom: 'XMR', + minimalDenom: 'piconero', + logoUrl: 'https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/color/xmr.svg', + nodes: [ + { type: 'indexer', url: 'https://indexer.monero.citizenweb3.com', provider: 'citizenweb3' }, + ], + mainRepo: 'https://github.com/monero-project/monero', + docs: 'https://docs.getmonero.org/', + githubUrl: 'https://github.com/monero-project', + twitterUrl: 'https://x.com/monero', + telegramUrl: 'https://t.me/monero', + discordInviteCode: 'SyGUMWBqvF', + tags: ['Privacy', 'PoW', 'L1', 'RandomX', 'CryptoNote'], + }, + ethereum: { rang: 1, ecosystem: 'ethereum', @@ -1473,6 +1508,10 @@ export const updateChainParamsUpdated = async (chainName: string) => { } } + if (params.consensusType === 'pow') { + return params; + } + const chainRegistryUrl = params.chainRegistry ? `${params.chainRegistry}/chain.json` : `https://raw.githubusercontent.com/cosmos/chain-registry/refs/heads/master/${chainName}/chain.json`; diff --git a/server/tools/init-chains.ts b/server/tools/init-chains.ts index 058f9e7b..9346bc61 100644 --- a/server/tools/init-chains.ts +++ b/server/tools/init-chains.ts @@ -33,6 +33,8 @@ async function addNetwork(chain: AddChainProps): Promise { hasValidators: chain.hasValidators, tags: chain.tags, supported: true, + consensusType: chain.consensusType, + hashrateUnit: chain.hashrateUnit, }; const existingChain = await db.chain.findUnique({ diff --git a/src/app/[locale]/about/contacts/page.tsx b/src/app/[locale]/about/contacts/page.tsx index 23e9a74f..f444142a 100644 --- a/src/app/[locale]/about/contacts/page.tsx +++ b/src/app/[locale]/about/contacts/page.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import PageTitle from '@/components/common/page-title'; import TabList from '@/components/common/tabs/tab-list'; -import { aboutTabs } from '@/components/common/tabs/tabs-data'; +import { homeTabsHorizontal } from '@/components/common/tabs/tabs-data'; import { NextPageWithLocale } from '@/i18n'; import SubDescription from '@/components/sub-description'; @@ -18,7 +18,7 @@ const ContactsPage: NextPageWithLocale = async ({ params: { locale } }) => { const size = 'h-24 w-24 min-w-24 min-h-24'; return (
- +
diff --git a/src/app/[locale]/about/page.tsx b/src/app/[locale]/about/page.tsx index c3308407..bd72c6eb 100644 --- a/src/app/[locale]/about/page.tsx +++ b/src/app/[locale]/about/page.tsx @@ -6,7 +6,7 @@ import AboutModalsGroup from '@/app/about/modals/about-modals-group'; import PageHeaderVisibilityWrapper from '@/components/common/page-header-visibility-wrapper'; import RichPageTitle from '@/components/common/rich-page-title'; import TabList from '@/components/common/tabs/tab-list'; -import { aboutTabs } from '@/components/common/tabs/tabs-data'; +import { homeTabsHorizontal } from '@/components/common/tabs/tabs-data'; import TextLink from '@/components/common/text-link'; import { Locale } from '@/i18n'; @@ -28,7 +28,7 @@ export default function AboutPage({ params: { locale } }: Readonly<{ params: { l return (
- +
diff --git a/src/app/[locale]/about/partners/page.tsx b/src/app/[locale]/about/partners/page.tsx index 310afb13..e19b81d1 100644 --- a/src/app/[locale]/about/partners/page.tsx +++ b/src/app/[locale]/about/partners/page.tsx @@ -2,7 +2,7 @@ import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; import PartnerItem from '@/app/about/partners/partner-item'; import TabList from '@/components/common/tabs/tab-list'; -import { aboutTabs } from '@/components/common/tabs/tabs-data'; +import { homeTabsHorizontal } from '@/components/common/tabs/tabs-data'; import { NextPageWithLocale } from '@/i18n'; import SubDescription from '@/components/sub-description'; import TextLink from '@/components/common/text-link'; @@ -26,7 +26,7 @@ const Partners: NextPageWithLocale = async ({ params: { locale } }) => { unstable_setRequestLocale(locale); return (
- +
{t.rich('Partners.title', { diff --git a/src/app/[locale]/about/podcasts/page.tsx b/src/app/[locale]/about/podcasts/page.tsx index 551b38ec..868f58a1 100644 --- a/src/app/[locale]/about/podcasts/page.tsx +++ b/src/app/[locale]/about/podcasts/page.tsx @@ -8,7 +8,7 @@ import Player from '@/app/about/podcasts/player'; import RoundedButton from '@/components/common/rounded-button'; import SubTitle from '@/components/common/sub-title'; import TabList from '@/components/common/tabs/tab-list'; -import { aboutTabs } from '@/components/common/tabs/tabs-data'; +import { homeTabsHorizontal } from '@/components/common/tabs/tabs-data'; import { Locale } from '@/i18n'; import SubDescription from '@/components/sub-description'; import TextLink from '@/components/common/text-link'; @@ -23,7 +23,7 @@ export default function PodcastPage({ params: { locale } }: Readonly<{ params: { return (
- +
{t.rich('Podcast.title', { diff --git a/src/app/[locale]/about/staking/page.tsx b/src/app/[locale]/about/staking/page.tsx index 1cf3a33c..fd985660 100644 --- a/src/app/[locale]/about/staking/page.tsx +++ b/src/app/[locale]/about/staking/page.tsx @@ -5,7 +5,7 @@ import GetStakingList from '@/app/about/staking/get-staking-list'; import RichPageTitle from '@/components/common/rich-page-title'; import SubTitle from '@/components/common/sub-title'; import TabList from '@/components/common/tabs/tab-list'; -import { aboutTabs } from '@/components/common/tabs/tabs-data'; +import { homeTabsHorizontal } from '@/components/common/tabs/tabs-data'; import TextLink from '@/components/common/text-link'; import SubDescription from '@/components/sub-description'; import { Locale } from '@/i18n'; @@ -18,7 +18,7 @@ export default function StakingPage({ params: { locale } }: Readonly<{ params: { return (
- +
{t.rich('Staking.title', { diff --git a/src/app/[locale]/ai/page.tsx b/src/app/[locale]/ai/page.tsx index f597f752..b4b25bf2 100644 --- a/src/app/[locale]/ai/page.tsx +++ b/src/app/[locale]/ai/page.tsx @@ -3,7 +3,7 @@ import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; import UnderDevelopment from '@/components/common/under-development'; import PageTitle from '@/components/common/page-title'; import TabList from '@/components/common/tabs/tab-list'; -import { mainTabs } from '@/components/common/tabs/tabs-data'; +import { homeTabsHorizontal } from '@/components/common/tabs/tabs-data'; import { NextPageWithLocale } from '@/i18n'; import SubDescription from '@/components/sub-description'; @@ -16,7 +16,7 @@ const RumorsPage: NextPageWithLocale = async ({ params: { locale } }) => { const underDevelopment = await getTranslations({ locale, namespace: 'UnderDevelopment' }); return (
- + = async ({ params: return (
- + diff --git a/src/app/[locale]/components/common/profile-banner.tsx b/src/app/[locale]/components/common/profile-banner.tsx new file mode 100644 index 00000000..e1e63be9 --- /dev/null +++ b/src/app/[locale]/components/common/profile-banner.tsx @@ -0,0 +1,112 @@ +import { getTranslations } from 'next-intl/server'; +import Link from 'next/link'; +import { FC } from 'react'; + +import NetworksCircle from '@/app/validators/[id]/(validator-profile)/validator-profile/validator-networks-circle'; +import PodcastSummary from '@/app/validators/[id]/(validator-profile)/validator-profile/podcast-summary'; +import PlusButton from '@/components/common/plus-button'; +import RoundedButton from '@/components/common/rounded-button'; +import Tooltip from '@/components/common/tooltip'; + +// Default Citizen Web3 podcast player, shown when a profile has no episode of its own (invites an +// interview via the "place your interview here" CTA). Shared so validators and pools stay in sync. +export const DEFAULT_PODCAST_PLAYER = 'https://player.fireside.fm/v2/7d8ZfYhp/latest?theme=dark'; + +export interface ProfileBannerChain { + name: string; + logoUrl: string; + prettyName: string; +} + +export interface ProfileBannerPodcast { + playerUrl: string; + summary?: { summary: string; title: string; episodeUrl: string } | null; + showInterviewCta: boolean; +} + +interface OwnProps { + locale: string; + story: string; + centerLogo: string; + chains: ProfileBannerChain[]; + github?: string | null; + // Optional — only profiles that have a podcast (validators) pass it; pools omit it. + podcast?: ProfileBannerPodcast; +} + +const iconsSize = 'h-10 min-h-10 w-10 min-w-10'; + +// Shared presentational profile banner (story + podcast? | NetworksCircle | Merits), reused by both the +// validator profile and the mining-pool profile. Labels live in the canonical ValidatorProfileHeader +// namespace; the entity-specific story is passed in already resolved. +const ProfileBanner: FC = async ({ locale, story, centerLogo, chains, github, podcast }) => { + const t = await getTranslations({ locale, namespace: 'ValidatorProfileHeader' }); + + return ( +
+
+
+

{story}

+ {podcast && ( + <> +
+ +
+ {podcast.summary && ( + + )} + {podcast.showInterviewCta && ( + + {t('place your interview here')} + + )} + + )} +
+
+
+

{t('Others Links')}

+ +
+
+
+ +
+
+

+ {t('Merits')} +

+
+ +
+ + +
+ + {github && ( + + +
+ + + )} +
+
+
+ ); +}; + +export default ProfileBanner; diff --git a/src/app/[locale]/components/common/tabs/tab-list-item.tsx b/src/app/[locale]/components/common/tabs/tab-list-item.tsx index 40c7bed9..685cf2e8 100644 --- a/src/app/[locale]/components/common/tabs/tab-list-item.tsx +++ b/src/app/[locale]/components/common/tabs/tab-list-item.tsx @@ -18,10 +18,56 @@ const menuButtonSelectedShadow = 'shadow-menu-button-rest'; const menuButtonHoverShadow = 'hover:shadow-menu-button-hover'; const menuButtonPressedShadow = 'active:shadow-menu-button-pressed'; -const TabListItem: FC = ({ page, item: { name, href, icon, iconHovered, isScroll = true } }) => { +const TabListItem: FC = ({ + page, + item: { name, href, icon, iconHovered, isScroll = true, disabled = false }, +}) => { const isActive = usePathname() === href; const t = useTranslations(`${page}.Tabs` as NamespaceKeys); + const content = ( +
+
+ {icon && ( + {name} + )} + {iconHovered && ( + {name} + )} +
+
{t(name as 'ValidatorInfo')}
+
+
+
+ ); + + // Disabled tab: no data for this entity (e.g. governance/revenue for a pool). Kept visible for + // layout parity but rendered blurred and non-interactive (no navigation). + if (disabled) { + return ( +
+ {content} +
+ ); + } + return ( = ({ page, item: { name, href, icon, iconHovered } ${menuButtonHoverShadow} ${menuButtonPressedShadow} group relative mt-12 flex min-h-36 min-w-0 w-full flex-grow cursor-pointer flex-row items-center justify-center overflow-hidden p-0.5 text-sm transition-width duration-300 hover:bg-card hover:text-highlight active:border-transparent active:bg-card sm:mt-12 sm:min-h-20 md:mt-0 md:min-h-10`} scroll={isScroll} > -
-
- {icon && ( - {name} - )} - {iconHovered && ( - {name} - )} -
-
{t(name as 'ValidatorInfo')}
-
-
-
+ {content} ); }; diff --git a/src/app/[locale]/components/common/tabs/tabs-data.ts b/src/app/[locale]/components/common/tabs/tabs-data.ts index b37fb7f5..d0c40fb1 100644 --- a/src/app/[locale]/components/common/tabs/tabs-data.ts +++ b/src/app/[locale]/components/common/tabs/tabs-data.ts @@ -8,6 +8,9 @@ export interface TabOptions { icon?: StaticImageData; iconHovered?: StaticImageData; isScroll?: boolean; + // When true the tab renders blurred + non-clickable (a section that has no data for this entity, + // e.g. governance/revenue for a mining pool). Kept visible for layout parity with siblings. + disabled?: boolean; } export const mainTabs: TabOptions[] = [ @@ -28,6 +31,53 @@ export const mainTabs: TabOptions[] = [ { name: 'Global', href: '/web3stats', icon: icons.GlobalIcon, iconHovered: icons.GlobalIconHovered }, ]; +// Navigation groups — the single source of truth shared by the left vertical menu +// (NavigationBar) and the horizontal TabList on the matching pages, so the two +// menus always stay in sync. homeTabs/toolsTabs back the home and tools pages; +// networkTabs backs the left menu's Networks group (and the mobile/game overlays). +export const homeTabs: TabOptions[] = [ + { name: 'Home', href: '/', icon: icons.HomeIcon, iconHovered: icons.HomeIconHovered }, + { name: 'You', href: '/profile', icon: icons.ContactsIcon, iconHovered: icons.ContactsIconHovered }, + { name: 'AI', href: '/ai', icon: icons.RabbitIcon, iconHovered: icons.RabbitIconHovered }, + { name: 'About Us', href: '/about', icon: icons.LogoIcon, iconHovered: icons.LogoIconHovered }, + { name: 'Play', href: '/library', icon: icons.LibraryIcon, iconHovered: icons.LibraryIconHovered }, +]; + +export const networkTabs: TabOptions[] = [ + { name: 'Networks', href: '/networks', icon: icons.NetworksIcon, iconHovered: icons.NetworksIconHovered }, + { name: 'Validators', href: '/validators', icon: icons.ValidatorsIcon, iconHovered: icons.ValidatorsIconHovered }, + { name: 'Nodes', href: '/nodes', icon: icons.NodesIcon, iconHovered: icons.NodesIconHovered }, + { name: 'Mining Pools', href: '/mining-pools', icon: icons.NodesIcon, iconHovered: icons.NodesIconHovered }, + { name: 'Ecosystems', href: '/ecosystems', icon: icons.EcosystemsIcon, iconHovered: icons.EcosystemsIconHovered }, +]; + +export const toolsTabs: TabOptions[] = [ + { name: 'Rumor', href: '/p2pchat', icon: icons.RumorsIcon, iconHovered: icons.RumorsIconHovered }, + { name: 'Analyze', href: '/web3stats', icon: icons.GlobalIcon, iconHovered: icons.GlobalIconHovered }, + { + name: 'Calculate', + href: '/stakingcalculator', + icon: icons.CalculatorIcon, + iconHovered: icons.CalculatorIconHovered, + }, + { + name: 'Compare', + href: '/comparevalidators', + icon: icons.ComparisonIcon, + iconHovered: icons.ComparisonIconHovered, + }, + { name: 'Explain', href: '/metrics', icon: icons.MetricsIcon, iconHovered: icons.MetricsIconHovered }, +]; + +// Horizontal tab bars centre the section's primary tab — the one that sits first in the vertical +// NavigationBar (Home for the home menu, Rumor for the tools menu) — to match the original +// main-menu layout. The vertical NavigationBar keeps the primary-first order. +const centrePrimary = (tabs: TabOptions[]): TabOptions[] => + tabs.length === 5 ? [tabs[1], tabs[2], tabs[0], tabs[3], tabs[4]] : tabs; + +export const homeTabsHorizontal: TabOptions[] = centrePrimary(homeTabs); +export const toolsTabsHorizontal: TabOptions[] = centrePrimary(toolsTabs); + export const validatorsTabs: TabOptions[] = [ { name: 'Validators', @@ -42,8 +92,8 @@ export const validatorsTabs: TabOptions[] = [ icon: icons.NetworksIcon, iconHovered: icons.NetworksIconHovered, }, + { name: 'Mining Pools', href: '/mining-pools', icon: icons.NodesIcon, iconHovered: icons.NodesIconHovered }, { name: 'Ecosystems', href: '/ecosystems', icon: icons.EcosystemsIcon, iconHovered: icons.EcosystemsIconHovered }, - { name: 'Metrics', href: '/metrics', icon: icons.MetricsIcon, iconHovered: icons.MetricsIconHovered }, ]; export const aboutTabs: TabOptions[] = [ @@ -97,6 +147,47 @@ export const getValidatorProfileTabs = (id: number): TabOptions[] => { ]; }; +// Mining-pool profile tabs — same positions as the validator profile, with one difference: Governance +// is replaced by Blocks. The centre tab "Network Table" (= /networks) is the default landing tab and +// is real, like the validator. Revenue/Metrics/Public Goods have no pool equivalent → blurred + disabled. +export const getMiningPoolProfileTabs = (slug: string): TabOptions[] => { + return [ + { + name: 'Revenue', + href: `/mining-pools/${slug}/revenue`, + icon: icons.RevenueIcon, + iconHovered: icons.RevenueIconHovered, + disabled: true, + }, + { + name: 'Metrics', + href: `/mining-pools/${slug}/metrics`, + icon: icons.MetricsIcon, + iconHovered: icons.MetricsIconHovered, + disabled: true, + }, + { + name: 'Network Table', + href: `/mining-pools/${slug}/networks`, + icon: icons.NetworkTableIcon, + iconHovered: icons.NetworkTableIconHovered, + }, + { + name: 'Public Goods', + href: `/mining-pools/${slug}/public_goods`, + icon: icons.PublicGoodsIcon, + iconHovered: icons.PublicGoodsIconHovered, + disabled: true, + }, + { + name: 'Blocks', + href: `/mining-pools/${slug}/blocks`, + icon: icons.NetworkBlocks, + iconHovered: icons.NetworkBlocksHovered, + }, + ]; +}; + export const getValidatorPublicGoodTabs = (id: number): TabOptions[] => { return [ { @@ -183,7 +274,7 @@ export const getPassportAuthzTabs = (id: number, operatorAddress: string): TabOp }; export const getNetworkProfileTabs = (networkName: string): TabOptions[] => { - return [ + const tabs: TabOptions[] = [ { name: 'Governance', href: `/networks/${networkName}/governance`, @@ -215,6 +306,8 @@ export const getNetworkProfileTabs = (networkName: string): TabOptions[] => { iconHovered: icons.TokenomicsIconHovered, }, ]; + + return tabs; }; export const getTxInformationTabs = (networkName: string, txHash: string): TabOptions[] => { diff --git a/src/app/[locale]/components/navigation-bar/menu-overlay.tsx b/src/app/[locale]/components/navigation-bar/menu-overlay.tsx index 54e38606..f5328d4b 100644 --- a/src/app/[locale]/components/navigation-bar/menu-overlay.tsx +++ b/src/app/[locale]/components/navigation-bar/menu-overlay.tsx @@ -4,7 +4,7 @@ import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import React, { FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { homeTabs, networkTabs, toolsTabs } from '@/components/navigation-bar/navigation-bar'; +import { homeTabs, networkTabs, toolsTabs } from '@/components/common/tabs/tabs-data'; interface OwnProps { visible: boolean; diff --git a/src/app/[locale]/components/navigation-bar/mobile-navigation-bar.tsx b/src/app/[locale]/components/navigation-bar/mobile-navigation-bar.tsx index 58706875..c64d2849 100644 --- a/src/app/[locale]/components/navigation-bar/mobile-navigation-bar.tsx +++ b/src/app/[locale]/components/navigation-bar/mobile-navigation-bar.tsx @@ -7,7 +7,7 @@ import { FC, useEffect } from 'react'; import Footer from '@/components/footer'; import icons from '@/components/icons'; import NavigationBarItem from '@/components/navigation-bar/navigation-bar-item'; -import { homeTabs, networkTabs, toolsTabs } from '@/components/navigation-bar/navigation-bar'; +import { homeTabs, networkTabs, toolsTabs } from '@/components/common/tabs/tabs-data'; interface OwnProps { isOpened: boolean; diff --git a/src/app/[locale]/components/navigation-bar/navigation-bar.tsx b/src/app/[locale]/components/navigation-bar/navigation-bar.tsx index 4e657079..4de4fe4a 100644 --- a/src/app/[locale]/components/navigation-bar/navigation-bar.tsx +++ b/src/app/[locale]/components/navigation-bar/navigation-bar.tsx @@ -2,46 +2,11 @@ import { FC, useCallback, useState } from 'react'; -import { TabOptions } from '@/components/common/tabs/tabs-data'; -import icons from '@/components/icons'; +import { homeTabs, networkTabs, toolsTabs, TabOptions } from '@/components/common/tabs/tabs-data'; import { useWindowEvent } from '@/hooks/useWindowEvent'; import NavigationBarItem from './navigation-bar-item'; -export const homeTabs: TabOptions[] = [ - { name: 'Home', href: '/', icon: icons.HomeIcon, iconHovered: icons.HomeIconHovered }, - { name: 'You', href: '/profile', icon: icons.ContactsIcon, iconHovered: icons.ContactsIconHovered }, - { name: 'AI', href: '/ai', icon: icons.RabbitIcon, iconHovered: icons.RabbitIconHovered }, - { name: 'About Us', href: '/about', icon: icons.LogoIcon, iconHovered: icons.LogoIconHovered }, - { name: 'Play', href: '/library', icon: icons.LibraryIcon, iconHovered: icons.LibraryIconHovered }, -]; - -export const networkTabs: TabOptions[] = [ - { name: 'Networks', href: '/networks', icon: icons.NetworksIcon, iconHovered: icons.NetworksIconHovered }, - { name: 'Validators', href: '/validators', icon: icons.ValidatorsIcon, iconHovered: icons.ValidatorsIconHovered }, - { name: 'Nodes', href: '/nodes', icon: icons.NodesIcon, iconHovered: icons.NodesIconHovered }, - { name: 'Mining Pools', href: '/mining-pools', icon: icons.NodesIcon, iconHovered: icons.NodesIconHovered }, - { name: 'Ecosystems', href: '/ecosystems', icon: icons.EcosystemsIcon, iconHovered: icons.EcosystemsIconHovered }, -]; - -export const toolsTabs: TabOptions[] = [ - { name: 'Rumor', href: '/p2pchat', icon: icons.RumorsIcon, iconHovered: icons.RumorsIconHovered }, - { name: 'Analyze', href: '/web3stats', icon: icons.GlobalIcon, iconHovered: icons.GlobalIconHovered }, - { - name: 'Calculate', - href: '/stakingcalculator', - icon: icons.CalculatorIcon, - iconHovered: icons.CalculatorIconHovered, - }, - { - name: 'Compare', - href: '/comparevalidators', - icon: icons.ComparisonIcon, - iconHovered: icons.ComparisonIconHovered, - }, - { name: 'Explain', href: '/metrics', icon: icons.MetricsIcon, iconHovered: icons.MetricsIconHovered }, -]; - interface OwnProps { isGameMenuMode?: boolean; activeSection?: number; diff --git a/src/app/[locale]/ecosystems/ecosystems-list/ecosystems-list-item-chains.tsx b/src/app/[locale]/ecosystems/ecosystems-list/ecosystems-list-item-chains.tsx index 261e8c14..e50ca542 100644 --- a/src/app/[locale]/ecosystems/ecosystems-list/ecosystems-list-item-chains.tsx +++ b/src/app/[locale]/ecosystems/ecosystems-list/ecosystems-list-item-chains.tsx @@ -8,11 +8,17 @@ import { FC, useState } from 'react'; import BaseModal from '@/components/common/modal/base-modal'; import PlusButton from '@/components/common/plus-button'; import Tooltip from '@/components/common/tooltip'; +import { hasTxPage } from '@/utils/tx-supported-chains'; interface OwnProps { chains: Chain[]; } +// Chains with a working tx page link straight to it; the rest fall back to their +// overview page so non-tx chains never land on the mock-data tx view. +const chainHref = (chain: Chain) => + hasTxPage(chain.name) ? `/networks/${chain.name}/tx` : `/networks/${chain.name}/overview`; + const EcosystemListItemChains: FC = ({ chains }) => { const [isModalOpened, setIsModalOpened] = useState(false); @@ -20,7 +26,7 @@ const EcosystemListItemChains: FC = ({ chains }) => {
{chains.length > 4 &&
{chains.length}:
} {chains.slice(0, 4).map((chain) => ( - + = ({ chains }) => { >
{chains.map((chain) => ( - + ) { return (
- + {children}
); diff --git a/src/app/[locale]/metrics/page.tsx b/src/app/[locale]/metrics/page.tsx index 0f33a5f7..7f8ef50f 100644 --- a/src/app/[locale]/metrics/page.tsx +++ b/src/app/[locale]/metrics/page.tsx @@ -8,7 +8,7 @@ import PageTitle from '@/components/common/page-title'; import PlusButton from '@/components/common/plus-button'; import SubTitle from '@/components/common/sub-title'; import TabList from '@/components/common/tabs/tab-list'; -import { validatorsTabs } from '@/components/common/tabs/tabs-data'; +import { toolsTabsHorizontal } from '@/components/common/tabs/tabs-data'; import { Locale, NextPageWithLocale } from '@/i18n'; export const dynamic = 'force-dynamic'; @@ -30,7 +30,7 @@ const MetricsPage: NextPageWithLocale = async ({ params: { locale } } return (
- + diff --git a/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/blocks/page.tsx b/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/blocks/page.tsx new file mode 100644 index 00000000..55e20ff3 --- /dev/null +++ b/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/blocks/page.tsx @@ -0,0 +1,75 @@ +import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; +import { notFound } from 'next/navigation'; + +import MiningPoolBlocksTable from '@/app/mining-pools/[poolSlug]/(mining-pool-profile)/mining-pool-blocks-table'; +import PageTitle from '@/components/common/page-title'; +import SubTitle from '@/components/common/sub-title'; +import db from '@/db'; +import { Locale, NextPageWithLocale } from '@/i18n'; +import moneroService from '@/services/monero-service'; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +interface PageProps { + params: { poolSlug: string }; + searchParams: { [key: string]: string | string[] | undefined }; +} + +export async function generateMetadata({ params: { locale, poolSlug } }: { params: { locale: Locale; poolSlug: string } }) { + const t = await getTranslations({ locale, namespace: 'MiningPoolDetail' }); + const pool = await db.miningPool.findFirst({ where: { slug: poolSlug }, select: { name: true } }); + + return { title: pool ? `${pool.name} — ${t('metaTitle')}` : t('metaTitle') }; +} + +const BLOCKS_PER_PAGE = 25; + +const MiningPoolBlocksPage: NextPageWithLocale = async ({ params: { locale, poolSlug }, searchParams: q }) => { + unstable_setRequestLocale(locale); + const t = await getTranslations({ locale, namespace: 'MiningPoolDetail' }); + + const pool = await db.miningPool.findFirst({ + where: { slug: poolSlug }, + include: { chain: true }, + }); + + if (!pool) notFound(); + if (!pool.isVerified) notFound(); + + const isMonero = pool.chain.name === 'monero'; + const totalCount = isMonero ? await moneroService.getMoneroPoolBlocksCount(pool.chainId, pool.id) : 0; + const pageLength = Math.max(1, Math.ceil(totalCount / BLOCKS_PER_PAGE)); + const currentPage = Math.min(Math.max(parseInt((q.p as string) || '1', 10) || 1, 1), pageLength); + const sortBy: 'height' | 'timestamp' = q.sortBy === 'height' ? 'height' : 'timestamp'; + const order: 'asc' | 'desc' = q.order === 'asc' ? 'asc' : 'desc'; + + const blocks = isMonero + ? await moneroService.getMoneroPoolRecentBlocks( + pool.chainId, + pool.id, + BLOCKS_PER_PAGE, + (currentPage - 1) * BLOCKS_PER_PAGE, + sortBy, + order, + ) + : []; + + return ( +
+ +
+ +

{t('recentBlocksDescription')}

+ + {blocks.length === 0 ? ( +
{t('recentBlocksEmpty')}
+ ) : ( + + )} +
+
+ ); +}; + +export default MiningPoolBlocksPage; diff --git a/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/layout.tsx b/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/layout.tsx new file mode 100644 index 00000000..24a6b390 --- /dev/null +++ b/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/layout.tsx @@ -0,0 +1,30 @@ +import { unstable_setRequestLocale } from 'next-intl/server'; +import { ReactNode } from 'react'; + +import MiningPoolProfile from '@/app/mining-pools/[poolSlug]/(mining-pool-profile)/mining-pool-profile/mining-pool-profile'; +import CollapsePageHeader from '@/components/common/collapse-page-header'; +import ProfileLayoutWrapper from '@/components/common/page-header-visibility-wrapper'; +import TabList from '@/components/common/tabs/tab-list'; +import { getMiningPoolProfileTabs } from '@/components/common/tabs/tabs-data'; +import { Locale } from '@/i18n'; + +export default async function MiningPoolProfileLayout({ + children, + params: { locale, poolSlug }, +}: Readonly<{ + children: ReactNode; + params: { locale: Locale; poolSlug: string }; +}>) { + unstable_setRequestLocale(locale); + const tabs = getMiningPoolProfileTabs(poolSlug); + + return ( + + + + + + {children} + + ); +} diff --git a/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/mining-pool-blocks-table.tsx b/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/mining-pool-blocks-table.tsx new file mode 100644 index 00000000..388227b8 --- /dev/null +++ b/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/mining-pool-blocks-table.tsx @@ -0,0 +1,75 @@ +import Link from 'next/link'; +import { FC } from 'react'; + +import CopyButton from '@/components/common/copy-button'; +import BaseTable from '@/components/common/table/base-table'; +import BaseTableCell from '@/components/common/table/base-table-cell'; +import BaseTableRow from '@/components/common/table/base-table-row'; +import TableHeaderItem from '@/components/common/table/table-header-item'; +import TablePagination from '@/components/common/table/table-pagination'; +import { MoneroPoolBlock } from '@/services/monero-service'; +import cutHash from '@/utils/cut-hash'; +import { formatTimestamp } from '@/utils/format-timestamp'; + +interface OwnProps { + blocks: MoneroPoolBlock[]; + chainName: string; + pageLength: number; +} + +// Pool's attributed blocks, styled like the validator tx-summary table (sortable headers, underlined +// links, copy buttons) for design parity. +const MiningPoolBlocksTable: FC = ({ blocks, chainName, pageLength }) => { + return ( + + + + + + + + + + {blocks.map((block) => { + const timestamp = formatTimestamp(block.blockTimestamp); + const blockLink = `/networks/${chainName}/blocks/${block.blockHash}`; + + return ( + + + +
+ {block.height.toLocaleString('en-US')} +
+ +
+ +
+ +
+ {cutHash({ value: block.blockHash })} +
+ + +
+
+ +
+ {timestamp} + +
+
+
+ ); + })} + + + + + + +
+ ); +}; + +export default MiningPoolBlocksTable; diff --git a/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/mining-pool-profile/mining-pool-profile.tsx b/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/mining-pool-profile/mining-pool-profile.tsx new file mode 100644 index 00000000..084ec2be --- /dev/null +++ b/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/mining-pool-profile/mining-pool-profile.tsx @@ -0,0 +1,43 @@ +import { getTranslations } from 'next-intl/server'; +import { FC } from 'react'; + +import ProfileBanner, { DEFAULT_PODCAST_PLAYER } from '@/components/common/profile-banner'; +import icons from '@/components/icons'; +import db from '@/db'; +import { safeHref } from '@/utils/safe-href'; + +interface OwnProps { + slug: string; + locale: string; +} + +// Thin data wrapper around the shared ProfileBanner (no podcast — a pool has none). The validator +// profile uses the same banner; only the data source and the optional podcast differ. +const MiningPoolProfile: FC = async ({ slug, locale }) => { + const t = await getTranslations({ locale, namespace: 'MiningPoolProfileHeader' }); + + const pool = await db.miningPool.findFirst({ + where: { slug }, + include: { chain: true }, + }); + if (!pool) return null; + // Mirror the pages' gate (they notFound unverified pools) so the header stays blank above a 404. + if (!pool.isVerified) return null; + + const poolLogo = pool.logoUrl || icons.AvatarIcon; + const chainPretty = pool.chain.prettyName ?? pool.chain.name; + const chains = [{ name: pool.chain.name, logoUrl: pool.chain.logoUrl || icons.AvatarIcon, prettyName: chainPretty }]; + + return ( + + ); +}; + +export default MiningPoolProfile; diff --git a/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/networks/page.tsx b/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/networks/page.tsx new file mode 100644 index 00000000..7e3329f9 --- /dev/null +++ b/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/networks/page.tsx @@ -0,0 +1,109 @@ +import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; +import { notFound } from 'next/navigation'; + +import HashrateWindowSelector from '@/app/networks/[name]/(network-profile)/stats/hashrate-window-selector'; +import PageTitle from '@/components/common/page-title'; +import BaseTable from '@/components/common/table/base-table'; +import BaseTableCell from '@/components/common/table/base-table-cell'; +import BaseTableRow from '@/components/common/table/base-table-row'; +import TableAvatar from '@/components/common/table/table-avatar'; +import TableHeaderItem from '@/components/common/table/table-header-item'; +import db from '@/db'; +import { Locale, NextPageWithLocale } from '@/i18n'; +import moneroService, { HashrateWindow, isValidWindow } from '@/services/monero-service'; +import { formatHashrate } from '@/utils/format-hashrate'; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +interface PageProps { + params: { poolSlug: string }; + searchParams: { [key: string]: string | string[] | undefined }; +} + +export async function generateMetadata({ params: { locale, poolSlug } }: { params: { locale: Locale; poolSlug: string } }) { + const t = await getTranslations({ locale, namespace: 'MiningPoolDetail' }); + const pool = await db.miningPool.findFirst({ where: { slug: poolSlug }, select: { name: true } }); + + return { title: pool ? `${pool.name} — ${t('metaTitle')}` : t('metaTitle') }; +} + +// Default (centre) tab — the pool's technical stats per NETWORK it mines, mirroring the validator-profile +// networks tab (first column = Network, then the metrics) via BaseTable + TableHeaderItem. +const MiningPoolNetworksPage: NextPageWithLocale = async ({ params: { locale, poolSlug }, searchParams: q }) => { + unstable_setRequestLocale(locale); + const t = await getTranslations({ locale, namespace: 'MiningPoolDetail' }); + + const pool = await db.miningPool.findFirst({ + where: { slug: poolSlug }, + include: { chain: true, stats: true }, + }); + + if (!pool) notFound(); + if (!pool.isVerified) notFound(); + + const availableWindows = await moneroService.getMoneroAvailableWindows(); + const windowRaw = Array.isArray(q.window) ? q.window[0] : q.window; + const requested: HashrateWindow = isValidWindow(windowRaw) ? windowRaw : '24h'; + const safeWindow: HashrateWindow = availableWindows.includes(requested) ? requested : '24h'; + + const stat = pool.stats.find((s) => s.windowKind === safeWindow) ?? null; + const feeText = pool.feePercent != null ? `${pool.feePercent.toFixed(2)}%` : '-'; + + const windowLabels: Record = { + '24h': t('window24h'), + '7d': t('window7d'), + '30d': t('window30d'), + all: t('windowAll'), + }; + const windowOptions = availableWindows.map((value) => ({ value, label: windowLabels[value] })); + + return ( +
+ +
+ {windowOptions.length > 1 && ( +
+ +
+ )} + + + + + + + + + + + + + + + + +
{stat ? stat.blocksFound.toLocaleString() : '-'}
+
+ +
{stat ? `${(stat.sharePercent ?? 0).toFixed(2)}%` : '-'}
+
+ +
{stat ? formatHashrate(stat.hashrateEstimate) : '-'}
+
+ +
{feeText}
+
+
+ +
+
+
+ ); +}; + +export default MiningPoolNetworksPage; diff --git a/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/page.tsx b/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/page.tsx new file mode 100644 index 00000000..13ba546c --- /dev/null +++ b/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/page.tsx @@ -0,0 +1,18 @@ +import { redirect } from 'next/navigation'; + +import { NextPageWithLocale } from '@/i18n'; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +interface PageProps { + params: { poolSlug: string }; +} + +// No standalone base page — like the validator profile, the default landing is the centre "networks" +// tab (in-URL). Direct hits on the base URL redirect there so the default tab always shows in the URL. +const MiningPoolProfileBasePage: NextPageWithLocale = ({ params: { poolSlug } }) => { + redirect(`/mining-pools/${poolSlug}/networks`); +}; + +export default MiningPoolProfileBasePage; diff --git a/src/app/[locale]/mining-pools/mining-pool-list-item.tsx b/src/app/[locale]/mining-pools/mining-pool-list-item.tsx new file mode 100644 index 00000000..94048c4b --- /dev/null +++ b/src/app/[locale]/mining-pools/mining-pool-list-item.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { Chain, MiningPool } from '@prisma/client'; +import { useTranslations } from 'next-intl'; +import Image from 'next/image'; +import Link from 'next/link'; +import { FC } from 'react'; + +import BaseTableCell from '@/components/common/table/base-table-cell'; +import BaseTableRow from '@/components/common/table/base-table-row'; +import TableAvatar from '@/components/common/table/table-avatar'; +import Tooltip from '@/components/common/tooltip'; +import icons from '@/components/icons'; +import { safeHref } from '@/utils/safe-href'; + +interface OwnProps { + pool: MiningPool & { chain: Chain }; +} + +const WEB_SIZE = 'h-12 w-12 min-w-12 min-h-12'; + +// Identity row, mirroring SimpleValidatorListItem + ValidatorListItemLinks: Pool | Links | Networks. +const MiningPoolListItem: FC = ({ pool }) => { + const t = useTranslations('common'); + + // Only treat http(s) links as real; anything else (e.g. javascript:) falls back to the "no link" state. + const safeWebsite = safeHref(pool.website); + const safeGithub = safeHref(pool.github); + const safeTwitter = safeHref(pool.twitter); + + return ( + + + + + + +
+
+ {safeWebsite ? ( + +
+ + ) : ( + +
+
+
+ + )} + {safeGithub ? ( + +
+ + ) : ( + +
+
+
+ + )} + {safeTwitter ? ( + +
+ + ) : ( + +
+
+
+ + )} +
+
+ + + +
+ + + {pool.chain.prettyName + + +
+
+ + ); +}; + +export default MiningPoolListItem; diff --git a/src/app/[locale]/mining-pools/mining-pools-filters.tsx b/src/app/[locale]/mining-pools/mining-pools-filters.tsx new file mode 100644 index 00000000..2bf7e5e2 --- /dev/null +++ b/src/app/[locale]/mining-pools/mining-pools-filters.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { FC, useCallback } from 'react'; + +import ValidatorListFiltersPorPage from '@/components/common/list-filters/validator-list-filters-perpage'; + +interface OwnProps { + perPage: number; +} + +const MiningPoolsFilters: FC = ({ perPage }) => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const handlePerPageChanged = useCallback( + (pp: number) => { + const params = new URLSearchParams(Array.from(searchParams.entries())); + params.set('pp', pp.toString()); + params.set('p', '1'); + router.push(`${pathname}?${params.toString()}`); + }, + [pathname, router, searchParams], + ); + + return ( +
+ +
+ ); +}; + +export default MiningPoolsFilters; diff --git a/src/app/[locale]/mining-pools/page.tsx b/src/app/[locale]/mining-pools/page.tsx index ede3f4e8..8806de08 100644 --- a/src/app/[locale]/mining-pools/page.tsx +++ b/src/app/[locale]/mining-pools/page.tsx @@ -1,28 +1,102 @@ +import { Prisma } from '@prisma/client'; import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; -import Description from '@/components/common/description'; -import UnderDevelopment from '@/components/common/under-development'; +import CollapsiblePageHeader from '@/app/validators/collapsible-page-header'; +import MiningPoolListItem from '@/app/mining-pools/mining-pool-list-item'; +import PageHeaderVisibilityWrapper from '@/components/common/page-header-visibility-wrapper'; import PageTitle from '@/components/common/page-title'; +import BaseTable from '@/components/common/table/base-table'; +import TableHeaderItem from '@/components/common/table/table-header-item'; +import TablePagination from '@/components/common/table/table-pagination'; +import TabList from '@/components/common/tabs/tab-list'; +import { validatorsTabs } from '@/components/common/tabs/tabs-data'; +import db from '@/db'; import { NextPageWithLocale } from '@/i18n'; +import { SortDirection } from '@/server/types'; + +import MiningPoolsFilters from './mining-pools-filters'; export const dynamic = 'force-dynamic'; export const revalidate = 0; -const MiningPoolsPage: NextPageWithLocale = async ({ params: { locale } }) => { +interface PageProps { + searchParams: { [key: string]: string | string[] | undefined }; +} + +const VALID_PER_PAGE = [25, 50, 100]; +const DEFAULT_PER_PAGE = 25; + +const parseOrder = (raw: string | string[] | undefined): SortDirection => { + const value = Array.isArray(raw) ? raw[0] : raw; + return value === 'desc' ? 'desc' : 'asc'; +}; + +const parsePerPage = (raw: string | string[] | undefined): number => { + const value = Array.isArray(raw) ? raw[0] : raw; + const parsed = value ? parseInt(value, 10) : NaN; + if (Number.isFinite(parsed) && VALID_PER_PAGE.includes(parsed)) return parsed; + return DEFAULT_PER_PAGE; +}; + +// Global mining-pool directory — pool IDENTITY only (Pool | Links | Networks), mirroring the +// /validators simple list. Only real (verified) pools; the synthetic "unknown/solo" bucket is a +// stats-only aggregate, not a listable pool. Per-window technical stats are network-scoped. +const MiningPoolsPage: NextPageWithLocale = async ({ params: { locale }, searchParams: q }) => { unstable_setRequestLocale(locale); - const t = await getTranslations({ locale, namespace: 'MiningPoolsPage' }); - const underDevelopment = await getTranslations({ locale, namespace: 'UnderDevelopment' }); + const t = await getTranslations({ locale, namespace: 'MiningPoolsList' }); + + const order = parseOrder(q.order); + const perPage = parsePerPage(q.pp); + const requestedPage = parseInt((q.p as string) || '1', 10) || 1; + + // Identity directory: only real (verified) pools. No network filter — pools currently live on a + // single chain (Monero), so a chain dropdown would be a useless one-option control. + const where: Prisma.MiningPoolWhereInput = { isVerified: true }; + + const totalCount = await db.miningPool.count({ where }); + const pageLength = Math.max(1, Math.ceil(totalCount / perPage)); + const currentPage = Math.min(Math.max(requestedPage, 1), pageLength); + + const pools = await db.miningPool.findMany({ + where, + include: { chain: true }, + orderBy: { name: order }, + skip: (currentPage - 1) * perPage, + take: perPage, + }); return ( -
-
+
+ + + + + + + + +
+ + + + + + + + + + {pools.map((pool) => ( + + ))} + + + + + + +
- -
); }; diff --git a/src/app/[locale]/networks/[name]/(network-profile)/governance/offchain-governance-config.ts b/src/app/[locale]/networks/[name]/(network-profile)/governance/offchain-governance-config.ts new file mode 100644 index 00000000..3f263f6b --- /dev/null +++ b/src/app/[locale]/networks/[name]/(network-profile)/governance/offchain-governance-config.ts @@ -0,0 +1,25 @@ +// Per-chain off-chain governance content for PoW networks (no on-chain proposals/voting). +// Keyed by chain.name. Channel labels are proper nouns (handles / repos) and live here, not in +// i18n; the translatable body sentence is referenced by `bodyKey` into the OffchainGovernanceInfo +// namespace. A PoW chain absent from this map falls back to a generic body with no channels list, +// so adding e.g. Bitcoin never shows Monero's text/links by accident. +export interface OffchainChannel { + label: string; + href: string; +} + +export interface OffchainGovernanceData { + bodyKey: string; + channels: OffchainChannel[]; +} + +export const OFFCHAIN_GOVERNANCE: Record = { + monero: { + bodyKey: 'infoBodyMonero', + channels: [ + { label: 'Reddit /r/Monero', href: 'https://www.reddit.com/r/Monero' }, + { label: 'IRC / Matrix', href: 'https://matrix.to/#/#monero:monero.social' }, + { label: 'GitHub: monero-project/monero', href: 'https://github.com/monero-project/monero' }, + ], + }, +}; diff --git a/src/app/[locale]/networks/[name]/(network-profile)/governance/offchain-governance-info.tsx b/src/app/[locale]/networks/[name]/(network-profile)/governance/offchain-governance-info.tsx new file mode 100644 index 00000000..5282dae5 --- /dev/null +++ b/src/app/[locale]/networks/[name]/(network-profile)/governance/offchain-governance-info.tsx @@ -0,0 +1,49 @@ +import { getTranslations } from 'next-intl/server'; +import { FC } from 'react'; + +import { OFFCHAIN_GOVERNANCE } from '@/app/networks/[name]/(network-profile)/governance/offchain-governance-config'; +import SubTitle from '@/components/common/sub-title'; + +interface OwnProps { + chainName: string; +} + +const OffchainGovernanceInfo: FC = async ({ chainName }) => { + const t = await getTranslations('OffchainGovernanceInfo'); + + const config = OFFCHAIN_GOVERNANCE[chainName]; + const channels = config?.channels ?? []; + + return ( +
+ +
+
{t('infoTitle')}
+

+ {config ? t(config.bodyKey as 'infoBodyMonero') : t('infoBodyGeneric')} +

+
+ {channels.length > 0 && ( +
+
{t('channelsTitle')}
+
    + {channels.map(({ label, href }) => ( +
  • + + {label} + +
  • + ))} +
+
+ )} +
+ ); +}; + +export default OffchainGovernanceInfo; diff --git a/src/app/[locale]/networks/[name]/(network-profile)/governance/page.tsx b/src/app/[locale]/networks/[name]/(network-profile)/governance/page.tsx index 5c91debb..6b87c760 100755 --- a/src/app/[locale]/networks/[name]/(network-profile)/governance/page.tsx +++ b/src/app/[locale]/networks/[name]/(network-profile)/governance/page.tsx @@ -26,6 +26,8 @@ import SlashingEventService from '@/services/slashing-event-service'; import { isAztecNetwork } from '@/utils/chain-utils'; import GovernanceTokenDistribution from '@/app/networks/[name]/(network-profile)/governance/governance-token-distribution'; +import OffchainGovernanceInfo + from '@/app/networks/[name]/(network-profile)/governance/offchain-governance-info'; const ProposalsVsTimeChart = nextDynamic( () => import('@/app/networks/[name]/(network-profile)/governance/charts/proposals-vs-time-chart'), @@ -61,6 +63,19 @@ const NetworkGovernancePage: NextPageWithLocale = async ({ params: { const t = await getTranslations({ locale, namespace: 'NetworkGovernance' }); const chain = await chainService.getByName(name); + const isPow = chain?.consensusType === 'pow'; + + if (isPow && chain) { + return ( +
+ + + + +
+ ); + } + const proposalsList = await ProposalService.getListByChainName(name); const isAztec = isAztecNetwork(name); diff --git a/src/app/[locale]/networks/[name]/(network-profile)/network-profile-header/metrics-header.tsx b/src/app/[locale]/networks/[name]/(network-profile)/network-profile-header/metrics-header.tsx index 1337359f..a4272294 100755 --- a/src/app/[locale]/networks/[name]/(network-profile)/network-profile-header/metrics-header.tsx +++ b/src/app/[locale]/networks/[name]/(network-profile)/network-profile-header/metrics-header.tsx @@ -11,34 +11,60 @@ interface OwnProps { chain: ChainWithParamsAndTokenomics | null; } +interface MetricRow { + key: string; + title: string; + data: string; + tooltip?: string; + blur?: boolean; +} + const MetricsHeader: FC = async ({ chain }) => { const t = await getTranslations('NetworkProfileHeader'); - const price = chain ? await chainService.getTokenPriceByChainId(chain?.id) : undefined; + + const isPow = chain?.consensusType === 'pow'; + + const rows: MetricRow[] = []; + + const price = chain ? await chainService.getTokenPriceByChainId(chain.id) : undefined; + // PoW chains (Monero) have no validator set / staking — validatorCost stays 0 and the row blurs, + // same as the placeholder MAU/TVL/Revenue rows below. They render but stay blurred + disabled. const validatorCost = chain?.tokenomics?.activeSetMinAmount && price && chain?.params?.coinDecimals != null - ? (Number(chain.tokenomics.activeSetMinAmount) / 10 ** Number(chain?.params?.coinDecimals)) * Number(price.value) + ? (Number(chain.tokenomics.activeSetMinAmount) / 10 ** Number(chain.params.coinDecimals)) * Number(price.value) : 0; + rows.push({ + key: 'validator cost', + title: t('validator cost'), + data: `$${formatCash(validatorCost)}`, + tooltip: validatorCost?.toLocaleString(), + blur: !validatorCost, + }); + + for (const item of networkProfileExample.headerMetrics) { + rows.push({ + key: item.title, + title: t(item.title as 'tvl' | 'revenue' | 'mau'), + data: String(item.data), + blur: true, + }); + } + + const containerBlur = !isPow && !rows.some((r) => !r.blur); + return ( -
-
- {t('validator cost')} -
- - {`$${formatCash(validatorCost)}`} - - -
-
- {networkProfileExample.headerMetrics.map((item) => ( +
+ {rows.map((row) => (
- {t(item.title as 'tvl')} + {row.title}
- {item.data} + + {row.tooltip ? {row.data} : row.data} +
diff --git a/src/app/[locale]/networks/[name]/(network-profile)/network-profile-header/network-profile-header.tsx b/src/app/[locale]/networks/[name]/(network-profile)/network-profile-header/network-profile-header.tsx index 844b32c8..9d062564 100755 --- a/src/app/[locale]/networks/[name]/(network-profile)/network-profile-header/network-profile-header.tsx +++ b/src/app/[locale]/networks/[name]/(network-profile)/network-profile-header/network-profile-header.tsx @@ -21,6 +21,9 @@ interface OwnProps { const NetworkProfileHeader: FC = async ({ chainName, locale }) => { const t = await getTranslations({ locale, namespace: 'NetworkProfileHeader' }); const chain = await chainService.getByName(chainName); + // PoW (Monero): no nodes / no validator distribution / no apps — blur + disable those header icons. + const isPow = chain?.consensusType === 'pow'; + const powOff = isPow ? 'blur-sm pointer-events-none' : ''; const chainLogo = chain?.logoUrl ?? icons.AvatarIcon; const chainHealth = 40; @@ -58,6 +61,18 @@ const NetworkProfileHeader: FC = async ({ chainName, locale }) => { />
+ {isPow && ( + + +
+ {t('Mining + {t('Mining +
+ +
+ )} {validators?.length != 0 && ( @@ -71,8 +86,8 @@ const NetworkProfileHeader: FC = async ({ chainName, locale }) => { )} - -
+ +
{t('Nodes')} {t('Nodes')} = async ({ chainName, locale }) => {
-
+
{t('distribution {t('distribution = async ({ chainName, locale }) => { -
+
{t('Apps')} {t('Apps')} = { + Daily: 'day', + Weekly: 'week', + Monthly: 'month', + Yearly: 'year', +}; + +const getWeekStart = (date: Date): string => { + const d = new Date(date); + const day = d.getDay(); + const diff = d.getDate() - day + (day === 0 ? -6 : 1); + d.setDate(diff); + return d.toISOString().split('T')[0]; +}; + +const aggregateByPeriod = (dailyData: MoneroHashratePoint[], period: PeriodType): MoneroHashratePoint[] => { + if (period === 'day') return dailyData; + + const aggregated = new Map(); + + dailyData.forEach((point) => { + const date = new Date(point.date); + let key: string; + if (period === 'week') { + key = getWeekStart(date); + } else if (period === 'month') { + key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + } else { + key = String(date.getFullYear()); + } + + const existing = aggregated.get(key); + if (!existing || new Date(point.date) > new Date(existing.date)) { + aggregated.set(key, { date: key, hashrate: point.hashrate }); + } + }); + + return Array.from(aggregated.values()).sort((a, b) => a.date.localeCompare(b.date)); +}; + +const MoneroHashrateChart: FC = ({ initialData }) => { + const [period, setPeriod] = useState('day'); + const [chartType, setChartType] = useState('Daily'); + const chartRef = useRef>(null); + + const data = useMemo(() => aggregateByPeriod(initialData, period), [initialData, period]); + + const { scaledData, unit } = useMemo(() => { + if (data.length === 0) return { scaledData: [] as MoneroHashratePoint[], unit: 'H/s' }; + const maxRaw = Math.max(...data.map((p) => p.hashrate)); + const sample = scaleHashrateForChart(maxRaw); + const divisor = maxRaw > 0 ? maxRaw / sample.value : 1; + return { + scaledData: data.map((p) => ({ date: p.date, hashrate: divisor > 0 ? p.hashrate / divisor : p.hashrate })), + unit: sample.unit, + }; + }, [data]); + + // Frame the visible data tightly (not from zero) so small hashrate variation reads as a real + // curve instead of a flat line — same idea as the tokenomics price chart (beginAtZero: false + + // padded range). Pad by a share of the visible span; fall back to ±5% when the data is constant. + const getAdaptiveYRange = useCallback( + (startIndex: number, endIndex: number): { min: number; max: number } => { + if (scaledData.length === 0) return { min: 0, max: 10 }; + const visible = scaledData.slice(startIndex, endIndex + 1); + if (visible.length === 0) return { min: 0, max: 10 }; + const values = visible.map((p) => p.hashrate); + const maxValue = Math.max(...values); + const minValue = Math.min(...values); + const span = maxValue - minValue; + if (span <= 0) { + const pad = maxValue * 0.05 || 1; + return { min: Math.max(0, maxValue - pad), max: maxValue + pad }; + } + const pad = span * 0.25; + return { min: Math.max(0, minValue - pad), max: maxValue + pad }; + }, + [scaledData], + ); + + const updateYAxis = useCallback( + (chart: ChartJS<'line'>) => { + const xScale = chart.scales.x; + if (!xScale) return; + + const minIndex = Math.max(0, Math.floor(xScale.min)); + const maxIndex = Math.min(scaledData.length - 1, Math.ceil(xScale.max)); + const { min: yMin, max: yMax } = getAdaptiveYRange(minIndex, maxIndex); + const yScale = chart.scales.y; + + if (yScale && (yScale.max !== yMax || yScale.min !== yMin)) { + yScale.options.min = yMin; + yScale.options.max = yMax; + chart.update('none'); + } + }, + [scaledData.length, getAdaptiveYRange], + ); + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const day = date.getDate(); + const monthShort = date.toLocaleDateString('en-US', { month: 'short' }); + if (period === 'day' || period === 'week') return `${day} ${monthShort}`; + if (period === 'month') return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); + return date.getFullYear().toString(); + }; + + const initialYRange = useMemo( + () => getAdaptiveYRange(0, scaledData.length - 1), + [scaledData, getAdaptiveYRange], + ); + + const chartData = { + labels: scaledData.map((p) => formatDate(p.date)), + datasets: [ + { + label: 'Hashrate', + data: scaledData.map((p) => p.hashrate), + borderColor: '#4FB848', + borderWidth: 2, + pointRadius: 0, + pointHoverRadius: 0, + tension: 0.4, + fill: false, + yAxisID: 'y', + }, + ], + }; + + const options: ChartOptions<'line'> = { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { display: false }, + tooltip: { + enabled: true, + backgroundColor: '#1E1E1E', + titleColor: '#2077E0', + bodyColor: '#FFFFFF', + borderColor: '#444444', + borderWidth: 1, + padding: 12, + displayColors: true, + boxWidth: 10, + boxHeight: 10, + boxPadding: 6, + usePointStyle: false, + titleFont: { family: 'Handjet, monospace', size: 14, weight: 400 }, + bodyFont: { family: 'SF Pro, -apple-system, BlinkMacSystemFont, sans-serif', size: 13 }, + callbacks: { + title: (tooltipItems) => { + if (tooltipItems.length === 0) return ''; + const index = tooltipItems[0].dataIndex; + const point = scaledData[index]; + if (!point) return ''; + const date = new Date(point.date); + return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); + }, + label: (context) => { + const value = context.parsed.y; + return ` Hashrate ${value.toFixed(2)} ${unit}`; + }, + labelColor: (context) => ({ + borderColor: '#FFFFFF', + backgroundColor: context.dataset.borderColor as string, + borderWidth: 1, + }), + }, + }, + zoom: { + pan: { + enabled: true, + mode: 'x', + onPan: ({ chart }) => updateYAxis(chart as ChartJS<'line'>), + onPanComplete: ({ chart }) => updateYAxis(chart as ChartJS<'line'>), + }, + zoom: { + wheel: { enabled: true }, + pinch: { enabled: true }, + mode: 'x', + onZoom: ({ chart }) => updateYAxis(chart as ChartJS<'line'>), + onZoomComplete: ({ chart }) => updateYAxis(chart as ChartJS<'line'>), + }, + }, + }, + scales: { + x: { + grid: { display: true, drawOnChartArea: false, drawTicks: true, tickLength: 6, tickColor: '#3E3E3E' }, + ticks: { + color: 'rgba(255, 255, 255, 0.8)', + font: { family: 'Handjet, monospace', size: 12 }, + maxRotation: 0, + minRotation: 0, + padding: 4, + autoSkip: true, + maxTicksLimit: 10, + callback: function (value, index) { + if (index === 0) return ''; + return this.getLabelForValue(value as number); + }, + }, + border: { color: '#3E3E3E' }, + }, + y: { + grid: { display: true, drawOnChartArea: false, drawTicks: true, tickLength: 6, tickColor: '#3E3E3E' }, + ticks: { + color: 'rgba(255, 255, 255, 0.8)', + font: { family: 'Handjet, monospace', size: 12 }, + callback: (value) => Number(value).toLocaleString('en-US', { maximumFractionDigits: 2 }), + }, + border: { color: '#3E3E3E' }, + afterFit: (axis) => { + axis.width = 60; + }, + position: 'left', + beginAtZero: false, + min: initialYRange.min, + max: initialYRange.max, + }, + }, + }; + + const handleTypeChanged = (name: string) => { + setChartType(name); + const mapped = periodMapping[name]; + if (mapped) setPeriod(mapped); + if (chartRef.current) chartRef.current.resetZoom(); + }; + + // Show a period filter only when the data aggregates into enough buckets to be meaningful — + // mirrors the tokenomics price chart. Daily is always available; weekly/monthly/yearly appear + // only once there are at least MIN_DATA_POINTS buckets at that granularity. + const MIN_DATA_POINTS = 3; + const periodButtons = useMemo( + () => + (['Daily', 'Weekly', 'Monthly', 'Yearly'] as const).filter((label) => { + const periodType = periodMapping[label]; + if (periodType === 'day') return true; + return aggregateByPeriod(initialData, periodType).length >= MIN_DATA_POINTS; + }), + [initialData], + ); + + return ( +
+
+
+ {periodButtons.map((name) => ( + + ))} +
+ + {scaledData.length === 0 ? ( +
+

Data is currently unavailable

+
+ ) : ( + + )} +
+ +
+
+
+
+ {`Hashrate (${unit})`} +
+
+
+
+ ); +}; + +export default MoneroHashrateChart; diff --git a/src/app/[locale]/networks/[name]/(network-profile)/overview/monero-hashrate-section.tsx b/src/app/[locale]/networks/[name]/(network-profile)/overview/monero-hashrate-section.tsx new file mode 100644 index 00000000..4c5675f6 --- /dev/null +++ b/src/app/[locale]/networks/[name]/(network-profile)/overview/monero-hashrate-section.tsx @@ -0,0 +1,39 @@ +import dynamic from 'next/dynamic'; +import { getTranslations } from 'next-intl/server'; +import { FC } from 'react'; + +import SubTitle from '@/components/common/sub-title'; +import moneroService from '@/services/monero-service'; + +// The chart is a heavy client component (chart.js + zoom/crosshair plugins) — load it client-side +// only, mirroring NetworkTvsAztecChart in network-apr-tvs.tsx. +const MoneroHashrateChart = dynamic(() => import('./monero-hashrate-chart'), { + ssr: false, + loading: () => ( +
+
Loading chart...
+
+ ), +}); + +interface OwnProps { + locale: string; +} + +const MoneroHashrateSection: FC = async ({ locale }) => { + const t = await getTranslations({ locale, namespace: 'NetworkPassport' }); + const data = await moneroService.getMoneroChartData(); + + if (data.length === 0) return null; + + return ( +
+ +
+ +
+
+ ); +}; + +export default MoneroHashrateSection; diff --git a/src/app/[locale]/networks/[name]/(network-profile)/overview/monero-network-rows.tsx b/src/app/[locale]/networks/[name]/(network-profile)/overview/monero-network-rows.tsx new file mode 100644 index 00000000..fd077df0 --- /dev/null +++ b/src/app/[locale]/networks/[name]/(network-profile)/overview/monero-network-rows.tsx @@ -0,0 +1,112 @@ +import { getTranslations } from 'next-intl/server'; +import Link from 'next/link'; +import { FC } from 'react'; + +import moneroService from '@/services/monero-service'; +import { formatHashrate } from '@/utils/format-hashrate'; + +interface OwnProps { + chainName: string; + blockTimeTarget: string; +} + +const formatRelativeTime = (date: Date | null | undefined, suffix: string): string => { + if (!date) return '-'; + const diffMs = Date.now() - date.getTime(); + if (diffMs < 0) return '-'; + const seconds = Math.floor(diffMs / 1000); + if (seconds < 60) return `${seconds}s ${suffix}`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ${suffix}`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ${suffix}`; + const days = Math.floor(hours / 24); + return `${days}d ${suffix}`; +}; + +const MoneroNetworkRows: FC = async ({ chainName, blockTimeTarget }) => { + const t = await getTranslations('NetworkPassport'); + const [snapshot, activePools] = await Promise.all([ + moneroService.getMoneroNetworkSnapshot(), + moneroService.getMoneroActivePoolsCount('24h'), + ]); + + if (!snapshot) { + return ( +
+
+ {t('tip height')} +
+
+ {t('no snapshot')} +
+
+ ); + } + + const difficultyDisplay = (() => { + try { + return BigInt(snapshot.difficulty).toLocaleString(); + } catch { + return snapshot.difficulty; + } + })(); + + return ( + <> +
+
+ {t('tip height')} +
+ + {snapshot.height.toLocaleString()} + +
+
+
+ {t('network hashrate')} +
+
+ {formatHashrate(snapshot.hashrate)} +
+
+
+
+ {t('current difficulty')} +
+
+ {difficultyDisplay} +
+
+
+
+ {t('last block time')} +
+
+ {formatRelativeTime(snapshot.snapshotAt, t('ago'))} +
+
+
+
+ {t('active pools')} +
+
+ {activePools.toLocaleString()} +
+
+
+
+ {t('block time target')} +
+
+ {blockTimeTarget} +
+
+ + ); +}; + +export default MoneroNetworkRows; diff --git a/src/app/[locale]/networks/[name]/(network-profile)/overview/network-apr-tvs.tsx b/src/app/[locale]/networks/[name]/(network-profile)/overview/network-apr-tvs.tsx index 23986718..590fa306 100755 --- a/src/app/[locale]/networks/[name]/(network-profile)/overview/network-apr-tvs.tsx +++ b/src/app/[locale]/networks/[name]/(network-profile)/overview/network-apr-tvs.tsx @@ -46,7 +46,7 @@ const NetworkAprTvs: FC = async ({ chain }) => {
APR
{((chain?.tokenomics?.apr ?? 0) * 100).toFixed(2)}%
@@ -55,7 +55,7 @@ const NetworkAprTvs: FC = async ({ chain }) => {
TVS
{((chain?.tokenomics?.tvs ?? 0) * 100).toFixed(2)}%
@@ -64,13 +64,22 @@ const NetworkAprTvs: FC = async ({ chain }) => {
{t('Validator Count')}
- - {validatorsCount} - + {validatorsCount > 0 ? ( + + {validatorsCount} + + ) : ( +
+ {validatorsCount} +
+ )}
diff --git a/src/app/[locale]/networks/[name]/(network-profile)/overview/network-overview.tsx b/src/app/[locale]/networks/[name]/(network-profile)/overview/network-overview.tsx index 78a84e91..5b1da156 100755 --- a/src/app/[locale]/networks/[name]/(network-profile)/overview/network-overview.tsx +++ b/src/app/[locale]/networks/[name]/(network-profile)/overview/network-overview.tsx @@ -18,6 +18,7 @@ import formatCash from '@/utils/format-cash'; import AztecBlockTimeDisplay from './aztec-block-time-display'; import CommitteeSizeDisplay from './committee-size-display'; +import MoneroNetworkRows from './monero-network-rows'; interface OwnProps { @@ -263,6 +264,7 @@ const NetworkOverview: FC = async ({ chain }) => { const isCosmoshub = chain?.name === 'cosmoshub'; const isMiden = chain?.name === 'miden-testnet'; const isAtomone = chain?.name === 'atomone'; + const isMonero = chain?.name === 'monero'; const activeValidators = chain ? isAztec ? await validatorService.getAztecValidators(chain.name as 'aztec' | 'aztec-testnet', chain.id) @@ -500,6 +502,13 @@ const NetworkOverview: FC = async ({ chain }) => {
)} + ) : isMonero && chain ? ( + + + ) : ( !!chain?.avgTxInterval && (
diff --git a/src/app/[locale]/networks/[name]/(network-profile)/overview/page.tsx b/src/app/[locale]/networks/[name]/(network-profile)/overview/page.tsx index 2f8e3912..3fb4028d 100755 --- a/src/app/[locale]/networks/[name]/(network-profile)/overview/page.tsx +++ b/src/app/[locale]/networks/[name]/(network-profile)/overview/page.tsx @@ -1,5 +1,6 @@ import { getTranslations } from 'next-intl/server'; +import MoneroHashrateSection from '@/app/networks/[name]/(network-profile)/overview/monero-hashrate-section'; import NetworkAprTvs from '@/app/networks/[name]/(network-profile)/overview/network-apr-tvs'; import NetworkOverview from '@/app/networks/[name]/(network-profile)/overview/network-overview'; import PageTitle from '@/components/common/page-title'; @@ -23,16 +24,18 @@ export async function generateMetadata({ params: { locale } }: { params: { local }; } -const NetworkPassportPage: NextPageWithLocale = async ({ params: { locale, name } }) => { - const t = await getTranslations({ locale, namespace: 'NetworkPassport' }); +const NetworkPassportPage: NextPageWithLocale = async ({ params: { name, locale } }) => { + const t = await getTranslations('NetworkPassport'); const chain = await chainService.getByName(name); + const isPow = chain?.consensusType === 'pow'; return (
- + {!isPow && } + {chain?.name === 'monero' && }
); diff --git a/src/app/[locale]/networks/[name]/(network-profile)/stats/hashrate-window-selector.tsx b/src/app/[locale]/networks/[name]/(network-profile)/stats/hashrate-window-selector.tsx new file mode 100644 index 00000000..508b96b3 --- /dev/null +++ b/src/app/[locale]/networks/[name]/(network-profile)/stats/hashrate-window-selector.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { FC, useCallback } from 'react'; + +import { cn } from '@/utils/cn'; + +import type { HashrateWindow } from '@/services/monero-service'; + +interface WindowOption { + value: HashrateWindow; + label: string; +} + +interface OwnProps { + current: HashrateWindow; + options: WindowOption[]; +} + +const HashrateWindowSelector: FC = ({ current, options }) => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const handleSelect = useCallback( + (value: HashrateWindow) => { + const params = new URLSearchParams(Array.from(searchParams.entries())); + params.set('window', value); + router.push(`${pathname}?${params.toString()}`); + }, + [pathname, router, searchParams], + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent, value: HashrateWindow) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleSelect(value); + } + }, + [handleSelect], + ); + + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +}; + +export default HashrateWindowSelector; diff --git a/src/app/[locale]/networks/[name]/(network-profile)/stats/page.tsx b/src/app/[locale]/networks/[name]/(network-profile)/stats/page.tsx index 2119385d..9bd2280c 100755 --- a/src/app/[locale]/networks/[name]/(network-profile)/stats/page.tsx +++ b/src/app/[locale]/networks/[name]/(network-profile)/stats/page.tsx @@ -4,6 +4,7 @@ import { Suspense } from 'react'; import NetworkStatistics from '@/app/networks/[name]/(network-profile)/stats/network-statistics'; import OperatorDistribution from '@/app/networks/[name]/(network-profile)/stats/operator-distribution'; import OperatorDistributionSkeleton from '@/app/networks/[name]/(network-profile)/stats/operator-distribution-skeleton'; +import PowNetworkStats from '@/app/networks/[name]/(network-profile)/stats/pow-network-stats'; import SocialStatistics from '@/app/networks/[name]/(network-profile)/stats/social-statistics'; import TransactionVolumeChart from '@/app/networks/[name]/(network-profile)/stats/transaction-volume-chart'; import CollapsiblePageHeader from '@/app/validators/collapsible-page-header'; @@ -11,12 +12,14 @@ import PageTitle from '@/components/common/page-title'; import SubTitle from '@/components/common/sub-title'; import { Locale, NextPageWithLocale } from '@/i18n'; import chainService from '@/services/chain-service'; +import { HashrateWindow } from '@/services/monero-service'; export const dynamic = 'force-dynamic'; export const revalidate = 0; interface PageProps { params: NextPageWithLocale & { name: string }; + searchParams: { [key: string]: string | string[] | undefined }; } export async function generateMetadata({ params: { locale } }: { params: { locale: Locale } }) { @@ -27,23 +30,41 @@ export async function generateMetadata({ params: { locale } }: { params: { local }; } -const NetworkStatisticsPage: NextPageWithLocale = async ({ params: { locale, name } }) => { - const t = await getTranslations({ locale, namespace: 'NetworkStatistics' }); +const NetworkStatisticsPage: NextPageWithLocale = async ({ + params: { name }, + searchParams, +}) => { + const t = await getTranslations('NetworkStatistics'); const chain = await chainService.getByName(name); + const isPow = chain?.consensusType === 'pow'; + const rawWindow = Array.isArray(searchParams.window) ? searchParams.window[0] : searchParams.window; + const windowParam = (rawWindow ?? '24h') as HashrateWindow; + return (
- - + {!isPow && } + {isPow && chain ? ( + + + + ) : ( + <> + + + + + )} - - - }> - - + {/* Operator/validator distribution — N/A for PoW (no validators). Shown but blurred + disabled. */} +
+ }> + + +
); }; diff --git a/src/app/[locale]/networks/[name]/(network-profile)/stats/pow-network-stats.tsx b/src/app/[locale]/networks/[name]/(network-profile)/stats/pow-network-stats.tsx new file mode 100644 index 00000000..595d9e30 --- /dev/null +++ b/src/app/[locale]/networks/[name]/(network-profile)/stats/pow-network-stats.tsx @@ -0,0 +1,90 @@ +import { getTranslations } from 'next-intl/server'; +import Link from 'next/link'; +import { FC } from 'react'; + +import SubTitle from '@/components/common/sub-title'; +import moneroService, { HashrateWindow, isValidWindow } from '@/services/monero-service'; + +import HashrateWindowSelector from './hashrate-window-selector'; + +interface OwnProps { + window: HashrateWindow; +} + +// Share-based bar colour (centralisation signal), mirrors the app's PowerBarChart palette. +const barColor = (pct: number): string => { + if (pct > 33) return '#EB1616B2'; + if (pct >= 10) return '#E5C46BB2'; + return '#4FB848B2'; +}; + +// PoW network stats — the centralisation "Pool Distribution" bars. The per-pool technical table lives on +// /networks/[name]/mining-pools (reachable from the network header), so it is not duplicated here. +const PowNetworkStats: FC = async ({ window }) => { + const t = await getTranslations('PowNetworkStats'); + + const availableWindows = await moneroService.getMoneroAvailableWindows(); + const requested: HashrateWindow = isValidWindow(window) ? window : '24h'; + const safeWindow: HashrateWindow = availableWindows.includes(requested) ? requested : '24h'; + + const rawStats = await moneroService.getMoneroPoolStats(safeWindow); + // Pin the unknown/solo bucket last; keep the rest share-desc (design §7). + const poolStats = [...rawStats].sort( + (a, b) => (a.pool.slug === 'unknown' ? 1 : 0) - (b.pool.slug === 'unknown' ? 1 : 0), + ); + + const windowLabels: Record = { + '24h': t('window24h'), + '7d': t('window7d'), + '30d': t('window30d'), + all: t('windowAll'), + }; + const windowOptions = availableWindows.map((value) => ({ value, label: windowLabels[value] })); + + return ( +
+
+
+ + {windowOptions.length > 1 && } +
+ {poolStats.length > 0 ? ( +
+ {poolStats.map((stat) => { + const pct = stat.sharePercent ?? 0; + // Unknown/solo is a mix of many miners, not one pool — neutral grey, not a centralisation colour. + const fillColor = stat.pool.slug === 'unknown' ? '#6B7280B2' : barColor(pct); + return ( +
+ {stat.pool.isVerified && stat.pool.slug !== 'unknown' ? ( + + {stat.pool.name} + + ) : ( + {stat.pool.name} + )} +
+
+
+ + {pct.toFixed(2)}% + +
+ ); + })} +
+ ) : ( +
{t('noPoolData')}
+ )} +
+
+ ); +}; + +export default PowNetworkStats; diff --git a/src/app/[locale]/networks/[name]/(network-profile)/tokenomics/page.tsx b/src/app/[locale]/networks/[name]/(network-profile)/tokenomics/page.tsx index a3d4c6fb..7a4f94a0 100644 --- a/src/app/[locale]/networks/[name]/(network-profile)/tokenomics/page.tsx +++ b/src/app/[locale]/networks/[name]/(network-profile)/tokenomics/page.tsx @@ -38,9 +38,11 @@ export async function generateMetadata({ params: { locale } }: { params: { local }; } -const NetworkTokenomicsPage: NextPageWithLocale = async ({ params: { locale, name } }) => { - const t = await getTranslations({ locale, namespace: 'NetworkTokenomics' }); +const NetworkTokenomicsPage: NextPageWithLocale = async ({ params: { name } }) => { + const t = await getTranslations('NetworkTokenomics'); const chain = await chainService.getByName(name); + const isPow = chain?.consensusType === 'pow'; + const tokenPrice = chain ? await chainService.getTokenPriceByChainId(chain?.id) : null; const tokenomics = chain ? await TokenomicsService.getTokenomicsByChainId(chain?.id) : null; const chartData = chain ? await priceHistoryService.getChartData(chain.id) : []; @@ -78,7 +80,10 @@ const NetworkTokenomicsPage: NextPageWithLocale = async ({ params: { )}
- + {/* Gini / staking distribution (community pool, rewards, undelegations) — N/A for PoW. */} +
+ +
); }; diff --git a/src/app/[locale]/networks/[name]/blocks/[hash]/block-information.tsx b/src/app/[locale]/networks/[name]/blocks/[hash]/block-information.tsx index 67600836..b583fa44 100644 --- a/src/app/[locale]/networks/[name]/blocks/[hash]/block-information.tsx +++ b/src/app/[locale]/networks/[name]/blocks/[hash]/block-information.tsx @@ -12,6 +12,7 @@ import LogosBlockInformation from '@/app/networks/[name]/blocks/[hash]/logos-blo import CosmosBlockInformation from '@/app/networks/[name]/blocks/[hash]/cosmos-block-information'; import MidenBlockInformation from '@/app/networks/[name]/blocks/[hash]/miden-block-information'; import AtomoneBlockInformation from '@/app/networks/[name]/blocks/[hash]/atomone-block-information'; +import MoneroBlockInformation from '@/app/networks/[name]/blocks/[hash]/monero-block-information'; interface OwnProps { chain: ChainWithParams | null; @@ -19,6 +20,10 @@ interface OwnProps { } const BlockInformation: FC = async ({ chain, hash }) => { + if (chain && chain.consensusType === 'pow') { + return ; + } + if (chain?.name === 'logos-testnet') { return ; } diff --git a/src/app/[locale]/networks/[name]/blocks/[hash]/expand/expanded-block-information.tsx b/src/app/[locale]/networks/[name]/blocks/[hash]/expand/expanded-block-information.tsx index 8b343e9c..39d06c12 100644 --- a/src/app/[locale]/networks/[name]/blocks/[hash]/expand/expanded-block-information.tsx +++ b/src/app/[locale]/networks/[name]/blocks/[hash]/expand/expanded-block-information.tsx @@ -3,18 +3,44 @@ import { notFound } from 'next/navigation'; import { FC } from 'react'; import CopyButton from '@/components/common/copy-button'; +import BaseTable from '@/components/common/table/base-table'; +import BaseTableCell from '@/components/common/table/base-table-cell'; +import BaseTableRow from '@/components/common/table/base-table-row'; +import TableHeaderItem from '@/components/common/table/table-header-item'; import { ChainWithParams } from '@/services/chain-service'; import { aztecIndexer } from '@/services/aztec-indexer-api'; import atomoneIndexer from '@/services/atomone-indexer-api'; import cosmosIndexer from '@/services/cosmos-indexer-api'; import logosIndexer from '@/services/logos-indexer-api'; import midenIndexer from '@/services/miden-indexer-api'; +import Link from 'next/link'; +import { getMoneroBlockDetail } from '@/server/tools/chains/monero/indexer-client'; +import cutHash from '@/utils/cut-hash'; +import { formatXmrReward } from '@/utils/monero'; interface OwnProps { chain: ChainWithParams | null; hash: string; } +const formatBytes = (bytes: number): string => { + if (!bytes) return '-'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +}; + +// Header cell matching the site's TableSortItems look (no sort). +const HeaderCell: FC<{ label: string }> = ({ label }) => ( + +
+
+
 {label}
+
+
+
+); + const ExpandedBlockInformation: FC = async ({ chain, hash }) => { const t = await getTranslations('BlockInformationPage'); const isLogos = chain?.name === 'logos-testnet'; @@ -22,6 +48,112 @@ const ExpandedBlockInformation: FC = async ({ chain, hash }) => { const isMiden = chain?.name === 'miden-testnet'; const isAtomone = chain?.name === 'atomone'; + if (chain?.consensusType === 'pow') { + const block = await getMoneroBlockDetail(hash).catch(() => null); + if (!block) { + notFound(); + } + + const expandedData: Array<{ title: string; data: string | number; type: 'hash' | 'number' | 'text' }> = [ + { + title: 'cumulative difficulty', + data: block.cumulativeDifficulty != null ? block.cumulativeDifficulty.toLocaleString('en-US') : '-', + type: 'text', + }, + { title: 'long term weight', data: block.longTermWeight, type: 'number' }, + { title: 'coinbase extra', data: block.coinbaseExtraHex ?? '-', type: 'hash' }, + { title: 'is canonical', data: block.isCanonical ? t('yes') : t('no'), type: 'text' }, + { title: 'is settled', data: block.isSettled ? t('yes') : t('no'), type: 'text' }, + { title: 'orphan status', data: block.orphanStatus ? t('yes') : t('no'), type: 'text' }, + { title: 'indexed at', data: block.indexedAt, type: 'text' }, + ]; + + const formatMonero = (data: string | number, type: 'hash' | 'number' | 'text') => { + if (type === 'hash') { + return ( +
+ {data} + +
+ ); + } + if (type === 'number') { + return ( +
+ {typeof data === 'number' ? data.toLocaleString('en-US') : data} +
+ ); + } + return
{data}
; + }; + + return ( +
+ {expandedData.map((item) => ( +
+
+ {t(item.title as 'block hash')} +
+
+ {formatMonero(item.data, item.type)} +
+
+ ))} + +
+
+ {t('block transactions')} ({block.transactions.length}) +
+

{t('amounts hidden')}

+ {block.transactions.length === 0 ? ( +
{t('no txs in block')}
+ ) : ( + + + + + + + + + + + + {block.transactions.map((tx) => ( + + + + + {cutHash({ value: tx.hash, cutLength: 12 })} + + + + +
+ {tx.isCoinbase ? t('coinbase') : t('regular')} +
+
+ +
{formatXmrReward(tx.fee)}
+
+ +
{formatBytes(tx.size)}
+
+ +
+ {tx.inputsCount} / {tx.outputsCount} +
+
+
+ ))} + +
+ )} +
+
+ ); + } + if (isMiden) { let block; try { diff --git a/src/app/[locale]/networks/[name]/blocks/[hash]/json/json-block-information.tsx b/src/app/[locale]/networks/[name]/blocks/[hash]/json/json-block-information.tsx index 68cc0b29..59b24103 100644 --- a/src/app/[locale]/networks/[name]/blocks/[hash]/json/json-block-information.tsx +++ b/src/app/[locale]/networks/[name]/blocks/[hash]/json/json-block-information.tsx @@ -8,6 +8,7 @@ import cosmosIndexer from '@/services/cosmos-indexer-api'; import logosIndexer from '@/services/logos-indexer-api'; import midenIndexer from '@/services/miden-indexer-api'; import { ChainWithParams } from '@/services/chain-service'; +import { getMoneroBlockDetail } from '@/server/tools/chains/monero/indexer-client'; interface OwnProps { chain: ChainWithParams | null; @@ -15,6 +16,27 @@ interface OwnProps { } const JsonBlockInformation: FC = async ({ chain, hash }) => { + if (chain?.consensusType === 'pow') { + const block = await getMoneroBlockDetail(hash).catch(() => null); + if (!block) { + notFound(); + } + // difficulty/cumulativeDifficulty are BigInt — stringify them so JSON.stringify doesn't throw. + const jsonString = JSON.stringify(block, (_key, value) => (typeof value === 'bigint' ? value.toString() : value), 4); + return ( +
+
+
+
{jsonString}
+
+
+ +
+
+
+ ); + } + const isLogos = chain?.name === 'logos-testnet'; const isCosmoshub = chain?.name === 'cosmoshub'; const isMiden = chain?.name === 'miden-testnet'; diff --git a/src/app/[locale]/networks/[name]/blocks/[hash]/monero-block-information.tsx b/src/app/[locale]/networks/[name]/blocks/[hash]/monero-block-information.tsx new file mode 100644 index 00000000..56a7d130 --- /dev/null +++ b/src/app/[locale]/networks/[name]/blocks/[hash]/monero-block-information.tsx @@ -0,0 +1,139 @@ +import { getTranslations } from 'next-intl/server'; +import { unstable_noStore as noStore } from 'next/cache'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { FC } from 'react'; + +import CopyButton from '@/components/common/copy-button'; +import RoundedButton from '@/components/common/rounded-button'; +import Tooltip from '@/components/common/tooltip'; +import { getMoneroBlockDetail } from '@/server/tools/chains/monero/indexer-client'; +import { ChainWithParams } from '@/services/chain-service'; +import { getPoolByBlockHashes } from '@/services/monero-service'; +import { bigIntSafeCache } from '@/utils/bigint-safe-cache'; +import cutHash from '@/utils/cut-hash'; +import { formatTimestamp } from '@/utils/format-timestamp'; +import { formatXmrReward } from '@/utils/monero'; + +interface OwnProps { + chain: ChainWithParams; + hash: string; +} + +// Block-by-hash is immutable — cache the indexer payload for 1h across requests. +const getCachedBlockDetail = bigIntSafeCache( + (hash: string) => getMoneroBlockDetail(hash), + ['monero-block-detail'], + { revalidate: 3600 }, +); + +const formatBytes = (bytes: number): string => { + if (!bytes) return '-'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +}; + +const MoneroBlockInformation: FC = async ({ chain, hash }) => { + const t = await getTranslations('BlockInformationPage'); + + // Pool attribution (getPoolByBlockHashes) lands after a block is first seen, so keep this + // render dynamic — a frozen full-route cache would pin "Mined By" to Unidentified. + noStore(); + + const block = await getCachedBlockDetail(hash).catch(() => null); + if (!block) notFound(); + + const poolMap = await getPoolByBlockHashes([block.hash]); + const poolName = poolMap.get(block.hash)?.name ?? t('unknown pool'); + + const blockData: { title: string; data: string | number }[] = [ + { title: 'block hash', data: block.hash }, + { title: 'block height', data: block.height }, + { title: 'timestamp', data: formatTimestamp(new Date(block.timestamp * 1000)) }, + { title: 'mining pool', data: poolName }, + { title: 'transaction count', data: block.txCount }, + { title: 'block reward', data: formatXmrReward(block.reward) }, + { title: 'size', data: formatBytes(block.size) }, + { title: 'weight', data: block.weight.toLocaleString('en-US') }, + { title: 'difficulty', data: block.difficulty != null ? block.difficulty.toLocaleString('en-US') : '-' }, + { title: 'version', data: `${block.majorVersion}.${block.minorVersion}` }, + { title: 'nonce', data: block.nonce }, + { title: 'previous block', data: block.prevHash }, + { title: 'miner tx hash', data: block.minerTxHash }, + ]; + + const formatData = (title: string, data: number | string) => { + switch (title) { + case 'block hash': + case 'miner tx hash': + return ( +
+ {data} + +
+ ); + case 'previous block': + return ( + + {data} + + ); + case 'block height': + return
{Number(data).toLocaleString('en-US')}
; + case 'mining pool': + return
{data}
; + case 'transaction count': + case 'nonce': + return
{Number(data).toLocaleString('en-US')}
; + default: + return
{data}
; + } + }; + + return ( +
+
+
+
+ +
+ +
+
+
+
+ {t('block')} #{block.height.toLocaleString('en-US')} +
+
+
+ {cutHash({ value: block.hash, cutLength: 16 })} + +
+
+
+
+ + {t('show all blocks')} + +
+
+ + {blockData.map((item) => ( +
+
+ {t(item.title as 'block hash')} +
+
+ {formatData(item.title, item.data)} +
+
+ ))} +
+ ); +}; + +export default MoneroBlockInformation; diff --git a/src/app/[locale]/networks/[name]/blocks/page.tsx b/src/app/[locale]/networks/[name]/blocks/page.tsx index f18d7aa1..47f05ca9 100644 --- a/src/app/[locale]/networks/[name]/blocks/page.tsx +++ b/src/app/[locale]/networks/[name]/blocks/page.tsx @@ -2,6 +2,7 @@ import { getTranslations } from 'next-intl/server'; import Link from 'next/link'; import NetworkBlocks from '@/app/networks/[name]/blocks/blocks-table/network-blocks'; +import PowBlocks from '@/app/networks/[name]/blocks/pow-blocks'; import PageTitle from '@/components/common/page-title'; import SubDescription from '@/components/sub-description'; import { Locale, NextPageWithLocale } from '@/i18n'; @@ -29,8 +30,10 @@ export async function generateMetadata({ params: { locale } }: { params: { local const TotalBlocksPage: NextPageWithLocale = async ({ params: { name, locale }, searchParams: q }) => { const t = await getTranslations({ locale, namespace: 'TotalBlocksPage' }); const currentPage = parseInt((q.p as string) || '1'); - const perPage = q.pp ? parseInt(q.pp as string) : defaultPerPage; + const ppNum = q.pp ? parseInt(q.pp as string, 10) : defaultPerPage; + const perPage = Number.isFinite(ppNum) && ppNum > 0 ? Math.min(ppNum, 100) : defaultPerPage; const chain = await chainService.getByName(name); + const isPow = chain?.consensusType === 'pow'; return (
@@ -46,7 +49,11 @@ const TotalBlocksPage: NextPageWithLocale = async ({ params: { name, } /> - + {isPow && chain ? ( + + ) : ( + + )}
); }; diff --git a/src/app/[locale]/networks/[name]/blocks/pow-blocks.tsx b/src/app/[locale]/networks/[name]/blocks/pow-blocks.tsx new file mode 100644 index 00000000..a2abb100 --- /dev/null +++ b/src/app/[locale]/networks/[name]/blocks/pow-blocks.tsx @@ -0,0 +1,155 @@ +import { Chain } from '@prisma/client'; +import { getTranslations } from 'next-intl/server'; +import Link from 'next/link'; +import { FC } from 'react'; + +import BaseTable from '@/components/common/table/base-table'; +import BaseTableCell from '@/components/common/table/base-table-cell'; +import BaseTableRow from '@/components/common/table/base-table-row'; +import TableHeaderItem from '@/components/common/table/table-header-item'; +import TablePagination from '@/components/common/table/table-pagination'; +import { listMoneroBlocks, MoneroBlock } from '@/server/tools/chains/monero/indexer-client'; +import { getPoolByBlockHashes } from '@/services/monero-service'; +import { bigIntSafeCache } from '@/utils/bigint-safe-cache'; +import cutHash from '@/utils/cut-hash'; +import { formatTimestamp } from '@/utils/format-timestamp'; + +interface OwnProps { + chain: Chain; + locale: string; + currentPage?: number; + limit?: number; +} + +const COLS = 4; + +// Shield the Monero indexer: cache the block-list payload ~10s across requests (SWR). Pool +// attribution (getPoolByBlockHashes) below stays uncached, so "Mined By" is always fresh. +const getCachedBlocks = bigIntSafeCache( + (opts: Parameters[0]) => listMoneroBlocks(opts), + ['monero-block-list'], + { revalidate: 10 }, +); + +// Header cell matching the site's TableSortItems look (no sort — the indexer serves desc only). +const HeaderCell: FC<{ label: string }> = ({ label }) => ( + +
+
+
 {label}
+
+
+
+); + +const PowBlocks: FC = async ({ chain, locale, currentPage = 1, limit = 20 }) => { + const t = await getTranslations({ locale, namespace: 'PowBlocks' }); + + if (!process.env.MONERO_INDEXER_BASE_URL) { + return ( +
+
{t('indexerDisabled')}
+

{t('indexerDisabledBody')}

+
+ ); + } + + const offset = (currentPage - 1) * limit; + let blocks: MoneroBlock[] = []; + let hasMore = false; + let fetchError: string | null = null; + try { + const res = await getCachedBlocks({ limit, offset, order: 'desc' }); + blocks = res.items; + hasMore = res.hasMore; + } catch (error) { + // Log the raw cause server-side; surface only a generic message (the raw fetch error + // leaks the internal indexer host). + console.error('[PowBlocks] Failed to fetch blocks:', error); + fetchError = t('fetchError'); + } + + if (fetchError) { + return ( +
+
{t('indexerDisabled')}
+

{fetchError}

+
+ ); + } + + if (blocks.length === 0) { + return
{t('noBlocks')}
; + } + + // "Mined By" comes from on-chain attribution (MoneroBlockAttribution). Reward, size, difficulty, + // tx count etc. live on the block detail page, not the list. + const poolMap = await getPoolByBlockHashes(blocks.map((b) => b.hash)); + // Indexer exposes has_more, not a total — pad the page count so the standard paginator shows + // "1 2 … ▷" (and stops cleanly on the last page when has_more is false). + const pageLength = hasMore ? currentPage + 3 : currentPage; + + return ( +
+ + + + + + + + + + + {blocks.map((block) => { + const pool = poolMap.get(block.hash); + const link = `/networks/${chain.name}/blocks/${block.hash}`; + return ( + + + + + {cutHash({ value: block.hash, cutLength: 12 })} + + + + + + {block.height.toLocaleString()} + + + + + + {formatTimestamp(new Date(block.timestamp * 1000))} + + + + + {pool && pool.isVerified ? ( + + + {pool.name} + + + ) : ( +
+ {pool?.name ?? t('unknownPool')} +
+ )} +
+
+ ); + })} + + + + + + +
+
+ ); +}; + +export default PowBlocks; diff --git a/src/app/[locale]/networks/[name]/mining-pools/network-mining-pools-table/network-mining-pools-item.tsx b/src/app/[locale]/networks/[name]/mining-pools/network-mining-pools-table/network-mining-pools-item.tsx new file mode 100644 index 00000000..29b4d278 --- /dev/null +++ b/src/app/[locale]/networks/[name]/mining-pools/network-mining-pools-table/network-mining-pools-item.tsx @@ -0,0 +1,50 @@ +import { FC } from 'react'; + +import BaseTableCell from '@/components/common/table/base-table-cell'; +import BaseTableRow from '@/components/common/table/base-table-row'; +import TableAvatar from '@/components/common/table/table-avatar'; +import { MoneroPoolStatsRow } from '@/services/monero-service'; +import { formatHashrate } from '@/utils/format-hashrate'; + +interface OwnProps { + stat: MoneroPoolStatsRow; +} + +// Per-network technical row, mirroring NetworkValidatorsItem: identity cell + numeric stat cells. +const NetworkMiningPoolsItem: FC = ({ stat }) => { + // The pool detail page notFound's unverified pools, so only verified pools get a clickable avatar. + // Unverified pools and the synthetic "unknown/solo" aggregate render as plain text (no dead link). + const linkable = stat.pool.isVerified && stat.pool.slug !== 'unknown'; + + return ( + + + {linkable ? ( + + ) : ( +
{stat.pool.name}
+ )} +
+ + +
{stat.blocksFound}
+
+ + +
{(stat.sharePercent ?? 0).toFixed(2)}%
+
+ + +
{formatHashrate(stat.hashrateEstimate)}
+
+ + +
+ {stat.pool.feePercent != null ? `${stat.pool.feePercent}%` : '-'} +
+
+
+ ); +}; + +export default NetworkMiningPoolsItem; diff --git a/src/app/[locale]/networks/[name]/mining-pools/network-mining-pools-table/network-mining-pools.tsx b/src/app/[locale]/networks/[name]/mining-pools/network-mining-pools-table/network-mining-pools.tsx new file mode 100644 index 00000000..65d695ba --- /dev/null +++ b/src/app/[locale]/networks/[name]/mining-pools/network-mining-pools-table/network-mining-pools.tsx @@ -0,0 +1,92 @@ +import { getTranslations } from 'next-intl/server'; +import { FC } from 'react'; + +import HashrateWindowSelector from '@/app/networks/[name]/(network-profile)/stats/hashrate-window-selector'; +import NetworkMiningPoolsItem from '@/app/networks/[name]/mining-pools/network-mining-pools-table/network-mining-pools-item'; +import BaseTable from '@/components/common/table/base-table'; +import TableHeaderItem from '@/components/common/table/table-header-item'; +import moneroService, { HashrateWindow, MoneroPoolStatsRow, isValidWindow } from '@/services/monero-service'; +import { SortDirection } from '@/server/types'; + +interface OwnProps { + chainName: string; + window: HashrateWindow; + sort: { sortBy: string; order: SortDirection }; +} + +// Sort key extraction for the numeric columns ("name" is compared lexically in the comparator). +const sortValue = (row: MoneroPoolStatsRow, sortBy: string): number => { + if (sortBy === 'blocksFound') return row.blocksFound; + if (sortBy === 'feePercent') return row.pool.feePercent ?? -1; + return row.sharePercent ?? 0; +}; + +const NetworkMiningPools: FC = async ({ chainName, window, sort }) => { + const t = await getTranslations('NetworkMiningPoolsPage'); + + // Pool attribution is a Monero-only capability today; other networks have no pools to list. + if (chainName !== 'monero') { + return
{t('empty')}
; + } + + const availableWindows = await moneroService.getMoneroAvailableWindows(); + const requested: HashrateWindow = isValidWindow(window) ? window : '24h'; + const safeWindow: HashrateWindow = availableWindows.includes(requested) ? requested : '24h'; + + const rawStats = await moneroService.getMoneroPoolStats(safeWindow); + + // Single comparator: the synthetic "unknown/solo" bucket is always pinned last (design §7), + // and within the rest we sort by the requested field/direction (name lexically, rest numerically). + const poolStats = [...rawStats].sort((a, b) => { + const aUnknown = a.pool.slug === 'unknown' ? 1 : 0; + const bUnknown = b.pool.slug === 'unknown' ? 1 : 0; + if (aUnknown !== bUnknown) return aUnknown - bUnknown; + + if (sort.sortBy === 'name') { + const cmp = a.pool.name.toLowerCase().localeCompare(b.pool.name.toLowerCase()); + return sort.order === 'asc' ? cmp : -cmp; + } + const diff = sortValue(a, sort.sortBy) - sortValue(b, sort.sortBy); + return sort.order === 'asc' ? diff : -diff; + }); + + const windowLabels: Record = { + '24h': t('window24h'), + '7d': t('window7d'), + '30d': t('window30d'), + all: t('windowAll'), + }; + const windowOptions = availableWindows.map((value) => ({ value, label: windowLabels[value] })); + + return ( +
+
+ {windowOptions.length > 1 && } +
+ + + + + + + + + + + + {poolStats.length > 0 ? ( + poolStats.map((stat) => ) + ) : ( + + + {t('empty')} + + + )} + + +
+ ); +}; + +export default NetworkMiningPools; diff --git a/src/app/[locale]/networks/[name]/mining-pools/page.tsx b/src/app/[locale]/networks/[name]/mining-pools/page.tsx new file mode 100644 index 00000000..11346d6b --- /dev/null +++ b/src/app/[locale]/networks/[name]/mining-pools/page.tsx @@ -0,0 +1,61 @@ +import { getTranslations } from 'next-intl/server'; +import Link from 'next/link'; + +import NetworkMiningPools from '@/app/networks/[name]/mining-pools/network-mining-pools-table/network-mining-pools'; +import PageTitle from '@/components/common/page-title'; +import SubDescription from '@/components/sub-description'; +import { Locale, NextPageWithLocale } from '@/i18n'; +import chainService from '@/services/chain-service'; +import { HashrateWindow } from '@/services/monero-service'; +import { SortDirection } from '@/server/types'; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +interface PageProps { + params: NextPageWithLocale & { name: string }; + searchParams: { [key: string]: string | string[] | undefined }; +} + +export async function generateMetadata({ params: { locale } }: { params: { locale: Locale } }) { + const t = await getTranslations({ locale, namespace: 'NetworkMiningPoolsPage' }); + + return { + title: t('title'), + }; +} + +const NetworkMiningPoolsPage: NextPageWithLocale = async ({ params: { name, locale }, searchParams: q }) => { + const t = await getTranslations({ locale, namespace: 'NetworkMiningPoolsPage' }); + + const rawWindow = Array.isArray(q.window) ? q.window[0] : q.window; + const window = (rawWindow ?? '24h') as HashrateWindow; + const sortBy = (Array.isArray(q.sortBy) ? q.sortBy[0] : q.sortBy) ?? 'sharePercent'; + const order = ((Array.isArray(q.order) ? q.order[0] : q.order) as SortDirection) ?? 'desc'; + + const chain = await chainService.getByName(name); + + return ( +
+ +
+ {chain?.prettyName} +
+
+ + } + /> + + +
+ ); +}; + +export default NetworkMiningPoolsPage; diff --git a/src/app/[locale]/networks/[name]/tx/[hash]/expand/expanded-tx-information.tsx b/src/app/[locale]/networks/[name]/tx/[hash]/expand/expanded-tx-information.tsx index 74c069f0..d69ac6a8 100644 --- a/src/app/[locale]/networks/[name]/tx/[hash]/expand/expanded-tx-information.tsx +++ b/src/app/[locale]/networks/[name]/tx/[hash]/expand/expanded-tx-information.tsx @@ -10,6 +10,7 @@ import { isAztecChainName } from '@/server/tools/chains/aztec/utils/contracts/co import { AztecTxEffect } from '@/services/aztec-indexer-api/types'; import TxService from '@/services/tx-service'; import { ChainWithParams } from '@/services/chain-service'; +import { getMoneroTransaction } from '@/server/tools/chains/monero/indexer-client'; import Link from 'next/link'; interface OwnProps { @@ -20,6 +21,42 @@ interface OwnProps { const ExpandedTxInformation: FC = async ({ chain, hash }) => { const t = await getTranslations('TxInformationPage'); + if (chain?.consensusType === 'pow') { + const tx = await getMoneroTransaction(hash).catch(() => null); + if (!tx) { + return ( +
+
{t('tx not found')}
+
{t('tx not found hint')}
+
+ ); + } + + const expandedData: Array<{ title: string; data: string | number }> = [ + { title: 'extra size', data: tx.extraSize }, + { title: 'unlock time', data: tx.unlockTime }, + { title: 'in pool', data: tx.inPool ? t('yes') : t('no') }, + { title: 'is canonical', data: tx.isCanonical ? t('yes') : t('no') }, + { title: 'is settled', data: tx.isSettled ? t('yes') : t('no') }, + { title: 'indexed at', data: tx.indexedAt }, + ]; + + return ( +
+ {expandedData.map((item) => ( +
+
+ {t(item.title as 'chain')} +
+
+
{item.data}
+
+
+ ))} +
+ ); + } + if (chain?.name === 'cosmoshub') { return ; } diff --git a/src/app/[locale]/networks/[name]/tx/[hash]/json/json-tx-information.tsx b/src/app/[locale]/networks/[name]/tx/[hash]/json/json-tx-information.tsx index d8403ec2..d3973b15 100644 --- a/src/app/[locale]/networks/[name]/tx/[hash]/json/json-tx-information.tsx +++ b/src/app/[locale]/networks/[name]/tx/[hash]/json/json-tx-information.tsx @@ -8,6 +8,7 @@ import { isAztecChainName } from '@/server/tools/chains/aztec/utils/contracts/co import atomoneIndexer from '@/services/atomone-indexer-api'; import cosmosIndexer from '@/services/cosmos-indexer-api'; import TxService from '@/services/tx-service'; +import { getMoneroTransaction } from '@/server/tools/chains/monero/indexer-client'; interface OwnProps { chainName: string; @@ -17,6 +18,31 @@ interface OwnProps { const JsonTxInformation: FC = async ({ chainName, hash }) => { const t = await getTranslations('TxInformationPage'); + if (chainName === 'monero') { + const tx = await getMoneroTransaction(hash).catch(() => null); + if (!tx) { + return ( +
+
{t('tx not found')}
+
{t('tx not found hint')}
+
+ ); + } + const jsonString = JSON.stringify(tx, null, 4); + return ( +
+
+
+
{jsonString}
+
+
+ +
+
+
+ ); + } + if (chainName === 'miden-testnet') { return ; } diff --git a/src/app/[locale]/networks/[name]/tx/[hash]/monero-tx-information.tsx b/src/app/[locale]/networks/[name]/tx/[hash]/monero-tx-information.tsx new file mode 100644 index 00000000..078391a8 --- /dev/null +++ b/src/app/[locale]/networks/[name]/tx/[hash]/monero-tx-information.tsx @@ -0,0 +1,166 @@ +import { getTranslations } from 'next-intl/server'; +import Link from 'next/link'; +import { FC } from 'react'; + +import CopyButton from '@/components/common/copy-button'; +import RoundedButton from '@/components/common/rounded-button'; +import Tooltip from '@/components/common/tooltip'; +import { getMoneroTransaction } from '@/server/tools/chains/monero/indexer-client'; +import { ChainWithParams } from '@/services/chain-service'; +import { bigIntSafeCache } from '@/utils/bigint-safe-cache'; +import cutHash from '@/utils/cut-hash'; +import { formatXmrReward } from '@/utils/monero'; + +interface OwnProps { + chain: ChainWithParams; + hash: string; +} + +// Tx-by-hash is immutable (no mutable attribution on this page) — cache the indexer payload 1h. +const getCachedTransaction = bigIntSafeCache( + (hash: string) => getMoneroTransaction(hash), + ['monero-tx-detail'], + { revalidate: 3600 }, +); + +const formatBytes = (bytes: number): string => { + if (!bytes) return '-'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +}; + +const MoneroTxInformation: FC = async ({ chain, hash }) => { + const t = await getTranslations('TxInformationPage'); + + const tx = await getCachedTransaction(hash).catch(() => null); + + if (!tx) { + return ( +
+
+
+
+ +
+ +
+
+
+ {cutHash({ value: hash, cutLength: 16 })} + +
+
+
+
+ + {t('show all transactions')} + +
+
+
+
{t('tx not found')}
+
{t('tx not found hint')}
+
+
+ ); + } + + const typeLabel = tx.isCoinbase ? t('coinbase') : t('regular'); + const txData: { title: string; data: string | number }[] = [ + { title: 'chain', data: chain.prettyName }, + { title: 'block height', data: tx.blockHeight }, + { title: 'block hash', data: tx.blockHash }, + { title: 'index in block', data: tx.position }, + { title: 'type', data: typeLabel }, + { title: 'inputs', data: tx.inputsCount }, + { title: 'outputs', data: tx.outputsCount }, + { title: 'fees', data: formatXmrReward(tx.fee) }, + { title: 'size', data: formatBytes(tx.size) }, + { title: 'ring version', data: tx.version }, + { title: 'unlock time', data: tx.unlockTime }, + { title: 'confirmations', data: tx.confirmations }, + ]; + + const formatData = (title: string, data: number | string) => { + switch (title) { + case 'chain': + return ( + + {data} + + ); + case 'block height': + return ( + + {Number(data).toLocaleString('en-US')} + + ); + case 'block hash': + return ( +
+ + {data} + + +
+ ); + case 'index in block': + case 'inputs': + case 'outputs': + case 'confirmations': + return
{Number(data).toLocaleString('en-US')}
; + default: + return
{data}
; + } + }; + + return ( +
+
+
+
+ +
+ +
+
+
+
+ {typeLabel} +
+
+
+ {cutHash({ value: tx.hash, cutLength: 16 })} + +
+
+
+
+ + {t('show all transactions')} + +
+
+

{t('amounts hidden')}

+ {txData.map((item) => ( +
+
+ {t(item.title as 'chain')} +
+
+ {formatData(item.title, item.data)} +
+
+ ))} +
+ ); +}; + +export default MoneroTxInformation; diff --git a/src/app/[locale]/networks/[name]/tx/[hash]/tx-information.tsx b/src/app/[locale]/networks/[name]/tx/[hash]/tx-information.tsx index 6c84173a..6f75a7c2 100644 --- a/src/app/[locale]/networks/[name]/tx/[hash]/tx-information.tsx +++ b/src/app/[locale]/networks/[name]/tx/[hash]/tx-information.tsx @@ -6,6 +6,7 @@ import { txExample } from '@/app/networks/[name]/tx/txExample'; import CosmosTxInformation from '@/app/networks/[name]/tx/[hash]/cosmos-tx-information'; import AtomoneTxInformation from '@/app/networks/[name]/tx/[hash]/atomone-tx-information'; import MidenTxInformation from '@/app/networks/[name]/tx/[hash]/miden-tx-information'; +import MoneroTxInformation from '@/app/networks/[name]/tx/[hash]/monero-tx-information'; import CopyButton from '@/components/common/copy-button'; import RoundedButton from '@/components/common/rounded-button'; import Tooltip from '@/components/common/tooltip'; @@ -35,6 +36,10 @@ const getStatusLabel = (status: TxStatus) => { const TxInformation: FC = async ({ chain, hash }) => { const t = await getTranslations('TxInformationPage'); + if (chain && chain.consensusType === 'pow') { + return ; + } + if (chain?.name === 'cosmoshub') { return ; } diff --git a/src/app/[locale]/networks/[name]/tx/monero-txs.tsx b/src/app/[locale]/networks/[name]/tx/monero-txs.tsx new file mode 100644 index 00000000..d10953ee --- /dev/null +++ b/src/app/[locale]/networks/[name]/tx/monero-txs.tsx @@ -0,0 +1,134 @@ +import { Chain } from '@prisma/client'; +import { getTranslations } from 'next-intl/server'; +import Link from 'next/link'; +import { FC } from 'react'; + +import BaseTable from '@/components/common/table/base-table'; +import BaseTableCell from '@/components/common/table/base-table-cell'; +import BaseTableRow from '@/components/common/table/base-table-row'; +import TableHeaderItem from '@/components/common/table/table-header-item'; +import TablePagination from '@/components/common/table/table-pagination'; +import { listMoneroTransactions, MoneroTransaction } from '@/server/tools/chains/monero/indexer-client'; +import { bigIntSafeCache } from '@/utils/bigint-safe-cache'; +import cutHash from '@/utils/cut-hash'; +import { formatBytes, formatXmrReward } from '@/utils/monero'; + +interface OwnProps { + chain: Chain; + locale: string; + currentPage?: number; + limit?: number; +} + +const COLS = 4; + +// Shield the Monero indexer: cache the tx-list payload ~10s across requests (SWR). +const getCachedTxs = bigIntSafeCache( + (opts: Parameters[0]) => listMoneroTransactions(opts), + ['monero-tx-list'], + { revalidate: 10 }, +); + +// Header cell matching the site's TableSortItems look (no sort — the indexer serves desc only). +const HeaderCell: FC<{ label: string }> = ({ label }) => ( + +
+
+
 {label}
+
+
+
+); + +const MoneroTxs: FC = async ({ chain, locale, currentPage = 1, limit = 20 }) => { + const t = await getTranslations({ locale, namespace: 'PowTxs' }); + + if (!process.env.MONERO_INDEXER_BASE_URL) { + return ( +
+
{t('indexerDisabled')}
+

{t('indexerDisabledBody')}

+
+ ); + } + + const offset = (currentPage - 1) * limit; + let txs: MoneroTransaction[] = []; + let hasMore = false; + let fetchError: string | null = null; + try { + const res = await getCachedTxs({ limit, offset, order: 'desc' }); + txs = res.items; + hasMore = res.hasMore; + } catch (error) { + // Log the raw cause server-side; surface only a generic message (the raw fetch error + // leaks the internal indexer host). + console.error('[MoneroTxs] Failed to fetch transactions:', error); + fetchError = t('fetchError'); + } + + if (fetchError) { + return ( +
+
{t('indexerDisabled')}
+

{fetchError}

+
+ ); + } + + if (txs.length === 0) { + return
{t('noTxs')}
; + } + + // Indexer exposes has_more, not a total — pad the page count so the standard paginator shows + // "1 2 … ▷" (and stops cleanly on the last page when has_more is false). + const pageLength = hasMore ? currentPage + 3 : currentPage; + + return ( +
+ + + + + + + + + + + {txs.map((tx) => ( + + + + + {cutHash({ value: tx.hash, cutLength: 12 })} + + + + + + + {tx.blockHeight.toLocaleString()} + + + + +
{formatXmrReward(tx.fee)}
+
+ +
{formatBytes(tx.size)}
+
+
+ ))} + + + + + + +
+
+ ); +}; + +export default MoneroTxs; diff --git a/src/app/[locale]/networks/[name]/tx/page.tsx b/src/app/[locale]/networks/[name]/tx/page.tsx index e09fb239..9c95fbb2 100644 --- a/src/app/[locale]/networks/[name]/tx/page.tsx +++ b/src/app/[locale]/networks/[name]/tx/page.tsx @@ -5,6 +5,7 @@ import chainService from '@/services/chain-service'; import TotalTxsMetrics from '@/app/networks/[name]/tx/total-txs-metrics'; import NetworkTxs from '@/app/networks/[name]/tx/txs-table/network-txs'; import TxStatusToggle from '@/app/networks/[name]/tx/tx-status-toggle'; +import MoneroTxs from '@/app/networks/[name]/tx/monero-txs'; import Link from 'next/link'; import SubDescription from '@/components/sub-description'; @@ -36,22 +37,31 @@ const TotalTxsPage: NextPageWithLocale = async ({ const showPending = q.status === 'pending'; const chain = await chainService.getByName(name); const isAztec = name.toLowerCase() === 'aztec'; + const isMonero = chain?.consensusType === 'pow'; + + const titlePrefix = ( + +
+ {chain?.prettyName} +
+
+ + ); + + // Monero (PoW privacy) has no per-tx amounts/addresses — a dedicated list, not the Aztec table. + if (chain && isMonero) { + return ( +
+ + + +
+ ); + } return (
- -
- - {chain?.prettyName} - -
-
- - } - /> + {isAztec && ( diff --git a/src/app/[locale]/networks/networks-list/networks-list-item.tsx b/src/app/[locale]/networks/networks-list/networks-list-item.tsx index fc6e1466..7bfa74ca 100755 --- a/src/app/[locale]/networks/networks-list/networks-list-item.tsx +++ b/src/app/[locale]/networks/networks-list/networks-list-item.tsx @@ -11,6 +11,7 @@ import Tooltip from '@/components/common/tooltip'; import { ChainWithParamsAndTokenomics } from '@/services/chain-service'; import colorStylization from '@/utils/color-stylization'; import formatCash from '@/utils/format-cash'; +import { hasTxPage } from '@/utils/tx-supported-chains'; interface OwnProps { item: ChainWithParamsAndTokenomics; @@ -23,7 +24,7 @@ const NetworksListItem: FC = async ({ item, health }) => { const size = 'h-12 w-12 min-w-12 min-h-12 mx-auto'; const supply = 100; - const hasTxPage = ['aztec', 'logos-testnet', 'cosmoshub', 'atomone'].includes(item.name); + const showTxIcon = hasTxPage(item.name); return ( @@ -31,7 +32,7 @@ const NetworksListItem: FC = async ({ item, health }) => {
- {hasTxPage && ( + {showTxIcon && ( { return (
- + diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index ed7679a4..32c0d3e3 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -3,7 +3,7 @@ import { getTranslations } from 'next-intl/server'; import Description from '@/components/common/description'; import PageTitle from '@/components/common/page-title'; import TabList from '@/components/common/tabs/tab-list'; -import { mainTabs } from '@/components/common/tabs/tabs-data'; +import { homeTabsHorizontal } from '@/components/common/tabs/tabs-data'; import AiChatInline from '@/components/home/ai-chat-inline'; import InfrastructureBanner from '@/components/home/infrastructure-banner'; import LinksGrid from '@/components/home/links-grid'; @@ -19,7 +19,7 @@ const Home: NextPageWithLocale = async ({ params: { locale } }) => { return (
- +
diff --git a/src/app/[locale]/stakingcalculator/page.tsx b/src/app/[locale]/stakingcalculator/page.tsx index ae8d0cd7..38254d74 100644 --- a/src/app/[locale]/stakingcalculator/page.tsx +++ b/src/app/[locale]/stakingcalculator/page.tsx @@ -8,7 +8,7 @@ import PageHeaderVisibilityWrapper from '@/components/common/page-header-visibil import PageTitle from '@/components/common/page-title'; import SubTitle from '@/components/common/sub-title'; import TabList from '@/components/common/tabs/tab-list'; -import { mainTabs } from '@/components/common/tabs/tabs-data'; +import { toolsTabsHorizontal } from '@/components/common/tabs/tabs-data'; import { Locale } from '@/i18n'; export async function generateMetadata({ params: { locale } }: { params: { locale: Locale } }) { @@ -29,7 +29,7 @@ export default async function StakingCalculatorPage({ params: { locale } }: Read return (
- + diff --git a/src/app/[locale]/web3stats/page.tsx b/src/app/[locale]/web3stats/page.tsx index fd67dbb0..b4a6d534 100644 --- a/src/app/[locale]/web3stats/page.tsx +++ b/src/app/[locale]/web3stats/page.tsx @@ -9,7 +9,7 @@ import PageHeaderVisibilityWrapper from '@/components/common/page-header-visibil import PageTitle from '@/components/common/page-title'; import SubTitle from '@/components/common/sub-title'; import TabList from '@/components/common/tabs/tab-list'; -import { mainTabs } from '@/components/common/tabs/tabs-data'; +import { toolsTabsHorizontal } from '@/components/common/tabs/tabs-data'; import TextLink from '@/components/common/text-link'; import { Locale } from '@/i18n'; @@ -39,7 +39,7 @@ export default async function GlobalPosPage({ params: { locale } }: { params: { return (
- + sum + BigInt(node.tokens), BigInt(0)); + const totalSelfDelegation = nodes.reduce((sum, node) => sum + BigInt(node.minSelfDelegation || '0'), BigInt(0)); const totalDelegatorShares = nodes.reduce((sum, node) => sum + parseFloat(node.delegatorShares), 0); const nodesWithMissedBlocks = nodes.filter( (node) => node.missedBlocks !== null && node.missedBlocks !== undefined && node.uptime !== null && node.uptime !== undefined, @@ -318,6 +319,7 @@ const getAztecValidatorsWithNodes = async ( ...firstNode, tokens: totalTokens.toString(), delegatorShares: totalDelegatorShares.toString(), + minSelfDelegation: totalSelfDelegation.toString(), missedBlocks: totalMissedBlocks, outstandingRewards: totalOutstandingRewards > 0 ? totalOutstandingRewards.toString() : null, outstandingCommissions: totalOutstandingCommissions > 0 ? totalOutstandingCommissions.toString() : null, diff --git a/src/app/services/monero-service.ts b/src/app/services/monero-service.ts new file mode 100644 index 00000000..48b39709 --- /dev/null +++ b/src/app/services/monero-service.ts @@ -0,0 +1,274 @@ +import { Chain, ChainHashrateSnapshot, ChainParams, MiningPool, MiningPoolStats } from '@prisma/client'; + +import db from '@/db'; + +export type HashrateWindow = '24h' | '7d' | '30d' | 'all'; + +// The window union, authoritative in one place; both the stats page and the network pools list +// validate a raw ?window param against this instead of re-declaring their own copy. +export const VALID_WINDOWS: HashrateWindow[] = ['24h', '7d', '30d', 'all']; + +export const isValidWindow = (value: string | undefined): value is HashrateWindow => + value !== undefined && (VALID_WINDOWS as string[]).includes(value); + +export type MoneroPoolStatsRow = MiningPoolStats & { + pool: MiningPool; +}; + +const MONERO_NAME = 'monero'; + +const windowToCutoff = (window: HashrateWindow): Date | null => { + const now = Date.now(); + if (window === '24h') return new Date(now - 24 * 60 * 60 * 1000); + if (window === '7d') return new Date(now - 7 * 24 * 60 * 60 * 1000); + if (window === '30d') return new Date(now - 30 * 24 * 60 * 60 * 1000); + return null; +}; + +const getMoneroChain = async (): Promise => { + return db.chain.findUnique({ where: { name: MONERO_NAME } }); +}; + +const getMoneroNetworkSnapshot = async (): Promise => { + const chain = await getMoneroChain(); + if (!chain) return null; + + return db.chainHashrateSnapshot.findFirst({ + where: { chainId: chain.id }, + orderBy: { snapshotAt: 'desc' }, + }); +}; + +const getMoneroHashrateHistory = async (window: HashrateWindow): Promise => { + const chain = await getMoneroChain(); + if (!chain) return []; + + const cutoff = windowToCutoff(window); + + return db.chainHashrateSnapshot.findMany({ + where: { + chainId: chain.id, + ...(cutoff ? { snapshotAt: { gte: cutoff } } : {}), + }, + orderBy: { snapshotAt: 'asc' }, + }); +}; + +const getMoneroPoolStats = async (window: HashrateWindow): Promise => { + const chain = await getMoneroChain(); + if (!chain) return []; + + return db.miningPoolStats.findMany({ + where: { chainId: chain.id, windowKind: window }, + include: { pool: true }, + orderBy: { sharePercent: 'desc' }, + }); +}; + +// Returns RAW ATOMIC piconero (emission-only). The caller MUST divide by +// 10^coinDecimals (=12) at render — never pre-divide here (design §6). +const getMoneroSupply = async (): Promise => { + const chain = await db.chain.findUnique({ + where: { name: MONERO_NAME }, + include: { tokenomics: true }, + }); + + if (!chain?.tokenomics?.totalSupply) return null; + return chain.tokenomics.totalSupply; +}; + +const getMoneroChainParams = async (): Promise => { + const chain = await db.chain.findUnique({ + where: { name: MONERO_NAME }, + include: { params: true }, + }); + + return chain?.params ?? null; +}; + +export interface MoneroHashratePoint { + date: string; + hashrate: number; +} + +const getMoneroChartData = async (): Promise => { + const chain = await getMoneroChain(); + if (!chain) return []; + + const snapshots = await db.chainHashrateSnapshot.findMany({ + where: { chainId: chain.id }, + orderBy: { snapshotAt: 'asc' }, + select: { snapshotAt: true, hashrate: true }, + }); + + const byDay = new Map(); + for (const row of snapshots) { + const day = row.snapshotAt.toISOString().slice(0, 10); + let value: number; + try { + value = Number(BigInt(row.hashrate)); + } catch { + value = Number(row.hashrate) || 0; + } + const existing = byDay.get(day); + if (!existing || row.snapshotAt > existing.snapshotAt) { + byDay.set(day, { snapshotAt: row.snapshotAt, hashrate: value }); + } + } + + return Array.from(byDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, { hashrate }]) => ({ date, hashrate })); +}; + +const getMoneroPoolsCount = async (): Promise => { + const chain = await getMoneroChain(); + if (!chain) return 0; + + return db.miningPool.count({ where: { chainId: chain.id } }); +}; + +const getMoneroActivePoolsCount = async (window: HashrateWindow = '24h'): Promise => { + const chain = await getMoneroChain(); + if (!chain) return 0; + + // Exclude the synthetic "unknown" pool — "active pools" must count real pools only. + const stats = await db.miningPoolStats.findMany({ + where: { + chainId: chain.id, + windowKind: window, + blocksFound: { gt: 0 }, + pool: { slug: { not: 'unknown' } }, + }, + select: { poolId: true }, + }); + + const unique = new Set(stats.map((s) => s.poolId)); + return unique.size; +}; + +export interface MoneroPoolBlock { + height: number; + blockHash: string; + blockTimestamp: Date; +} + +// A pool's recently-attributed canonical blocks (from MoneroBlockAttribution — +// replaces the dead coinbase-fingerprint lookup; design §5/§7). +const getMoneroPoolRecentBlocks = async ( + chainId: number, + poolId: number, + limit = 50, + skip = 0, + sortBy: 'height' | 'timestamp' = 'timestamp', + order: 'asc' | 'desc' = 'desc', +): Promise => { + const orderBy = sortBy === 'height' ? { height: order } : { blockTimestamp: order }; + + return db.moneroBlockAttribution.findMany({ + where: { chainId, poolId, isCanonical: true, isConflicted: false }, + orderBy, + skip, + take: limit, + select: { height: true, blockHash: true, blockTimestamp: true }, + }); +}; + +// Total canonical blocks attributed to a pool — for paginating its Blocks tab. +const getMoneroPoolBlocksCount = async (chainId: number, poolId: number): Promise => { + return db.moneroBlockAttribution.count({ + where: { chainId, poolId, isCanonical: true, isConflicted: false }, + }); +}; + +export interface BlockPoolInfo { + slug: string; + name: string; + isVerified: boolean; +} + +// blockHash -> attributed pool (slug/name/verified), for the blocks table's "Mined By" column. Only +// named, non-conflicted attributions resolve; everything else is "unknown". Verified pools have a +// profile page → the caller links them; unverified/unknown render as plain text. +const getPoolByBlockHashes = async (hashes: string[]): Promise> => { + if (hashes.length === 0) return new Map(); + const chain = await getMoneroChain(); + if (!chain) return new Map(); + + const rows = await db.moneroBlockAttribution.findMany({ + where: { chainId: chain.id, blockHash: { in: hashes }, isCanonical: true, isConflicted: false }, + select: { blockHash: true, pool: { select: { slug: true, name: true, isVerified: true } } }, + }); + + const map = new Map(); + for (const row of rows) { + if (row.pool && row.pool.slug !== 'unknown') { + map.set(row.blockHash, { slug: row.pool.slug, name: row.pool.name, isVerified: row.pool.isVerified }); + } + } + return map; +}; + +// Timestamp of the earliest attributed canonical block — how far back our pool coverage reaches. +// Used to only offer stats windows (24h/7d/30d/all) the attribution history actually covers. +const getMoneroAttributionStart = async (): Promise => { + const chain = await getMoneroChain(); + if (!chain) return null; + + const row = await db.moneroBlockAttribution.findFirst({ + where: { chainId: chain.id, isCanonical: true, isConflicted: false }, + orderBy: { blockTimestamp: 'asc' }, + select: { blockTimestamp: true }, + }); + return row?.blockTimestamp ?? null; +}; + +// Windows the attribution history actually covers — single source of truth shared by the stats +// page and the network mining-pools list. Offering 30d/All before we have that much history would +// dilute a few days of data across a month-wide denominator (misleading, mostly "unknown"). +const getMoneroAvailableWindows = async (): Promise => { + const attributionStart = await getMoneroAttributionStart(); + const spanMs = attributionStart ? Date.now() - attributionStart.getTime() : 0; + const DAY_MS = 24 * 60 * 60 * 1000; + + const windows: HashrateWindow[] = ['24h']; + if (spanMs >= 7 * DAY_MS) windows.push('7d'); + if (spanMs >= 30 * DAY_MS) windows.push('30d'); + if (spanMs >= 7 * DAY_MS) windows.push('all'); + return windows; +}; + +const moneroService = { + getMoneroChain, + getMoneroAttributionStart, + getMoneroAvailableWindows, + getMoneroNetworkSnapshot, + getMoneroHashrateHistory, + getMoneroPoolStats, + getMoneroSupply, + getMoneroChainParams, + getMoneroActivePoolsCount, + getMoneroChartData, + getMoneroPoolsCount, + getMoneroPoolRecentBlocks, + getMoneroPoolBlocksCount, + getPoolByBlockHashes, +}; + +export default moneroService; + +export { + getMoneroChain, + getMoneroAvailableWindows, + getMoneroNetworkSnapshot, + getMoneroHashrateHistory, + getMoneroPoolStats, + getMoneroSupply, + getMoneroChainParams, + getMoneroActivePoolsCount, + getMoneroChartData, + getMoneroPoolsCount, + getMoneroPoolRecentBlocks, + getMoneroPoolBlocksCount, + getPoolByBlockHashes, +}; diff --git a/src/app/services/tx-service.ts b/src/app/services/tx-service.ts index 13446832..74cfa43d 100644 --- a/src/app/services/tx-service.ts +++ b/src/app/services/tx-service.ts @@ -586,6 +586,9 @@ const getAtomoneTxByHash = async ( const getAtomoneTxMetrics = (chainId: number, chainName: string): Promise => readTxMetrics(chainId, chainName); +// The set of chains handled below is the source of truth for tx support. +// Keep it in sync with TX_SUPPORTED_CHAINS in `@/utils/tx-supported-chains`, +// which gates the tx icon (/networks) and tx links (/ecosystems) on the UI side. const getTxsByChainName = async ( chainName: string, currentPage: number = 1, diff --git a/src/types.d.ts b/src/types.d.ts index 9c203042..b39bcd67 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -59,6 +59,9 @@ export interface PagesProps { | 'NodesPage' | 'NetworkValidatorsPage' | 'NetworksPage' + | 'MiningPoolsList' + | 'NetworkMiningPoolsPage' + | 'MiningPoolProfileHeader' | 'NetworkNodesPage' | 'ProposalPage' | 'LibraryPage' diff --git a/src/utils/bigint-safe-cache.ts b/src/utils/bigint-safe-cache.ts new file mode 100644 index 00000000..40c60355 --- /dev/null +++ b/src/utils/bigint-safe-cache.ts @@ -0,0 +1,27 @@ +import { unstable_cache } from 'next/cache'; + +// next/cache's unstable_cache serializes results with JSON.stringify, which throws on BigInt. +// Some Monero indexer DTOs carry bigint fields (e.g. block difficulty — exact, > 2^53), so we +// tag bigints as strings across the cache boundary and revive them on read. Precision preserved, +// the wrapper keeps the original function signature/return type. +const BIGINT_TAG = '__bigint__:'; + +const replacer = (_key: string, value: unknown): unknown => + typeof value === 'bigint' ? `${BIGINT_TAG}${value}` : value; + +const reviver = (_key: string, value: unknown): unknown => + typeof value === 'string' && value.startsWith(BIGINT_TAG) ? BigInt(value.slice(BIGINT_TAG.length)) : value; + +export const bigIntSafeCache = ( + fn: (...args: A) => Promise, + keyParts: string[], + options: { revalidate: number }, +): ((...args: A) => Promise) => { + const cached = unstable_cache( + async (...args: A) => JSON.stringify(await fn(...args), replacer), + keyParts, + options, + ); + + return async (...args: A) => JSON.parse(await cached(...args), reviver) as R; +}; diff --git a/src/utils/format-hashrate.ts b/src/utils/format-hashrate.ts new file mode 100644 index 00000000..11df3dae --- /dev/null +++ b/src/utils/format-hashrate.ts @@ -0,0 +1,62 @@ +const HASH_UNITS = ['H/s', 'KH/s', 'MH/s', 'GH/s', 'TH/s', 'PH/s', 'EH/s']; + +/** + * Formats a hashrate (raw H/s as BigInt-string or number) into a human-readable string. + * Stored as BigInt-as-string in DB; we parse with BigInt to avoid precision loss for >2^53 values. + */ +export const formatHashrate = (raw: string | number | null | undefined, fractionDigits: number = 2): string => { + if (raw === null || raw === undefined || raw === '') return '-'; + + let value: number; + if (typeof raw === 'number') { + value = raw; + } else { + try { + // Parse via BigInt to handle very large numeric strings safely, then narrow. + value = Number(BigInt(raw)); + } catch { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return '-'; + value = parsed; + } + } + + if (!Number.isFinite(value) || value <= 0) return '0 H/s'; + + let unitIndex = 0; + let scaled = value; + while (scaled >= 1000 && unitIndex < HASH_UNITS.length - 1) { + scaled /= 1000; + unitIndex += 1; + } + + return `${scaled.toFixed(fractionDigits)} ${HASH_UNITS[unitIndex]}`; +}; + +/** + * Returns just the numeric portion of a hashrate at its preferred unit. + * Useful for chart Y-axis values where the unit is displayed in the axis label. + */ +export const scaleHashrateForChart = ( + raw: string | number, +): { value: number; unit: string } => { + let value: number; + if (typeof raw === 'number') { + value = raw; + } else { + try { + value = Number(BigInt(raw)); + } catch { + value = Number(raw); + } + } + if (!Number.isFinite(value) || value <= 0) return { value: 0, unit: 'H/s' }; + + let unitIndex = 0; + let scaled = value; + while (scaled >= 1000 && unitIndex < HASH_UNITS.length - 1) { + scaled /= 1000; + unitIndex += 1; + } + return { value: scaled, unit: HASH_UNITS[unitIndex] }; +}; diff --git a/src/utils/monero.ts b/src/utils/monero.ts new file mode 100644 index 00000000..be482fd5 --- /dev/null +++ b/src/utils/monero.ts @@ -0,0 +1,33 @@ +export const MONERO_DECIMALS = 12; + +export const formatXmrReward = (piconero: string | undefined | null): string => { + if (!piconero) return '-'; + try { + const big = BigInt(piconero); + const divisor = BigInt(10 ** MONERO_DECIMALS); + const whole = big / divisor; + const fraction = big % divisor; + const fractionStr = fraction.toString().padStart(MONERO_DECIMALS, '0').slice(0, 4); + return `${whole.toString()}.${fractionStr} XMR`; + } catch { + return '-'; + } +}; + +export const formatBytes = (bytes: number | null | undefined): string => { + if (!bytes) return '-'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +}; + +export const formatRelativeTimeFromUnix = (unixSeconds: number): string => { + const diffSec = Math.max(0, Math.floor(Date.now() / 1000) - unixSeconds); + if (diffSec < 60) return `${diffSec}s ago`; + const minutes = Math.floor(diffSec / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +}; diff --git a/src/utils/safe-href.ts b/src/utils/safe-href.ts new file mode 100644 index 00000000..23496baf --- /dev/null +++ b/src/utils/safe-href.ts @@ -0,0 +1,11 @@ +// Guards against javascript:/data: URL injection when rendering operator-supplied links. +// Returns the URL only when it uses an http(s) scheme; otherwise null so the caller omits the link. +export const safeHref = (url: string | null | undefined): string | null => { + if (!url) return null; + try { + const parsed = new URL(url); + return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? url : null; + } catch { + return null; + } +}; diff --git a/src/utils/tx-supported-chains.ts b/src/utils/tx-supported-chains.ts new file mode 100644 index 00000000..0692e3bb --- /dev/null +++ b/src/utils/tx-supported-chains.ts @@ -0,0 +1,23 @@ +// Single source of truth for chains that expose a working transactions page +// (/networks/[name]/tx). Used to gate the tx icon on /networks and the tx links +// on /ecosystems. +// +// Two rendering paths sit behind these chains, both reached from +// `src/app/[locale]/networks/[name]/tx/page.tsx`: +// - monero (consensusType 'pow') renders the dedicated list; +// - the rest render , whose data comes from the getTxsByChainName +// dispatcher in `src/app/services/tx-service.ts`. +// Keep this list in sync with those two paths. +export const TX_SUPPORTED_CHAINS = [ + 'aztec', + 'logos-testnet', + 'miden-testnet', + 'cosmoshub', + 'atomone', + 'monero', +] as const; + +export type TxSupportedChain = (typeof TX_SUPPORTED_CHAINS)[number]; + +export const hasTxPage = (chainName: string): boolean => + (TX_SUPPORTED_CHAINS as readonly string[]).includes(chainName.toLowerCase()); diff --git a/tailwind.config.ts b/tailwind.config.ts index 587fc6c6..d40a6224 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,9 +1,9 @@ // @ts-ignore import tailwindScrollbar from 'tailwind-scrollbar'; import type { Config } from 'tailwindcss'; -import colors from 'tailwindcss/colors'; -import defaultConfig from 'tailwindcss/defaultConfig'; -import plugin from 'tailwindcss/plugin'; +import colors from 'tailwindcss/colors.js'; +import defaultConfig from 'tailwindcss/defaultConfig.js'; +import plugin from 'tailwindcss/plugin.js'; const config: Config = { content: [