diff --git a/messages/en.json b/messages/en.json index 748e1e40..b16000fe 100644 --- a/messages/en.json +++ b/messages/en.json @@ -574,6 +574,12 @@ "forked": "Forked", "repositories": "Repositories", "most active repo": "Most Active Repo", + "most active repo tooltip": "The highest total commit count in a single tracked repository across all of its activity history stored in the index", + "open prs": "Open PRs", + "open issues": "Open Issues", + "open issues tooltip": "Open issues excluding pull requests", + "active contributors": "Active Contributors", + "active contributors tooltip": "Unique linked GitHub commit authors across tracked repositories over the latest 30 fully indexed UTC days, excluding bots", "Table": { "Repository Name": { "name": "Repository Name", @@ -619,6 +625,9 @@ "stock-to-flow": "Stock-to-Flow", "emission phase": "Emission Phase", "tail emission": "Tail emission", + "tail emission security": "Tail Emission Security", + "subsidy funded": "{share} subsidy-funded", + "tail security tooltip": "Share of 24h miner revenue paid by the 0.6 XMR tail emission versus transaction fees", "per block value": "{amount}/block", "per year value": "{amount}/yr", "not enough data": "—", @@ -1973,10 +1982,12 @@ "Blocks": "Blocks" }, "story": "{name} is a verified mining pool on the {chain} network. The stats below are derived from on-chain block attribution.", + "p2poolMiners": "P2Pool reports sidechain participants, not stratum workers.", "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" }, + "Miners": { "name": "Miners", "hint": "Fresh connected-miner count reported for this pool on this network" }, "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" }, @@ -2017,6 +2028,14 @@ "windowAll": "All-time", "noHashrateData": "No hashrate snapshots yet — backfill in progress", "noPoolData": "No pool stats yet — backfill in progress", + "dominanceTitle": "Pool Dominance Over Time", + "dominanceEmpty": "Daily pool dominance data is unavailable.", + "dominanceNotEnoughData": "At least 7 completed UTC days are required to show the dominance chart.", + "periodDaily": "Daily", + "periodWeekly": "Weekly", + "periodMonthly": "Monthly", + "periodYearly": "Yearly", + "switchPeriod": "Switch to {period} view", "networkHealth": "Network Health", "nakamoto": "Nakamoto Coefficient", "nakamotoLowerBound": "≥ {count}", @@ -2032,6 +2051,8 @@ "difficultyStable": "stable", "difficultyModerate": "moderate", "difficultyVolatile": "volatile", + "feeShare": "Fee Share of Rewards (24h)", + "feeShareValue": "{fees} fees / {subsidy} tail emission", "emissionTitle": "Emission / Supply", "circulatingSupply": "Circulating Supply", "blockReward": "Block Reward", diff --git a/messages/pt.json b/messages/pt.json index 8e993ab6..ca61533a 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -574,6 +574,12 @@ "forked": "Bifurcado", "repositories": "Repositórios", "most active repo": "Repositório Mais Ativo", + "most active repo tooltip": "A maior contagem total de commits em um único repositório monitorado em todo o histórico de atividade armazenado no índice", + "open prs": "PRs Abertas", + "open issues": "Issues Abertas", + "open issues tooltip": "Issues abertas, excluindo pull requests", + "active contributors": "Colaboradores Ativos", + "active contributors tooltip": "Autores únicos de commits vinculados ao GitHub nos repositórios monitorados nos últimos 30 dias UTC totalmente indexados, excluindo bots", "Table": { "Repository Name": { "name": "Nome do Repositório", @@ -622,6 +628,9 @@ "stock-to-flow": "Stock-to-Flow", "emission phase": "Fase de Emissão", "tail emission": "Emissão de Cauda", + "tail emission security": "Segurança da Emissão de Cauda", + "subsidy funded": "{share} financiada pelo subsídio", + "tail security tooltip": "Parcela da receita dos mineradores em 24h paga pela emissão de cauda de 0,6 XMR em comparação com as taxas de transação", "per block value": "{amount}/bloco", "per year value": "{amount}/ano", "not enough data": "—", @@ -1974,10 +1983,12 @@ "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.", + "p2poolMiners": "O P2Pool informa participantes da sidechain, não workers Stratum.", "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" }, + "Miners": { "name": "Mineradores", "hint": "Contagem recente de mineradores conectados informada para esta pool nesta rede" }, "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" }, @@ -2018,6 +2029,14 @@ "windowAll": "Todo o período", "noHashrateData": "Sem snapshots de hashrate ainda — reabastecimento em andamento", "noPoolData": "Sem estatísticas de pools ainda — reabastecimento em andamento", + "dominanceTitle": "Dominância das Pools ao Longo do Tempo", + "dominanceEmpty": "Os dados diários de dominância das pools não estão disponíveis.", + "dominanceNotEnoughData": "São necessários pelo menos 7 dias UTC concluídos para exibir o gráfico de dominância.", + "periodDaily": "Diário", + "periodWeekly": "Semanal", + "periodMonthly": "Mensal", + "periodYearly": "Anual", + "switchPeriod": "Mudar para a visualização {period}", "networkHealth": "Saúde da Rede", "nakamoto": "Coeficiente de Nakamoto", "nakamotoLowerBound": "≥ {count}", @@ -2033,6 +2052,8 @@ "difficultyStable": "estável", "difficultyModerate": "moderada", "difficultyVolatile": "volátil", + "feeShare": "Participação das Taxas nas Recompensas (24h)", + "feeShareValue": "{fees} taxas / {subsidy} emissão de cauda", "emissionTitle": "Emissão / Oferta", "circulatingSupply": "Oferta circulante", "blockReward": "Recompensa por bloco", diff --git a/messages/ru.json b/messages/ru.json index 897380c0..6479d2a3 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -574,6 +574,12 @@ "forked": "Форки", "repositories": "Репозитории", "most active repo": "Самый активный репозиторий", + "most active repo tooltip": "Максимальное число коммитов в одном отслеживаемом репозитории за всю сохранённую в индексе историю активности", + "open prs": "Открытые PR", + "open issues": "Открытые issues", + "open issues tooltip": "Открытые issues без учёта pull requests", + "active contributors": "Активные контрибьюторы", + "active contributors tooltip": "Уникальные авторы коммитов с привязанным GitHub-аккаунтом во всех отслеживаемых репозиториях за последние 30 полностью проиндексированных UTC-дней, без ботов", "Table": { "Repository Name": { "name": "Название репозитория", @@ -622,6 +628,9 @@ "stock-to-flow": "Stock-to-Flow", "emission phase": "Фаза эмиссии", "tail emission": "Хвостовая эмиссия", + "tail emission security": "Безопасность хвостовой эмиссии", + "subsidy funded": "{share} финансируется субсидией", + "tail security tooltip": "Доля дохода майнеров за 24 часа, выплаченная за счёт хвостовой эмиссии 0,6 XMR, по сравнению с комиссиями за транзакции", "per block value": "{amount}/блок", "per year value": "{amount}/год", "not enough data": "—", @@ -1974,10 +1983,12 @@ "Blocks": "Блоки" }, "story": "{name} — проверенный майнинг-пул в сети {chain}. Статистика ниже получена из ончейн-атрибуции блоков.", + "p2poolMiners": "P2Pool показывает участников сайдчейна, а не Stratum-воркеров.", "Table": { "Network": { "name": "Сеть", "hint": "Сеть, которую майнит пул" }, "Blocks": { "name": "Блоков найдено", "hint": "Блоки, атрибутированные пулу за выбранное окно" }, "Share": { "name": "Доминирование", "hint": "Доля блоков сети, найденных за выбранное окно" }, + "Miners": { "name": "Майнеры", "hint": "Актуальное число подключённых майнеров этого пула в этой сети" }, "Hashrate": { "name": "Расчётный хешрейт", "hint": "Расчётный хешрейт по доле блоков пула" }, "Fee": { "name": "Комиссия", "hint": "Комиссия пула согласно заявленной пулом" }, "Height": { "name": "Высота", "hint": "Высота блока" }, @@ -2018,6 +2029,14 @@ "windowAll": "Всё время", "noHashrateData": "Снимков хешрейта пока нет — идёт наполнение", "noPoolData": "Статистики пулов пока нет — идёт наполнение", + "dominanceTitle": "Доминирование пулов во времени", + "dominanceEmpty": "Данные о ежедневном доминировании пулов недоступны.", + "dominanceNotEnoughData": "Для отображения графика доминирования требуется не менее 7 завершённых UTC-дней.", + "periodDaily": "По дням", + "periodWeekly": "По неделям", + "periodMonthly": "По месяцам", + "periodYearly": "По годам", + "switchPeriod": "Переключить на представление «{period}»", "networkHealth": "Здоровье сети", "nakamoto": "Коэффициент Накамото", "nakamotoLowerBound": "≥ {count}", @@ -2033,6 +2052,8 @@ "difficultyStable": "стабильная", "difficultyModerate": "умеренная", "difficultyVolatile": "волатильная", + "feeShare": "Доля комиссий в вознаграждении (24ч)", + "feeShareValue": "{fees} комиссии / {subsidy} хвостовая эмиссия", "emissionTitle": "Эмиссия / предложение", "circulatingSupply": "Циркулирующее предложение", "blockReward": "Награда за блок", diff --git a/prisma/migrations/20260711182455_add_mining_pool_miners_count/migration.sql b/prisma/migrations/20260711182455_add_mining_pool_miners_count/migration.sql new file mode 100644 index 00000000..7cabee07 --- /dev/null +++ b/prisma/migrations/20260711182455_add_mining_pool_miners_count/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "mining_pools" +ADD COLUMN "miners_count" INTEGER, +ADD COLUMN "miners_updated_at" TIMESTAMPTZ(6); diff --git a/prisma/migrations/20260711191517_add_monero_pool_daily_share/migration.sql b/prisma/migrations/20260711191517_add_monero_pool_daily_share/migration.sql new file mode 100644 index 00000000..8875e4da --- /dev/null +++ b/prisma/migrations/20260711191517_add_monero_pool_daily_share/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable +CREATE TABLE "monero_pool_daily_shares" ( + "id" SERIAL NOT NULL, + "chain_id" INTEGER NOT NULL, + "pool_id" INTEGER NOT NULL, + "date" DATE NOT NULL, + "blocks_found" INTEGER NOT NULL, + "share_percent" DOUBLE PRECISION NOT NULL, + "network_blocks" INTEGER NOT NULL, + "updated_at" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "monero_pool_daily_shares_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "monero_pool_daily_shares_chain_id_pool_id_date_key" +ON "monero_pool_daily_shares"("chain_id", "pool_id", "date"); + +-- CreateIndex +CREATE INDEX "monero_pool_daily_shares_chain_id_date_idx" +ON "monero_pool_daily_shares"("chain_id", "date"); + +-- AddForeignKey +ALTER TABLE "monero_pool_daily_shares" +ADD CONSTRAINT "monero_pool_daily_shares_chain_id_fkey" +FOREIGN KEY ("chain_id") REFERENCES "chains"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "monero_pool_daily_shares" +ADD CONSTRAINT "monero_pool_daily_shares_pool_id_fkey" +FOREIGN KEY ("pool_id") REFERENCES "mining_pools"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260711205000_add_github_dev_health/migration.sql b/prisma/migrations/20260711205000_add_github_dev_health/migration.sql new file mode 100644 index 00000000..b3b51b00 --- /dev/null +++ b/prisma/migrations/20260711205000_add_github_dev_health/migration.sql @@ -0,0 +1,23 @@ +-- AlterTable +ALTER TABLE "github_repositories" +ADD COLUMN "author_coverage_from" DATE, +ADD COLUMN "activity_fetched_through" DATE; + +-- AlterTable +ALTER TABLE "github_activities" +ADD COLUMN "author_logins" JSONB; + +-- CreateTable +CREATE TABLE "github_dev_health" ( + "chain_id" INTEGER NOT NULL, + "open_prs_count" INTEGER NOT NULL, + "open_issues_count" INTEGER NOT NULL, + "issues_updated_at" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "github_dev_health_pkey" PRIMARY KEY ("chain_id") +); + +-- AddForeignKey +ALTER TABLE "github_dev_health" +ADD CONSTRAINT "github_dev_health_chain_id_fkey" +FOREIGN KEY ("chain_id") REFERENCES "chains"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9ceeaf3e..26a6751b 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -131,6 +131,7 @@ model Chain { nodeVotes NodeVote[] @relation("node_vote_chain") proposals Proposal[] @relation("chain_proposals") githubRepositories GithubRepository[] @relation("chain_github_repositories") + githubDevHealth GithubDevHealth? tvsHistory ChainTvsHistory[] @relation("chain_tvs_history") aprHistory ChainAprHistory[] @relation("chain_apr_history") validatorsHistory ChainValidatorsHistory[] @relation("chain_validators_history") @@ -141,6 +142,7 @@ model Chain { miningPools MiningPool[] @relation("chain_mining_pools") miningPoolStats MiningPoolStats[] @relation("chain_mining_pool_stats") + moneroPoolDailyShares MoneroPoolDailyShare[] @relation("chain_monero_pool_daily_shares") hashrateSnapshots ChainHashrateSnapshot[] @relation("chain_hashrate_snapshots") moneroBlocks MoneroBlock[] @relation("chain_monero_blocks") moneroBlockAttributions MoneroBlockAttribution[] @relation("chain_monero_block_attributions") @@ -387,21 +389,23 @@ model NodeVote { } model GithubRepository { - id Int @id @default(autoincrement()) - chainId Int @map("chain_id") - name String - fullName String @map("full_name") - description String? - url String - homepage String? - language String? - starsCount Int @default(0) @map("stars_count") - forksCount Int @default(0) @map("forks_count") - watchersCount Int @default(0) @map("watchers_count") - openIssuesCount Int @default(0) @map("open_issues_count") - createdAt DateTime @map("created_at") @db.Timestamptz(6) - updatedAt DateTime @map("updated_at") @db.Timestamptz(6) - pushedAt DateTime @map("pushed_at") @db.Timestamptz(6) + id Int @id @default(autoincrement()) + chainId Int @map("chain_id") + name String + fullName String @map("full_name") + description String? + url String + homepage String? + language String? + starsCount Int @default(0) @map("stars_count") + forksCount Int @default(0) @map("forks_count") + watchersCount Int @default(0) @map("watchers_count") + openIssuesCount Int @default(0) @map("open_issues_count") + createdAt DateTime @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @map("updated_at") @db.Timestamptz(6) + pushedAt DateTime @map("pushed_at") @db.Timestamptz(6) + authorCoverageFrom DateTime? @map("author_coverage_from") @db.Date + activityFetchedThrough DateTime? @map("activity_fetched_through") @db.Date chain Chain @relation("chain_github_repositories", fields: [chainId], references: [id]) activities GithubActivity[] @relation("repository_activities") @@ -410,6 +414,17 @@ model GithubRepository { @@map("github_repositories") } +model GithubDevHealth { + chainId Int @id @map("chain_id") + openPrsCount Int @map("open_prs_count") + openIssuesCount Int @map("open_issues_count") + issuesUpdatedAt DateTime @map("issues_updated_at") @db.Timestamptz(6) + + chain Chain @relation(fields: [chainId], references: [id], onDelete: Cascade) + + @@map("github_dev_health") +} + model ChainTvsHistory { id Int @id @default(autoincrement()) chainId Int @map("chain_id") @@ -484,6 +499,7 @@ model GithubActivity { commitCount Int @default(0) @map("commit_count") additions Int @default(0) deletions Int @default(0) + authorLogins Json? @map("author_logins") repository GithubRepository @relation("repository_activities", fields: [repositoryId], references: [id], onDelete: Cascade) @@ -589,25 +605,28 @@ model PodcastChunk { } 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") + 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") + paymentScheme String? @map("payment_scheme") @db.VarChar(64) + feePercent Float? @map("fee_percent") + minersCount Int? @map("miners_count") + minersUpdatedAt DateTime? @map("miners_updated_at") @db.Timestamptz(6) + 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) + 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") + dailyShares MoneroPoolDailyShare[] @relation("mining_pool_daily_shares") attributions MoneroBlockAttribution[] @relation("mining_pool_attributions") @@unique([chainId, slug]) @@ -635,6 +654,24 @@ model MiningPoolStats { @@map("mining_pool_stats") } +model MoneroPoolDailyShare { + id Int @id @default(autoincrement()) + chainId Int @map("chain_id") + poolId Int @map("pool_id") + date DateTime @db.Date + blocksFound Int @map("blocks_found") + sharePercent Float @map("share_percent") + networkBlocks Int @map("network_blocks") + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + + chain Chain @relation("chain_monero_pool_daily_shares", fields: [chainId], references: [id]) + pool MiningPool @relation("mining_pool_daily_shares", fields: [poolId], references: [id]) + + @@unique([chainId, poolId, date]) + @@index([chainId, date]) + @@map("monero_pool_daily_shares") +} + model ChainHashrateSnapshot { id Int @id @default(autoincrement()) chainId Int @map("chain_id") diff --git a/scripts/verify-github-dev-health.ts b/scripts/verify-github-dev-health.ts new file mode 100644 index 00000000..3f909596 --- /dev/null +++ b/scripts/verify-github-dev-health.ts @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; + +import { selectAuthorBackfillFullNames } from '@/server/jobs/update-github-repositories'; +import { getDailyCommits, getOpenPullRequestsCountByOwner } from '@/server/tools/github-api'; +import { getActiveContributorsCount, type GithubContributorRepository } from '@/services/github-service'; +import { getGithubContributorWindow, toUtcDateString } from '@/utils/github-dev-health'; + +const originalFetch = global.fetch; + +const jsonResponse = (body: unknown, status: number = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '100' }, + }); + +const rateLimitResponse = (): Response => + jsonResponse({ resources: { core: { limit: 5000, remaining: 4999, reset: 0 } } }); + +const verifyPullRequestCounts = async () => { + global.fetch = async () => jsonResponse({ total_count: 17, incomplete_results: false }); + assert.equal(await getOpenPullRequestsCountByOwner('example'), 17); + + global.fetch = async () => jsonResponse({ total_count: 17, incomplete_results: true }); + assert.equal(await getOpenPullRequestsCountByOwner('example'), null); + + global.fetch = async () => jsonResponse({ total_count: '17', incomplete_results: false }); + assert.equal(await getOpenPullRequestsCountByOwner('example'), null); + + global.fetch = async () => jsonResponse({ message: 'not found' }, 404); + assert.equal(await getOpenPullRequestsCountByOwner('example'), null); +}; + +const verifyCommitExtraction = async () => { + const responses = [ + rateLimitResponse(), + jsonResponse([ + { + author: { login: 'alice' }, + commit: { author: { date: '2026-07-10T08:00:00.000Z' } }, + }, + { + author: { login: 'alice' }, + commit: { author: { date: '2026-07-10T09:00:00.000Z' } }, + }, + { + author: { login: 'dependabot[bot]' }, + commit: { author: { date: '2026-07-10T10:00:00.000Z' } }, + }, + { + author: { login: 'UPDATER[BOT]' }, + commit: { author: { date: '2026-07-10T11:00:00.000Z' } }, + }, + { + author: null, + commit: { author: { date: '2026-07-11T01:00:00.000Z' } }, + }, + ]), + ]; + global.fetch = async () => responses.shift() ?? jsonResponse([]); + + const result = await getDailyCommits('example', 'repo', '2026-07-10', '2026-07-11'); + assert.equal(result.complete, true); + assert.deepEqual(result.days.get('2026-07-10'), { commitCount: 4, authorLogins: ['alice'] }); + assert.deepEqual(result.days.get('2026-07-11'), { commitCount: 1, authorLogins: [] }); +}; + +const verifyPartialCommitResult = async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => ({ + author: { login: index % 2 === 0 ? 'alice' : 'bob' }, + commit: { author: { date: '2026-07-10T08:00:00.000Z' } }, + })); + const responses = [rateLimitResponse(), jsonResponse(firstPage), rateLimitResponse(), jsonResponse({}, 500)]; + global.fetch = async () => responses.shift() ?? jsonResponse([]); + + const result = await getDailyCommits('example', 'repo', '2026-07-10', '2026-07-10'); + assert.equal(result.complete, false); + assert.deepEqual(result.days.get('2026-07-10'), { + commitCount: 100, + authorLogins: ['alice', 'bob'], + }); +}; + +const verifyBackfillSelection = () => { + const cutoff = new Date('2026-06-11T00:00:00.000Z'); + const candidates = [ + { fullName: 'org/a', pushedAt: new Date('2026-07-10'), authorCoverageFrom: null }, + { fullName: 'org/a', pushedAt: new Date('2026-07-09'), authorCoverageFrom: null }, + { fullName: 'org/b', pushedAt: new Date('2026-07-08'), authorCoverageFrom: new Date('2026-07-01') }, + { fullName: 'org/c', pushedAt: new Date('2026-01-01'), authorCoverageFrom: null }, + ]; + + assert.deepEqual(selectAuthorBackfillFullNames(candidates, cutoff, 2), ['org/a', 'org/b']); + + const window = getGithubContributorWindow(new Date('2026-07-11T12:00:00.000Z')); + assert.equal(toUtcDateString(window.completedThrough), '2026-07-10'); + assert.equal(toUtcDateString(window.cutoff), '2026-06-11'); +}; + +const verifyActiveContributorUnion = () => { + const coverageFrom = new Date('2026-06-11T00:00:00.000Z'); + const fetchedThrough = new Date('2026-07-10T00:00:00.000Z'); + const repositories: GithubContributorRepository[] = [ + { + pushedAt: new Date('2026-07-09T00:00:00.000Z'), + authorCoverageFrom: coverageFrom, + activityFetchedThrough: fetchedThrough, + activities: [ + { date: new Date('2026-06-10T00:00:00.000Z'), authorLogins: ['outside-window'] }, + { date: new Date('2026-07-09T00:00:00.000Z'), authorLogins: ['alice', 'bob', 'release[bot]'] }, + ], + }, + { + pushedAt: new Date('2026-07-08T00:00:00.000Z'), + authorCoverageFrom: coverageFrom, + activityFetchedThrough: fetchedThrough, + activities: [ + { date: new Date('2026-07-08T00:00:00.000Z'), authorLogins: ['bob', 'carol', 42] }, + ], + }, + { + pushedAt: new Date('2026-01-01T00:00:00.000Z'), + authorCoverageFrom: null, + activityFetchedThrough: null, + activities: [], + }, + ]; + + const now = new Date('2026-07-12T12:00:00.000Z'); + assert.equal(getActiveContributorsCount(repositories, now), 3); + + const incompleteRepositories = repositories.map((repository, index) => + index === 0 ? { ...repository, authorCoverageFrom: null } : repository, + ); + assert.equal(getActiveContributorsCount(incompleteRepositories, now), null); + + const staleRepositories = repositories.map((repository, index) => + index < 2 ? { ...repository, activityFetchedThrough: new Date('2026-07-09T00:00:00.000Z') } : repository, + ); + assert.equal(getActiveContributorsCount(staleRepositories, now), null); +}; + +const run = async () => { + try { + await verifyPullRequestCounts(); + await verifyCommitExtraction(); + await verifyPartialCommitResult(); + verifyBackfillSelection(); + verifyActiveContributorUnion(); + console.log('GitHub dev-health verification passed'); + } finally { + global.fetch = originalFetch; + } +}; + +void run(); diff --git a/scripts/verify-monero-pool-miners.ts b/scripts/verify-monero-pool-miners.ts new file mode 100644 index 00000000..34a6e4dc --- /dev/null +++ b/scripts/verify-monero-pool-miners.ts @@ -0,0 +1,37 @@ +import { + fetchPoolStats, + getPoolRegistry, +} from '@/server/tools/chains/monero/pool-client'; + +const MINIMUM_SUCCESSFUL_POOLS = 4; +const REQUIRED_POOLS = ['supportxmr', 'hashvault'] as const; + +const verifyMoneroPoolMiners = async (): Promise => { + const configuredPools = getPoolRegistry().filter((pool) => pool.statsUrl !== null); + const results = await Promise.all( + configuredPools.map(async (pool) => { + const stats = await fetchPoolStats(pool); + return { key: pool.key, miners: stats?.miners ?? null, hashRate: stats?.hashRate ?? null }; + }), + ); + + console.table(results); + + const successfulPools = results.filter((result) => result.miners !== null); + const missingRequiredPools = REQUIRED_POOLS.filter( + (requiredPool) => !successfulPools.some((result) => result.key === requiredPool), + ); + + if (successfulPools.length < MINIMUM_SUCCESSFUL_POOLS || missingRequiredPools.length > 0) { + throw new Error( + `Pool miners verification failed: successful=${successfulPools.length}, missingRequired=${missingRequiredPools.join(',') || 'none'}`, + ); + } + + console.log(`Verified connected-miner counts for ${successfulPools.length}/${configuredPools.length} configured pools.`); +}; + +void verifyMoneroPoolMiners().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/server/indexer.ts b/server/indexer.ts index c23bafb9..eb577d49 100755 --- a/server/indexer.ts +++ b/server/indexer.ts @@ -163,6 +163,7 @@ const runServer = async () => { { name: 'monero-network-info', schedule: timers.every5mins }, { name: 'monero-pool-attribution', schedule: timers.every10mins }, { name: 'monero-pool-stats', schedule: timers.everyHour }, + { name: 'monero-pool-daily-share', schedule: dailyAt(22, 23) }, ]; specialTasks.forEach(({ name, schedule }) => { diff --git a/server/jobs/AGENTS.md b/server/jobs/AGENTS.md index ada38996..f40fa0b4 100644 --- a/server/jobs/AGENTS.md +++ b/server/jobs/AGENTS.md @@ -47,9 +47,10 @@ | `update-average-delegation.ts` | Calculates average delegation amount across validators | | `update-unbonding-tokens.ts` | Updates tokens currently in unbonding period | | `update-staking-page-json.ts` | Fetches networks.json from GitHub for staking page | -| `update-github-repositories.ts` | Syncs GitHub repository data and commit activity | +| `update-github-repositories.ts` | Syncs GitHub repository data, owner-level issue/PR health, commit activity, and bounded author coverage | | `check-nodes-health.ts` | Health checks for nodes | | `match-chain-nodes.ts` | Matches nodes to validators | +| `monero-pool-daily-share.ts` | Materializes completed UTC-day Monero pool dominance history from persisted blocks and attributions | ## Dependencies @@ -186,4 +187,4 @@ const timers = { ### 5. Task Types - **Regular tasks**: Run immediately on startup + scheduled -- **Special tasks**: Run 5 minutes after startup (wait for validators to update first) \ No newline at end of file +- **Special tasks**: Run 5 minutes after startup (wait for validators to update first) diff --git a/server/jobs/monero-pool-daily-share.ts b/server/jobs/monero-pool-daily-share.ts new file mode 100644 index 00000000..aef8f89d --- /dev/null +++ b/server/jobs/monero-pool-daily-share.ts @@ -0,0 +1,189 @@ +import db from '@/db'; +import logger from '@/logger'; +import { + UNKNOWN_POOL_NAME, + UNKNOWN_POOL_SLUG, +} from '@/server/tools/chains/monero/attribution-source'; + +const { logInfo, logError, logWarn } = logger('monero-pool-daily-share'); + +const DAY_MS = 24 * 60 * 60 * 1000; + +const startOfUtcDay = (date: Date): Date => + new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + +const addUtcDays = (date: Date, days: number): Date => new Date(date.getTime() + days * DAY_MS); + +const dateKey = (date: Date): string => date.toISOString().slice(0, 10); + +const updateMoneroPoolDailyShare = async (): Promise => { + logInfo('Starting Monero pool daily-share update'); + + try { + const chain = await db.chain.findUnique({ where: { name: 'monero' }, select: { id: true } }); + if (!chain) { + logWarn('Chain "monero" not found, skipping'); + return; + } + + const historyStartRow = await db.moneroBlock.findFirst({ + where: { chainId: chain.id, isCanonical: true }, + orderBy: { blockTimestamp: 'asc' }, + select: { blockTimestamp: true }, + }); + if (!historyStartRow) { + logWarn('No canonical MoneroBlock history, skipping'); + return; + } + + const today = startOfUtcDay(new Date()); + const yesterday = addUtcDays(today, -1); + const historyStart = startOfUtcDay(historyStartRow.blockTimestamp); + if (historyStart > yesterday) { + logInfo('No completed UTC day in MoneroBlock history, skipping'); + return; + } + + const lastStored = await db.moneroPoolDailyShare.findFirst({ + where: { chainId: chain.id }, + orderBy: { date: 'desc' }, + select: { date: true }, + }); + + // Resume from the earliest day that still needs computing, not merely from the + // latest stored day. If an interior day's transaction threw on an earlier run, + // MAX(date) would advance past it and that day would never be revisited — a + // permanent hole in the series. Walk forward and stop at the first day that has + // canonical blocks but no stored rows. + const storedRows = await db.moneroPoolDailyShare.findMany({ + where: { chainId: chain.id }, + distinct: ['date'], + select: { date: true }, + }); + const storedDays = new Set(storedRows.map((row) => dateKey(startOfUtcDay(row.date)))); + + let firstGap: Date | null = null; + for (let day = historyStart; day <= yesterday; day = addUtcDays(day, 1)) { + if (storedDays.has(dateKey(day))) continue; + // A day with no canonical blocks legitimately has no rows — skip it, don't chase it forever. + const dayBlocks = await db.moneroBlock.count({ + where: { chainId: chain.id, isCanonical: true, blockTimestamp: { gte: day, lt: addUtcDays(day, 1) } }, + }); + if (dayBlocks > 0) { + firstGap = day; + break; + } + } + + // Keep a two-day overlap on the tail for reorg-driven recomputes of recent days. + const overlapStart = lastStored ? addUtcDays(startOfUtcDay(lastStored.date), -1) : historyStart; + const resumeCandidate = firstGap + ? new Date(Math.min(firstGap.getTime(), overlapStart.getTime())) + : overlapStart; + const recomputeStart = new Date( + Math.max(historyStart.getTime(), Math.min(resumeCandidate.getTime(), yesterday.getTime())), + ); + + const existingPools = await db.miningPool.findMany({ + where: { chainId: chain.id }, + select: { id: true, slug: true }, + }); + let unknownPool = existingPools.find((pool) => pool.slug === UNKNOWN_POOL_SLUG); + if (!unknownPool) { + unknownPool = await db.miningPool.upsert({ + where: { chainId_slug: { chainId: chain.id, slug: UNKNOWN_POOL_SLUG } }, + update: {}, + create: { + chainId: chain.id, + slug: UNKNOWN_POOL_SLUG, + name: UNKNOWN_POOL_NAME, + identificationMethod: 'unknown', + isVerified: false, + }, + select: { id: true, slug: true }, + }); + } + const namedPools = existingPools.filter((pool) => pool.slug !== UNKNOWN_POOL_SLUG); + + for (let day = recomputeStart; day <= yesterday; day = addUtcDays(day, 1)) { + const dayStart = new Date(day); + const dayEnd = addUtcDays(dayStart, 1); + + try { + const networkBlockRows = await db.moneroBlock.findMany({ + where: { + chainId: chain.id, + isCanonical: true, + blockTimestamp: { gte: dayStart, lt: dayEnd }, + }, + select: { blockHash: true }, + }); + const networkBlocks = networkBlockRows.length; + + if (networkBlocks === 0) { + logWarn(`${dateKey(dayStart)}: no canonical MoneroBlock rows, skipping day`); + continue; + } + + const attributed = await db.moneroBlockAttribution.findMany({ + where: { + chainId: chain.id, + poolId: { not: unknownPool.id }, + blockHash: { in: networkBlockRows.map((row) => row.blockHash) }, + isCanonical: true, + isConflicted: false, + }, + select: { poolId: true }, + }); + const blocksByPool = new Map(); + for (const row of attributed) { + blocksByPool.set(row.poolId, (blocksByPool.get(row.poolId) ?? 0) + 1); + } + const attributedBlocks = attributed.length; + if (attributedBlocks > networkBlocks) { + logWarn( + `${dateKey(dayStart)}: attributed blocks (${attributedBlocks}) exceed network blocks (${networkBlocks}); unknown clamped to 0`, + ); + } + const unknownBlocks = Math.max(0, networkBlocks - attributedBlocks); + const dailyRows = [ + ...namedPools.map((pool) => ({ poolId: pool.id, blocksFound: blocksByPool.get(pool.id) ?? 0 })), + { poolId: unknownPool.id, blocksFound: unknownBlocks }, + ]; + + await db.$transaction( + dailyRows.map(({ poolId, blocksFound }) => + db.moneroPoolDailyShare.upsert({ + where: { chainId_poolId_date: { chainId: chain.id, poolId, date: dayStart } }, + update: { + blocksFound, + sharePercent: (blocksFound / networkBlocks) * 100, + networkBlocks, + }, + create: { + chainId: chain.id, + poolId, + date: dayStart, + blocksFound, + sharePercent: (blocksFound / networkBlocks) * 100, + networkBlocks, + }, + }), + ), + ); + + logInfo( + `${dateKey(dayStart)}: network=${networkBlocks} attributed=${attributedBlocks} unknown=${unknownBlocks} pools=${dailyRows.length}`, + ); + } catch (error) { + logError(`${dateKey(dayStart)}: daily-share update failed`, error); + } + } + + logInfo('Monero pool daily-share update completed'); + } catch (error) { + logError('Monero pool daily-share update failed', error); + } +}; + +export default updateMoneroPoolDailyShare; diff --git a/server/jobs/monero-pool-stats.ts b/server/jobs/monero-pool-stats.ts index 64be3b75..dec08855 100644 --- a/server/jobs/monero-pool-stats.ts +++ b/server/jobs/monero-pool-stats.ts @@ -6,6 +6,7 @@ import { UNKNOWN_POOL_SLUG, } from '@/server/tools/chains/monero/attribution-source'; import { getTipBlock } from '@/server/tools/chains/monero/indexer-client'; +import { fetchPoolStats, getPoolRegistry } from '@/server/tools/chains/monero/pool-client'; const { logInfo, logError, logWarn } = logger('monero-pool-stats'); @@ -42,13 +43,6 @@ const updateMoneroPoolStats = async (): Promise => { } 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) { @@ -63,6 +57,46 @@ const updateMoneroPoolStats = async (): Promise => { const namedPools = pools.filter((p) => p.slug !== UNKNOWN_POOL_SLUG); const now = new Date(); + const registryBySlug = new Map(getPoolRegistry().map((pool) => [pool.key, pool])); + + const minerUpdateResults = await Promise.all( + namedPools.map(async (pool) => { + const registryPool = registryBySlug.get(pool.slug); + if (!registryPool) { + logWarn(`pool=${pool.slug} missing from registry, skipping live miners`); + return false; + } + if (!registryPool.statsUrl) return false; + + try { + const stats = await fetchPoolStats(registryPool); + const miners = stats?.miners; + if (miners == null || !Number.isSafeInteger(miners) || miners <= 0) { + logWarn(`pool=${pool.slug} live miners unavailable, keeping previous value`); + return false; + } + + await db.miningPool.update({ + where: { id: pool.id }, + data: { minersCount: miners, minersUpdatedAt: now }, + }); + return true; + } catch (e) { + logWarn(`pool=${pool.slug} live miners update failed: ${e instanceof Error ? e.message : String(e)}`); + return false; + } + }), + ); + const configuredPoolCount = namedPools.filter((pool) => registryBySlug.get(pool.slug)?.statsUrl).length; + const updatedMinerPools = minerUpdateResults.filter(Boolean).length; + logInfo(`monero-pool-stats miners: updated=${updatedMinerPools}/${configuredPoolCount}`); + + const tip = await getTipBlock(); + if (!tip || tip.height <= 0) { + logWarn('No usable tip block, skipping window stats after processing live miners'); + return; + } + const tipHeight = tip.height; for (const window of WINDOWS) { // Height-based window bound + timestamps for the stored row. diff --git a/server/jobs/update-github-repositories.ts b/server/jobs/update-github-repositories.ts index 70df81be..3e3be984 100644 --- a/server/jobs/update-github-repositories.ts +++ b/server/jobs/update-github-repositories.ts @@ -1,171 +1,321 @@ +import type { GithubRepository as StoredGithubRepository } from '@prisma/client'; + import db from '@/db'; import logger from '@/logger'; import { getChainParams } from '@/server/tools/chains/params'; -import { type GithubRepository, getDailyCommits, getOrganizationRepos } from '@/server/tools/github-api'; +import { + type GithubDailyCommitsResult, + type GithubRepository, + getDailyCommits, + getOpenPullRequestsCountByOwner, + getOrganizationRepos, +} from '@/server/tools/github-api'; import { getGithubOrganization, isValidGithubUrl } from '@/server/utils/parse-github-url'; +import { addUtcDays, getGithubContributorWindow, toUtcDateString } from '@/utils/github-dev-health'; -const { logInfo, logError } = logger('update-github-repositories'); +const { logInfo, logError, logWarn } = logger('update-github-repositories'); -const updateGithubRepositories = async (chainNames: string[]) => { - for (const chainName of chainNames) { - const chain = getChainParams(chainName); +export const MAX_AUTHOR_BACKFILLS_PER_RUN = 50; +const FULL_ACTIVITY_WINDOW_DAYS = 365; - const dbChain = await db.chain.findFirst({ - where: { chainId: chain.chainId }, - }); - if (!dbChain) { - logError(`Chain ${chain.chainId} not found in database`); - continue; - } +interface AuthorBackfillCandidate { + fullName: string; + pushedAt: Date; + authorCoverageFrom: Date | null; +} - let totalReposProcessed = 0; +interface RepositoryProcessingContext { + authorBackfillFullNames: Set; + authorCutoff: Date; + completedThrough: Date; + commitCache: Map>; +} - try { - logInfo(`Processing chain: ${chain.name}`); +export const selectAuthorBackfillFullNames = ( + candidates: AuthorBackfillCandidate[], + cutoff: Date, + limit: number = MAX_AUTHOR_BACKFILLS_PER_RUN, +): string[] => { + const sortedCandidates = [...candidates] + .filter((candidate) => candidate.pushedAt >= cutoff) + .sort((a, b) => { + const aCoverage = a.authorCoverageFrom?.getTime() ?? Number.NEGATIVE_INFINITY; + const bCoverage = b.authorCoverageFrom?.getTime() ?? Number.NEGATIVE_INFINITY; + if (aCoverage !== bCoverage) return aCoverage - bCoverage; - if (!isValidGithubUrl(chain.githubUrl)) { - logError(`Invalid GitHub URL for ${chain.name}: ${chain.githubUrl}`); - continue; - } + const pushedComparison = b.pushedAt.getTime() - a.pushedAt.getTime(); + return pushedComparison || a.fullName.localeCompare(b.fullName); + }); - const orgName = getGithubOrganization(chain.githubUrl); - logInfo(`Fetching repositories for organization: ${orgName}`); + const fullNames: string[] = []; + const seen = new Set(); + for (const candidate of sortedCandidates) { + if (seen.has(candidate.fullName)) continue; + seen.add(candidate.fullName); + fullNames.push(candidate.fullName); + if (fullNames.length >= limit) break; + } + return fullNames; +}; - const repos = await getOrganizationRepos(orgName); +const getAuthorBackfillFullNames = async (cutoff: Date): Promise> => { + const candidates = await db.githubRepository.findMany({ + where: { + pushedAt: { gte: cutoff }, + OR: [{ authorCoverageFrom: null }, { authorCoverageFrom: { gt: cutoff } }], + }, + select: { fullName: true, pushedAt: true, authorCoverageFrom: true }, + }); - if (repos.length === 0) { - logError(`No repositories found for ${chain.name} (${orgName})`); - continue; - } + return new Set(selectAuthorBackfillFullNames(candidates, cutoff)); +}; - logInfo(`Found ${repos.length} repositories for ${chain.name}`); +const getCachedOrganizationRepos = ( + owner: string, + cache: Map>, +): Promise => { + const cached = cache.get(owner); + if (cached) return cached; - for (const repo of repos) { - try { - await processRepository(dbChain.id, repo); - totalReposProcessed++; - } catch (e) { - logError(`Failed to process repository ${repo.full_name}:`, e); - continue; - } - } - } catch (e) { - logError(`Failed to process chain ${chain.name}:`, e); - continue; + const request = getOrganizationRepos(owner); + cache.set(owner, request); + return request; +}; + +const updateGithubDevHealth = async ( + chainId: number, + owner: string, + repositories: GithubRepository[], + startOfToday: Date, + pullCountCache: Map>, +): Promise => { + const currentSnapshot = await db.githubDevHealth.findUnique({ where: { chainId } }); + if (currentSnapshot?.issuesUpdatedAt && currentSnapshot.issuesUpdatedAt >= startOfToday) { + if (!pullCountCache.has(owner)) { + pullCountCache.set(owner, Promise.resolve(currentSnapshot.openPrsCount)); } + return; } - logInfo(`GitHub repositories update completed.`); -}; + const cachedPullCount = pullCountCache.get(owner); + const pullCountRequest = cachedPullCount ?? getOpenPullRequestsCountByOwner(owner); + if (!cachedPullCount) pullCountCache.set(owner, pullCountRequest); -const processRepository = async (chainId: number, repo: GithubRepository): Promise => { - const dbRepo = await db.githubRepository.upsert({ - where: { - chainId_fullName: { chainId, fullName: repo.full_name }, - }, + const openPrsCount = await pullCountRequest; + if (openPrsCount === null) return; + + const rawOpenIssues = repositories.reduce((sum, repository) => sum + repository.open_issues_count, 0); + await db.githubDevHealth.upsert({ + where: { chainId }, create: { chainId, - name: repo.name, - fullName: repo.full_name, - description: repo.description, - url: repo.html_url, - homepage: repo.homepage, - language: repo.language, - starsCount: repo.stargazers_count, - forksCount: repo.forks_count, - watchersCount: repo.watchers_count, - openIssuesCount: repo.open_issues_count, - createdAt: new Date(repo.created_at), - updatedAt: new Date(repo.updated_at), - pushedAt: new Date(repo.pushed_at), + openPrsCount, + openIssuesCount: Math.max(0, rawOpenIssues - openPrsCount), + issuesUpdatedAt: new Date(), }, update: { - name: repo.name, - description: repo.description, - url: repo.html_url, - homepage: repo.homepage, - language: repo.language, - starsCount: repo.stargazers_count, - forksCount: repo.forks_count, - watchersCount: repo.watchers_count, - openIssuesCount: repo.open_issues_count, - updatedAt: new Date(repo.updated_at), - pushedAt: new Date(repo.pushed_at), + openPrsCount, + openIssuesCount: Math.max(0, rawOpenIssues - openPrsCount), + issuesUpdatedAt: new Date(), }, }); - - await fetchAndStoreActivity(dbRepo.id, repo.full_name); }; -const fetchAndStoreActivity = async (repositoryId: number, fullName: string): Promise => { - try { - const [owner, repo] = fullName.split('/'); +const getLegacyActivityStart = async (repositoryId: number, completedThrough: Date): Promise => { + const mostRecentActivity = await db.githubActivity.findFirst({ + where: { repositoryId, date: { lte: completedThrough } }, + select: { date: true }, + orderBy: { date: 'desc' }, + }); - const endDate = new Date(); - const startDate = new Date(); - startDate.setDate(startDate.getDate() - 365); + if (!mostRecentActivity) return addUtcDays(completedThrough, -(FULL_ACTIVITY_WINDOW_DAYS - 1)); - const startDateStr = startDate.toISOString().split('T')[0]; - const endDateStr = endDate.toISOString().split('T')[0]; + const daysSinceLastUpdate = Math.floor( + (completedThrough.getTime() - mostRecentActivity.date.getTime()) / (1000 * 60 * 60 * 24), + ); + if (daysSinceLastUpdate === 0) return null; + if (daysSinceLastUpdate <= 30) return addUtcDays(mostRecentActivity.date, 1); - const existingDates = await db.githubActivity.findMany({ - where: { repositoryId }, - select: { date: true }, - }); + logInfo(`Repository ${repositoryId}: activity is ${daysSinceLastUpdate} days old, doing full refresh`); + return addUtcDays(completedThrough, -(FULL_ACTIVITY_WINDOW_DAYS - 1)); +}; - let actualStartDate = startDateStr; - let skipFetch = false; - - if (existingDates.length > 0) { - const mostRecentDate = new Date(Math.max(...existingDates.map((d) => d.date.getTime()))); - const daysSinceLastUpdate = Math.floor((endDate.getTime() - mostRecentDate.getTime()) / (1000 * 60 * 60 * 24)); - - if (daysSinceLastUpdate === 0) { - skipFetch = true; - } else if (daysSinceLastUpdate <= 30) { - const nextDay = new Date(mostRecentDate); - nextDay.setDate(nextDay.getDate() + 1); - actualStartDate = nextDay.toISOString().split('T')[0]; - } else { - logInfo(`${fullName}: Data is ${daysSinceLastUpdate} days old, doing full 365-day refresh`); - } - } +const getActivityStart = async ( + repository: StoredGithubRepository, + shouldBackfillAuthors: boolean, + authorCutoff: Date, + completedThrough: Date, +): Promise => { + const incrementalStart = repository.activityFetchedThrough + ? addUtcDays(repository.activityFetchedThrough, 1) + : await getLegacyActivityStart(repository.id, completedThrough); - if (skipFetch) { - return; - } + if (!shouldBackfillAuthors) { + return incrementalStart && incrementalStart <= completedThrough ? incrementalStart : null; + } - const dailyCommits = await getDailyCommits(owner, repo, actualStartDate, endDateStr); + if (!incrementalStart) return authorCutoff; + return incrementalStart < authorCutoff ? incrementalStart : authorCutoff; +}; - if (dailyCommits.size === 0) { - return; - } +const getCachedDailyCommits = ( + fullName: string, + startDate: string, + endDate: string, + cache: Map>, +): Promise => { + const key = `${fullName}:${startDate}:${endDate}`; + const cached = cache.get(key); + if (cached) return cached; - for (const [dateStr, commitCount] of Array.from(dailyCommits.entries())) { - const date = new Date(dateStr); + const [owner, repository] = fullName.split('/'); + const request = getDailyCommits(owner, repository, startDate, endDate); + cache.set(key, request); + return request; +}; - await db.githubActivity.upsert({ - where: { - repositoryId_date: { - repositoryId, - date, - }, - }, +const fetchAndStoreActivity = async ( + repository: StoredGithubRepository, + context: RepositoryProcessingContext, +): Promise => { + try { + const shouldBackfillAuthors = context.authorBackfillFullNames.has(repository.fullName); + const start = await getActivityStart( + repository, + shouldBackfillAuthors, + context.authorCutoff, + context.completedThrough, + ); + if (!start) return; + + const startDate = toUtcDateString(start); + const endDate = toUtcDateString(context.completedThrough); + const result = await getCachedDailyCommits(repository.fullName, startDate, endDate, context.commitCache); + const activityWrites = Array.from(result.days.entries()).map(([date, activity]) => + db.githubActivity.upsert({ + where: { repositoryId_date: { repositoryId: repository.id, date: new Date(`${date}T00:00:00.000Z`) } }, create: { - repositoryId, - date, - commitCount, + repositoryId: repository.id, + date: new Date(`${date}T00:00:00.000Z`), + commitCount: activity.commitCount, additions: 0, deletions: 0, + authorLogins: activity.authorLogins, }, - update: { - commitCount, - }, - }); + update: { commitCount: activity.commitCount, authorLogins: activity.authorLogins }, + }), + ); + + if (!result.complete) { + if (activityWrites.length > 0) await db.$transaction(activityWrites); + logWarn(`Partial GitHub activity result for ${repository.fullName}; coverage was not advanced`); + return; + } + + const currentCoverage = repository.authorCoverageFrom; + const authorCoverageFrom = currentCoverage && currentCoverage < start ? currentCoverage : start; + await db.$transaction([ + ...activityWrites, + db.githubRepository.update({ + where: { id: repository.id }, + data: { authorCoverageFrom, activityFetchedThrough: context.completedThrough }, + }), + ]); + } catch (error) { + logError(`Failed to fetch/store activity for ${repository.fullName}:`, error); + } +}; + +const processRepository = async ( + chainId: number, + repository: GithubRepository, + context: RepositoryProcessingContext, +): Promise => { + const storedRepository = await db.githubRepository.upsert({ + where: { chainId_fullName: { chainId, fullName: repository.full_name } }, + create: { + chainId, + name: repository.name, + fullName: repository.full_name, + description: repository.description, + url: repository.html_url, + homepage: repository.homepage, + language: repository.language, + starsCount: repository.stargazers_count, + forksCount: repository.forks_count, + watchersCount: repository.watchers_count, + openIssuesCount: repository.open_issues_count, + createdAt: new Date(repository.created_at), + updatedAt: new Date(repository.updated_at), + pushedAt: new Date(repository.pushed_at), + }, + update: { + name: repository.name, + description: repository.description, + url: repository.html_url, + homepage: repository.homepage, + language: repository.language, + starsCount: repository.stargazers_count, + forksCount: repository.forks_count, + watchersCount: repository.watchers_count, + openIssuesCount: repository.open_issues_count, + updatedAt: new Date(repository.updated_at), + pushedAt: new Date(repository.pushed_at), + }, + }); + + await fetchAndStoreActivity(storedRepository, context); +}; + +const updateGithubRepositories = async (chainNames: string[]) => { + const { cutoff: authorCutoff, completedThrough } = getGithubContributorWindow(); + const authorBackfillFullNames = await getAuthorBackfillFullNames(authorCutoff); + const context: RepositoryProcessingContext = { + authorBackfillFullNames, + authorCutoff, + completedThrough, + commitCache: new Map(), + }; + const repositoryCache = new Map>(); + const pullCountCache = new Map>(); + const startOfToday = addUtcDays(completedThrough, 1); + + for (const chainName of chainNames) { + try { + const chain = getChainParams(chainName); + const dbChain = await db.chain.findFirst({ where: { chainId: chain.chainId } }); + if (!dbChain) { + logError(`Chain ${chain.chainId} not found in database`); + continue; + } + if (!isValidGithubUrl(chain.githubUrl)) { + logError(`Invalid GitHub URL for ${chain.name}: ${chain.githubUrl}`); + continue; + } + + const owner = getGithubOrganization(chain.githubUrl); + const repositories = await getCachedOrganizationRepos(owner, repositoryCache); + if (repositories.length === 0) { + logError(`No repositories found for ${chain.name} (${owner})`); + continue; + } + + logInfo(`Processing ${repositories.length} repositories for ${chain.name} (${owner})`); + await updateGithubDevHealth(dbChain.id, owner, repositories, startOfToday, pullCountCache); + + for (const repository of repositories) { + try { + await processRepository(dbChain.id, repository, context); + } catch (error) { + logError(`Failed to process repository ${repository.full_name}:`, error); + } + } + } catch (error) { + logError(`Failed to process chain ${chainName}:`, error); } - } catch (e) { - logError(`Failed to fetch/store activity for ${fullName}:`, e); } + + logInfo('GitHub repositories update completed.'); }; export default updateGithubRepositories; diff --git a/server/task-worker.ts b/server/task-worker.ts index f6196b13..7d794ae5 100755 --- a/server/task-worker.ts +++ b/server/task-worker.ts @@ -39,6 +39,7 @@ 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 updateMoneroPoolDailyShare from '@/server/jobs/monero-pool-daily-share'; import updateMoneroPoolStats from '@/server/jobs/monero-pool-stats'; import updateNodesCommissions from '@/server/jobs/update-nodes-commissions'; import updateNodesRewards from '@/server/jobs/update-nodes-rewards'; @@ -225,6 +226,9 @@ async function runTask() { case 'monero-pool-stats': await updateMoneroPoolStats(); break; + case 'monero-pool-daily-share': + await updateMoneroPoolDailyShare(); + break; default: throw new Error(`Unknown task: ${taskName}`); } diff --git a/server/tools/chains/monero/AGENTS.md b/server/tools/chains/monero/AGENTS.md index e8df5bcb..5e5e6a9e 100644 --- a/server/tools/chains/monero/AGENTS.md +++ b/server/tools/chains/monero/AGENTS.md @@ -43,6 +43,7 @@ by hash against the indexer's canonical set, and count per-pool share. VI 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 | +| `monero-pool-daily-share` | daily at 22:23 | Idempotent completed-UTC-day catch-up over persisted `MoneroBlock`/`MoneroBlockAttribution`; transactional per-day pool rows, zero rows for named pools, residual unknown, and a 2-day recompute overlap | Deleted (do NOT re-add): `monero-pool-discover`, `monero-pool-cluster`, `monero-pool-identify`, `rpc-client.ts`, `identify-pool.ts` — all coinbase- @@ -52,7 +53,7 @@ fingerprint machinery, proven non-viable. `ChainHashrateSnapshot` (hashrate/difficulty time series), `Tokenomics.totalSupply` (raw atomic, emission-only — UI divides by `10^coinDecimals`=12), `MiningPool`, -`MiningPoolStats` (windowed share), `MoneroBlockAttribution` (per-block +`MiningPoolStats` (windowed share), `MoneroPoolDailyShare` (materialized completed-day share history), `MoneroBlockAttribution` (per-block hash→pool, `isCanonical`/`isConflicted`). UI reads these via `monero-service.ts`. ## Constraints diff --git a/server/tools/github-api.ts b/server/tools/github-api.ts index cc45c2a5..8dba5a84 100644 --- a/server/tools/github-api.ts +++ b/server/tools/github-api.ts @@ -45,6 +45,21 @@ interface GithubContributorStats { }>; } +interface GithubCommitResponseItem { + author: { login?: unknown } | null; + commit?: { author?: { date?: unknown } | null } | null; +} + +export interface GithubDailyCommitData { + commitCount: number; + authorLogins: string[]; +} + +export interface GithubDailyCommitsResult { + days: Map; + complete: boolean; +} + const GITHUB_API_BASE = 'https://api.github.com'; const GITHUB_API_TOKEN = process.env.GITHUB_API_TOKEN; @@ -186,6 +201,36 @@ export const getRepositoryStats = async (owner: string, repo: string): Promise => { + try { + const query = encodeURIComponent(`org:${owner} is:pr is:open`); + const url = `${GITHUB_API_BASE}/search/issues?q=${query}&per_page=1`; + const response = await fetch(url, { headers: getHeaders() }); + + await handleRateLimit(response.headers); + + if (!response.ok) { + if (response.status === 404) { + logWarn(`GitHub organization not found while counting pull requests: ${owner}`); + return null; + } + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as { total_count?: unknown; incomplete_results?: unknown }; + const count = data.total_count; + if (data.incomplete_results !== false || typeof count !== 'number' || !Number.isInteger(count) || count < 0) { + logWarn(`Incomplete or malformed pull-request count for organization: ${owner}`); + return null; + } + + return count; + } catch (error) { + logError(`Failed to count open pull requests for ${owner}:`, error); + return null; + } +}; + export const getRepositoryActivity = async (owner: string, repo: string): Promise => { try { const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/stats/commit_activity`; @@ -272,25 +317,39 @@ export const getDailyCommits = async ( repo: string, startDate: string, endDate: string, -): Promise> => { - const dailyCommits = new Map(); +): Promise => { + const dailyCommits = new Map }>(); - const start = new Date(startDate); - const end = new Date(endDate); + const start = new Date(`${startDate}T00:00:00.000Z`); + const end = new Date(`${endDate}T00:00:00.000Z`); const totalDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1; + const buildResult = (complete: boolean): GithubDailyCommitsResult => ({ + complete, + days: new Map( + Array.from(dailyCommits.entries()).map(([date, value]) => [ + date, + { + commitCount: value.commitCount, + authorLogins: Array.from(value.authorLogins).sort(), + }, + ]), + ), + }); + try { logInfo(`Fetching daily commits for ${owner}/${repo} from ${startDate} to ${endDate} (${totalDays} days)`); const startTime = new Date(start); - startTime.setDate(startTime.getDate() - 7); + startTime.setUTCDate(startTime.getUTCDate() - 7); const endTime = new Date(end); - endTime.setDate(endTime.getDate() + 1); + endTime.setUTCDate(endTime.getUTCDate() + 1); let page = 1; const perPage = 100; let hasMorePages = true; + let complete = true; while (hasMorePages) { await waitForRateLimitIfNeeded(50); @@ -306,29 +365,49 @@ export const getDailyCommits = async ( if (!response.ok) { if (response.status === 404) { logError(`Repository not found: ${owner}/${repo}`); - return dailyCommits; + return buildResult(false); } if (response.status === 409) { logInfo(`Repository ${owner}/${repo} is empty or has no commits`); - return dailyCommits; + return buildResult(true); } throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); } const data = await response.json(); - if (!Array.isArray(data) || data.length === 0) { + if (!Array.isArray(data)) { + complete = false; + break; + } + if (data.length === 0) { hasMorePages = false; break; } - for (const commit of data) { - const commitDate = new Date(commit.commit.author.date); - const commitDateStr = commitDate.toISOString().split('T')[0]; + for (const commit of data as GithubCommitResponseItem[]) { + const commitDateValue = commit.commit?.author?.date; + if (typeof commitDateValue !== 'string') { + complete = false; + continue; + } + + const commitDate = new Date(commitDateValue); + if (Number.isNaN(commitDate.getTime())) { + complete = false; + continue; + } + const commitDateStr = commitDate.toISOString().slice(0, 10); if (commitDateStr >= startDate && commitDateStr <= endDate) { - const existing = dailyCommits.get(commitDateStr) || 0; - dailyCommits.set(commitDateStr, existing + 1); + const existing = dailyCommits.get(commitDateStr) ?? { commitCount: 0, authorLogins: new Set() }; + existing.commitCount += 1; + + const login = commit.author?.login; + if (typeof login === 'string' && !login.toLowerCase().endsWith('[bot]')) { + existing.authorLogins.add(login); + } + dailyCommits.set(commitDateStr, existing); } } @@ -342,11 +421,11 @@ export const getDailyCommits = async ( } logInfo(`Completed fetching daily commits for ${owner}/${repo}: ${dailyCommits.size} days with commits`); - return dailyCommits; + return buildResult(complete); } catch (error) { logError(`Failed to fetch daily commits for ${owner}/${repo}:`, error); logInfo(`Returning partial data: ${dailyCommits.size} days processed out of ${totalDays}`); - return dailyCommits; + return buildResult(false); } }; diff --git a/src/app/[locale]/main-validators/validator-list/validator-list-item/validator-list-item-chains.tsx b/src/app/[locale]/main-validators/validator-list/validator-list-item/validator-list-item-chains.tsx index a11ebef7..bcf0fd2a 100644 --- a/src/app/[locale]/main-validators/validator-list/validator-list-item/validator-list-item-chains.tsx +++ b/src/app/[locale]/main-validators/validator-list/validator-list-item/validator-list-item-chains.tsx @@ -6,26 +6,36 @@ 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 { ValidatorWithNodes } from '@/services/validator-service'; interface OwnProps { chains: (Chain & { valoper: string })[]; - validator: ValidatorWithNodes; } -const ValidatorListItemChains: FC = ({ chains: raw, validator }) => { +// One entry per NODE arrives here; collapse to unique networks so a validator running +// several nodes on the same chain shows that chain once, linked to the network — not to +// each duplicated node. +const dedupeByChain = (raw: (Chain & { valoper: string })[]): Chain[] => { + const seen = new Set(); + const unique: Chain[] = []; + for (const chain of raw) { + if (chain == null || typeof chain.id !== 'number' || !chain.name) continue; + if (seen.has(chain.id)) continue; + seen.add(chain.id); + unique.push(chain); + } + return unique; +}; + +const ValidatorListItemChains: FC = ({ chains: raw }) => { const [isModalOpened, setIsModalOpened] = useState(false); - const chains = raw.filter((c) => typeof c !== 'undefined'); + const chains = dedupeByChain(raw); return (
{chains.length > 4 &&
{chains.length}:
} {chains.slice(0, 4).map((chain) => ( - + = ({ chains: raw, validator }) => { >
{chains.map((chain) => ( - + = ({ chains, validator }) => { - + ); diff --git a/src/app/[locale]/main-validators/validator-list/validator-list-item/validators-list-item-dev.tsx b/src/app/[locale]/main-validators/validator-list/validator-list-item/validators-list-item-dev.tsx index be98a822..d4fb68a9 100644 --- a/src/app/[locale]/main-validators/validator-list/validator-list-item/validators-list-item-dev.tsx +++ b/src/app/[locale]/main-validators/validator-list/validator-list-item/validators-list-item-dev.tsx @@ -92,7 +92,7 @@ const ValidatorListItemDev: FC = ({ chains, validator }) => { - + ); diff --git a/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/metrics/page.tsx b/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/metrics/page.tsx index c1eadf92..2843d0a1 100644 --- a/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/metrics/page.tsx +++ b/src/app/[locale]/mining-pools/[poolSlug]/(mining-pool-profile)/metrics/page.tsx @@ -3,7 +3,6 @@ import { notFound } from 'next/navigation'; import MiningPoolMetrics from '@/app/mining-pools/[poolSlug]/(mining-pool-profile)/metrics/mining-pool-metrics'; import PageTitle from '@/components/common/page-title'; -import db from '@/db'; import { Locale, NextPageWithLocale } from '@/i18n'; import moneroService, { HashrateWindow, isValidWindow } from '@/services/monero-service'; @@ -16,8 +15,10 @@ interface PageProps { } 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 } }); + const [t, pool] = await Promise.all([ + getTranslations({ locale, namespace: 'MiningPoolDetail' }), + moneroService.getMoneroPoolBySlug(poolSlug), + ]); return { title: pool ? `${pool.name} — ${t('metaTitle')}` : t('metaTitle') }; } @@ -29,10 +30,7 @@ const MiningPoolMetricsPage: NextPageWithLocale = async ({ params: { const [t, pool, availableWindows] = await Promise.all([ getTranslations({ locale, namespace: 'MiningPoolDetail' }), - db.miningPool.findFirst({ - where: { slug: poolSlug }, - include: { chain: true, stats: true }, - }), + moneroService.getMoneroPoolBySlug(poolSlug), moneroService.getMoneroAvailableWindows(), ]); 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 index 7e3329f9..142329f0 100644 --- 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 @@ -8,10 +8,12 @@ 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 Tooltip from '@/components/common/tooltip'; import { Locale, NextPageWithLocale } from '@/i18n'; -import moneroService, { HashrateWindow, isValidWindow } from '@/services/monero-service'; +import miningPoolService from '@/services/mining-pool-service'; +import { HashrateWindow, isValidWindow } from '@/services/monero-service'; import { formatHashrate } from '@/utils/format-hashrate'; +import { getFreshMinersCount } from '@/utils/monero-pool-miners'; export const dynamic = 'force-dynamic'; export const revalidate = 0; @@ -21,9 +23,14 @@ interface PageProps { searchParams: { [key: string]: string | string[] | undefined }; } +const WINDOW_ORDER: HashrateWindow[] = ['24h', '7d', '30d', 'all']; + 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 } }); + const [t, pools] = await Promise.all([ + getTranslations({ locale, namespace: 'MiningPoolDetail' }), + miningPoolService.getNetworksBySlug(poolSlug), + ]); + const pool = pools[0]; return { title: pool ? `${pool.name} — ${t('metaTitle')}` : t('metaTitle') }; } @@ -32,23 +39,23 @@ export async function generateMetadata({ params: { locale, poolSlug } }: { param // 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 }, - }); + const [t, tProfile, pools] = await Promise.all([ + getTranslations({ locale, namespace: 'MiningPoolDetail' }), + getTranslations({ locale, namespace: 'MiningPoolProfileHeader' }), + miningPoolService.getNetworksBySlug(poolSlug), + ]); - if (!pool) notFound(); - if (!pool.isVerified) notFound(); + if (pools.length === 0) notFound(); - const availableWindows = await moneroService.getMoneroAvailableWindows(); + const availableWindows = WINDOW_ORDER.filter((window) => + pools.some((pool) => pool.stats.some((stat) => stat.windowKind === window)), + ); 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 safeWindow: HashrateWindow = availableWindows.includes(requested) + ? requested + : (availableWindows[0] ?? '24h'); + const referenceTime = Date.now(); const windowLabels: Record = { '24h': t('window24h'), @@ -60,7 +67,7 @@ const MiningPoolNetworksPage: NextPageWithLocale = async ({ params: { return (
- +
{windowOptions.length > 1 && (
@@ -73,32 +80,48 @@ const MiningPoolNetworksPage: NextPageWithLocale = async ({ params: { + - - - - - -
{stat ? stat.blocksFound.toLocaleString() : '-'}
-
- -
{stat ? `${(stat.sharePercent ?? 0).toFixed(2)}%` : '-'}
-
- -
{stat ? formatHashrate(stat.hashrateEstimate) : '-'}
-
- -
{feeText}
-
-
+ {pools.map((pool) => { + const stat = pool.stats.find((item) => item.windowKind === safeWindow) ?? null; + const minersCount = getFreshMinersCount(pool.minersCount, pool.minersUpdatedAt, referenceTime); + const feeText = pool.feePercent != null ? `${pool.feePercent.toFixed(2)}%` : '-'; + + return ( + + + + + +
{stat ? stat.blocksFound.toLocaleString(locale) : '-'}
+
+ +
{stat ? `${(stat.sharePercent ?? 0).toFixed(2)}%` : '-'}
+
+ + +
+ {minersCount === null ? '-' : minersCount.toLocaleString(locale)} +
+
+
+ +
{stat ? formatHashrate(stat.hashrateEstimate) : '-'}
+
+ +
{feeText}
+
+
+ ); + })}
diff --git a/src/app/[locale]/networks/[name]/(network-profile)/dev/developer-activity/developer-activity.tsx b/src/app/[locale]/networks/[name]/(network-profile)/dev/developer-activity/developer-activity.tsx index 442bea6d..6c211752 100644 --- a/src/app/[locale]/networks/[name]/(network-profile)/dev/developer-activity/developer-activity.tsx +++ b/src/app/[locale]/networks/[name]/(network-profile)/dev/developer-activity/developer-activity.tsx @@ -1,7 +1,9 @@ -import { getTranslations } from 'next-intl/server'; +import { getLocale, getTranslations } from 'next-intl/server'; import { FC } from 'react'; import DeveloperActivityChart from '@/app/networks/[name]/(network-profile)/dev/developer-activity/developer-activity-chart'; +import MetricsCardItem from '@/components/common/metrics-cards/metrics-card-item'; +import Tooltip from '@/components/common/tooltip'; import { ChainWithParamsAndTokenomics } from '@/services/chain-service'; import githubService from '@/services/github-service'; @@ -10,36 +12,64 @@ interface OwnProps { } const DeveloperActivity: FC = async ({ chain }) => { - const t = await getTranslations('NetworkDevInfo.DeveloperActivity'); + const [t, locale] = await Promise.all([ + getTranslations('NetworkDevInfo.DeveloperActivity'), + getLocale(), + ]); if (!chain) { return null; } - const stats = await githubService.getStats(chain.id); - const activityData = await githubService.getActivityData(chain.id); + const { stats, activityData } = await githubService.getDeveloperActivity(chain.id); + const metrics = [ + { key: 'stars', label: t('star'), value: stats.totalStars }, + { key: 'forks', label: t('forked'), value: stats.totalForks }, + { key: 'repositories', label: t('repositories'), value: stats.repositoryCount }, + { + key: 'most-active', + label: t('most active repo'), + value: stats.mostActiveRepoCommits, + tooltip: t('most active repo tooltip'), + }, + { key: 'open-prs', label: t('open prs'), value: stats.openPrs }, + { + key: 'open-issues', + label: t('open issues'), + value: stats.openIssues, + tooltip: t('open issues tooltip'), + }, + { + key: 'active-contributors', + label: t('active contributors'), + value: stats.activeContributors, + tooltip: t('active contributors tooltip'), + }, + ]; return (
-
-
-
{t('star')}:
-
{stats.totalStars}
-
-
-
{t('forked')}:
-
{stats.totalForks}
-
-
-
{t('repositories')}:
-
{stats.repositoryCount}
-
-
-
{t('most active repo')}:
-
{stats.mostActiveRepoCommits}
-
+
+ {metrics.map((metric) => { + const card = ( + + ); + + return metric.tooltip ? ( + + {card} + + ) : ( +
{card}
+ ); + })}
-
+
diff --git a/src/app/[locale]/networks/[name]/(network-profile)/stats/network-health.tsx b/src/app/[locale]/networks/[name]/(network-profile)/stats/network-health.tsx index aa89a771..25d82c08 100644 --- a/src/app/[locale]/networks/[name]/(network-profile)/stats/network-health.tsx +++ b/src/app/[locale]/networks/[name]/(network-profile)/stats/network-health.tsx @@ -5,6 +5,8 @@ import PassportRow, { signalColors } from '@/components/common/passport-row'; import SubTitle from '@/components/common/sub-title'; import { MONERO_BLOCK_TIME_SECONDS } from '@/server/tools/chains/monero/constants'; import moneroService, { HashrateWindow, MoneroPoolStatsRow } from '@/services/monero-service'; +import { TAIL_EMISSION_ATOMIC, parseAtomicAmount } from '@/utils/monero-emission'; +import { rewardComposition } from '@/utils/monero-tx-metrics'; import { blockTimeStats, difficultyStability, hhi, nakamotoCoefficient } from '@/utils/pow-decentralization'; interface OwnProps { @@ -72,8 +74,11 @@ const difficultyColor = (band: ReturnType): string | unde }; const NetworkHealth: FC = async ({ poolStats, window }) => { - const t = await getTranslations('PowNetworkStats'); - const hashrateHistory = await moneroService.getMoneroHashrateHistory(window); + const [t, hashrateHistory, txMetrics] = await Promise.all([ + getTranslations('PowNetworkStats'), + moneroService.getMoneroHashrateHistory(window), + moneroService.getMoneroTxMetrics24h(), + ]); const nakamoto = nakamotoCoefficient(poolStats); const hhiResult = hhi(poolStats); @@ -81,6 +86,10 @@ const NetworkHealth: FC = async ({ poolStats, window }) => { const blockTime = blockTimeStats(hashrateHistory); const difficulty = difficultyStability(hashrateHistory); const difficultyStatus = difficultyBand(difficulty.coefficientOfVariation); + const sumRewardAtomic = txMetrics ? parseAtomicAmount(txMetrics.sumRewardAtomic) : null; + const composition = txMetrics && sumRewardAtomic !== null + ? rewardComposition(sumRewardAtomic, TAIL_EMISSION_ATOMIC, txMetrics.feeBlockCount) + : null; const nakamotoValue = nakamoto.count === null @@ -113,6 +122,13 @@ const NetworkHealth: FC = async ({ poolStats, window }) => { status: t(difficultyLabelKeys[difficultyStatus]), }); + const feeShareValue = composition + ? t('feeShareValue', { + fees: formatPercent(composition.feeShare), + subsidy: formatPercent(composition.subsidyShare), + }) + : t('notEnoughData'); + return (
@@ -133,6 +149,7 @@ const NetworkHealth: FC = async ({ poolStats, window }) => { value={difficultyValue} color={difficultyColor(difficultyStatus)} /> +
); diff --git a/src/app/[locale]/networks/[name]/(network-profile)/stats/pool-dominance-chart.tsx b/src/app/[locale]/networks/[name]/(network-profile)/stats/pool-dominance-chart.tsx new file mode 100644 index 00000000..810c6c00 --- /dev/null +++ b/src/app/[locale]/networks/[name]/(network-profile)/stats/pool-dominance-chart.tsx @@ -0,0 +1,248 @@ +'use client'; + +import { + CategoryScale, + Chart as ChartJS, + type ChartOptions, + Legend, + LineElement, + LinearScale, + PointElement, + Tooltip, +} from 'chart.js'; +import zoomPlugin from 'chartjs-plugin-zoom'; +import { type FC, useMemo, useRef, useState } from 'react'; +import { Line } from 'react-chartjs-2'; + +import { chartAreaBackgroundPlugin } from '@/components/chart/chart-area-background-plugin'; +import type { MoneroPoolShareSeries } from '@/services/monero-service'; +import { cn } from '@/utils/cn'; +import { + aggregatePoolShareSeries, + getUniquePoolShareDates, + type PoolSharePeriod, +} from '@/utils/monero-pool-share'; + +import type { PoolDominancePeriodOption } from './pool-dominance-section'; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Tooltip, + Legend, + zoomPlugin, + chartAreaBackgroundPlugin, +); + +const MIN_AGGREGATED_POINTS = 3; +const UNKNOWN_POOL_SLUG = 'unknown'; +const UNKNOWN_POOL_COLOR = '#6B7280'; +const SERIES_COLORS = [ + '#4FB848', + '#2077E0', + '#F59E0B', + '#A855F7', + '#EC4899', + '#06B6D4', + '#EF4444', + '#84CC16', + '#F97316', +]; + +interface OwnProps { + initialSeries: MoneroPoolShareSeries[]; + locale: string; + emptyMessage: string; + periodOptions: PoolDominancePeriodOption[]; +} + +const formatDate = (dateString: string, period: PoolSharePeriod, locale: string): string => { + const date = new Date(`${dateString}T00:00:00.000Z`); + if (period === 'year') { + return date.toLocaleDateString(locale, { year: 'numeric', timeZone: 'UTC' }); + } + if (period === 'month') { + return date.toLocaleDateString(locale, { month: 'short', year: 'numeric', timeZone: 'UTC' }); + } + return date.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }); +}; + +const formatTooltipDate = (dateString: string, locale: string): string => + new Date(`${dateString}T00:00:00.000Z`).toLocaleDateString(locale, { + day: 'numeric', + month: 'long', + year: 'numeric', + timeZone: 'UTC', + }); + +// Historical pool distribution belongs to the network statistics surface. +const PoolDominanceChart: FC = ({ initialSeries, locale, emptyMessage, periodOptions }) => { + const [period, setPeriod] = useState('day'); + const chartRef = useRef>(null); + + const periodButtons = useMemo( + () => + periodOptions.filter((option) => { + if (option.value === 'day') return true; + + const aggregatedSeries = aggregatePoolShareSeries(initialSeries, option.value); + let maxPointCount = 0; + for (const poolSeries of aggregatedSeries) { + maxPointCount = Math.max(maxPointCount, poolSeries.points.length); + } + return maxPointCount >= MIN_AGGREGATED_POINTS; + }), + [initialSeries, periodOptions], + ); + + const displaySeries = useMemo( + () => aggregatePoolShareSeries(initialSeries, period), + [initialSeries, period], + ); + const dates = useMemo(() => getUniquePoolShareDates(displaySeries), [displaySeries]); + + const chartData = useMemo( + () => ({ + labels: dates.map((date) => formatDate(date, period, locale)), + datasets: displaySeries.map((poolSeries, index) => { + const pointsByDate = new Map(poolSeries.points.map((point) => [point.date, point.sharePercent])); + const color = poolSeries.pool.slug === UNKNOWN_POOL_SLUG + ? UNKNOWN_POOL_COLOR + : SERIES_COLORS[index % SERIES_COLORS.length]; + + return { + label: poolSeries.pool.name, + data: dates.map((date) => pointsByDate.get(date) ?? null), + borderColor: color, + backgroundColor: color, + borderWidth: 2, + pointRadius: 0, + pointHoverRadius: 3, + tension: 0.25, + spanGaps: false, + fill: false, + }; + }), + }), + [dates, displaySeries, locale, period], + ); + + const options = useMemo>( + () => ({ + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { + display: true, + position: 'bottom', + labels: { + color: '#FFFFFF', + boxHeight: 8, + boxWidth: 24, + font: { family: 'SF Pro, -apple-system, BlinkMacSystemFont, sans-serif', size: 12 }, + }, + }, + tooltip: { + enabled: true, + backgroundColor: '#1E1E1E', + titleColor: '#2077E0', + bodyColor: '#FFFFFF', + borderColor: '#444444', + borderWidth: 1, + padding: 12, + callbacks: { + title: (tooltipItems) => { + const dataIndex = tooltipItems[0]?.dataIndex; + if (dataIndex === undefined) return ''; + const date = dates[dataIndex]; + return date ? formatTooltipDate(date, locale) : ''; + }, + label: (context) => { + const value = context.parsed.y; + if (value === null) return context.dataset.label ?? ''; + return `${context.dataset.label ?? ''}: ${value.toLocaleString(locale, { + maximumFractionDigits: 2, + })}%`; + }, + }, + }, + zoom: { + pan: { enabled: true, mode: 'x' }, + zoom: { + wheel: { enabled: true }, + pinch: { enabled: true }, + mode: 'x', + }, + }, + }, + scales: { + x: { + grid: { display: false }, + border: { color: '#3E3E3E' }, + ticks: { + color: 'rgba(255, 255, 255, 0.8)', + font: { family: 'Handjet, monospace', size: 12 }, + maxRotation: 0, + minRotation: 0, + maxTicksLimit: 10, + }, + }, + y: { + beginAtZero: true, + min: 0, + max: 100, + grid: { color: 'rgba(255, 255, 255, 0.08)' }, + border: { color: '#3E3E3E' }, + ticks: { + color: 'rgba(255, 255, 255, 0.8)', + font: { family: 'Handjet, monospace', size: 12 }, + callback: (value) => `${Number(value).toLocaleString(locale)}%`, + }, + }, + }, + }), + [dates, locale], + ); + + const handlePeriodChange = (nextPeriod: PoolSharePeriod) => { + setPeriod(nextPeriod); + chartRef.current?.resetZoom(); + }; + + return ( +
+
+ {periodButtons.map((option) => ( + + ))} +
+ +
+ {dates.length === 0 ? ( +
+

{emptyMessage}

+
+ ) : ( + + )} +
+
+ ); +}; + +export default PoolDominanceChart; diff --git a/src/app/[locale]/networks/[name]/(network-profile)/stats/pool-dominance-section.tsx b/src/app/[locale]/networks/[name]/(network-profile)/stats/pool-dominance-section.tsx new file mode 100644 index 00000000..8ce02c20 --- /dev/null +++ b/src/app/[locale]/networks/[name]/(network-profile)/stats/pool-dominance-section.tsx @@ -0,0 +1,68 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { FC } from 'react'; + +import SubTitle from '@/components/common/sub-title'; +import type { MoneroPoolShareSeries } from '@/services/monero-service'; +import type { PoolSharePeriod } from '@/utils/monero-pool-share'; +import { getUniquePoolShareDates } from '@/utils/monero-pool-share'; + +const MIN_HISTORY_DAYS = 7; + +const PoolDominanceChart = dynamic(() => import('./pool-dominance-chart'), { + ssr: false, + loading: () =>
, +}); + +export interface PoolDominancePeriodOption { + value: PoolSharePeriod; + label: string; + ariaLabel: string; +} + +interface OwnProps { + series: MoneroPoolShareSeries[]; + locale: string; + title: string; + emptyMessage: string; + notEnoughDataMessage: string; + periodOptions: PoolDominancePeriodOption[]; +} + +const PoolDominanceSection: FC = ({ + series, + locale, + title, + emptyMessage, + notEnoughDataMessage, + periodOptions, +}) => { + const historyDays = getUniquePoolShareDates(series).length; + + return ( +
+ +
+ {historyDays === 0 ? ( +
+ {emptyMessage} +
+ ) : historyDays < MIN_HISTORY_DAYS ? ( +
+ {notEnoughDataMessage} +
+ ) : ( + + )} +
+
+ ); +}; + +export default PoolDominanceSection; 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 index c7390518..3dc1d1cd 100644 --- 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 @@ -1,4 +1,4 @@ -import { getTranslations } from 'next-intl/server'; +import { getLocale, getTranslations } from 'next-intl/server'; import Link from 'next/link'; import { FC } from 'react'; @@ -7,6 +7,7 @@ import moneroService, { HashrateWindow, isValidWindow } from '@/services/monero- import HashrateWindowSelector from './hashrate-window-selector'; import NetworkHealth from './network-health'; +import PoolDominanceSection, { type PoolDominancePeriodOption } from './pool-dominance-section'; interface OwnProps { window: HashrateWindow; @@ -22,9 +23,12 @@ const barColor = (pct: number): string => { // 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 [t, locale, availableWindows, shareHistory] = await Promise.all([ + getTranslations('PowNetworkStats'), + getLocale(), + moneroService.getMoneroAvailableWindows(), + moneroService.getMoneroPoolShareHistory(), + ]); const requested: HashrateWindow = isValidWindow(window) ? window : '24h'; const safeWindow: HashrateWindow = availableWindows.includes(requested) ? requested : '24h'; @@ -41,6 +45,12 @@ const PowNetworkStats: FC = async ({ window }) => { all: t('windowAll'), }; const windowOptions = availableWindows.map((value) => ({ value, label: windowLabels[value] })); + const periodOptions: PoolDominancePeriodOption[] = [ + { value: 'day', label: t('periodDaily'), ariaLabel: t('switchPeriod', { period: t('periodDaily') }) }, + { value: 'week', label: t('periodWeekly'), ariaLabel: t('switchPeriod', { period: t('periodWeekly') }) }, + { value: 'month', label: t('periodMonthly'), ariaLabel: t('switchPeriod', { period: t('periodMonthly') }) }, + { value: 'year', label: t('periodYearly'), ariaLabel: t('switchPeriod', { period: t('periodYearly') }) }, + ]; return (
@@ -84,6 +94,14 @@ const PowNetworkStats: FC = async ({ window }) => {
{t('noPoolData')}
)} +
); diff --git a/src/app/[locale]/networks/[name]/(network-profile)/tokenomics/pow-tokenomics-params.tsx b/src/app/[locale]/networks/[name]/(network-profile)/tokenomics/pow-tokenomics-params.tsx index a74f3567..5f269316 100644 --- a/src/app/[locale]/networks/[name]/(network-profile)/tokenomics/pow-tokenomics-params.tsx +++ b/src/app/[locale]/networks/[name]/(network-profile)/tokenomics/pow-tokenomics-params.tsx @@ -14,6 +14,7 @@ import { parseAtomicAmount, stockToFlow, } from '@/utils/monero-emission'; +import { rewardComposition } from '@/utils/monero-tx-metrics'; interface OwnProps { chainName: string | null; @@ -68,6 +69,7 @@ const PowTokenomicsParams: FC = async ({ chainName }) => { t('annual inflation'), t('stock-to-flow'), t('emission phase'), + t('tail emission security'), ].map((title) => ( = async ({ chainName }) => { ); } - const [supplyRaw, params] = await Promise.all([ + const [supplyRaw, params, txMetrics] = await Promise.all([ moneroService.getMoneroSupply(), moneroService.getMoneroChainParams(), + moneroService.getMoneroTxMetrics24h(), ]); const supplyAtomic = parseAtomicAmount(supplyRaw); + const sumRewardAtomic = txMetrics ? parseAtomicAmount(txMetrics.sumRewardAtomic) : null; + const composition = txMetrics && sumRewardAtomic !== null + ? rewardComposition(sumRewardAtomic, TAIL_EMISSION_ATOMIC, txMetrics.feeBlockCount) + : null; const coinDecimals = params?.coinDecimals ?? null; const denom = params?.denom ?? 'XMR'; @@ -119,6 +126,10 @@ const PowTokenomicsParams: FC = async ({ chainName }) => { const isTailEmission = blockRewardAtomic === TAIL_EMISSION_ATOMIC && tailEmissionAmount !== null; const emissionPhaseLine = isTailEmission ? t('per block value', { amount: tailEmissionAmount }) : ''; const emissionPhaseValue = isTailEmission ? withTooltip(emissionPhaseLine, t('tail emission')) : noData; + const subsidyShare = composition ? formatPercent(composition.subsidyShare) : null; + const tailSecurityValue = subsidyShare + ? withTooltip(t('subsidy funded', { share: subsidyShare }), t('tail security tooltip')) + : noData; return (
@@ -158,6 +169,12 @@ const PowTokenomicsParams: FC = async ({ chainName }) => { className={cardClassName} dataClassName={dataClassName} /> +
); }; 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 65d695ba..913d4a9b 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,8 +60,10 @@ const NetworkMiningPools: FC = async ({ chainName, window, sort }) => return (
-
- {windowOptions.length > 1 && } +
+ {windowOptions.length > 1 && ( + + )}
diff --git a/src/app/[locale]/validators/simple-validators/simple-validator-list-item.tsx b/src/app/[locale]/validators/simple-validators/simple-validator-list-item.tsx index 5020a2b9..64040fd1 100644 --- a/src/app/[locale]/validators/simple-validators/simple-validator-list-item.tsx +++ b/src/app/[locale]/validators/simple-validators/simple-validator-list-item.tsx @@ -33,7 +33,7 @@ const SimpleValidatorListItem: FC = ({ validator, chains }) => { - + ); diff --git a/src/app/[locale]/validators/simple-validators/simple-validators-list.tsx b/src/app/[locale]/validators/simple-validators/simple-validators-list.tsx index 52bad801..7285fce0 100644 --- a/src/app/[locale]/validators/simple-validators/simple-validators-list.tsx +++ b/src/app/[locale]/validators/simple-validators/simple-validators-list.tsx @@ -14,7 +14,9 @@ interface OwnProps { } const SimpleValidatorsList: FC = async ({ sort, perPage, currentPage = 1, ecosystems }) => { - const chains = await ChainService.getAll([], 0, 1000); + // showAll=true so closed (unsupported) networks are in the lookup too — a validator's + // nodes on them would otherwise resolve to undefined and drop out of Supported Assets. + const chains = await ChainService.getAll([], 0, 1000, 'name', 'asc', true); const { validators: list, pages } = await validatorService.getAll( ecosystems, perPage * (currentPage - 1), diff --git a/src/app/services/AGENTS.md b/src/app/services/AGENTS.md index 18b34eb5..915e2a39 100644 --- a/src/app/services/AGENTS.md +++ b/src/app/services/AGENTS.md @@ -13,7 +13,7 @@ | `vote-service.ts` | Voting data: votes by proposal, votes by validator | | `price-service.ts` | Token prices: current and historical | | `slashing-event-service.ts` | Slashing events for validators | -| `github-service.ts` | GitHub repository data for chains | +| `github-service.ts` | GitHub repository activity plus DB-backed PR, issue, and complete author-union health metrics for chains | | `infrastructure-service.ts` | Infrastructure providers data | | `search-service.ts` | Search functionality across chains and validators | | `ecosystem-service.ts` | Ecosystem groupings and filtering | @@ -22,6 +22,7 @@ | `headerInfo-service.ts` | Header and navigation info data | | `blocks-service.ts` | Block data | | `node-consensus-service.ts` | Consensus-specific node data | +| `mining-pool-service.ts` | Mining-pool profile networks and per-network stored gauges | | `aztec-db-service.ts` | Aztec database operations | | `aztec-contracts-service.ts` | Aztec L1 contracts data | | `aztec-vote-event-service.ts` | Aztec vote events | diff --git a/src/app/services/github-service.ts b/src/app/services/github-service.ts index 2f6a824b..4aa210f6 100644 --- a/src/app/services/github-service.ts +++ b/src/app/services/github-service.ts @@ -1,6 +1,11 @@ -import { Prisma } from '@prisma/client'; +import { type GithubDevHealth, Prisma } from '@prisma/client'; import db from '@/db'; +import { + addUtcDays, + GITHUB_CONTRIBUTOR_WINDOW_DAYS, + getGithubContributorWindow, +} from '@/utils/github-dev-health'; export type GithubRepositoryWithActivity = Prisma.GithubRepositoryGetPayload<{ include: { activities: true }; @@ -17,6 +22,9 @@ export interface GithubStats { totalForks: number; repositoryCount: number; mostActiveRepoCommits: number; + openPrs: number | null; + openIssues: number | null; + activeContributors: number | null; } export interface DailyActivity { @@ -25,6 +33,13 @@ export interface DailyActivity { level: number; } +export interface GithubContributorRepository { + pushedAt: Date; + authorCoverageFrom: Date | null; + activityFetchedThrough: Date | null; + activities: Array<{ date: Date; authorLogins: Prisma.JsonValue | null }>; +} + const getByChainId = async (chainId: number): Promise => { return db.githubRepository.findMany({ where: { @@ -51,15 +66,69 @@ const getRepositoriesWithCommits = async (chainId: number): Promise => { - const repositories = await getByChainId(chainId); +const MAX_CONTRIBUTOR_LAG_DAYS = 1; + +export const getActiveContributorsCount = ( + repositories: GithubContributorRepository[], + now: Date = new Date(), +): number | null => { + const targetWindow = getGithubContributorWindow(now); + const candidateCutoff = addUtcDays(targetWindow.cutoff, -MAX_CONTRIBUTOR_LAG_DAYS); + const candidateRepositories = repositories.filter((repository) => repository.pushedAt >= candidateCutoff); + + if (candidateRepositories.length === 0) return 0; + + let completedThrough = targetWindow.completedThrough; + for (const repository of candidateRepositories) { + if (repository.activityFetchedThrough === null) return null; + if (repository.activityFetchedThrough < completedThrough) { + completedThrough = repository.activityFetchedThrough; + } + } + const minimumFreshDate = addUtcDays(targetWindow.completedThrough, -MAX_CONTRIBUTOR_LAG_DAYS); + if (completedThrough < minimumFreshDate) return null; + + const cutoff = addUtcDays(completedThrough, -(GITHUB_CONTRIBUTOR_WINDOW_DAYS - 1)); + const activeRepositories = repositories.filter((repository) => repository.pushedAt >= cutoff); + + const hasCompleteCoverage = activeRepositories.every( + (repository) => + repository.authorCoverageFrom !== null && + repository.authorCoverageFrom <= cutoff && + repository.activityFetchedThrough !== null && + repository.activityFetchedThrough >= completedThrough, + ); + if (!hasCompleteCoverage) return null; + + const logins = new Set(); + for (const repository of activeRepositories) { + for (const activity of repository.activities) { + if (activity.date < cutoff || activity.date > completedThrough || !Array.isArray(activity.authorLogins)) { + continue; + } + for (const login of activity.authorLogins) { + if (typeof login === 'string' && !login.toLowerCase().endsWith('[bot]')) logins.add(login); + } + } + } + return logins.size; +}; + +const getStatsFromRepositories = ( + repositories: GithubRepositoryWithActivity[], + devHealth: GithubDevHealth | null, + now: Date = new Date(), +): GithubStats => { if (repositories.length === 0) { return { totalStars: 0, totalForks: 0, repositoryCount: 0, mostActiveRepoCommits: 0, + openPrs: null, + openIssues: null, + activeContributors: null, }; } @@ -77,12 +146,16 @@ const getStats = async (chainId: number): Promise => { totalForks, repositoryCount: repositories.length, mostActiveRepoCommits, + openPrs: devHealth?.openPrsCount ?? null, + openIssues: devHealth?.openIssuesCount ?? null, + activeContributors: getActiveContributorsCount(repositories, now), }; }; -const getActivityData = async (chainId: number): Promise => { - const repositories = await getByChainId(chainId); - +const getActivityDataFromRepositories = ( + repositories: GithubRepositoryWithActivity[], + now: Date = new Date(), +): DailyActivity[] => { const dailyMap = new Map(); for (const repo of repositories) { @@ -96,7 +169,7 @@ const getActivityData = async (chainId: number): Promise => { const maxCommits = Math.max(...Array.from(dailyMap.values()), 0); const result: DailyActivity[] = []; - const today = new Date(); + const today = new Date(now); const startDate = new Date(today); startDate.setDate(startDate.getDate() - 365); @@ -123,11 +196,39 @@ const getActivityData = async (chainId: number): Promise => { return result; }; +const getStats = async (chainId: number): Promise => { + const [repositories, devHealth] = await Promise.all([ + getByChainId(chainId), + db.githubDevHealth.findUnique({ where: { chainId } }), + ]); + return getStatsFromRepositories(repositories, devHealth); +}; + +const getActivityData = async (chainId: number): Promise => { + const repositories = await getByChainId(chainId); + return getActivityDataFromRepositories(repositories); +}; + +const getDeveloperActivity = async ( + chainId: number, +): Promise<{ stats: GithubStats; activityData: DailyActivity[] }> => { + const [repositories, devHealth] = await Promise.all([ + getByChainId(chainId), + db.githubDevHealth.findUnique({ where: { chainId } }), + ]); + + return { + stats: getStatsFromRepositories(repositories, devHealth), + activityData: getActivityDataFromRepositories(repositories), + }; +}; + const githubService = { getByChainId, getRepositoriesWithCommits, getStats, getActivityData, + getDeveloperActivity, }; export default githubService; diff --git a/src/app/services/mining-pool-service.ts b/src/app/services/mining-pool-service.ts new file mode 100644 index 00000000..a2bf613a --- /dev/null +++ b/src/app/services/mining-pool-service.ts @@ -0,0 +1,16 @@ +import db from '@/db'; + +const getNetworksBySlug = async (poolSlug: string) => { + return db.miningPool.findMany({ + where: { slug: poolSlug, isVerified: true }, + include: { chain: true, stats: true }, + orderBy: { chainId: 'asc' }, + }); +}; + +const miningPoolService = { + getNetworksBySlug, +}; + +export default miningPoolService; +export { getNetworksBySlug }; diff --git a/src/app/services/monero-service.ts b/src/app/services/monero-service.ts index c3be67aa..a049e502 100644 --- a/src/app/services/monero-service.ts +++ b/src/app/services/monero-service.ts @@ -15,6 +15,18 @@ export type MoneroPoolStatsRow = MiningPoolStats & { pool: MiningPool; }; +export interface MoneroPoolSharePoint { + date: string; + sharePercent: number; + blocksFound: number; + networkBlocks: number; +} + +export interface MoneroPoolShareSeries { + pool: { slug: string; name: string; isVerified: boolean }; + points: MoneroPoolSharePoint[]; +} + const MONERO_NAME = 'monero'; const MONERO_BLOCK_METRICS_WINDOW_HOURS = 24; const MONERO_TX_METRICS_WINDOW_HOURS = 24; @@ -67,6 +79,51 @@ const getMoneroPoolStats = async (window: HashrateWindow): Promise { + return db.miningPool.findFirst({ + where: { slug: poolSlug, chain: { name: MONERO_NAME } }, + include: { chain: true, stats: true }, + }); +}; + +const getMoneroPoolShareHistory = async (): Promise => { + const chain = await getMoneroChain(); + if (!chain) return []; + + const rows = await db.moneroPoolDailyShare.findMany({ + where: { chainId: chain.id }, + orderBy: [{ date: 'asc' }, { poolId: 'asc' }], + select: { + date: true, + blocksFound: true, + sharePercent: true, + networkBlocks: true, + pool: { select: { slug: true, name: true, isVerified: true } }, + }, + }); + + const seriesBySlug = new Map(); + for (const row of rows) { + const series = seriesBySlug.get(row.pool.slug) ?? { pool: row.pool, points: [] }; + series.points.push({ + date: row.date.toISOString().slice(0, 10), + blocksFound: row.blocksFound, + sharePercent: row.sharePercent, + networkBlocks: row.networkBlocks, + }); + seriesBySlug.set(row.pool.slug, series); + } + + return Array.from(seriesBySlug.values()).sort((a, b) => { + const isAUnknown = a.pool.slug === 'unknown'; + const isBUnknown = b.pool.slug === 'unknown'; + if (isAUnknown !== isBUnknown) return isAUnknown ? 1 : -1; + + const nameComparison = a.pool.name.localeCompare(b.pool.name); + return nameComparison || a.pool.slug.localeCompare(b.pool.slug); + }); +}; + // 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 => { @@ -356,6 +413,8 @@ const moneroService = { getMoneroNetworkSnapshot, getMoneroHashrateHistory, getMoneroPoolStats, + getMoneroPoolBySlug, + getMoneroPoolShareHistory, getMoneroSupply, getMoneroChainParams, getMoneroActivePoolsCount, @@ -376,6 +435,8 @@ export { getMoneroNetworkSnapshot, getMoneroHashrateHistory, getMoneroPoolStats, + getMoneroPoolBySlug, + getMoneroPoolShareHistory, getMoneroSupply, getMoneroChainParams, getMoneroActivePoolsCount, diff --git a/src/utils/github-dev-health.ts b/src/utils/github-dev-health.ts new file mode 100644 index 00000000..b397d275 --- /dev/null +++ b/src/utils/github-dev-health.ts @@ -0,0 +1,18 @@ +export const GITHUB_CONTRIBUTOR_WINDOW_DAYS = 30; + +const startOfUtcDay = (date: Date): Date => + new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + +export const addUtcDays = (date: Date, days: number): Date => { + const result = new Date(date); + result.setUTCDate(result.getUTCDate() + days); + return result; +}; + +export const toUtcDateString = (date: Date): string => date.toISOString().slice(0, 10); + +export const getGithubContributorWindow = (now: Date = new Date()): { cutoff: Date; completedThrough: Date } => { + const completedThrough = addUtcDays(startOfUtcDay(now), -1); + const cutoff = addUtcDays(completedThrough, -(GITHUB_CONTRIBUTOR_WINDOW_DAYS - 1)); + return { cutoff, completedThrough }; +}; diff --git a/src/utils/monero-pool-miners.ts b/src/utils/monero-pool-miners.ts new file mode 100644 index 00000000..510bbeda --- /dev/null +++ b/src/utils/monero-pool-miners.ts @@ -0,0 +1,29 @@ +export const MINERS_STALE_HOURS = 24; + +const MINERS_STALE_MS = MINERS_STALE_HOURS * 60 * 60 * 1000; + +// Indexer and web hosts may drift apart (NTP); a write stamped slightly in the +// future must not read as "not fresh yet" right after the hourly job runs. +const CLOCK_SKEW_TOLERANCE_MS = 5 * 60 * 1000; + +export const getFreshMinersCount = ( + minersCount: number | null | undefined, + minersUpdatedAt: Date | null | undefined, + referenceTime = Date.now(), +): number | null => { + if ( + minersCount == null + || minersUpdatedAt == null + || !Number.isSafeInteger(minersCount) + || minersCount <= 0 + || !Number.isFinite(referenceTime) + ) { + return null; + } + + const updatedAt = minersUpdatedAt.getTime(); + const age = referenceTime - updatedAt; + if (!Number.isFinite(updatedAt) || age < -CLOCK_SKEW_TOLERANCE_MS || age > MINERS_STALE_MS) return null; + + return minersCount; +}; diff --git a/src/utils/monero-pool-share.ts b/src/utils/monero-pool-share.ts new file mode 100644 index 00000000..03579dd1 --- /dev/null +++ b/src/utils/monero-pool-share.ts @@ -0,0 +1,56 @@ +import type { MoneroPoolShareSeries } from '@/services/monero-service'; + +export type PoolSharePeriod = 'day' | 'week' | 'month' | 'year'; + +const toUtcDate = (date: string): Date => new Date(`${date}T00:00:00.000Z`); + +const periodKey = (dateString: string, period: PoolSharePeriod): string => { + if (period === 'day') return dateString; + + const date = toUtcDate(dateString); + if (period === 'week') { + const weekday = date.getUTCDay(); + const offset = weekday === 0 ? -6 : 1 - weekday; + date.setUTCDate(date.getUTCDate() + offset); + return date.toISOString().slice(0, 10); + } + if (period === 'month') { + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-01`; + } + return `${date.getUTCFullYear()}-01-01`; +}; + +export const aggregatePoolShareSeries = ( + series: MoneroPoolShareSeries[], + period: PoolSharePeriod, +): MoneroPoolShareSeries[] => { + if (period === 'day') return series; + + return series.map((poolSeries) => { + const buckets = new Map(); + for (const point of poolSeries.points) { + const key = periodKey(point.date, period); + const bucket = buckets.get(key) ?? { blocksFound: 0, networkBlocks: 0 }; + bucket.blocksFound += point.blocksFound; + bucket.networkBlocks += point.networkBlocks; + buckets.set(key, bucket); + } + + return { + pool: poolSeries.pool, + points: Array.from(buckets.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, bucket]) => ({ + date, + blocksFound: bucket.blocksFound, + networkBlocks: bucket.networkBlocks, + sharePercent: bucket.networkBlocks > 0 + ? (bucket.blocksFound / bucket.networkBlocks) * 100 + : 0, + })), + }; + }); +}; + +export const getUniquePoolShareDates = (series: MoneroPoolShareSeries[]): string[] => + Array.from(new Set(series.flatMap((poolSeries) => poolSeries.points.map((point) => point.date)))).sort(); diff --git a/src/utils/monero-tx-metrics.ts b/src/utils/monero-tx-metrics.ts index d1a2bd72..39b37b3f 100644 --- a/src/utils/monero-tx-metrics.ts +++ b/src/utils/monero-tx-metrics.ts @@ -1,5 +1,10 @@ const ZERO = BigInt(0); +export interface RewardComposition { + feeShare: number; + subsidyShare: number; +} + export const tps = (totalTx: number, windowSeconds: number): number | null => { if (windowSeconds <= 0 || totalTx < 0) return null; return totalTx / windowSeconds; @@ -18,3 +23,21 @@ export const avgFeeAtomic = ( return totalFees / BigInt(totalTx); }; + +export const rewardComposition = ( + sumRewardAtomic: bigint, + subsidyAtomic: bigint, + blockCount: number, +): RewardComposition | null => { + if (!Number.isSafeInteger(blockCount) || blockCount <= 0 || sumRewardAtomic <= ZERO || subsidyAtomic < ZERO) { + return null; + } + + const totalSubsidy = subsidyAtomic * BigInt(blockCount); + const totalFees = sumRewardAtomic > totalSubsidy ? sumRewardAtomic - totalSubsidy : ZERO; + const feeShare = Number(totalFees) / Number(sumRewardAtomic); + + if (!Number.isFinite(feeShare)) return null; + + return { feeShare, subsidyShare: 1 - feeShare }; +};