From 84cbc91caadadd9b2451b899ce764a2e902d45ad Mon Sep 17 00:00:00 2001 From: Prisca Chidimma Maduka Date: Tue, 25 Aug 2026 09:52:32 +0100 Subject: [PATCH] Add PPLNS projection page and watcher section - add the PPLNS projection page, route, and sidebar entry - hide the sidebar entry unless work is retained and aggregated mode is off - reject projections that are not model v2 with horizons 0-8 - treat 404 and "not available" as no projection yet, other failures as errors - carry the HTTP status on DmndApiError so callers can tell 404 from other 4xx - serve the projection to watcher links over the token-only client - share one panel between the owner page and the watcher view --- src/App.tsx | 6 + src/api/__tests__/client.test.ts | 48 +++++ src/api/__tests__/watcherClient.test.ts | 31 +++ src/api/client.ts | 40 +++- src/api/types.ts | 34 ++++ src/api/watcherClient.ts | 41 +++- src/components/dashboard/DashboardShell.tsx | 7 +- src/components/dashboard/Sidebar.tsx | 26 ++- src/components/dashboard/nav.ts | 13 +- .../pplns-projection/PplnsProjectionPanel.tsx | 180 ++++++++++++++++++ .../watcher-links/view/WatcherSidebar.tsx | 7 +- src/hooks/usePplnsProjection.ts | 26 +++ src/hooks/useWatcherView.ts | 24 +++ src/lib/pplnsProjection.ts | 31 +++ src/lib/watcherLinks.ts | 2 +- .../pplns-projection/PplnsProjectionPage.tsx | 20 ++ src/pages/watcher-links/WatcherView.tsx | 30 ++- 17 files changed, 545 insertions(+), 21 deletions(-) create mode 100644 src/components/pplns-projection/PplnsProjectionPanel.tsx create mode 100644 src/hooks/usePplnsProjection.ts create mode 100644 src/lib/pplnsProjection.ts create mode 100644 src/pages/pplns-projection/PplnsProjectionPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 5f6b9d44..85dc6e91 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { JobDeclarationPage } from '@/pages/build-your-block/JobDeclarationPage' import { PrioritizeTransactionsPage } from '@/pages/build-your-block/PrioritizeTransactionsPage'; import { MergeMiningPage } from '@/pages/build-your-block/MergeMiningPage'; import { WatcherLinksPage } from '@/pages/watcher-links/WatcherLinksPage'; +import { PplnsProjectionPage } from '@/pages/pplns-projection/PplnsProjectionPage'; import { WatcherView } from '@/pages/watcher-links/WatcherView'; import { MultiwatcherView } from '@/pages/watcher-links/MultiwatcherView'; import { SignIn } from '@/pages/auth/SignIn'; @@ -105,6 +106,11 @@ function AppRoutes() { + + + + + diff --git a/src/api/__tests__/client.test.ts b/src/api/__tests__/client.test.ts index 50e1d572..4ae2d24d 100644 --- a/src/api/__tests__/client.test.ts +++ b/src/api/__tests__/client.test.ts @@ -637,3 +637,51 @@ test('getPayoutAddresses GETs the payout addresses', async () => { assert.ok(calls[0].url.endsWith('/api/payouts/addresses')); assert.deepEqual(result, addrs); }); + +test('getPplnsProjection GETs the sub_account projection and returns it', async () => { + const body = { subaccount_id: 'acct-1', model_version: 2, horizons: [] }; + const { fetchImpl, calls } = fakeFetch(() => jsonResponse(body)); + const client = createUser({ fetchImpl, backoffMs: 0 }); + + const result = await client.getPplnsProjection('acct-1'); + + assert.equal(calls[0].init.method, 'GET'); + assert.ok(calls[0].url.endsWith('/api/user/sub_account/acct-1/pplns_projection')); + assert.deepEqual(result, body); +}); + +test('a 404 means nothing is cached yet, so getPplnsProjection returns null', async () => { + const { fetchImpl } = fakeFetch(() => new Response('', { status: 404 })); + const client = createUser({ fetchImpl, backoffMs: 0 }); + + assert.equal(await client.getPplnsProjection('acct-1'), null); +}); + +test('a 4xx naming the missing projection also returns null', async () => { + const { fetchImpl } = fakeFetch(() => + jsonResponse({ message: 'PPLNS projection is not available for this boundary' }, 400), + ); + const client = createUser({ fetchImpl, backoffMs: 0 }); + + assert.equal(await client.getPplnsProjection('acct-1'), null); +}); + +test('any other 4xx stays an error rather than reading as an empty cache', async () => { + const { fetchImpl } = fakeFetch(() => jsonResponse({ message: 'Bad subaccount id' }, 400)); + const client = createUser({ fetchImpl, backoffMs: 0 }); + + await assert.rejects( + () => client.getPplnsProjection('acct-1'), + (e: unknown) => e instanceof DmndApiError && e.code === 'other' && e.status === 400, + ); +}); + +test('an auth failure is an error even when its body names the missing projection', async () => { + const { fetchImpl } = fakeFetch(() => jsonResponse({ message: 'projection-not-found' }, 403)); + const client = createUser({ fetchImpl, backoffMs: 0 }); + + await assert.rejects( + () => client.getPplnsProjection('acct-1'), + (e: unknown) => e instanceof DmndApiError && e.code === 'unauthorized', + ); +}); diff --git a/src/api/__tests__/watcherClient.test.ts b/src/api/__tests__/watcherClient.test.ts index a06eea92..dad253ae 100644 --- a/src/api/__tests__/watcherClient.test.ts +++ b/src/api/__tests__/watcherClient.test.ts @@ -139,3 +139,34 @@ test('a non-array historical response collapses to an empty series', async () => assert.deepEqual(await client.getHashrateHistory('2026-07-01T00:00:00Z', '2026-07-02T00:00:00Z'), []); }); + +test('getPplnsProjection reads the account path with the token and no session', async () => { + const body = { subaccount_id: 'acct-1', model_version: 2, horizons: [] }; + const { fetchImpl, calls } = fakeFetch(() => jsonResponse(body)); + const client = createWatcherClient('SECRETTOKEN', { fetchImpl }); + + const result = await client.getPplnsProjection('acct-1'); + + const call = calls[0]; + assert.ok(call.url.includes('/api/user/sub_account/acct-1/pplns_projection')); + assert.ok(call.url.includes('token=SECRETTOKEN')); + assert.notEqual(call.init.credentials, 'include'); + assert.deepEqual(result, body); +}); + +test('getPplnsProjection returns null when nothing is cached yet', async () => { + const { fetchImpl } = fakeFetch(() => new Response('', { status: 404 })); + const client = createWatcherClient('TOK', { fetchImpl }); + + assert.equal(await client.getPplnsProjection('acct-1'), null); +}); + +test('a token that cannot read the projection still reports the link as invalid', async () => { + const { fetchImpl } = fakeFetch(() => new Response('', { status: 403 })); + const client = createWatcherClient('TOK', { fetchImpl }); + + await assert.rejects( + () => client.getPplnsProjection('acct-1'), + (e: unknown) => e instanceof Error && e.message === 'This Watcher link is no longer valid.', + ); +}); diff --git a/src/api/client.ts b/src/api/client.ts index 8f736447..6092edb5 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -11,6 +11,7 @@ import { type HashrateSnapshot, type PayoutAddresses, type GeneratedBtcEntry, + type PplnsProjection, type WatcherLink, type RequestOptions, type SignupInput, @@ -111,6 +112,19 @@ async function readErrorMessage(response: Response): Promise } } +// The server responds "no projection for this boundary yet" with a 404 +const PPLNS_PROJECTION_MISSING_MESSAGES = ['pplns projection is not available', 'projection-not-found']; + +/** + * Whether a failed projection request means the cache simply has nothing yet. + */ +export function isPplnsProjectionMissing(status: number | undefined, message: string): boolean { + if (status === 401 || status === 403) return false; + if (status === 404) return true; + const lower = message.toLowerCase(); + return PPLNS_PROJECTION_MISSING_MESSAGES.some((phrase) => lower.includes(phrase)); +} + interface RequestSpec { method: 'GET' | 'POST' | 'PUT' | 'DELETE'; path: string; @@ -166,11 +180,15 @@ async function request( }); if (response.status === 401 || response.status === 403) { - throw new DmndApiError((await readErrorMessage(response)) ?? API_ERROR_MESSAGES.unauthorized, 'unauthorized'); + throw new DmndApiError( + (await readErrorMessage(response)) ?? API_ERROR_MESSAGES.unauthorized, + 'unauthorized', + response.status, + ); } const serverMessage = response.ok ? undefined : await readErrorMessage(response); if (response.status === 400 && serverMessage === 'Unauthorized. User ID cookie not found or invalid.') { - throw new DmndApiError(API_ERROR_MESSAGES.unauthorized, 'unauthorized'); + throw new DmndApiError(API_ERROR_MESSAGES.unauthorized, 'unauthorized', response.status); } if (response.status >= 500) { if (serverMessage === 'Invalid referral code') { @@ -179,7 +197,7 @@ async function request( lastError = new DmndApiError(API_ERROR_MESSAGES.server, 'server'); } else if (!response.ok) { // 4xx with a server message (e.g. weak password) surfaces that message. - throw new DmndApiError(serverMessage || 'Something went wrong. Please try again.', 'other'); + throw new DmndApiError(serverMessage || 'Something went wrong. Please try again.', 'other', response.status); } else { const text = await response.text(); return (text ? JSON.parse(text) : undefined) as T; @@ -484,6 +502,22 @@ export function createUser(options: DmndClientOptions = {}): DmndClient { req, ); }, + async getPplnsProjection(id, req) { + try { + return await request( + { method: 'GET', path: `/api/user/sub_account/${encodeURIComponent(id)}/pplns_projection` }, + opts, + req, + ); + } catch (err) { + const missing = + err instanceof DmndApiError && + err.code !== 'unauthorized' && + isPplnsProjectionMissing(err.status, err.message); + if (missing) return null; + throw err; + } + }, }; } diff --git a/src/api/types.ts b/src/api/types.ts index aebfef28..97d41ff7 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -4,6 +4,10 @@ export class DmndApiError extends Error { constructor( message: string, public readonly code: DmndApiErrorCode, + /** + * The HTTP status behind the failure, set whenever the server answered with one. + */ + public readonly status?: number, ) { super(message); this.name = 'DmndApiError'; @@ -320,4 +324,34 @@ export interface DmndClient { * session (the new tab carries its own server-set cookie). */ logSubaccount(ownerToken: string, subaccountToken: string, req?: RequestOptions): Promise; + /** + * The cached PPLNS projection for an account (GET /api/user/sub_account/{id}/pplns_projection). + * Returns null when no model-v2 projection is available yet (404 / cache not refreshed). + */ + getPplnsProjection(id: string, req?: RequestOptions): Promise; +} + +export interface PplnsProjectionHorizon { + horizon: number; + retained_difficulty: number; + total_modeled_window_difficulty: number; + difficulty_score: number; + gross_subsidy_sats: number; + pool_fee: number; + broker_fee: number; + net_sats: number; +} + +export interface PplnsProjection { + subaccount_id: string; + calculated_at: string; + source_snapshot_at: string; + source_block_height: number; + last_pool_block_height: number; + pool_work_since_last_block: number; + synthetic_fill_difficulty: number; + network_difficulty: number; + block_subsidy_sats: number; + model_version: number; + horizons: PplnsProjectionHorizon[]; } diff --git a/src/api/watcherClient.ts b/src/api/watcherClient.ts index 2e675735..d7bb3721 100644 --- a/src/api/watcherClient.ts +++ b/src/api/watcherClient.ts @@ -1,6 +1,23 @@ -import { API_BASE } from './client'; +import { API_BASE, isPplnsProjectionMissing } from './client'; import { API_ERROR_MESSAGES } from './errorMessages'; -import type { GeneratedBtcEntry, HashratePoint, HashrateSnapshot, SubaccountFees, WorkersResponse } from './types'; +import type { + GeneratedBtcEntry, + HashratePoint, + HashrateSnapshot, + PplnsProjection, + SubaccountFees, + WorkersResponse, +} from './types'; + +class WatcherRequestError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + this.name = 'WatcherRequestError'; + } +} /** * A client for the public Watcher View. Unlike the authenticated client, it sends @@ -15,6 +32,8 @@ export interface WatcherClient { getHashrateHistory(from: string, to: string, signal?: AbortSignal): Promise; getGeneratedBtc(signal?: AbortSignal): Promise; getFees(signal?: AbortSignal): Promise; + /** Null when the cache holds no projection for the latest PPLNS boundary yet. */ + getPplnsProjection(accountId: string, signal?: AbortSignal): Promise; } interface WatcherClientOptions { @@ -29,10 +48,10 @@ export function createWatcherClient(token: string, options: WatcherClientOptions // No credentials, no X-Account-ID: the token is the only thing sent. const response = await fetchImpl(`${API_BASE}${path}?${query}`, { method: 'GET', signal }); if (response.status === 401 || response.status === 403) { - throw new Error('This Watcher link is no longer valid.'); + throw new WatcherRequestError('This Watcher link is no longer valid.', response.status); } if (!response.ok) { - throw new Error(API_ERROR_MESSAGES.watcher); + throw new WatcherRequestError(API_ERROR_MESSAGES.watcher, response.status); } const text = await response.text(); return (text ? JSON.parse(text) : undefined) as T; @@ -76,5 +95,19 @@ export function createWatcherClient(token: string, options: WatcherClientOptions // rates already in percent (2 = 2%), so the view shows the number verbatim. return get('/api/user/fees', {}, signal); }, + async getPplnsProjection(accountId, signal) { + try { + return await get( + `/api/user/sub_account/${encodeURIComponent(accountId)}/pplns_projection`, + {}, + signal, + ); + } catch (err) { + if (err instanceof WatcherRequestError && isPplnsProjectionMissing(err.status, err.message)) { + return null; + } + throw err; + } + }, }; } diff --git a/src/components/dashboard/DashboardShell.tsx b/src/components/dashboard/DashboardShell.tsx index 69e3c9ae..bd9ed9e4 100644 --- a/src/components/dashboard/DashboardShell.tsx +++ b/src/components/dashboard/DashboardShell.tsx @@ -3,6 +3,7 @@ import { useLocation } from 'wouter'; import { cn } from '@/lib/utils'; import { AggregatedModeProvider, useAggregatedModeContext } from '@/hooks/AggregatedModeProvider'; import { useHasSubaccounts } from '@/hooks/useSubaccounts'; +import { isAggregatedRestrictedRoute } from './nav'; import { Sidebar } from './Sidebar'; import { TopBar } from './TopBar'; import { AggregatedBanner } from './AggregatedBanner'; @@ -42,7 +43,7 @@ export function DashboardShell({ children }: { children: ReactNode }) { function DashboardShellInner({ children }: { children: ReactNode }) { const [drawerOpen, setDrawerOpen] = useState(false); const [collapsed, setCollapsed] = useState(readCollapsed); - const [location] = useLocation(); + const [location, navigate] = useLocation(); const { aggregated, setAggregated } = useAggregatedModeContext(); // The banner only shows when the mode is genuinely available: on it is stored, but // a miner with no subaccounts (or one who has none anymore) should never see it. @@ -51,6 +52,10 @@ function DashboardShellInner({ children }: { children: ReactNode }) { useEffect(() => writeCollapsed(collapsed), [collapsed]); + useEffect(() => { + if (aggregated && isAggregatedRestrictedRoute(location)) navigate('/home', { replace: true }); + }, [aggregated, location, navigate]); + // Close the drawer when navigating or pressing Escape. useEffect(() => setDrawerOpen(false), [location]); useEffect(() => { diff --git a/src/components/dashboard/Sidebar.tsx b/src/components/dashboard/Sidebar.tsx index e83fb82c..dfc45b68 100644 --- a/src/components/dashboard/Sidebar.tsx +++ b/src/components/dashboard/Sidebar.tsx @@ -9,7 +9,17 @@ import { TooltipPill } from '@/components/ui/tooltip-pill'; import { useAggregatedModeContext } from '@/hooks/AggregatedModeProvider'; import { useHasSubaccounts } from '@/hooks/useSubaccounts'; import { useAccountScope } from '@/hooks/useAccountScope'; -import { NAV_GROUPS, SETTINGS_ITEM, isPathActive, isSubaccountRestrictedRoute, type NavItem } from './nav'; +import { usePplnsProjection } from '@/hooks/usePplnsProjection'; +import { hasPayablePplnsWork } from '@/lib/pplnsProjection'; +import { + NAV_GROUPS, + SETTINGS_ITEM, + PPLNS_PROJECTION_ROUTE, + isPathActive, + isAggregatedRestrictedRoute, + isSubaccountRestrictedRoute, + type NavItem, +} from './nav'; import { AccountSwitcher } from './AccountSwitcher'; import { accountInitials } from './accountInitials'; @@ -156,7 +166,7 @@ function NavRow({ /** * The DMND dashboard's left navigation: brand, an account row, grouped nav - * (Overview / Mining / Developer), and Settings + Logout pinned to the bottom. + * (Overview / Mining / Monitoring), and Settings + Logout pinned to the bottom. * `collapsed` renders an icon-only rail (desktop); `onToggleCollapse` shows the * collapse control. `onNavigate` lets the mobile drawer close itself after a tap. */ @@ -182,6 +192,9 @@ export function Sidebar({ // A subaccount cannot reach the subaccounts page, so its nav entry is dropped rather // than left to lead somewhere it has no permission for. const { canViewSubaccounts } = useAccountScope(); + const { data: pplnsProjection } = usePplnsProjection(); + + const hasPplnsWork = hasPayablePplnsWork(pplnsProjection); return (
{NAV_GROUPS.map((group) => { - const items = canViewSubaccounts - ? group.items - : group.items.filter((item) => !isSubaccountRestrictedRoute(item.href)); + const items = group.items.filter( + (item) => + (canViewSubaccounts || !isSubaccountRestrictedRoute(item.href)) && + (!aggregated || !isAggregatedRestrictedRoute(item.href)) && + (item.href !== PPLNS_PROJECTION_ROUTE || hasPplnsWork), + ); // A group whose every entry is restricted would otherwise leave a bare heading. if (items.length === 0) return null; return ( diff --git a/src/components/dashboard/nav.ts b/src/components/dashboard/nav.ts index be7d2cc6..193c92e0 100644 --- a/src/components/dashboard/nav.ts +++ b/src/components/dashboard/nav.ts @@ -6,6 +6,7 @@ import { LiKeyMinimalistic, LiSettingsMinimalistic, LiShieldCheck, + LiChart, } from 'solar-icon-react/li'; import { BdHomeAngle, @@ -14,6 +15,7 @@ import { BdKeyMinimalistic, BdSettingsMinimalistic, BdShieldCheck, + BdChart, } from 'solar-icon-react/bd'; import { MiningIcon } from './icons/MiningIcon'; import { NodeHardwareIcon } from './icons/NodeHardwareIcon'; @@ -23,6 +25,8 @@ type IconComp = ComponentType<{ className?: string }>; const TRUST_CENTER_URL = 'https://app.eu.vanta.com/dmnd.work/trust/4u48n4nf8yiwi9swpqjsf'; +export const PPLNS_PROJECTION_ROUTE = '/pplns-projection'; + export interface NavItem { /** Resting glyph: a solar outline icon, or a custom DMND glyph. */ icon: IconComp; @@ -66,10 +70,11 @@ export const NAV_GROUPS: { label: string; items: NavItem[] }[] = [ { icon: LiLayersMinimalistic, iconActive: BdLayersMinimalistic, label: 'Subaccounts', href: '/subaccounts' }, { icon: BitcoinCircleIcon, label: 'Generated BTC', href: '/generated-bitcoin' }, { icon: LiWallet, iconActive: BdWallet, label: 'Payouts', href: '/payouts' }, + { icon: LiChart, iconActive: BdChart, label: 'PPLNS Projection', href: PPLNS_PROJECTION_ROUTE }, ], }, { - label: 'Developer', + label: 'Monitoring', items: [ { icon: LiKeyMinimalistic, iconActive: BdKeyMinimalistic, label: 'Watcher links', href: '/watcher-links' }, ], @@ -110,6 +115,12 @@ export function isSubaccountRestrictedRoute(path: string): boolean { return SUBACCOUNT_RESTRICTED_ROUTES.includes(path); } +export const AGGREGATED_RESTRICTED_ROUTES = [PPLNS_PROJECTION_ROUTE]; + +export function isAggregatedRestrictedRoute(path: string): boolean { + return AGGREGATED_RESTRICTED_ROUTES.includes(path); +} + // Dropdown children are real routes, so they must be flattened too or their pages // fall back to the default title. const ALL_ITEMS = [ diff --git a/src/components/pplns-projection/PplnsProjectionPanel.tsx b/src/components/pplns-projection/PplnsProjectionPanel.tsx new file mode 100644 index 00000000..e766fec9 --- /dev/null +++ b/src/components/pplns-projection/PplnsProjectionPanel.tsx @@ -0,0 +1,180 @@ +import { hasPayablePplnsWork } from '@/lib/pplnsProjection'; +import type { PplnsProjection, PplnsProjectionHorizon } from '@/api/types'; + +function roundTo(digits: number, value: number): string { + const factor = Math.pow(10, digits); + return String(Math.round(value * factor) / factor); +} + +function formatDifficulty(d: number): string { + if (d >= 1e18) return `${roundTo(3, d / 1e18)} E`; + if (d >= 1e15) return `${roundTo(3, d / 1e15)} P`; + if (d >= 1e12) return `${roundTo(3, d / 1e12)} T`; + if (d >= 1e9) return `${roundTo(3, d / 1e9)} G`; + if (d >= 1e6) return `${roundTo(3, d / 1e6)} M`; + if (d >= 1e3) return `${roundTo(3, d / 1e3)} k`; + return roundTo(3, d); +} + +function formatBtcFromSats(sats: number): string { + return `${(sats / 1e8).toFixed(8)} BTC`; +} + +function formatTimestamp(iso: string): string { + if (!iso) return '—'; + const date = iso.slice(0, 10); + const time = iso.slice(11, 16); + return time ? `${date} ${time} UTC` : date; +} + +function formatShare(score: number): string { + const pct = score * 100; + if (pct > 0 && pct < 0.0001) return '<0.0001%'; + return `${roundTo(4, pct)}%`; +} + +function formatFee(poolFee: number, brokerFee: number): string { + return `${roundTo(2, Math.min(100, poolFee + brokerFee))}%`; +} + +function horizonLabel(horizon: number): string { + return horizon === 0 ? 'At snapshot' : `+${horizon} D of pool work`; +} + + +function StateMessage({ title, body }: { title: string; body: string }) { + return ( +
+

{title}

+

{body}

+
+ ); +} + +function MetadataGrid({ projection }: { projection: PplnsProjection }) { + const items: [string, string][] = [ + ['Data cutoff', formatTimestamp(projection.source_snapshot_at)], + ['Calculated', formatTimestamp(projection.calculated_at)], + ['Model', `v${projection.model_version}`], + ['Snapshot height', `#${projection.source_block_height}`], + ['Latest PPLNS boundary', `#${projection.last_pool_block_height}`], + ['Network difficulty', formatDifficulty(projection.network_difficulty)], + ['Pool work since PPLNS boundary', formatDifficulty(projection.pool_work_since_last_block)], + ['Anonymous current-round fill', formatDifficulty(projection.synthetic_fill_difficulty)], + ['Fixed block subsidy', formatBtcFromSats(projection.block_subsidy_sats)], + ]; + + return ( +
+ {items.map(([label, value]) => ( +
+ + {label} + + {value} +
+ ))} +
+ ); +} + +function ProjectionTable({ horizons }: { horizons: PplnsProjectionHorizon[] }) { + return ( +
+ + + + {['Scenario', 'Reward share', 'Gross subsidy', 'Applied fee', 'Net subsidy'].map( + (h, i) => ( + + ), + )} + + + + {horizons.map((h) => { + const isSnap = h.horizon === 0; + return ( + + + + + + + + ); + })} + +
+ {h} +
+ {horizonLabel(h.horizon)} + + {formatShare(h.difficulty_score)} + + {formatBtcFromSats(h.gross_subsidy_sats)} + + {formatFee(h.pool_fee, h.broker_fee)} + + {formatBtcFromSats(h.net_sats)} +
+
+ ); +} + +function ProjectionBody({ projection }: { projection: PplnsProjection }) { + return ( +
+ + {hasPayablePplnsWork(projection) ? ( + + ) : ( + + )} +
+ ); +} + +export function PplnsProjectionPanel({ + projection, + isLoading, + isError, +}: { + /** `null` means the cache holds no projection for the latest boundary yet. */ + projection: PplnsProjection | null | undefined; + isLoading: boolean; + isError: boolean; +}) { + return ( +
+ {isLoading ? ( +
+ ) : isError ? ( + + ) : projection === null ? ( + + ) : projection !== undefined ? ( + + ) : null} +
+ ); +} diff --git a/src/components/watcher-links/view/WatcherSidebar.tsx b/src/components/watcher-links/view/WatcherSidebar.tsx index ce681921..a3527d74 100644 --- a/src/components/watcher-links/view/WatcherSidebar.tsx +++ b/src/components/watcher-links/view/WatcherSidebar.tsx @@ -1,13 +1,13 @@ import type { ComponentType } from 'react'; -import { LiHomeAngle, LiSidebarMinimalistic, LiGasStation } from 'solar-icon-react/li'; -import { BdHomeAngle } from 'solar-icon-react/bd'; +import { LiHomeAngle, LiSidebarMinimalistic, LiGasStation, LiChart } from 'solar-icon-react/li'; +import { BdHomeAngle, BdChart } from 'solar-icon-react/bd'; import { cn } from '@/lib/utils'; import { DmndLogo } from '@/components/auth/Logo'; import { MiningIcon } from '@/components/dashboard/icons/MiningIcon'; import { BitcoinCircleIcon } from '@/components/dashboard/icons/BitcoinCircleIcon'; /** The sections a watcher link can expose, in the order the sidebar lists them. */ -export type WatcherSection = 'home' | 'workers' | 'generated' | 'fees'; +export type WatcherSection = 'home' | 'workers' | 'generated' | 'fees' | 'pplns'; type IconComp = ComponentType<{ className?: string }>; @@ -25,6 +25,7 @@ export const WATCHER_SECTIONS: Record = { workers: { group: 'Mining', label: 'Workers', icon: MiningIcon }, generated: { group: 'Mining', label: 'Generated BTC', icon: BitcoinCircleIcon }, fees: { group: 'Mining', label: 'Fees', icon: LiGasStation }, + pplns: { group: 'Mining', label: 'PPLNS Projection', icon: LiChart, iconActive: BdChart }, }; function NavRow({ diff --git a/src/hooks/usePplnsProjection.ts b/src/hooks/usePplnsProjection.ts new file mode 100644 index 00000000..98d4e370 --- /dev/null +++ b/src/hooks/usePplnsProjection.ts @@ -0,0 +1,26 @@ +import { useQuery } from '@tanstack/react-query'; +import { getUser } from '@/api'; +import { isSupportedPplnsProjection } from '@/lib/pplnsProjection'; +import { useActiveAccountId } from './useActiveAccountId'; + +const REFRESH_INTERVAL_MS = 5 * 60 * 1000; + +export function usePplnsProjection() { + const accountId = useActiveAccountId(); + return useQuery({ + queryKey: ['account', 'pplns-projection', accountId], + queryFn: async ({ signal }) => { + if (!accountId) throw new Error('No account'); + const projection = await getUser().getPplnsProjection(accountId, { signal }); + if (projection && !isSupportedPplnsProjection(projection)) { + throw new Error('Unsupported PPLNS projection model'); + } + return projection; + }, + enabled: !!accountId, + staleTime: REFRESH_INTERVAL_MS, + refetchInterval: REFRESH_INTERVAL_MS, + refetchOnWindowFocus: false, + retry: false, + }); +} diff --git a/src/hooks/useWatcherView.ts b/src/hooks/useWatcherView.ts index 276aefda..55af6ae0 100644 --- a/src/hooks/useWatcherView.ts +++ b/src/hooks/useWatcherView.ts @@ -3,11 +3,16 @@ import { useQuery } from '@tanstack/react-query'; import { createWatcherClient } from '@/api/watcherClient'; import type { HashrateRange } from '@/api/types'; import { rangeToWindow } from '@/lib/hashrateHistory'; +import { isSupportedPplnsProjection } from '@/lib/pplnsProjection'; // The public view polls a little slower than the owner's dashboard; it is a shared, // read-only page and does not need second-by-second freshness. const WATCHER_POLL_MS = 60 * 1000; +// The projection is recomputed only when the cache crosses a PPLNS boundary, so it is +// polled on the same five-minute (+5s) cadence the owner's dashboard uses for it. +const PROJECTION_POLL_MS = 5 * 61 * 1000; + /** A memoised token-only client for one watcher token. */ function useClient(token: string) { return useMemo(() => createWatcherClient(token), [token]); @@ -91,3 +96,22 @@ export function useWatcherFees(token: string, enabled: boolean) { retry: false, }); } + +export function useWatcherPplnsProjection(accountId: string, token: string, enabled: boolean) { + const client = useClient(token); + return useQuery({ + queryKey: ['watcher', token, 'pplns-projection', accountId], + queryFn: async ({ signal }) => { + const projection = await client.getPplnsProjection(accountId, signal); + if (projection && !isSupportedPplnsProjection(projection)) { + throw new Error('Unsupported PPLNS projection model'); + } + return projection; + }, + enabled, + staleTime: PROJECTION_POLL_MS, + refetchInterval: PROJECTION_POLL_MS, + refetchOnWindowFocus: false, + retry: false, + }); +} diff --git a/src/lib/pplnsProjection.ts b/src/lib/pplnsProjection.ts new file mode 100644 index 00000000..8f3b883a --- /dev/null +++ b/src/lib/pplnsProjection.ts @@ -0,0 +1,31 @@ +import type { PplnsProjection } from '@/api/types'; + +/** Horizon 0 is where the payout window stands now; 1..8 are what-if scenarios. */ +const SNAPSHOT_HORIZON = 0; + +/** A model-v2 payload carries the snapshot plus eight days of projected pool work. */ +const HORIZON_COUNT = 9; + +/** + * Whether the account still has accepted work inside the modeled payout window. With + * nothing retained every row of the projection reads zero, so the sidebar entry is + * hidden and the page says as much instead of showing a table of zeroes. + */ +export function hasPayablePplnsWork(projection: PplnsProjection | null | undefined): boolean { + const snapshot = projection?.horizons?.find((h) => h.horizon === SNAPSHOT_HORIZON); + return snapshot ? snapshot.retained_difficulty > 0 : false; +} + +/** + * Whether a payload is one the page can read: model v2, with horizons 0..8 in order. + * Anything else is refused rather than displayed, because rendering another model's + * numbers under these labels would misstate the reward share. + */ +export function isSupportedPplnsProjection(projection: PplnsProjection): boolean { + return ( + projection.model_version === 2 && + Array.isArray(projection.horizons) && + projection.horizons.length === HORIZON_COUNT && + projection.horizons.every((h, i) => h.horizon === i) + ); +} diff --git a/src/lib/watcherLinks.ts b/src/lib/watcherLinks.ts index 90909511..e8fbe533 100644 --- a/src/lib/watcherLinks.ts +++ b/src/lib/watcherLinks.ts @@ -22,7 +22,7 @@ const SCOPE_LABELS: Record = { export const SCOPE_DESCRIPTIONS: Record = { hashrate_read: 'Current and historical hashrate data.', workers_read: 'Live worker roster, miner count and share counts per worker.', - earnings_read: 'Daily generated BTC for FPPS earnings.', + earnings_read: 'Daily generated BTC for FPPS earnings, and the PPLNS projection.', rejects_read: 'Aggregate accepted and rejected share counts.', fees_read: 'The pool fee percentage charged on this account.', }; diff --git a/src/pages/pplns-projection/PplnsProjectionPage.tsx b/src/pages/pplns-projection/PplnsProjectionPage.tsx new file mode 100644 index 00000000..785b43eb --- /dev/null +++ b/src/pages/pplns-projection/PplnsProjectionPage.tsx @@ -0,0 +1,20 @@ +import { usePplnsProjection } from '@/hooks/usePplnsProjection'; +import { PplnsProjectionPanel } from '@/components/pplns-projection/PplnsProjectionPanel'; + +export function PplnsProjectionPage() { + const { data, isLoading, isError } = usePplnsProjection(); + + return ( +
+
+

PPLNS projection

+

+ See how the accepted work still retained in the PPLNS window contributes to a modeled + block subsidy. +

+
+ + +
+ ); +} diff --git a/src/pages/watcher-links/WatcherView.tsx b/src/pages/watcher-links/WatcherView.tsx index a267a9d0..dfec47c8 100644 --- a/src/pages/watcher-links/WatcherView.tsx +++ b/src/pages/watcher-links/WatcherView.tsx @@ -11,8 +11,11 @@ import { useWatcherWorkers, useWatcherGeneratedBtc, useWatcherFees, + useWatcherPplnsProjection, type CustomWindow, } from '@/hooks/useWatcherView'; +import { hasPayablePplnsWork } from '@/lib/pplnsProjection'; +import { PplnsProjectionPanel } from '@/components/pplns-projection/PplnsProjectionPanel'; import { WatcherHashratePanel } from '@/components/watcher-links/view/WatcherHashratePanel'; import { WatcherPerformanceChart } from '@/components/watcher-links/view/WatcherPerformanceChart'; import { WatcherWorkersSection } from '@/components/watcher-links/view/WatcherWorkersSection'; @@ -35,6 +38,7 @@ const SECTION_NOUNS: Record = { workers: 'workers', generated: 'earnings', fees: 'fees', + pplns: 'PPLNS projection', }; function joinNouns(sections: WatcherSection[]): string { @@ -55,10 +59,10 @@ export function WatcherView({ userId, token }: { userId: string; token: string } useAppliedTheme(); const parsed = parseWatcherPath(userId, token); if (!parsed) return ; - return ; + return ; } -function WatcherViewInner({ token }: { token: string }) { +function WatcherViewInner({ accountId, token }: { accountId: string; token: string }) { const [range, setRange] = useState('24H'); const [custom, setCustom] = useState(null); const [collapsed, setCollapsed] = useState(false); @@ -73,17 +77,20 @@ function WatcherViewInner({ token }: { token: string }) { // scope still renders that section instead of looking like a dead link. const generated = useWatcherGeneratedBtc(token, true); const fees = useWatcherFees(token, true); + const pplns = useWatcherPplnsProjection(accountId, token, true); // A scope is granted unless its probe came back unauthorised; while a probe is still // loading we cannot yet tell, so navigation waits for it to settle before deciding // the link is dead. const granted = (q: { isError: boolean; error: unknown }) => !(q.isError && isUnauthorized(q.error)); - const settled = !hashrate.isLoading && !workers.isLoading && !generated.isLoading && !fees.isLoading; + const settled = + !hashrate.isLoading && !workers.isLoading && !generated.isLoading && !fees.isLoading && !pplns.isLoading; const sections: WatcherSection[] = [ granted(hashrate) ? 'home' : null, granted(workers) ? 'workers' : null, granted(generated) ? 'generated' : null, granted(fees) ? 'fees' : null, + granted(pplns) && hasPayablePplnsWork(pplns.data) ? 'pplns' : null, ].filter((s): s is WatcherSection => s !== null); const active: WatcherSection = chosen && sections.includes(chosen) ? chosen : sections[0] ?? 'home'; @@ -244,6 +251,23 @@ function WatcherViewInner({ token }: { token: string }) { /> )} + + {active === 'pplns' && ( +
+
+

PPLNS projection

+

+ See how the accepted work still retained in the PPLNS window contributes to a + modeled block subsidy. +

+
+ +
+ )}