From 63eb68853d7338ad54d9dbbf278e04a8e3a4ac4c Mon Sep 17 00:00:00 2001 From: m1amgn Date: Thu, 21 May 2026 12:41:56 +0700 Subject: [PATCH 01/10] integrate Miden testnet indexer Add Miden testnet chain support via citizenweb3 indexer: chain methods (TPS, uptime, total txs, avg fee, txs last 24h), indexer API client, Miden-specific block/tx UI components, and i18n strings. Includes integration design docs and Monero AGENTS.md scaffolding. Co-Authored-By: Claude Opus 4.7 --- .env.example | 11 + AGENTS.md | 8 + CLAUDE.md | 8 + ...26-05-19-miden-indexer-timestamp-issues.md | 158 +++++ ...08-cosmoshub-indexer-integration-design.md | 402 +++++++++++ ...-05-12-miden-testnet-integration-design.md | 298 ++++++++ ...-19-miden-testnet-stage3-tx-integration.md | 637 ++++++++++++++++++ messages/en.json | 32 +- messages/pt.json | 32 +- messages/ru.json | 32 +- server/tools/chains/methods.ts | 2 + server/tools/chains/miden-testnet/AGENTS.md | 52 ++ .../tools/chains/miden-testnet/get-avg-fee.ts | 8 + .../chains/miden-testnet/get-chain-uptime.ts | 83 +++ .../chains/miden-testnet/get-total-txs.ts | 11 + server/tools/chains/miden-testnet/get-tps.ts | 17 + .../chains/miden-testnet/get-txs-last-24h.ts | 38 ++ server/tools/chains/miden-testnet/methods.ts | 57 ++ server/tools/chains/monero/AGENTS.md | 65 ++ server/tools/chains/params.ts | 32 + .../overview/network-overview.tsx | 89 +++ .../blocks/[hash]/block-information.tsx | 5 + .../expand/expanded-block-information.tsx | 77 +++ .../[hash]/json/json-block-information.tsx | 4 + .../blocks/[hash]/miden-block-information.tsx | 142 ++++ .../[hash]/expand/expanded-tx-information.tsx | 5 + .../expand/miden-expanded-tx-information.tsx | 108 +++ .../tx/[hash]/json/json-tx-information.tsx | 5 + .../[hash]/json/miden-json-tx-information.tsx | 41 ++ .../[name]/tx/[hash]/miden-tx-information.tsx | 192 ++++++ .../[name]/tx/[hash]/tx-information.tsx | 5 + .../networks/[name]/tx/total-txs-metrics.tsx | 16 +- .../[name]/tx/txs-table/network-txs-items.tsx | 60 +- .../[name]/tx/txs-table/network-txs-list.tsx | 4 +- src/app/services/blocks-service.ts | 42 ++ src/app/services/miden-indexer-api/client.ts | 229 +++++++ .../services/miden-indexer-api/endpoints.ts | 91 +++ src/app/services/miden-indexer-api/index.ts | 17 + src/app/services/miden-indexer-api/types.ts | 81 +++ src/app/services/tx-service.ts | 87 +++ 40 files changed, 3268 insertions(+), 15 deletions(-) create mode 100644 docs/2026-05-19-miden-indexer-timestamp-issues.md create mode 100644 docs/plans/2026-05-08-cosmoshub-indexer-integration-design.md create mode 100644 docs/plans/2026-05-12-miden-testnet-integration-design.md create mode 100644 docs/plans/2026-05-19-miden-testnet-stage3-tx-integration.md create mode 100644 server/tools/chains/miden-testnet/AGENTS.md create mode 100644 server/tools/chains/miden-testnet/get-avg-fee.ts create mode 100644 server/tools/chains/miden-testnet/get-chain-uptime.ts create mode 100644 server/tools/chains/miden-testnet/get-total-txs.ts create mode 100644 server/tools/chains/miden-testnet/get-tps.ts create mode 100644 server/tools/chains/miden-testnet/get-txs-last-24h.ts create mode 100644 server/tools/chains/miden-testnet/methods.ts create mode 100644 server/tools/chains/monero/AGENTS.md create mode 100644 src/app/[locale]/networks/[name]/blocks/[hash]/miden-block-information.tsx create mode 100644 src/app/[locale]/networks/[name]/tx/[hash]/expand/miden-expanded-tx-information.tsx create mode 100644 src/app/[locale]/networks/[name]/tx/[hash]/json/miden-json-tx-information.tsx create mode 100644 src/app/[locale]/networks/[name]/tx/[hash]/miden-tx-information.tsx create mode 100644 src/app/services/miden-indexer-api/client.ts create mode 100644 src/app/services/miden-indexer-api/endpoints.ts create mode 100644 src/app/services/miden-indexer-api/index.ts create mode 100644 src/app/services/miden-indexer-api/types.ts diff --git a/.env.example b/.env.example index eecbecd8..278fe15d 100644 --- a/.env.example +++ b/.env.example @@ -103,6 +103,17 @@ LOGOS_INDEXER_BASE_URL="https://indexer.testnet-logos.citizenweb3.com" # Required for getChainUptime to fetch /api/v1/stats. If empty, getChainUptime returns null with a warning. LOGOS_INDEXER_API_TOKEN="" +# ============================================ +# Miden Testnet +# ============================================ + +# Base URL for citizenweb3 Miden indexer (server- and client-side) +MIDEN_INDEXER_BASE_URL="https://indexer.miden-testnet.citizenweb3.com" + +# Bearer token for citizenweb3 Miden indexer (https://indexer.miden-testnet.citizenweb3.com) +# Required for getChainUptime to fetch /api/v1/stats. If empty, getChainUptime returns null with a warning. +MIDEN_INDEXER_API_TOKEN="" + # ============================================ # Cosmos Hub Indexer (chain-data-indexer, cosmos-indexer-api) # ============================================ diff --git a/AGENTS.md b/AGENTS.md index 90dc70ce..8befd5eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -415,3 +415,11 @@ Before completing any code modification task, verify: - Generate docs: `npx gitnexus wiki` + +--- + +## ClawMem — Semantic Code Memory + +> ⚠️ Not indexed yet. Add to `~/.config/clawmem/index.yml` to enable. + +**When indexed:** use `memory_retrieve` MCP tool before code searches and `reindex` after each commit. diff --git a/CLAUDE.md b/CLAUDE.md index b3e897ad..596eb414 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -484,3 +484,11 @@ Before completing any code modification task, verify: - Run `yarn build` before pushing + +--- + +## ClawMem — Semantic Code Memory + +> ⚠️ Not indexed yet. Add to `~/.config/clawmem/index.yml` to enable. + +**When indexed:** use `memory_retrieve` MCP tool before code searches and `reindex` after each commit. diff --git a/docs/2026-05-19-miden-indexer-timestamp-issues.md b/docs/2026-05-19-miden-indexer-timestamp-issues.md new file mode 100644 index 00000000..1bd119cd --- /dev/null +++ b/docs/2026-05-19-miden-indexer-timestamp-issues.md @@ -0,0 +1,158 @@ +# Miden Indexer — Block & Transaction Timestamp Issues + +**Reporter:** ValidatorInfo team +**Date:** 2026-05-19 +**Indexer URL:** `https://indexer.miden-testnet.citizenweb3.com` +**Affected endpoints:** `/api/v1/stats`, `/api/v1/blocks`, `/api/v1/transactions` + +--- + +## Summary + +Two timestamp issues found while integrating Miden testnet indexer into ValidatorInfo block explorer: + +1. **Transactions have no production timestamp** — only `inserted_at` (indexer write time). This is an indexer-side gap and should be fixed. +2. **Block `timestamp` field lags ~5 days behind real time** while `block_num` grows live. Likely upstream (block producer / chain) — please investigate and document. + +--- + +## Evidence (live data, 2026-05-19 14:27 UTC) + +### `GET /api/v1/stats` + +```json +{ + "last_block": 679699, + "total_blocks": 679700, + "total_transactions": 917927, + "total_notes": 456809, + "total_nullifiers": 219487, + "total_accounts": 132598, + "latest_block_timestamp": "2026-05-14T01:00:19.000Z", + "tps": 6.0333 +} +``` + +`latest_block_timestamp` = **14.05**, but `last_block` had already grown to **679899** by the time we hit `/blocks` minutes later — chain is producing live. + +### `GET /api/v1/blocks?limit=5&offset=0` + +``` +block_num | timestamp | inserted_at +679899 | 2026-05-14T01:10:19.000Z | 2026-05-19T14:27:59.907Z +679898 | 2026-05-14T01:10:16.000Z | 2026-05-19T14:27:59.907Z +679897 | 2026-05-14T01:10:13.000Z | 2026-05-19T14:27:59.907Z +679896 | 2026-05-14T01:10:10.000Z | 2026-05-19T14:27:59.907Z +679895 | 2026-05-14T01:10:07.000Z | 2026-05-19T14:27:59.907Z +``` + +`timestamp` increments ~3 sec per block (consistent with block time), but absolute value sits on **14.05** while real time is **19.05**. + +### `GET /api/v1/transactions?limit=5&offset=0` + +``` +tx_id (short) | block_num | inserted_at | timestamp +0f4a20d0... | 679899 | 2026-05-19T14:27:59.907Z | (missing) +a3ae5630... | 679895 | 2026-05-19T14:27:59.907Z | (missing) +... | 679895 | 2026-05-19T14:27:59.907Z | (missing) +... | 679895 | 2026-05-19T14:27:59.907Z | (missing) +... | 679894 | 2026-05-19T14:27:59.907Z | (missing) +``` + +No `timestamp` or `block_timestamp` field. Consumers cannot determine when a transaction was produced without a second request per tx. + +--- + +## Issue #1 — Transaction object lacks production timestamp + +**Indexer-side bug. Please fix.** + +### Current behavior + +Transaction objects from `GET /api/v1/transactions` and `GET /api/v1/transactions/{tx_id}` expose only `inserted_at` (when the indexer wrote the row to its DB). + +### Expected behavior + +Each transaction should expose the timestamp of its parent block (the on-chain production timestamp). Either: + +- **Option A** — add field `block_timestamp` (ISO 8601 string) to the transaction response, OR +- **Option B** — join `blocks` on `block_num` server-side and expose `timestamp` directly on the transaction. + +Option A is preferred — keeps schema explicit and avoids naming collision with future per-tx timestamp fields. + +### Why this matters + +- Block explorers must display when a transaction was **produced**, not when it was **indexed**. +- Without this field, every transaction detail view needs an extra request `GET /api/v1/blocks/{block_num}` — multiplies API load and adds latency. +- `inserted_at` is meaningless for users — depends on indexer downtime, backfill jobs, restarts, network latency between producer and indexer. +- Currently in our explorer, the block page shows chain `timestamp` (14.05) while the transaction page shows `inserted_at` (19.05) — confusing inconsistency for the same chain event. + +### Proposed schema + +```json +{ + "tx_id": "0f4a20d059ca45ed149c7a69d00d86ad461f3f27de6fca1212b491e3ed0df784", + "block_num": 679899, + "block_timestamp": "2026-05-14T01:10:19.000Z", + "inserted_at": "2026-05-19T14:27:59.907Z", + "account_id": "...", + "init_account_state": "...", + "final_account_state": "...", + ... +} +``` + +Apply to both list endpoint (`/transactions`) and detail endpoint (`/transactions/{tx_id}`). + +--- + +## Issue #2 — Block timestamp lags real time by ~5 days + +**Likely upstream (block producer / chain), but please investigate and document.** + +### Symptom + +- `block_num` grows live: 679699 → 679899 in a few minutes (chain producing blocks normally). +- `timestamp` field on latest block returns `2026-05-14T01:10:19Z` while wall clock is `2026-05-19T14:27Z`. +- Delta between adjacent blocks is correct (~3 sec block time). Only the absolute base is shifted ~5 days into the past. + +### Possible causes (need indexer team / chain team to confirm) + +1. Block producer node clock unsynchronized (NTP drift, container clock skew, sealed VM clock). +2. Miden testnet uses a non-wall-clock timestamp semantic (e.g., L1 anchor time, slot number translated to time, sequencer-local monotonic clock). +3. Producer hardcodes initial timestamp at genesis and only increments by per-block delta. + +### Action requested + +- Clarify what `timestamp` in the Miden block header is supposed to represent (chain spec / sequencer doc). +- If it should be wall-clock UTC: check producer node NTP, restart if drift confirmed. +- If it has different semantic (slot-based, L1-anchor, monotonic): document this clearly in the indexer API spec so consumers don't treat it as wall time. + +--- + +## Reproduction + +```bash +TOKEN="" +BASE="https://indexer.miden-testnet.citizenweb3.com" + +curl -sS -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/stats" +curl -sS -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/blocks?limit=5&offset=0" +curl -sS -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/transactions?limit=5&offset=0" +``` + +--- + +## Impact on downstream consumers + +- Block explorer page shows chain `timestamp` (14.05) — appears stale to end users. +- Transaction explorer page shows `inserted_at` (19.05) — visible mismatch with block page for the same on-chain event. +- Cannot reliably compute "last activity", "tx age", or "block age" without a per-tx round-trip to `/blocks/{block_num}`. +- TPS metric on `/stats` is unaffected (computed independently), but `latest_block_timestamp` looks suspicious next to a growing `last_block`. + +--- + +## Priority + +- **Issue #1** — high. Blocks UX of every explorer integration. Schema change is non-breaking (additive field). +- **Issue #2** — medium. Needs investigation first to determine whether it is a producer bug or a documentation gap. diff --git a/docs/plans/2026-05-08-cosmoshub-indexer-integration-design.md b/docs/plans/2026-05-08-cosmoshub-indexer-integration-design.md new file mode 100644 index 00000000..ab4d6ee7 --- /dev/null +++ b/docs/plans/2026-05-08-cosmoshub-indexer-integration-design.md @@ -0,0 +1,402 @@ +# Cosmoshub Indexer Integration — Design + +**Date:** 2026-05-08 +**Branch:** `feat/cosmos-indexer-integration` (off `dev`) +**Scope:** Integrate `cosmos-indexer-api` (separate repo `chain-data-indexer`, branch `cosmos-indexer-api`) into ValidatorInfo for chain `cosmoshub`. Adds blocks and transactions UI plus tx-metrics (totalTxs, txsLast24h, tps, avgFee). Mirrors patterns established for Aztec and Logos-testnet. + +--- + +## 1. Background + +### 1.1 cosmos-indexer-api summary + +Read-only Next.js 16 JSON API over the `chain-data-indexer` Postgres DB. + +| Aspect | Value | +|---|---| +| Auth | `x-api-key` header (timingSafeEqual, single key) | +| Pagination | Keyset: `before_height` + `before_index`, `limit+1` probe → `has_more`, returns cursor `{next_before_height[, next_before_index]}` | +| Numeric fields | All `uint64` (height, gas, totals) serialized as decimal strings | +| Cache | `Cache-Control: private` on auth-gated routes | + +**Endpoints used by this integration:** + +| Path | Returns | +|---|---| +| `GET /api/v1/health` | `SELECT 1` | +| `GET /api/v1/blocks?limit&before_height` | `{data: BlockSummary[], cursor, has_more, total}` | +| `GET /api/v1/blocks/height/{h}` | `{data: BlockDetail}` | +| `GET /api/v1/blocks/stats` | `{data: {total_blocks, last_height}}` | +| `GET /api/v1/txs?limit&before_height&before_index` | `{data: TxSummary[], cursor, has_more, total}` | +| `GET /api/v1/txs/{hash}` | `{data: TxDetail}` (includes `messages[]`, `events[]`) | +| `GET /api/v1/txs/{hash}/raw` | `{data: {raw_tx}}` | +| `GET /api/v1/txs/stats` | `{data: {total_txs, last_height}}` (60s in-memory cache + inflight dedup on indexer side) | + +**Schemas (zod-mirrored):** + +```ts +BlockSummary { block_hash, height, time, tx_count, proposer_address } +BlockDetail extends Summary + { size_bytes, last_commit_hash, data_hash, app_hash, evidence_count } + +TxSummary { tx_hash, height, tx_index, time, code, first_msg_type, fee:{amount,denom}|null } +Fee { amount: [{amount, denom}], gas_limit, payer, granter } +Message { msg_index, type_url, value, signer } +Event { msg_index, event_index, event_type, attributes } +TxDetail { ...summary, gas_wanted, gas_used, fee: Fee|null, memo, signers, log_summary, messages, events } +``` + +### 1.2 Existing ValidatorInfo patterns + +| Pattern | Source | +|---|---| +| Per-chain API client folder | `src/app/services/{chain}-indexer-api/` (Aztec, Logos) | +| Block/tx dispatch by chain | `BlocksService.getBlocksByChainName`, `TxService.getTxsByChainName` | +| Tx metrics storage | Prisma `chainTxMetrics` (totalTxs, txsLast24h, tps, avgFee, txs30d), populated by `server/jobs/update-tx-metrics.ts` (cron `everyDay`) | +| Daily snapshot for delta-based metrics | Prisma `chainTxDailySnapshot` (totalTxs per UTC day), Logos pattern | +| Chain methods | `server/tools/chains/{chain}/methods.ts` exports `ChainMethods` record | +| Null fallback | `null-tx-metrics.ts` returns null for all 4 tx metrics — currently spread into `cosmoshub/methods.ts` | + +--- + +## 2. Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Target chain | `cosmoshub` mainnet | Already registered in `params.ts` | +| Scope | UI (blocks + txs) + tx metrics | Surfaces real-time + aggregate data | +| Metrics strategy | Hybrid: stats endpoints for totals, snapshot delta for `txsLast24h`, local aggregation for `tps`/`avgFee` | No indexer changes needed, real values for all metrics | +| Auth | `x-api-key` header (not Bearer) | Matches indexer; no indexer change | +| Deployment | Already deployed at prod URL | No infra work | + +--- + +## 3. Architecture + +### 3.1 New / changed files + +``` +src/app/services/cosmos-indexer-api/ NEW +├── client.ts fetch + timeout + safeJsonParse + x-api-key +├── endpoints.ts typed wrappers per endpoint +├── types.ts CosmosBlock{Summary,Detail}, CosmosTx{Summary,Detail}, CosmosListResponse +└── index.ts export const cosmosIndexer = {...} + +src/app/services/blocks-service.ts getCosmosBlocks + dispatch for 'cosmoshub' +src/app/services/tx-service.ts getCosmosTxs, getCosmosTxByHash, getCosmosTxMetrics + dispatch + +server/tools/chains/cosmoshub/ +├── get-total-txs.ts NEW /txs/stats → bigint +├── get-txs-last-24h.ts NEW snapshot delta (Logos pattern) +├── get-tps.ts NEW local aggregation /blocks?limit=100 +├── get-avg-fee.ts NEW local aggregation /txs?limit=100, uatom only +└── methods.ts override 4 methods after ...nullTxMetrics + +src/app/[locale]/networks/[name]/blocks/[hash]/cosmos-block-information.tsx NEW +src/app/[locale]/networks/[name]/blocks/[hash]/block-information.tsx chain-aware delegation +src/app/[locale]/networks/[name]/blocks/[hash]/expand/expanded-block-information.tsx +src/app/[locale]/networks/[name]/blocks/[hash]/json/json-block-information.tsx +src/app/[locale]/networks/[name]/blocks/blocks-table/network-blocks.tsx column header for cosmoshub +src/app/[locale]/networks/[name]/blocks/blocks-table/network-blocks-list.tsx + +src/app/[locale]/networks/[name]/tx/[hash]/cosmos-tx-information.tsx NEW +src/app/[locale]/networks/[name]/tx/[hash]/tx-information.tsx chain-aware delegation +src/app/[locale]/networks/[name]/tx/[hash]/expand/expanded-cosmos-tx-information.tsx NEW +src/app/[locale]/networks/[name]/tx/[hash]/json/json-tx-information.tsx cosmoshub branch +src/app/[locale]/networks/[name]/tx/txs-table/network-txs-items.tsx +src/app/[locale]/networks/[name]/tx/txs-table/network-txs-list.tsx +src/app/[locale]/networks/[name]/tx/txs-table/network-txs.tsx +src/app/[locale]/networks/[name]/tx/total-txs-metrics.tsx +src/app/[locale]/networks/[name]/overview/network-overview.tsx isCosmoshub branch + +messages/{en,pt,ru}.json new BlockInformationPage + TxInformationPage keys + +.env.example COSMOS_INDEXER_BASE_URL, COSMOS_INDEXER_API_KEY +``` + +### 3.2 Service layer (`cosmos-indexer-api`) + +**`client.ts`** — clone of `logos-indexer-api/client.ts` with two changes: + +```ts +const buildHeaders = (custom?: HeadersInit): HeadersInit => { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + const apiKey = process.env.COSMOS_INDEXER_API_KEY; + if (apiKey) headers['x-api-key'] = apiKey; // not Bearer + // merge custom headers ... + return headers; +}; + +const buildUrl = (path: string, params?: QueryParams) => + `${process.env.COSMOS_INDEXER_BASE_URL}${path}` + qs(params); +``` + +Same fetchWithTimeout, safeJsonParse, request, get, post, healthCheck, getBaseUrl as Logos. + +**`types.ts`** — mirrors `chain-data-indexer/src/schemas/{blocks,txs}.ts`. All BigInt-derived fields are typed as `string`. + +**`endpoints.ts`:** + +```ts +export const getBlocksList = (p, opts?) => + client.get>('/api/v1/blocks', p, opts); +export const getBlockByHeight = (h, opts?) => + client.get<{data: CosmosBlockDetail}>(`/api/v1/blocks/height/${h}`, null, opts); +export const getBlocksStats = (opts?) => + client.get<{data: CosmosBlocksStats}>('/api/v1/blocks/stats', null, opts); +export const getTxsList = (p, opts?) => + client.get>('/api/v1/txs', p, opts); +export const getTxByHash = async (hash, opts?) => { + try { return await client.get<{data: CosmosTxDetail}>(`/api/v1/txs/${hash}`, null, opts); } + catch (e) { if (e.message.includes('HTTP 404')) return null; throw e; } +}; +export const getTxRaw = async (hash, opts?) => { ... }; +export const getTxsStats = (opts?) => client.get<{data: CosmosTxsStats}>('/api/v1/txs/stats', null, opts); +``` + +**`index.ts`:** + +```ts +export * from './types'; +export const cosmosIndexer = { getBlocksList, getBlockByHeight, getBlocksStats, + getTxsList, getTxByHash, getTxRaw, getTxsStats, healthCheck }; +export default cosmosIndexer; +``` + +### 3.3 Chain methods + cron + +**`get-total-txs.ts`:** + +```ts +const getTotalTxs: GetTotalTxs = async () => { + const { data } = await cosmosIndexer.getTxsStats({ cache: 'no-store' }); + return BigInt(data.total_txs); +}; +``` + +**`get-txs-last-24h.ts`** — exact Logos pattern (read yesterday UTC snapshot, subtract from current `total_txs`, return null if no snapshot). + +**`get-tps.ts`** — local aggregation: + +```ts +const TPS_BLOCK_WINDOW = 100; +const getTps: GetTps = async () => { + const { data } = await cosmosIndexer.getBlocksList({ limit: TPS_BLOCK_WINDOW }, { cache: 'no-store' }); + if (data.length < 2) return null; + const sorted = [...data].sort((a,b) => Number(a.height) - Number(b.height)); + const totalTxs = sorted.reduce((s, b) => s + b.tx_count, 0); + const elapsedSec = Math.max((new Date(sorted.at(-1)!.time).getTime() + - new Date(sorted[0].time).getTime()) / 1000, 1); + return totalTxs / elapsedSec; +}; +``` + +**`get-avg-fee.ts`** — local aggregation, uatom only: + +```ts +const getAvgFee: GetAvgFee = async () => { + const { data } = await cosmosIndexer.getTxsList({ limit: 100 }, { cache: 'no-store' }); + const fees = data + .filter(t => t.code === 0 && t.fee?.amount && t.fee.denom === 'uatom') + .map(t => BigInt(t.fee!.amount!)); + if (fees.length === 0) return null; + const sum = fees.reduce((a,b) => a+b, 0n); + return (sum / BigInt(fees.length)).toString(); +}; +``` + +**`cosmoshub/methods.ts`:** + +```ts +import getTotalTxs from '@/server/tools/chains/cosmoshub/get-total-txs'; +import getTxsLast24h from '@/server/tools/chains/cosmoshub/get-txs-last-24h'; +import getTps from '@/server/tools/chains/cosmoshub/get-tps'; +import getAvgFee from '@/server/tools/chains/cosmoshub/get-avg-fee'; + +const chainMethods: ChainMethods = { + ...nullTxMetrics, // fallback no longer used; explicit override below + getNodes, /* ... unchanged ... */ + getTotalTxs, getTxsLast24h, getTps, getAvgFee, +}; +``` + +`server/jobs/update-tx-metrics.ts` and `server/tools/chains/chains.ts` are NOT modified — cosmoshub already in auto-derived chains array, job already iterates it. Returning real values from the 4 methods is sufficient. + +### 3.4 UI layer + +**`blocks-service.getCosmosBlocks`** — heights are dense, compute keyset boundary directly: + +```ts +const getCosmosBlocks = async (currentPage, perPage) => { + try { + const stats = await cosmosIndexer.getBlocksStats({ cache: 'no-store' }); + const lastHeight = BigInt(stats.data.last_height); + if (lastHeight <= 0n) return { blocks: [], totalPages: 1 }; + const totalPages = Math.max(1, Math.ceil(Number(lastHeight) / perPage)); + const beforeHeight = lastHeight - BigInt((currentPage - 1) * perPage) + 1n; + const { data } = await cosmosIndexer.getBlocksList( + { limit: perPage, before_height: beforeHeight.toString() }, { cache: 'no-store' }); + const blocks = data.map(b => ({ + hash: b.block_hash, + height: b.height, + timestamp: formatTimestamp(new Date(b.time)), + finalizationStatus: 3, // Tendermint: instant finality + })); + return { blocks, totalPages }; + } catch (e) { console.error('cosmos blocks fail:', e); return { blocks: [], totalPages: 1 }; } +}; +``` + +Dispatch: `if (normalizedChainName === 'cosmoshub') return getCosmosBlocks(...);` + +**`tx-service.getCosmosTxs`** — keyset is forward-only. For arbitrary `currentPage` we walk via cursor in chunks. + +```ts +const getCosmosTxs = async (currentPage, perPage) => { + const stats = await cosmosIndexer.getTxsStats(TX_LIST_CACHE); + const total = Number(stats.data.total_txs); + const totalPages = Math.max(1, Math.ceil(total / perPage)); + const skip = (currentPage - 1) * perPage; + + let cursor: { before_height?: string; before_index?: number } | undefined; + let walked = 0; + while (walked < skip) { + const step = Math.min(perPage, skip - walked); + const r = await cosmosIndexer.getTxsList({ limit: step, ...cursor }, TX_LIST_CACHE); + if (!r.has_more || !r.cursor) break; + cursor = { before_height: r.cursor.next_before_height, before_index: r.cursor.next_before_index }; + walked += r.data.length; + } + const page = await cosmosIndexer.getTxsList({ limit: perPage, ...cursor }, TX_LIST_CACHE); + + const txs: TxItem[] = page.data.map(t => ({ + hash: t.tx_hash, + status: t.code === 0 ? 'confirmed' : 'dropped', // code≠0 = failed cosmos tx + blockHeight: Number(t.height), + timestamp: new Date(t.time).getTime(), + transactionFee: t.fee?.amount ?? undefined, + opType: t.first_msg_type ?? undefined, + })); + return { txs, totalPages }; +}; + +const getCosmosTxByHash = async (hash: string) => { + const tx = await cosmosIndexer.getTxByHash(hash, { cache: 'no-store' }); + return tx ? { status: tx.data.code === 0 ? 'confirmed' : 'dropped', data: tx.data } : null; +}; + +const getCosmosTxMetrics = async (chainId: number) => { /* read chainTxMetrics, same as Logos */ }; +``` + +**Detail components** (mirror Logos): +- `cosmos-block-information.tsx` — 9 rows (block_hash, height, time, proposer, tx_count, size_bytes, last_commit_hash, app_hash, evidence_count) +- `cosmos-tx-information.tsx` — main fields (hash, height, time, code, gas_wanted, gas_used, fee, signers[], memo, log_summary) +- `expanded-cosmos-tx-information.tsx` — `messages[]` (collapsible, type_url badges, value JSON) + `events[]` grouped by `msg_index` +- `json-tx-information.tsx` — cosmoshub branch reads `cosmosIndexer.getTxRaw(hash)` + +**Chain-aware delegation** — existing `block-information.tsx`, `tx-information.tsx`, `network-blocks.tsx`, `network-txs-items.tsx`, `total-txs-metrics.tsx`, `network-overview.tsx` get an `if (chainName === 'cosmoshub') return ` branch. Block table column header: `Height` (dense heights, no slot column). + +**i18n** — new keys in `messages/{en,pt,ru}.json`: +- `BlockInformationPage`: `evidenceCount`, `lastCommitHash`, `appHash`, `dataHash`, `sizeBytes` +- `TxInformationPage`: `logSummary`, `memo`, `signers`, `events`, `messages`, `gasWanted`, `gasUsed` + +--- + +## 4. Phasing + +### Stage 1 — Service layer + chain methods (no UI) + +1. `src/app/services/cosmos-indexer-api/{client,endpoints,types,index}.ts` +2. `.env.example` — `COSMOS_INDEXER_BASE_URL`, `COSMOS_INDEXER_API_KEY` +3. `cosmoshub/get-{total-txs,txs-last-24h,tps,avg-fee}.ts` +4. `cosmoshub/methods.ts` — override 4 methods after `...nullTxMetrics` +5. **Smoke test:** node script invokes `cosmosIndexer.getBlocksStats() / getTxsStats()` against prod +6. **Validation:** run `update-tx-metrics` manually for `['cosmoshub']` → confirm `chainTxMetrics` row populated with real values +7. `yarn lint && yarn build` clean + +### Stage 2 — UI (blocks) + +1. `blocks-service.getCosmosBlocks` + dispatch +2. `cosmos-block-information.tsx` +3. Delegation in `block-information.tsx`, `expanded-block-information.tsx`, `json-block-information.tsx`, `network-blocks.tsx` (`Height` column for cosmoshub) +4. i18n keys (3 locales) +5. **Manual test:** `/networks/cosmoshub/blocks` paginates, detail page renders, JSON view works + +### Stage 3 — UI (txs) + +1. `tx-service.getCosmosTxs`, `getCosmosTxByHash`, `getCosmosTxMetrics` + dispatch +2. `cosmos-tx-information.tsx` +3. `expanded-cosmos-tx-information.tsx` (messages + events) +4. `network-txs-items.tsx`, `network-txs-list.tsx`, `total-txs-metrics.tsx`, `network-overview.tsx` — `isCosmoshub` branches +5. `json-tx-information.tsx` — cosmoshub via `getTxRaw` +6. i18n keys +7. **Manual test:** `/networks/cosmoshub/tx` list + detail + raw JSON; failed-tx (`code≠0`) renders correctly +8. `network-profile-header.tsx` — confirm Blocks/Txs tabs visible for cosmoshub + +### Stage 4 — Polish + +- TPS / avgFee surface on `network-overview.tsx` +- `chainTxDailySnapshot` populated → `txs30d` works after 1-2 daily ticks + +--- + +## 5. Testing strategy + +### API smoke + +```bash +curl -H "x-api-key: $COSMOS_INDEXER_API_KEY" \ + $COSMOS_INDEXER_BASE_URL/api/v1/blocks/stats +curl -H "x-api-key: ..." "$URL/api/v1/blocks?limit=5" +curl -H "x-api-key: ..." "$URL/api/v1/txs?limit=5" +curl -H "x-api-key: ..." "$URL/api/v1/txs/stats" +``` + +### Indexer job + +```bash +docker compose -f docker-compose.dev.yml exec indexer node -e \ + "require('./dist/server/jobs/update-tx-metrics').default(['cosmoshub']).then(()=>process.exit())" +``` + +Expected: `chainTxMetrics` row for cosmoshub has `totalTxs`, `tps`, `avgFee` non-null; `txsLast24h` null on first run, populated on day 2. + +### Build / lint + +```bash +yarn lint +yarn build +``` + +### UI manual + +- `/networks/cosmoshub/blocks` — page 1, page 2, deep page +- `/networks/cosmoshub/blocks/` — detail, expand, JSON +- `/networks/cosmoshub/tx` — page 1, deep page (verify keyset walk completes in reasonable time) +- `/networks/cosmoshub/tx/` — detail, expand (messages + events render), JSON (raw_tx) +- Failed tx (`code !== 0`) — status badge correct +- `/networks/cosmoshub` overview — totalTxs, tps, avgFee surface + +--- + +## 6. Risks / open items + +| Risk | Mitigation | +|---|---| +| Tx pagination via iterative keyset is O(currentPage) — slow at deep pages | Cap UI `totalPages` at e.g. 50, or accept latency. Revisit in Stage 3 | +| `total_txs` from `pg_class.reltuples` is estimate, not exact count | Sufficient for display; documented in indexer AGENTS.md | +| Failed-tx semantics (`code≠0`): cosmos tx is included in block but execution failed | Show `failed`, not `dropped`. i18n key `TxStatus.failed` | +| Indexer downtime breaks cosmoshub page render | All service methods catch errors, return empty arrays. Logs surfaced to console | +| `avgFee` filtering only uatom — txs paying in other denoms (rare on cosmoshub) excluded | Acceptable; cosmoshub native fee denom is uatom | +| Cron `update-tx-metrics` failure for cosmoshub block other chains | Job already wraps each `processChain` in try/catch (line 161) | + +--- + +## 7. Out of scope + +- Replacing existing cosmoshub data sources (governance, staking, nodes) with indexer +- Other cosmos chains beyond cosmoshub (cosmoshub-testnet, osmosis, etc.) — design extends naturally per chain +- Search by address / multi-message filters — current API does not expose +- WebSocket / realtime tx feed diff --git a/docs/plans/2026-05-12-miden-testnet-integration-design.md b/docs/plans/2026-05-12-miden-testnet-integration-design.md new file mode 100644 index 00000000..d4a054d8 --- /dev/null +++ b/docs/plans/2026-05-12-miden-testnet-integration-design.md @@ -0,0 +1,298 @@ +# Miden Testnet Integration — Design + +**Date:** 2026-05-12 +**Branch:** `feat/miden-integration` (from `dev`) +**Mirrors:** Logos integration (`c66fbf9` + `48b3c18`) +**Scope:** Stage 1 (registration + uptime via indexer) + Stage 2 (UI for blocks). +Tx metrics (Stage 3) deferred until the indexer indexes transactions. + +--- + +## 1. Data sources + +### Indexer REST (primary, like Logos) +- Base: `https://indexer.miden-testnet.citizenweb3.com` +- Auth: `Authorization: Bearer ` +- Endpoints used: + - `GET /health` (root) — liveness probe + - `GET /api/v1/stats` — `last_block`, `total_blocks`, `total_transactions`, + `total_notes`, `total_nullifiers`, `total_accounts`, `latest_block_timestamp` + - `GET /api/v1/blocks?limit=&offset=&sort=block_num&order=desc` — paginated list + - `GET /api/v1/blocks/:block_num` — block detail (incl. `raw_block_bytes`) +- Known indexer issue (not fixed on the explorer side): server currently + returns `block_num` lexicographically and ignores `sort/order`. Backend will + fix; the explorer passes `sort=block_num&order=desc` as if it works. + +### gRPC (`grpc.miden.citizenweb3.com:443`) +Live data source (chain tip, headers). Service: `rpc.Api`. Reflection enabled. +Methods discovered: `Status`, `GetLimits`, `GetBlockByNumber`, +`GetBlockHeaderByNumber`, `GetAccount`, `GetNotesById`, `GetNoteScriptByRoot`, +`CheckNullifiers`, `SyncState`, `SyncNotes`, `SyncNullifiers`, +`SyncAccountVault`, `SyncAccountStorageMaps`, `SyncTransactions`, +`SubmitProvenBatch`, `SubmitProvenTransaction`. + +**Not used in Stages 1–2.** Listed for future reference (e.g. live chain tip, +mempool stats). Current chain tip via gRPC `Status` is ~2.4M while indexer is +at ~82.6k — divergence acknowledged; the indexer team will catch up. + +### Miden as ecosystem +- Standalone (org renamed `0xPolygonMiden` → `0xMiden`, separated from Polygon). +- STARK-based zkVM rollup, client-side proving, privacy-preserving smart + contracts. Centralized block producer in testnet (single `validator_key` per + header). No PoS / no validators-as-stakers ⇒ `hasValidators: false`. + +--- + +## 2. Chain registration + +### `server/tools/chains/params.ts` + +Add `miden` to `ecosystemParams`: +```ts +{ + name: 'miden', + prettyName: 'Miden', + logoUrl: 'https://raw.githubusercontent.com/citizenweb3/staking/refs/heads/chain-images/miden/miden.svg', + tags: ['Privacy', 'zkVM', 'STARK', 'Client-side Proving'], +}, +``` + +Add `miden-testnet` to `chainParams`: +```ts +'miden-testnet': { + rang: 3, + ecosystem: 'miden', + hasValidators: false, + name: 'miden-testnet', + prettyName: 'Miden Testnet', + shortDescription: 'STARK-based zkVM rollup with client-side proving and privacy-preserving smart contracts', + chainId: 'miden-testnet-v0.13', + bech32Prefix: '', + coinDecimals: 0, + coinGeckoId: '', + coinType: 0, + denom: '', + minimalDenom: '', + logoUrl: 'https://raw.githubusercontent.com/citizenweb3/staking/refs/heads/chain-images/miden/miden.svg', + nodes: [ + { type: 'grpc', url: 'https://grpc.miden.citizenweb3.com', provider: 'citizenweb3' }, + { type: 'indexer', url: 'https://indexer.miden-testnet.citizenweb3.com', provider: 'citizenweb3' }, + ], + mainRepo: 'https://github.com/0xMiden/miden-node', + docs: 'https://0xmiden.github.io/miden-docs/', + githubUrl: 'https://github.com/0xMiden', + twitterUrl: 'https://x.com/0xMiden', + tags: ['Miden Ecosystem', 'Testnet', 'zkVM', 'Privacy', 'STARK', 'Client-side Proving'], +}, +``` + +### `server/tools/chains/chains.ts` +Add `'miden-testnet'` to the chains array. + +### `server/tools/chains/methods.ts` +```ts +import midenTestnetMethods from '@/server/tools/chains/miden-testnet/methods'; +// ... +'miden-testnet': midenTestnetMethods, +``` + +--- + +## 3. Chain methods + +`server/tools/chains/miden-testnet/methods.ts` — mirrors Logos stubs; only +`getChainUptime` implemented. All validator/staking/proposal methods return +empty/null. Tx-metrics methods (`getTotalTxs`, `getTxsLast24h`, `getTps`, +`getAvgFee`) return `null` in Stage 1 (added in Stage 3). + +`server/tools/chains/miden-testnet/types.ts` — shared types used by chain +methods (re-exports from `services/miden-indexer-api/types.ts` is acceptable +once that exists; otherwise local declarations). + +`server/tools/chains/miden-testnet/get-chain-uptime.ts`: +- Token env: `MIDEN_INDEXER_API_TOKEN` +- Indexer URL: from `params.ts` (`type: 'indexer'`) +- Steps: + 1. `GET /api/v1/stats` → `last_block` → `uptimeHeight = Number(last_block)` + 2. `GET /api/v1/blocks?limit=100&sort=block_num&order=desc` + 3. Median of `Δtimestamp / Δblock_num` over the page → `blockTimeMs` + 4. Return `{ lastUptimeUpdated, uptimeHeight, avgTxInterval: blockTimeMs, blockTime: blockTimeMs }` +- Fallback `FALLBACK_BLOCK_TIME_MS = 1000` if sampling fails +- Timeout: 8000 ms via `AbortController` +- Logger tag: `miden-chain-uptime` + +`.env.example`: +``` +MIDEN_INDEXER_URL=https://indexer.miden-testnet.citizenweb3.com +MIDEN_INDEXER_API_TOKEN= +``` + +--- + +## 4. Services — `src/app/services/miden-indexer-api/` + +Mirror `logos-indexer-api/` (4 files): + +### `client.ts` +- `MIDEN_INDEXER_BASE_URL` from env, fallback to citizenweb3 URL +- `MIDEN_INDEXER_API_TOKEN` Bearer auth +- `get(path, params, options)` with timeout, 404→typed error, Next revalidate hint + +### `types.ts` +```ts +export interface MidenStats { + last_block: string; + total_blocks: number; + total_transactions: number; + total_notes: number; + total_nullifiers: number; + total_accounts: number; + latest_block_timestamp: string; +} + +export interface MidenBlock { + block_num: string; + block_hash: string; + prev_block_commitment: string; + chain_commitment: string; + account_root: string; + nullifier_root: string; + note_root: string; + tx_commitment: string; + validator_key: string; + tx_kernel_commitment: string; + native_asset_id: string; + verification_base_fee: string; + timestamp: string; + tx_count: number; + note_count: number; + nullifier_count: number; + version: number; + chain_length: number | null; + inserted_at: string; + raw_block_bytes?: string; +} + +export interface MidenBlocksResponse { + data: MidenBlock[]; + total: number; + limit: number; + offset: number; +} +``` + +### `endpoints.ts` +- `getStats(opts?) → MidenStats` +- `getBlocks({ limit, offset, sort, order }, opts?) → MidenBlocksResponse` +- `getBlock(blockNum, opts?) → MidenBlock | null` (404 → null) + +### `index.ts` +Re-export. + +--- + +## 5. UI + +### `src/app/services/blocks-service.ts` +Add `getMidenBlocks(currentPage, perPage) → BlocksResponse`: +- `pageSize = clamp(perPage, 1, 100)` +- `getStats({ cache: 'no-store' })` → `totalBlocks` +- `totalPages = ceil(totalBlocks / pageSize)` +- `getBlocks({ limit, offset, sort: 'block_num', order: 'desc' })` +- Map to `BlockItem`: + - `hash` = `block_hash` + - `height` = `Number(block_num)` + - `timestamp` = `formatTimestamp(new Date(b.timestamp))` + - `finalizationStatus` = `3` (Miden blocks are final on publication) +- Add branch in `getBlocksByChainName`: + ```ts + if (normalizedChainName === 'miden-testnet') return getMidenBlocks(currentPage, perPage); + ``` + +### `networks/[name]/blocks/[hash]/miden-block-information.tsx` +14 detail rows (mirror logos-block-information shape): +1. block hash +2. block number +3. parent commitment (`prev_block_commitment`) +4. chain commitment +5. account root +6. nullifier root +7. note root +8. tx commitment +9. validator key +10. tx kernel commitment +11. native asset id +12. verification base fee +13. timestamp +14. tx count / note count / nullifier count + +Header pill: shows `block_num` and a "finalized" badge (always finalized). + +### `block-information.tsx` +```ts +if (chain?.name === 'miden-testnet') { + return ; +} +``` + +### `expand/expanded-block-information.tsx` +`isMiden` branch — extended block view with `raw_block_bytes` (collapsible). + +### `json/json-block-information.tsx` + `json/page.tsx` +`isMiden` branch — return raw `MidenBlock` JSON. + +### `blocks-table/network-blocks.tsx` +No column-header override needed (block_num is numeric height; standard +"Block" column works). + +### `blocks-table/network-blocks-list.tsx` +Empty-state already covered by Logos PR. + +### `(network-profile)/overview/network-overview.tsx` +`isMiden` branch — show `avgTxInterval` (block time from `getChainUptime`) and +last block height; "Indexer mode" label. + +--- + +## 6. i18n (`messages/{en,pt,ru}.json` → `BlockInformationPage`) + +New keys (3 files, identical structure): +- `block number` +- `parent commitment` +- `chain commitment` +- `account root` +- `nullifier root` +- `note root` +- `tx commitment` +- `tx kernel commitment` +- `validator key` +- `native asset id` +- `verification base fee` +- `note count` +- `nullifier count` +- `version` + +--- + +## 7. PR breakdown + +| Stage | Commit subject | Scope | +|-------|---------------|-------| +| 1 | `feat(miden-testnet): stage 1 — chain registration & indexer uptime` | `params.ts`, `chains.ts`, `methods.ts`, `miden-testnet/{methods,get-chain-uptime,types}.ts`, `.env.example`, indexer wiring if needed | +| 2 | `feat(miden-testnet): stage 2 — UI integration for blocks` | `services/miden-indexer-api/*`, `blocks-service.ts`, `miden-block-information.tsx`, `block-information.tsx`, expand/json branches, `network-overview.tsx`, `messages/{en,pt,ru}.json` | +| 3 (later) | `feat(miden-testnet): stage 3 — tx integration` | tx services, jobs, UI — once indexer indexes transactions | + +--- + +## 8. Open items / follow-ups + +1. **Indexer lag.** Currently `last_block=82599` vs chain tip ~2.4M. Backend team + to catch up; explorer behavior is correct in either state. +2. **Indexer sort.** `sort=block_num&order=desc` currently no-op on the server; + the explorer passes the params and expects them to be honored after the fix. +3. **Validator key per block.** Centralized producer for testnet; no UI surface + beyond "validator key" field in block details. +4. **gRPC integration.** Deferred — no current UI need; `Status` could power a + live "tip" widget in a later PR if helpful. +5. **chainId provisional.** `miden-testnet-v0.13` matches node `v0.13.4`. Update + when Miden publishes an official identifier or genesis-commitment-based id. diff --git a/docs/plans/2026-05-19-miden-testnet-stage3-tx-integration.md b/docs/plans/2026-05-19-miden-testnet-stage3-tx-integration.md new file mode 100644 index 00000000..f8d26e39 --- /dev/null +++ b/docs/plans/2026-05-19-miden-testnet-stage3-tx-integration.md @@ -0,0 +1,637 @@ +# Miden Testnet — Stage 3: Transaction Integration + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Wire Miden testnet transactions into ValidatorInfo (tx list, tx detail page, tx metrics on overview), mirroring the Logos Stage 3 pattern (`96c6dbb`). + +**Architecture:** Indexer at `https://indexer.miden-testnet.citizenweb3.com` now exposes `/api/v1/transactions` (list + detail) and includes `tps` in `/api/v1/stats`. Implement: (a) typed API client wrappers, (b) chain methods `getTotalTxs/getTps/getTxsLast24h/getAvgFee`, (c) `tx-service.ts` Miden branch, (d) tx-information UI branch for Miden, (e) translations for new Miden-specific field labels. Avg-fee returns `null` (Miden v1 fee semantics unclear; verification_base_fee is block-level, not tx-level). + +**Tech Stack:** Next.js 14 (App Router + RSC), Prisma (`chainTxDailySnapshot`, `chainTxMetrics` tables already exist), TypeScript, Tailwind, next-intl, fetch wrapper in `miden-indexer-api/client.ts`. + +**Reference commit:** `96c6dbb added txs to logos-testnet` — same structure, identical Prisma tables, same job `update-tx-metrics.ts`. + +--- + +## Live indexer schema (verified 2026-05-19) + +`GET /api/v1/stats`: +```json +{ + "last_block": 499699, + "total_blocks": 499700, + "total_transactions": 701103, + "total_notes": 339327, + "total_nullifiers": 176595, + "total_accounts": 111872, + "latest_block_timestamp": "2026-05-07T19:00:19.000Z", + "tps": 97.1667 +} +``` + +`GET /api/v1/transactions?limit=N&offset=M`: +```json +{ + "data": [{ + "tx_id": "0c02279bf25b8063b7fcd4de6b8abbf6701cab43fb50812863fe47d550015a85", + "block_num": 499749, + "account_id": "c5cd4390b149bc403053c44d97e5e2", + "init_account_state": "b6e00eec...", + "final_account_state": "ea108ce3...", + "input_notes_commitment": "08de28fe...", + "output_notes_commitment": "a276e4e9...", + "expiration_block_num": null, + "input_nullifiers": null, + "output_note_ids": null, + "inserted_at": "2026-05-19T08:07:46.691Z", + "account_id_bech32": "miden1chx58y93fx7yqvznc3xe0e0zwyqrx7" + }], + "total": 701888, "limit": 3, "offset": 0 +} +``` + +`GET /api/v1/transactions/{tx_id}` — assumed same shape (verify in Task 2). + +⚠️ **Known indexer side-bugs (out of scope, file as separate issue to owner):** +- `last_block` field type fluctuates between `number` and `string` in `/stats`. +- `sort=block_num&order=desc` on `/blocks` returns lexicographic order (9999 between 99990/99989). + +--- + +### Task 1: Verify tx detail endpoint shape + +**Files:** +- Read-only verification step. + +**Step 1: curl tx detail** + +```bash +curl -s -H "Authorization: Bearer $MIDEN_INDEXER_API_TOKEN" \ + https://indexer.miden-testnet.citizenweb3.com/api/v1/transactions/0c02279bf25b8063b7fcd4de6b8abbf6701cab43fb50812863fe47d550015a85 +``` + +Expected: same fields as list item, possibly extra metadata. If 404 on first try, pick a fresh `tx_id` from `?limit=1`. + +**Step 2: Record actual fields in Task 2 types** + +If detail has additional fields (e.g., `raw_tx_bytes`, `proof`), reflect them in `MidenTxDetail`. Otherwise reuse list-item shape. + +**Step 3: No commit** — research step. + +--- + +### Task 2: Extend `miden-indexer-api` types + +**Files:** +- Modify: `src/app/services/miden-indexer-api/types.ts` + +**Step 1: Add `tps` to `MidenStats`** + +In `MidenStats` interface, add: +```ts +tps?: number; +``` + +(Optional because old responses lacked it; defensive.) + +**Step 2: Add transaction types** + +Append to `types.ts`: +```ts +export interface MidenTransaction { + tx_id: string; + block_num: number; + account_id: string; + init_account_state: string; + final_account_state: string; + input_notes_commitment: string; + output_notes_commitment: string; + expiration_block_num: number | null; + input_nullifiers: string[] | null; + output_note_ids: string[] | null; + inserted_at: string; + account_id_bech32: string; +} + +export type MidenTxDetail = MidenTransaction; + +export interface MidenTransactionsResponse { + data: MidenTransaction[]; + total: number; + limit: number; + offset: number; +} +``` + +**Step 3: Type-check** + +Run: `yarn build` (in lead's terminal, not in subagents per project rules). +Expected: no TS errors in miden-indexer-api files. + +**Step 4: Commit** + +⚠️ **Per user preference, do NOT auto-commit.** Lead reviews diff, user commits manually. + +--- + +### Task 3: Add endpoints `getTransactions` / `getTransaction` + +**Files:** +- Modify: `src/app/services/miden-indexer-api/endpoints.ts` +- Modify: `src/app/services/miden-indexer-api/index.ts` + +**Step 1: Add endpoint signatures** + +In `endpoints.ts`, after `getBlock`: + +```ts +export interface GetTransactionsParams { + limit?: number; + offset?: number; + sort?: 'block_num' | 'inserted_at'; + order?: 'asc' | 'desc'; + account_id?: string; +} + +export const getTransactions = ( + params: GetTransactionsParams = {}, + options?: MidenIndexerRequestOptions, +): Promise => + client.get( + '/api/v1/transactions', + { + limit: params.limit, + offset: params.offset, + sort: params.sort, + order: params.order, + account_id: params.account_id, + }, + options, + ); + +export const getTransaction = async ( + txId: string, + options?: MidenIndexerRequestOptions, +): Promise => { + try { + return await client.get( + `/api/v1/transactions/${encodeURIComponent(txId)}`, + null, + options, + ); + } catch (e) { + if (e instanceof Error && e.message.includes('HTTP 404')) { + return null; + } + throw e; + } +}; +``` + +Also add the new type import at the top: +```ts +import { MidenBlock, MidenBlocksResponse, MidenIndexerRequestOptions, MidenStats, MidenTxDetail, MidenTransactionsResponse } from './types'; +``` + +**Step 2: Export from `index.ts`** + +Add to the default object: +```ts +import { getBlock, getBlocks, getStats, getTransactions, getTransaction } from './endpoints'; +... +export const midenIndexer = { + getStats, + getBlocks, + getBlock, + getTransactions, + getTransaction, + healthCheck, + getBaseUrl, +}; +``` + +**Step 3: Manual smoke test** + +```bash +docker exec validatorinfo-dev-frontend node -e " +import('./dist/services/miden-indexer-api/index.js').then(async m => { + const r = await m.default.getTransactions({ limit: 2 }); + console.log(JSON.stringify(r, null, 2)); +}).catch(e => { console.error(e); process.exit(1); }); +" +``` +(Or test through running `/networks/miden-testnet/tx` page after Task 7.) + +--- + +### Task 4: Chain method — `getTotalTxs` + +**Files:** +- Create: `server/tools/chains/miden-testnet/get-total-txs.ts` + +**Step 1: Implement** + +```ts +import midenIndexer from '@/services/miden-indexer-api'; +import { GetTotalTxs } from '@/server/tools/chains/chain-indexer'; + +const getTotalTxs: GetTotalTxs = async () => { + const stats = await midenIndexer.getStats({ cache: 'no-store' }); + return typeof stats?.total_transactions === 'number' ? BigInt(stats.total_transactions) : null; +}; + +export default getTotalTxs; +``` + +Mirrors `logos-testnet/get-total-txs.ts` exactly. + +--- + +### Task 5: Chain method — `getTps` + +**Files:** +- Create: `server/tools/chains/miden-testnet/get-tps.ts` + +**Step 1: Implement** + +Miden has `stats.tps` field — use directly, fall back to derivation if missing: + +```ts +import midenIndexer from '@/services/miden-indexer-api'; +import { GetTps } from '@/server/tools/chains/chain-indexer'; +import getTxsLast24h from '@/server/tools/chains/miden-testnet/get-txs-last-24h'; + +// Miden indexer exposes `tps` in /stats. Use directly; fall back to derivation from 24h delta. +const getTps: GetTps = async (dbChain) => { + const stats = await midenIndexer.getStats({ cache: 'no-store' }); + if (typeof stats?.tps === 'number' && Number.isFinite(stats.tps)) { + return stats.tps; + } + const txsLast24h = await getTxsLast24h(dbChain); + return txsLast24h !== null ? txsLast24h / 86400 : null; +}; + +export default getTps; +``` + +--- + +### Task 6: Chain method — `getTxsLast24h` + +**Files:** +- Create: `server/tools/chains/miden-testnet/get-txs-last-24h.ts` + +**Step 1: Copy Logos pattern** + +Verbatim port of `logos-testnet/get-txs-last-24h.ts`, swapping `logosIndexer` → `midenIndexer`: + +```ts +import db from '@/db'; +import midenIndexer from '@/services/miden-indexer-api'; +import { GetTxsLast24h } from '@/server/tools/chains/chain-indexer'; + +const getYesterdayUtc = (): Date => { + const d = new Date(); + d.setUTCHours(0, 0, 0, 0); + d.setUTCDate(d.getUTCDate() - 1); + return d; +}; + +const getTxsLast24h: GetTxsLast24h = async (dbChain) => { + const stats = await midenIndexer.getStats({ cache: 'no-store' }); + const currentTotal = + typeof stats?.total_transactions === 'number' ? stats.total_transactions : null; + if (currentTotal === null) { + return null; + } + + const snapshot = await db.chainTxDailySnapshot.findUnique({ + where: { chainId_snapshotAt: { chainId: dbChain.id, snapshotAt: getYesterdayUtc() } }, + }); + if (!snapshot) { + return null; + } + + const delta = BigInt(currentTotal) - snapshot.totalTxs; + return delta >= BigInt(0) ? Number(delta) : null; +}; + +export default getTxsLast24h; +``` + +--- + +### Task 7: Chain method — `getAvgFee` (null) + +**Files:** +- Create: `server/tools/chains/miden-testnet/get-avg-fee.ts` + +**Step 1: Implement** + +```ts +import { GetAvgFee } from '@/server/tools/chains/chain-indexer'; + +// Miden testnet: tx-level fee not exposed in indexer (only block-level +// `verification_base_fee`). Tx schema lacks fee_amount; return null until +// the indexer surfaces per-tx fees. +const getAvgFee: GetAvgFee = async () => null; + +export default getAvgFee; +``` + +--- + +### Task 8: Wire methods.ts + +**Files:** +- Modify: `server/tools/chains/miden-testnet/methods.ts` + +**Step 1: Replace `nullTxMetrics` spread with real methods** + +```diff +- import nullTxMetrics from '@/server/tools/chains/null-tx-metrics'; ++ import getAvgFee from '@/server/tools/chains/miden-testnet/get-avg-fee'; ++ import getTotalTxs from '@/server/tools/chains/miden-testnet/get-total-txs'; ++ import getTps from '@/server/tools/chains/miden-testnet/get-tps'; ++ import getTxsLast24h from '@/server/tools/chains/miden-testnet/get-txs-last-24h'; + + ... + getChainUptime, + getRewardAddress: async () => [], +- ...nullTxMetrics, ++ getTotalTxs, ++ getTxsLast24h, ++ getTps, ++ getAvgFee, + }; +``` + +**Step 2: Update header comment** + +Remove the "tx metrics are deferred to Stage 3" line — replace with brief note that Miden tx metrics are wired via indexer's `/transactions` and `stats.tps`. + +--- + +### Task 9: tx-service.ts — `getMidenTxs` + `getMidenTxByHash` + `getMidenTxMetrics` + +**Files:** +- Modify: `src/app/services/tx-service.ts` + +**Step 1: Add Miden import block** + +After existing `logosIndexer` imports: +```ts +import midenIndexer from '@/services/miden-indexer-api'; +import { MidenTxDetail } from '@/services/miden-indexer-api'; +``` + +**Step 2: Extend `TxItem` if needed** + +Miden tx has `account_id_bech32`. Repurpose existing optional fields where possible (`feePayer` for account_id_bech32 won't fit semantically — Miden has no fee payer). Add one new optional: + +```ts +export interface TxItem { + ... + // Miden-specific + accountId?: string; +} +``` + +**Step 3: Implement list fetch** + +After `getLogosTxs`, add: + +```ts +const getMidenTxs = async (currentPage: number, perPage: number): Promise => { + try { + const offset = (currentPage - 1) * perPage; + const [{ data, total }, stats] = await Promise.all([ + midenIndexer.getTransactions( + { limit: perPage, offset, sort: 'block_num', order: 'desc' }, + TX_LIST_CACHE, + ), + midenIndexer.getStats(TX_LIST_CACHE), + ]); + + const totalCount = total ?? (typeof stats?.total_transactions === 'number' ? stats.total_transactions : 0); + const totalPages = Math.max(1, Math.ceil(totalCount / perPage)); + + const txs: TxItem[] = data.map((tx) => ({ + hash: tx.tx_id, + status: 'confirmed' as const, + blockHeight: tx.block_num, + timestamp: new Date(tx.inserted_at).getTime(), + accountId: tx.account_id_bech32, + })); + + return { txs, totalPages }; + } catch (error) { + console.error('Failed to fetch Miden transactions:', error); + return { txs: [], totalPages: 1, error: true }; + } +}; + +const getMidenTxByHash = async ( + txId: string, +): Promise<{ status: TxStatus; data: MidenTxDetail } | null> => { + const tx = await midenIndexer.getTransaction(txId, { cache: 'no-store' }).catch(() => null); + if (!tx) { + return null; + } + return { status: 'confirmed', data: tx }; +}; + +const getMidenTxMetrics = async (chainId: number): Promise => { + const cached = await db.chainTxMetrics.findUnique({ where: { chainId } }); + + if (!cached) { + return { totalTxs: null, txsLast24h: null, txs30d: null, tps: null, avgFee: null }; + } + + return { + totalTxs: cached.totalTxs ? Number(cached.totalTxs) : null, + txsLast24h: cached.txsLast24h, + txs30d: cached.txs30d, + tps: cached.tps, + avgFee: cached.avgFee, + }; +}; +``` + +**Step 4: Wire into `getTxsByChainName`** + +After the `logos-testnet` branch: +```ts +if (normalizedChainName === 'miden-testnet') { + return getMidenTxs(currentPage, perPage); +} +``` + +**Step 5: Export** + +Add to `TxService` object: +```ts +getMidenTxs, +getMidenTxByHash, +getMidenTxMetrics, +``` + +--- + +### Task 10: UI — Miden tx-detail rendering + +**Files:** +- Create: `src/app/[locale]/networks/[name]/tx/[hash]/miden-tx-information.tsx` +- Modify: `src/app/[locale]/networks/[name]/tx/[hash]/tx-information.tsx` + +**Step 1: Create `miden-tx-information.tsx`** + +Follow the structure of `miden-block-information.tsx` (already in repo). Render fields: +- chain (link) +- block height (link to `/blocks/{block_num}`) +- timestamp (formatted) +- account id (bech32 + raw, copy buttons) +- init/final account state (hash row with CopyButton) +- input/output notes commitment (hash row) +- expiration block num (nullable) +- input nullifiers count + raw on hover (null-safe) +- output note ids count +- inserted at + +**Step 2: Branch in `tx-information.tsx`** + +Add after the cosmoshub branch, before the Aztec branch: +```tsx +if (chain?.name === 'miden-testnet') { + return ; +} +``` + +Import: +```ts +import MidenTxInformation from '@/app/networks/[name]/tx/[hash]/miden-tx-information'; +``` + +--- + +### Task 11: Locale files (en/pt/ru) + +**Files:** +- Modify: `messages/en.json` +- Modify: `messages/pt.json` +- Modify: `messages/ru.json` + +**Step 1: Add new TxInformationPage keys** + +For every new field rendered in `miden-tx-information.tsx` that isn't already in en.json: +- `account id`, `account id bech32`, `init account state`, `final account state`, +- `input notes commitment`, `output notes commitment`, +- `expiration block num`, `input nullifiers`, `output note ids`, `inserted at` + +Add to all three locale files under `TxInformationPage` namespace with proper translations (en/pt/ru). Use existing wording style. + +**Step 2: Verify symmetry** + +Compare keys across en.json/pt.json/ru.json — every Miden key present in all three. + +--- + +### Task 12: Verify tx list/metrics already render + +**Files:** +- Read-only review: + - `src/app/[locale]/networks/[name]/tx/txs-table/network-txs-list.tsx` + - `src/app/[locale]/networks/[name]/tx/total-txs-metrics.tsx` + - `src/app/[locale]/networks/[name]/(network-profile)/overview/network-overview.tsx` + +**Step 1: Confirm generic dispatch** + +`network-txs-list` calls `TxService.getTxsByChainName(chainName, …)` — after Task 9 this returns Miden data automatically. No edit needed. + +`total-txs-metrics` reads `chainTxMetrics` via service — after `update-tx-metrics` cron runs for Miden, values populate. No edit needed. + +`network-overview` already has generic tx-metrics block (from commit 96c6dbb). Verify `miden-testnet` not excluded by any chain-name check. + +**Step 2: If gated by chain-name allowlist** — add `'miden-testnet'`. + +--- + +### Task 13: Verify `update-tx-metrics` job runs for Miden + +**Files:** +- Read: `server/jobs/update-tx-metrics.ts` + +**Step 1: Ensure no chain-name allowlist excludes Miden** + +`update-tx-metrics` should run for any chain where `chainMethods[name].getTotalTxs` exists. After Task 8, Miden qualifies. If the job has a hardcoded allowlist of chain names, add `'miden-testnet'`. + +**Step 2: Manual trigger to populate metrics** + +```bash +docker exec validatorinfo-dev-indexer npx tsx -e " +import updateTxMetrics from './server/jobs/update-tx-metrics.js'; +await updateTxMetrics(); +process.exit(0); +" +``` + +Then check: +```bash +docker exec validatorinfo-dev-db psql -U validatorinfo_user validatorinfo_db -c \ + "SELECT \"chainId\", \"totalTxs\", tps, \"txsLast24h\" FROM \"ChainTxMetrics\" WHERE \"chainId\" = (SELECT id FROM \"Chain\" WHERE name='miden-testnet');" +``` + +--- + +### Task 14: yarn lint + yarn build (lead-only) + +Run: +```bash +yarn lint +yarn build +``` + +Fix any errors. **Do not commit** — leave working-tree changes for user review. + +--- + +### Task 15: Manual UI verification + +**Step 1: Start frontend** (already running). + +**Step 2: Visit pages** + +- `http://localhost:3000/networks/miden-testnet/tx` — list shows real tx_ids, blocks, accounts +- `http://localhost:3000/networks/miden-testnet/tx/` — detail page renders all fields +- `http://localhost:3000/networks/miden-testnet/overview` — total tx, tps, txsLast24h populated (after Task 13 cron) + +**Step 3: Spot-check formatting** + +- account_id_bech32 truncation +- block_num link works +- locales switch correctly (try `?locale=ru`, `?locale=pt`) + +--- + +## Out of scope (file separately) + +- Indexer-side bugs (`last_block` type drift, lex sort on `block_num`). +- Per-tx fee semantics (Miden v1 has no tx-level fee in indexer schema). +- Account detail page for Miden accounts (`/networks/miden-testnet/address/...`). +- `nullTxMetrics` cleanup (still used by other chains). + +--- + +## File ownership matrix (for team execution) + +| Teammate | Owns | Does NOT touch | +|---|---|---| +| backend-dev | `server/tools/chains/miden-testnet/*`, `src/app/services/miden-indexer-api/*`, `src/app/services/tx-service.ts` | UI components, locale files | +| frontend-dev | `src/app/[locale]/networks/[name]/tx/[hash]/miden-tx-information.tsx`, `tx-information.tsx` branch only | server/, services/ | +| i18n-dev | `messages/en.json`, `messages/pt.json`, `messages/ru.json` | code files | +| qa-dev | Manual UI verification, indexer job trigger | implementation files | + +Sequential gates: +- Tasks 2 → 3 (types before endpoints) +- Tasks 4-7 (all four chain methods) → 8 (wire methods.ts) → 9 (tx-service) +- Tasks 9 + 11 → 10 (tx-information needs both service + locale keys) +- Task 14 (build/lint) gates Task 15 (manual UI) diff --git a/messages/en.json b/messages/en.json index 0ce998d5..8488445c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -399,7 +399,10 @@ "average block time": "Average Block Time", "slot duration": "Slot Duration", "epoch progress": "Epoch Progress", - "committee size": "Committee Size" + "committee size": "Committee Size", + "indexer mode": "Indexer Mode", + "active": "Active", + "inactive": "inactive" }, "NetworkStatistics": { "title": "Blockchain Statistics Dashboard: Live Network Metrics", @@ -1484,7 +1487,18 @@ "messages": "Messages", "events": "Events", "failed": "Failed", - "none": "None" + "none": "None", + "account id": "Account ID", + "account id bech32": "Account ID (Bech32)", + "init account state": "Initial Account State", + "final account state": "Final Account State", + "input notes commitment": "Input Notes Commitment", + "output notes commitment": "Output Notes Commitment", + "expiration block num": "Expiration Block", + "input nullifiers": "Input Nullifiers", + "output note ids": "Output Note IDs", + "inserted at": "Inserted At", + "items count": "{count, plural, one {# item} other {# items}}" }, "BlockInformationPage": { "title": "Blockchain Block Details: In-Depth Information of the Block", @@ -1538,6 +1552,20 @@ "app hash": "App Hash", "data hash": "Data Hash", "size bytes": "Size (bytes)", + "block number": "Block Number", + "parent commitment": "Parent Commitment", + "chain commitment": "Chain Commitment", + "account root": "Account Root", + "nullifier root": "Nullifier Root", + "note root": "Note Root", + "tx commitment": "Tx Commitment", + "tx kernel commitment": "Tx Kernel Commitment", + "validator key": "Validator Key", + "native asset id": "Native Asset ID", + "verification base fee": "Verification Base Fee", + "note count": "Note Count", + "nullifier count": "Nullifier Count", + "version": "Version", "Tabs": { "Expand": "Expand", "JSON": "JSON" diff --git a/messages/pt.json b/messages/pt.json index 13c4b856..969e90a4 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -399,7 +399,10 @@ "average block time": "Tempo Médio de Bloqueio", "slot duration": "Duração do Slot", "epoch progress": "Progresso da Época", - "committee size": "Dimensão da Comissão" + "committee size": "Dimensão da Comissão", + "indexer mode": "Modo Indexador", + "active": "Ativo", + "inactive": "inativo" }, "NetworkStatistics": { "title": "Painel de Estatísticas Blockchain: Métricas ao Vivo da Rede", @@ -1504,7 +1507,18 @@ "messages": "Mensagens", "events": "Eventos", "failed": "Falhou", - "none": "Nenhum" + "none": "Nenhum", + "account id": "ID da Conta", + "account id bech32": "ID da Conta (Bech32)", + "init account state": "Estado Inicial da Conta", + "final account state": "Estado Final da Conta", + "input notes commitment": "Compromisso das Notas de Entrada", + "output notes commitment": "Compromisso das Notas de Saída", + "expiration block num": "Bloco de Expiração", + "input nullifiers": "Anuladores de Entrada", + "output note ids": "IDs das Notas de Saída", + "inserted at": "Inserido em", + "items count": "{count, plural, one {# item} other {# itens}}" }, "BlockInformationPage": { "title": "Detalhes do Bloco Blockchain: Informações Detalhadas do Bloco", @@ -1558,6 +1572,20 @@ "app hash": "Hash do App", "data hash": "Hash dos Dados", "size bytes": "Tamanho (bytes)", + "block number": "Número do Bloco", + "parent commitment": "Compromisso do Pai", + "chain commitment": "Compromisso da Cadeia", + "account root": "Raiz da Conta", + "nullifier root": "Raiz do Anulador", + "note root": "Raiz da Nota", + "tx commitment": "Compromisso da Tx", + "tx kernel commitment": "Compromisso do Kernel da Tx", + "validator key": "Chave do Validador", + "native asset id": "ID do Ativo Nativo", + "verification base fee": "Taxa Base de Verificação", + "note count": "Contagem de Notas", + "nullifier count": "Contagem de Anuladores", + "version": "Versão", "Tabs": { "Expand": "Expandir", "JSON": "JSON" diff --git a/messages/ru.json b/messages/ru.json index 5e5ff428..ec260dee 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -399,7 +399,10 @@ "average block time": "Среднее время блока", "slot duration": "Время слота", "epoch progress": "Прогресс эпохи", - "committee size": "Размер комитета" + "committee size": "Размер комитета", + "indexer mode": "Режим индексатора", + "active": "Активен", + "inactive": "неактивен" }, "NetworkStatistics": { "title": "Панель статистики блокчейна: Живые показатели сети", @@ -1485,7 +1488,18 @@ "messages": "Сообщения", "events": "События", "failed": "Ошибка", - "none": "Нет" + "none": "Нет", + "account id": "ID аккаунта", + "account id bech32": "ID аккаунта (Bech32)", + "init account state": "Начальное состояние аккаунта", + "final account state": "Конечное состояние аккаунта", + "input notes commitment": "Коммитмент входящих заметок", + "output notes commitment": "Коммитмент исходящих заметок", + "expiration block num": "Блок истечения", + "input nullifiers": "Входящие нуллификаторы", + "output note ids": "ID исходящих заметок", + "inserted at": "Добавлено", + "items count": "{count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элементов}}" }, "BlockInformationPage": { "title": "Детали блока в блокчейне: Подробная информация о блоке", @@ -1539,6 +1553,20 @@ "app hash": "Хеш Приложения", "data hash": "Хеш Данных", "size bytes": "Размер (байт)", + "block number": "Номер блока", + "parent commitment": "Коммитмент родителя", + "chain commitment": "Коммитмент цепи", + "account root": "Корень аккаунта", + "nullifier root": "Корень нуллификатора", + "note root": "Корень ноты", + "tx commitment": "Коммитмент транзакции", + "tx kernel commitment": "Коммитмент ядра транзакции", + "validator key": "Ключ валидатора", + "native asset id": "ID нативного актива", + "verification base fee": "Базовая комиссия верификации", + "note count": "Количество нот", + "nullifier count": "Количество нуллификаторов", + "version": "Версия", "Tabs": { "Expand": "Развернуть", "JSON": "JSON" diff --git a/server/tools/chains/methods.ts b/server/tools/chains/methods.ts index 85e822fd..a653f1dc 100644 --- a/server/tools/chains/methods.ts +++ b/server/tools/chains/methods.ts @@ -20,6 +20,7 @@ import strideChainMethods from '@/server/tools/chains/stride/methods'; 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'; const chainMethods: Record = { namada: namadaChainMethods, @@ -63,6 +64,7 @@ const chainMethods: Record = { 'symphony-testnet': symphonyChainMethods, 'aztec-testnet': aztecChainMethods, 'logos-testnet': logosTestnetChainMethods, + 'miden-testnet': midenTestnetChainMethods, }; const getChainMethods = (chainName: string): ChainMethods => { diff --git a/server/tools/chains/miden-testnet/AGENTS.md b/server/tools/chains/miden-testnet/AGENTS.md new file mode 100644 index 00000000..4cb2c9d0 --- /dev/null +++ b/server/tools/chains/miden-testnet/AGENTS.md @@ -0,0 +1,52 @@ +# Miden Testnet Module + +## Purpose + +Минимальная интеграция Miden testnet (STARK-based zkVM rollup, client-side proving, privacy-preserving smart contracts) в ValidatorInfo. Ecosystem: `miden` (standalone — после ребрендинга `0xPolygonMiden` → `0xMiden` отделён от Polygon). `hasValidators: false`. + +## Why this module is small + +Testnet работает с централизованным block producer (единый `validator_key` в каждом header'е), без PoS и без validators-as-stakers. Стандартная Cosmos-shape `ChainMethods` неприменима. В Stage 1 реализован только `getChainUptime`; остальные методы — null/empty stubs. + +## Data source + +- **citizenweb3 Miden indexer** (`https://indexer.miden-testnet.citizenweb3.com`, Bearer auth) + - `GET /api/v1/stats` — `last_block`, `total_blocks`, `total_transactions`, `total_notes`, `total_nullifiers`, `total_accounts`, `latest_block_timestamp` + - `GET /api/v1/blocks?limit=&offset=&sort=block_num&order=desc` — paginated block list + - `GET /api/v1/blocks/:block_num` — block detail (включая `raw_block_bytes`) + - Known issue: server игнорирует `sort/order` и возвращает `block_num` лексикографически — backend починит; explorer передаёт параметры как если бы они работали. +- **gRPC** (`grpc.miden.citizenweb3.com:443`, service `rpc.Api`, reflection enabled) — НЕ используется в Stage 1/2. Резерв для live chain tip / mempool (методы `Status`, `GetBlockHeaderByNumber`, `SyncState`, etc.). + +URL'ы хранятся в `params.ts` через `nodes` массив с типами `'grpc'` и `'indexer'`. Bearer-токен — в `MIDEN_INDEXER_API_TOKEN` env (конвенция `_INDEXER_API_TOKEN`). + +## Files + +| File | Purpose | +|---|---| +| `types.ts` | `MidenStats`, `MidenBlock`, `MidenBlocksResponse` — schema indexer REST (verified live 2026-05-12) | +| `get-chain-uptime.ts` | Единственный рабочий метод: возвращает `{lastUptimeUpdated, uptimeHeight=Number(last_block), avgTxInterval=medianBlockTimeMs│fallback 1000ms, blockTime=medianBlockTimeMs│null}` | +| `methods.ts` | `ChainMethods` объект: `getChainUptime` + null/empty stubs + spread `nullTxMetrics` | + +## Stage boundaries + +- **Stage 1** (this PR): chain registration (`params.ts`, `methods.ts`) + `getChainUptime` через indexer. +- **Stage 2**: UI integration для blocks — `src/app/services/miden-indexer-api/*`, `getMidenBlocks` в `blocks-service.ts`, `miden-block-information.tsx`, JSON/expanded ветки, `network-overview.tsx` ветка, i18n keys. +- **Stage 3** (deferred): tx-metrics jobs + UI — когда indexer начнёт индексировать транзакции (сейчас `total_transactions=0`). + +См. `docs/plans/2026-05-12-miden-testnet-integration-design.md`. + +## Constraints + +- `hasValidators: false` — пропускает 7 validator-centric jobs (centralized block producer, нет staking). +- НЕ менять Prisma schema под Miden — нет attributable validators / stakers. +- НЕ заполнять `Validator` table — single `validator_key` на все блоки testnet'а. +- Tx-метрики (`getTotalTxs`, `getTxsLast24h`, `getTps`, `getAvgFee`) возвращают `null` через `nullTxMetrics` пока indexer не индексирует транзакции. +- `FALLBACK_BLOCK_TIME_MS = 1000` в `get-chain-uptime.ts` — используется только для `avgTxInterval`. `blockTime` остаётся `null` если sampling провалился (по logos-pattern). + +## Versioning + +`chainId: 'miden-testnet-v0.13'` — provisional, соответствует node v0.13.4. Обновить когда Miden опубликует официальный identifier или genesis-commitment-based id. + +## Indexer lag + +На момент 2026-05-12: `last_block=82599` в indexer'е vs ~2.4M на чейн-типе через gRPC `Status`. Backend догоняет. Explorer-логика корректна в обоих состояниях — отображает то, что indexer успел проиндексировать. diff --git a/server/tools/chains/miden-testnet/get-avg-fee.ts b/server/tools/chains/miden-testnet/get-avg-fee.ts new file mode 100644 index 00000000..df8cdee6 --- /dev/null +++ b/server/tools/chains/miden-testnet/get-avg-fee.ts @@ -0,0 +1,8 @@ +import { GetAvgFee } from '@/server/tools/chains/chain-indexer'; + +// Miden testnet: tx-level fee is not exposed in the indexer schema. The only +// fee-related field is block-level `verification_base_fee`; there is no +// per-tx `fee_amount`. Return null until the indexer surfaces per-tx fees. +const getAvgFee: GetAvgFee = async () => null; + +export default getAvgFee; diff --git a/server/tools/chains/miden-testnet/get-chain-uptime.ts b/server/tools/chains/miden-testnet/get-chain-uptime.ts new file mode 100644 index 00000000..813617c0 --- /dev/null +++ b/server/tools/chains/miden-testnet/get-chain-uptime.ts @@ -0,0 +1,83 @@ +import { Chain } from '@prisma/client'; + +import logger from '@/logger'; +import { GetChainUptime } from '@/server/tools/chains/chain-indexer'; +import midenIndexer, { MidenBlock } from '@/services/miden-indexer-api'; + +const { logError, logWarn } = logger('miden-chain-uptime'); + +const FALLBACK_BLOCK_TIME_MS = 1000; +const SAMPLE_LIMIT = 100; +const REQUEST_TIMEOUT_MS = 8000; + +const computeMedianBlockTimeMs = (blocks: MidenBlock[]): number | null => { + const samples: number[] = []; + for (let i = 0; i < blocks.length - 1; i++) { + const a = blocks[i]; + const b = blocks[i + 1]; + const aNum = Number(a.block_num); + const bNum = Number(b.block_num); + if (!Number.isFinite(aNum) || !Number.isFinite(bNum)) continue; + const dtMs = new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); + const dNum = aNum - bNum; + if (Number.isFinite(dtMs) && Number.isFinite(dNum) && dNum !== 0 && dtMs !== 0) { + samples.push(Math.abs(dtMs / dNum)); + } + } + if (samples.length === 0) return null; + samples.sort((a, b) => a - b); + const mid = Math.floor(samples.length / 2); + return samples.length % 2 === 0 ? (samples[mid - 1] + samples[mid]) / 2 : samples[mid]; +}; + +const getChainUptime: GetChainUptime = async (dbChain: Chain) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + let stats; + try { + stats = await midenIndexer.getStats({ cache: 'no-store', signal: controller.signal }); + } catch (e) { + logError(`${dbChain.name} - getStats failed`, e); + return null; + } + + const uptimeHeight = Number(stats.last_block); + + if (!Number.isFinite(uptimeHeight) || uptimeHeight <= 0 || !stats.last_block) { + logError(`${dbChain.name} - invalid last_block in stats; response keys: ${Object.keys(stats).join(',')}`); + return null; + } + + let slotDurationMs: number = FALLBACK_BLOCK_TIME_MS; + let blockTimeMs: number | null = null; + try { + const blocksJson = await midenIndexer.getBlocks( + { limit: SAMPLE_LIMIT, offset: 0, sort: 'block_num', order: 'desc' }, + { cache: 'no-store', signal: controller.signal }, + ); + const blocks = blocksJson.data ?? []; + const computed = computeMedianBlockTimeMs(blocks); + if (computed !== null && computed > 0) { + slotDurationMs = computed; + blockTimeMs = computed; + } else { + logWarn(`${dbChain.name} - block time sampling produced no valid samples, using fallback for avgTxInterval`); + } + } catch (e) { + logWarn(`${dbChain.name} - getBlocks sampling failed, using fallback for avgTxInterval: ${(e as Error).message}`); + } + + return { + lastUptimeUpdated: new Date(), + uptimeHeight, + avgTxInterval: slotDurationMs, + blockTime: blockTimeMs, + }; + } finally { + clearTimeout(timeout); + } +}; + +export default getChainUptime; diff --git a/server/tools/chains/miden-testnet/get-total-txs.ts b/server/tools/chains/miden-testnet/get-total-txs.ts new file mode 100644 index 00000000..50059a27 --- /dev/null +++ b/server/tools/chains/miden-testnet/get-total-txs.ts @@ -0,0 +1,11 @@ +import midenIndexer from '@/services/miden-indexer-api'; +import { GetTotalTxs } from '@/server/tools/chains/chain-indexer'; + +const getTotalTxs: GetTotalTxs = async () => { + const stats = await midenIndexer.getStats({ cache: 'no-store' }); + const total = stats?.total_transactions; + if (typeof total !== 'number' || !Number.isInteger(total) || total < 0) return null; + return BigInt(total); +}; + +export default getTotalTxs; diff --git a/server/tools/chains/miden-testnet/get-tps.ts b/server/tools/chains/miden-testnet/get-tps.ts new file mode 100644 index 00000000..dddde263 --- /dev/null +++ b/server/tools/chains/miden-testnet/get-tps.ts @@ -0,0 +1,17 @@ +import midenIndexer from '@/services/miden-indexer-api'; +import { GetTps } from '@/server/tools/chains/chain-indexer'; +import getTxsLast24h from '@/server/tools/chains/miden-testnet/get-txs-last-24h'; + +// Miden indexer exposes `tps` directly in /stats. Use it as the primary source +// and fall back to derivation from the 24h delta if the field is missing +// (older indexer responses) or non-finite. +const getTps: GetTps = async (dbChain) => { + const stats = await midenIndexer.getStats({ cache: 'no-store' }); + if (typeof stats?.tps === 'number' && Number.isFinite(stats.tps) && stats.tps >= 0) { + return stats.tps; + } + const txsLast24h = await getTxsLast24h(dbChain); + return txsLast24h !== null ? txsLast24h / 86400 : null; +}; + +export default getTps; diff --git a/server/tools/chains/miden-testnet/get-txs-last-24h.ts b/server/tools/chains/miden-testnet/get-txs-last-24h.ts new file mode 100644 index 00000000..c6d3754d --- /dev/null +++ b/server/tools/chains/miden-testnet/get-txs-last-24h.ts @@ -0,0 +1,38 @@ +import db from '@/db'; +import midenIndexer from '@/services/miden-indexer-api'; +import { GetTxsLast24h } from '@/server/tools/chains/chain-indexer'; + +const getYesterdayUtc = (): Date => { + const d = new Date(); + d.setUTCHours(0, 0, 0, 0); + d.setUTCDate(d.getUTCDate() - 1); + return d; +}; + +/** + * Miden indexer has no `/tx-effects-last-24h`-style endpoint, and paginating + * the full tx list per cron tick is far too expensive. Use the daily snapshot + * table that powers txs30d: read yesterday's totalTxs snapshot (UTC), subtract + * from current total. Returns null on the first run (no snapshot yet). + */ +const getTxsLast24h: GetTxsLast24h = async (dbChain) => { + const stats = await midenIndexer.getStats({ cache: 'no-store' }); + const currentTotal = + typeof stats?.total_transactions === 'number' ? stats.total_transactions : null; + if (currentTotal === null) { + return null; + } + + const snapshot = await db.chainTxDailySnapshot.findUnique({ + where: { chainId_snapshotAt: { chainId: dbChain.id, snapshotAt: getYesterdayUtc() } }, + }); + if (!snapshot) { + return null; + } + + if (!Number.isInteger(currentTotal) || currentTotal < 0) return null; + const delta = BigInt(currentTotal) - snapshot.totalTxs; + return delta >= BigInt(0) ? Number(delta) : null; +}; + +export default getTxsLast24h; diff --git a/server/tools/chains/miden-testnet/methods.ts b/server/tools/chains/miden-testnet/methods.ts new file mode 100644 index 00000000..8ded203c --- /dev/null +++ b/server/tools/chains/miden-testnet/methods.ts @@ -0,0 +1,57 @@ +import { ChainMethods } from '@/server/tools/chains/chain-indexer'; +import getAvgFee from '@/server/tools/chains/miden-testnet/get-avg-fee'; +import getChainUptime from '@/server/tools/chains/miden-testnet/get-chain-uptime'; +import getTotalTxs from '@/server/tools/chains/miden-testnet/get-total-txs'; +import getTps from '@/server/tools/chains/miden-testnet/get-tps'; +import getTxsLast24h from '@/server/tools/chains/miden-testnet/get-txs-last-24h'; + +// Miden — STARK-based zkVM rollup with client-side proving. Testnet has a +// centralized block producer (single validator_key per header), no PoS and +// no validators-as-stakers ⇒ hasValidators: false in params.ts skips +// validator-centric jobs. Tx metrics are wired via the citizenweb3 indexer's +// /transactions endpoints and stats.tps; avgFee stays null until the indexer +// surfaces per-tx fees. See docs/plans/2026-05-19-miden-testnet-stage3-tx-integration.md +const chainMethods: ChainMethods = { + 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, + getRewardAddress: async () => [], + getTotalTxs, + getTxsLast24h, + getTps, + getAvgFee, +}; + +export default chainMethods; diff --git a/server/tools/chains/monero/AGENTS.md b/server/tools/chains/monero/AGENTS.md new file mode 100644 index 00000000..e6b2a9cd --- /dev/null +++ b/server/tools/chains/monero/AGENTS.md @@ -0,0 +1,65 @@ +# Monero Module + +## Purpose + +Интеграция Monero (PoW, mainnet) в ValidatorInfo. Ecosystem: `monero`. `hasValidators: false` — у Monero нет stake/validators по дизайну (PoW). Цель — отображать сетевые метрики (height, hashrate, difficulty, supply) и активность mining pools. + +## Why this module is small + +PoW chain. Стандартная Cosmos-shape `ChainMethods` (validators, staking params, slashing, governance, votes, rewards) неприменима — нечего возвращать. Реализация = network-info job + помощник идентификации pools. Все validator/staking методы — null/empty stubs. + +## Data sources + +- **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. + +## 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) | + +## Indexer jobs (server/jobs/) + +| 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 операторы) | + +Failure policy: outer try/catch — любая ошибка swallowed + logged; следующий тик cron = natural retry. Snapshot пишется ДО supply update — даже если `get_coinbase_tx_sum` упал, hashrate-метрики уже в DB. + +## 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. + +## 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 = ...`. diff --git a/server/tools/chains/params.ts b/server/tools/chains/params.ts index 4fdae245..51115b32 100755 --- a/server/tools/chains/params.ts +++ b/server/tools/chains/params.ts @@ -55,6 +55,12 @@ export const ecosystemParams = [ logoUrl: 'https://raw.githubusercontent.com/citizenweb3/staking/refs/heads/chain-images/logos/logos.svg', tags: ['Privacy', 'PoS', 'Cryptarchia', 'Anonymous Proposers'], }, + { + name: 'miden', + prettyName: 'Miden', + logoUrl: 'https://raw.githubusercontent.com/citizenweb3/staking/refs/heads/chain-images/miden/miden.svg', + tags: ['Privacy', 'zkVM', 'STARK', 'Client-side Proving'], + }, ]; const chainParams: Record = { @@ -1277,6 +1283,32 @@ const chainParams: Record = { tags: ['Logos Ecosystem', 'Testnet', 'Privacy', 'Cryptarchia'], }, + 'miden-testnet': { + rang: 3, + ecosystem: 'miden', + hasValidators: false, + name: 'miden-testnet', + prettyName: 'Miden Testnet', + shortDescription: 'STARK-based zkVM rollup with client-side proving and privacy-preserving smart contracts', + chainId: 'miden-testnet-v0.13', + bech32Prefix: '', + coinDecimals: 0, + coinGeckoId: '', + coinType: 0, + denom: '', + minimalDenom: '', + logoUrl: 'https://raw.githubusercontent.com/citizenweb3/staking/refs/heads/chain-images/miden/miden.svg', + nodes: [ + { type: 'grpc', url: 'https://grpc.miden.citizenweb3.com', provider: 'citizenweb3' }, + { type: 'indexer', url: 'https://indexer.miden-testnet.citizenweb3.com', provider: 'citizenweb3' }, + ], + mainRepo: 'https://github.com/0xMiden/miden-node', + docs: 'https://0xmiden.github.io/miden-docs/', + githubUrl: 'https://github.com/0xMiden', + twitterUrl: 'https://x.com/0xMiden', + tags: ['Miden Ecosystem', 'Testnet', 'zkVM', 'Privacy', 'STARK', 'Client-side Proving'], + }, + ethereum: { rang: 1, ecosystem: 'ethereum', 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 0d091f7a..bad7a7cc 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 @@ -9,6 +9,7 @@ import validatorService from '@/services/validator-service'; import { aztecIndexer } from '@/services/aztec-indexer-api'; import cosmosIndexer from '@/services/cosmos-indexer-api'; import logosIndexer from '@/services/logos-indexer-api'; +import midenIndexer from '@/services/miden-indexer-api'; import aztecContractService from '@/services/aztec-contracts-service'; import { getLatestFinalizedBlock } from '@/server/tools/chains/aztec/utils/get-latest-finalized-block'; import { getAztecBlockHeight } from '@/utils/aztec'; @@ -167,6 +168,69 @@ const LogosBlocksSlotsRows: FC<{ chainName: string }> = async ({ chainName }) => ); }; +const MidenTxRow: FC<{ chainName: string }> = async ({ chainName }) => { + const t = await getTranslations('NetworkPassport'); + const stats = await midenIndexer.getStats({ cache: 'no-store' }).catch(() => null); + const totalTxs = + stats && typeof stats.total_transactions === 'number' + ? stats.total_transactions.toLocaleString('en-US') + : 'N/A'; + + return ( +
+
+ {t('total amount of tx')} +
+ + {totalTxs} + +
+ ); +}; + +const MidenIndexerHealthRow: FC = async () => { + const t = await getTranslations('NetworkPassport'); + const isHealthy = await midenIndexer.healthCheck().catch(() => false); + + return ( +
+
+ {t('indexer mode')} +
+
+ {isHealthy ? t('active') : t('inactive')} +
+
+ ); +}; + +const MidenBlocksRow: FC<{ chainName: string }> = async ({ chainName }) => { + const t = await getTranslations('NetworkPassport'); + const stats = await midenIndexer.getStats({ cache: 'no-store' }).catch(() => null); + const lastBlock = stats?.last_block ? Number(stats.last_block) : null; + + if (lastBlock === null || Number.isNaN(lastBlock)) { + return null; + } + + return ( +
+
+ {t('total amount of blocks')} +
+ + {lastBlock.toLocaleString('en-US')} + +
+ ); +}; + const NetworkOverview: FC = async ({ chain }) => { const t = await getTranslations('NetworkPassport'); const price = chain ? await chainService.getTokenPriceByChainId(chain?.id) : undefined; @@ -174,6 +238,7 @@ const NetworkOverview: FC = async ({ chain }) => { const isAztec = chain?.name === 'aztec' || chain?.name === 'aztec-testnet'; const isLogos = chain?.name === 'logos-testnet'; const isCosmoshub = chain?.name === 'cosmoshub'; + const isMiden = chain?.name === 'miden-testnet'; const activeValidators = chain ? isAztec ? await validatorService.getAztecValidators(chain.name as 'aztec' | 'aztec-testnet', chain.id) @@ -223,6 +288,11 @@ const NetworkOverview: FC = async ({ chain }) => { )} + {isMiden && chain && ( + + + + )} {totalSupply > 0 && (
@@ -356,6 +426,25 @@ const NetworkOverview: FC = async ({ chain }) => { + ) : isMiden && chain ? ( + <> + + + + + + + {!!chain.avgTxInterval && ( +
+
+ {t('average block time')} +
+
+ {(chain.avgTxInterval / 1000).toFixed(2)}s +
+
+ )} + ) : isLogos && chain ? ( <> 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 6d88944a..45efd458 100644 --- a/src/app/[locale]/networks/[name]/blocks/[hash]/block-information.tsx +++ b/src/app/[locale]/networks/[name]/blocks/[hash]/block-information.tsx @@ -10,6 +10,7 @@ import { ChainWithParams } from '@/services/chain-service'; import { getAztecBlockHeight, getAztecFinalizationLabel, getAztecTimestampMs } from '@/utils/aztec'; import LogosBlockInformation from '@/app/networks/[name]/blocks/[hash]/logos-block-information'; import CosmosBlockInformation from '@/app/networks/[name]/blocks/[hash]/cosmos-block-information'; +import MidenBlockInformation from '@/app/networks/[name]/blocks/[hash]/miden-block-information'; interface OwnProps { chain: ChainWithParams | null; @@ -25,6 +26,10 @@ const BlockInformation: FC = async ({ chain, hash }) => { return ; } + if (chain?.name === 'miden-testnet') { + return ; + } + const t = await getTranslations('BlockInformationPage'); const isHeight = /^\d+$/.test(hash); 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 5eacd914..1014d375 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 @@ -7,6 +7,7 @@ import { ChainWithParams } from '@/services/chain-service'; import { aztecIndexer } from '@/services/aztec-indexer-api'; import cosmosIndexer from '@/services/cosmos-indexer-api'; import logosIndexer from '@/services/logos-indexer-api'; +import midenIndexer from '@/services/miden-indexer-api'; interface OwnProps { chain: ChainWithParams | null; @@ -17,6 +18,82 @@ const ExpandedBlockInformation: FC = async ({ chain, hash }) => { const t = await getTranslations('BlockInformationPage'); const isLogos = chain?.name === 'logos-testnet'; const isCosmoshub = chain?.name === 'cosmoshub'; + const isMiden = chain?.name === 'miden-testnet'; + + if (isMiden) { + let block; + try { + block = await midenIndexer.getBlock(hash, { revalidate: false }); + } catch (error) { + console.error('Error fetching Miden block for expanded view:', error); + notFound(); + } + if (!block) { + notFound(); + } + + const expandedData: Array<{ title: string; data: string | number; type: 'hash' | 'number' | 'text' }> = [ + { title: 'chain commitment', data: block.chain_commitment, type: 'hash' }, + { title: 'account root', data: block.account_root, type: 'hash' }, + { title: 'nullifier root', data: block.nullifier_root, type: 'hash' }, + { title: 'note root', data: block.note_root, type: 'hash' }, + { title: 'tx commitment', data: block.tx_commitment, type: 'hash' }, + { title: 'tx kernel commitment', data: block.tx_kernel_commitment, type: 'hash' }, + { title: 'validator key', data: block.validator_key, type: 'hash' }, + { title: 'native asset id', data: block.native_asset_id, type: 'hash' }, + { title: 'verification base fee', data: block.verification_base_fee, type: 'text' }, + { title: 'note count', data: block.note_count, type: 'number' }, + { title: 'nullifier count', data: block.nullifier_count, type: 'number' }, + { title: 'version', data: block.version, type: 'number' }, + ]; + + const formatData = (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')} +
+
+ {formatData(item.data, item.type)} +
+
+ ))} + {block.raw_block_bytes && ( +
+ + raw_block_bytes + +
+
+ {block.raw_block_bytes} + +
+
+
+ )} +
+ ); + } if (isCosmoshub) { if (!/^\d+$/.test(hash)) { 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 b1a71308..0c26f587 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 @@ -5,6 +5,7 @@ import CopyButton from '@/components/common/copy-button'; import { aztecIndexer } from '@/services/aztec-indexer-api'; 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'; interface OwnProps { @@ -15,6 +16,7 @@ interface OwnProps { const JsonBlockInformation: FC = async ({ chain, hash }) => { const isLogos = chain?.name === 'logos-testnet'; const isCosmoshub = chain?.name === 'cosmoshub'; + const isMiden = chain?.name === 'miden-testnet'; const isHeight = /^\d+$/.test(hash); let block; @@ -27,6 +29,8 @@ const JsonBlockInformation: FC = async ({ chain, hash }) => { block = response?.data; } else if (isLogos) { block = await logosIndexer.getBlock(hash, { revalidate: false }); + } else if (isMiden) { + block = await midenIndexer.getBlock(hash, { revalidate: false }); } else { block = isHeight ? await aztecIndexer.getBlockByHeight(parseInt(hash, 10), { revalidate: false }) diff --git a/src/app/[locale]/networks/[name]/blocks/[hash]/miden-block-information.tsx b/src/app/[locale]/networks/[name]/blocks/[hash]/miden-block-information.tsx new file mode 100644 index 00000000..ada94af0 --- /dev/null +++ b/src/app/[locale]/networks/[name]/blocks/[hash]/miden-block-information.tsx @@ -0,0 +1,142 @@ +import { getTranslations } from 'next-intl/server'; +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 { ChainWithParams } from '@/services/chain-service'; +import midenIndexer from '@/services/miden-indexer-api'; + +interface OwnProps { + chain: ChainWithParams | null; + hash: string; +} + +const HASH_FIELDS = new Set([ + 'block hash', + 'parent commitment', + 'chain commitment', + 'account root', + 'nullifier root', + 'note root', + 'tx commitment', + 'tx kernel commitment', + 'validator key', + 'native asset id', +]); + +const NUMBER_FIELDS = new Set(['block number', 'tx count', 'note count', 'nullifier count', 'version']); + +const MidenBlockInformation: FC = async ({ chain, hash }) => { + const t = await getTranslations('BlockInformationPage'); + + let block; + try { + block = await midenIndexer.getBlock(hash, { revalidate: false }); + } catch (error) { + console.error('Error fetching Miden block:', error); + notFound(); + } + + if (!block) { + notFound(); + } + + const timestamp = new Date(block.timestamp); + const formattedTimestamp = Number.isFinite(timestamp.getTime()) + ? timestamp.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC') + : '—'; + const blockNumber = Number(block.block_num); + + const blockData: Array<{ title: string; data: string | number }> = [ + { title: 'block hash', data: block.block_hash }, + { title: 'block number', data: blockNumber }, + { title: 'parent commitment', data: block.prev_block_commitment }, + { title: 'chain commitment', data: block.chain_commitment }, + { title: 'account root', data: block.account_root }, + { title: 'nullifier root', data: block.nullifier_root }, + { title: 'note root', data: block.note_root }, + { title: 'tx commitment', data: block.tx_commitment }, + { title: 'validator key', data: block.validator_key }, + { title: 'tx kernel commitment', data: block.tx_kernel_commitment }, + { title: 'native asset id', data: block.native_asset_id }, + { title: 'verification base fee', data: block.verification_base_fee }, + { title: 'timestamp', data: formattedTimestamp }, + { title: 'tx count', data: block.tx_count }, + { title: 'note count', data: block.note_count }, + { title: 'nullifier count', data: block.nullifier_count }, + { title: 'version', data: block.version }, + ]; + + const renderValue = (title: string, data: string | number) => { + if (HASH_FIELDS.has(title) && typeof data === 'string') { + return ( +
+ {data} + +
+ ); + } + + if (title === 'timestamp' && typeof data === 'string') { + return ( +
+ {data} + +
+ ); + } + + if (NUMBER_FIELDS.has(title) && typeof data === 'number') { + return
{data.toLocaleString('en-US')}
; + } + + return
{data}
; + }; + + return ( +
+
+
+
+ +
+ +
+
+
+
+ {t('finalized')} +
+
+ {t('block')} #{blockNumber.toLocaleString('en-US')} +
+
+
+ {block.block_hash} + +
+
+
+
+ + {t('show all blocks')} + +
+
+ {blockData.map((item) => ( +
+
+ {t(item.title as 'block hash')} +
+
+ {renderValue(item.title, item.data)} +
+
+ ))} +
+ ); +}; + +export default MidenBlockInformation; 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 4654eb61..345b6213 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 @@ -3,6 +3,7 @@ import { FC } from 'react'; import { txExample } from '@/app/networks/[name]/tx/txExample'; import ExpandedCosmosTxInformation from '@/app/networks/[name]/tx/[hash]/expand/expanded-cosmos-tx-information'; +import MidenExpandedTxInformation from '@/app/networks/[name]/tx/[hash]/expand/miden-expanded-tx-information'; import CopyButton from '@/components/common/copy-button'; import { isAztecChainName } from '@/server/tools/chains/aztec/utils/contracts/contracts-config'; import { AztecTxEffect } from '@/services/aztec-indexer-api/types'; @@ -22,6 +23,10 @@ const ExpandedTxInformation: FC = async ({ chain, hash }) => { return ; } + if (chain?.name === 'miden-testnet') { + return ; + } + if (chain && isAztecChainName(chain.name)) { const result = await TxService.getAztecTxByHash(hash); diff --git a/src/app/[locale]/networks/[name]/tx/[hash]/expand/miden-expanded-tx-information.tsx b/src/app/[locale]/networks/[name]/tx/[hash]/expand/miden-expanded-tx-information.tsx new file mode 100644 index 00000000..ca0306ff --- /dev/null +++ b/src/app/[locale]/networks/[name]/tx/[hash]/expand/miden-expanded-tx-information.tsx @@ -0,0 +1,108 @@ +import { getTranslations } from 'next-intl/server'; +import { FC } from 'react'; + +import CopyButton from '@/components/common/copy-button'; +import { ChainWithParams } from '@/services/chain-service'; +import TxService from '@/services/tx-service'; + +interface OwnProps { + chain: ChainWithParams | null; + hash: string; +} + +type ExpandedRowType = 'hash' | 'number' | 'text'; + +const MidenExpandedTxInformation: FC = async ({ chain, hash }) => { + const t = await getTranslations('TxInformationPage'); + + if (!chain) { + return null; + } + + const result = await TxService.getMidenTxByHash(hash); + + if (!result) { + return ( +
+
{t('tx not found')}
+
{t('tx not found hint')}
+
+ ); + } + + const tx = result.data; + + const expandedData: Array<{ title: string; data: string | number; type: ExpandedRowType }> = [ + { title: 'account id', data: tx.account_id, type: 'hash' }, + { title: 'account id bech32', data: tx.account_id_bech32, type: 'hash' }, + { title: 'init account state', data: tx.init_account_state, type: 'hash' }, + { title: 'final account state', data: tx.final_account_state, type: 'hash' }, + { title: 'input notes commitment', data: tx.input_notes_commitment, type: 'hash' }, + { title: 'output notes commitment', data: tx.output_notes_commitment, type: 'hash' }, + { title: 'expiration block num', data: tx.expiration_block_num ?? '—', type: 'number' }, + ]; + + const formatData = (data: string | number, type: ExpandedRowType) => { + if (type === 'hash') { + if (typeof data === 'string' && data === '—') { + return
{data}
; + } + return ( +
+ {data} + +
+ ); + } + if (type === 'number') { + return ( +
+ {typeof data === 'number' ? data.toLocaleString('en-US') : data} +
+ ); + } + return
{data}
; + }; + + const inputNullifiers = tx.input_nullifiers ?? []; + const outputNoteIds = tx.output_note_ids ?? []; + + const renderListSection = (title: 'input nullifiers' | 'output note ids', items: string[]) => ( +
+
+ {t(title)} ({items.length}) +
+
+ {items.length === 0 ? ( + {t('none')} + ) : ( + items.map((item, idx) => ( +
+ {item} + +
+ )) + )} +
+
+ ); + + return ( +
+ {expandedData.map((item) => ( +
+
+ {t(item.title as 'account id')} +
+
+ {formatData(item.data, item.type)} +
+
+ ))} + {renderListSection('input nullifiers', inputNullifiers)} + {renderListSection('output note ids', outputNoteIds)} +
+ ); +}; + +export default MidenExpandedTxInformation; 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 2f7f0d31..76fe3e70 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 @@ -2,6 +2,7 @@ import { getTranslations } from 'next-intl/server'; import { FC } from 'react'; import { txExample } from '@/app/networks/[name]/tx/txExample'; +import MidenJsonTxInformation from '@/app/networks/[name]/tx/[hash]/json/miden-json-tx-information'; import CopyButton from '@/components/common/copy-button'; import { isAztecChainName } from '@/server/tools/chains/aztec/utils/contracts/contracts-config'; import cosmosIndexer from '@/services/cosmos-indexer-api'; @@ -15,6 +16,10 @@ interface OwnProps { const JsonTxInformation: FC = async ({ chainName, hash }) => { const t = await getTranslations('TxInformationPage'); + if (chainName === 'miden-testnet') { + return ; + } + if (chainName === 'cosmoshub') { const raw = await cosmosIndexer.getTxRaw(hash, { cache: 'no-store' }).catch((error) => { console.error('Failed to fetch raw cosmos tx:', error); diff --git a/src/app/[locale]/networks/[name]/tx/[hash]/json/miden-json-tx-information.tsx b/src/app/[locale]/networks/[name]/tx/[hash]/json/miden-json-tx-information.tsx new file mode 100644 index 00000000..da1acc8c --- /dev/null +++ b/src/app/[locale]/networks/[name]/tx/[hash]/json/miden-json-tx-information.tsx @@ -0,0 +1,41 @@ +import { getTranslations } from 'next-intl/server'; +import { FC } from 'react'; + +import CopyButton from '@/components/common/copy-button'; +import TxService from '@/services/tx-service'; + +interface OwnProps { + hash: string; +} + +const MidenJsonTxInformation: FC = async ({ hash }) => { + const t = await getTranslations('TxInformationPage'); + + const result = await TxService.getMidenTxByHash(hash); + + if (!result) { + return ( +
+
{t('tx not found')}
+
{t('tx not found hint')}
+
+ ); + } + + const jsonString = JSON.stringify(result.data, null, 4); + + return ( +
+
+
+
{jsonString}
+
+
+ +
+
+
+ ); +}; + +export default MidenJsonTxInformation; diff --git a/src/app/[locale]/networks/[name]/tx/[hash]/miden-tx-information.tsx b/src/app/[locale]/networks/[name]/tx/[hash]/miden-tx-information.tsx new file mode 100644 index 00000000..60fd867c --- /dev/null +++ b/src/app/[locale]/networks/[name]/tx/[hash]/miden-tx-information.tsx @@ -0,0 +1,192 @@ +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 { ChainWithParams } from '@/services/chain-service'; +import TxService from '@/services/tx-service'; + +interface OwnProps { + chain: ChainWithParams; + hash: string; +} + +const HASH_FIELDS = new Set([ + 'account id', + 'account id bech32', + 'init account state', + 'final account state', + 'input notes commitment', + 'output notes commitment', +]); + +const NUMBER_FIELDS = new Set(['block height', 'expiration block num']); + +const EMPTY = '—'; + +const formatTimestamp = (iso: string): string => { + const d = new Date(iso); + if (!Number.isFinite(d.getTime())) { + return EMPTY; + } + return d.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC'); +}; + +const MidenTxInformation: FC = async ({ chain, hash }) => { + const t = await getTranslations('TxInformationPage'); + + const result = await TxService.getMidenTxByHash(hash); + + if (!result) { + return ( +
+
+
+
+ +
+ +
+
+
+ {hash} + +
+
+
+
+ + {t('show all transactions')} + +
+
+
+
{t('tx not found')}
+
{t('tx not found hint')}
+
+
+ ); + } + + const { data: tx } = result; + + const blockHeightRaw = Number(tx.block_num); + const blockHeight = Number.isFinite(blockHeightRaw) ? blockHeightRaw : null; + const inputNullifiersCount = tx.input_nullifiers?.length ?? 0; + const outputNoteIdsCount = tx.output_note_ids?.length ?? 0; + + const txData: Array<{ title: string; data: string | number | null }> = [ + { title: 'chain', data: chain.prettyName }, + { title: 'block height', data: blockHeight }, + { title: 'account id', data: tx.account_id }, + { title: 'account id bech32', data: tx.account_id_bech32 }, + { title: 'init account state', data: tx.init_account_state }, + { title: 'final account state', data: tx.final_account_state }, + { title: 'input notes commitment', data: tx.input_notes_commitment }, + { title: 'output notes commitment', data: tx.output_notes_commitment }, + { title: 'expiration block num', data: tx.expiration_block_num }, + { + title: 'input nullifiers', + data: inputNullifiersCount > 0 ? t('items count', { count: inputNullifiersCount }) : EMPTY, + }, + { + title: 'output note ids', + data: outputNoteIdsCount > 0 ? t('items count', { count: outputNoteIdsCount }) : EMPTY, + }, + { title: 'inserted at', data: formatTimestamp(tx.inserted_at) }, + ]; + + const renderValue = (title: string, data: string | number | null) => { + if (title === 'chain') { + return ( + + {data} + + ); + } + + if (title === 'block height') { + if (data !== null && typeof data === 'number') { + return ( + + {data.toLocaleString('en-US')} + + ); + } + return
{EMPTY}
; + } + + if (data === null) { + return
{EMPTY}
; + } + + if (HASH_FIELDS.has(title) && typeof data === 'string') { + return ( +
+ {data} + +
+ ); + } + + if (title === 'inserted at' && typeof data === 'string') { + return ( +
+ {data} + +
+ ); + } + + if (NUMBER_FIELDS.has(title) && typeof data === 'number') { + return
{data.toLocaleString('en-US')}
; + } + + return
{data}
; + }; + + return ( +
+
+
+
+ +
+ +
+
+
+
{t('accepted')}
+
+
+ {tx.tx_id} + +
+
+
+
+ + {t('show all transactions')} + +
+
+ {txData.map((item) => ( +
+
+ {t(item.title as 'chain')} +
+
+ {renderValue(item.title, item.data)} +
+
+ ))} +
+ ); +}; + +export default MidenTxInformation; 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 b712b8a1..60ebbba1 100644 --- a/src/app/[locale]/networks/[name]/tx/[hash]/tx-information.tsx +++ b/src/app/[locale]/networks/[name]/tx/[hash]/tx-information.tsx @@ -4,6 +4,7 @@ import { FC } from 'react'; import { txExample } from '@/app/networks/[name]/tx/txExample'; import CosmosTxInformation from '@/app/networks/[name]/tx/[hash]/cosmos-tx-information'; +import MidenTxInformation from '@/app/networks/[name]/tx/[hash]/miden-tx-information'; import CopyButton from '@/components/common/copy-button'; import RoundedButton from '@/components/common/rounded-button'; import Tooltip from '@/components/common/tooltip'; @@ -37,6 +38,10 @@ const TxInformation: FC = async ({ chain, hash }) => { return ; } + if (chain?.name === 'miden-testnet') { + return ; + } + if (chain && isAztecChainName(chain.name)) { const result = await TxService.getAztecTxByHash(hash); diff --git a/src/app/[locale]/networks/[name]/tx/total-txs-metrics.tsx b/src/app/[locale]/networks/[name]/tx/total-txs-metrics.tsx index d7fde195..5f8f3359 100644 --- a/src/app/[locale]/networks/[name]/tx/total-txs-metrics.tsx +++ b/src/app/[locale]/networks/[name]/tx/total-txs-metrics.tsx @@ -15,9 +15,10 @@ const TotalTxsMetrics: FC = async ({ chainName }) => { const t = await getTranslations('TotalTxsPage'); const isLogos = chainName.toLowerCase() === 'logos-testnet'; + const isMiden = chainName.toLowerCase() === 'miden-testnet'; const isCosmoshub = chainName.toLowerCase() === 'cosmoshub'; - if (isAztecChainName(chainName) || isLogos || isCosmoshub) { + if (isAztecChainName(chainName) || isLogos || isMiden || isCosmoshub) { const chain = await chainService.getByName(chainName); if (!chain) { @@ -26,9 +27,11 @@ const TotalTxsMetrics: FC = async ({ chainName }) => { const metrics = isLogos ? await TxService.getLogosTxMetrics(chain.id) - : isCosmoshub - ? await TxService.getCosmosTxMetrics(chain.id) - : await TxService.getAztecTxMetrics(chain.id); + : isMiden + ? await TxService.getMidenTxMetrics(chain.id) + : isCosmoshub + ? await TxService.getCosmosTxMetrics(chain.id) + : await TxService.getAztecTxMetrics(chain.id); const feeUnit = isCosmoshub ? (chain.params?.denom ?? 'ATOM') : 'AZTEC'; @@ -51,8 +54,9 @@ const TotalTxsMetrics: FC = async ({ chainName }) => { ? `${metrics.tps.toLocaleString('en-US', { maximumFractionDigits: 2 })} txs/s` : 'N/A', }, - // Logos testnet has all gas prices = 0; avg fee is meaningless. Hide the card. - ...(isLogos + // Logos has all gas prices = 0; Miden v1 indexer has no per-tx fee. + // For both, avgFee is meaningless — hide the card. + ...(isLogos || isMiden ? [] : [ { diff --git a/src/app/[locale]/networks/[name]/tx/txs-table/network-txs-items.tsx b/src/app/[locale]/networks/[name]/tx/txs-table/network-txs-items.tsx index 74cf73dd..476df046 100644 --- a/src/app/[locale]/networks/[name]/tx/txs-table/network-txs-items.tsx +++ b/src/app/[locale]/networks/[name]/tx/txs-table/network-txs-items.tsx @@ -39,11 +39,19 @@ interface CosmoshubItem { opType?: string; } +interface MidenItem { + hash: string; + status: TxStatus; + blockHeight?: number; + accountId?: string; +} + interface OwnProps { name: string; - item: CosmosItem | AztecItem | LogosItem | CosmoshubItem; + item: CosmosItem | AztecItem | LogosItem | CosmoshubItem | MidenItem; isAztec?: boolean; isLogos?: boolean; + isMiden?: boolean; isCosmoshub?: boolean; coinDecimals?: number; timestampSlot?: ReactNode; @@ -60,7 +68,55 @@ const getStatusIcon = (status: TxStatus) => { } }; -const NetworkTxsItem: FC = ({ name, item, isAztec, isLogos, isCosmoshub, coinDecimals, timestampSlot }) => { +const NetworkTxsItem: FC = ({ + name, + item, + isAztec, + isLogos, + isMiden, + isCosmoshub, + coinDecimals, + timestampSlot, +}) => { + if (isMiden) { + const tx = item as MidenItem; + const link = `/networks/${name}/tx/${encodeURIComponent(tx.hash)}`; + const statusIcon = getStatusIcon(tx.status); + const accountLabel = tx.accountId ? cutHash({ value: tx.accountId, cutLength: 12 }) : '—'; + + return ( + + + +
+ {tx.status} +
+
+ {cutHash({ value: tx.hash, cutLength: 12 })} +
+ +
+ +
{accountLabel}
+
+ + {tx.blockHeight != null ? ( + +
+ {tx.blockHeight.toLocaleString('en-US')} +
+ + ) : ( +
+ )} +
+ +
{timestampSlot}
+
+
+ ); + } + if (isCosmoshub) { const tx = item as CosmoshubItem; const link = `/networks/${name}/tx/${encodeURIComponent(tx.hash)}`; diff --git a/src/app/[locale]/networks/[name]/tx/txs-table/network-txs-list.tsx b/src/app/[locale]/networks/[name]/tx/txs-table/network-txs-list.tsx index a9334119..99a32e4e 100644 --- a/src/app/[locale]/networks/[name]/tx/txs-table/network-txs-list.tsx +++ b/src/app/[locale]/networks/[name]/tx/txs-table/network-txs-list.tsx @@ -21,9 +21,10 @@ const NetworkTxsList: FC = async ({ name, chainName, perPage, currentP const t = await getTranslations('TotalTxsPage'); const isAztec = isAztecChainName(chainName); const isLogos = chainName.toLowerCase() === 'logos-testnet'; + const isMiden = chainName.toLowerCase() === 'miden-testnet'; const isCosmoshub = chainName.toLowerCase() === 'cosmoshub'; - if (isAztec || isLogos || isCosmoshub) { + if (isAztec || isLogos || isMiden || isCosmoshub) { const { txs, totalPages, error } = await TxService.getTxsByChainName(chainName, currentPage, perPage, showPending); if (txs.length === 0) { @@ -54,6 +55,7 @@ const NetworkTxsList: FC = async ({ name, chainName, perPage, currentP item={item} isAztec={isAztec} isLogos={isLogos} + isMiden={isMiden} isCosmoshub={isCosmoshub} coinDecimals={coinDecimals} timestampSlot={ diff --git a/src/app/services/blocks-service.ts b/src/app/services/blocks-service.ts index 32aaa244..118804bb 100644 --- a/src/app/services/blocks-service.ts +++ b/src/app/services/blocks-service.ts @@ -1,6 +1,7 @@ import aztecIndexer from '@/services/aztec-indexer-api'; import cosmosIndexer from '@/services/cosmos-indexer-api'; import logosIndexer from '@/services/logos-indexer-api'; +import midenIndexer from '@/services/miden-indexer-api'; import { formatTimestamp } from '@/utils/format-timestamp'; import { AZTEC_INDEXER_MAX_BLOCK_RANGE, @@ -136,6 +137,42 @@ const getCosmosBlocks = async (currentPage: number, perPage: number): Promise => { + try { + const pageSize = Math.max(1, Math.min(perPage, MIDEN_MAX_PER_PAGE)); + const stats = await midenIndexer.getStats({ cache: 'no-store' }); + const totalBlocks = stats.total_blocks; + + if (!totalBlocks || totalBlocks <= 0) { + return { blocks: [], totalPages: 1 }; + } + + const totalPages = Math.max(1, Math.ceil(totalBlocks / pageSize)); + const safePage = Math.max(1, Math.min(currentPage, totalPages)); + const offset = (safePage - 1) * pageSize; + + const { data } = await midenIndexer.getBlocks( + { limit: pageSize, offset, sort: 'block_num', order: 'desc' }, + { cache: 'no-store' }, + ); + + const sorted = [...data].sort((a, b) => Number(b.block_num) - Number(a.block_num)); + const blocks: BlockItem[] = sorted.map((b) => ({ + hash: b.block_hash, + height: Number(b.block_num), + timestamp: formatTimestamp(new Date(b.timestamp)), + finalizationStatus: 3, + })); + + return { blocks, totalPages }; + } catch (error) { + console.error('Failed to fetch Miden blocks:', error); + return { blocks: [], totalPages: 1 }; + } +}; + const getBlocksByChainName = async ( chainName: string, currentPage: number = 1, @@ -155,6 +192,10 @@ const getBlocksByChainName = async ( return getCosmosBlocks(currentPage, perPage); } + if (normalizedChainName === 'miden-testnet') { + return getMidenBlocks(currentPage, perPage); + } + return { blocks: [], totalPages: 1 }; }; @@ -163,6 +204,7 @@ const BlocksService = { getAztecBlocks, getLogosBlocks, getCosmosBlocks, + getMidenBlocks, }; export default BlocksService; diff --git a/src/app/services/miden-indexer-api/client.ts b/src/app/services/miden-indexer-api/client.ts new file mode 100644 index 00000000..74e4b70e --- /dev/null +++ b/src/app/services/miden-indexer-api/client.ts @@ -0,0 +1,229 @@ +import logger from '@/logger'; + +import { MidenIndexerRequestOptions } from './types'; + + +const { logDebug, logError } = logger('miden-indexer-client'); + +type QueryParamValue = string | number | boolean | undefined | null; + +export type QueryParams = Record; + +const DEFAULT_TIMEOUT = 30000; +const DEFAULT_BASE_URL = ''; + +export class HttpError extends Error { + constructor(public status: number, message: string) { + super(message); + this.name = 'HttpError'; + } +} + +export const buildQueryString = (params: QueryParams): string => { + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + + if (Array.isArray(value)) { + value.forEach((item) => { + if (item !== undefined && item !== null) { + searchParams.append(key, String(item)); + } + }); + } else { + searchParams.append(key, String(value)); + } + }); + + return searchParams.toString(); +}; + +export const buildUrl = (path: string, params?: QueryParams): string => { + const base = process.env.MIDEN_INDEXER_BASE_URL || DEFAULT_BASE_URL; + const baseUrl = `${base}${path}`; + + if (!params || Object.keys(params).length === 0) { + return baseUrl; + } + + const queryString = buildQueryString(params); + return queryString ? `${baseUrl}?${queryString}` : baseUrl; +}; + +const buildHeaders = (customHeaders?: HeadersInit): HeadersInit => { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + + const apiToken = process.env.MIDEN_INDEXER_API_TOKEN; + if (apiToken) { + headers.Authorization = `Bearer ${apiToken}`; + } + + if (customHeaders) { + if (customHeaders instanceof Headers) { + customHeaders.forEach((value, key) => { + headers[key] = value; + }); + } else if (Array.isArray(customHeaders)) { + customHeaders.forEach(([key, value]) => { + headers[key] = value; + }); + } else { + Object.assign(headers, customHeaders); + } + } + + return headers; +}; + +const buildNextOptions = (options?: MidenIndexerRequestOptions): RequestInit => { + const nextOptions: RequestInit = {}; + const hasRevalidate = options?.revalidate !== undefined; + const hasTags = options?.tags && options.tags.length > 0; + + if (hasRevalidate || hasTags) { + (nextOptions as any).next = { + ...(hasRevalidate && { revalidate: options.revalidate }), + ...(hasTags && { tags: options.tags }), + }; + } else if (options?.cache !== undefined) { + nextOptions.cache = options.cache; + } else { + nextOptions.cache = 'no-store'; + } + + return nextOptions; +}; + +const fetchWithTimeout = async (url: string, options: RequestInit, timeoutMs: number): Promise => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + ...options, + signal: options.signal || controller.signal, + }); + + return response; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Request timeout after ${timeoutMs}ms: ${url}`); + } + + const errorMsg = error instanceof Error ? error.message : String(error); + throw new Error(`Network error: ${errorMsg} (URL: ${url})`); + } finally { + clearTimeout(timeoutId); + } +}; + +const safeJsonParse = async (response: Response, url: string): Promise => { + const contentType = response.headers.get('content-type'); + const responseText = await response.text().catch(() => '[unable to read response]'); + + try { + const data = JSON.parse(responseText); + return data as T; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + + if (contentType && !contentType.includes('application/json')) { + throw new Error( + `Failed to parse JSON response: Expected JSON body, got content-type: ${contentType} (URL: ${url}, Status: ${response.status}, Body: ${responseText.slice(0, 200)})`, + ); + } + + throw new Error(`Failed to parse JSON response: ${errorMsg} (URL: ${url}, Status: ${response.status})`); + } +}; + +const request = async ( + url: string, + method: string, + options?: MidenIndexerRequestOptions, + body?: unknown, +): Promise => { + const timeout = options?.timeout || DEFAULT_TIMEOUT; + + logDebug(`${method} ${url}`); + + try { + const response = await fetchWithTimeout( + url, + { + method, + headers: buildHeaders(options?.headers), + body: body ? JSON.stringify(body) : undefined, + signal: options?.signal, + ...buildNextOptions(options), + }, + timeout, + ); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + + let errorBody = text; + try { + const errorJson = JSON.parse(text); + errorBody = errorJson.message || errorJson.error || text; + } catch {} + + logError(`${method} ${url} failed: HTTP ${response.status} ${response.statusText}`, { body: errorBody }); + + throw new HttpError( + response.status, + `HTTP ${response.status} ${response.statusText}: ${errorBody} (URL: ${url})`, + ); + } + + const data = await safeJsonParse(response, url); + + logDebug(`${method} ${url} success`); + return data; + } catch (error) { + if (error instanceof Error) { + throw error; + } + + logError(`${method} ${url} unexpected error:`, error); + throw new Error(`Unexpected error: ${String(error)} (URL: ${url})`); + } +}; + +export const get = async ( + path: string, + params?: QueryParams | null, + options?: MidenIndexerRequestOptions, +): Promise => { + const url = buildUrl(path, params || undefined); + return request(url, 'GET', options); +}; + +export const post = async ( + path: string, + body?: B, + options?: MidenIndexerRequestOptions, +): Promise => { + const url = buildUrl(path); + return request(url, 'POST', options, body); +}; + +export const healthCheck = async (): Promise => { + try { + await get('/health', null, { timeout: 5000, cache: 'no-store' }); + return true; + } catch { + return false; + } +}; + +export const getBaseUrl = (): string => { + return process.env.MIDEN_INDEXER_BASE_URL || DEFAULT_BASE_URL; +}; diff --git a/src/app/services/miden-indexer-api/endpoints.ts b/src/app/services/miden-indexer-api/endpoints.ts new file mode 100644 index 00000000..801b850f --- /dev/null +++ b/src/app/services/miden-indexer-api/endpoints.ts @@ -0,0 +1,91 @@ +import * as client from './client'; +import { HttpError } from './client'; +import { + MidenBlock, + MidenBlocksResponse, + MidenIndexerRequestOptions, + MidenStats, + MidenTransactionsResponse, + MidenTxDetail, +} from './types'; + +export const getStats = (options?: MidenIndexerRequestOptions): Promise => + client.get('/api/v1/stats', null, options); + +export interface GetBlocksParams { + limit?: number; + offset?: number; + order?: 'asc' | 'desc'; + sort?: 'block_num'; +} + +export const getBlocks = ( + params: GetBlocksParams = {}, + options?: MidenIndexerRequestOptions, +): Promise => + client.get( + '/api/v1/blocks', + { + limit: params.limit, + offset: params.offset, + order: params.order, + sort: params.sort, + }, + options, + ); + +export const getBlock = async ( + blockNum: string, + options?: MidenIndexerRequestOptions, +): Promise => { + try { + return await client.get(`/api/v1/blocks/${encodeURIComponent(blockNum)}`, null, options); + } catch (e) { + if (e instanceof HttpError && e.status === 404) { + return null; + } + throw e; + } +}; + +export interface GetTransactionsParams { + limit?: number; + offset?: number; + sort?: 'block_num' | 'inserted_at'; + order?: 'asc' | 'desc'; + account_id?: string; +} + +export const getTransactions = ( + params: GetTransactionsParams = {}, + options?: MidenIndexerRequestOptions, +): Promise => + client.get( + '/api/v1/transactions', + { + limit: params.limit, + offset: params.offset, + sort: params.sort, + order: params.order, + account_id: params.account_id, + }, + options, + ); + +export const getTransaction = async ( + txId: string, + options?: MidenIndexerRequestOptions, +): Promise => { + try { + return await client.get( + `/api/v1/transactions/${encodeURIComponent(txId)}`, + null, + options, + ); + } catch (e) { + if (e instanceof HttpError && e.status === 404) { + return null; + } + throw e; + } +}; diff --git a/src/app/services/miden-indexer-api/index.ts b/src/app/services/miden-indexer-api/index.ts new file mode 100644 index 00000000..1cdc3799 --- /dev/null +++ b/src/app/services/miden-indexer-api/index.ts @@ -0,0 +1,17 @@ +import { getBaseUrl, healthCheck } from './client'; +import { getBlock, getBlocks, getStats, getTransaction, getTransactions } from './endpoints'; + +export * from './types'; +export { HttpError } from './client'; + +export const midenIndexer = { + getStats, + getBlocks, + getBlock, + getTransactions, + getTransaction, + healthCheck, + getBaseUrl, +}; + +export default midenIndexer; diff --git a/src/app/services/miden-indexer-api/types.ts b/src/app/services/miden-indexer-api/types.ts new file mode 100644 index 00000000..1ae59d82 --- /dev/null +++ b/src/app/services/miden-indexer-api/types.ts @@ -0,0 +1,81 @@ +export interface MidenIndexerRequestOptions { + cache?: RequestCache; + revalidate?: number | false; + tags?: string[]; + timeout?: number; + signal?: AbortSignal; + headers?: HeadersInit; +} + +export interface MidenStats { + last_block: string; + total_blocks: number; + total_transactions: number; + total_notes: number; + total_nullifiers: number; + total_accounts: number; + latest_block_timestamp: string; + // `tps` was added to /stats in Stage 3; optional for backward-compat with cached responses. + tps?: number; +} + +export interface MidenBlock { + block_num: string; + block_hash: string; + prev_block_commitment: string; + chain_commitment: string; + account_root: string; + nullifier_root: string; + note_root: string; + tx_commitment: string; + validator_key: string; + tx_kernel_commitment: string; + native_asset_id: string; + verification_base_fee: string; + timestamp: string; + tx_count: number; + note_count: number; + nullifier_count: number; + version: number; + chain_length: number | null; + inserted_at: string; + raw_block_bytes?: string; +} + +export interface MidenBlocksResponse { + data: MidenBlock[]; + total: number; + limit: number; + offset: number; +} + +/** + * Miden transaction shape — verified live 2026-05-19 against + * GET /api/v1/transactions and GET /api/v1/transactions/{tx_id}. + * Detail endpoint returns the same fields as the list item (no extra metadata). + * `input_nullifiers` / `output_note_ids` arrive as `null` for the empty case + * (not `[]`); when populated they are hex string arrays. + */ +export interface MidenTransaction { + tx_id: string; + block_num: number; + account_id: string; + init_account_state: string; + final_account_state: string; + input_notes_commitment: string; + output_notes_commitment: string; + expiration_block_num: number | null; + input_nullifiers: string[] | null; + output_note_ids: string[] | null; + inserted_at: string; + account_id_bech32: string; +} + +export type MidenTxDetail = MidenTransaction; + +export interface MidenTransactionsResponse { + data: MidenTransaction[]; + total: number; + limit: number; + offset: number; +} diff --git a/src/app/services/tx-service.ts b/src/app/services/tx-service.ts index 9c3b5094..71ab854d 100644 --- a/src/app/services/tx-service.ts +++ b/src/app/services/tx-service.ts @@ -4,6 +4,8 @@ import cosmosIndexer from '@/services/cosmos-indexer-api'; import { CosmosTxDetail } from '@/services/cosmos-indexer-api'; import logosIndexer from '@/services/logos-indexer-api'; import { LogosTxDetail } from '@/services/logos-indexer-api'; +import midenIndexer from '@/services/miden-indexer-api'; +import { MidenTxDetail } from '@/services/miden-indexer-api'; import { getAztecTimestampMs } from '@/utils/aztec'; export type TxStatus = 'pending' | 'confirmed' | 'dropped'; @@ -20,6 +22,8 @@ export interface TxItem { opCount?: number; slot?: number; blockId?: string; + // Miden-specific: bech32-encoded account that submitted the tx. + accountId?: string; } export interface TxsResponse { @@ -256,6 +260,82 @@ const getLogosTxMetrics = async (chainId: number): Promise => { }; }; +/** + * Miden: list transactions sorted by block_num desc. Total page count comes from + * the response's `total` field (falling back to `stats.total_transactions` if absent). + */ +const getMidenTxs = async (currentPage: number, perPage: number): Promise => { + try { + const offset = (currentPage - 1) * perPage; + const [{ data, total }, stats] = await Promise.all([ + midenIndexer.getTransactions( + { limit: perPage, offset, sort: 'block_num', order: 'desc' }, + TX_LIST_CACHE, + ), + midenIndexer.getStats(TX_LIST_CACHE), + ]); + + const isValidCount = (n: unknown): n is number => + typeof n === 'number' && Number.isFinite(n) && n >= 0; + const totalCount = isValidCount(total) + ? total + : isValidCount(stats?.total_transactions) + ? stats.total_transactions + : 0; + // If the indexer's stats cache is stale (total === 0 while data has items), + // fall back to a data-length signal so the user isn't locked on page 1. + const totalFromData = data.length === perPage ? currentPage + 1 : currentPage; + const totalPages = Math.max(1, Math.ceil(totalCount / perPage), totalFromData); + + const txs: TxItem[] = data.map((tx) => ({ + hash: tx.tx_id, + status: 'confirmed' as const, + blockHeight: tx.block_num, + timestamp: new Date(tx.inserted_at).getTime(), + accountId: tx.account_id_bech32, + })); + + return { txs, totalPages }; + } catch (error) { + console.error('Failed to fetch Miden transactions:', error); + return { txs: [], totalPages: 1, error: true }; + } +}; + +const getMidenTxByHash = async ( + txId: string, +): Promise<{ status: TxStatus; data: MidenTxDetail } | null> => { + let tx; + try { + tx = await midenIndexer.getTransaction(txId, { cache: 'no-store' }); + } catch (e) { + console.error('Failed to fetch Miden transaction by hash:', e); + return null; + } + if (!tx) { + return null; + } + + return { status: 'confirmed', data: tx }; +}; + +const getMidenTxMetrics = async (chainId: number): Promise => { + const cached = await db.chainTxMetrics.findUnique({ where: { chainId } }); + + if (!cached) { + return { totalTxs: null, txsLast24h: null, txs30d: null, tps: null, avgFee: null }; + } + + return { + totalTxs: cached.totalTxs !== null ? Number(cached.totalTxs) : null, + txsLast24h: cached.txsLast24h, + txs30d: cached.txs30d, + tps: cached.tps, + // Miden v1 indexer does not surface per-tx fees; avgFee stays null. + avgFee: cached.avgFee, + }; +}; + /** * Cosmoshub: keyset cursor is forward-only (`before_height` + `before_index`). * For arbitrary `currentPage` we walk forward in chunks of `perPage` until reaching the offset, @@ -353,6 +433,10 @@ const getTxsByChainName = async ( return getLogosTxs(currentPage, perPage); } + if (normalizedChainName === 'miden-testnet') { + return getMidenTxs(currentPage, perPage); + } + if (normalizedChainName === 'cosmoshub') { return getCosmosTxs(currentPage, perPage); } @@ -384,6 +468,9 @@ const TxService = { getLogosTxs, getLogosTxByHash, getLogosTxMetrics, + getMidenTxs, + getMidenTxByHash, + getMidenTxMetrics, getCosmosTxs, getCosmosTxByHash, getCosmosTxMetrics, From 438eda92528598e2c56c23710c7bd569120598ce Mon Sep 17 00:00:00 2001 From: m1amgn Date: Sat, 6 Jun 2026 22:51:51 +0700 Subject: [PATCH 02/10] #621: added validators votes services --- .gitignore | 1 + AGENTS.md | 53 +++++++++++++++--- CLAUDE.md | 39 ++++++++++++- server/jobs/update-nodes-votes.ts | 22 ++++---- server/tools/chains/chain-indexer.ts | 1 + .../tools/chains/cosmoshub/get-nodes-votes.ts | 55 +++++++++++++++++++ server/tools/chains/cosmoshub/methods.ts | 3 +- .../validators-votes-item.tsx | 33 +++++------ .../validators-votes-list.tsx | 10 ++-- .../validators-votes.tsx | 1 - .../validatorsVotesExample.ts | 10 ++++ src/app/services/aztec-vote-event-service.ts | 1 + .../services/cosmos-indexer-api/endpoints.ts | 21 +++++++ src/app/services/cosmos-indexer-api/index.ts | 2 + src/app/services/cosmos-indexer-api/types.ts | 16 ++++++ src/app/services/vote-service.ts | 9 ++- 16 files changed, 228 insertions(+), 49 deletions(-) create mode 100644 server/tools/chains/cosmoshub/get-nodes-votes.ts diff --git a/.gitignore b/.gitignore index 5216314c..86d53ae4 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ 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 diff --git a/AGENTS.md b/AGENTS.md index 494366b5..2aaefad4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,19 +11,19 @@ If an `AGENTS.md` file exists in the target directory: ## Code Search & Documentation -### Semantic Search (DeepContext) +### Semantic Search (clawmem) -When you need to find code by meaning — use DeepContext MCP tools: +When you need to find code by meaning — use clawmem MCP tools: -1. First run `index_codebase` if not indexed yet -2. Use `search_codebase` for semantic search queries +- Use `find_similar` for semantic search queries +- Use `intent_search` for concept-based discovery Examples: -- "authentication logic" → search_codebase -- "where is JWT validated" → search_codebase -- "find all API endpoints" → search_codebase +- "authentication logic" → find_similar +- "where is JWT validated" → find_similar +- "find all API endpoints" → intent_search -Use DeepContext when you don't know exact names and need to discover relevant code. +Use clawmem when you don't know exact names and need to discover relevant code. ### Code Relationships & Impact (GitNexus) @@ -54,7 +54,7 @@ Use Context7 for: | Need | Tool | |------|------------------------------------| -| Semantic search by meaning | DeepContext (`search_codebase`) | +| Semantic search by meaning | clawmem (`find_similar`) | | Code relationships / execution flows | GitNexus (`query`, `context`) | | Impact before changes | GitNexus (`impact`, `detect_changes`) | | Library docs / examples | Context7 | @@ -72,6 +72,41 @@ Use Context7 for: --- +## Cross-Agent Reviews + +This repository uses the global `agent-review` CLI for Codex <-> Claude review handoffs. +The CLI is installed outside the repo; review state is local in `.agent-reviews/`. + +Use from the repository root: + +```bash +agent-review handoff \ + --assignee codex \ + --title "Short review title" \ + --summary "What changed and why" \ + --files path/a.ts,path/b.ts \ + --basis docs/design.md,commit-or-review-id \ + --review-focus "correctness, regressions, missing tests" \ + --tests "yarn test ..." \ + --risks "known risks or none" + +agent-review next --assignee codex +agent-review respond --finding F1 --status fixed --summary "What changed" +agent-review verify --status passed --summary "Verification result" +agent-review close +agent-review validate +``` + +Protocol: +- `.agent-reviews/open/` contains active reviews; `.agent-reviews/closed/` contains closed reviews. +- Claude creates structured handoffs with `agent-review handoff --assignee codex`. +- Codex adds findings under `## Findings` and only closes after satisfactory verification. +- Assignees must respond to every stable finding ID under `# Assignee Response`. +- Do not delete or rewrite reviewer findings; append responses and verification instead. +- Keep the tool global. Do not add repo-local review scripts unless the protocol itself changes. + +--- + ## Project Overview ValidatorInfo is a Web3 blockchain explorer providing real-time metrics for validators, mining pools, tokens, and networks across multiple blockchain ecosystems (Cosmos, Polkadot, Ethereum, Namada, Aztec, etc.). The application provides interactive dashboards, validator comparisons, staking information, and governance proposals tracking. diff --git a/CLAUDE.md b/CLAUDE.md index b22cfdb5..4e10f183 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ Use Context7 for: | Need | Tool | |------|------------------------------------| -| Semantic search by meaning | DeepContext (`search_codebase`) | +| Semantic search by meaning | clawmem (`find_similar`) | | Code relationships / execution flows | GitNexus (`query`, `context`) | | Impact before changes | GitNexus (`impact`, `detect_changes`) | | Library docs / examples | Context7 | @@ -71,6 +71,41 @@ Use Context7 for: --- +## Cross-Agent Reviews + +This repository uses the global `agent-review` CLI for Codex <-> Claude review handoffs. +The CLI is installed outside the repo; review state is local in `.agent-reviews/`. + +Use from the repository root: + +```bash +agent-review handoff \ + --assignee codex \ + --title "Short review title" \ + --summary "What changed and why" \ + --files path/a.ts,path/b.ts \ + --basis docs/design.md,commit-or-review-id \ + --review-focus "correctness, regressions, missing tests" \ + --tests "yarn test ..." \ + --risks "known risks or none" + +agent-review next --assignee codex +agent-review respond --finding F1 --status fixed --summary "What changed" +agent-review verify --status passed --summary "Verification result" +agent-review close +agent-review validate +``` + +Protocol: +- `.agent-reviews/open/` contains active reviews; `.agent-reviews/closed/` contains closed reviews. +- Claude creates structured handoffs with `agent-review handoff --assignee codex`. +- Codex adds findings under `## Findings` and only closes after satisfactory verification. +- Assignees must respond to every stable finding ID under `# Assignee Response`. +- Do not delete or rewrite reviewer findings; append responses and verification instead. +- Keep the tool global. Do not add repo-local review scripts unless the protocol itself changes. + +--- + ## Project Overview ValidatorInfo is a Web3 blockchain explorer providing real-time metrics for validators, mining pools, tokens, and networks across multiple blockchain ecosystems (Cosmos, Polkadot, Ethereum, Namada, Aztec, etc.). The application provides interactive dashboards, validator comparisons, staking information, and governance proposals tracking. @@ -319,7 +354,7 @@ Before starting work, determine what you're doing and follow the right path: - **Adding a new indexer job?** → Read `server/jobs/AGENTS.md`. Follow existing job structure (worker thread + cron schedule). - **Debugging indexer or chain data?** → Use the `validatorinfo-testing` skill. - **Build fails?** → See "When Things Break" table below. -- **Not sure where something lives?** → Use DeepContext `search_codebase`, not grep. +- **Not sure where something lives?** → Use clawmem `find_similar`, not grep. --- diff --git a/server/jobs/update-nodes-votes.ts b/server/jobs/update-nodes-votes.ts index 8dfeeec5..602daa8a 100644 --- a/server/jobs/update-nodes-votes.ts +++ b/server/jobs/update-nodes-votes.ts @@ -40,23 +40,25 @@ const updateNodesVotes = async (chainNames: string[]) => { select: { id: true }, }); if (!proposal) { - logError(`${chainParams.chainId}: proposal ${vote.proposalId} not found`); + // Expected ordering condition (proposal not yet indexed), not an error — avoid spam. + logInfo(`${chainParams.chainId}: proposal ${vote.proposalId} not found, skipping`); continue; } - const exists = await db.nodeVote.findFirst({ - where: { nodeId: node.id, proposalId: proposal.id }, - select: { id: true }, - }); - if (exists) continue; - - await db.nodeVote.create({ - data: { + // Upsert (not insert-if-absent): a validator can change its vote during the voting + // period, and the indexer returns the final vote, so refresh existing rows too. + await db.nodeVote.upsert({ + where: { nodeId_proposalId: { nodeId: node.id, proposalId: proposal.id } }, + create: { nodeId: node.id, proposalId: proposal.id, chainId: dbChain.id, vote: unifyVotes(vote.vote), - txHash: null, + txHash: vote.txHash ?? null, + }, + update: { + vote: unifyVotes(vote.vote), + txHash: vote.txHash ?? null, }, }); } diff --git a/server/tools/chains/chain-indexer.ts b/server/tools/chains/chain-indexer.ts index 93766151..516f83d0 100755 --- a/server/tools/chains/chain-indexer.ts +++ b/server/tools/chains/chain-indexer.ts @@ -86,6 +86,7 @@ export interface NodeVote { address: string; proposalId: string; vote: string; + txHash?: string | null; } export interface AztecGovernanceConfigAdditional { diff --git a/server/tools/chains/cosmoshub/get-nodes-votes.ts b/server/tools/chains/cosmoshub/get-nodes-votes.ts new file mode 100644 index 00000000..0e3e7d2d --- /dev/null +++ b/server/tools/chains/cosmoshub/get-nodes-votes.ts @@ -0,0 +1,55 @@ +import cosmosIndexer from '@/services/cosmos-indexer-api'; +import logger from '@/logger'; +import { GetNodesVotes, NodeVote } from '@/server/tools/chains/chain-indexer'; +import { fromValoperToAccount } from '@/utils/cosmos-address-converter'; + +const { logError } = logger('cosmos-nodes-votes'); + +const PAGE_LIMIT = 100; +const MAX_PAGES = 1000; + +// Only cosmoshub has a populated cosmos-indexer-api deployment (a single +// COSMOS_INDEXER_BASE_URL = cosmoshub-4). This method lives in the shared cosmoshub methods +// reused by other cosmos chains, so guard on chain.name to avoid querying the wrong indexer. +const getNodesVotes: GetNodesVotes = async (chain, operatorAddress) => { + if (chain.name !== 'cosmoshub') return []; + + // gov.votes is keyed by the voter ACCOUNT address; the job passes the validator operator + // (valoper) address, so convert it. Same bech32 payload, different prefix. + const voter = fromValoperToAccount(operatorAddress, chain.bech32Prefix); + if (!voter) return []; + + try { + const votes: NodeVote[] = []; + let before: string | undefined; + let pages = 0; + + // Drain the keyset cursor so the validator's full vote history is not truncated. + for (;;) { + const { data, cursor, has_more } = await cosmosIndexer.getGovVotes({ + voter, + limit: PAGE_LIMIT, + before_proposal_id: before, + }); + + for (const v of data) { + votes.push({ address: voter, proposalId: v.proposal_id, vote: v.option, txHash: v.tx_hash }); + } + + if (!has_more || !cursor) break; + + const next = cursor.next_before_proposal_id; + // Defensive bound against a misbehaving indexer: the keyset cursor strictly decreases, + // so stop if it fails to advance or the page count explodes. + if (next === before || ++pages >= MAX_PAGES) break; + before = next; + } + + return votes; + } catch (e) { + logError(`Can't fetch gov votes for ${voter}`, e); + return []; + } +}; + +export default getNodesVotes; diff --git a/server/tools/chains/cosmoshub/methods.ts b/server/tools/chains/cosmoshub/methods.ts index fe942e1c..61ab4576 100755 --- a/server/tools/chains/cosmoshub/methods.ts +++ b/server/tools/chains/cosmoshub/methods.ts @@ -13,6 +13,7 @@ import getNodeCommissions from '@/server/tools/chains/cosmoshub/get-node-commiss import getNodeParams from '@/server/tools/chains/cosmoshub/get-node-params'; import getNodeRewards from '@/server/tools/chains/cosmoshub/get-node-rewards'; import getNodes from '@/server/tools/chains/cosmoshub/get-nodes'; +import getNodesVotes from '@/server/tools/chains/cosmoshub/get-nodes-votes'; import getProposalParams from '@/server/tools/chains/cosmoshub/get-proposal-params'; import getProposals from '@/server/tools/chains/cosmoshub/get-proposals'; import getSlashingParams from '@/server/tools/chains/cosmoshub/get-slashing-params'; @@ -37,7 +38,7 @@ const chainMethods: ChainMethods = { getProposals, getSlashingParams, getMissedBlocks, - getNodesVotes: () => Promise.resolve([]), + getNodesVotes, getCommTax, getWalletsAmount, getProposalParams, diff --git a/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes-item.tsx b/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes-item.tsx index 2788166f..946e6c1a 100644 --- a/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes-item.tsx +++ b/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes-item.tsx @@ -19,38 +19,31 @@ const ValidatorsVotesItem: FC = ({ item, chainName }) => { return ( - + - +
- - {item.validator.moniker === 'Validator Name' - ? cutHash({ value: 'D3DD629470947D318DFCC1D66F8FA8534B0A14164761852D782BB33BEA495660' }) - : '-'} - - {item.validator.moniker === 'Validator Name' && - - } + {item.txHash ? ( + <> + + {cutHash({ value: item.txHash })} + + + + ) : ( + '-' + )}
- +
{item.vote ?? 'DID NOT VOTE'}
- - - {item.validator.moniker === 'Validator Name' - ?
Dec 20th, 2024 01:46:06 (14 days)
- : '-'} - -
); }; diff --git a/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes-list.tsx b/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes-list.tsx index 36bbb8d0..eb2e28ae 100644 --- a/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes-list.tsx +++ b/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes-list.tsx @@ -33,7 +33,7 @@ const ValidatorsVotesList: FC = async ({ sort, perPage, currentPage = return ( - +

This is a payload in signaling phase.

= async ({ sort, perPage, currentPage = return ( - +

Token votes are aggregated by the GSE contract.

Individual validator votes cannot be tracked during the proposal voting stage. @@ -69,7 +69,7 @@ const ValidatorsVotesList: FC = async ({ sort, perPage, currentPage = ); - } else if (chainName === 'namada' || chainName === 'namada-testnet') { + } else if (chainName === 'namada' || chainName === 'namada-testnet' || chainName === 'cosmoshub') { const result = await voteService.getProposalValidatorsVotes( chainName, proposalId, @@ -94,7 +94,7 @@ const ValidatorsVotesList: FC = async ({ sort, perPage, currentPage = ))} - + @@ -102,7 +102,7 @@ const ValidatorsVotesList: FC = async ({ sort, perPage, currentPage = ) : ( - + No votes yet diff --git a/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes.tsx b/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes.tsx index 1292b0f1..36b0e9ed 100644 --- a/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes.tsx +++ b/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes.tsx @@ -59,7 +59,6 @@ const ValidatorsVotes: FC = async ({ page, perPage, sort, currentPage, - => client.get('/api/v1/txs/stats', null, options); + +export interface GetGovVotesParams { + voter: string; + limit?: number; + before_proposal_id?: string; +} + +export const getGovVotes = ( + params: GetGovVotesParams, + options?: CosmosIndexerRequestOptions, +): Promise => + client.get( + '/api/v1/gov/votes', + { + voter: params.voter, + limit: params.limit, + before_proposal_id: params.before_proposal_id, + }, + options, + ); diff --git a/src/app/services/cosmos-indexer-api/index.ts b/src/app/services/cosmos-indexer-api/index.ts index 68cc1521..dd2ef0bf 100644 --- a/src/app/services/cosmos-indexer-api/index.ts +++ b/src/app/services/cosmos-indexer-api/index.ts @@ -3,6 +3,7 @@ import { getBlockByHeight, getBlocksList, getBlocksStats, + getGovVotes, getTxByHash, getTxRaw, getTxsList, @@ -19,6 +20,7 @@ export const cosmosIndexer = { getTxByHash, getTxRaw, getTxsStats, + getGovVotes, healthCheck, getBaseUrl, }; diff --git a/src/app/services/cosmos-indexer-api/types.ts b/src/app/services/cosmos-indexer-api/types.ts index 2ed6af3d..108bbb1a 100644 --- a/src/app/services/cosmos-indexer-api/types.ts +++ b/src/app/services/cosmos-indexer-api/types.ts @@ -130,3 +130,19 @@ export interface CosmosTxRawResponse { export interface CosmosTxsStatsResponse { data: CosmosTxsStats; } + +export type CosmosGovVoteOption = 'YES' | 'NO' | 'ABSTAIN' | 'VETO' | 'UNSPECIFIED'; + +export interface CosmosGovVote { + proposal_id: string; + option: CosmosGovVoteOption; + weight: string | null; + height: string; + tx_hash: string; +} + +export interface CosmosGovVotesCursor { + next_before_proposal_id: string; +} + +export type CosmosGovVotesResponse = CosmosListResponse; diff --git a/src/app/services/vote-service.ts b/src/app/services/vote-service.ts index d0a1c7b9..93d871fa 100644 --- a/src/app/services/vote-service.ts +++ b/src/app/services/vote-service.ts @@ -24,6 +24,7 @@ export interface ProposalValidatorsVotes { iconUrl: string | null; }; vote: VoteOption | null; + txHash: string | null; } export interface ChainNodeVote { @@ -107,12 +108,17 @@ export const getProposalValidatorsVotes = async ( }, select: { vote: true, + txHash: true, node: { select: { validatorId: true } }, }, }); const voteMap = new Map(); - rowVotes.forEach((r) => voteMap.set(r.node.validatorId!, r.vote)); + const txHashMap = new Map(); + rowVotes.forEach((r) => { + voteMap.set(r.node.validatorId!, r.vote); + txHashMap.set(r.node.validatorId!, r.txHash); + }); const nodeValidators = await db.node.findMany({ where: { @@ -130,6 +136,7 @@ export const getProposalValidatorsVotes = async ( let fullList: ProposalValidatorsVotes[] = allValidators.map((v) => ({ validator: { id: v.id, moniker: v.moniker, iconUrl: v.url }, vote: voteMap.get(v.id) ?? null, + txHash: txHashMap.get(v.id) ?? null, })); if (searchValidatorId) { From d26b08bc49dc857e175e6aa6cc63e2f64fd90af1 Mon Sep 17 00:00:00 2001 From: m1amgn Date: Tue, 9 Jun 2026 18:26:40 +0700 Subject: [PATCH 03/10] fix: reschedule jobs --- server/indexer.ts | 98 +++++++++++++++++++++++++++++++---------------- 1 file changed, 64 insertions(+), 34 deletions(-) diff --git a/server/indexer.ts b/server/indexer.ts index 4e5b77f3..dde9e533 100755 --- a/server/indexer.ts +++ b/server/indexer.ts @@ -18,12 +18,28 @@ const timers = { in45MinEveryHour: '45 * * * *', }; +// everyDay tasks are spread across the day (previously all fired at 00:00). Each task runs in its +// own worker thread that builds its own Prisma clients — ~100 connections per worker (50 main + +// 50 events pool, both created in src/db.ts under NODE_ENV=production); firing the daily tasks +// together — plus the everyHour / every6hours crons that also land on 00:00 — far exceeded +// Postgres max_connections (500, see docker-compose) and timed out the pool +// (PrismaClientInitializationError on the first query). +// Minutes stay off :00/:15/:30/:45 to also dodge the hourly / 30-min / 45-min / 15-min crons. +const dailyAt = (hour: number, minute: number) => `${minute} ${hour} * * *`; + const { logInfo, logError } = logger('indexer'); const runServer = async () => { logInfo('Starting indexer server'); const tasksRunning: Record = {}; + // Initial (boot) runs are staggered, not fired all at once: each task spawns a worker thread + // that opens ~100 connections (50 main + 50 events pool), so a synchronous burst exhausts + // Postgres max_connections (500). tasks[] starts immediately; specialTasks waits 10 min for + // validators to load. + const INITIAL_RUN_DELAY_MS = 10 * 60 * 1000; + const INITIAL_STAGGER_MS = 30 * 1000; + async function spawnTask(taskName: string, chains: string[]) { if (tasksRunning[taskName]) { logInfo(`${taskName} already running, skipping.`); @@ -58,23 +74,27 @@ const runServer = async () => { } const tasks = [ + // update-nodes-votes runs in specialTasks (spread via dailyAt); kept out here to avoid a + // duplicate schedule plus the immediate boot run. { name: 'sync-aztec-events', schedule: timers.every10mins }, { name: 'prices', schedule: timers.every5mins }, { name: 'validators', schedule: timers.everyHour }, - { name: 'update-reward-address', schedule: timers.everyDay }, - { name: 'chain-proposals', schedule: timers.everyDay }, { name: 'chain-tvls', schedule: timers.everyHour }, { name: 'chain-aprs', schedule: timers.in15MinEveryHour }, - { name: 'chain-staking-params', schedule: timers.everyDay }, - { name: 'chain-slashing-params', schedule: timers.everyDay }, - { name: 'chain-node-params', schedule: timers.everyDay }, - { name: 'community-tax', schedule: timers.everyDay }, - { name: 'wallets-amount', schedule: timers.everyDay }, - { name: 'proposal-params', schedule: timers.everyDay }, - { name: 'update-staking-page-json', schedule: timers.everyDay }, - { name: 'community-pool', schedule: timers.everyDay }, { name: 'active-set-min-amount', schedule: timers.in45MinEveryHour }, - { name: 'inflation-rate', schedule: timers.everyDay }, + // everyDay jobs spread across the day. chain-proposals + proposal-params run BEFORE + // update-nodes-votes (02:23 in specialTasks) so votes match already-indexed proposals. + { name: 'chain-proposals', schedule: dailyAt(1, 7) }, + { name: 'proposal-params', schedule: dailyAt(1, 43) }, + { name: 'update-reward-address', schedule: dailyAt(5, 13) }, + { name: 'chain-staking-params', schedule: dailyAt(7, 7) }, + { name: 'chain-slashing-params', schedule: dailyAt(10, 23) }, + { name: 'chain-node-params', schedule: dailyAt(12, 43) }, + { name: 'community-tax', schedule: dailyAt(14, 53) }, + { name: 'wallets-amount', schedule: dailyAt(16, 43) }, + { name: 'update-staking-page-json', schedule: dailyAt(17, 23) }, + { name: 'community-pool', schedule: dailyAt(19, 43) }, + { name: 'inflation-rate', schedule: dailyAt(20, 23) }, ]; tasks.forEach((task) => { @@ -94,32 +114,40 @@ const runServer = async () => { job.start(); }); - tasks.forEach((task) => { - spawnTask(task.name, chains).catch((e) => logError(`Initial run error for task ${task.name}:`, e)); + // Staggered boot run (see INITIAL_STAGGER_MS): do not spawn all tasks[] workers at once. + tasks.forEach((task, i) => { + setTimeout( + () => spawnTask(task.name, chains).catch((e) => logError(`Initial run error for task ${task.name}:`, e)), + i * INITIAL_STAGGER_MS, + ); }); const specialTasks: Array<{ name: string; schedule: string }> = [ - { name: 'validatorInfo', schedule: timers.everyDay }, + { name: 'validatorInfo', schedule: dailyAt(0, 7) }, { name: 'update-aztec-sequencer-stake', schedule: timers.everyHour }, { name: 'update-aztec-coinbase-address', schedule: timers.everyHour }, { name: 'slashing-infos', schedule: timers.every10mins }, - { name: 'update-nodes-votes', schedule: timers.everyDay }, + // Depends on chain-proposals having populated the proposals table (else votes are dropped as + // "proposal not found"). Scheduled safe (chain-proposals 01:07 < 02:23). At boot it relies on + // chain-proposals (boot ~3min) finishing before this fires (~12min); a mismatch self-heals at + // the next 02:23 run. Keep chain-proposals earlier than this if the stagger/order changes. + { name: 'update-nodes-votes', schedule: dailyAt(2, 23) }, { name: 'update-nodes-rewards', schedule: timers.everyHour }, { name: 'update-nodes-commissions', schedule: timers.everyHour }, - { name: 'circulating-tokens-onchain', schedule: timers.everyDay }, - { name: 'circulating-tokens-public', schedule: timers.everyDay }, + { name: 'circulating-tokens-onchain', schedule: dailyAt(3, 37) }, + { name: 'circulating-tokens-public', schedule: dailyAt(4, 53) }, { name: 'coingecko-data', schedule: timers.in30MinEveryHour }, - { name: 'price-history', schedule: timers.everyDay }, + { name: 'price-history', schedule: dailyAt(6, 13) }, { name: 'update-fdv', schedule: timers.everyHour }, - { name: 'update-delegators-amount', schedule: timers.everyDay }, + { name: 'update-delegators-amount', schedule: dailyAt(8, 7) }, { name: 'update-average-delegation', schedule: timers.in45MinEveryHour }, - { name: 'github-repositories', schedule: timers.everyDay }, - { name: 'unbonding-tokens', schedule: timers.everyDay }, - { name: 'match-chain-nodes', schedule: timers.everyDay }, + { name: 'github-repositories', schedule: dailyAt(9, 43) }, + { name: 'unbonding-tokens', schedule: dailyAt(11, 23) }, + { name: 'match-chain-nodes', schedule: dailyAt(13, 37) }, { name: 'check-nodes-health', schedule: timers.everyHour }, { name: 'update-chain-rewards', schedule: timers.everyHour }, - { name: 'update-twitter-followers-amount', schedule: timers.everyDay }, - { name: 'update-community-members', schedule: timers.everyDay }, + { name: 'update-twitter-followers-amount', schedule: dailyAt(15, 53) }, + { name: 'update-community-members', schedule: dailyAt(18, 13) }, { name: 'update-validators-aztec-logos', schedule: timers.everyHour }, { name: 'sync-aztec-committee', schedule: timers.every10mins }, { name: 'update-aztec-l1-contracts', schedule: timers.every6hours }, @@ -129,7 +157,7 @@ const runServer = async () => { { name: 'update-aztec-validators-history', schedule: timers.every6hours }, { name: 'update-aztec-node-distribution', schedule: timers.every6hours }, { name: 'update-aztec-total-earned-rewards', schedule: timers.every6hours }, - { name: 'update-tx-metrics', schedule: timers.everyDay }, + { name: 'update-tx-metrics', schedule: dailyAt(21, 7) }, { name: 'update-proposal-texts', schedule: timers.every10mins }, { name: 'update-validator-ranks', schedule: timers.every6hours }, ]; @@ -151,15 +179,17 @@ const runServer = async () => { job.start(); }); - // Initial run for specialTasks after 10 minutes for wait validators updated - setTimeout( - () => { - specialTasks.forEach(({ name }) => { - spawnTask(name, chains).catch((e) => logError(`Initial run error for task ${name}:`, e)); - }); - }, - 10 * 60 * 1000, - ); + // Initial run for specialTasks, 10 minutes after boot (wait for validators to be updated). + // Staggered, NOT fired all at once (see INITIAL_STAGGER_MS): each task runs in its own worker + // thread that opens ~100 connections (50 main + 50 events pool); spawning all specialTasks + // together exhausts Postgres max_connections (500). Palliative — spreads start times, not a + // hard concurrency cap. + specialTasks.forEach(({ name }, i) => { + setTimeout( + () => spawnTask(name, chains).catch((e) => logError(`Initial run error for task ${name}:`, e)), + INITIAL_RUN_DELAY_MS + i * INITIAL_STAGGER_MS, + ); + }); }; runServer(); From 000ddca3b49f8bc45df4bde488b97b6b19122d1a Mon Sep 17 00:00:00 2001 From: m1amgn Date: Thu, 11 Jun 2026 17:55:39 +0700 Subject: [PATCH 04/10] #634: added self-staked Aztec providers --- .../migration.sql | 2 + prisma/schema.prisma | 1 + server/tools/chains/aztec/AGENTS.md | 74 ++++++---- .../aztec/find-or-create-aztec-validator.ts | 36 +++-- server/tools/chains/aztec/get-nodes.ts | 107 +++++++++++--- .../chains/aztec/sync-aztec-providers.ts | 1 + .../chains/aztec/utils/classify-sequencer.ts | 69 +++++++++ .../aztec/utils/fetch-provider-metadata.ts | 139 +++++++++++++----- src/app/services/node-service.ts | 11 ++ src/app/services/validator-service.ts | 24 ++- 10 files changed, 365 insertions(+), 99 deletions(-) create mode 100644 prisma/migrations/20260611100705_add_validator_provider_rates/migration.sql create mode 100644 server/tools/chains/aztec/utils/classify-sequencer.ts diff --git a/prisma/migrations/20260611100705_add_validator_provider_rates/migration.sql b/prisma/migrations/20260611100705_add_validator_provider_rates/migration.sql new file mode 100644 index 00000000..ccb9ff8c --- /dev/null +++ b/prisma/migrations/20260611100705_add_validator_provider_rates/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "validators" ADD COLUMN "provider_rates" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b8dc20e2..e0261337 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -340,6 +340,7 @@ model Validator { wrongKey Boolean @default(false) @map("wrong_key") chainId Int? @map("chain_id") providerAddresses Json? @map("provider_addresses") + providerRates Json? @map("provider_rates") chain Chain? @relation("chain_validators", fields: [chainId], references: [id]) nodes Node[] @relation("validator_node") diff --git a/server/tools/chains/aztec/AGENTS.md b/server/tools/chains/aztec/AGENTS.md index 490a02fd..667bb3f7 100644 --- a/server/tools/chains/aztec/AGENTS.md +++ b/server/tools/chains/aztec/AGENTS.md @@ -172,6 +172,7 @@ server/tools/chains/aztec/ │ │ └── aztec-testnent/ # Testnet ABIs (note: directory name has typo) │ │ └── ... (same structure) │ │ +│ ├── classify-sequencer.ts # Pure classifier: delegated / provider self-stake / anonymous │ ├── get-providers.ts # Fetch all providers from StakingRegistry │ ├── get-provider-attesters.ts # Map attesters to providers (reads from DB only) │ ├── get-active-sequencers.ts # Get active sequencers from ValidatorQueued - Withdrawals @@ -329,43 +330,50 @@ export const syncXxxEvents = async ( }; ``` -### 4. Validator Identification (Self-Staked vs Delegated) +### 4. Validator Identification (Delegated vs Provider Self-Stake vs Anonymous) -Aztec has two types of sequencers: +Aztec has three categories of sequencers: -- **Delegated sequencers**: Stake through a provider (infrastructure operator) -- **Self-staked sequencers**: Stake directly without a provider +- **Delegated sequencers**: Stake through a provider (infrastructure operator) — + identified by on-chain `AttestersAddedToProvider` events +- **Provider self-stake sequencers** (mainnet only): Stake directly, but claimed by a + provider via the `providerSelfStake` field of the external providers API + (`https://d10cun7h2qqnvc.cloudfront.net/api/providers`) +- **Anonymous self-staked sequencers**: Stake directly, no provider association **How it works:** 1. `ValidatorQueued` event = sequencer registered (from Rollup contract) 2. `WithdrawFinalized` event = sequencer exited (from Rollup contract) 3. `AttestersAddedToProvider` event = attester delegated to provider (from StakingRegistry) +4. `providerSelfStake` API field = off-chain claim of a self-staked attester by a provider -**Identification logic in `get-nodes.ts`:** +**Classification (pure helper `utils/classify-sequencer.ts`, used by `get-nodes.ts`):** -```typescript -// 1. Get ALL active sequencers from ValidatorQueued - WithdrawFinalized -const activeSequencers = await getActiveSequencers(dbChain.id); - -// 2. Get attester->provider mapping from AttestersAddedToProvider events -const attesterToProvider = await getProviderAttesters(l1RpcUrls, chainName); - -// 3. For each active sequencer: -for (const attesterAddress of activeSequencers.keys()) { - const providerId = attesterToProvider.get(attesterAddress); - - if (providerId) { - // Has provider = DELEGATED - delegatedCount++; - node.moniker = provider.name; - } else { - // No provider = SELF-STAKED - selfStakedCount++; - node.moniker = `Sequencer ${shortAddress}`; - } -} -``` +- **Events win**: an attester present in the event mapping is always DELEGATED, even if + also claimed in `providerSelfStake` (logged as a conflict warning). +- A `providerSelfStake` claim is honored only when ALL hold (mainnet only): + - the API provider address matches an on-chain `providerAdmin` (from `getProviders`), + - provider metadata exists with a non-empty name. +- Duplicate attesters claimed by multiple providers are quarantined in + `buildProviderSelfStakeMap` (anonymous + warning). +- Everything else → anonymous (`moniker = Sequencer 0x...`, details + `Self-staked sequencer`). + +**Node shape for provider self-stake** (`createProviderSelfStakedNode`): +`identity = providerAdmin` (drives `findOrCreateAztecValidator` linking in +`server/jobs/get-nodes.ts`), moniker/website = provider brand, +`details = "Self-staked sequencer of "`, `account/reward = withdrawer`, +commission 0 (provider take rate does not apply to self-stake). + +**Persistence**: `upsertNode` (node-service) relinks existing nodes via +`buildNodeValidatorLinkUpdate` — connect scoped to `chain.name === 'aztec'` only; +undefined validatorId never unlinks (API outage safe). + +**Fixture assertions**: `scripts/test-aztec-selfstake.ts` +(`./node_modules/.bin/tsx scripts/test-aztec-selfstake.ts`). + +Design doc: `docs/plans/2026-06-11-aztec-selfstake-provider-linking-design.md`. **Data flow:** @@ -381,10 +389,16 @@ for (const attesterAddress of activeSequencers.keys()) { │ │ │ │ │ ▼ ▼ │ │ ┌──────────────────────────────────────────────────────┐ │ -│ │ Merge: active sequencers + provider mapping │ │ +│ │ classifyAztecSequencer (utils/classify-sequencer) │ │ +│ │ inputs: event mapping, selfStakeMap (mainnet API), │ │ +│ │ on-chain providerAdmins, provider metadata │ │ │ │ │ │ -│ │ IF attester IN providerMapping → DELEGATED │ │ -│ │ IF attester NOT IN providerMapping → SELF-STAKED │ │ +│ │ IF attester IN event mapping → DELEGATED │ │ +│ │ (+ warn if also claimed in providerSelfStake) │ │ +│ │ ELSE IF valid providerSelfStake claim │ │ +│ │ (on-chain admin + non-empty name, mainnet only) │ │ +│ │ → PROVIDER SELF-STAKE │ │ +│ │ ELSE → ANONYMOUS SELF-STAKED │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘ diff --git a/server/tools/chains/aztec/find-or-create-aztec-validator.ts b/server/tools/chains/aztec/find-or-create-aztec-validator.ts index f4f6cbbc..8ffdfad5 100644 --- a/server/tools/chains/aztec/find-or-create-aztec-validator.ts +++ b/server/tools/chains/aztec/find-or-create-aztec-validator.ts @@ -6,6 +6,13 @@ import { normalizeName } from '@/server/utils/normalize-node-name'; const { logInfo } = logger('find-or-create-aztec-validator'); +// Merge a chain-keyed JSON field (providerAddresses / providerRates) preserving the +// other chains' keys. Exported for the fixture script. +export const mergeChainKeyedJson = (current: unknown, chainName: string, value: T): Record => ({ + ...((current as Record | null | undefined) || {}), + [chainName]: value, +}); + interface AztecValidatorParams { providerAdmin: string; moniker: string; @@ -13,16 +20,18 @@ interface AztecValidatorParams { securityContact: string; details: string; chainName: string; + // Provider base take rate as a fraction (e.g. 0.0475), stored in validator.providerRates + providerRate?: number; } export async function findOrCreateAztecValidator( params: AztecValidatorParams, ): Promise<{ id: number; moniker: string; identity: string; providerAddresses: any }> { - const { providerAdmin, moniker, website, securityContact, details, chainName } = params; + const { providerAdmin, moniker, website, securityContact, details, chainName, providerRate } = params; const checksummedAdmin = getAddress(providerAdmin); const allValidators = await db.validator.findMany({ - select: { id: true, moniker: true, identity: true, providerAddresses: true }, + select: { id: true, moniker: true, identity: true, providerAddresses: true, providerRates: true }, }); let validator: any = null; @@ -62,26 +71,33 @@ export async function findOrCreateAztecValidator( providerAddresses: { [chainName]: checksummedAdmin, }, + ...(providerRate !== undefined ? { providerRates: { [chainName]: providerRate } } : {}), }, }); logInfo(`Created global validator "${moniker}" with placeholder identity for ${chainName}`); } else { const currentProviderAddresses = (validator.providerAddresses as Record) || {}; + const currentProviderRates = (validator.providerRates as Record) || {}; const needsProviderAddressUpdate = currentProviderAddresses[chainName] !== checksummedAdmin; + const needsProviderRateUpdate = providerRate !== undefined && currentProviderRates[chainName] !== providerRate; - if (needsProviderAddressUpdate) { - const updatedProviderAddresses = { - ...currentProviderAddresses, - [chainName]: checksummedAdmin, - }; - + if (needsProviderAddressUpdate || needsProviderRateUpdate) { await db.validator.update({ where: { id: validator.id }, data: { - providerAddresses: updatedProviderAddresses, + ...(needsProviderAddressUpdate + ? { providerAddresses: mergeChainKeyedJson(currentProviderAddresses, chainName, checksummedAdmin) } + : {}), + ...(needsProviderRateUpdate + ? { providerRates: mergeChainKeyedJson(currentProviderRates, chainName, providerRate) } + : {}), }, }); - logInfo(`Linked existing validator "${validator.moniker}" to ${chainName} (${checksummedAdmin})`); + if (needsProviderAddressUpdate) { + logInfo(`Linked existing validator "${validator.moniker}" to ${chainName} (${checksummedAdmin})`); + } else { + logInfo(`Updated provider rate of validator "${validator.moniker}" for ${chainName}: ${providerRate}`); + } } } diff --git a/server/tools/chains/aztec/get-nodes.ts b/server/tools/chains/aztec/get-nodes.ts index 42530eb7..845ecb35 100644 --- a/server/tools/chains/aztec/get-nodes.ts +++ b/server/tools/chains/aztec/get-nodes.ts @@ -2,8 +2,14 @@ import { getAddress } from 'viem'; import db from '@/db'; import logger from '@/logger'; +import { classifyAztecSequencer } from '@/server/tools/chains/aztec/utils/classify-sequencer'; import { getL1 } from '@/server/tools/chains/aztec/utils/contracts/contracts-config'; -import { fetchProviderMetadata } from '@/server/tools/chains/aztec/utils/fetch-provider-metadata'; +import { + ProviderMetadata, + buildProviderMetadata, + buildProviderSelfStakeMap, + fetchProvidersApiResponse, +} from '@/server/tools/chains/aztec/utils/fetch-provider-metadata'; import { getActiveSequencers } from '@/server/tools/chains/aztec/utils/get-active-sequencers'; import { getProviderAttesters } from '@/server/tools/chains/aztec/utils/get-provider-attesters'; import { getProviders } from '@/server/tools/chains/aztec/utils/get-providers'; @@ -82,6 +88,43 @@ const createSelfStakedNode = (attester: string, withdrawer: string): NodeResult unbonding_time: new Date(0).toISOString(), }); +const createProviderSelfStakedNode = ( + attester: string, + withdrawer: string, + providerAdmin: string, + metadata: ProviderMetadata, +): NodeResult => ({ + operator_address: getAddress(attester), + account_address: getAddress(withdrawer), + reward_address: getAddress(withdrawer), + delegator_shares: '0', + tokens: '0', + consensus_pubkey: { + '@type': 'aztec/AttesterAddress', + key: getAddress(attester), + }, + jailed: false, + status: 'BOND_STATUS_BONDED', + commission: { + commission_rates: { + rate: '0', + max_rate: '1', + max_change_rate: '0.01', + }, + update_time: new Date().toISOString(), + }, + description: { + identity: getAddress(providerAdmin), + moniker: metadata.name, + website: metadata.website || '', + security_contact: '', + details: `Self-staked sequencer of ${metadata.name}`, + }, + min_self_delegation: '0', + unbonding_height: '0', + unbonding_time: new Date(0).toISOString(), +}); + const getAztecNodes: GetNodesFunction = async (chain) => { const chainName = chain.name as 'aztec' | 'aztec-testnet'; @@ -113,41 +156,65 @@ const getAztecNodes: GetNodesFunction = async (chain) => { let providers: Map; let attesterToProvider: Map; - let providerMetadata: Map; + let providerMetadata: Map; + let selfStakeMap: Map; try { - [providers, attesterToProvider, providerMetadata] = await Promise.all([ + const [providersResult, attesterToProviderResult, providersApiResponse] = await Promise.all([ getProviders(l1RpcUrls, chainName), getProviderAttesters(l1RpcUrls, chainName), - fetchProviderMetadata(), + fetchProvidersApiResponse(), ]); + + providers = providersResult; + attesterToProvider = attesterToProviderResult; + providerMetadata = buildProviderMetadata(providersApiResponse); + // The providerSelfStake field exists only in the mainnet providers API. + selfStakeMap = chainName === 'aztec' ? buildProviderSelfStakeMap(providersApiResponse) : new Map(); } catch (e: any) { throw new Error(`${chainName}: Failed to fetch provider data: ${e.message}`); } - logInfo(`Found ${providers.size} providers and ${attesterToProvider.size} attester-to-provider mappings`); + const onChainProviderAdmins = new Set(); + for (const provider of Array.from(providers.values())) { + try { + onChainProviderAdmins.add(getAddress(provider.providerAdmin)); + } catch { + logWarn(`Provider ${provider.providerIdentifier} has invalid providerAdmin: ${provider.providerAdmin}`); + } + } + + logInfo( + `Found ${providers.size} providers, ${attesterToProvider.size} attester-to-provider mappings, ` + + `${selfStakeMap.size} provider self-stake claims`, + ); const nodes: NodeResult[] = []; let delegatedCount = 0; let selfStakedCount = 0; + let providerSelfStakeCount = 0; let errorCount = 0; for (const [attester, withdrawer] of Array.from(activeSequencers.entries())) { try { - const providerId = attesterToProvider.get(attester); + const classification = classifyAztecSequencer({ + chainName, + attester, + eventProviderId: attesterToProvider.get(attester), + selfStakeMap, + onChainProviderAdmins, + providerMetadata, + providers, + }); - if (providerId) { - const provider = providers.get(providerId); - - if (!provider) { - logWarn(`Provider ${providerId} not found for attester ${attester}, treating as self-staked`); - nodes.push(createSelfStakedNode(attester, withdrawer)); - selfStakedCount++; - continue; - } + for (const warn of classification.warns) { + logWarn(warn); + } + if (classification.kind === 'delegated') { + const provider = classification.provider; const metadata = chainName === 'aztec' ? providerMetadata.get(getAddress(provider.providerAdmin)) : undefined; - const moniker = metadata?.name || `Provider ${providerId}`; + const moniker = metadata?.name || `Provider ${provider.providerIdentifier}`; const website = metadata?.website || ''; const details = metadata?.description || ''; @@ -184,6 +251,11 @@ const getAztecNodes: GetNodesFunction = async (chain) => { }); delegatedCount++; + } else if (classification.kind === 'provider-self-stake') { + nodes.push( + createProviderSelfStakedNode(attester, withdrawer, classification.providerAdmin, classification.metadata), + ); + providerSelfStakeCount++; } else { nodes.push(createSelfStakedNode(attester, withdrawer)); selfStakedCount++; @@ -195,7 +267,8 @@ const getAztecNodes: GetNodesFunction = async (chain) => { } logInfo( - `Created ${nodes.length} nodes: ${delegatedCount} delegated, ${selfStakedCount} self-staked` + + `Created ${nodes.length} nodes: ${delegatedCount} delegated, ${selfStakedCount} self-staked, ` + + `${providerSelfStakeCount} provider self-stake` + (errorCount > 0 ? `, ${errorCount} errors` : '') ); diff --git a/server/tools/chains/aztec/sync-aztec-providers.ts b/server/tools/chains/aztec/sync-aztec-providers.ts index 9f549db1..6932ea4e 100644 --- a/server/tools/chains/aztec/sync-aztec-providers.ts +++ b/server/tools/chains/aztec/sync-aztec-providers.ts @@ -53,6 +53,7 @@ export const syncAztecProviders = async (chainName: 'aztec' | 'aztec-testnet'): securityContact: '', details: description, chainName, + providerRate: provider.providerTakeRate / 10000, }); if (result.moniker === moniker) { diff --git a/server/tools/chains/aztec/utils/classify-sequencer.ts b/server/tools/chains/aztec/utils/classify-sequencer.ts new file mode 100644 index 00000000..a711e7a5 --- /dev/null +++ b/server/tools/chains/aztec/utils/classify-sequencer.ts @@ -0,0 +1,69 @@ +import { ProviderMetadata } from '@/server/tools/chains/aztec/utils/fetch-provider-metadata'; +import { ProviderConfig } from '@/server/tools/chains/aztec/utils/get-providers'; + +export interface ClassifySequencerInput { + chainName: 'aztec' | 'aztec-testnet'; + attester: string; + eventProviderId?: bigint; + selfStakeMap: Map; + onChainProviderAdmins: Set; + providerMetadata: Map; + providers: Map; +} + +export type SequencerClassification = + | { kind: 'delegated'; provider: ProviderConfig; warns: string[] } + | { kind: 'provider-self-stake'; providerAdmin: string; metadata: ProviderMetadata; warns: string[] } + | { kind: 'anonymous'; warns: string[] }; + +export const classifyAztecSequencer = (input: ClassifySequencerInput): SequencerClassification => { + const { chainName, attester, eventProviderId, selfStakeMap, onChainProviderAdmins, providerMetadata, providers } = + input; + + const warns: string[] = []; + const selfStakeProviderAddress = selfStakeMap.get(attester); + + // Events win: the on-chain AttestersAddedToProvider mapping is the source of truth + // for delegation; the API self-stake list is off-chain metadata. + if (eventProviderId !== undefined) { + if (selfStakeProviderAddress) { + warns.push( + `Attester ${attester} is delegated to provider ${eventProviderId} by on-chain events ` + + `but also claimed in providerSelfStake by ${selfStakeProviderAddress} - events win`, + ); + } + + const provider = providers.get(eventProviderId); + if (provider) { + return { kind: 'delegated', provider, warns }; + } + + warns.push(`Provider ${eventProviderId} not found for attester ${attester}, treating as self-staked`); + return { kind: 'anonymous', warns }; + } + + if (chainName !== 'aztec' || !selfStakeProviderAddress) { + return { kind: 'anonymous', warns }; + } + + // The API provider address must resolve to a current on-chain providerAdmin - + // a stale/compromised API entry must not reach findOrCreateAztecValidator. + if (!onChainProviderAdmins.has(selfStakeProviderAddress)) { + warns.push( + `Attester ${attester} claimed in providerSelfStake by ${selfStakeProviderAddress}, ` + + 'which is not an on-chain providerAdmin - keeping it anonymous', + ); + return { kind: 'anonymous', warns }; + } + + const metadata = providerMetadata.get(selfStakeProviderAddress); + if (!metadata?.name) { + warns.push( + `Attester ${attester} claimed in providerSelfStake by ${selfStakeProviderAddress}, ` + + 'but provider metadata is missing or has an empty name - keeping it anonymous', + ); + return { kind: 'anonymous', warns }; + } + + return { kind: 'provider-self-stake', providerAdmin: selfStakeProviderAddress, metadata, warns }; +}; diff --git a/server/tools/chains/aztec/utils/fetch-provider-metadata.ts b/server/tools/chains/aztec/utils/fetch-provider-metadata.ts index 22faedcb..8687ac9f 100644 --- a/server/tools/chains/aztec/utils/fetch-provider-metadata.ts +++ b/server/tools/chains/aztec/utils/fetch-provider-metadata.ts @@ -21,6 +21,7 @@ export interface AztecProviderApiResponse { logo_url: string; email: string; discord: string; + providerSelfStake?: string[] | null; }>; } @@ -31,9 +32,28 @@ export interface ProviderMetadata { logoUrl?: string; } -export const fetchProviderMetadata = async (includeLogo = false): Promise> => { +const isValidProvidersResponse = (data: any): data is AztecProviderApiResponse => + Boolean(data) && Array.isArray(data.providers); + +// The API is third-party: truthy non-string fields (e.g. name: {}) must not reach +// node persistence, where they would crash .startsWith()/Prisma and drop the node. +const asString = (value: unknown): string => (typeof value === 'string' ? value : ''); + +const loadProvidersFromFile = (): AztecProviderApiResponse => { + const data = providersMonikersData as AztecProviderApiResponse; + + if (!isValidProvidersResponse(data)) { + logError('Local providers JSON file has invalid shape, returning empty providers list'); + return { providers: [] }; + } + + logInfo(`Loaded ${data.providers.length} providers from local JSON file`); + return data; +}; + +export const fetchProvidersApiResponse = async (): Promise => { try { - logInfo('Attempting to fetch provider metadata from API'); + logInfo('Attempting to fetch providers from API'); const response = await fetch(AZTEC_PROVIDERS_API, { headers: { @@ -52,60 +72,103 @@ export const fetchProviderMetadata = async (includeLogo = false): Promise(); + const data = await response.json(); - for (const provider of data.providers) { - const checksummedAddress = getAddress(provider.address); - const metadata: ProviderMetadata = { - name: provider.name, - website: provider.website || '', - description: provider.description || '', - }; - - if (includeLogo && provider.logo_url) { - metadata.logoUrl = provider.logo_url; - } - - providerMetadata.set(checksummedAddress, metadata); + if (!isValidProvidersResponse(data)) { + logWarn('Providers API returned invalid payload shape, falling back to local JSON'); + return loadProvidersFromFile(); } - logInfo(`Fetched ${providerMetadata.size} provider metadata entries from API`); - return providerMetadata; + logInfo(`Fetched ${data.providers.length} providers from API`); + return data; } catch (e: any) { - logWarn(`Error fetching provider metadata from API: ${e.message}, falling back to local JSON`); - return loadProviderMetadataFromFile(includeLogo); + logWarn(`Error fetching providers from API: ${e.message}, falling back to local JSON`); + return loadProvidersFromFile(); } }; -const loadProviderMetadataFromFile = (includeLogo = false): Map => { - try { - const data = providersMonikersData as AztecProviderApiResponse; - const providerMetadata = new Map(); +export const buildProviderMetadata = ( + data: AztecProviderApiResponse, + includeLogo = false, +): Map => { + const providerMetadata = new Map(); - for (const provider of data.providers) { + for (const provider of data.providers) { + try { const checksummedAddress = getAddress(provider.address); const metadata: ProviderMetadata = { - name: provider.name, - website: provider.website || '', - description: provider.description || '', + name: asString(provider.name), + website: asString(provider.website), + description: asString(provider.description), }; - if (includeLogo && provider.logo_url) { - metadata.logoUrl = provider.logo_url; + const logoUrl = asString(provider.logo_url); + if (includeLogo && logoUrl) { + metadata.logoUrl = logoUrl; } providerMetadata.set(checksummedAddress, metadata); + } catch (e: any) { + logWarn(`Skipping provider ${provider?.id ?? '?'} with invalid address: ${provider?.address}`); } + } - logInfo(`Loaded ${providerMetadata.size} provider metadata entries from local JSON file`); - return providerMetadata; - } catch (e: any) { - logError(`Error loading provider metadata from local JSON file: ${e.message}`); - return new Map(); + return providerMetadata; +}; + +export const buildProviderSelfStakeMap = (data: AztecProviderApiResponse): Map => { + const attesterClaims = new Map(); + + for (const provider of data.providers) { + try { + if (!provider?.providerSelfStake?.length) { + continue; + } + + const providerAddress = getAddress(provider.address); + + for (const attester of provider.providerSelfStake) { + try { + const checksummedAttester = getAddress(attester); + const claims = attesterClaims.get(checksummedAttester) ?? []; + // Repeats within the same provider's list are data sloppiness, not an + // ownership conflict - only distinct providers count as competing claims. + if (!claims.includes(providerAddress)) { + claims.push(providerAddress); + } + attesterClaims.set(checksummedAttester, claims); + } catch { + logWarn(`Skipping invalid attester address in providerSelfStake of provider ${provider.id}: ${attester}`); + } + } + } catch { + logWarn(`Skipping malformed provider entry in providerSelfStake build: ${JSON.stringify(provider)?.slice(0, 200)}`); + } } + + const selfStakeMap = new Map(); + + for (const [attester, claims] of Array.from(attesterClaims.entries())) { + if (claims.length > 1) { + logWarn( + `Attester ${attester} claimed in providerSelfStake by multiple providers (${claims.join(', ')}), ` + + 'quarantined - keeping it anonymous until the source is corrected', + ); + continue; + } + selfStakeMap.set(attester, claims[0]); + } + + return selfStakeMap; +}; + +export const fetchProviderMetadata = async (includeLogo = false): Promise> => { + const data = await fetchProvidersApiResponse(); + const providerMetadata = buildProviderMetadata(data, includeLogo); + logInfo(`Built ${providerMetadata.size} provider metadata entries`); + return providerMetadata; }; diff --git a/src/app/services/node-service.ts b/src/app/services/node-service.ts index d9e04267..b50abf62 100644 --- a/src/app/services/node-service.ts +++ b/src/app/services/node-service.ts @@ -18,6 +18,16 @@ export type NodeWithValidatorAndChain = Prisma.NodeGetPayload<{ include: { validator: true; chain: { include: { params: true; tokenomics: true } } }; }>; +// Aztec mainnet only: existing self-stake sequencer nodes (validatorId = null) must be +// relinked when the providers API starts claiming them. For other chains validatorId can +// come from a nondeterministic fuzzy match, so re-asserting it on every update is unsafe. +// Undefined validatorId never unlinks - an API outage must not drop existing links. +export const buildNodeValidatorLinkUpdate = ( + chainName: string, + validatorId?: number, +): Prisma.NodeUpdateInput | Record => + validatorId && chainName === 'aztec' ? { validator: { connect: { id: validatorId } } } : {}; + const upsertNode = async ( chain: Chain, bech32Prefix: string | undefined, @@ -47,6 +57,7 @@ const upsertNode = async ( const node = await db.node.upsert({ where: { operatorAddress: val.operator_address }, update: { + ...buildNodeValidatorLinkUpdate(chain.name, val.validatorId), accountAddress: accountAddress, rewardAddress: val.reward_address, ...(val.tokens && val.tokens !== '0' ? { tokens: val.tokens } : {}), diff --git a/src/app/services/validator-service.ts b/src/app/services/validator-service.ts index 7dd97c4d..f645f205 100755 --- a/src/app/services/validator-service.ts +++ b/src/app/services/validator-service.ts @@ -165,8 +165,10 @@ const computeVotingPower = (node: { delegatorShares: string; chain: { tokenomics return bondedTokens === 0 ? 0 : (delegatorShares / bondedTokens) * 100; }; -const aggregateNodesByChain = ( +// Exported for the fixture script (scripts/test-aztec-selfstake.ts). +export const aggregateNodesByChain = ( nodes: validatorNodesWithChainData[], + providerRates: Record | null = null, ): validatorNodesWithChainData[] => { const chainGroups = new Map(); @@ -184,8 +186,14 @@ const aggregateNodesByChain = ( return nodeScore > bestScore ? node : best; }); + // Provider base take rate (e.g. Aztec providerTakeRate) overrides the primary node's + // rate in the aggregated row: self-stake nodes carry rate 0, which would otherwise + // misrepresent the provider's public commission when such a node ends up primary. + const providerRate = providerRates?.[primaryNode.chain.name]; + const rateOverride = providerRate !== undefined ? { rate: String(providerRate) } : {}; + if (group.length === 1) { - return primaryNode; + return { ...primaryNode, ...rateOverride }; } // Sum numeric metrics across all nodes in the group @@ -197,6 +205,7 @@ const aggregateNodesByChain = ( return { ...primaryNode, + ...rateOverride, tokens: totalTokens.toString(), delegatorShares: totalDelegatorShares.toString(), delegatorsAmount: totalDelegators, @@ -237,6 +246,13 @@ const getValidatorNodesWithChains = async ( where.chain = { ...where.chain, name: { in: chainFilters } }; } + // Provider base take rates per chain (e.g. Aztec) - used to override the rate of + // aggregated rows so self-stake nodes (rate 0) don't misrepresent the provider commission. + const validatorProviderRates = aggregated + ? (((await db.validator.findUnique({ where: { id }, select: { providerRates: true } }))?.providerRates ?? + null) as Record | null) + : null; + if (sortBy === 'votingPower') { const allNodes = await db.node.findMany({ where, @@ -256,7 +272,7 @@ const getValidatorNodesWithChains = async ( return { ...node, votingPower }; }); - const finalNodes = aggregated ? aggregateNodesByChain(computedNodes) : computedNodes; + const finalNodes = aggregated ? aggregateNodesByChain(computedNodes, validatorProviderRates) : computedNodes; finalNodes.sort((a, b) => (order === 'asc' ? a.votingPower - b.votingPower : b.votingPower - a.votingPower)); const totalCount = finalNodes.length; @@ -292,7 +308,7 @@ const getValidatorNodesWithChains = async ( return { ...node, votingPower }; }); - const aggregatedNodes = aggregateNodesByChain(allComputedNodes); + const aggregatedNodes = aggregateNodesByChain(allComputedNodes, validatorProviderRates); // Re-sort aggregated results if (sortBy === 'prettyName') { From e03e01b570d14bc704193a565b776d7b64c0f37d Mon Sep 17 00:00:00 2001 From: m1amgn Date: Thu, 11 Jun 2026 22:57:37 +0700 Subject: [PATCH 05/10] #634: added self-staked Aztec providers --- server/tools/chains/aztec/get-node-stake.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/tools/chains/aztec/get-node-stake.ts b/server/tools/chains/aztec/get-node-stake.ts index 7ed467a0..fba79fff 100644 --- a/server/tools/chains/aztec/get-node-stake.ts +++ b/server/tools/chains/aztec/get-node-stake.ts @@ -53,7 +53,12 @@ export const fetchStakesForNodes = async ( const tokens = await fetchNodeStake(node.operatorAddress, l1RpcUrls, chainName); const delegatedStake = delegatedStakesMap.get(node.operatorAddress); - const delegatorShares = delegatedStake ? delegatedStake.delegatedStake : '0'; + const delegatedShares = delegatedStake ? delegatedStake.delegatedStake : '0'; + // Self-staked sequencers stake directly, not through a provider, so they emit no + // delegation event and delegatedShares is 0 - yet they hold real bonded stake. + // tokens is effectiveBalanceOf (the on-chain bonded balance), so for them it IS the + // voting-power stake. Delegated nodes keep their event-derived shares. + const delegatorShares = delegatedShares !== '0' ? delegatedShares : String(tokens); if (tokens !== BigInt(0) || delegatorShares !== '0') { return { From 8951e0c93443a1c73812114ffdcf3cfae43cb01c Mon Sep 17 00:00:00 2001 From: m1amgn Date: Sat, 13 Jun 2026 13:31:12 +0700 Subject: [PATCH 06/10] #634: added self-staked Aztec providers --- server/jobs/update-aztec-sequencer-stake.ts | 1 + server/tools/chains/aztec/get-node-stake.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/server/jobs/update-aztec-sequencer-stake.ts b/server/jobs/update-aztec-sequencer-stake.ts index 6c445aa4..cc42a683 100644 --- a/server/jobs/update-aztec-sequencer-stake.ts +++ b/server/jobs/update-aztec-sequencer-stake.ts @@ -25,6 +25,7 @@ const updateStakesInDB = async (stakes: NodeStake[], batchNumber: number): Promi data: { tokens: stake.tokens, delegatorShares: stake.delegatorShares, + minSelfDelegation: stake.minSelfDelegation, jailed: !stake.tokens || stake.tokens === '0', }, }); diff --git a/server/tools/chains/aztec/get-node-stake.ts b/server/tools/chains/aztec/get-node-stake.ts index fba79fff..7e3e3458 100644 --- a/server/tools/chains/aztec/get-node-stake.ts +++ b/server/tools/chains/aztec/get-node-stake.ts @@ -13,6 +13,7 @@ export interface NodeStake { operatorAddress: `0x${string}`; tokens: string; delegatorShares: string; + minSelfDelegation: string; } export const fetchStakesForNodes = async ( @@ -59,12 +60,17 @@ export const fetchStakesForNodes = async ( // tokens is effectiveBalanceOf (the on-chain bonded balance), so for them it IS the // voting-power stake. Delegated nodes keep their event-derived shares. const delegatorShares = delegatedShares !== '0' ? delegatedShares : String(tokens); + // For the same reason the whole bond is self-stake: no provider delegates to them, so + // effectiveBalanceOf is entirely the operator's own stake. Delegated nodes have no + // event-derived self-stake here and stay at 0. + const selfStake = delegatedShares !== '0' ? '0' : String(tokens); if (tokens !== BigInt(0) || delegatorShares !== '0') { return { operatorAddress: node.operatorAddress, tokens: String(tokens), delegatorShares: delegatorShares, + minSelfDelegation: selfStake, }; } @@ -72,6 +78,7 @@ export const fetchStakesForNodes = async ( operatorAddress: node.operatorAddress, tokens: '0', delegatorShares: '0', + minSelfDelegation: '0', }; } catch (e: any) { if (attempt < MAX_RETRIES - 1) { @@ -90,6 +97,7 @@ export const fetchStakesForNodes = async ( operatorAddress: node.operatorAddress, tokens: '0', delegatorShares: delegatedStake.delegatedStake, + minSelfDelegation: '0', }; } logError( From 48ffa6fe8075b62400ffd78b74ec96e9414fc7f1 Mon Sep 17 00:00:00 2001 From: m1amgn Date: Tue, 16 Jun 2026 22:02:56 +0700 Subject: [PATCH 07/10] #621: added account and valoper txs --- messages/en.json | 5 +- messages/pt.json | 5 +- messages/ru.json | 5 +- src/actions/get-txs-batch.ts | 50 ++++ .../components/txs/tx-cursor-pagination.tsx | 86 +++++++ .../components/txs/tx-cursor-token.ts | 20 ++ .../components/txs/tx-list-client.tsx | 223 ++++++++++++++++++ src/app/[locale]/components/txs/tx-row.tsx | 81 +++++++ .../components/txs/tx-rows-skeleton.tsx | 31 +++ .../[accountAddress]/transactions/page.tsx | 16 +- .../account-transactions-list.tsx | 50 ++-- .../account-transactions.tsx | 34 ++- .../[operatorAddress]/tx_summary/page.tsx | 17 +- .../tx_summary/txs-table/node-txs-list.tsx | 60 +++-- .../tx_summary/txs-table/node-txs.tsx | 37 ++- .../services/cosmos-indexer-api/endpoints.ts | 28 +++ src/app/services/cosmos-indexer-api/index.ts | 2 + src/app/services/redis-cache.ts | 8 + src/app/services/tx-service.ts | 106 ++++++++- 19 files changed, 774 insertions(+), 90 deletions(-) create mode 100644 src/actions/get-txs-batch.ts create mode 100644 src/app/[locale]/components/txs/tx-cursor-pagination.tsx create mode 100644 src/app/[locale]/components/txs/tx-cursor-token.ts create mode 100644 src/app/[locale]/components/txs/tx-list-client.tsx create mode 100644 src/app/[locale]/components/txs/tx-row.tsx create mode 100644 src/app/[locale]/components/txs/tx-rows-skeleton.tsx diff --git a/messages/en.json b/messages/en.json index 87c4cf0c..10200d67 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1404,6 +1404,9 @@ "noTransactionsYet": "No transactions yet", "transactionsWillAppear": "Transaction display will appear when transactions begin on the network", "txListLoadError": "Could not load transactions. Please try reloading the page.", + "retry": "Retry", + "prevPage": "Previous page", + "nextPage": "Next page", "Confirmed": "Confirmed", "Pending": "Pending", "Table": { @@ -1785,7 +1788,7 @@ }, "Transactions": { "title": "Blockchain Account Transaction History", - "description": "The complete transaction log is here. I’ve detailed every send, receive, and interaction, with filters to help you navigate. It’s your transparent record of blockchain activity. Need to dig deeper? Use our Transaction Explorer for advanced insights!" + "description": "The complete transaction log is here. I’ve detailed every transaction this address signed or took part in, with filters to help you navigate. It’s your transparent record of blockchain activity. Need to dig deeper? Use our Transaction Explorer for advanced insights!" }, "Governance": { "title": "Governance Participation: Votes and Network Impact", diff --git a/messages/pt.json b/messages/pt.json index c25e9571..666d376a 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -1404,6 +1404,9 @@ "noTransactionsYet": "Ainda não há transações", "transactionsWillAppear": "A exibição de transações aparecerá quando as transações começarem na rede", "txListLoadError": "Não foi possível carregar as transações. Tente recarregar a página.", + "retry": "Tentar novamente", + "prevPage": "Página anterior", + "nextPage": "Próxima página", "Confirmed": "Confirmadas", "Pending": "Pendentes", "Table": { @@ -1786,7 +1789,7 @@ }, "Transactions": { "title": "Histórico de Transações da Conta Blockchain", - "description": "O registo completo das transações está aqui. Detalhei todas as enviadas, recebidas e interações, com filtros para ajudar na navegação. É o seu registo transparente da atividade da blockchain. Precisa aprofundar mais? Use o nosso Explorador de Transações para obter informações avançadas!" + "description": "O registo completo das transações está aqui. Detalhei todas as transações que este endereço assinou ou nas quais participou, com filtros para ajudar na navegação. É o seu registo transparente da atividade da blockchain. Precisa aprofundar mais? Use o nosso Explorador de Transações para obter informações avançadas!" }, "Governance": { "title": "Participação em Governança: Votos e Impacto na Rede", diff --git a/messages/ru.json b/messages/ru.json index 83a303b5..d39a5a3b 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -1404,6 +1404,9 @@ "noTransactionsYet": "Транзакций пока нет", "transactionsWillAppear": "Отображение транзакций появится, когда в сети начнутся транзакции", "txListLoadError": "Не удалось загрузить транзакции. Попробуйте перезагрузить страницу.", + "retry": "Повторить", + "prevPage": "Предыдущая страница", + "nextPage": "Следующая страница", "Confirmed": "Подтверждённые", "Pending": "В ожидании", "Table": { @@ -1786,7 +1789,7 @@ }, "Transactions": { "title": "История транзакций учетной записи в блокчейне", - "description": "Здесь находится полный журнал транзакций. Я подробно описал каждую отправку, получение и взаимодействие, добавив фильтры, которые помогут вам ориентироваться. Это ваша прозрачная запись активности блокчейна. Хотите узнать больше? Воспользуйтесь нашим Transaction Explorer для получения более подробной информации!3" + "description": "Здесь находится полный журнал транзакций. Я подробно описал каждую транзакцию, которую этот адрес подписал или в которой участвовал, добавив фильтры, которые помогут вам ориентироваться. Это ваша прозрачная запись активности блокчейна. Хотите узнать больше? Воспользуйтесь нашим Transaction Explorer для получения более подробной информации!" }, "Governance": { "title": "Участие в управлении: Голоса и влияние на сеть", diff --git a/src/actions/get-txs-batch.ts b/src/actions/get-txs-batch.ts new file mode 100644 index 00000000..d94f8341 --- /dev/null +++ b/src/actions/get-txs-batch.ts @@ -0,0 +1,50 @@ +'use server'; + +import logger from '@/logger'; +import TxService from '@/services/tx-service'; +import type { Cursor, TxBatchResult } from '@/services/tx-service'; + +const { logError } = logger('get-txs-batch'); + +const COSMOSHUB = 'cosmoshub'; + +// Loose bech32 shape: 1. Accepts both `cosmos1…` and `cosmosvaloper1…` (the validator +// feed sends both). The indexer validates strictly server-side; this is a cheap pre-filter so a +// malformed input fails fast as INVALID_REQUEST instead of hitting the indexer. +const BECH32 = /^[a-z]+1[02-9ac-hj-np-z]{6,}$/; + +/** + * Server action: fetch one by-address batch for the client tx list. Cosmoshub-gated. Non-cosmoshub + * and empty address sets short-circuit to an empty OK result (mirrors the SSR gate). A thrown/error + * batch surfaces as SERVICE_ERROR; malformed input as INVALID_REQUEST — the client maps the code to + * a localized message and a Retry affordance. + */ +export const getTxsBatch = async ( + addresses: string[], + chainName: string, + cursor?: Cursor, +): Promise => { + try { + if (chainName.toLowerCase() !== COSMOSHUB) { + return { ok: true, rows: [], nextCursor: null, hasMore: false }; + } + + const list = addresses.filter(Boolean); + if (!list.length) { + return { ok: true, rows: [], nextCursor: null, hasMore: false }; + } + if (!list.every((address) => BECH32.test(address))) { + return { ok: false, code: 'INVALID_REQUEST' }; + } + + const batch = await TxService.getCosmosTxsBatch(list, cursor); + if (batch.error) { + return { ok: false, code: 'SERVICE_ERROR' }; + } + + return { ok: true, rows: batch.rows, nextCursor: batch.nextCursor, hasMore: batch.hasMore }; + } catch (error) { + logError(`getTxsBatch failed: ${error instanceof Error ? error.message : String(error)}`, error); + return { ok: false, code: 'SERVICE_ERROR' }; + } +}; diff --git a/src/app/[locale]/components/txs/tx-cursor-pagination.tsx b/src/app/[locale]/components/txs/tx-cursor-pagination.tsx new file mode 100644 index 00000000..0baff99f --- /dev/null +++ b/src/app/[locale]/components/txs/tx-cursor-pagination.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { FC } from 'react'; + +import TriangleButton from '@/components/common/triangle-button'; +import { cn } from '@/utils/cn'; + +interface OwnProps { + current: number; // 1-based, session-relative (landed window = 1) + loadedWindows: number; + hasMore: boolean; + onSelect: (page: number) => void; + onPrev: () => void; + onNext: () => void; + prevLabel?: string; + nextLabel?: string; +} + +type Item = { kind: 'num'; n: number } | { kind: 'ellipsis'; id: string }; + +// Cursor pagination control. Visually mirrors TablePagination's `hideLastPage` mode used on the +// big-data txs/blocks tables — `< 1 … 6 [7] 8 … >` — but driven by client state, not URL page +// numbers, and with NO last-page number (no COUNT). Numbers are session-relative loaded windows. +const TxCursorPagination: FC = ({ + current, + loadedWindows, + hasMore, + onSelect, + onPrev, + onNext, + prevLabel = 'Previous', + nextLabel = 'Next', +}) => { + const hasPrev = current > 1; + const hasNext = current < loadedWindows || hasMore; + + if (!hasPrev && !hasNext) { + return

; + } + + const items: Item[] = []; + if (current > 1) items.push({ kind: 'num', n: 1 }); + if (current >= 3) { + items.push({ kind: 'ellipsis', id: 'lead' }); + items.push({ kind: 'num', n: current - 1 }); + } + items.push({ kind: 'num', n: current }); + if (current < loadedWindows) items.push({ kind: 'num', n: current + 1 }); + if (current + 1 < loadedWindows || hasMore) items.push({ kind: 'ellipsis', id: 'trail' }); + + return ( +
+ {hasPrev && ( + + )} + {items.map((item) => + item.kind === 'ellipsis' ? ( +
+ … +
+ ) : ( + + ), + )} + {hasNext && ( + + )} +
+ ); +}; + +export default TxCursorPagination; diff --git a/src/app/[locale]/components/txs/tx-cursor-token.ts b/src/app/[locale]/components/txs/tx-cursor-token.ts new file mode 100644 index 00000000..17e8469f --- /dev/null +++ b/src/app/[locale]/components/txs/tx-cursor-token.ts @@ -0,0 +1,20 @@ +import type { Cursor } from '@/services/tx-service'; + +// Compact, isomorphic cursor token for the URL: `${before_height}-${before_index}` (e.g. "31577956-5"). +// Both fields are non-negative integers, so the single `-` separator is unambiguous. Used by the SSR +// wrapper (decode from searchParams) and the client widget (encode into history.pushState URLs). + +export const encodeCursorToken = (cursor: Cursor): string => + `${cursor.before_height}-${cursor.before_index}`; + +export const decodeCursorToken = (token: string | undefined | null): Cursor | undefined => { + if (!token) return undefined; + const sep = token.lastIndexOf('-'); + if (sep <= 0) return undefined; + const before_height = token.slice(0, sep); + const before_index = Number(token.slice(sep + 1)); + if (!/^\d+$/.test(before_height) || !Number.isInteger(before_index) || before_index < 0) { + return undefined; + } + return { before_height, before_index }; +}; diff --git a/src/app/[locale]/components/txs/tx-list-client.tsx b/src/app/[locale]/components/txs/tx-list-client.tsx new file mode 100644 index 00000000..4dd35da6 --- /dev/null +++ b/src/app/[locale]/components/txs/tx-list-client.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { FC, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { getTxsBatch } from '@/actions/get-txs-batch'; +import type { Cursor, TxBatch, TxItem } from '@/services/tx-service'; +import TxRow from '@/components/txs/tx-row'; +import TxRowsSkeleton from '@/components/txs/tx-rows-skeleton'; +import TxCursorPagination from '@/components/txs/tx-cursor-pagination'; + +const PER_PAGE = 20; +const PREFETCH_RUNWAY = PER_PAGE * 2; // start the next-batch fetch when fewer than 2 windows remain ahead + +interface LoadedBatch { + cursor: Cursor | null; // the cursor that produced this batch (null = head) + rows: TxItem[]; + nextCursor: Cursor | null; + hasMore: boolean; +} + +interface OwnProps { + addresses: string[]; + chainName: string; + initialCursor: Cursor | null; // cursor that produced `initial` (from the URL `c`) + initialWindow: number; // window within `initial` (from the URL `w`, clamped server-side) + initial: TxBatch; +} + +const cursorKey = (cursor: Cursor | null): string => + cursor ? `${cursor.before_height}-${cursor.before_index}` : 'head'; + +const MessageRow: FC<{ message: string; hint?: string; action?: ReactNode }> = ({ message, hint, action }) => ( + + + +
+
{message}
+ {hint &&
{hint}
} + {action} +
+ + + +); + +const TxListClient: FC = ({ addresses, chainName, initialCursor, initialWindow, initial }) => { + const t = useTranslations('TotalTxsPage'); + + const [loaded, setLoaded] = useState([ + { cursor: initialCursor, rows: initial.rows, nextCursor: initial.nextCursor, hasMore: initial.hasMore }, + ]); + const [windowIdx, setWindowIdx] = useState(initialWindow); + const [loading, setLoading] = useState(false); + const [errored, setErrored] = useState(Boolean(initial.error)); + const inFlight = useRef>(new Set()); + const didMount = useRef(false); + + const loadedRows = useMemo(() => loaded.flatMap((batch) => batch.rows), [loaded]); + const tail = loaded[loaded.length - 1]; + const totalLoadedWindows = Math.max(1, Math.ceil(loadedRows.length / PER_PAGE)); + const windowRows = loadedRows.slice(windowIdx * PER_PAGE, windowIdx * PER_PAGE + PER_PAGE); + const isLastWindow = (windowIdx + 1) * PER_PAGE >= loadedRows.length; + const hasNext = !isLastWindow || tail.hasMore; + + // Append the next batch (forward). Deduped by cursor via the inFlight ref so a fast clicker / the + // prefetch effect can't double-fetch or append out of order. + const fetchNext = useCallback(async () => { + const last = loaded[loaded.length - 1]; + if (!last.hasMore || !last.nextCursor) return; + const key = cursorKey(last.nextCursor); + if (inFlight.current.has(key)) return; + inFlight.current.add(key); + setLoading(true); + const res = await getTxsBatch(addresses, chainName, last.nextCursor); + inFlight.current.delete(key); + setLoading(false); + if (!res.ok) { + setErrored(true); + return; + } + setErrored(false); + setLoaded((prev) => { + if (prev.some((batch) => cursorKey(batch.cursor) === key)) return prev; // race guard + return [...prev, { cursor: last.nextCursor, rows: res.rows, nextCursor: res.nextCursor, hasMore: res.hasMore }]; + }); + }, [addresses, chainName, loaded]); + + // Re-fetch the head batch by its own cursor and replace it (recovers a failed cold-load). + const reloadHead = useCallback(async () => { + const head = loaded[0]; + const key = `reload:${cursorKey(head.cursor)}`; + if (inFlight.current.has(key)) return; + inFlight.current.add(key); + setLoading(true); + const res = await getTxsBatch(addresses, chainName, head.cursor ?? undefined); + inFlight.current.delete(key); + setLoading(false); + if (!res.ok) { + setErrored(true); + return; + } + setErrored(false); + setLoaded([{ cursor: head.cursor, rows: res.rows, nextCursor: res.nextCursor, hasMore: res.hasMore }]); + setWindowIdx((i) => Math.min(i, Math.max(0, Math.ceil(res.rows.length / PER_PAGE) - 1))); + }, [addresses, chainName, loaded]); + + // Load-ahead: prefetch the next batch when ≤2 windows remain ahead of the current one. On a + // 5-window batch this fires at window 3 (`batchStart+2`, e.g. pages 3/8/13), leaving pages 4–5 as + // runway before the batch boundary at page 6. `<=` so the trigger lands exactly on that boundary + // (with `<` it slipped a window late → fired at page 4 → page 6 wasn't ready in time). + useEffect(() => { + const rowsAhead = loadedRows.length - (windowIdx + 1) * PER_PAGE; + if (rowsAhead <= PREFETCH_RUNWAY && tail.hasMore && !loading && !errored) { + fetchNext(); + } + }, [windowIdx, loadedRows.length, tail.hasMore, loading, errored, fetchNext]); + + // URL sync (client-only via history; no server round-trip). Reflects the current window as ?c=&w= + // so copy-link / F5 / Back land exactly. We read c/w client-side, never the server header. + useEffect(() => { + const locate = (idx: number): { cursor: Cursor | null; w: number } => { + let acc = 0; + for (const batch of loaded) { + const wins = Math.max(1, Math.ceil(batch.rows.length / PER_PAGE)); + if (idx < acc + wins) return { cursor: batch.cursor, w: idx - acc }; + acc += wins; + } + return { cursor: tail.cursor, w: 0 }; + }; + const { cursor, w } = locate(windowIdx); + const sp = new URLSearchParams(window.location.search); + if (cursor) sp.set('c', `${cursor.before_height}-${cursor.before_index}`); + else sp.delete('c'); + if (w > 0) sp.set('w', String(w)); + else sp.delete('w'); + const qs = sp.toString(); + const url = `${window.location.pathname}${qs ? `?${qs}` : ''}`; + const state = { txWindow: windowIdx }; + if (!didMount.current) { + didMount.current = true; + window.history.replaceState(state, '', url); + } else { + window.history.pushState(state, '', url); + } + }, [windowIdx, loaded, tail.cursor]); + + // Browser Back/Forward restores the window from history state (batches stay in memory). + useEffect(() => { + const onPopState = (event: PopStateEvent) => { + const idx = (event.state as { txWindow?: number } | null)?.txWindow; + setWindowIdx(typeof idx === 'number' && idx >= 0 ? idx : 0); + }; + window.addEventListener('popstate', onPopState); + return () => window.removeEventListener('popstate', onPopState); + }, []); + + const handlePrev = useCallback(() => setWindowIdx((i) => Math.max(0, i - 1)), []); + const handleNext = useCallback(() => { + if (hasNext) setWindowIdx((i) => i + 1); + }, [hasNext]); + const handleSelect = useCallback((page: number) => setWindowIdx(Math.max(0, page - 1)), []); + const handleRetry = useCallback(() => { + setErrored(false); + if (loadedRows.length === 0) { + reloadHead(); + } else { + fetchNext(); + } + }, [loadedRows.length, reloadHead, fetchNext]); + + const retryAction = ( + + ); + + let body: ReactNode; + if (loadedRows.length === 0) { + if (errored) { + body = ; + } else if (loading) { + body = ; + } else { + body = ; + } + } else if (windowRows.length === 0) { + // advanced into a window that is still loading (or whose fetch failed) + body = errored ? : ; + } else { + body = ( + + {windowRows.map((item) => ( + + ))} + + ); + } + + return ( + <> + {body} + + + + + + + + + ); +}; + +export default TxListClient; diff --git a/src/app/[locale]/components/txs/tx-row.tsx b/src/app/[locale]/components/txs/tx-row.tsx new file mode 100644 index 00000000..ebb34600 --- /dev/null +++ b/src/app/[locale]/components/txs/tx-row.tsx @@ -0,0 +1,81 @@ +'use client'; + +import Image from 'next/image'; +import Link from 'next/link'; +import { FC } from 'react'; + +import BaseTableRow from '@/components/common/table/base-table-row'; +import BaseTableCell from '@/components/common/table/base-table-cell'; +import CopyButton from '@/components/common/copy-button'; +import icons from '@/components/icons'; +import cutHash from '@/utils/cut-hash'; +import { parseMessage } from '@/utils/parse-proposal-message'; +import { formatTimestamp } from '@/utils/format-timestamp'; +import type { TxItem, TxStatus } from '@/services/tx-service'; + +interface OwnProps { + item: TxItem; + chainName: string; +} + +const getStatusIcon = (status: TxStatus) => { + switch (status) { + case 'pending': + return icons.YellowSquareIcon; + case 'confirmed': + return icons.GreenSquareIcon; + case 'dropped': + return icons.RedSquareIcon; + } +}; + +// Shared cosmos tx row (4 cols: status+type | hash | timestamp | block). Consolidates the former +// account/node item components. Client component so the client-driven tx list can render it directly. +const TxRow: FC = ({ item, chainName }) => { + // opType is the raw message type_url (e.g. /cosmos.bank.v1beta1.MsgSend); parseMessage humanizes it. + const type = item.opType ? parseMessage(item.opType) : '—'; + const txLink = `/networks/${chainName}/tx/${encodeURIComponent(item.hash)}`; + const timestamp = item.timestamp ? formatTimestamp(new Date(item.timestamp)) : '—'; + + return ( + + +
+
+ {item.status} +
+
{type}
+
+
+ +
+ +
+ {cutHash({ value: item.hash })} +
+ + +
+
+ +
+ {timestamp} + {item.timestamp != null && } +
+
+ + {item.blockHeight != null ? ( + +
+ {item.blockHeight.toLocaleString('en-US')} +
+ + ) : ( +
+ )} +
+
+ ); +}; + +export default TxRow; diff --git a/src/app/[locale]/components/txs/tx-rows-skeleton.tsx b/src/app/[locale]/components/txs/tx-rows-skeleton.tsx new file mode 100644 index 00000000..995c302c --- /dev/null +++ b/src/app/[locale]/components/txs/tx-rows-skeleton.tsx @@ -0,0 +1,31 @@ +import { FC } from 'react'; + +import BaseTableRow from '@/components/common/table/base-table-row'; +import BaseTableCell from '@/components/common/table/base-table-cell'; + +interface OwnProps { + rows?: number; +} + +const widths = ['w-24', 'w-28', 'w-40', 'w-20']; + +// Pulse-row placeholder for the tx table body. Custom palette only (`bg-primary`), no inline styles — +// mirrors network-overview-skeleton. Used both as the fallback (server) and as the +// in-window placeholder while a batch is loading (client). +const TxRowsSkeleton: FC = ({ rows = 20 }) => ( + + {Array.from({ length: rows }).map((_, rowIndex) => ( + + {widths.map((width, cellIndex) => ( + +
+
+
+ + ))} + + ))} + +); + +export default TxRowsSkeleton; diff --git a/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/page.tsx b/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/page.tsx index db441adc..167cd11b 100644 --- a/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/page.tsx +++ b/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/page.tsx @@ -1,5 +1,4 @@ import { Locale, NextPageWithLocale } from '@/i18n'; -import { SortDirection } from '@/server/types'; import { getTranslations } from 'next-intl/server'; import SubDescription from '@/components/sub-description'; import PageTitle from '@/components/common/page-title'; @@ -22,8 +21,6 @@ export async function generateMetadata({ params: { locale } }: { params: { local }; } -const defaultPerPage = 1; - const AccountTransactionsPage: NextPageWithLocale = async ( { params: { locale, name, accountAddress }, @@ -31,10 +28,9 @@ const AccountTransactionsPage: NextPageWithLocale = async ( }) => { const t = await getTranslations({ locale, namespace: 'AccountPage.Transactions' }); - const currentPage = parseInt((q.p as string) || '1'); - const perPage = q.pp ? parseInt(q.pp as string) : defaultPerPage; - const sortBy = (q.sortBy as 'name') ?? 'name'; - const order = (q.order as SortDirection) ?? 'asc'; + // Cursor-in-URL pagination: ?c=&w=. Cold load lands exactly on that window. + const cursorToken = typeof q.c === 'string' ? q.c : undefined; + const windowIndex = q.w ? parseInt(q.w as string, 10) : 0; return (
@@ -42,9 +38,9 @@ const AccountTransactionsPage: NextPageWithLocale = async ( + accountAddress={accountAddress} + cursorToken={cursorToken} + windowIndex={Number.isFinite(windowIndex) ? windowIndex : 0} />
); }; diff --git a/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions-list.tsx b/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions-list.tsx index 49e12de9..1a045c80 100644 --- a/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions-list.tsx +++ b/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions-list.tsx @@ -1,29 +1,51 @@ import { FC } from 'react'; + import TablePagination from '@/components/common/table/table-pagination'; -import { SortDirection } from '@/server/types'; +import TxService from '@/services/tx-service'; +import TxListClient from '@/components/txs/tx-list-client'; +import { decodeCursorToken } from '@/components/txs/tx-cursor-token'; import { accountTxsExample } from '@/app/networks/[name]/address/[accountAddress]/transactions/transactions-table/accountTxsExample'; import AccountTransactionsItem from '@/app/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions-item'; +const PER_PAGE = 20; + interface OwnProps { - currentPage?: number; - perPage: number; - sort: { sortBy: string; order: SortDirection }; chainName: string; + accountAddress: string; + cursorToken?: string; + windowIndex: number; } -const AccountTransactionsList: FC = async ({ chainName, sort, perPage, currentPage = 1 }) => { - const pages = 1; +const AccountTransactionsList: FC = async ({ chainName, accountAddress, cursorToken, windowIndex }) => { + // CosmosHub carries REAL indexer data via cursor pagination. Other networks keep the static mock + // placeholder (no per-address tx indexer yet) — same fallback the global /tx table uses. + if (chainName.toLowerCase() === 'cosmoshub' && accountAddress) { + const cursor = decodeCursorToken(cursorToken); + const initial = await TxService.getCosmosTxsBatch([accountAddress], cursor); + const windows = Math.max(1, Math.ceil(initial.rows.length / PER_PAGE)); + const clampedWindow = Math.min(Math.max(0, windowIndex), windows - 1); + + return ( + + ); + } return ( - {accountTxsExample.map((item) => ( - - ))} - - - - - + {accountTxsExample.map((item, index) => ( + + ))} + + + + + ); }; diff --git a/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions.tsx b/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions.tsx index bbdd2370..f33c5297 100644 --- a/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions.tsx +++ b/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions.tsx @@ -1,32 +1,40 @@ -import { FC } from 'react'; +import { FC, Suspense } from 'react'; import BaseTable from '@/components/common/table/base-table'; import TableHeaderItem from '@/components/common/table/table-header-item'; -import { SortDirection } from '@/server/types'; +import TxRowsSkeleton from '@/components/txs/tx-rows-skeleton'; import { PagesProps } from '@/types'; import AccountTransactionsList from '@/app/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions-list'; interface OwnProps extends PagesProps { - perPage: number; - currentPage?: number; - sort: { sortBy: string; order: SortDirection }; chainName: string; + accountAddress: string; + cursorToken?: string; + windowIndex: number; } -const AccountTransactions: FC = async ({ chainName, page, perPage, sort, currentPage }) => { +const AccountTransactions: FC = ({ chainName, page, accountAddress, cursorToken, windowIndex }) => { return (
- - - - - - + + + + + + - + {/* key=accountAddress: only reset the streaming boundary when the address actually changes */} + }> + +
); diff --git a/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/page.tsx b/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/page.tsx index 79fc7fec..6dd99ead 100644 --- a/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/page.tsx +++ b/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/page.tsx @@ -1,6 +1,5 @@ import NodeTxs from '@/app/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs'; import { Locale, NextPageWithLocale } from '@/i18n'; -import { SortDirection } from '@/server/types'; import validatorService from '@/services/validator-service'; import { getTranslations } from 'next-intl/server'; import SubDescription from '@/components/sub-description'; @@ -21,16 +20,13 @@ export async function generateMetadata({ params: { locale } }: { params: { local }; } -const defaultPerPage = 1; - const TxSummaryPage: NextPageWithLocale = async ({ params: { locale, id, operatorAddress }, searchParams: q }) => { const t = await getTranslations({ locale, namespace: 'TxSummaryPage' }); const validatorId = parseInt(id); - const currentPage = parseInt((q.p as string) || '1'); - const perPage = q.pp ? parseInt(q.pp as string) : defaultPerPage; - const sortBy = (q.sortBy as 'name') ?? 'name'; - const order = (q.order as SortDirection) ?? 'asc'; + // Cursor-in-URL pagination: ?c=&w=. Cold load lands exactly on that window. + const cursorToken = typeof q.c === 'string' ? q.c : undefined; + const windowIndex = q.w ? parseInt(q.w as string, 10) : 0; const { validatorNodesWithChainData: list } = await validatorService.getValidatorNodesWithChains(validatorId); const node = list.find((item) => item.operatorAddress === operatorAddress); @@ -40,9 +36,10 @@ const TxSummaryPage: NextPageWithLocale = async ({ params: { locale, + accountAddress={node?.accountAddress ?? null} + operatorAddress={operatorAddress} + cursorToken={cursorToken} + windowIndex={Number.isFinite(windowIndex) ? windowIndex : 0} />
); }; diff --git a/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs-list.tsx b/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs-list.tsx index 4357c83e..05eb8267 100644 --- a/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs-list.tsx +++ b/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs-list.tsx @@ -1,30 +1,58 @@ import { FC } from 'react'; -import NodeTxsItem from '@/app/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs-items'; -import { nodeTxsExample } from '@/app/validators/[id]/[operatorAddress]/tx_summary/txs-table/nodeTxsExample'; import TablePagination from '@/components/common/table/table-pagination'; -import { SortDirection } from '@/server/types'; +import TxService from '@/services/tx-service'; +import TxListClient from '@/components/txs/tx-list-client'; +import { decodeCursorToken } from '@/components/txs/tx-cursor-token'; +import { nodeTxsExample } from '@/app/validators/[id]/[operatorAddress]/tx_summary/txs-table/nodeTxsExample'; +import NodeTxsItem from '@/app/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs-items'; + +const PER_PAGE = 20; interface OwnProps { - currentPage?: number; - perPage: number; - sort: { sortBy: string; order: SortDirection }; chainName: string; + accountAddress: string | null; + operatorAddress: string; + cursorToken?: string; + windowIndex: number; } -const NodeTxsList: FC = async ({ chainName, sort, perPage, currentPage = 1 }) => { - const pages = 1; +const NodeTxsList: FC = async ({ chainName, accountAddress, operatorAddress, cursorToken, windowIndex }) => { + // CosmosHub carries REAL indexer data via cursor pagination. Other networks keep the static mock + // placeholder (no per-address tx indexer yet) — same fallback the global /tx table uses. + if (chainName.toLowerCase() === 'cosmoshub') { + // Query the node's account AND operator address (server-side `signers &&` union). A null + // accountAddress yields an operator-only feed (account-signed ops omitted) — known gap. + const addresses = [accountAddress, operatorAddress].filter((address): address is string => !!address); + + if (addresses.length > 0) { + const cursor = decodeCursorToken(cursorToken); + const initial = await TxService.getCosmosTxsBatch(addresses, cursor); + const windows = Math.max(1, Math.ceil(initial.rows.length / PER_PAGE)); + const clampedWindow = Math.min(Math.max(0, windowIndex), windows - 1); + + return ( + + ); + } + } return ( - {nodeTxsExample.map((item) => ( - - ))} - - - - - + {nodeTxsExample.map((item, index) => ( + + ))} + + + + + ); }; diff --git a/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs.tsx b/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs.tsx index ff14f7c7..00094aae 100644 --- a/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs.tsx +++ b/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs.tsx @@ -1,31 +1,42 @@ -import { FC } from 'react'; +import { FC, Suspense } from 'react'; import NodeTxsList from '@/app/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs-list'; import BaseTable from '@/components/common/table/base-table'; import TableHeaderItem from '@/components/common/table/table-header-item'; -import { SortDirection } from '@/server/types'; +import TxRowsSkeleton from '@/components/txs/tx-rows-skeleton'; import { PagesProps } from '@/types'; interface OwnProps extends PagesProps { - perPage: number; - currentPage?: number; - sort: { sortBy: string; order: SortDirection }; chainName: string; + accountAddress: string | null; + operatorAddress: string; + cursorToken?: string; + windowIndex: number; } -const NodeTxs: FC = async ({ chainName, page, perPage, sort, currentPage }) => { +const NodeTxs: FC = ({ chainName, page, accountAddress, operatorAddress, cursorToken, windowIndex }) => { + // key on the address set so the streaming boundary resets only when the addresses change + const addressKey = `${accountAddress ?? ''}-${operatorAddress}`; return (
- - - - - - + + + + + + - + }> + +
); diff --git a/src/app/services/cosmos-indexer-api/endpoints.ts b/src/app/services/cosmos-indexer-api/endpoints.ts index b3ff92df..91a79e90 100644 --- a/src/app/services/cosmos-indexer-api/endpoints.ts +++ b/src/app/services/cosmos-indexer-api/endpoints.ts @@ -112,3 +112,31 @@ export const getGovVotes = ( }, options, ); + +export interface GetTxsByAddressParams { + // comma-separated list of 1-5 bech32 addresses (e.g. account, or account+operator for a validator) + address: string; + limit?: number; + before_height?: string; + before_index?: number; + // 'false' skips the exact COUNT(*) `total` server-side (10–20s on a top validator). Cursor + // clients that don't read `total` should pass 'false'. Ignored by older deployments (unknown + // params are stripped), so it's safe to send before the indexer ships support. + count?: 'true' | 'false'; +} + +export const getTxsByAddress = ( + params: GetTxsByAddressParams, + options?: CosmosIndexerRequestOptions, +): Promise => + client.get( + '/api/v1/txs/by-address', + { + address: params.address, + limit: params.limit, + before_height: params.before_height, + before_index: params.before_index, + count: params.count, + }, + options, + ); diff --git a/src/app/services/cosmos-indexer-api/index.ts b/src/app/services/cosmos-indexer-api/index.ts index dd2ef0bf..979953dc 100644 --- a/src/app/services/cosmos-indexer-api/index.ts +++ b/src/app/services/cosmos-indexer-api/index.ts @@ -6,6 +6,7 @@ import { getGovVotes, getTxByHash, getTxRaw, + getTxsByAddress, getTxsList, getTxsStats, } from './endpoints'; @@ -21,6 +22,7 @@ export const cosmosIndexer = { getTxRaw, getTxsStats, getGovVotes, + getTxsByAddress, healthCheck, getBaseUrl, }; diff --git a/src/app/services/redis-cache.ts b/src/app/services/redis-cache.ts index 68d33001..ca40790e 100644 --- a/src/app/services/redis-cache.ts +++ b/src/app/services/redis-cache.ts @@ -120,6 +120,12 @@ export const CACHE_KEYS = { latestEpoch: (chainName: string) => `aztec:${chainName}:latest-epoch`, payloadUri: (chainName: string, address: string) => `aztec:${chainName}:payload-uri:${address}`, }, + txs: { + // order-independent: the indexer predicate is `signers && ARRAY[...]` (array-overlap, commutative, + // dedups), so [acc,op] and [op,acc] return identical rows. Do NOT sort if it ever becomes positional. + byAddress: (addresses: string, cursorKey: string) => + `txs:byaddr:${addresses.split(',').sort().join(',')}:${cursorKey}`, + }, }; export const CACHE_TTL = { @@ -127,4 +133,6 @@ export const CACHE_TTL = { MEDIUM: 300, // 5 minutes - for moderately changing data LONG: 600, // 10 minutes - for slow-changing data STATIC: 86400, // 24 hours - for data that never changes (payloadUri) + TXS_HEAD: 15, // by-address head batch — mutable (new txs arrive at the top), keep fresh + TXS_DEEP: 300, // by-address cursor batch — immutable window, cache long }; diff --git a/src/app/services/tx-service.ts b/src/app/services/tx-service.ts index 119ada04..13446832 100644 --- a/src/app/services/tx-service.ts +++ b/src/app/services/tx-service.ts @@ -3,13 +3,14 @@ import atomoneIndexer from '@/services/atomone-indexer-api'; import { AtomoneTxDetail } from '@/services/atomone-indexer-api'; import aztecIndexer from '@/services/aztec-indexer-api'; import cosmosIndexer from '@/services/cosmos-indexer-api'; -import { CosmosTxDetail } from '@/services/cosmos-indexer-api'; +import { CosmosTxDetail, CosmosTxSummary } from '@/services/cosmos-indexer-api'; import logosIndexer from '@/services/logos-indexer-api'; import { LogosTxDetail } from '@/services/logos-indexer-api'; import midenIndexer from '@/services/miden-indexer-api'; import { MidenTxDetail } from '@/services/miden-indexer-api'; import { refreshChainTxMetrics } from '@/server/jobs/update-tx-metrics'; import { getAztecTimestampMs } from '@/utils/aztec'; +import { cacheGetOrFetch, CACHE_KEYS, CACHE_TTL } from '@/services/redis-cache'; export type TxStatus = 'pending' | 'confirmed' | 'dropped'; @@ -370,6 +371,39 @@ const getMidenTxByHash = async ( const getMidenTxMetrics = (chainId: number, chainName: string): Promise => readTxMetrics(chainId, chainName); +export interface Cursor { + before_height: string; + before_index: number; +} + +export interface TxBatch { + rows: TxItem[]; + nextCursor: Cursor | null; + hasMore: boolean; + error?: true; +} + +// Server-action result (defined here, not in the `'use server'` action file, which may only +// export async functions). `code` lets the client map failures to a localized message + Retry. +export type TxBatchResult = + | { ok: true; rows: TxItem[]; nextCursor: Cursor | null; hasMore: boolean } + | { ok: false; code: 'INVALID_REQUEST' | 'SERVICE_ERROR' }; + +const EMPTY_BATCH: TxBatch = { rows: [], nextCursor: null, hasMore: false }; +const ERROR_BATCH: TxBatch = { rows: [], nextCursor: null, hasMore: false, error: true }; + +// Shared mapper: indexer CosmosTxSummary -> UI TxItem. +// `code !== 0` means the tx is in the block but execution failed; surfaced as 'dropped' +// since TxStatus has no 'failed' member. +const toCosmosTxItem = (t: CosmosTxSummary): TxItem => ({ + hash: t.tx_hash, + status: t.code === 0 ? 'confirmed' : 'dropped', + blockHeight: Number(t.height), + timestamp: new Date(t.time).getTime(), + transactionFee: t.fee?.amount ?? undefined, + opType: t.first_msg_type ?? undefined, +}); + /** * Cosmoshub: keyset cursor is forward-only (`before_height` + `before_index`). * For arbitrary `currentPage` we walk forward in chunks of `perPage` until reaching the offset, @@ -402,16 +436,7 @@ const getCosmosTxs = async (currentPage: number, perPage: number): Promise ({ - hash: t.tx_hash, - // `code !== 0` in Cosmos means the tx is included in the block but execution failed. - // We surface that as 'dropped' since `TxStatus` has no 'failed' member. - status: t.code === 0 ? ('confirmed' as const) : ('dropped' as const), - blockHeight: Number(t.height), - timestamp: new Date(t.time).getTime(), - transactionFee: t.fee?.amount ?? undefined, - opType: t.first_msg_type ?? undefined, - })); + const txs: TxItem[] = page.data.map(toCosmosTxItem); return { txs, totalPages }; } catch (error) { @@ -420,6 +445,64 @@ const getCosmosTxs = async (currentPage: number, perPage: number): Promise => { + const page = await cosmosIndexer.getTxsByAddress( + { + address: addresses.join(','), + limit: ITEMS_PER_BATCH, + before_height: cursor?.before_height, + before_index: cursor?.before_index, + // cursor model never uses `total` — skip the expensive COUNT(*) server-side + count: 'false', + }, + { cache: 'no-store' }, + ); + + // Remap the indexer cursor ({next_before_*}) to the request/URL shape ({before_*}). + // null-guard: never restart from head if the API returns has_more with a null cursor. + const hasMore = page.has_more && page.cursor != null; + const nextCursor: Cursor | null = hasMore + ? { before_height: page.cursor!.next_before_height, before_index: page.cursor!.next_before_index } + : null; + + return { rows: page.data.map(toCosmosTxItem), nextCursor, hasMore }; +}; + +const txsByAddressInflight = new Map>(); + +/** + * Cosmoshub: one batch of transactions involving an address set (account, or account+operator for a + * validator — server-side `signers &&` union). Keyset cursor, no COUNT. Read-through Redis warm-cache + * (head 15s — mutable; deep 300s — immutable) + in-process single-flight to collapse cold-key + * stampedes. Indexer failure is caught into ERROR_BATCH so neither the action nor the RSC cold-load + * caller ever receives a rejection (which would blank the Suspense subtree). The catch is outside + * cacheGetOrFetch, so errors are not cached — the next call retries. + */ +const getCosmosTxsBatch = async (addresses: string[], cursor?: Cursor): Promise => { + const list = addresses.filter(Boolean); + if (!list.length) return EMPTY_BATCH; + + const address = list.join(','); + const cursorKey = cursor ? `c:${cursor.before_height}:${cursor.before_index}` : 'head'; + const key = CACHE_KEYS.txs.byAddress(address, cursorKey); + const ttl = cursor ? CACHE_TTL.TXS_DEEP : CACHE_TTL.TXS_HEAD; + + const existing = txsByAddressInflight.get(key); + if (existing) return existing; + + const promise = cacheGetOrFetch(key, () => fetchTxsByAddressBatch(list, cursor), ttl) + .then((v) => v ?? EMPTY_BATCH) + .catch(() => ERROR_BATCH) + .finally(() => { + txsByAddressInflight.delete(key); + }); + + txsByAddressInflight.set(key, promise); + return promise; +}; + const getCosmosTxByHash = async ( hash: string, ): Promise<{ status: TxStatus; data: CosmosTxDetail } | null> => { @@ -549,6 +632,7 @@ const TxService = { getMidenTxByHash, getMidenTxMetrics, getCosmosTxs, + getCosmosTxsBatch, getCosmosTxByHash, getCosmosTxMetrics, getAtomoneTxs, From 03a9ed048e2e14c7169c7991704d0bb86d8a785b Mon Sep 17 00:00:00 2001 From: m1amgn Date: Tue, 23 Jun 2026 18:07:24 +0700 Subject: [PATCH 08/10] #566: added Monero --- .env.example | 10 + .gitignore | 11 +- ...6-16-monero-indexer-coinbase-extra-task.md | 152 ++++ ...-06-19-monero-indexer-ordering-fix-task.md | 116 +++ .../2026-06-19-monero-pow-redesign-design.md | 397 ++++++++ messages/en.json | 184 +++- messages/pt.json | 184 +++- messages/ru.json | 184 +++- .../migration.sql | 84 ++ .../migration.sql | 36 + .../migration.sql | 3 + prisma/schema.prisma | 195 +++- server/indexer.ts | 3 + server/jobs/get-coingecko-data.ts | 5 +- server/jobs/get-price-history.ts | 5 +- server/jobs/get-prices.ts | 5 +- server/jobs/monero-coinbase-attribution.ts | 210 +++++ server/jobs/monero-network-info.ts | 121 +++ server/jobs/monero-pool-attribution.ts | 247 +++++ server/jobs/monero-pool-stats.ts | 175 ++++ server/task-worker.ts | 12 + server/tools/chains/chain-indexer.ts | 2 + server/tools/chains/methods.ts | 2 + server/tools/chains/monero/AGENTS.md | 94 +- .../__fixtures__/block-detail.sample.json | 861 ++++++++++++++++++ .../monero/__fixtures__/blocks.sample.json | 54 ++ .../chains/monero/__fixtures__/dto.check.ts | 97 ++ .../__fixtures__/pool-cryptonote.gntl.json | 35 + .../pool-cryptonote.supportxmr.json | 38 + .../monero/__fixtures__/pool-nanopool.json | 29 + .../__fixtures__/pool-observer.p2pool.json | 72 ++ .../monero/__fixtures__/supply.sample.json | 19 + .../__fixtures__/transactions.sample.json | 68 ++ .../tools/chains/monero/attribution-source.ts | 27 + server/tools/chains/monero/coinbase-parse.ts | 81 ++ .../tools/chains/monero/coinbase-pools.json | 20 + server/tools/chains/monero/constants.ts | 3 + server/tools/chains/monero/indexer-client.ts | 426 +++++++++ server/tools/chains/monero/methods.ts | 44 + server/tools/chains/monero/pool-apis.json | 93 ++ server/tools/chains/monero/pool-client.ts | 119 +++ server/tools/chains/monero/pool-parse.ts | 120 +++ server/tools/chains/params.ts | 39 + server/tools/init-chains.ts | 2 + .../components/common/profile-banner.tsx | 112 +++ .../components/common/tabs/tab-list-item.tsx | 76 +- .../components/common/tabs/tabs-data.ts | 48 +- .../(mining-pool-profile)/blocks/page.tsx | 75 ++ .../(mining-pool-profile)/layout.tsx | 30 + .../mining-pool-blocks-table.tsx | 75 ++ .../mining-pool-profile.tsx | 43 + .../(mining-pool-profile)/networks/page.tsx | 109 +++ .../[poolSlug]/(mining-pool-profile)/page.tsx | 18 + .../mining-pools/mining-pool-list-item.tsx | 96 ++ .../mining-pools/mining-pools-filters.tsx | 34 + src/app/[locale]/mining-pools/page.tsx | 92 +- .../governance/offchain-governance-config.ts | 25 + .../governance/offchain-governance-info.tsx | 49 + .../(network-profile)/governance/page.tsx | 15 + .../network-profile-header/metrics-header.tsx | 62 +- .../network-profile-header.tsx | 23 +- .../overview/monero-hashrate-chart.tsx | 337 +++++++ .../overview/monero-hashrate-section.tsx | 39 + .../overview/monero-network-rows.tsx | 112 +++ .../overview/network-apr-tvs.tsx | 27 +- .../overview/network-overview.tsx | 9 + .../(network-profile)/overview/page.tsx | 9 +- .../stats/hashrate-window-selector.tsx | 69 ++ .../[name]/(network-profile)/stats/page.tsx | 39 +- .../stats/pow-network-stats.tsx | 90 ++ .../(network-profile)/tokenomics/page.tsx | 11 +- .../blocks/[hash]/block-information.tsx | 5 + .../expand/expanded-block-information.tsx | 132 +++ .../[hash]/json/json-block-information.tsx | 22 + .../[hash]/monero-block-information.tsx | 139 +++ .../[locale]/networks/[name]/blocks/page.tsx | 11 +- .../networks/[name]/blocks/pow-blocks.tsx | 155 ++++ .../network-mining-pools-item.tsx | 50 + .../network-mining-pools.tsx | 92 ++ .../networks/[name]/mining-pools/page.tsx | 61 ++ .../[hash]/expand/expanded-tx-information.tsx | 37 + .../tx/[hash]/json/json-tx-information.tsx | 26 + .../tx/[hash]/monero-tx-information.tsx | 166 ++++ .../[name]/tx/[hash]/tx-information.tsx | 5 + .../networks/[name]/tx/monero-txs.tsx | 134 +++ src/app/[locale]/networks/[name]/tx/page.tsx | 36 +- .../networks-list/networks-list-item.tsx | 2 +- src/app/services/monero-service.ts | 274 ++++++ src/types.d.ts | 3 + src/utils/bigint-safe-cache.ts | 27 + src/utils/format-hashrate.ts | 62 ++ src/utils/monero.ts | 33 + src/utils/safe-href.ts | 11 + tailwind.config.ts | 6 +- 94 files changed, 7689 insertions(+), 208 deletions(-) create mode 100644 docs/plans/2026-06-16-monero-indexer-coinbase-extra-task.md create mode 100644 docs/plans/2026-06-19-monero-indexer-ordering-fix-task.md create mode 100644 docs/plans/2026-06-19-monero-pow-redesign-design.md create mode 100644 prisma/migrations/20260429154100_add_monero_pow_models/migration.sql create mode 100644 prisma/migrations/20260619091412_add_monero_block_attribution/migration.sql create mode 100644 prisma/migrations/20260620160000_add_mining_pool_socials/migration.sql create mode 100644 server/jobs/monero-coinbase-attribution.ts create mode 100644 server/jobs/monero-network-info.ts create mode 100644 server/jobs/monero-pool-attribution.ts create mode 100644 server/jobs/monero-pool-stats.ts create mode 100644 server/tools/chains/monero/__fixtures__/block-detail.sample.json create mode 100644 server/tools/chains/monero/__fixtures__/blocks.sample.json create mode 100644 server/tools/chains/monero/__fixtures__/dto.check.ts create mode 100644 server/tools/chains/monero/__fixtures__/pool-cryptonote.gntl.json create mode 100644 server/tools/chains/monero/__fixtures__/pool-cryptonote.supportxmr.json create mode 100644 server/tools/chains/monero/__fixtures__/pool-nanopool.json create mode 100644 server/tools/chains/monero/__fixtures__/pool-observer.p2pool.json create mode 100644 server/tools/chains/monero/__fixtures__/supply.sample.json create mode 100644 server/tools/chains/monero/__fixtures__/transactions.sample.json create mode 100644 server/tools/chains/monero/attribution-source.ts create mode 100644 server/tools/chains/monero/coinbase-parse.ts create mode 100644 server/tools/chains/monero/coinbase-pools.json create mode 100644 server/tools/chains/monero/constants.ts create mode 100644 server/tools/chains/monero/indexer-client.ts create mode 100644 server/tools/chains/monero/methods.ts create mode 100644 server/tools/chains/monero/pool-apis.json create mode 100644 server/tools/chains/monero/pool-client.ts create mode 100644 server/tools/chains/monero/pool-parse.ts create mode 100644 src/app/[locale]/components/common/profile-banner.tsx create mode 100644 src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/blocks/page.tsx create mode 100644 src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/layout.tsx create mode 100644 src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/mining-pool-blocks-table.tsx create mode 100644 src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/mining-pool-profile/mining-pool-profile.tsx create mode 100644 src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/networks/page.tsx create mode 100644 src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/page.tsx create mode 100644 src/app/[locale]/mining-pools/mining-pool-list-item.tsx create mode 100644 src/app/[locale]/mining-pools/mining-pools-filters.tsx create mode 100644 src/app/[locale]/networks/[name]/(network-profile)/governance/offchain-governance-config.ts create mode 100644 src/app/[locale]/networks/[name]/(network-profile)/governance/offchain-governance-info.tsx create mode 100644 src/app/[locale]/networks/[name]/(network-profile)/overview/monero-hashrate-chart.tsx create mode 100644 src/app/[locale]/networks/[name]/(network-profile)/overview/monero-hashrate-section.tsx create mode 100644 src/app/[locale]/networks/[name]/(network-profile)/overview/monero-network-rows.tsx create mode 100644 src/app/[locale]/networks/[name]/(network-profile)/stats/hashrate-window-selector.tsx create mode 100644 src/app/[locale]/networks/[name]/(network-profile)/stats/pow-network-stats.tsx create mode 100644 src/app/[locale]/networks/[name]/blocks/[hash]/monero-block-information.tsx create mode 100644 src/app/[locale]/networks/[name]/blocks/pow-blocks.tsx create mode 100644 src/app/[locale]/networks/[name]/mining-pools/network-mining-pools-table/network-mining-pools-item.tsx create mode 100644 src/app/[locale]/networks/[name]/mining-pools/network-mining-pools-table/network-mining-pools.tsx create mode 100644 src/app/[locale]/networks/[name]/mining-pools/page.tsx create mode 100644 src/app/[locale]/networks/[name]/tx/[hash]/monero-tx-information.tsx create mode 100644 src/app/[locale]/networks/[name]/tx/monero-txs.tsx create mode 100644 src/app/services/monero-service.ts create mode 100644 src/utils/bigint-safe-cache.ts create mode 100644 src/utils/format-hashrate.ts create mode 100644 src/utils/monero.ts create mode 100644 src/utils/safe-href.ts 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/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..e44cd3cc 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", @@ -1437,6 +1453,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 +1539,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 +1911,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..552a0495 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", @@ -1465,6 +1481,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 +1568,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 +1912,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..ad3bc845 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": "Панель статистики блокчейна: Живые показатели сети", @@ -1437,6 +1453,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 +1540,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 +1912,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]/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..74d32c36 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[] = [ @@ -97,6 +100,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 +227,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 +259,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]/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..6d564c97 100644 --- a/src/app/[locale]/mining-pools/page.tsx +++ b/src/app/[locale]/mining-pools/page.tsx @@ -1,28 +1,96 @@ +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 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 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..dfdc09f4 --- /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..00725ff1 100755 --- a/src/app/[locale]/networks/networks-list/networks-list-item.tsx +++ b/src/app/[locale]/networks/networks-list/networks-list-item.tsx @@ -23,7 +23,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 hasTxPage = ['aztec', 'logos-testnet', 'cosmoshub', 'atomone', 'miden-testnet', 'monero'].includes(item.name); return ( 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/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/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: [ From 9be9c8d5b47a5d582736557418640707ab157df3 Mon Sep 17 00:00:00 2001 From: m1amgn Date: Tue, 23 Jun 2026 22:29:25 +0700 Subject: [PATCH 09/10] #616: fixed menus tabs --- AGENTS.md | 98 +++++++--------- CLAUDE.md | 110 ++++++++---------- messages/en.json | 1 + messages/pt.json | 1 + messages/ru.json | 1 + src/app/[locale]/about/contacts/page.tsx | 4 +- src/app/[locale]/about/page.tsx | 4 +- src/app/[locale]/about/partners/page.tsx | 4 +- src/app/[locale]/about/podcasts/page.tsx | 4 +- src/app/[locale]/about/staking/page.tsx | 4 +- src/app/[locale]/ai/page.tsx | 4 +- src/app/[locale]/comparevalidators/page.tsx | 4 +- .../components/common/tabs/tabs-data.ts | 49 +++++++- .../navigation-bar/menu-overlay.tsx | 2 +- .../navigation-bar/mobile-navigation-bar.tsx | 2 +- .../navigation-bar/navigation-bar.tsx | 37 +----- .../ecosystems-list-item-chains.tsx | 10 +- src/app/[locale]/library/layout.tsx | 4 +- src/app/[locale]/metrics/page.tsx | 4 +- src/app/[locale]/mining-pools/page.tsx | 6 + .../network-mining-pools.tsx | 2 +- .../networks-list/networks-list-item.tsx | 5 +- src/app/[locale]/p2pchat/page.tsx | 4 +- src/app/[locale]/page.tsx | 4 +- src/app/[locale]/stakingcalculator/page.tsx | 4 +- src/app/[locale]/web3stats/page.tsx | 4 +- src/app/services/chain-service.ts | 2 + src/app/services/tx-service.ts | 3 + src/utils/tx-supported-chains.ts | 23 ++++ 29 files changed, 219 insertions(+), 185 deletions(-) create mode 100644 src/utils/tx-supported-chains.ts 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/messages/en.json b/messages/en.json index e44cd3cc..8a324e22 100644 --- a/messages/en.json +++ b/messages/en.json @@ -843,6 +843,7 @@ "Validators": "Validators", "Nodes": "Nodes", "Networks": "Networks", + "Mining Pools": "Mining Pools", "Ecosystems": "Ecosystems", "Metrics": "Metrics" }, diff --git a/messages/pt.json b/messages/pt.json index 552a0495..b85b1b7b 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -842,6 +842,7 @@ "Tabs": { "Validators": "Validadores", "Networks": "Redes", + "Mining Pools": "Pools de Mineração", "Metrics": "Métricos", "Ecosystems": "Ecossistemas", "Nodes": "Nodes" diff --git a/messages/ru.json b/messages/ru.json index ad3bc845..9e0cd7d4 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -842,6 +842,7 @@ "Tabs": { "Validators": "Валидаторы", "Networks": "Сети", + "Mining Pools": "Майнинг-пулы", "Metrics": "Метрики", "Ecosystems": "Экосистемы", "Nodes": "Ноды" 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/tabs/tabs-data.ts b/src/app/[locale]/components/common/tabs/tabs-data.ts index 74d32c36..d0c40fb1 100644 --- a/src/app/[locale]/components/common/tabs/tabs-data.ts +++ b/src/app/[locale]/components/common/tabs/tabs-data.ts @@ -31,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', @@ -45,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[] = [ 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/page.tsx b/src/app/[locale]/mining-pools/page.tsx index 6d564c97..8806de08 100644 --- a/src/app/[locale]/mining-pools/page.tsx +++ b/src/app/[locale]/mining-pools/page.tsx @@ -3,10 +3,13 @@ import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; 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'; @@ -64,6 +67,9 @@ const MiningPoolsPage: NextPageWithLocale = async ({ params: { locale return (
+ + + 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 index dfdc09f4..65d695ba 100644 --- 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 @@ -60,7 +60,7 @@ const NetworkMiningPools: FC = async ({ chainName, window, sort }) => return (
-
+
{windowOptions.length > 1 && }
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 00725ff1..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', 'miden-testnet', 'monero'].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/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/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()); From ca5a7465d26bbee13ab3025dd7a9d277be4ca066 Mon Sep 17 00:00:00 2001 From: m1amgn Date: Fri, 26 Jun 2026 11:57:12 +0700 Subject: [PATCH 10/10] #621: added AtomOne validators votes, txs and accounts txs --- .../tools/chains/atomone/get-nodes-votes.ts | 55 ++++++++++++++++++ server/tools/chains/atomone/methods.ts | 2 + src/actions/get-txs-batch.ts | 23 ++++---- .../account-transactions-list.tsx | 9 +-- .../validators-votes-list.tsx | 7 ++- .../tx_summary/txs-table/node-txs-list.tsx | 9 +-- .../services/atomone-indexer-api/endpoints.ts | 48 +++++++++++++++ src/app/services/atomone-indexer-api/index.ts | 4 ++ src/app/services/atomone-indexer-api/types.ts | 16 +++++ src/app/services/redis-cache.ts | 6 +- src/app/services/tx-service.ts | 58 ++++++++++++++----- 11 files changed, 203 insertions(+), 34 deletions(-) create mode 100644 server/tools/chains/atomone/get-nodes-votes.ts diff --git a/server/tools/chains/atomone/get-nodes-votes.ts b/server/tools/chains/atomone/get-nodes-votes.ts new file mode 100644 index 00000000..43a7f3df --- /dev/null +++ b/server/tools/chains/atomone/get-nodes-votes.ts @@ -0,0 +1,55 @@ +import atomoneIndexer from '@/services/atomone-indexer-api'; +import logger from '@/logger'; +import { GetNodesVotes, NodeVote } from '@/server/tools/chains/chain-indexer'; +import { fromValoperToAccount } from '@/utils/cosmos-address-converter'; + +const { logError } = logger('atomone-nodes-votes'); + +const PAGE_LIMIT = 100; +const MAX_PAGES = 1000; + +// AtomOne has its own populated citizenweb3 indexer deployment (ATOMONE_INDEXER_BASE_URL). This +// method overrides the inherited cosmoshub one (which guards on chain.name and would return []), +// so it points at the atomone indexer and guards on the atomone chain name. +const getNodesVotes: GetNodesVotes = async (chain, operatorAddress) => { + if (chain.name !== 'atomone') return []; + + // gov.votes is keyed by the voter ACCOUNT address; the job passes the validator operator + // (valoper) address, so convert it. Same bech32 payload, different prefix (atone / atonevaloper). + const voter = fromValoperToAccount(operatorAddress, chain.bech32Prefix); + if (!voter) return []; + + try { + const votes: NodeVote[] = []; + let before: string | undefined; + let pages = 0; + + // Drain the keyset cursor so the validator's full vote history is not truncated. + for (;;) { + const { data, cursor, has_more } = await atomoneIndexer.getGovVotes({ + voter, + limit: PAGE_LIMIT, + before_proposal_id: before, + }); + + for (const v of data) { + votes.push({ address: voter, proposalId: v.proposal_id, vote: v.option, txHash: v.tx_hash }); + } + + if (!has_more || !cursor) break; + + const next = cursor.next_before_proposal_id; + // Defensive bound against a misbehaving indexer: the keyset cursor strictly decreases, + // so stop if it fails to advance or the page count explodes. + if (next === before || ++pages >= MAX_PAGES) break; + before = next; + } + + return votes; + } catch (e) { + logError(`Can't fetch gov votes for ${voter}`, e); + return []; + } +}; + +export default getNodesVotes; diff --git a/server/tools/chains/atomone/methods.ts b/server/tools/chains/atomone/methods.ts index 76aba703..ce77958b 100644 --- a/server/tools/chains/atomone/methods.ts +++ b/server/tools/chains/atomone/methods.ts @@ -1,4 +1,5 @@ import getAvgFee from '@/server/tools/chains/atomone/get-avg-fee'; +import getNodesVotes from '@/server/tools/chains/atomone/get-nodes-votes'; import getProposalParams from '@/server/tools/chains/atomone/get-proposal-params'; import getProposals from '@/server/tools/chains/atomone/get-proposals'; import getTotalTxs from '@/server/tools/chains/atomone/get-total-txs'; @@ -15,6 +16,7 @@ const chainMethods: ChainMethods = { getTxsLast24h, getTps, getAvgFee, + getNodesVotes, }; export default chainMethods; diff --git a/src/actions/get-txs-batch.ts b/src/actions/get-txs-batch.ts index d94f8341..74602921 100644 --- a/src/actions/get-txs-batch.ts +++ b/src/actions/get-txs-batch.ts @@ -6,18 +6,21 @@ import type { Cursor, TxBatchResult } from '@/services/tx-service'; const { logError } = logger('get-txs-batch'); -const COSMOSHUB = 'cosmoshub'; +// Chains with a per-address tx indexer (separate citizenweb3 deployments, same API shape). +const TX_BY_ADDRESS_CHAINS = new Set(['cosmoshub', 'atomone']); -// Loose bech32 shape: 1. Accepts both `cosmos1…` and `cosmosvaloper1…` (the validator -// feed sends both). The indexer validates strictly server-side; this is a cheap pre-filter so a -// malformed input fails fast as INVALID_REQUEST instead of hitting the indexer. +// Loose bech32 shape: 1. Accepts account + operator forms for any hrp (`cosmos1…`, +// `cosmosvaloper1…`, `atone1…`, `atonevaloper1…` — the validator feed sends both). The indexer +// validates strictly server-side; this is a cheap pre-filter so a malformed input fails fast as +// INVALID_REQUEST instead of hitting the indexer. const BECH32 = /^[a-z]+1[02-9ac-hj-np-z]{6,}$/; /** - * Server action: fetch one by-address batch for the client tx list. Cosmoshub-gated. Non-cosmoshub - * and empty address sets short-circuit to an empty OK result (mirrors the SSR gate). A thrown/error - * batch surfaces as SERVICE_ERROR; malformed input as INVALID_REQUEST — the client maps the code to - * a localized message and a Retry affordance. + * Server action: fetch one by-address batch for the client tx list. Gated to chains with a + * per-address indexer (cosmoshub, atomone). Unsupported chains and empty address sets short-circuit + * to an empty OK result (mirrors the SSR gate). A thrown/error batch surfaces as SERVICE_ERROR; + * malformed input as INVALID_REQUEST — the client maps the code to a localized message and a Retry + * affordance. */ export const getTxsBatch = async ( addresses: string[], @@ -25,7 +28,7 @@ export const getTxsBatch = async ( cursor?: Cursor, ): Promise => { try { - if (chainName.toLowerCase() !== COSMOSHUB) { + if (!TX_BY_ADDRESS_CHAINS.has(chainName.toLowerCase())) { return { ok: true, rows: [], nextCursor: null, hasMore: false }; } @@ -37,7 +40,7 @@ export const getTxsBatch = async ( return { ok: false, code: 'INVALID_REQUEST' }; } - const batch = await TxService.getCosmosTxsBatch(list, cursor); + const batch = await TxService.getTxsByAddressBatch(chainName, list, cursor); if (batch.error) { return { ok: false, code: 'SERVICE_ERROR' }; } diff --git a/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions-list.tsx b/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions-list.tsx index 1a045c80..0ba42b3e 100644 --- a/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions-list.tsx +++ b/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions-list.tsx @@ -17,11 +17,12 @@ interface OwnProps { } const AccountTransactionsList: FC = async ({ chainName, accountAddress, cursorToken, windowIndex }) => { - // CosmosHub carries REAL indexer data via cursor pagination. Other networks keep the static mock - // placeholder (no per-address tx indexer yet) — same fallback the global /tx table uses. - if (chainName.toLowerCase() === 'cosmoshub' && accountAddress) { + // CosmosHub and AtomOne carry REAL indexer data via cursor pagination. Other networks keep the + // static mock placeholder (no per-address tx indexer yet) — same fallback the global /tx table uses. + const normalizedChainName = chainName.toLowerCase(); + if ((normalizedChainName === 'cosmoshub' || normalizedChainName === 'atomone') && accountAddress) { const cursor = decodeCursorToken(cursorToken); - const initial = await TxService.getCosmosTxsBatch([accountAddress], cursor); + const initial = await TxService.getTxsByAddressBatch(chainName, [accountAddress], cursor); const windows = Math.max(1, Math.ceil(initial.rows.length / PER_PAGE)); const clampedWindow = Math.min(Math.max(0, windowIndex), windows - 1); diff --git a/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes-list.tsx b/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes-list.tsx index eb2e28ae..f14a150c 100644 --- a/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes-list.tsx +++ b/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes-list.tsx @@ -69,7 +69,12 @@ const ValidatorsVotesList: FC = async ({ sort, perPage, currentPage = ); - } else if (chainName === 'namada' || chainName === 'namada-testnet' || chainName === 'cosmoshub') { + } else if ( + chainName === 'namada' || + chainName === 'namada-testnet' || + chainName === 'cosmoshub' || + chainName === 'atomone' + ) { const result = await voteService.getProposalValidatorsVotes( chainName, proposalId, diff --git a/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs-list.tsx b/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs-list.tsx index 05eb8267..16d8b1f9 100644 --- a/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs-list.tsx +++ b/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs-list.tsx @@ -18,16 +18,17 @@ interface OwnProps { } const NodeTxsList: FC = async ({ chainName, accountAddress, operatorAddress, cursorToken, windowIndex }) => { - // CosmosHub carries REAL indexer data via cursor pagination. Other networks keep the static mock - // placeholder (no per-address tx indexer yet) — same fallback the global /tx table uses. - if (chainName.toLowerCase() === 'cosmoshub') { + // CosmosHub and AtomOne carry REAL indexer data via cursor pagination. Other networks keep the + // static mock placeholder (no per-address tx indexer yet) — same fallback the global /tx table uses. + const normalizedChainName = chainName.toLowerCase(); + if (normalizedChainName === 'cosmoshub' || normalizedChainName === 'atomone') { // Query the node's account AND operator address (server-side `signers &&` union). A null // accountAddress yields an operator-only feed (account-signed ops omitted) — known gap. const addresses = [accountAddress, operatorAddress].filter((address): address is string => !!address); if (addresses.length > 0) { const cursor = decodeCursorToken(cursorToken); - const initial = await TxService.getCosmosTxsBatch(addresses, cursor); + const initial = await TxService.getTxsByAddressBatch(chainName, addresses, cursor); const windows = Math.max(1, Math.ceil(initial.rows.length / PER_PAGE)); const clampedWindow = Math.min(Math.max(0, windowIndex), windows - 1); diff --git a/src/app/services/atomone-indexer-api/endpoints.ts b/src/app/services/atomone-indexer-api/endpoints.ts index 4cc11111..61a66d4b 100644 --- a/src/app/services/atomone-indexer-api/endpoints.ts +++ b/src/app/services/atomone-indexer-api/endpoints.ts @@ -3,6 +3,7 @@ import { AtomoneBlockDetailResponse, AtomoneBlocksListResponse, AtomoneBlocksStatsResponse, + AtomoneGovVotesResponse, AtomoneIndexerRequestOptions, AtomoneTxDetailResponse, AtomoneTxRawResponse, @@ -91,3 +92,50 @@ export const getTxsStats = ( options?: AtomoneIndexerRequestOptions, ): Promise => client.get('/api/v1/txs/stats', null, options); + +export interface GetGovVotesParams { + voter: string; + limit?: number; + before_proposal_id?: string; +} + +export const getGovVotes = ( + params: GetGovVotesParams, + options?: AtomoneIndexerRequestOptions, +): Promise => + client.get( + '/api/v1/gov/votes', + { + voter: params.voter, + limit: params.limit, + before_proposal_id: params.before_proposal_id, + }, + options, + ); + +export interface GetTxsByAddressParams { + // comma-separated list of 1-5 bech32 addresses (e.g. account, or account+operator for a validator) + address: string; + limit?: number; + before_height?: string; + before_index?: number; + // 'false' skips the exact COUNT(*) `total` server-side. Cursor clients that don't read `total` + // should pass 'false'. Ignored by older deployments (unknown params are stripped). + count?: 'true' | 'false'; +} + +export const getTxsByAddress = ( + params: GetTxsByAddressParams, + options?: AtomoneIndexerRequestOptions, +): Promise => + client.get( + '/api/v1/txs/by-address', + { + address: params.address, + limit: params.limit, + before_height: params.before_height, + before_index: params.before_index, + count: params.count, + }, + options, + ); diff --git a/src/app/services/atomone-indexer-api/index.ts b/src/app/services/atomone-indexer-api/index.ts index e348b4eb..abe6fc8c 100644 --- a/src/app/services/atomone-indexer-api/index.ts +++ b/src/app/services/atomone-indexer-api/index.ts @@ -3,8 +3,10 @@ import { getBlockByHeight, getBlocksList, getBlocksStats, + getGovVotes, getTxByHash, getTxRaw, + getTxsByAddress, getTxsList, getTxsStats, } from './endpoints'; @@ -19,6 +21,8 @@ export const atomoneIndexer = { getTxByHash, getTxRaw, getTxsStats, + getGovVotes, + getTxsByAddress, healthCheck, getBaseUrl, }; diff --git a/src/app/services/atomone-indexer-api/types.ts b/src/app/services/atomone-indexer-api/types.ts index f64f2890..f88a1485 100644 --- a/src/app/services/atomone-indexer-api/types.ts +++ b/src/app/services/atomone-indexer-api/types.ts @@ -130,3 +130,19 @@ export interface AtomoneTxRawResponse { export interface AtomoneTxsStatsResponse { data: AtomoneTxsStats; } + +export type AtomoneGovVoteOption = 'YES' | 'NO' | 'ABSTAIN' | 'VETO' | 'UNSPECIFIED'; + +export interface AtomoneGovVote { + proposal_id: string; + option: AtomoneGovVoteOption; + weight: string | null; + height: string; + tx_hash: string; +} + +export interface AtomoneGovVotesCursor { + next_before_proposal_id: string; +} + +export type AtomoneGovVotesResponse = AtomoneListResponse; diff --git a/src/app/services/redis-cache.ts b/src/app/services/redis-cache.ts index ca40790e..b9ffd3c3 100644 --- a/src/app/services/redis-cache.ts +++ b/src/app/services/redis-cache.ts @@ -123,8 +123,10 @@ export const CACHE_KEYS = { txs: { // order-independent: the indexer predicate is `signers && ARRAY[...]` (array-overlap, commutative, // dedups), so [acc,op] and [op,acc] return identical rows. Do NOT sort if it ever becomes positional. - byAddress: (addresses: string, cursorKey: string) => - `txs:byaddr:${addresses.split(',').sort().join(',')}:${cursorKey}`, + // `chainName` namespaces the key: cosmoshub and atomone are separate indexer deployments, so the + // same-shaped cursorKey must never collide across chains. + byAddress: (chainName: string, addresses: string, cursorKey: string) => + `txs:byaddr:${chainName}:${addresses.split(',').sort().join(',')}:${cursorKey}`, }, }; diff --git a/src/app/services/tx-service.ts b/src/app/services/tx-service.ts index 74cfa43d..acf98254 100644 --- a/src/app/services/tx-service.ts +++ b/src/app/services/tx-service.ts @@ -447,8 +447,17 @@ const getCosmosTxs = async (currentPage: number, perPage: number): Promise => { - const page = await cosmosIndexer.getTxsByAddress( +// CosmosHub and AtomOne are separate deployments of the same citizenweb3 indexer API, so their +// `getTxsByAddress` share an identical request/response shape (structurally interchangeable). One +// client type lets the batch core serve either chain. +type TxsByAddressClient = { getTxsByAddress: typeof cosmosIndexer.getTxsByAddress }; + +const fetchTxsByAddressBatch = async ( + indexer: TxsByAddressClient, + addresses: string[], + cursor?: Cursor, +): Promise => { + const page = await indexer.getTxsByAddress( { address: addresses.join(','), limit: ITEMS_PER_BATCH, @@ -473,26 +482,32 @@ const fetchTxsByAddressBatch = async (addresses: string[], cursor?: Cursor): Pro const txsByAddressInflight = new Map>(); /** - * Cosmoshub: one batch of transactions involving an address set (account, or account+operator for a - * validator — server-side `signers &&` union). Keyset cursor, no COUNT. Read-through Redis warm-cache - * (head 15s — mutable; deep 300s — immutable) + in-process single-flight to collapse cold-key - * stampedes. Indexer failure is caught into ERROR_BATCH so neither the action nor the RSC cold-load - * caller ever receives a rejection (which would blank the Suspense subtree). The catch is outside - * cacheGetOrFetch, so errors are not cached — the next call retries. + * Shared by-address batch core. Picks the indexer client + cache namespace by chain. One batch of + * transactions involving an address set (account, or account+operator for a validator — server-side + * `signers &&` union). Keyset cursor, no COUNT. Read-through Redis warm-cache (head 15s — mutable; + * deep 300s — immutable) + in-process single-flight to collapse cold-key stampedes. Indexer failure + * is caught into ERROR_BATCH so neither the action nor the RSC cold-load caller ever receives a + * rejection (which would blank the Suspense subtree). The catch is outside cacheGetOrFetch, so + * errors are not cached — the next call retries. */ -const getCosmosTxsBatch = async (addresses: string[], cursor?: Cursor): Promise => { +const runTxsByAddressBatch = ( + indexer: TxsByAddressClient, + chainKey: string, + addresses: string[], + cursor?: Cursor, +): Promise => { const list = addresses.filter(Boolean); - if (!list.length) return EMPTY_BATCH; + if (!list.length) return Promise.resolve(EMPTY_BATCH); const address = list.join(','); const cursorKey = cursor ? `c:${cursor.before_height}:${cursor.before_index}` : 'head'; - const key = CACHE_KEYS.txs.byAddress(address, cursorKey); + const key = CACHE_KEYS.txs.byAddress(chainKey, address, cursorKey); const ttl = cursor ? CACHE_TTL.TXS_DEEP : CACHE_TTL.TXS_HEAD; const existing = txsByAddressInflight.get(key); if (existing) return existing; - const promise = cacheGetOrFetch(key, () => fetchTxsByAddressBatch(list, cursor), ttl) + const promise = cacheGetOrFetch(key, () => fetchTxsByAddressBatch(indexer, list, cursor), ttl) .then((v) => v ?? EMPTY_BATCH) .catch(() => ERROR_BATCH) .finally(() => { @@ -503,6 +518,23 @@ const getCosmosTxsBatch = async (addresses: string[], cursor?: Cursor): Promise< return promise; }; +// Routed by-address batch entry — the single path used by both the pages' SSR initial fetch and the +// client action, so chain→client routing lives in one place. Each branch pairs the chain's indexer +// client with its cache namespace. Unsupported chains short-circuit to an empty batch. +const getTxsByAddressBatch = (chainName: string, addresses: string[], cursor?: Cursor): Promise => { + const normalizedChainName = chainName.toLowerCase(); + + if (normalizedChainName === 'cosmoshub') { + return runTxsByAddressBatch(cosmosIndexer, 'cosmoshub', addresses, cursor); + } + + if (normalizedChainName === 'atomone') { + return runTxsByAddressBatch(atomoneIndexer, 'atomone', addresses, cursor); + } + + return Promise.resolve(EMPTY_BATCH); +}; + const getCosmosTxByHash = async ( hash: string, ): Promise<{ status: TxStatus; data: CosmosTxDetail } | null> => { @@ -635,12 +667,12 @@ const TxService = { getMidenTxByHash, getMidenTxMetrics, getCosmosTxs, - getCosmosTxsBatch, getCosmosTxByHash, getCosmosTxMetrics, getAtomoneTxs, getAtomoneTxByHash, getAtomoneTxMetrics, + getTxsByAddressBatch, }; export default TxService;