diff --git a/server/src/benchmark.test.ts b/server/src/benchmark.test.ts new file mode 100644 index 00000000..ebfe18a5 --- /dev/null +++ b/server/src/benchmark.test.ts @@ -0,0 +1,255 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { PoolConfig, SetupData } from '@sv2-ui/shared'; +import { + BenchmarkManager, + rotatePoolsForBenchmark, + ShareCounterAccumulator, + summarizeLatencySamples, +} from './benchmark.js'; + +const POOLS: PoolConfig[] = [ + { + name: 'Alpha', + address: 'alpha.example.com', + port: 3333, + authority_public_key: 'alpha-key', + user_identity: 'miner', + }, + { + name: 'Beta', + address: 'beta.example.com', + port: 4444, + authority_public_key: 'beta-key', + user_identity: 'miner', + }, +]; + +const SETUP: SetupData = { + miningMode: 'solo', + mode: 'no-jd', + pool: POOLS[0], + fallbackPools: [POOLS[1]], + bitcoin: null, + jdc: null, + translator: { + enable_vardiff: true, + aggregate_channels: false, + min_hashrate: 1, + shares_per_minute: 1, + downstream_extranonce2_size: 4, + }, +}; + +test('summarizes latency using the median so outliers cannot dominate', () => { + assert.deepEqual(summarizeLatencySamples([10, 12, 11, 50]), { + medianLatencyMs: 11.5, + }); + assert.deepEqual(summarizeLatencySamples([10, 12, 11]), { + medianLatencyMs: 11, + }); + assert.deepEqual(summarizeLatencySamples([]), { + medianLatencyMs: null, + }); +}); + +test('accumulates shares when channels disconnect, reconnect, or reset', () => { + const accumulator = new ShareCounterAccumulator(); + + assert.deepEqual(accumulator.update([ + { key: 'extended:1', accepted: 10, stale: 1 }, + { key: 'extended:2', accepted: 5, stale: 0 }, + ]), { accepted: 0, stale: 0 }); + assert.deepEqual(accumulator.update([ + { key: 'extended:1', accepted: 12, stale: 2 }, + { key: 'extended:2', accepted: 6, stale: 0 }, + ]), { accepted: 3, stale: 1 }); + assert.deepEqual(accumulator.update([ + { key: 'extended:1', accepted: 13, stale: 2 }, + ]), { accepted: 4, stale: 1 }); + assert.deepEqual(accumulator.update([ + { key: 'extended:1', accepted: 14, stale: 2 }, + { key: 'extended:3', accepted: 2, stale: 1 }, + ]), { accepted: 7, stale: 2 }); + assert.deepEqual(accumulator.update([ + { key: 'extended:1', accepted: 1, stale: 0 }, + { key: 'extended:3', accepted: 3, stale: 1 }, + ]), { accepted: 9, stale: 2 }); +}); + +test('rotates the existing ordered fallback list without mutating the saved setup', () => { + const rotated = rotatePoolsForBenchmark(SETUP, POOLS, 1); + + assert.equal(rotated.pool?.name, 'Beta'); + assert.deepEqual(rotated.fallbackPools.map((pool) => pool.name), ['Alpha']); + assert.equal(SETUP.pool?.name, 'Alpha'); + assert.deepEqual(SETUP.fallbackPools.map((pool) => pool.name), ['Beta']); +}); + +test('runs each pool, records measurements, and restores the original setup', async () => { + const applied: SetupData[] = []; + const counterReads = new Map(); + let settled = 0; + + const manager = new BenchmarkManager({ + applyConfiguration: async (data) => { + applied.push(structuredClone(data)); + }, + getActivePool: async () => ({ index: 0 }), + readShareCounters: async () => { + const poolName = applied.at(-1)?.pool?.name ?? ''; + const readCount = (counterReads.get(poolName) ?? 0) + 1; + counterReads.set(poolName, readCount); + + if (poolName === 'Alpha') { + return readCount === 1 + ? [{ key: 'extended:1', accepted: 10, stale: 1 }] + : [{ key: 'extended:1', accepted: 13, stale: 2 }]; + } + + return readCount === 1 + ? [{ key: 'extended:1', accepted: 20, stale: 4 }] + : [{ key: 'extended:1', accepted: 22, stale: 4 }]; + }, + measureLatency: async (pool) => pool.name === 'Alpha' ? 10 : 20, + sampleIntervalMs: 1, + // Multi-sample scheduling is covered separately. Keep this orchestration + // test independent of sub-millisecond timer precision on slower CI hosts. + maxLatencySamples: 1, + activePoolPollIntervalMs: 1, + activePoolTimeoutMs: 20, + onSettled: () => { + settled += 1; + }, + }); + + manager.start(SETUP, POOLS, 0.01); + await manager.waitForCompletion(); + + const run = manager.getSnapshot(); + assert.equal(run?.status, 'completed'); + assert.deepEqual(run?.results.map((result) => result.status), ['completed', 'completed']); + assert.equal(run?.results[0].medianLatencyMs, 10); + assert.equal(run?.results[0].acceptedShares, 3); + assert.equal(run?.results[0].staleShares, 1); + assert.equal(run?.results[1].medianLatencyMs, 20); + assert.equal(run?.results[1].acceptedShares, 2); + assert.equal(run?.results[1].staleShares, 0); + assert.deepEqual(applied.map((data) => data.pool?.name), ['Alpha', 'Beta', 'Alpha']); + assert.deepEqual(applied.at(-1), SETUP); + assert.equal(settled, 1); +}); + +test('publishes accepted and stale share deltas while a pool is still running', async () => { + let counterRead = 0; + const manager = new BenchmarkManager({ + applyConfiguration: async () => {}, + getActivePool: async () => ({ index: 0 }), + readShareCounters: async () => { + counterRead += 1; + return counterRead === 1 + ? [{ key: 'extended:1', accepted: 10, stale: 1 }] + : [{ key: 'extended:1', accepted: 12, stale: 2 }]; + }, + measureLatency: async () => 10, + sampleIntervalMs: 1, + maxLatencySamples: 2, + activePoolPollIntervalMs: 1, + activePoolTimeoutMs: 20, + }); + + manager.start(SETUP, POOLS, 1); + + let liveRun = manager.getSnapshot(); + for (let attempt = 0; attempt < 100 && liveRun?.results[0].acceptedShares !== 2; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 1)); + liveRun = manager.getSnapshot(); + } + + assert.equal(liveRun?.status, 'running'); + assert.equal(liveRun?.results[0].status, 'running'); + assert.equal(liveRun?.results[0].acceptedShares, 2); + assert.equal(liveRun?.results[0].staleShares, 1); + + assert.equal(manager.stop(), true); + await manager.waitForCompletion(); +}); + +test('spreads latency attempts across the interval and does not rank partial success', async () => { + const attemptTimes: number[] = []; + const manager = new BenchmarkManager({ + applyConfiguration: async () => {}, + getActivePool: async () => ({ index: 0 }), + readShareCounters: async () => [], + measureLatency: async () => { + attemptTimes.push(Date.now()); + if (attemptTimes.length === 2) { + throw new Error('connection refused'); + } + return 10; + }, + sampleIntervalMs: 5, + maxLatencySamples: 3, + activePoolPollIntervalMs: 1, + activePoolTimeoutMs: 20, + }); + + manager.start(SETUP, [POOLS[0]], 0.06); + await manager.waitForCompletion(); + + const result = manager.getSnapshot()?.results[0]; + assert.equal(result?.status, 'failed'); + assert.equal(result?.attemptedSamples, 3); + assert.equal(result?.successfulSamples, 2); + assert.equal(result?.medianLatencyMs, null); + assert.match(result?.error ?? '', /no latency rank was assigned/i); + assert.equal(attemptTimes.length, 3); + assert.ok(attemptTimes[2] - attemptTimes[0] >= 30); +}); + +test('stopping a run cancels unfinished rows and still restores the setup', async () => { + const applied: SetupData[] = []; + const manager = new BenchmarkManager({ + applyConfiguration: async (data) => { + applied.push(structuredClone(data)); + }, + getActivePool: async () => ({ index: 0 }), + readShareCounters: async () => [], + measureLatency: async () => 10, + sampleIntervalMs: 5, + maxLatencySamples: 2, + activePoolPollIntervalMs: 1, + activePoolTimeoutMs: 20, + }); + + manager.start(SETUP, POOLS, 1); + await new Promise((resolve) => setTimeout(resolve, 15)); + assert.equal(manager.stop(), true); + await manager.waitForCompletion(); + + const run = manager.getSnapshot(); + assert.equal(run?.status, 'cancelled'); + assert.ok(run?.results.some((result) => result.status === 'cancelled')); + assert.deepEqual(applied.at(-1), SETUP); +}); + +test('marks the intended pool failed when the fallback becomes active', async () => { + const manager = new BenchmarkManager({ + applyConfiguration: async () => {}, + getActivePool: async () => ({ index: 1 }), + readShareCounters: async () => [], + sampleIntervalMs: 1, + maxLatencySamples: 2, + activePoolPollIntervalMs: 1, + activePoolTimeoutMs: 20, + }); + + manager.start(SETUP, POOLS, 0.01); + await manager.waitForCompletion(); + + const run = manager.getSnapshot(); + assert.equal(run?.status, 'completed'); + assert.equal(run?.results[0].status, 'failed'); + assert.match(run?.results[0].error ?? '', /Beta became active/i); +}); diff --git a/server/src/benchmark.ts b/server/src/benchmark.ts new file mode 100644 index 00000000..8de8ee10 --- /dev/null +++ b/server/src/benchmark.ts @@ -0,0 +1,535 @@ +import { randomUUID } from 'node:crypto'; +import { lookup } from 'node:dns/promises'; +import { createConnection } from 'node:net'; +import { performance } from 'node:perf_hooks'; + +import type { + BenchmarkPoolResult, + BenchmarkRun, + PoolConfig, + SetupData, + SetupMode, +} from '@sv2-ui/shared'; + +export type ShareCounters = { + accepted: number; + stale: number; +}; + +export type ShareChannelCounters = ShareCounters & { + key: string; +}; + +export type ActivePoolSelection = { + index: number; +}; + +export type BenchmarkDependencies = { + applyConfiguration: (data: SetupData) => Promise; + getActivePool: ( + mode: SetupMode, + pools: PoolConfig[] + ) => Promise; + readShareCounters: (mode: SetupMode) => Promise; + measureLatency?: (pool: PoolConfig, signal: AbortSignal) => Promise; + onSettled?: () => void; + sampleIntervalMs?: number; + maxLatencySamples?: number; + activePoolTimeoutMs?: number; + activePoolPollIntervalMs?: number; +}; + +type LatencySummary = { + medianLatencyMs: number | null; +}; + +const DEFAULT_SAMPLE_INTERVAL_MS = 5_000; +const DEFAULT_MAX_LATENCY_SAMPLES = 10; +const DEFAULT_ACTIVE_POOL_TIMEOUT_MS = 60_000; +const DEFAULT_ACTIVE_POOL_POLL_INTERVAL_MS = 1_000; +const TCP_CONNECT_TIMEOUT_MS = 5_000; + +function abortError(): Error { + const error = new Error('Benchmark stopped'); + error.name = 'AbortError'; + return error; +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function clone(value: T): T { + return structuredClone(value); +} + +function sleep(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(abortError()); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timeout); + reject(abortError()); + }; + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +/** + * The median is used instead of the mean so a single transient outlier + * (e.g. one SYN retransmission) cannot dominate a pool's ping for the run. + */ +export function summarizeLatencySamples(samples: number[]): LatencySummary { + if (samples.length === 0) { + return { + medianLatencyMs: null, + }; + } + + const sorted = [...samples].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + const median = sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; + + return { + medianLatencyMs: Math.round(median * 10) / 10, + }; +} + +function counterIncrement(before: number, after: number): number { + return after >= before ? after - before : after; +} + +export class ShareCounterAccumulator { + private readonly previous = new Map(); + private readonly totals: ShareCounters = { accepted: 0, stale: 0 }; + private initialized = false; + + update(channels: ShareChannelCounters[]): ShareCounters { + for (const channel of channels) { + const before = this.previous.get(channel.key); + + if (this.initialized) { + this.totals.accepted += before + ? counterIncrement(before.accepted, channel.accepted) + : channel.accepted; + this.totals.stale += before + ? counterIncrement(before.stale, channel.stale) + : channel.stale; + } + + this.previous.set(channel.key, { + accepted: channel.accepted, + stale: channel.stale, + }); + } + + this.initialized = true; + return { ...this.totals }; + } +} + +export function rotatePoolsForBenchmark( + data: SetupData, + pools: PoolConfig[], + primaryIndex: number +): SetupData { + const rotated = [ + ...pools.slice(primaryIndex), + ...pools.slice(0, primaryIndex), + ]; + + return { + ...clone(data), + pool: clone(rotated[0]), + fallbackPools: clone(rotated.slice(1)), + }; +} + +export async function measureTcpConnectLatency( + pool: PoolConfig, + signal: AbortSignal, + timeoutMs = TCP_CONNECT_TIMEOUT_MS +): Promise { + if (signal.aborted) throw abortError(); + + // Resolve before starting the stopwatch: DNS latency, resolver cache state, + // and Happy-Eyeballs family selection are not properties of the pool + // endpoint being compared. Connecting to the resolved address times the + // TCP handshake alone. + const hostname = pool.address.replace(/^\[|\]$/g, ''); + const resolved = await lookup(hostname); + if (signal.aborted) throw abortError(); + + return new Promise((resolve, reject) => { + const startedAt = performance.now(); + const socket = createConnection({ + host: resolved.address, + family: resolved.family, + port: pool.port, + }); + let settled = false; + + const finish = (error?: Error) => { + if (settled) return; + settled = true; + signal.removeEventListener('abort', onAbort); + socket.removeAllListeners(); + socket.destroy(); + + if (error) { + reject(error); + } else { + resolve(performance.now() - startedAt); + } + }; + + const onAbort = () => finish(abortError()); + + signal.addEventListener('abort', onAbort, { once: true }); + socket.setTimeout(timeoutMs); + socket.once('connect', () => finish()); + socket.once('timeout', () => finish(new Error(`TCP connection timed out after ${timeoutMs} ms`))); + socket.once('error', (error) => finish(error)); + }); +} + +export class BenchmarkManager { + private run: BenchmarkRun | null = null; + private abortController: AbortController | null = null; + private execution: Promise | null = null; + + constructor(private readonly dependencies: BenchmarkDependencies) {} + + getSnapshot(): BenchmarkRun | null { + return this.run ? clone(this.run) : null; + } + + isActive(): boolean { + return this.run?.status === 'running' || this.run?.status === 'stopping'; + } + + findSelectedPool(address: string, port: number): PoolConfig | null { + const pool = this.run?.selectedPools.find((candidate) => ( + candidate.address.trim().toLowerCase() === address.trim().toLowerCase() && + candidate.port === port + )); + return pool ? clone(pool) : null; + } + + getSelectedPools(): PoolConfig[] { + return this.run ? clone(this.run.selectedPools) : []; + } + + start( + originalData: SetupData, + pools: PoolConfig[], + poolDurationSeconds: number + ): BenchmarkRun { + if (this.isActive()) { + throw new Error('A benchmark is already running'); + } + + const startedAt = new Date().toISOString(); + this.run = { + id: randomUUID(), + status: 'running', + poolDurationSeconds, + createdAt: startedAt, + startedAt, + completedAt: null, + currentPoolIndex: null, + currentPoolStartedAt: null, + currentPoolEndsAt: null, + selectedPools: clone(pools), + results: pools.map((pool) => ({ + pool: clone(pool), + status: 'pending', + startedAt: null, + completedAt: null, + medianLatencyMs: null, + successfulSamples: 0, + attemptedSamples: 0, + acceptedShares: null, + staleShares: null, + })), + }; + + this.abortController = new AbortController(); + this.execution = this.execute( + clone(originalData), + clone(pools), + poolDurationSeconds, + this.abortController.signal + ).finally(() => { + this.abortController = null; + this.dependencies.onSettled?.(); + }); + + return clone(this.run); + } + + stop(): boolean { + if (!this.isActive() || !this.abortController || !this.run) return false; + + this.run.status = 'stopping'; + this.abortController.abort(); + return true; + } + + async waitForCompletion(): Promise { + await this.execution; + } + + private async execute( + originalData: SetupData, + pools: PoolConfig[], + poolDurationSeconds: number, + signal: AbortSignal + ): Promise { + let wasCancelled = false; + + try { + for (let index = 0; index < pools.length; index += 1) { + if (signal.aborted) throw abortError(); + await this.runPool(index, originalData, pools, poolDurationSeconds, signal); + } + } catch (error) { + if (isAbortError(error)) { + wasCancelled = true; + } else if (this.run) { + this.run.status = 'failed'; + this.run.error = errorMessage(error); + } + } finally { + if (this.run) { + for (const result of this.run.results) { + if (result.status === 'pending' || result.status === 'connecting' || result.status === 'running') { + result.status = wasCancelled ? 'cancelled' : 'failed'; + result.completedAt = new Date().toISOString(); + if (!result.error) { + result.error = wasCancelled ? 'Benchmark stopped' : 'Benchmark did not finish'; + } + } + } + } + + try { + await this.dependencies.applyConfiguration(originalData); + } catch (error) { + if (this.run) { + this.run.status = 'failed'; + this.run.error = `Could not restore the original pool configuration: ${errorMessage(error)}`; + } + } + + if (this.run) { + if (this.run.status !== 'failed') { + this.run.status = wasCancelled ? 'cancelled' : 'completed'; + } + this.run.currentPoolIndex = null; + this.run.currentPoolStartedAt = null; + this.run.currentPoolEndsAt = null; + this.run.completedAt = new Date().toISOString(); + } + } + } + + private async runPool( + index: number, + originalData: SetupData, + pools: PoolConfig[], + poolDurationSeconds: number, + signal: AbortSignal + ): Promise { + if (!this.run) return; + + const result = this.run.results[index]; + const rotatedData = rotatePoolsForBenchmark(originalData, pools, index); + const rotatedPools = [ + rotatedData.pool, + ...rotatedData.fallbackPools, + ].filter((pool): pool is PoolConfig => Boolean(pool)); + + this.run.currentPoolIndex = index; + result.status = 'connecting'; + result.startedAt = new Date().toISOString(); + + try { + await this.dependencies.applyConfiguration(rotatedData); + await this.waitForIntendedPool(rotatedData.mode, rotatedPools, signal); + await this.measurePool(result, rotatedData.mode, rotatedPools, poolDurationSeconds, signal); + } catch (error) { + if (isAbortError(error)) throw error; + + result.status = 'failed'; + result.error = errorMessage(error); + result.completedAt = new Date().toISOString(); + } finally { + if (this.run) { + this.run.currentPoolStartedAt = null; + this.run.currentPoolEndsAt = null; + } + } + } + + private async waitForIntendedPool( + mode: SetupMode | null, + pools: PoolConfig[], + signal: AbortSignal + ): Promise { + if (!mode) throw new Error('Mining mode is not configured'); + + const timeoutMs = this.dependencies.activePoolTimeoutMs ?? DEFAULT_ACTIVE_POOL_TIMEOUT_MS; + const pollIntervalMs = + this.dependencies.activePoolPollIntervalMs ?? DEFAULT_ACTIVE_POOL_POLL_INTERVAL_MS; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (signal.aborted) throw abortError(); + + const activePool = await this.dependencies.getActivePool(mode, pools); + if (activePool?.index === 0) { + return; + } + if (activePool && activePool.index > 0) { + const fallback = pools[activePool.index]; + throw new Error( + `Could not negotiate SV2 with this pool; ${fallback?.name ?? 'a fallback pool'} became active` + ); + } + + await sleep(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())), signal); + } + + throw new Error(`Pool did not complete SV2 negotiation within ${Math.round(timeoutMs / 1000)} seconds`); + } + + private async measurePool( + result: BenchmarkPoolResult, + mode: SetupMode | null, + pools: PoolConfig[], + poolDurationSeconds: number, + signal: AbortSignal + ): Promise { + if (!this.run || !mode) throw new Error('Mining mode is not configured'); + + const sampleIntervalMs = this.dependencies.sampleIntervalMs ?? DEFAULT_SAMPLE_INTERVAL_MS; + const maxLatencySamples = Math.max( + 1, + Math.floor(this.dependencies.maxLatencySamples ?? DEFAULT_MAX_LATENCY_SAMPLES) + ); + const samples: number[] = []; + const measureLatency = this.dependencies.measureLatency ?? measureTcpConnectLatency; + const startedAtMs = Date.now(); + const endsAtMs = startedAtMs + poolDurationSeconds * 1_000; + const latencySampleIntervalMs = (endsAtMs - startedAtMs) / maxLatencySamples; + let lastSampleError: string | null = null; + let nextLatencySampleAtMs = startedAtMs; + let nextShareRefreshAtMs = startedAtMs + sampleIntervalMs; + + result.status = 'running'; + this.run.currentPoolStartedAt = new Date(startedAtMs).toISOString(); + this.run.currentPoolEndsAt = new Date(endsAtMs).toISOString(); + + const shareCounters = new ShareCounterAccumulator(); + + const refreshShareCounters = async () => { + const snapshot = await this.tryReadShareCounters(mode); + if (!snapshot) return; + + const totals = shareCounters.update(snapshot); + result.acceptedShares = totals.accepted; + result.staleShares = totals.stale; + }; + + await refreshShareCounters(); + + while (Date.now() < endsAtMs) { + if (signal.aborted) throw abortError(); + + const activePool = await this.dependencies.getActivePool(mode, pools); + if (activePool && activePool.index !== 0) { + const fallback = pools[activePool.index]; + throw new Error( + `Pool connection changed during measurement; ${fallback?.name ?? 'a fallback pool'} became active` + ); + } + + if ( + result.attemptedSamples < maxLatencySamples && + Date.now() >= nextLatencySampleAtMs + ) { + result.attemptedSamples += 1; + try { + const latency = await measureLatency(result.pool, signal); + samples.push(latency); + result.successfulSamples = samples.length; + Object.assign(result, summarizeLatencySamples(samples)); + } catch (error) { + if (isAbortError(error)) throw error; + lastSampleError = errorMessage(error); + } + nextLatencySampleAtMs = + startedAtMs + result.attemptedSamples * latencySampleIntervalMs; + } + + if (Date.now() >= nextShareRefreshAtMs) { + await refreshShareCounters(); + nextShareRefreshAtMs = Date.now() + sampleIntervalMs; + } + + const wakeAtMs = Math.min( + endsAtMs, + nextShareRefreshAtMs, + result.attemptedSamples < maxLatencySamples + ? nextLatencySampleAtMs + : endsAtMs + ); + const sleepMs = wakeAtMs - Date.now(); + if (sleepMs > 0) { + await sleep(sleepMs, signal); + } + } + + await refreshShareCounters(); + result.completedAt = new Date().toISOString(); + + if (result.attemptedSamples < maxLatencySamples) { + result.medianLatencyMs = null; + result.status = 'failed'; + result.error = + `Only ${result.attemptedSamples} of ${maxLatencySamples} TCP latency samples completed`; + return; + } + + if (result.successfulSamples < result.attemptedSamples) { + const failedSamples = result.attemptedSamples - result.successfulSamples; + result.medianLatencyMs = null; + result.status = 'failed'; + result.error = + `${failedSamples} of ${result.attemptedSamples} TCP latency samples failed; ` + + `no latency rank was assigned${lastSampleError ? `: ${lastSampleError}` : ''}`; + return; + } + + result.status = 'completed'; + } + + private async tryReadShareCounters(mode: SetupMode): Promise { + try { + return await this.dependencies.readShareCounters(mode); + } catch { + return null; + } + } +} diff --git a/server/src/index.ts b/server/src/index.ts index 124d6bde..7d16b548 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -11,6 +11,7 @@ import fs from 'fs/promises'; import { fileURLToPath } from 'url'; import type { PoolConfig, SetupData, StatusResponse, SetupResponse } from './types.js'; +import type { BenchmarkStartRequest, SetupMode } from '@sv2-ui/shared'; import { generateTranslatorConfig, generateJdcConfig, normalizeSetupData } from './config-generator.js'; import { isSupportedBitcoinCoreVersion, @@ -18,6 +19,7 @@ import { TRANSLATOR_MONITORING_PORT, JDC_MONITORING_PORT, getMinerTelemetryCidrError, + MAX_FALLBACK_POOLS, } from '@sv2-ui/shared'; import { BITCOIN_ERROR_MESSAGES } from './messages.js'; import { @@ -34,7 +36,20 @@ import { } from './docker.js'; import { getLogDiagnostics, getLogStreams, readCollatedLogLines } from './logs/diagnostics.js'; import { ActivePoolTracker } from './active-pool.js'; -import { getPoolConfigError, MAX_FALLBACK_POOLS } from './pool-validation.js'; +import { BenchmarkManager, type ShareChannelCounters } from './benchmark.js'; +import { getPoolConfigError } from './pool-validation.js'; +import { + collectPaginatedMonitoringItems, + getTelegramWorkerCount, + TelegramApiError, + TelegramConfigError, + TelegramService, +} from './telegram.js'; +import type { + TelegramActivitySnapshot, + TelegramMiningChannel, + TelegramSettingsUpdate, +} from './telegram.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); @@ -43,13 +58,23 @@ const PORT = process.env.PORT || 3001; // Config storage const CONFIG_DIR = process.env.CONFIG_DIR || path.join(__dirname, '../../data/config'); const STATE_FILE = path.join(CONFIG_DIR, 'state.json'); +const TELEGRAM_SETTINGS_FILE = path.join(CONFIG_DIR, 'telegram.json'); const AUTO_START_RETRY_INTERVAL_MS = 30_000; +const TELEGRAM_POLL_INTERVAL_MS = 5_000; -type StackBusyReason = 'auto-start' | 'manual'; +type StackBusyReason = 'auto-start' | 'manual' | 'benchmark'; let stackBusyReason: StackBusyReason | null = null; const activePoolTracker = new ActivePoolTracker(readContainerLogs); +const telegramService = new TelegramService(TELEGRAM_SETTINGS_FILE); +let telegramMonitorTimer: ReturnType | null = null; +const benchmarkManager = new BenchmarkManager({ + applyConfiguration: applyBenchmarkConfiguration, + getActivePool: getBenchmarkActivePool, + readShareCounters: readBenchmarkShareCounters, + onSettled: () => finishStackOperation('benchmark'), +}); type SavedState = { configured: boolean; @@ -136,6 +161,128 @@ async function saveState(data: SetupData, shouldBeRunning = true): Promise }, null, 2)); } +async function removeConfigDirectory(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + if (stat.isDirectory()) { + await fs.rm(filePath, { recursive: true }); + } + } catch { + // The path does not exist, which is the normal first-run case. + } +} + +async function writeStackConfigFiles(data: SetupData): Promise { + await fs.mkdir(CONFIG_DIR, { recursive: true }); + + const translatorPath = path.join(CONFIG_DIR, 'translator.toml'); + const jdcPath = path.join(CONFIG_DIR, 'jdc.toml'); + await removeConfigDirectory(translatorPath); + await removeConfigDirectory(jdcPath); + + await fs.writeFile(translatorPath, generateTranslatorConfig(data)); + + if (data.mode === 'jd') { + const jdcConfig = generateJdcConfig(data); + if (jdcConfig) { + await fs.writeFile(jdcPath, jdcConfig); + } + } +} + +async function applyBenchmarkConfiguration(data: SetupData): Promise { + await writeStackConfigFiles(data); + activePoolTracker.reset(); + await stopStack(); + await startStack(data, CONFIG_DIR); +} + +async function getBenchmarkActivePool(mode: SetupMode, pools: PoolConfig[]) { + return activePoolTracker.getActivePool(mode === 'jd' ? 'jdc' : 'translator', pools); +} + +type BenchmarkMonitoringServerChannel = { + channel_id: number; + shares_acknowledged?: number; + shares_rejected?: number; + shares_rejected_by_reason?: Record; +}; + +type BenchmarkMonitoringServerChannelsResponse = { + total_extended?: number; + total_standard?: number; + extended_channels?: BenchmarkMonitoringServerChannel[]; + standard_channels?: BenchmarkMonitoringServerChannel[]; +}; + +/** + * The benchmark methodology counts only stale shares: a rejection the pool + * attributes to serving/accepting work too late. Difficulty-related + * rejections reflect vardiff retargeting after the stack restart that every + * benchmark leg begins with, not pool quality, so they are excluded. + * Falls back to the aggregate counter only when the monitoring API omits the + * per-reason map entirely. + */ +function staleShareCount(channel: BenchmarkMonitoringServerChannel): number { + const byReason = channel.shares_rejected_by_reason; + if (!byReason || typeof byReason !== 'object') { + return channel.shares_rejected ?? 0; + } + + let stale = 0; + for (const [reason, count] of Object.entries(byReason)) { + if (/stale/i.test(reason) && Number.isFinite(count)) { + stale += count; + } + } + return stale; +} + +async function readBenchmarkShareCounters(mode: SetupMode): Promise { + const containerName = mode === 'jd' ? 'sv2-jdc' : 'sv2-translator'; + const port = mode === 'jd' ? JDC_MONITORING_PORT : TRANSLATOR_MONITORING_PORT; + const channels: ShareChannelCounters[] = []; + const limit = 100; + let offset = 0; + let totalChannels = 0; + + do { + const response = await fetch( + `${getContainerUrl(containerName, port)}/api/v1/server/channels?offset=${offset}&limit=${limit}`, + { signal: AbortSignal.timeout(5_000) } + ); + + if (!response.ok) { + throw new Error(`Monitoring API returned HTTP ${response.status}`); + } + + const data = await response.json() as BenchmarkMonitoringServerChannelsResponse; + const extendedChannels = data.extended_channels ?? []; + const standardChannels = data.standard_channels ?? []; + + channels.push( + ...extendedChannels.map((channel) => ({ + key: `extended:${channel.channel_id}`, + accepted: channel.shares_acknowledged ?? 0, + stale: staleShareCount(channel), + })), + ...standardChannels.map((channel) => ({ + key: `standard:${channel.channel_id}`, + accepted: channel.shares_acknowledged ?? 0, + stale: staleShareCount(channel), + })) + ); + + totalChannels = Math.max( + data.total_extended ?? extendedChannels.length, + data.total_standard ?? standardChannels.length + ); + offset += limit; + } while (offset < totalChannels); + + return channels; +} + function configuredPools(data: SetupData): PoolConfig[] { if (data.miningMode === 'solo' && data.mode === 'jd') { return []; @@ -217,7 +364,42 @@ function stackBusyResponse() { success: false, error: stackBusyReason === 'auto-start' ? 'Mining services are already starting. Please wait.' - : 'Mining services are busy. Please wait.', + : stackBusyReason === 'benchmark' + ? 'A pool benchmark is in progress. Stop it before changing mining services.' + : 'Mining services are busy. Please wait.', + }; +} + +async function getCurrentStatus(): Promise { + const state = await loadState(); + const containers = await getStackStatus(state.mode); + const running = isStackRunning(state.mode, containers); + const isSovereignSolo = state.data?.miningMode === 'solo' && state.data?.mode === 'jd'; + const pools = state.data && !isSovereignSolo ? configuredPools(state.data) : []; + + if (!running) { + activePoolTracker.reset(); + } + + const activePool = running && state.mode && pools.length > 0 + ? await activePoolTracker.getActivePool( + state.mode === 'jd' ? 'jdc' : 'translator', + pools + ) + : null; + + return { + configured: state.configured, + running, + autoStarting: stackBusyReason === 'auto-start', + shouldBeRunning: state.shouldBeRunning, + miningMode: state.miningMode, + mode: state.mode, + poolName: isSovereignSolo + ? 'Sovereign Solo Mining' + : (activePool?.name ?? null), + activePoolIndex: activePool?.index ?? null, + containers, }; } @@ -237,38 +419,7 @@ app.get('/api/health', async (_req, res) => { */ app.get('/api/status', async (_req, res) => { try { - const state = await loadState(); - const containers = await getStackStatus(state.mode); - const running = isStackRunning(state.mode, containers); - const isSovereignSolo = state.data?.miningMode === 'solo' && state.data?.mode === 'jd'; - const pools = state.data && !isSovereignSolo ? configuredPools(state.data) : []; - - if (!running) { - activePoolTracker.reset(); - } - - const activePool = running && state.mode && pools.length > 0 - ? await activePoolTracker.getActivePool( - state.mode === 'jd' ? 'jdc' : 'translator', - pools - ) - : null; - - const response: StatusResponse = { - configured: state.configured, - running, - autoStarting: stackBusyReason === 'auto-start', - shouldBeRunning: state.shouldBeRunning, - miningMode: state.miningMode, - mode: state.mode, - poolName: isSovereignSolo - ? 'Sovereign Solo Mining' - : (activePool?.name ?? null), - activePoolIndex: activePool?.index ?? null, - containers, - }; - - res.json(response); + res.json(await getCurrentStatus()); } catch (error) { console.error('Status error:', error); res.status(500).json({ error: 'Failed to get status' }); @@ -291,6 +442,208 @@ app.get('/api/config', async (_req, res) => { } }); +/** + * GET /api/benchmark - Get the current or most recent benchmark run. + */ +app.get('/api/benchmark', (_req, res) => { + res.json({ run: benchmarkManager.getSnapshot() }); +}); + +/** + * POST /api/benchmark/start - Start a backend-owned benchmark run. + */ +app.post('/api/benchmark/start', async (req, res) => { + if (!beginStackOperation('benchmark')) { + return res.status(409).json(stackBusyResponse()); + } + + let handedOffToBenchmark = false; + + try { + if (!isJsonObject(req.body)) { + return res.status(400).json({ success: false, error: 'Benchmark request must be a JSON object' }); + } + + const request = req.body as Partial; + if (!Array.isArray(request.pools)) { + return res.status(400).json({ success: false, error: 'Select at least two pools to benchmark' }); + } + if (request.pools.length < 2 || request.pools.length > MAX_FALLBACK_POOLS + 1) { + return res.status(400).json({ + success: false, + error: `Select between 2 and ${MAX_FALLBACK_POOLS + 1} pools to benchmark`, + }); + } + if ( + !Number.isInteger(request.poolDurationSeconds) || + (request.poolDurationSeconds ?? 0) < 60 || + (request.poolDurationSeconds ?? 0) > 86_400 + ) { + return res.status(400).json({ + success: false, + error: 'Benchmark duration must be between 1 minute and 24 hours per pool', + }); + } + + const pools: PoolConfig[] = []; + const endpoints = new Set(); + for (const [index, value] of request.pools.entries()) { + if (!isJsonObject(value)) { + return res.status(400).json({ success: false, error: `Pool ${index + 1} is invalid` }); + } + + const pool = value as unknown as PoolConfig; + const error = getPoolConfigError(pool, `Pool ${index + 1}`); + if (error) { + return res.status(400).json({ success: false, error }); + } + + const endpoint = `${pool.address.toLowerCase()}:${pool.port}`; + if (endpoints.has(endpoint)) { + return res.status(400).json({ success: false, error: 'Each benchmark pool must be unique' }); + } + endpoints.add(endpoint); + pools.push(pool); + } + + const state = await loadState(); + if (!state.configured || !state.data) { + return res.status(400).json({ success: false, error: 'Configure mining before starting a benchmark' }); + } + if (state.data.miningMode === 'solo' && state.data.mode === 'jd') { + return res.status(400).json({ + success: false, + error: 'Pool benchmarking is unavailable for sovereign solo mining', + }); + } + + const benchmarkData = normalizeSetupData({ + ...state.data, + pool: pools[0], + fallbackPools: pools.slice(1), + }); + const setupValidationError = getSetupValidationError(benchmarkData); + if (setupValidationError) { + return res.status(400).json({ success: false, error: setupValidationError }); + } + + await ensureDockerAvailable(); + const bitcoinSocketError = await getBitcoinSocketStartupError(benchmarkData); + if (bitcoinSocketError) { + return res.status(400).json({ success: false, error: bitcoinSocketError }); + } + + const run = benchmarkManager.start( + state.data, + pools, + request.poolDurationSeconds as number + ); + handedOffToBenchmark = true; + res.status(202).json({ success: true, run }); + } catch (error) { + console.error('Benchmark start error:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to start benchmark', + }); + } finally { + if (!handedOffToBenchmark) { + finishStackOperation('benchmark'); + } + } +}); + +/** + * POST /api/benchmark/stop - Stop the run and restore the original pool order. + */ +app.post('/api/benchmark/stop', (_req, res) => { + if (!benchmarkManager.stop()) { + return res.status(409).json({ success: false, error: 'No benchmark is running' }); + } + + return res.status(202).json({ success: true }); +}); + +/** + * POST /api/benchmark/mine - Promote a successful result and start mining. + */ +app.post('/api/benchmark/mine', async (req, res) => { + if (benchmarkManager.isActive()) { + return res.status(409).json(stackBusyResponse()); + } + if (!beginStackOperation('manual')) { + return res.status(409).json(stackBusyResponse()); + } + + try { + if (!isJsonObject(req.body)) { + return res.status(400).json({ success: false, error: 'Pool selection must be a JSON object' }); + } + + const address = typeof req.body.address === 'string' ? req.body.address : ''; + const port = typeof req.body.port === 'number' ? req.body.port : 0; + const selectedPool = benchmarkManager.findSelectedPool(address, port); + const run = benchmarkManager.getSnapshot(); + const successfulResult = run?.results.find((result) => ( + result.status === 'completed' && + result.pool.address.trim().toLowerCase() === address.trim().toLowerCase() && + result.pool.port === port + )); + + if (!selectedPool || !successfulResult) { + return res.status(400).json({ + success: false, + error: 'Choose a pool with a completed benchmark result', + }); + } + + const state = await loadState(); + if (!state.configured || !state.data) { + return res.status(400).json({ success: false, error: 'No configuration to update' }); + } + + const selectedPools = benchmarkManager.getSelectedPools(); + const orderedPools = [ + selectedPool, + ...selectedPools.filter((pool) => ( + pool.address.trim().toLowerCase() !== address.trim().toLowerCase() || + pool.port !== port + )), + ]; + const newData = normalizeSetupData({ + ...state.data, + pool: orderedPools[0], + fallbackPools: orderedPools.slice(1), + }); + const setupValidationError = getSetupValidationError(newData); + if (setupValidationError) { + return res.status(400).json({ success: false, error: setupValidationError }); + } + + await ensureDockerAvailable(); + const bitcoinSocketError = await getBitcoinSocketStartupError(newData); + if (bitcoinSocketError) { + return res.status(400).json({ success: false, error: bitcoinSocketError }); + } + + await writeStackConfigFiles(newData); + await saveState(newData, true); + activePoolTracker.reset(); + await stopStack(); + await startStack(newData, CONFIG_DIR); + + return res.json({ success: true }); + } catch (error) { + console.error('Benchmark mining selection error:', error); + return res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to start mining with selected pool', + }); + } finally { + finishStackOperation('manual'); + } +}); + /** * GET /api/env - Host environment variables relevant to the UI @@ -299,6 +652,87 @@ app.get('/api/env', (_req, res) => { res.json({ HOST_OS: process.env.HOST_OS || null, STRATUM_HOST: process.env.STRATUM_HOST || null }); }); +function sendTelegramError(res: express.Response, error: unknown): void { + if (error instanceof TelegramConfigError) { + res.status(400).json({ success: false, error: error.message }); + return; + } + + if (error instanceof TelegramApiError) { + res.status(error.statusCode).json({ success: false, error: error.message }); + return; + } + + console.error( + 'Telegram settings error:', + error instanceof Error ? error.message : 'Unknown error' + ); + res.status(500).json({ success: false, error: 'Telegram settings could not be updated' }); +} + +/** + * Telegram notification proof of concept. + * + * The bot token and chat ID are stored only in CONFIG_DIR/telegram.json and + * are never included in an API response. + */ +app.get('/api/telegram', async (_req, res) => { + try { + res.json(await telegramService.getSettings()); + } catch (error) { + sendTelegramError(res, error); + } +}); + +app.post('/api/telegram/connect', async (req, res) => { + try { + if (!isJsonObject(req.body) || typeof req.body.botToken !== 'string') { + throw new TelegramConfigError('A Telegram bot token is required'); + } + + res.json(await telegramService.connectBot(req.body.botToken)); + } catch (error) { + sendTelegramError(res, error); + } +}); + +app.post('/api/telegram/pair', async (_req, res) => { + try { + res.json(await telegramService.pairChat()); + } catch (error) { + sendTelegramError(res, error); + } +}); + +app.patch('/api/telegram', async (req, res) => { + try { + if (!isJsonObject(req.body)) { + throw new TelegramConfigError('Telegram settings must be a JSON object'); + } + + res.json(await telegramService.updateSettings(req.body as TelegramSettingsUpdate)); + } catch (error) { + sendTelegramError(res, error); + } +}); + +app.post('/api/telegram/test', async (_req, res) => { + try { + await telegramService.sendTestMessage(); + res.json({ success: true }); + } catch (error) { + sendTelegramError(res, error); + } +}); + +app.delete('/api/telegram', async (_req, res) => { + try { + res.json(await telegramService.disconnect()); + } catch (error) { + sendTelegramError(res, error); + } +}); + /** * POST /api/validate/bitcoin-socket - Check if a Bitcoin Core IPC socket is listening */ @@ -380,40 +814,7 @@ app.put('/api/config', async (req, res) => { return res.status(400).json({ success: false, error: bitcoinSocketError }); } - await fs.mkdir(CONFIG_DIR, { recursive: true }); - - const translatorPath = path.join(CONFIG_DIR, 'translator.toml'); - const jdcPath = path.join(CONFIG_DIR, 'jdc.toml'); - - try { - const translatorStat = await fs.stat(translatorPath); - if (translatorStat.isDirectory()) { - await fs.rm(translatorPath, { recursive: true }); - } - } catch { - // translatorPath doesn't exist or isn't a directory, ignore - } - - try { - const jdcStat = await fs.stat(jdcPath); - if (jdcStat.isDirectory()) { - await fs.rm(jdcPath, { recursive: true }); - } - } catch { - // jdcPath doesn't exist or isn't a directory, ignore - } - - const translatorConfig = generateTranslatorConfig(newData); - await fs.writeFile(translatorPath, translatorConfig); - console.log('Updated translator.toml'); - - if (newData.mode === 'jd') { - const jdcConfig = generateJdcConfig(newData); - if (jdcConfig) { - await fs.writeFile(jdcPath, jdcConfig); - console.log('Updated jdc.toml'); - } - } + await writeStackConfigFiles(newData); await saveState(newData); @@ -520,41 +921,8 @@ app.post('/api/setup', async (req, res) => { return res.status(400).json({ success: false, error: bitcoinSocketError }); } - // Generate config files - await fs.mkdir(CONFIG_DIR, { recursive: true }); - - const translatorPath = path.join(CONFIG_DIR, 'translator.toml'); - const jdcPath = path.join(CONFIG_DIR, 'jdc.toml'); - - // Remove if exists as directory (can happen from Docker volume mounts) - try { - const translatorStat = await fs.stat(translatorPath); - if (translatorStat.isDirectory()) { - await fs.rm(translatorPath, { recursive: true }); - } - } catch { - // Doesn't exist, fine - } - try { - const jdcStat = await fs.stat(jdcPath); - if (jdcStat.isDirectory()) { - await fs.rm(jdcPath, { recursive: true }); - } - } catch { - // Doesn't exist, fine - } - - const translatorConfig = generateTranslatorConfig(data); - await fs.writeFile(translatorPath, translatorConfig); - console.log('Generated translator.toml'); - - if (data.mode === 'jd') { - const jdcConfig = generateJdcConfig(data); - if (jdcConfig) { - await fs.writeFile(jdcPath, jdcConfig); - console.log('Generated jdc.toml'); - } - } + // Generate config files. + await writeStackConfigFiles(data); // Save state await saveState(data); @@ -702,6 +1070,191 @@ function getContainerUrl(containerName: string, port: number): string { : `http://localhost:${port}`; } +type MonitoringGlobal = { + server?: { total_hashrate: number } | null; + sv1_clients?: { total_clients: number; total_hashrate: number } | null; + sv2_clients?: { + total_clients: number; + total_channels: number; + total_hashrate: number; + } | null; +}; + +type MonitoringServerChannel = { + channel_id: number; + user_identity: string; + best_diff: number; + blocks_found: number; + shares_submitted: number; + shares_acknowledged: number; + shares_rejected: number; +}; + +type MonitoringChannelsPage = { + total_extended: number; + total_standard: number; + extended_channels: T[]; + standard_channels: T[]; +}; + +type MonitoringClient = { + client_id: number; +}; + +type MonitoringItemsPage = { + total: number; + items: T[]; +}; + +type TaggedMonitoringChannel = { + kind: 'extended' | 'standard'; + channel: T; +}; + +type MonitoringMiningChannel = { + channel_id: number; + user_identity: string; + best_diff: number; + blocks_found: number; +}; + +async function fetchMonitoringJson(url: string): Promise { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(5000), + }); + return response.ok ? await response.json() as T : null; + } catch { + return null; + } +} + +function combineMonitoringChannelPage( + page: MonitoringChannelsPage, +): { + items: TaggedMonitoringChannel[]; + total: number; +} { + return { + items: [ + ...page.extended_channels.map((channel) => ({ + kind: 'extended' as const, + channel, + })), + ...page.standard_channels.map((channel) => ({ + kind: 'standard' as const, + channel, + })), + ], + total: Math.max(page.total_extended, page.total_standard), + }; +} + +async function fetchAllMonitoringChannels( + endpoint: string, +): Promise[] | null> { + return collectPaginatedMonitoringItems(async (offset, limit) => { + const page = await fetchMonitoringJson>( + `${endpoint}?offset=${offset}&limit=${limit}` + ); + return page ? combineMonitoringChannelPage(page) : null; + }); +} + +async function fetchAllMonitoringItems(endpoint: string): Promise { + return collectPaginatedMonitoringItems(async (offset, limit) => { + const page = await fetchMonitoringJson>( + `${endpoint}?offset=${offset}&limit=${limit}` + ); + return page ? { items: page.items, total: page.total } : null; + }); +} + +async function getTelegramActivitySnapshot(): Promise { + const status = await getCurrentStatus(); + + if (!status.running || !status.mode) { + return { + running: false, + poolName: status.poolName, + activePoolIndex: status.activePoolIndex, + hashrate: null, + workers: null, + sharesSubmitted: null, + sharesAccepted: null, + sharesRejected: null, + channels: null, + }; + } + + const isJdMode = status.mode === 'jd'; + const containerName = isJdMode ? 'sv2-jdc' : 'sv2-translator'; + const port = isJdMode ? JDC_MONITORING_PORT : TRANSLATOR_MONITORING_PORT; + const baseUrl = `${getContainerUrl(containerName, port)}/api/v1`; + const [global, serverChannels, monitoringClients] = await Promise.all([ + fetchMonitoringJson(`${baseUrl}/global`), + fetchAllMonitoringChannels(`${baseUrl}/server/channels`), + isJdMode + ? fetchAllMonitoringItems(`${baseUrl}/clients`) + : Promise.resolve(null), + ]); + + const clients = isJdMode ? global?.sv2_clients : global?.sv1_clients; + let miningChannels: TelegramMiningChannel[] | null = null; + + if (isJdMode && monitoringClients) { + const downstreamResponses = await Promise.all( + monitoringClients.map(async (client) => ({ + clientId: client.client_id, + channels: await fetchAllMonitoringChannels( + `${baseUrl}/clients/${client.client_id}/channels` + ), + })) + ); + + if (downstreamResponses.every((response) => response.channels !== null)) { + miningChannels = downstreamResponses.flatMap(({ clientId, channels }) => { + if (!channels) return []; + return channels.map(({ kind, channel }) => ({ + key: `jdc:${clientId}:${kind}:${channel.channel_id}:${channel.user_identity}`, + userIdentity: channel.user_identity, + blocksFound: channel.blocks_found, + bestDifficulty: channel.best_diff, + })); + }); + } + } else if (!isJdMode && serverChannels) { + miningChannels = serverChannels.map(({ kind, channel }) => ({ + key: `translator:server:${kind}:${channel.channel_id}:${channel.user_identity}`, + userIdentity: channel.user_identity, + blocksFound: channel.blocks_found, + bestDifficulty: channel.best_diff, + })); + } + + return { + running: true, + poolName: status.poolName, + activePoolIndex: status.activePoolIndex, + hashrate: clients?.total_hashrate ?? global?.server?.total_hashrate ?? null, + workers: getTelegramWorkerCount( + isJdMode, + global?.sv1_clients, + global?.sv2_clients, + ), + sharesSubmitted: serverChannels + ? serverChannels.reduce((sum, item) => sum + item.channel.shares_submitted, 0) + : null, + sharesAccepted: serverChannels + ? serverChannels.reduce((sum, item) => sum + item.channel.shares_acknowledged, 0) + : null, + sharesRejected: serverChannels + ? serverChannels.reduce((sum, item) => sum + item.channel.shares_rejected, 0) + : null, + channels: miningChannels, + }; +} + /** * Proxy requests to Translator monitoring API * This avoids CORS issues when the frontend is served from a different port @@ -774,6 +1327,10 @@ async function reconcileShouldBeRunning(): Promise { } } + // Recreate config from saved state before starting. This also guarantees + // recovery to the original pool order if the process stopped mid-benchmark. + await writeStackConfigFiles(state.data); + activePoolTracker.reset(); await startStack(state.data, CONFIG_DIR); console.log('Auto-start: containers started successfully'); } catch (error) { @@ -783,6 +1340,17 @@ async function reconcileShouldBeRunning(): Promise { } } +async function pollTelegramNotifications(): Promise { + try { + await telegramService.poll(getTelegramActivitySnapshot); + } catch (error) { + console.warn( + 'Telegram notification check failed:', + error instanceof Error ? error.message : 'Unknown error' + ); + } +} + app.listen(PORT, () => { const dockerConnection = getDockerConnectionInfo(); @@ -807,6 +1375,13 @@ app.listen(PORT, () => { setInterval(() => { void reconcileShouldBeRunning(); }, AUTO_START_RETRY_INTERVAL_MS); + + // Telegram notifications run in the local backend, so they keep working + // while the browser UI is closed. + void pollTelegramNotifications(); + telegramMonitorTimer = setInterval(() => { + void pollTelegramNotifications(); + }, TELEGRAM_POLL_INTERVAL_MS); }); // Graceful shutdown: stop mining containers when sv2-ui exits @@ -816,8 +1391,17 @@ async function shutdown(signal: string) { if (isShuttingDown) return; isShuttingDown = true; + if (telegramMonitorTimer) { + clearInterval(telegramMonitorTimer); + telegramMonitorTimer = null; + } + console.log(`\n${signal} received. Stopping mining containers...`); try { + if (benchmarkManager.isActive()) { + benchmarkManager.stop(); + await benchmarkManager.waitForCompletion(); + } await stopStack(); console.log('Mining containers stopped.'); } catch { diff --git a/server/src/pool-validation.ts b/server/src/pool-validation.ts index 6cee7816..04246684 100644 --- a/server/src/pool-validation.ts +++ b/server/src/pool-validation.ts @@ -5,7 +5,6 @@ const MAX_POOL_NAME_LENGTH = 128; const MAX_POOL_ADDRESS_LENGTH = 255; const MAX_AUTHORITY_KEY_LENGTH = 128; const MAX_IDENTITY_LENGTH = 512; -export const MAX_FALLBACK_POOLS = 16; // These characters can escape or terminate a generated TOML basic string. // eslint-disable-next-line no-control-regex diff --git a/server/src/telegram.test.ts b/server/src/telegram.test.ts new file mode 100644 index 00000000..4cf58d2b --- /dev/null +++ b/server/src/telegram.test.ts @@ -0,0 +1,441 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test, { type TestContext } from 'node:test'; + +import { + collectPaginatedMonitoringItems, + formatTelegramStatus, + getTelegramWorkerCount, + getStatusChangeMessage, + TelegramConfigError, + TelegramService, +} from './telegram.js'; +import type { TelegramActivitySnapshot } from './telegram.js'; + +const BOT_TOKEN = '123456:test-token'; +const BOT = { + id: 123456, + is_bot: true, + first_name: 'SV2 alerts', + username: 'sv2_alerts_bot', +}; + +type FetchCall = { + method: string; + url: string; + body: Record; +}; + +function createTelegramFetch(initialResults: Record = {}) { + const calls: FetchCall[] = []; + const results = new Map( + Object.entries(initialResults).map(([method, values]) => [method, [...values]]) + ); + + const fetchImplementation = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + const method = url.split('/').at(-1) ?? ''; + const body = init?.body + ? JSON.parse(String(init.body)) as Record + : {}; + calls.push({ method, url, body }); + + const queued = results.get(method); + const result = queued?.length + ? queued.shift() + : method === 'getUpdates' + ? [] + : { message_id: calls.length }; + if (result instanceof Error) throw result; + + return new Response(JSON.stringify({ + ok: true, + result, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + return { + calls, + fetchImplementation, + enqueue(method: string, ...values: unknown[]) { + const queued = results.get(method) ?? []; + queued.push(...values); + results.set(method, queued); + }, + callsFor(method: string) { + return calls.filter((call) => call.method === method); + }, + }; +} + +async function createSettingsFile(t: TestContext): Promise { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sv2-telegram-test-')); + t.after(async () => { + await fs.rm(directory, { recursive: true, force: true }); + }); + return path.join(directory, 'telegram.json'); +} + +function snapshot( + update: Partial = {} +): TelegramActivitySnapshot { + return { + running: true, + poolName: 'Primary pool', + activePoolIndex: 0, + hashrate: 125_000_000_000_000, + workers: 3, + sharesSubmitted: 42, + sharesAccepted: 40, + sharesRejected: 2, + channels: [{ + key: 'translator:server:extended:1:miner-one', + userIdentity: 'miner-one', + blocksFound: 0, + bestDifficulty: 1250, + }], + ...update, + }; +} + +test('collects every monitoring page using the reported total', async () => { + const offsets: number[] = []; + const items = await collectPaginatedMonitoringItems(async (offset, limit) => { + offsets.push(offset); + const total = 205; + const pageLength = Math.min(limit, total - offset); + return { + total, + items: Array.from({ length: pageLength }, (_, index) => offset + index), + }; + }); + + assert.deepEqual(offsets, [0, 100, 200]); + assert.equal(items?.length, 205); + assert.equal(items?.at(-1), 204); +}); + +test('returns no partial monitoring snapshot when a later page fails', async () => { + const items = await collectPaginatedMonitoringItems(async (offset) => ( + offset === 0 + ? { total: 101, items: Array.from({ length: 100 }, (_, index) => index) } + : null + )); + + assert.equal(items, null); +}); + +test('counts JD channels as workers instead of downstream connections', () => { + assert.equal( + getTelegramWorkerCount( + true, + { total_clients: 4 }, + { total_channels: 17 }, + ), + 17, + ); + assert.equal( + getTelegramWorkerCount( + false, + { total_clients: 4 }, + { total_channels: 17 }, + ), + 4, + ); +}); + +async function pairService( + t: TestContext, + telegram = createTelegramFetch({ getMe: [BOT] }) +) { + const settingsFile = await createSettingsFile(t); + const service = new TelegramService(settingsFile, telegram.fetchImplementation); + const connected = await service.connectBot(BOT_TOKEN); + const pairingCode = new URL(connected.pairingUrl ?? '').searchParams.get('start'); + telegram.enqueue('getUpdates', [{ + update_id: 77, + message: { + message_id: 4, + text: `/start ${pairingCode}`, + chat: { + id: 987, + type: 'private', + first_name: 'Miner', + username: 'miner_one', + }, + }, + }]); + await service.pairChat(); + return { service, settingsFile, telegram }; +} + +test('formats a compact mining summary with block and difficulty data', () => { + assert.equal( + formatTelegramStatus(snapshot()), + [ + 'โ› SV2 mining status', + 'Status: Running', + 'Pool: Primary pool', + 'Hashrate: 125 TH/s', + 'Workers: 3', + 'Shares: 42 submitted ยท 40 accepted ยท 2 rejected', + 'Blocks found: 0', + 'Best difficulty: 1,250', + ].join('\n') + ); +}); + +test('reports only meaningful status changes', () => { + const stopped = snapshot({ + running: false, + poolName: null, + activePoolIndex: null, + hashrate: null, + workers: null, + sharesSubmitted: null, + sharesAccepted: null, + sharesRejected: null, + channels: null, + }); + + assert.match(getStatusChangeMessage(stopped, snapshot()) ?? '', /mining started/); + assert.match( + getStatusChangeMessage(snapshot(), snapshot({ poolName: 'Fallback pool' })) ?? '', + /active pool changed/ + ); + assert.equal(getStatusChangeMessage(snapshot(), snapshot()), null); +}); + +test('pairs a private chat with the three critical alerts enabled by default', async (t) => { + const { service, settingsFile, telegram } = await pairService(t); + const paired = await service.getSettings(); + + assert.equal(paired.paired, true); + assert.equal(paired.enabled, true); + assert.equal(paired.recipient, '@miner_one'); + assert.equal(paired.pairingUrl, null); + assert.equal(paired.notifyOnBlockFound, true); + assert.equal(paired.notifyOnBestDifficulty, true); + assert.equal(paired.notifyOnPoolChange, true); + assert.equal(paired.notifyOnStatusChange, false); + assert.equal(paired.notifyOnWorkerChange, false); + assert.equal(paired.notifyOnRejectedShares, false); + assert.equal(paired.summaryIntervalMinutes, 0); + assert.equal(JSON.stringify(paired).includes(BOT_TOKEN), false); + assert.match( + String(telegram.callsFor('sendMessage').at(-1)?.body.text), + /Block found, new best difficulty, and pool failover/ + ); + + const savedMode = (await fs.stat(settingsFile)).mode & 0o777; + assert.equal(savedMode, 0o600); +}); + +test('does not pair a chat until the matching Start command arrives', async (t) => { + const settingsFile = await createSettingsFile(t); + const telegram = createTelegramFetch({ + getMe: [BOT], + getUpdates: [[]], + }); + const service = new TelegramService(settingsFile, telegram.fetchImplementation); + await service.connectBot(BOT_TOKEN); + + await assert.rejects( + service.pairChat(), + (error: unknown) => + error instanceof TelegramConfigError && + error.message.includes('press Start') + ); +}); + +test('establishes a baseline and announces only a new block', async (t) => { + const { service, telegram } = await pairService(t); + + await service.poll(async () => snapshot()); + assert.equal(telegram.callsFor('sendMessage').length, 1); + + await service.poll(async () => snapshot({ channels: null })); + assert.equal(telegram.callsFor('sendMessage').length, 1); + + await service.poll(async () => snapshot({ + channels: [{ + key: 'translator:server:extended:1:miner-one', + userIdentity: 'miner-one', + blocksFound: 1, + bestDifficulty: 99_000, + }], + })); + + const alert = String(telegram.callsFor('sendMessage').at(-1)?.body.text); + assert.match(alert, /^๐ŸŽ‰ Block found!/); + assert.match(alert, /Worker: miner-one/); + assert.doesNotMatch(alert, /New best difficulty/); + + await service.poll(async () => snapshot({ + channels: [{ + key: 'translator:server:extended:1:miner-one', + userIdentity: 'miner-one', + blocksFound: 1, + bestDifficulty: 99_000, + }], + })); + assert.equal(telegram.callsFor('sendMessage').length, 2); +}); + +test('announces a new best difficulty on an existing channel', async (t) => { + const { service, telegram } = await pairService(t); + + await service.poll(async () => snapshot()); + await service.poll(async () => snapshot({ + channels: [{ + key: 'translator:server:extended:1:miner-one', + userIdentity: 'miner-one', + blocksFound: 0, + bestDifficulty: 2500, + }], + })); + + const alert = String(telegram.callsFor('sendMessage').at(-1)?.body.text); + assert.match(alert, /^๐Ÿ† New best difficulty!/); + assert.match(alert, /Difficulty: 2,500/); +}); + +test('detects failover across a temporary unknown pool state', async (t) => { + const { service, telegram } = await pairService(t); + + await service.poll(async () => snapshot()); + await service.poll(async () => snapshot({ + poolName: null, + activePoolIndex: null, + })); + assert.equal(telegram.callsFor('sendMessage').length, 1); + + await service.poll(async () => snapshot({ + poolName: 'Fallback pool', + activePoolIndex: 1, + })); + const alert = String(telegram.callsFor('sendMessage').at(-1)?.body.text); + assert.match(alert, /^๐Ÿ” Pool failover/); + assert.match(alert, /From: Primary pool/); + assert.match(alert, /To: Fallback pool/); +}); + +test('detects failover between custom pools with the same name', async (t) => { + const { service, telegram } = await pairService(t); + + await service.poll(async () => snapshot({ poolName: 'Custom Pool' })); + await service.poll(async () => snapshot({ + poolName: 'Custom Pool', + activePoolIndex: 1, + })); + + const alert = String(telegram.callsFor('sendMessage').at(-1)?.body.text); + assert.match(alert, /^๐Ÿ” Pool failover/); + assert.match(alert, /From: Custom Pool \(Primary\)/); + assert.match(alert, /To: Custom Pool \(Fallback 1\)/); +}); + +test('configures alerts with the bot settings keyboard', async (t) => { + const { service, telegram } = await pairService(t); + + telegram.enqueue('getUpdates', [{ + update_id: 78, + message: { + message_id: 8, + text: '/settings', + chat: { id: 987, type: 'private', first_name: 'Miner' }, + }, + }]); + await service.poll(async () => snapshot()); + + const settingsMessage = telegram.callsFor('sendMessage').at(-1); + assert.match(String(settingsMessage?.body.text), /SV2 Telegram alerts/); + assert.ok(settingsMessage?.body.reply_markup); + + telegram.enqueue('getUpdates', [ + { + update_id: 79, + callback_query: { + id: 'callback-1', + data: 'sv2:toggle:block', + message: { + message_id: 9, + chat: { id: 987, type: 'private', first_name: 'Miner' }, + }, + }, + }, + { + update_id: 80, + callback_query: { + id: 'callback-2', + data: 'sv2:toggle:best', + message: { + message_id: 9, + chat: { id: 987, type: 'private', first_name: 'Miner' }, + }, + }, + }, + ]); + await service.poll(async () => snapshot()); + + const settings = await service.getSettings(); + assert.equal(settings.notifyOnBlockFound, false); + assert.equal(settings.notifyOnBestDifficulty, false); + assert.equal(telegram.callsFor('answerCallbackQuery').length, 2); + assert.equal(telegram.callsFor('editMessageText').length, 2); + assert.equal( + (telegram.callsFor('getUpdates').at(-1)?.body.offset), + 79 + ); +}); + +test('keeps bot commands active when notifications are disabled', async (t) => { + const { service, telegram } = await pairService(t); + await service.updateSettings({ enabled: false }); + telegram.enqueue('getUpdates', [{ + update_id: 78, + message: { + message_id: 10, + text: '/status', + chat: { id: 987, type: 'private', first_name: 'Miner' }, + }, + }]); + + await service.poll(async () => snapshot()); + assert.match( + String(telegram.callsFor('sendMessage').at(-1)?.body.text), + /SV2 mining status/ + ); +}); + +test('migrates the original proof-of-concept settings', async (t) => { + const settingsFile = await createSettingsFile(t); + await fs.writeFile(settingsFile, JSON.stringify({ + version: 1, + botToken: BOT_TOKEN, + botUsername: 'sv2_alerts_bot', + botName: 'SV2 alerts', + pairingCode: null, + chatId: 987, + recipient: '@miner_one', + enabled: true, + notifyOnStatusChange: true, + summaryIntervalMinutes: 60, + })); + + const service = new TelegramService( + settingsFile, + createTelegramFetch().fetchImplementation + ); + const settings = await service.getSettings(); + assert.equal(settings.notifyOnBlockFound, true); + assert.equal(settings.notifyOnBestDifficulty, true); + assert.equal(settings.notifyOnPoolChange, true); + assert.equal(settings.notifyOnStatusChange, true); + assert.equal(settings.summaryIntervalMinutes, 60); +}); diff --git a/server/src/telegram.ts b/server/src/telegram.ts new file mode 100644 index 00000000..52627415 --- /dev/null +++ b/server/src/telegram.ts @@ -0,0 +1,1083 @@ +import { randomBytes } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const MIN_SUMMARY_INTERVAL_MINUTES = 15; +const MAX_SUMMARY_INTERVAL_MINUTES = 24 * 60; +const SUMMARY_INTERVAL_OPTIONS = [0, 15, 60, 6 * 60] as const; + +type FetchImplementation = typeof fetch; + +type TelegramBot = { + id: number; + is_bot: boolean; + first_name: string; + username?: string; +}; + +type TelegramChat = { + id: number; + type: string; + first_name?: string; + last_name?: string; + username?: string; + title?: string; +}; + +type TelegramMessage = { + message_id: number; + text?: string; + chat: TelegramChat; +}; + +type TelegramCallbackQuery = { + id: string; + data?: string; + message?: TelegramMessage; +}; + +type TelegramUpdate = { + update_id: number; + message?: TelegramMessage; + callback_query?: TelegramCallbackQuery; +}; + +type TelegramApiResponse = { + ok: boolean; + result?: T; + description?: string; + error_code?: number; +}; + +type TelegramAlertSettings = { + enabled: boolean; + notifyOnBlockFound: boolean; + notifyOnBestDifficulty: boolean; + notifyOnPoolChange: boolean; + notifyOnStatusChange: boolean; + notifyOnWorkerChange: boolean; + notifyOnRejectedShares: boolean; + summaryIntervalMinutes: number; +}; + +type SavedTelegramSettings = TelegramAlertSettings & { + version: 2; + botToken: string; + botUsername: string; + botName: string; + pairingCode: string | null; + chatId: number | null; + recipient: string | null; + lastUpdateId: number | null; +}; + +type LegacySavedTelegramSettings = { + version: 1; + botToken: string; + botUsername: string; + botName: string; + pairingCode: string | null; + chatId: number | null; + recipient: string | null; + enabled: boolean; + notifyOnStatusChange: boolean; + summaryIntervalMinutes: number; +}; + +export type TelegramSettings = TelegramAlertSettings & { + connected: boolean; + paired: boolean; + botUsername: string | null; + botName: string | null; + recipient: string | null; + pairingUrl: string | null; +}; + +export type TelegramSettingsUpdate = Partial; + +export type TelegramMiningChannel = { + key: string; + userIdentity: string; + blocksFound: number; + bestDifficulty: number; +}; + +export type TelegramActivitySnapshot = { + running: boolean; + poolName: string | null; + activePoolIndex: number | null; + hashrate: number | null; + workers: number | null; + sharesSubmitted: number | null; + sharesAccepted: number | null; + sharesRejected: number | null; + channels: TelegramMiningChannel[] | null; +}; + +type MonitoringPage = { + items: T[]; + total: number; +}; + +export async function collectPaginatedMonitoringItems( + fetchPage: (offset: number, limit: number) => Promise | null>, + pageSize = 100, +): Promise { + if (!Number.isInteger(pageSize) || pageSize <= 0) { + throw new RangeError('Monitoring page size must be a positive integer'); + } + + const items: T[] = []; + let offset = 0; + + while (true) { + const page = await fetchPage(offset, pageSize); + if (!page) return null; + + items.push(...page.items); + if (offset + pageSize >= page.total) return items; + offset += pageSize; + } +} + +export function getTelegramWorkerCount( + isJdMode: boolean, + sv1Clients: { total_clients: number } | null | undefined, + sv2Clients: { total_channels: number } | null | undefined, +): number | null { + return isJdMode + ? sv2Clients?.total_channels ?? null + : sv1Clients?.total_clients ?? null; +} + +export class TelegramConfigError extends Error {} + +export class TelegramApiError extends Error { + constructor( + message: string, + readonly statusCode: number + ) { + super(message); + } +} + +const DEFAULT_ALERT_SETTINGS: TelegramAlertSettings = { + enabled: true, + notifyOnBlockFound: true, + notifyOnBestDifficulty: true, + notifyOnPoolChange: true, + notifyOnStatusChange: false, + notifyOnWorkerChange: false, + notifyOnRejectedShares: false, + summaryIntervalMinutes: 0, +}; + +function getEmptySettings(): TelegramSettings { + return { + connected: false, + paired: false, + botUsername: null, + botName: null, + recipient: null, + pairingUrl: null, + ...DEFAULT_ALERT_SETTINGS, + enabled: false, + }; +} + +function toPublicSettings(settings: SavedTelegramSettings | null): TelegramSettings { + if (!settings) return getEmptySettings(); + + return { + connected: true, + paired: settings.chatId !== null, + botUsername: settings.botUsername, + botName: settings.botName, + recipient: settings.recipient, + pairingUrl: settings.pairingCode + ? `https://t.me/${settings.botUsername}?start=${settings.pairingCode}` + : null, + enabled: settings.enabled, + notifyOnBlockFound: settings.notifyOnBlockFound, + notifyOnBestDifficulty: settings.notifyOnBestDifficulty, + notifyOnPoolChange: settings.notifyOnPoolChange, + notifyOnStatusChange: settings.notifyOnStatusChange, + notifyOnWorkerChange: settings.notifyOnWorkerChange, + notifyOnRejectedShares: settings.notifyOnRejectedShares, + summaryIntervalMinutes: settings.summaryIntervalMinutes, + }; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasConnectionFields(settings: Record): boolean { + return typeof settings.botToken === 'string' && + typeof settings.botUsername === 'string' && + typeof settings.botName === 'string' && + (settings.pairingCode === null || typeof settings.pairingCode === 'string') && + (settings.chatId === null || typeof settings.chatId === 'number') && + (settings.recipient === null || typeof settings.recipient === 'string') && + typeof settings.enabled === 'boolean' && + typeof settings.notifyOnStatusChange === 'boolean' && + Number.isInteger(settings.summaryIntervalMinutes); +} + +function parseSavedSettings(value: unknown): SavedTelegramSettings | null { + if (!isObject(value) || !hasConnectionFields(value)) return null; + + if (value.version === 1) { + const legacy = value as LegacySavedTelegramSettings; + return { + ...legacy, + version: 2, + notifyOnBlockFound: true, + notifyOnBestDifficulty: true, + notifyOnPoolChange: true, + notifyOnWorkerChange: false, + notifyOnRejectedShares: false, + lastUpdateId: null, + }; + } + + if ( + value.version !== 2 || + typeof value.notifyOnBlockFound !== 'boolean' || + typeof value.notifyOnBestDifficulty !== 'boolean' || + typeof value.notifyOnPoolChange !== 'boolean' || + typeof value.notifyOnWorkerChange !== 'boolean' || + typeof value.notifyOnRejectedShares !== 'boolean' || + (value.lastUpdateId !== null && !Number.isInteger(value.lastUpdateId)) + ) { + return null; + } + + return value as SavedTelegramSettings; +} + +function getRecipientLabel(chat: TelegramChat): string { + if (chat.username) return `@${chat.username}`; + if (chat.title) return chat.title; + + const name = [chat.first_name, chat.last_name].filter(Boolean).join(' ').trim(); + return name || 'Telegram chat'; +} + +function getStartParameter(text: string | undefined): string | null { + if (!text) return null; + const match = text.trim().match(/^\/start(?:@[A-Za-z0-9_]+)?\s+([A-Za-z0-9_-]+)$/); + return match?.[1] ?? null; +} + +function getCommand(text: string | undefined): string | null { + if (!text) return null; + const match = text.trim().match(/^\/([a-z]+)(?:@[A-Za-z0-9_]+)?(?:\s|$)/i); + return match?.[1]?.toLowerCase() ?? null; +} + +function formatHashrate(hashrate: number | null): string | null { + if (hashrate === null || !Number.isFinite(hashrate)) return null; + + const units = ['H/s', 'kH/s', 'MH/s', 'GH/s', 'TH/s', 'PH/s', 'EH/s']; + let value = Math.max(0, hashrate); + let unitIndex = 0; + + while (value >= 1000 && unitIndex < units.length - 1) { + value /= 1000; + unitIndex += 1; + } + + const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(decimals)} ${units[unitIndex]}`; +} + +function formatDifficulty(difficulty: number): string { + if (!Number.isFinite(difficulty)) return 'Unknown'; + return new Intl.NumberFormat('en-US', { + maximumFractionDigits: difficulty >= 100 ? 0 : 2, + }).format(difficulty); +} + +function formatPoolPriority(index: number): string { + return index === 0 ? 'Primary' : `Fallback ${index}`; +} + +function getBestChannel(channels: TelegramMiningChannel[] | null): TelegramMiningChannel | null { + if (!channels?.length) return null; + return channels.reduce((best, channel) => + channel.bestDifficulty > best.bestDifficulty ? channel : best + ); +} + +export function formatTelegramStatus( + snapshot: TelegramActivitySnapshot, + heading = 'โ› SV2 mining status' +): string { + const lines = [ + heading, + `Status: ${snapshot.running ? 'Running' : 'Stopped'}`, + ]; + + if (snapshot.poolName) lines.push(`Pool: ${snapshot.poolName}`); + + const hashrate = formatHashrate(snapshot.hashrate); + if (hashrate) lines.push(`Hashrate: ${hashrate}`); + if (snapshot.workers !== null) lines.push(`Workers: ${snapshot.workers.toLocaleString()}`); + + if (snapshot.sharesSubmitted !== null) { + const shareParts = [`${snapshot.sharesSubmitted.toLocaleString()} submitted`]; + if (snapshot.sharesAccepted !== null) { + shareParts.push(`${snapshot.sharesAccepted.toLocaleString()} accepted`); + } + if (snapshot.sharesRejected !== null) { + shareParts.push(`${snapshot.sharesRejected.toLocaleString()} rejected`); + } + lines.push(`Shares: ${shareParts.join(' ยท ')}`); + } + + if (snapshot.channels) { + const blocksFound = snapshot.channels.reduce( + (total, channel) => total + channel.blocksFound, + 0 + ); + const bestChannel = getBestChannel(snapshot.channels); + lines.push(`Blocks found: ${blocksFound.toLocaleString()}`); + if (bestChannel) { + lines.push(`Best difficulty: ${formatDifficulty(bestChannel.bestDifficulty)}`); + } + } + + return lines.join('\n'); +} + +export function getStatusChangeMessage( + previous: TelegramActivitySnapshot, + current: TelegramActivitySnapshot +): string | null { + if (!previous.running && current.running) { + return formatTelegramStatus(current, '๐ŸŸข SV2 mining started'); + } + + if (previous.running && !current.running) { + return formatTelegramStatus(current, '๐Ÿ”ด SV2 mining stopped'); + } + + if ( + current.running && + current.poolName !== null && + ( + previous.poolName !== current.poolName || + ( + previous.activePoolIndex !== null && + current.activePoolIndex !== null && + previous.activePoolIndex !== current.activePoolIndex + ) + ) + ) { + return formatTelegramStatus(current, '๐Ÿ” SV2 active pool changed'); + } + + return null; +} + +function getMiningStatusChangeMessage( + previous: TelegramActivitySnapshot, + current: TelegramActivitySnapshot +): string | null { + if (!previous.running && current.running) { + return formatTelegramStatus(current, '๐ŸŸข SV2 mining started'); + } + if (previous.running && !current.running) { + return formatTelegramStatus(current, '๐Ÿ”ด SV2 mining stopped'); + } + return null; +} + +function getBlockFoundMessages( + previous: TelegramActivitySnapshot, + current: TelegramActivitySnapshot +): string[] { + if (!previous.channels || !current.channels) return []; + + const previousByKey = new Map(previous.channels.map((channel) => [channel.key, channel])); + return current.channels.flatMap((channel) => { + const before = previousByKey.get(channel.key); + if (!before || channel.blocksFound <= before.blocksFound) return []; + + const delta = channel.blocksFound - before.blocksFound; + const lines = ['๐ŸŽ‰ Block found!']; + if (current.poolName) lines.push(`Pool: ${current.poolName}`); + lines.push(`Worker: ${channel.userIdentity}`); + lines.push( + delta === 1 + ? `Channel total: ${channel.blocksFound.toLocaleString()}` + : `New blocks: ${delta.toLocaleString()} ยท Channel total: ${channel.blocksFound.toLocaleString()}` + ); + lines.push(`Best difficulty: ${formatDifficulty(channel.bestDifficulty)}`); + return [lines.join('\n')]; + }); +} + +function getBestDifficultyMessage( + previous: TelegramActivitySnapshot, + current: TelegramActivitySnapshot, + highWatermark: number +): string | null { + if (!previous.channels || !current.channels) return null; + + const previousByKey = new Map(previous.channels.map((channel) => [channel.key, channel])); + const improved = current.channels + .filter((channel) => { + const before = previousByKey.get(channel.key); + return before && + channel.bestDifficulty > before.bestDifficulty && + channel.bestDifficulty > highWatermark; + }) + .sort((left, right) => right.bestDifficulty - left.bestDifficulty)[0]; + + if (!improved) return null; + + const lines = ['๐Ÿ† New best difficulty!']; + if (current.poolName) lines.push(`Pool: ${current.poolName}`); + lines.push(`Worker: ${improved.userIdentity}`); + lines.push(`Difficulty: ${formatDifficulty(improved.bestDifficulty)}`); + return lines.join('\n'); +} + +function formatSummaryInterval(minutes: number): string { + if (minutes === 0) return 'Off'; + if (minutes % 60 === 0) return `${minutes / 60}h`; + return `${minutes}m`; +} + +function getSettingsMessage(settings: SavedTelegramSettings): string { + return [ + 'โš™๏ธ SV2 Telegram alerts', + '', + 'Tap a button to toggle an alert. Critical defaults are block found, new best difficulty, and pool failover.', + '', + `Notifications: ${settings.enabled ? 'ON' : 'OFF'}`, + `Summary: ${formatSummaryInterval(settings.summaryIntervalMinutes)}`, + ].join('\n'); +} + +function toggleButton(enabled: boolean, label: string, callbackData: string) { + return { + text: `${enabled ? 'โœ…' : 'โฌœ'} ${label}`, + callback_data: callbackData, + }; +} + +function getSettingsKeyboard(settings: SavedTelegramSettings) { + return { + inline_keyboard: [ + [toggleButton(settings.enabled, 'Notifications', 'sv2:toggle:enabled')], + [ + toggleButton(settings.notifyOnBlockFound, 'Block found', 'sv2:toggle:block'), + toggleButton(settings.notifyOnBestDifficulty, 'Best difficulty', 'sv2:toggle:best'), + ], + [toggleButton(settings.notifyOnPoolChange, 'Pool failover', 'sv2:toggle:pool')], + [ + toggleButton(settings.notifyOnStatusChange, 'Mining status', 'sv2:toggle:status'), + toggleButton(settings.notifyOnWorkerChange, 'Workers', 'sv2:toggle:workers'), + ], + [toggleButton(settings.notifyOnRejectedShares, 'Rejected shares', 'sv2:toggle:rejected')], + [{ + text: `โฑ Summary: ${formatSummaryInterval(settings.summaryIntervalMinutes)}`, + callback_data: 'sv2:toggle:summary', + }], + ], + }; +} + +function getHelpMessage(): string { + return [ + 'โ› SV2 UI Telegram bot', + '', + '/settings โ€” choose alerts', + '/status โ€” current mining status', + '/help โ€” show these commands', + ].join('\n'); +} + +function cycleSummaryInterval(current: number): number { + const currentIndex = SUMMARY_INTERVAL_OPTIONS.indexOf( + current as typeof SUMMARY_INTERVAL_OPTIONS[number] + ); + return SUMMARY_INTERVAL_OPTIONS[ + currentIndex === -1 ? 0 : (currentIndex + 1) % SUMMARY_INTERVAL_OPTIONS.length + ]; +} + +export class TelegramService { + private settings: SavedTelegramSettings | null = null; + private initialized = false; + private previousSnapshot: TelegramActivitySnapshot | null = null; + private channelBaselines = new Map(); + private bestDifficultyHighWatermark = 0; + private lastSummaryAt: number | null = null; + private lastKnownPool: { name: string; index: number } | null = null; + private pollInProgress = false; + + constructor( + private readonly settingsFile: string, + private readonly fetchImplementation: FetchImplementation = fetch + ) {} + + async initialize(): Promise { + if (this.initialized) return; + + try { + const raw = await fs.readFile(this.settingsFile, 'utf8'); + const parsed = parseSavedSettings(JSON.parse(raw) as unknown); + if (!parsed) { + throw new TelegramConfigError('Stored Telegram settings are invalid'); + } + this.settings = parsed; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') { + console.warn( + 'Telegram settings could not be loaded:', + error instanceof Error ? error.message : 'Unknown error' + ); + } + this.settings = null; + } + + this.initialized = true; + } + + async getSettings(): Promise { + await this.initialize(); + return toPublicSettings(this.settings); + } + + async connectBot(botToken: string): Promise { + await this.initialize(); + const token = botToken.trim(); + + if (!token || token.length > 256 || /\s/.test(token)) { + throw new TelegramConfigError('Enter a valid Telegram bot token'); + } + + const bot = await this.callApi(token, 'getMe'); + if (!bot.is_bot || !bot.username) { + throw new TelegramConfigError('Telegram did not return a usable bot username'); + } + + this.settings = { + version: 2, + botToken: token, + botUsername: bot.username, + botName: bot.first_name, + pairingCode: `sv2_${randomBytes(18).toString('base64url')}`, + chatId: null, + recipient: null, + lastUpdateId: null, + ...DEFAULT_ALERT_SETTINGS, + enabled: false, + }; + this.resetMonitorState(); + await this.save(); + return toPublicSettings(this.settings); + } + + async pairChat(): Promise { + await this.initialize(); + const settings = this.requireConnected(); + + if (settings.chatId !== null) { + return toPublicSettings(settings); + } + if (!settings.pairingCode) { + throw new TelegramConfigError('Create a new Telegram pairing link first'); + } + + const updates = await this.callApi( + settings.botToken, + 'getUpdates', + { + offset: -100, + limit: 100, + timeout: 0, + allowed_updates: ['message'], + } + ); + + const matchingUpdate = [...updates].reverse().find((update) => + update.message?.chat.type === 'private' && + getStartParameter(update.message.text) === settings.pairingCode + ); + const chat = matchingUpdate?.message?.chat; + + if (!chat) { + throw new TelegramConfigError( + `Open @${settings.botUsername} from the pairing link and press Start, then try again` + ); + } + + const pairedSettings: SavedTelegramSettings = { + ...settings, + pairingCode: null, + chatId: chat.id, + recipient: getRecipientLabel(chat), + lastUpdateId: updates.reduce( + (latest, update) => Math.max(latest, update.update_id), + matchingUpdate.update_id + ), + ...DEFAULT_ALERT_SETTINGS, + }; + + await this.sendMessage( + pairedSettings.botToken, + chat.id, + [ + 'โœ… SV2 UI is linked.', + '', + 'Block found, new best difficulty, and pool failover alerts are enabled.', + 'Use /settings to configure alerts or /status for a live summary.', + ].join('\n') + ); + + this.settings = pairedSettings; + this.resetMonitorState(); + await this.save(); + return toPublicSettings(this.settings); + } + + async updateSettings(update: TelegramSettingsUpdate): Promise { + await this.initialize(); + const settings = this.requirePaired(); + const next = { + ...settings, + enabled: update.enabled ?? settings.enabled, + notifyOnBlockFound: update.notifyOnBlockFound ?? settings.notifyOnBlockFound, + notifyOnBestDifficulty: + update.notifyOnBestDifficulty ?? settings.notifyOnBestDifficulty, + notifyOnPoolChange: update.notifyOnPoolChange ?? settings.notifyOnPoolChange, + notifyOnStatusChange: update.notifyOnStatusChange ?? settings.notifyOnStatusChange, + notifyOnWorkerChange: update.notifyOnWorkerChange ?? settings.notifyOnWorkerChange, + notifyOnRejectedShares: + update.notifyOnRejectedShares ?? settings.notifyOnRejectedShares, + summaryIntervalMinutes: + update.summaryIntervalMinutes ?? settings.summaryIntervalMinutes, + }; + + const booleanKeys = [ + 'enabled', + 'notifyOnBlockFound', + 'notifyOnBestDifficulty', + 'notifyOnPoolChange', + 'notifyOnStatusChange', + 'notifyOnWorkerChange', + 'notifyOnRejectedShares', + ] as const; + if (booleanKeys.some((key) => typeof next[key] !== 'boolean')) { + throw new TelegramConfigError('Notification settings must be true or false'); + } + if ( + !Number.isInteger(next.summaryIntervalMinutes) || + (next.summaryIntervalMinutes !== 0 && + ( + next.summaryIntervalMinutes < MIN_SUMMARY_INTERVAL_MINUTES || + next.summaryIntervalMinutes > MAX_SUMMARY_INTERVAL_MINUTES + )) + ) { + throw new TelegramConfigError( + `Summary interval must be 0 (off) or ${MIN_SUMMARY_INTERVAL_MINUTES}-${MAX_SUMMARY_INTERVAL_MINUTES} minutes` + ); + } + + this.settings = next; + this.resetMonitorState(); + await this.save(); + return toPublicSettings(this.settings); + } + + async sendTestMessage(): Promise { + await this.initialize(); + const settings = this.requirePaired(); + await this.sendMessage( + settings.botToken, + settings.chatId, + 'โœ… SV2 UI Telegram notifications are working.' + ); + } + + async disconnect(): Promise { + await this.initialize(); + this.settings = null; + this.resetMonitorState(); + + try { + await fs.unlink(this.settingsFile); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + return getEmptySettings(); + } + + async poll(snapshotProvider: () => Promise): Promise { + await this.initialize(); + if (this.pollInProgress) return; + + const initialSettings = this.settings; + if (!initialSettings || initialSettings.chatId === null) { + this.resetMonitorState(); + return; + } + + this.pollInProgress = true; + let snapshotPromise: Promise | null = null; + const getSnapshot = () => { + snapshotPromise ??= snapshotProvider(); + return snapshotPromise; + }; + + try { + await this.processBotUpdates( + initialSettings as SavedTelegramSettings & { chatId: number }, + getSnapshot + ); + const settings = this.requirePaired(); + if (!settings.enabled) { + this.resetMonitorState(); + return; + } + + const current = await getSnapshot(); + const now = Date.now(); + + if (!this.previousSnapshot) { + this.previousSnapshot = current; + this.updateChannelBaselines(current.channels); + this.updateLastKnownPool(current); + this.lastSummaryAt = now; + return; + } + + const messages: string[] = []; + const channelBaselineSnapshot = { + ...this.previousSnapshot, + channels: [...this.channelBaselines.values()], + }; + const blockMessages = settings.notifyOnBlockFound + ? getBlockFoundMessages(channelBaselineSnapshot, current) + : []; + messages.push(...blockMessages); + + if ( + settings.notifyOnBestDifficulty && + blockMessages.length === 0 + ) { + const bestDifficultyMessage = getBestDifficultyMessage( + channelBaselineSnapshot, + current, + this.bestDifficultyHighWatermark + ); + if (bestDifficultyMessage) messages.push(bestDifficultyMessage); + } + + const currentPool = current.poolName !== null && current.activePoolIndex !== null + ? { name: current.poolName, index: current.activePoolIndex } + : null; + if ( + settings.notifyOnPoolChange && + current.running && + this.lastKnownPool && + currentPool && + ( + this.lastKnownPool.index !== currentPool.index || + this.lastKnownPool.name !== currentPool.name + ) + ) { + const duplicateName = this.lastKnownPool.name === currentPool.name; + const previousLabel = duplicateName + ? `${this.lastKnownPool.name} (${formatPoolPriority(this.lastKnownPool.index)})` + : this.lastKnownPool.name; + const currentLabel = duplicateName + ? `${currentPool.name} (${formatPoolPriority(currentPool.index)})` + : currentPool.name; + messages.push([ + '๐Ÿ” Pool failover', + `From: ${previousLabel}`, + `To: ${currentLabel}`, + ].join('\n')); + } + + if (settings.notifyOnStatusChange) { + const statusMessage = getMiningStatusChangeMessage(this.previousSnapshot, current); + if (statusMessage) messages.push(statusMessage); + } + + if ( + settings.notifyOnWorkerChange && + this.previousSnapshot.workers !== null && + current.workers !== null && + this.previousSnapshot.workers !== current.workers + ) { + messages.push([ + current.workers > this.previousSnapshot.workers + ? '๐ŸŸข Worker connected' + : '๐ŸŸ  Worker disconnected', + `Workers: ${this.previousSnapshot.workers.toLocaleString()} โ†’ ${current.workers.toLocaleString()}`, + ].join('\n')); + } + + if ( + settings.notifyOnRejectedShares && + this.previousSnapshot.sharesRejected !== null && + current.sharesRejected !== null && + current.sharesRejected > this.previousSnapshot.sharesRejected + ) { + messages.push([ + 'โš ๏ธ Rejected shares increased', + `New rejected shares: ${(current.sharesRejected - this.previousSnapshot.sharesRejected).toLocaleString()}`, + `Total rejected: ${current.sharesRejected.toLocaleString()}`, + ].join('\n')); + } + + const summaryDue = settings.summaryIntervalMinutes > 0 && + this.lastSummaryAt !== null && + now - this.lastSummaryAt >= settings.summaryIntervalMinutes * 60_000; + + if (messages.length > 0) { + await this.sendMessage(settings.botToken, settings.chatId, messages.join('\n\n')); + this.lastSummaryAt = now; + } else if (summaryDue) { + await this.sendMessage( + settings.botToken, + settings.chatId, + formatTelegramStatus(current) + ); + this.lastSummaryAt = now; + } + + this.previousSnapshot = current.channels === null + ? { ...current, channels: this.previousSnapshot.channels } + : current; + this.updateChannelBaselines(current.channels); + this.updateLastKnownPool(current); + } finally { + this.pollInProgress = false; + } + } + + private async processBotUpdates( + settings: SavedTelegramSettings & { chatId: number }, + getSnapshot: () => Promise + ): Promise { + const updates = await this.callApi( + settings.botToken, + 'getUpdates', + { + offset: settings.lastUpdateId === null ? 0 : settings.lastUpdateId + 1, + limit: 100, + timeout: 0, + allowed_updates: ['message', 'callback_query'], + } + ); + + if (updates.length === 0) return; + + for (const update of [...updates].sort((left, right) => left.update_id - right.update_id)) { + if (this.settings) { + this.settings = { ...this.settings, lastUpdateId: update.update_id }; + await this.save(); + } + const currentSettings = this.requirePaired(); + const message = update.message; + if ( + message?.chat.type === 'private' && + message.chat.id === currentSettings.chatId + ) { + const command = getCommand(message.text); + if (command === 'settings' || command === 'alerts') { + await this.sendSettingsMessage(currentSettings); + } else if (command === 'status') { + await this.sendMessage( + currentSettings.botToken, + currentSettings.chatId, + formatTelegramStatus(await getSnapshot()) + ); + } else if (command === 'start' || command === 'help') { + await this.sendMessage( + currentSettings.botToken, + currentSettings.chatId, + getHelpMessage() + ); + } + } + + const callback = update.callback_query; + if ( + callback?.message?.chat.type === 'private' && + callback.message.chat.id === currentSettings.chatId && + callback.data?.startsWith('sv2:toggle:') + ) { + await this.handleToggle(callback); + } + } + } + + private async handleToggle(callback: TelegramCallbackQuery): Promise { + const settings = this.requirePaired(); + const key = callback.data?.slice('sv2:toggle:'.length); + const next = { ...settings }; + + switch (key) { + case 'enabled': + next.enabled = !next.enabled; + break; + case 'block': + next.notifyOnBlockFound = !next.notifyOnBlockFound; + break; + case 'best': + next.notifyOnBestDifficulty = !next.notifyOnBestDifficulty; + break; + case 'pool': + next.notifyOnPoolChange = !next.notifyOnPoolChange; + break; + case 'status': + next.notifyOnStatusChange = !next.notifyOnStatusChange; + break; + case 'workers': + next.notifyOnWorkerChange = !next.notifyOnWorkerChange; + break; + case 'rejected': + next.notifyOnRejectedShares = !next.notifyOnRejectedShares; + break; + case 'summary': + next.summaryIntervalMinutes = cycleSummaryInterval(next.summaryIntervalMinutes); + break; + default: + await this.callApi(settings.botToken, 'answerCallbackQuery', { + callback_query_id: callback.id, + text: 'This alert option is no longer available.', + }); + return; + } + + this.settings = next; + this.resetMonitorState(); + await this.save(); + await this.callApi(settings.botToken, 'answerCallbackQuery', { + callback_query_id: callback.id, + text: 'Alert settings updated.', + }); + + if (callback.message) { + await this.callApi(settings.botToken, 'editMessageText', { + chat_id: settings.chatId, + message_id: callback.message.message_id, + text: getSettingsMessage(next), + reply_markup: getSettingsKeyboard(next), + }); + } + } + + private async sendSettingsMessage(settings: SavedTelegramSettings): Promise { + await this.callApi(settings.botToken, 'sendMessage', { + chat_id: settings.chatId, + text: getSettingsMessage(settings), + reply_markup: getSettingsKeyboard(settings), + }); + } + + private requireConnected(): SavedTelegramSettings { + if (!this.settings) { + throw new TelegramConfigError('Connect a Telegram bot first'); + } + return this.settings; + } + + private requirePaired(): SavedTelegramSettings & { chatId: number } { + const settings = this.requireConnected(); + if (settings.chatId === null) { + throw new TelegramConfigError('Pair a Telegram chat first'); + } + return settings as SavedTelegramSettings & { chatId: number }; + } + + private async save(): Promise { + if (!this.settings) return; + + await fs.mkdir(path.dirname(this.settingsFile), { recursive: true }); + await fs.writeFile( + this.settingsFile, + JSON.stringify(this.settings, null, 2), + { mode: 0o600 } + ); + await fs.chmod(this.settingsFile, 0o600); + } + + private resetMonitorState(): void { + this.previousSnapshot = null; + this.channelBaselines.clear(); + this.bestDifficultyHighWatermark = 0; + this.lastSummaryAt = null; + this.lastKnownPool = null; + } + + private updateLastKnownPool(snapshot: TelegramActivitySnapshot): void { + if (snapshot.poolName !== null && snapshot.activePoolIndex !== null) { + this.lastKnownPool = { + name: snapshot.poolName, + index: snapshot.activePoolIndex, + }; + } + } + + private updateChannelBaselines(channels: TelegramMiningChannel[] | null): void { + if (!channels) return; + + for (const channel of channels) { + this.channelBaselines.set(channel.key, channel); + this.bestDifficultyHighWatermark = Math.max( + this.bestDifficultyHighWatermark, + channel.bestDifficulty + ); + } + } + + private async sendMessage(botToken: string, chatId: number, text: string): Promise { + await this.callApi(botToken, 'sendMessage', { chat_id: chatId, text }); + } + + private async callApi( + botToken: string, + method: string, + body: Record = {} + ): Promise { + let response: Response; + + try { + response = await this.fetchImplementation( + `https://api.telegram.org/bot${botToken}/${method}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + } + ); + } catch { + throw new TelegramApiError( + 'Could not reach Telegram. Check this machineโ€™s internet connection.', + 502 + ); + } + + const payload = await response.json().catch(() => null) as TelegramApiResponse | null; + if (!response.ok || !payload?.ok || payload.result === undefined) { + const description = payload?.description?.replace(/^Bad Request:\s*/i, ''); + const message = description || 'Telegram rejected the request'; + throw new TelegramApiError(message, response.status >= 400 ? response.status : 502); + } + + return payload.result; + } +} diff --git a/shared/src/constants.ts b/shared/src/constants.ts index 355c4b45..23a297c2 100644 --- a/shared/src/constants.ts +++ b/shared/src/constants.ts @@ -42,6 +42,7 @@ export const DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE = 4; export const MAX_DOWNSTREAM_EXTRANONCE2_SIZE = 65_535; export const DEFAULT_MIN_HASHRATE = 100_000_000_000_000; export const DEFAULT_POOL_PORT = 34254; +export const MAX_FALLBACK_POOLS = 16; export function computeDefaultSocketPath(dataDir: string, network: BitcoinNetwork): string { return network === 'mainnet' ? `${dataDir}/node.sock` : `${dataDir}/${network}/node.sock`; diff --git a/shared/src/types.ts b/shared/src/types.ts index 97c7adda..4daae180 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -50,6 +50,58 @@ export interface SetupData { translator: TranslatorConfig | null; } +export type BenchmarkRunStatus = + | 'running' + | 'stopping' + | 'completed' + | 'cancelled' + | 'failed'; + +export type BenchmarkPoolStatus = + | 'pending' + | 'connecting' + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface BenchmarkPoolResult { + pool: PoolConfig; + status: BenchmarkPoolStatus; + startedAt: string | null; + completedAt: string | null; + medianLatencyMs: number | null; + successfulSamples: number; + attemptedSamples: number; + acceptedShares: number | null; + staleShares: number | null; + error?: string; +} + +export interface BenchmarkRun { + id: string; + status: BenchmarkRunStatus; + poolDurationSeconds: number; + createdAt: string; + startedAt: string; + completedAt: string | null; + currentPoolIndex: number | null; + currentPoolStartedAt: string | null; + currentPoolEndsAt: string | null; + selectedPools: PoolConfig[]; + results: BenchmarkPoolResult[]; + error?: string; +} + +export interface BenchmarkStatusResponse { + run: BenchmarkRun | null; +} + +export interface BenchmarkStartRequest { + pools: PoolConfig[]; + poolDurationSeconds: number; +} + export type LogContainerRole = 'translator' | 'jdc'; export type LogOutputStream = 'stdout' | 'stderr'; export type LogSourceKind = 'docker-container-logs' | 'container-log-file'; diff --git a/src/App.tsx b/src/App.tsx index 7f5a47eb..fef27eb2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,13 @@ import { useEffect } from 'react'; -import { Switch, Route, useLocation } from 'wouter'; +import { Redirect, Switch, Route, useLocation } from 'wouter'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from '@/lib/queryClient'; import { UnifiedDashboard } from '@/pages/UnifiedDashboard'; import { Settings } from '@/pages/Settings'; import { Setup } from '@/pages/Setup'; import { FAQ } from '@/pages/FAQ'; +import { Benchmark } from '@/pages/Benchmark'; +import { useExperimentalFeatures } from '@/hooks/useExperimentalFeatures'; import { useSetupStatus } from '@/hooks/useSetupStatus'; /** @@ -26,6 +28,7 @@ import { useSetupStatus } from '@/hooks/useSetupStatus'; function Router() { const [location, navigate] = useLocation(); const { isLoading, isOrchestrated, needsSetup } = useSetupStatus(); + const { features: experimentalFeatures } = useExperimentalFeatures(); // Redirect to setup if needed (only when orchestration backend is present) useEffect(() => { @@ -60,6 +63,11 @@ function Router() { + + {experimentalFeatures.benchmark + ? + : } + diff --git a/src/components/ExperimentalTab.test.ts b/src/components/ExperimentalTab.test.ts new file mode 100644 index 00000000..6881a0aa --- /dev/null +++ b/src/components/ExperimentalTab.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { isTelegramExperimentOpen } from './settings/ExperimentalTab'; + +test('keeps an unconfigured Telegram experiment closed until it is enabled', () => { + const settings = { + connected: false, + paired: false, + enabled: false, + }; + + assert.equal(isTelegramExperimentOpen(settings, null), false); + assert.equal(isTelegramExperimentOpen(settings, true), true); +}); + +test('opens an existing unpaired Telegram setup unless the user closes it', () => { + const settings = { + connected: true, + paired: false, + enabled: false, + }; + + assert.equal(isTelegramExperimentOpen(settings, null), true); + assert.equal(isTelegramExperimentOpen(settings, false), false); +}); + +test('uses the backend notification state after Telegram is paired', () => { + const settings = { + connected: true, + paired: true, + enabled: false, + }; + + assert.equal(isTelegramExperimentOpen(settings, true), false); + assert.equal(isTelegramExperimentOpen({ ...settings, enabled: true }, false), true); +}); diff --git a/src/components/layout/Shell.tsx b/src/components/layout/Shell.tsx index 08e023d3..b15d8239 100644 --- a/src/components/layout/Shell.tsx +++ b/src/components/layout/Shell.tsx @@ -1,10 +1,11 @@ import { useEffect, useRef, useState } from 'react'; import { Link, useLocation } from 'wouter'; -import { Sun, Moon, Menu, X, LayoutDashboard, Settings, HelpCircle } from 'lucide-react'; +import { Sun, Moon, Menu, X, LayoutDashboard, Settings, HelpCircle, Gauge } from 'lucide-react'; import { cn, formatUptime } from '@/lib/utils'; import { getKnownPoolByName } from '@/lib/pools'; import type { AppMode, AppFeatures } from '@/types/api'; import { getAppFeatures } from '@/types/api'; +import { useExperimentalFeatures } from '@/hooks/useExperimentalFeatures'; import { useUiConfig } from '@/hooks/useUiConfig'; import { PoolIcon } from '@/components/ui/pool-icon'; @@ -36,12 +37,22 @@ interface NavItem { href: string; } -function getNavItems(_features: AppFeatures, _appMode: AppMode): NavItem[] { - return [ +function getNavItems( + _features: AppFeatures, + _appMode: AppMode, + benchmarkEnabled: boolean, +): NavItem[] { + const items: NavItem[] = [ { icon: LayoutDashboard, label: 'Dashboard', href: '/' }, { icon: HelpCircle, label: 'Support', href: '/faq' }, { icon: Settings, label: 'Settings', href: '/settings' }, ]; + + if (benchmarkEnabled) { + items.splice(1, 0, { icon: Gauge, label: 'Benchmark', href: '/benchmark' }); + } + + return items; } interface ShellProps { @@ -64,11 +75,12 @@ export function Shell({ const [location] = useLocation(); const { isDark, toggle } = useTheme(); const { config } = useUiConfig(); + const { features: experimentalFeatures } = useExperimentalFeatures(); const [menuOpen, setMenuOpen] = useState(false); const menuRef = useRef(null); const features = getAppFeatures(appMode); - const navItems = getNavItems(features, appMode); + const navItems = getNavItems(features, appMode, experimentalFeatures.benchmark); const connectedPool = getKnownPoolByName(poolName); const connectedStatusLabel = connectionLabel || `Connected to ${poolName || 'Pool'}`; @@ -205,7 +217,7 @@ export function Shell({ )} ) : connectionStatus === 'connecting' - ? 'Connecting...' + ? (connectionLabel || 'Connecting...') : 'Disconnected'} diff --git a/src/components/pools/PoolPriorityEditor.tsx b/src/components/pools/PoolPriorityEditor.tsx index f64275fb..99ae4f2a 100644 --- a/src/components/pools/PoolPriorityEditor.tsx +++ b/src/components/pools/PoolPriorityEditor.tsx @@ -3,7 +3,8 @@ import { ArrowDown, ArrowUp, GripVertical, X } from 'lucide-react'; import { DEFAULT_POOL_PORT, type MiningMode, type PoolConfig } from '@sv2-ui/shared'; import { PoolIcon } from '@/components/ui/pool-icon'; import { - createEmptyCustomPool, + appendEmptyCustomPool, + canAddPool, isSamePool, knownPoolToConfig, type KnownPool, @@ -17,6 +18,7 @@ interface PoolPriorityEditorProps { miningMode: MiningMode | null; isJdMode: boolean; onChange: (pools: PoolConfig[]) => void; + priorityLabel?: (index: number) => string; } function getSelectedPreset(pool: PoolConfig, presets: KnownPool[]): KnownPool | null { @@ -29,14 +31,15 @@ export function PoolPriorityEditor({ miningMode, isJdMode, onChange, + priorityLabel, }: PoolPriorityEditorProps) { const [draggedIndex, setDraggedIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); const draggedIndexRef = useRef(null); - const selectedCustomIndex = pools.findIndex((pool) => !getSelectedPreset(pool, presets)); const unselectedPresets = presets.filter((preset) => ( !pools.some((selectedPool) => isSamePool(selectedPool, preset)) )); + const poolLimitReached = !canAddPool(pools); const togglePreset = (preset: KnownPool) => { const selectedIndex = pools.findIndex((pool) => isSamePool(pool, preset)); @@ -45,7 +48,7 @@ export function PoolPriorityEditor({ return; } - if (preset.badge === 'coming-soon') return; + if (preset.badge === 'coming-soon' || poolLimitReached) return; onChange([ ...pools, @@ -57,20 +60,8 @@ export function PoolPriorityEditor({ ]); }; - const toggleCustomPool = () => { - if (selectedCustomIndex >= 0) { - onChange(pools.filter((_, index) => index !== selectedCustomIndex)); - return; - } - - onChange([ - ...pools, - withCompatiblePoolIdentity( - pools[0], - createEmptyCustomPool(), - miningMode, - ), - ]); + const addCustomPool = () => { + onChange(appendEmptyCustomPool(pools, miningMode)); }; const updatePool = (index: number, pool: PoolConfig) => { @@ -164,7 +155,7 @@ export function PoolPriorityEditor({
{displayName} - {index === 0 ? 'Primary' : `Fallback ${index}`} + {priorityLabel?.(index) ?? (index === 0 ? 'Primary' : `Fallback ${index}`)}
{pool.address && ( @@ -221,7 +212,7 @@ export function PoolPriorityEditor({ })} {unselectedPresets.map((preset) => { - const isDisabled = preset.badge === 'coming-soon'; + const isDisabled = preset.badge === 'coming-soon' || poolLimitReached; return ( - )} + ); } diff --git a/src/components/settings/ExperimentalTab.tsx b/src/components/settings/ExperimentalTab.tsx new file mode 100644 index 00000000..83ac0396 --- /dev/null +++ b/src/components/settings/ExperimentalTab.tsx @@ -0,0 +1,520 @@ +import { useEffect, useState, type ReactNode } from 'react'; +import { + Bot, + ExternalLink, + Loader2, + Send, + Unplug, +} from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { useExperimentalFeatures } from '@/hooks/useExperimentalFeatures'; +import { useTelegram, type TelegramSettings } from '@/hooks/useTelegram'; +import { useLocation } from 'wouter'; + +const TELEGRAM_EXPERIMENT_STORAGE_KEY = 'sv2-ui-experiment-telegram-enabled'; + +function readStoredTelegramExperimentState(): boolean | null { + if (typeof window === 'undefined') return null; + + const stored = window.localStorage.getItem(TELEGRAM_EXPERIMENT_STORAGE_KEY); + return stored === null ? null : stored === 'true'; +} + +function storeTelegramExperimentState(enabled: boolean): void { + if (typeof window === 'undefined') return; + window.localStorage.setItem(TELEGRAM_EXPERIMENT_STORAGE_KEY, String(enabled)); +} + +export function isTelegramExperimentOpen( + settings: Pick, + setupEnabled: boolean | null, +): boolean { + return settings.paired + ? settings.enabled + : setupEnabled ?? settings.connected; +} + +function runMutation(promise: Promise): void { + void promise.catch(() => { + // Mutation errors are rendered from the hook state. + }); +} + +export function ExperimentalTab() { + const [, navigate] = useLocation(); + const { features, setFeature } = useExperimentalFeatures(); + const { + settings, + isLoading, + retry, + isPending, + error, + testSent, + connect, + pair, + update, + sendTest, + disconnect, + clearError, + } = useTelegram(); + const [botToken, setBotToken] = useState(''); + const [summaryInterval, setSummaryInterval] = useState('60'); + const [telegramSetupEnabled, setTelegramSetupEnabled] = useState( + readStoredTelegramExperimentState, + ); + + useEffect(() => { + if (settings) { + setSummaryInterval(String(settings.summaryIntervalMinutes)); + } + }, [settings]); + + const handleConnect = async () => { + clearError(); + try { + await connect(botToken); + setBotToken(''); + } catch { + // Mutation errors are rendered from the hook state. + } + }; + + const handleUpdateSummary = async () => { + clearError(); + const value = Number(summaryInterval); + try { + await update({ summaryIntervalMinutes: value }); + } catch { + // Mutation errors are rendered from the hook state. + } + }; + + const openPairingLink = () => { + if (!settings?.pairingUrl) return; + window.open(settings.pairingUrl, '_blank', 'noopener,noreferrer'); + }; + + const handleTelegramExperimentChange = (enabled: boolean) => { + clearError(); + + if (settings?.paired) { + runMutation(update({ enabled })); + return; + } + + setTelegramSetupEnabled(enabled); + storeTelegramExperimentState(enabled); + }; + + if (isLoading) { + return ( +
+ + Loading experimental settings... +
+ ); + } + + if (!settings) { + return ( +
+

+ {error || 'Telegram settings could not be loaded.'} +

+ +
+ ); + } + + const telegramExperimentOpen = isTelegramExperimentOpen( + settings, + telegramSetupEnabled, + ); + + return ( +
+
+

Experiments

+

+ These features are functional but still being refined. Enable them to try new + capabilities early. +

+
+ +
+ setFeature('benchmark', enabled)} + > +
+

+ Benchmark is now available in the main navigation. +

+ +
+
+ + +
+

Proof-of-concept flow

+

+ Create a dedicated bot with{' '} + + @BotFather + + , enter its token here, then press Start in the private bot chat on Telegram. +

+
+ + {!settings.connected && ( +
+
+ + setBotToken(event.target.value)} + placeholder="Paste the token from @BotFather" + /> +

+ The token controls the bot. SV2 UI stores it only in the local config volume with + owner-only file permissions and never returns it to the browser. +

+
+ + +
+ )} + + {settings.connected && !settings.paired && ( +
+
+
+ + {settings.botName} ยท @{settings.botUsername} +
+

+ Open the one-time link, press Start in Telegram, then come back and check the + pairing. This links only that private chat. +

+
+ +
+ + + +
+
+ )} + + {settings.paired && ( +
+
+
+

Paired with {settings.recipient}

+

+ @{settings.botUsername} sends updates from this local SV2 UI backend. Send{' '} + /settings to configure these alerts in Telegram. +

+
+ +
+ +
+
+
+ +

+ Notify immediately when a channel's block counter increases. +

+
+ { + clearError(); + runMutation(update({ notifyOnBlockFound })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+
+ +

+ Notify when an existing miner channel sets a higher best share difficulty. +

+
+ { + clearError(); + runMutation(update({ notifyOnBestDifficulty })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+
+ +

+ Notify when mining moves from one configured pool to another. +

+
+ { + clearError(); + runMutation(update({ notifyOnPoolChange })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+
+ +

+ Notify when the local mining stack starts or stops. +

+
+ { + clearError(); + runMutation(update({ notifyOnStatusChange })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+
+ +

+ Notify when the monitored worker count increases or decreases. +

+
+ { + clearError(); + runMutation(update({ notifyOnWorkerChange })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+
+ +

+ Notify when the upstream rejected-share counter increases. +

+
+ { + clearError(); + runMutation(update({ notifyOnRejectedShares })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+ +
+ setSummaryInterval(event.target.value)} + disabled={isPending || !settings.enabled} + /> + +
+
+
+ +
+ +
+
+ )} + + {testSent && !error && ( +

+ Test update sent to Telegram. +

+ )} +
+ + {error && ( +

+ {error} +

+ )} +
+
+ ); +} + +function ExperimentToggleCard({ + id, + title, + description, + enabled, + disabled = false, + onEnabledChange, + children, +}: { + id: string; + title: string; + description: string; + enabled: boolean; + disabled?: boolean; + onEnabledChange: (enabled: boolean) => void; + children: ReactNode; +}) { + const switchId = `${id}-enabled`; + const labelId = `${id}-label`; + const contentId = `${id}-content`; + + return ( + +
+
+ +

{description}

+
+ +
+ + {enabled && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/src/hooks/useBenchmark.ts b/src/hooks/useBenchmark.ts new file mode 100644 index 00000000..32c324f3 --- /dev/null +++ b/src/hooks/useBenchmark.ts @@ -0,0 +1,127 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import type { + BenchmarkRun, + BenchmarkStartRequest, + BenchmarkStatusResponse, + SetupData, +} from '@sv2-ui/shared'; + +type ConfigResponse = { + configured: boolean; + config: SetupData | null; +}; + +type BenchmarkMutationResponse = { + success: boolean; + run?: BenchmarkRun; + error?: string; +}; + +async function parseResponse(response: Response): Promise { + const data = await response.json().catch(() => ({})) as T & { + success?: boolean; + error?: string; + message?: string; + }; + + if (!response.ok || data.success === false) { + throw new Error(data.error || data.message || `Request failed (${response.status})`); + } + + return data; +} + +async function fetchBenchmarkStatus(): Promise { + const response = await fetch('/api/benchmark', { + signal: AbortSignal.timeout(5_000), + }); + return parseResponse(response); +} + +async function fetchCurrentConfig(): Promise { + const response = await fetch('/api/config', { + signal: AbortSignal.timeout(5_000), + }); + return parseResponse(response); +} + +async function startBenchmark( + request: BenchmarkStartRequest +): Promise { + const response = await fetch('/api/benchmark/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal: AbortSignal.timeout(300_000), + }); + return parseResponse(response); +} + +async function stopBenchmark(): Promise { + const response = await fetch('/api/benchmark/stop', { + method: 'POST', + }); + return parseResponse(response); +} + +async function startMiningWithPool( + pool: { address: string; port: number } +): Promise { + const response = await fetch('/api/benchmark/mine', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(pool), + signal: AbortSignal.timeout(300_000), + }); + return parseResponse(response); +} + +export function useBenchmark() { + const queryClient = useQueryClient(); + const statusQuery = useQuery({ + queryKey: ['benchmark'], + queryFn: fetchBenchmarkStatus, + refetchInterval: 2_000, + retry: false, + }); + const configQuery = useQuery({ + queryKey: ['current-config'], + queryFn: fetchCurrentConfig, + staleTime: 5_000, + retry: false, + }); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: ['benchmark'] }); + queryClient.invalidateQueries({ queryKey: ['setup-status'] }); + queryClient.invalidateQueries({ queryKey: ['current-config'] }); + }; + + const startMutation = useMutation({ + mutationFn: startBenchmark, + onSuccess: invalidate, + }); + const stopMutation = useMutation({ + mutationFn: stopBenchmark, + onSuccess: invalidate, + }); + const mineMutation = useMutation({ + mutationFn: startMiningWithPool, + onSuccess: invalidate, + }); + + return { + run: statusQuery.data?.run ?? null, + config: configQuery.data?.config ?? null, + isLoading: statusQuery.isLoading || configQuery.isLoading, + statusError: statusQuery.error ?? configQuery.error, + start: startMutation.mutateAsync, + stop: stopMutation.mutateAsync, + startMining: mineMutation.mutateAsync, + isStarting: startMutation.isPending, + isStopping: stopMutation.isPending, + isStartingMining: mineMutation.isPending, + actionError: startMutation.error ?? stopMutation.error ?? mineMutation.error, + }; +} diff --git a/src/hooks/useExperimentalFeatures.test.ts b/src/hooks/useExperimentalFeatures.test.ts new file mode 100644 index 00000000..05f36c3c --- /dev/null +++ b/src/hooks/useExperimentalFeatures.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { normalizeExperimentalFeatures } from './useExperimentalFeatures'; + +test('experimental features are disabled by default', () => { + assert.deepEqual(normalizeExperimentalFeatures(undefined), { benchmark: false }); + assert.deepEqual(normalizeExperimentalFeatures({}), { benchmark: false }); +}); + +test('benchmark is enabled only by an explicit boolean', () => { + assert.deepEqual(normalizeExperimentalFeatures({ benchmark: true }), { benchmark: true }); + assert.deepEqual(normalizeExperimentalFeatures({ benchmark: 'true' }), { benchmark: false }); +}); diff --git a/src/hooks/useExperimentalFeatures.ts b/src/hooks/useExperimentalFeatures.ts new file mode 100644 index 00000000..52c5bd44 --- /dev/null +++ b/src/hooks/useExperimentalFeatures.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useState } from 'react'; + +export interface ExperimentalFeatures { + benchmark: boolean; +} + +const STORAGE_KEY = 'sv2-ui-experimental-features'; +const CHANGE_EVENT = 'sv2-ui-experimental-features-change'; +const DEFAULT_FEATURES: ExperimentalFeatures = { + benchmark: false, +}; + +export function normalizeExperimentalFeatures(value: unknown): ExperimentalFeatures { + if (!value || typeof value !== 'object') return DEFAULT_FEATURES; + + return { + benchmark: (value as Partial).benchmark === true, + }; +} + +function readExperimentalFeatures(): ExperimentalFeatures { + if (typeof window === 'undefined') return DEFAULT_FEATURES; + + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + return stored ? normalizeExperimentalFeatures(JSON.parse(stored)) : DEFAULT_FEATURES; + } catch { + return DEFAULT_FEATURES; + } +} + +function storeExperimentalFeatures(features: ExperimentalFeatures): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(features)); + } catch { + // Keep the in-memory state usable if browser storage is unavailable. + } +} + +export function useExperimentalFeatures() { + const [features, setFeatures] = useState(readExperimentalFeatures); + + useEffect(() => { + const syncFeatures = () => setFeatures(readExperimentalFeatures()); + + window.addEventListener('storage', syncFeatures); + window.addEventListener(CHANGE_EVENT, syncFeatures); + + return () => { + window.removeEventListener('storage', syncFeatures); + window.removeEventListener(CHANGE_EVENT, syncFeatures); + }; + }, []); + + const setFeature = useCallback( + (feature: keyof ExperimentalFeatures, enabled: boolean) => { + const nextFeatures = { + ...readExperimentalFeatures(), + [feature]: enabled, + }; + + storeExperimentalFeatures(nextFeatures); + setFeatures(nextFeatures); + window.dispatchEvent(new Event(CHANGE_EVENT)); + }, + [], + ); + + return { features, setFeature }; +} diff --git a/src/hooks/useTelegram.ts b/src/hooks/useTelegram.ts new file mode 100644 index 00000000..dbde2ae4 --- /dev/null +++ b/src/hooks/useTelegram.ts @@ -0,0 +1,161 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +export type TelegramSettings = { + connected: boolean; + paired: boolean; + botUsername: string | null; + botName: string | null; + recipient: string | null; + pairingUrl: string | null; + enabled: boolean; + notifyOnBlockFound: boolean; + notifyOnBestDifficulty: boolean; + notifyOnPoolChange: boolean; + notifyOnStatusChange: boolean; + notifyOnWorkerChange: boolean; + notifyOnRejectedShares: boolean; + summaryIntervalMinutes: number; +}; + +export type TelegramSettingsUpdate = { + enabled?: boolean; + notifyOnBlockFound?: boolean; + notifyOnBestDifficulty?: boolean; + notifyOnPoolChange?: boolean; + notifyOnStatusChange?: boolean; + notifyOnWorkerChange?: boolean; + notifyOnRejectedShares?: boolean; + summaryIntervalMinutes?: number; +}; + +type SuccessResponse = { + success: true; +}; + +async function parseResponse(response: Response): Promise { + const data = await response.json().catch(() => null) as { + error?: string; + } | null; + + if (!data) { + throw new Error( + 'Telegram settings API is unavailable. Restart the SV2 UI backend from this branch.' + ); + } + + if (!response.ok) { + throw new Error(data.error || `Request failed (${response.status})`); + } + + return data as T; +} + +async function fetchSettings(): Promise { + return parseResponse(await fetch('/api/telegram')); +} + +async function connectBot(botToken: string): Promise { + return parseResponse(await fetch('/api/telegram/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ botToken }), + })); +} + +async function pairChat(): Promise { + return parseResponse(await fetch('/api/telegram/pair', { + method: 'POST', + })); +} + +async function updateSettings(update: TelegramSettingsUpdate): Promise { + return parseResponse(await fetch('/api/telegram', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(update), + })); +} + +async function sendTestMessage(): Promise { + return parseResponse(await fetch('/api/telegram/test', { + method: 'POST', + })); +} + +async function disconnectBot(): Promise { + return parseResponse(await fetch('/api/telegram', { + method: 'DELETE', + })); +} + +export function useTelegram() { + const queryClient = useQueryClient(); + const queryKey = ['telegram-settings']; + + const settingsQuery = useQuery({ + queryKey, + queryFn: fetchSettings, + retry: false, + refetchInterval: 5000, + }); + + const updateCachedSettings = (settings: TelegramSettings) => { + queryClient.setQueryData(queryKey, settings); + }; + + const connectMutation = useMutation({ + mutationFn: connectBot, + onSuccess: updateCachedSettings, + }); + const pairMutation = useMutation({ + mutationFn: pairChat, + onSuccess: updateCachedSettings, + }); + const settingsMutation = useMutation({ + mutationFn: updateSettings, + onSuccess: updateCachedSettings, + }); + const testMutation = useMutation({ + mutationFn: sendTestMessage, + }); + const disconnectMutation = useMutation({ + mutationFn: disconnectBot, + onSuccess: updateCachedSettings, + }); + + const error = + connectMutation.error ?? + pairMutation.error ?? + settingsMutation.error ?? + testMutation.error ?? + disconnectMutation.error ?? + settingsQuery.error; + + const clearError = () => { + connectMutation.reset(); + pairMutation.reset(); + settingsMutation.reset(); + testMutation.reset(); + disconnectMutation.reset(); + }; + + return { + settings: settingsQuery.data, + isLoading: settingsQuery.isLoading, + retry: settingsQuery.refetch, + isPending: + connectMutation.isPending || + pairMutation.isPending || + settingsMutation.isPending || + testMutation.isPending || + disconnectMutation.isPending, + error: error instanceof Error ? error.message : null, + testSent: testMutation.isSuccess, + connect: connectMutation.mutateAsync, + pair: pairMutation.mutateAsync, + update: settingsMutation.mutateAsync, + sendTest: testMutation.mutateAsync, + disconnect: disconnectMutation.mutateAsync, + clearError, + }; +} diff --git a/src/lib/pools.test.ts b/src/lib/pools.test.ts index 4c552668..a49f584e 100644 --- a/src/lib/pools.test.ts +++ b/src/lib/pools.test.ts @@ -1,9 +1,48 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { SOLO_POOLS, knownPoolToConfig } from './pools'; +import { MAX_FALLBACK_POOLS, type PoolConfig } from '@sv2-ui/shared'; +import { + appendEmptyCustomPool, + canAddPool, + knownPoolToConfig, + SOLO_POOLS, +} from './pools'; import { isValidPoolAuthorityPubkey } from './utils'; +const PRIMARY_POOL: PoolConfig = { + name: 'Primary Pool', + address: 'pool.example.com', + port: 3333, + authority_public_key: 'primary-key', + user_identity: 'miner.worker', +}; + +test('appendEmptyCustomPool allows multiple custom fallback pools', () => { + const withFirstCustomPool = appendEmptyCustomPool([PRIMARY_POOL], 'pool'); + const withSecondCustomPool = appendEmptyCustomPool(withFirstCustomPool, 'pool'); + + assert.equal(withSecondCustomPool.length, 3); + assert.equal(withSecondCustomPool[1].name, 'Custom Pool'); + assert.equal(withSecondCustomPool[2].name, 'Custom Pool'); + assert.equal(withSecondCustomPool[1].user_identity, PRIMARY_POOL.user_identity); + assert.equal(withSecondCustomPool[2].user_identity, PRIMARY_POOL.user_identity); +}); + +test('pool additions stop at the configured fallback limit', () => { + const maximumPools = Array.from( + { length: MAX_FALLBACK_POOLS + 1 }, + (_, index) => ({ + ...PRIMARY_POOL, + address: `pool-${index}.example.com`, + }), + ); + + assert.equal(canAddPool(maximumPools.slice(0, -1)), true); + assert.equal(canAddPool(maximumPools), false); + assert.strictEqual(appendEmptyCustomPool(maximumPools, 'pool'), maximumPools); +}); + test('solo pool presets are sorted alphabetically', () => { const names = SOLO_POOLS.map((pool) => pool.name); diff --git a/src/lib/pools.ts b/src/lib/pools.ts index fbad94ac..0cbd1f12 100644 --- a/src/lib/pools.ts +++ b/src/lib/pools.ts @@ -1,7 +1,12 @@ /** * Shared pool preset definitions used by both the Setup Wizard and Settings. */ -import type { PoolConfig } from '@sv2-ui/shared'; +import { + MAX_FALLBACK_POOLS, + type MiningMode, + type PoolConfig, +} from '@sv2-ui/shared'; +import { withCompatiblePoolIdentity } from './miningIdentity'; export interface KnownPool { id: string; @@ -148,6 +153,26 @@ export function createEmptyCustomPool(userIdentity = ''): PoolConfig { }; } +export function appendEmptyCustomPool( + pools: PoolConfig[], + miningMode: MiningMode | null, +): PoolConfig[] { + if (!canAddPool(pools)) return pools; + + return [ + ...pools, + withCompatiblePoolIdentity( + pools[0], + createEmptyCustomPool(), + miningMode, + ), + ]; +} + +export function canAddPool(pools: PoolConfig[]): boolean { + return pools.length < MAX_FALLBACK_POOLS + 1; +} + export function isSamePool(a: Pick | null | undefined, b: Pick | null | undefined): boolean { if (!a || !b) return false; return a.address.trim().toLowerCase() === b.address.trim().toLowerCase() && a.port === b.port; diff --git a/src/pages/Benchmark.tsx b/src/pages/Benchmark.tsx new file mode 100644 index 00000000..8fe4c2d0 --- /dev/null +++ b/src/pages/Benchmark.tsx @@ -0,0 +1,531 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useLocation } from 'wouter'; +import { + AlertTriangle, + Loader2, + Play, + Square, + Trophy, +} from 'lucide-react'; + +import type { BenchmarkPoolResult, BenchmarkPoolStatus, PoolConfig } from '@sv2-ui/shared'; +import { Shell } from '@/components/layout/Shell'; +import { PoolPriorityEditor } from '@/components/pools/PoolPriorityEditor'; +import { Badge, type BadgeProps } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { InfoPopover } from '@/components/ui/info-popover'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { useBenchmark } from '@/hooks/useBenchmark'; +import { useConnectionStatus } from '@/hooks/useConnectionStatus'; +import { useSetupStatus } from '@/hooks/useSetupStatus'; +import { getKnownPoolForConfig, getPoolsForMode } from '@/lib/pools'; +import { PoolIcon } from '@/components/ui/pool-icon'; + +const DURATION_OPTIONS_MINUTES = [1, 5, 10, 30, 60]; + +function formatDuration(totalSeconds: number): string { + if (totalSeconds < 60) return `${totalSeconds}s`; + const hours = Math.floor(totalSeconds / 3_600); + const minutes = Math.ceil((totalSeconds % 3_600) / 60); + if (hours === 0) return `${minutes} min`; + if (minutes === 0) return `${hours} hr`; + return `${hours} hr ${minutes} min`; +} + +function formatLatency(value: number | null | undefined): string { + return value == null ? 'โ€”' : `${value.toFixed(1)} ms`; +} + +function observedShares(result: BenchmarkPoolResult): number | null { + if (result.acceptedShares === null || result.staleShares === null) return null; + return result.acceptedShares + result.staleShares; +} + +function formatStaleRate(result: BenchmarkPoolResult): string | null { + const total = observedShares(result); + if (total === null) return null; + if (total === 0) return 'no shares observed'; + return `${((result.staleShares! / total) * 100).toFixed(2)}% of ${total.toLocaleString()}`; +} + +/** + * Pools are ranked by median ping; when two pings land within the same + * ~5 ms bucket the lower stale-share rate wins the position. A pool with + * no observed shares cannot win a near-tie. + */ +const PING_TIE_BUCKET_MS = 5; + +function staleRateValue(result: BenchmarkPoolResult): number { + const total = observedShares(result); + if (total === null || total === 0) return Number.POSITIVE_INFINITY; + return result.staleShares! / total; +} + +function isRankable(result: BenchmarkPoolResult): boolean { + return result.status === 'completed' && result.medianLatencyMs !== null; +} + +function resultBadge(status: BenchmarkPoolStatus): { + label: string; + variant: BadgeProps['variant']; +} { + switch (status) { + case 'completed': + return { label: 'Complete', variant: 'success' }; + case 'failed': + return { label: 'Failed', variant: 'error' }; + case 'cancelled': + return { label: 'Cancelled', variant: 'secondary' }; + case 'connecting': + return { label: 'Connecting', variant: 'warning' }; + case 'running': + return { label: 'Measuring', variant: 'default' }; + default: + return { label: 'Queued', variant: 'outline' }; + } +} + +function endpointKey(pool: PoolConfig): string { + return `${pool.address.toLowerCase()}:${pool.port}`; +} + +function MetricHeader({ + label, + description, +}: { + label: string; + description: string; +}) { + return ( + + {label} + +

{description}

+
+
+ ); +} + +export function Benchmark() { + const [, navigate] = useLocation(); + const { + status: connectionStatus, + statusLabel: connectionLabel, + poolName, + uptime, + } = useConnectionStatus(); + const { + isOrchestrated, + isConfigured, + miningMode, + mode, + } = useSetupStatus(); + const { + run, + config, + isLoading, + statusError, + start, + stop, + startMining, + isStarting, + isStopping, + isStartingMining, + actionError, + } = useBenchmark(); + + const [pools, setPools] = useState([]); + const [durationMinutes, setDurationMinutes] = useState(10); + const [now, setNow] = useState(Date.now()); + const [miningPoolKey, setMiningPoolKey] = useState(null); + const initialized = useRef(false); + + const isSovereignSolo = miningMode === 'solo' && mode === 'jd'; + const isActive = run?.status === 'running' || run?.status === 'stopping'; + const isTerminal = run?.status === 'completed' || run?.status === 'cancelled' || run?.status === 'failed'; + const presets = getPoolsForMode(miningMode, mode); + + useEffect(() => { + if (initialized.current) return; + + const configuredPools = config?.pool + ? [config.pool, ...(config.fallbackPools ?? [])] + : []; + const initialPools = run?.selectedPools.length ? run.selectedPools : configuredPools; + + if (initialPools.length > 0) { + setPools(initialPools); + if (run) { + setDurationMinutes(Math.max(1, Math.round(run.poolDurationSeconds / 60))); + } + initialized.current = true; + } + }, [config, run]); + + useEffect(() => { + if (!isActive) return; + const interval = window.setInterval(() => setNow(Date.now()), 1_000); + return () => window.clearInterval(interval); + }, [isActive]); + + const currentResult = run?.currentPoolIndex === null || run?.currentPoolIndex === undefined + ? null + : run.results[run.currentPoolIndex] ?? null; + const currentEndsAt = run?.currentPoolEndsAt ? new Date(run.currentPoolEndsAt).getTime() : null; + const currentStartedAt = run?.currentPoolStartedAt + ? new Date(run.currentPoolStartedAt).getTime() + : null; + const remainingSeconds = currentEndsAt === null + ? null + : Math.max(0, Math.ceil((currentEndsAt - now) / 1_000)); + const currentProgress = currentStartedAt !== null && currentEndsAt !== null + ? Math.min(100, Math.max(0, ((now - currentStartedAt) / (currentEndsAt - currentStartedAt)) * 100)) + : 0; + + const rankedResults = useMemo(() => { + if (!run) return []; + if (!isTerminal) return run.results; + + return [...run.results].sort((left, right) => { + if (isRankable(left) && !isRankable(right)) return -1; + if (!isRankable(left) && isRankable(right)) return 1; + if (left.medianLatencyMs === null && right.medianLatencyMs === null) return 0; + if (left.medianLatencyMs === null) return 1; + if (right.medianLatencyMs === null) return -1; + + const leftBucket = Math.round(left.medianLatencyMs / PING_TIE_BUCKET_MS); + const rightBucket = Math.round(right.medianLatencyMs / PING_TIE_BUCKET_MS); + if (leftBucket !== rightBucket) { + return left.medianLatencyMs - right.medianLatencyMs; + } + + const leftRate = staleRateValue(left); + const rightRate = staleRateValue(right); + if (leftRate !== rightRate) return leftRate < rightRate ? -1 : 1; + return left.medianLatencyMs - right.medianLatencyMs; + }); + }, [isTerminal, run]); + + const handleStart = async () => { + try { + await start({ + pools, + poolDurationSeconds: durationMinutes * 60, + }); + } catch { + // Mutation state renders the server error. + } + }; + + const handleStop = async () => { + try { + await stop(); + } catch { + // Mutation state renders the server error. + } + }; + + const handleStartMining = async (result: BenchmarkPoolResult) => { + const key = endpointKey(result.pool); + setMiningPoolKey(key); + try { + await startMining({ + address: result.pool.address, + port: result.pool.port, + }); + navigate('/'); + } catch { + // Mutation state renders the server error. + } finally { + setMiningPoolKey(null); + } + }; + + const error = actionError ?? statusError; + + return ( + +
+
+

Pool Benchmark

+

+ Compare pools by ping and stale shares. +

+

+ Experimental feature. Results can change with network conditions and benchmark duration. +

+
+ + {!isLoading && (!isOrchestrated || !isConfigured || isSovereignSolo) ? ( + + + Benchmark unavailable + + {isSovereignSolo + ? 'Sovereign solo mining does not use an external pool, so there is nothing to compare.' + : 'Complete the mining setup before selecting pools to benchmark.'} + + + + ) : ( + <> + {error && ( +
+ + {error instanceof Error ? error.message : 'Benchmark request failed'} +
+ )} + + {!isActive && ( + + + Benchmark setup + + Select at least two pools and drag them into test order. Each duration + applies per pool. + + + + `Run ${index + 1}`} + /> + +
+
+ + +
+ +
+ Estimated total: + {formatDuration(pools.length * durationMinutes * 60)} + +
+ Opening this page downloads nothing. Mining services are only rotated after Start is clicked. +
+
+ + +
+
+
+ )} + + {run && ( + + +
+
+ + {isActive ? 'Benchmark in progress' : 'Benchmark results'} + + + {run.status === 'stopping' + ? 'Stopping and restoring the original pool configuration...' + : run.status === 'completed' + ? 'Ranked by median TCP connect time. The original pool order has been restored.' + : run.status === 'cancelled' + ? 'The run was stopped and the original pool order was restored.' + : run.status === 'failed' + ? run.error ?? 'The benchmark could not be completed.' + : currentResult?.status === 'connecting' + ? `Connecting to ${currentResult.pool.name} and verifying SV2 negotiation...` + : currentResult + ? `Measuring ${currentResult.pool.name}` + : 'Preparing the next pool...'} + +
+ {isTerminal && rankedResults.some((result) => result.status === 'completed') && ( +
+ + Lowest ping ranks first; stale shares break near-ties +
+ )} + {isActive && ( + + )} +
+ {isActive && currentEndsAt !== null && ( +
+
+ + Pool {(run.currentPoolIndex ?? 0) + 1} of {run.selectedPools.length} + + {formatDuration(remainingSeconds ?? 0)} remaining +
+
+
+
+
+ )} + + + + + + Rank + Pool + Status + + + + + + + Action + + + + {rankedResults.map((result, index) => { + const knownPool = getKnownPoolForConfig(result.pool); + const badge = resultBadge(result.status); + const key = endpointKey(result.pool); + const hasRank = isTerminal && isRankable(result); + const staleRate = formatStaleRate(result); + + return ( + + + {hasRank ? index + 1 : 'โ€”'} + + +
+ +
+
{result.pool.name}
+
+ {result.pool.address}:{result.pool.port} +
+
+
+
+ + {badge.label} + {result.error && ( +
+ {result.error} +
+ )} +
+ +
{formatLatency(result.medianLatencyMs)}
+
+ +
{result.staleShares?.toLocaleString() ?? 'โ€”'}
+ {staleRate && ( +
{staleRate}
+ )} +
+ + {isTerminal && isRankable(result) ? ( + + ) : ( + โ€” + )} + +
+ ); + })} +
+
+

+ Results reflect conditions during this run. Pools are ranked by median ping; + pools within ~{PING_TIE_BUCKET_MS} ms of each other are ordered by stale-share + rate instead. A pool receives no rank if any TCP attempt fails. Short runs see + few shares, so treat the stale rate as meaningful only over longer durations. +

+
+ + )} + + )} +
+ + ); +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 5a5a6849..1febe32a 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -15,16 +15,26 @@ import { Upload, } from 'lucide-react'; import { ConfigurationTab } from '@/components/settings/ConfigurationTab'; +import { ExperimentalTab } from '@/components/settings/ExperimentalTab'; /** - * Settings page with Configuration and Appearance tabs. + * Settings page with Configuration, Logs, Appearance, and Experimental tabs. */ +function getInitialTab(): string { + if (typeof window === 'undefined') return 'configuration'; + + const requestedTab = new URLSearchParams(window.location.search).get('tab'); + return ['configuration', 'logs', 'appearance', 'experimental'].includes(requestedTab ?? '') + ? requestedTab! + : 'configuration'; +} + export function Settings() { const { config, updateConfig, resetConfig } = useUiConfig(); const { status: connectionStatus, statusLabel: connectionLabel, poolName, uptime } = useConnectionStatus(); const { mode } = useSetupStatus(); const isJdMode = mode === 'jd'; - const [activeTab, setActiveTab] = useState('configuration'); + const [activeTab, setActiveTab] = useState(getInitialTab); const { data: rawLogs, isLoading: logsLoading } = useContainerLogs(activeTab === 'logs'); const logoInputRef = useRef(null); @@ -66,16 +76,17 @@ export function Settings() {

Settings

- Manage your configuration and appearance. + Manage your configuration, appearance, and experimental features.

- + Configuration Logs Appearance + Experimental @@ -183,6 +194,10 @@ export function Settings() { + + + +