Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -105,6 +106,11 @@ function AppRoutes() {
<GeneratedBtcPage />
</DashboardShell>
</Route>
<Route path="/pplns-projection">
<DashboardShell>
<PplnsProjectionPage />
</DashboardShell>
</Route>
<Route path="/watcher-links">
<DashboardShell>
<WatcherLinksPage />
Expand Down
48 changes: 48 additions & 0 deletions src/api/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});
31 changes: 31 additions & 0 deletions src/api/__tests__/watcherClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
});
40 changes: 37 additions & 3 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type HashrateSnapshot,
type PayoutAddresses,
type GeneratedBtcEntry,
type PplnsProjection,
type WatcherLink,
type RequestOptions,
type SignupInput,
Expand Down Expand Up @@ -111,6 +112,19 @@ async function readErrorMessage(response: Response): Promise<string | undefined>
}
}

// 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;
Expand Down Expand Up @@ -166,11 +180,15 @@ async function request<T>(
});

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') {
Expand All @@ -179,7 +197,7 @@ async function request<T>(
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;
Expand Down Expand Up @@ -484,6 +502,22 @@ export function createUser(options: DmndClientOptions = {}): DmndClient {
req,
);
},
async getPplnsProjection(id, req) {
try {
return await request<PplnsProjection>(
{ 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;
}
},
};
}

Expand Down
34 changes: 34 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<DmndSession>;
/**
* 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<PplnsProjection | null>;
}

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[];
}
41 changes: 37 additions & 4 deletions src/api/watcherClient.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,6 +32,8 @@ export interface WatcherClient {
getHashrateHistory(from: string, to: string, signal?: AbortSignal): Promise<HashratePoint[]>;
getGeneratedBtc(signal?: AbortSignal): Promise<GeneratedBtcEntry[]>;
getFees(signal?: AbortSignal): Promise<SubaccountFees>;
/** Null when the cache holds no projection for the latest PPLNS boundary yet. */
getPplnsProjection(accountId: string, signal?: AbortSignal): Promise<PplnsProjection | null>;
}

interface WatcherClientOptions {
Expand All @@ -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;
Expand Down Expand Up @@ -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<SubaccountFees>('/api/user/fees', {}, signal);
},
async getPplnsProjection(accountId, signal) {
try {
return await get<PplnsProjection>(
`/api/user/sub_account/${encodeURIComponent(accountId)}/pplns_projection`,
{},
signal,
);
} catch (err) {
if (err instanceof WatcherRequestError && isPplnsProjectionMissing(err.status, err.message)) {
return null;
}
throw err;
}
},
};
}
7 changes: 6 additions & 1 deletion src/components/dashboard/DashboardShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand All @@ -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(() => {
Expand Down
26 changes: 21 additions & 5 deletions src/components/dashboard/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
*/
Expand All @@ -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 (
<div
Expand Down Expand Up @@ -244,9 +257,12 @@ export function Sidebar({

<nav className="flex-1 overflow-y-auto px-3 pb-2">
{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 (
Expand Down
Loading
Loading