From 4482c15b1156230101882adc3f06ae6490244075 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=A2=D0=BE=D0=BB=D0=BB=D0=B8?=
Date: Thu, 23 Apr 2026 13:36:54 +0300
Subject: [PATCH 1/3] add multiple fallback pools to setup and settings
---
server/src/config-generator.test.ts | 193 +++++++++---
server/src/config-generator.ts | 50 +--
server/src/index.ts | 15 +-
server/src/types.ts | 1 +
src/components/settings/ConfigurationTab.tsx | 243 ++++++++++++++-
src/components/setup/PoolPicker.tsx | 182 +++++++++++
src/components/setup/SetupWizard.tsx | 4 +-
.../setup/steps/MiningModeSelection.tsx | 2 +-
src/components/setup/steps/PoolConfigStep.tsx | 289 +++++++-----------
src/components/setup/steps/ReviewStart.tsx | 13 +
.../setup/steps/TemplateModeSelection.tsx | 1 +
src/components/setup/types.ts | 2 +
12 files changed, 750 insertions(+), 245 deletions(-)
create mode 100644 src/components/setup/PoolPicker.tsx
diff --git a/server/src/config-generator.test.ts b/server/src/config-generator.test.ts
index 6c1e8e2..58202d8 100644
--- a/server/src/config-generator.test.ts
+++ b/server/src/config-generator.test.ts
@@ -1,65 +1,166 @@
import assert from 'node:assert/strict';
-import { test } from 'node:test';
-import { generateJdcConfig, generateTranslatorConfig, normalizeSetupData } from './config-generator.js';
-import type { SetupData } from './types.js';
+import test from 'node:test';
-const BASE_DATA: SetupData = {
- miningMode: 'pool',
- mode: 'jd',
- pool: {
- name: 'Custom Pool',
- address: 'pool.example.com',
- port: 34254,
- authority_public_key: 'authority-key',
- },
- bitcoin: {
- network: 'testnet4',
- os: 'linux',
- customDataDir: '',
- socket_path: '/tmp/bitcoin.sock',
- },
- jdc: {
- user_identity: 'miner.worker1',
- jdc_signature: 'custom-miner-tag',
- coinbase_reward_address: 'tb1qexample',
- },
- translator: {
- user_identity: 'miner.worker1',
- enable_vardiff: true,
- aggregate_channels: false,
- min_hashrate: 100_000_000_000_000,
- shares_per_minute: 12.5,
- downstream_extranonce2_size: 8,
- },
+import { generateTranslatorConfig, generateJdcConfig, normalizeSetupData } from './config-generator.js';
+import type { PoolConfig, SetupData } from './types.js';
+
+const PRIMARY: PoolConfig = {
+ name: 'Primary',
+ address: 'pool-a.example.com',
+ port: 3333,
+ authority_public_key: '9awtMD5KQgvRUh2yFbjVeT7b6hjipWcAsQHd6wEhgtDT9soosna',
};
-test('translator config uses advanced setup values', () => {
- const config = generateTranslatorConfig(BASE_DATA);
+const FALLBACK_1: PoolConfig = {
+ name: 'Fallback 1',
+ address: 'pool-b.example.com',
+ port: 4444,
+ authority_public_key: '9bCoFxTszKCuffyywH5uS5o6WcU4vsjTH2axxc7wE86y2HhvULU',
+};
+
+const FALLBACK_2: PoolConfig = {
+ name: 'Fallback 2',
+ address: 'pool-c.example.com',
+ port: 5555,
+ authority_public_key: '9cDpGyTtaLDvggzzxI6vT6p7XdV5wtkUI3byyd8xF97z3IiwVMV',
+};
+
+function countOccurrences(haystack: string, needle: string): number {
+ let count = 0;
+ let i = 0;
+ while ((i = haystack.indexOf(needle, i)) !== -1) {
+ count += 1;
+ i += needle.length;
+ }
+ return count;
+}
+
+function baseData(overrides: Partial = {}): SetupData {
+ return {
+ miningMode: 'pool',
+ mode: 'no-jd',
+ pool: PRIMARY,
+ fallbackPools: [],
+ bitcoin: null,
+ jdc: null,
+ translator: {
+ user_identity: 'worker',
+ enable_vardiff: true,
+ aggregate_channels: false,
+ min_hashrate: 100_000_000_000_000,
+ shares_per_minute: 6,
+ downstream_extranonce2_size: 4,
+ },
+ ...overrides,
+ };
+}
+
+test('translator config emits one [[upstreams]] block when fallbackPools is empty', () => {
+ const toml = generateTranslatorConfig(baseData({ fallbackPools: [] }));
+ assert.equal(countOccurrences(toml, '[[upstreams]]'), 1);
+});
+
+test('translator config emits N+1 [[upstreams]] blocks for N fallbacks, in order', () => {
+ const toml = generateTranslatorConfig(baseData({ fallbackPools: [FALLBACK_1, FALLBACK_2] }));
+ assert.equal(countOccurrences(toml, '[[upstreams]]'), 3);
+ const primaryIdx = toml.indexOf(PRIMARY.address);
+ const f1Idx = toml.indexOf(FALLBACK_1.address);
+ const f2Idx = toml.indexOf(FALLBACK_2.address);
+ assert.ok(primaryIdx < f1Idx && f1Idx < f2Idx, 'upstreams must appear in declaration order');
+});
+
+test('translator config in JD mode never emits fallbacks — JDC handles failover', () => {
+ const data = baseData({
+ mode: 'jd',
+ fallbackPools: [FALLBACK_1, FALLBACK_2],
+ bitcoin: { network: 'mainnet', os: 'linux', customDataDir: '', socket_path: '/tmp/x' },
+ jdc: { user_identity: 'w', jdc_signature: '', coinbase_reward_address: 'bc1q' },
+ });
+ const toml = generateTranslatorConfig(data);
+ assert.equal(countOccurrences(toml, '[[upstreams]]'), 1);
+ assert.match(toml, /address = "sv2-jdc"/);
+ assert.ok(
+ !toml.includes(FALLBACK_1.address) && !toml.includes(FALLBACK_2.address),
+ 'fallbacks should not appear in translator TOML in JD mode',
+ );
+});
+test('jdc config emits N+1 [[upstreams]] blocks for N fallbacks', () => {
+ const data = baseData({
+ mode: 'jd',
+ fallbackPools: [FALLBACK_1, FALLBACK_2],
+ bitcoin: { network: 'mainnet', os: 'linux', customDataDir: '', socket_path: '/tmp/x' },
+ jdc: { user_identity: 'w', jdc_signature: 'sig', coinbase_reward_address: 'bc1q' },
+ });
+ const toml = generateJdcConfig(data);
+ assert.ok(toml);
+ assert.equal(countOccurrences(toml!, '[[upstreams]]'), 3);
+ assert.match(toml!, /\(primary\)/);
+ assert.match(toml!, /\(fallback 1\)/);
+ assert.match(toml!, /\(fallback 2\)/);
+});
+
+test('jdc config in sovereign-solo mode emits empty upstreams regardless of fallbackPools', () => {
+ const data = baseData({
+ miningMode: 'solo',
+ mode: 'jd',
+ pool: null,
+ fallbackPools: [FALLBACK_1],
+ bitcoin: { network: 'mainnet', os: 'linux', customDataDir: '', socket_path: '/tmp/x' },
+ jdc: { user_identity: 'w', jdc_signature: '', coinbase_reward_address: 'bc1q' },
+ });
+ const toml = generateJdcConfig(data);
+ assert.ok(toml);
+ assert.match(toml!, /upstreams = \[\]/);
+ assert.equal(countOccurrences(toml!, '[[upstreams]]'), 0);
+});
+
+test('translator config uses advanced setup values', () => {
+ const data = baseData({
+ translator: {
+ user_identity: 'worker',
+ enable_vardiff: true,
+ aggregate_channels: false,
+ min_hashrate: 100_000_000_000_000,
+ shares_per_minute: 12.5,
+ downstream_extranonce2_size: 8,
+ },
+ });
+ const config = generateTranslatorConfig(data);
assert.match(config, /downstream_extranonce2_size = 8/);
assert.match(config, /shares_per_minute = 12\.5/);
});
test('jdc config uses shared shares-per-minute and miner signature', () => {
- const config = generateJdcConfig(BASE_DATA);
-
+ const data = baseData({
+ mode: 'jd',
+ bitcoin: { network: 'mainnet', os: 'linux', customDataDir: '', socket_path: '/tmp/x' },
+ jdc: { user_identity: 'w', jdc_signature: 'custom-miner-tag', coinbase_reward_address: 'bc1q' },
+ translator: {
+ user_identity: 'w',
+ enable_vardiff: true,
+ aggregate_channels: false,
+ min_hashrate: 100_000_000_000_000,
+ shares_per_minute: 12.5,
+ downstream_extranonce2_size: 4,
+ },
+ });
+ const config = generateJdcConfig(data);
assert.ok(config);
- assert.match(config, /shares_per_minute = 12\.5/);
- assert.match(config, /jdc_signature = "custom-miner-tag"/);
+ assert.match(config!, /shares_per_minute = 12\.5/);
+ assert.match(config!, /jdc_signature = "custom-miner-tag"/);
});
test('normalization backfills advanced defaults for old saved configs', () => {
- const data = {
- ...BASE_DATA,
+ const data = baseData({
translator: {
- ...BASE_DATA.translator,
- shares_per_minute: undefined,
- downstream_extranonce2_size: undefined,
- },
- } as unknown as SetupData;
-
+ user_identity: 'w',
+ enable_vardiff: true,
+ aggregate_channels: false,
+ min_hashrate: 100_000_000_000_000,
+ } as unknown as SetupData['translator'],
+ });
const normalized = normalizeSetupData(data);
-
assert.equal(normalized.translator.shares_per_minute, 6);
assert.equal(normalized.translator.downstream_extranonce2_size, 4);
});
diff --git a/server/src/config-generator.ts b/server/src/config-generator.ts
index ac7e21a..d755548 100644
--- a/server/src/config-generator.ts
+++ b/server/src/config-generator.ts
@@ -82,13 +82,13 @@ export function normalizeSetupData(data: SetupData): SetupData {
*/
export function generateTranslatorConfig(data: SetupData): string {
const normalizedData = normalizeSetupData(data);
- const { pool, translator, mode } = normalizedData;
+ const { pool, fallbackPools, translator, mode } = normalizedData;
const isJdMode = mode === 'jd';
-
+
if (!translator || (!isJdMode && !pool)) {
throw new Error('Pool and translator configuration are required');
}
-
+
// If JD mode, translator connects to JDC container; otherwise directly to pool
// Both containers are on sv2-network, so we can use the container name as hostname
// (hostname resolution supported since sv2-apps PR #286)
@@ -109,6 +109,18 @@ export function generateTranslatorConfig(data: SetupData): string {
DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE,
);
+ const fallbackBlocks = !isJdMode
+ ? fallbackPools
+ .map(
+ (p) => `\n[[upstreams]]
+address = "${p.address}"
+port = ${p.port}
+authority_pubkey = "${p.authority_public_key}"
+`,
+ )
+ .join('')
+ : '';
+
return `# Translator Proxy Configuration
# Generated by sv2-ui
@@ -148,7 +160,7 @@ job_keepalive_interval_secs = 60
address = "${upstreamAddress}"
port = ${upstreamPort}
authority_pubkey = "${authorityPubkey}"
-`;
+${fallbackBlocks}`;
}
/**
@@ -159,7 +171,7 @@ export function generateJdcConfig(data: SetupData): string | null {
return null;
}
- const { pool, jdc, bitcoin } = data;
+ const { pool, fallbackPools, jdc, bitcoin } = data;
const isSovereignSolo = data.miningMode === 'solo';
const jdcSignature = isSovereignSolo ? (jdc.jdc_signature || jdc.user_identity) : jdc.jdc_signature;
@@ -172,21 +184,25 @@ export function generateJdcConfig(data: SetupData): string | null {
// Fee threshold and min interval for template provider
const feeThreshold = '1000';
const minInterval = '5';
- const upstreamsConfig = !isSovereignSolo && pool
- ? `# Upstream pool connection
-[[upstreams]]
-authority_pubkey = "${pool.authority_public_key}"
-pool_address = "${pool.address}"
-pool_port = ${pool.port}
-jds_address = "${pool.address}"
-jds_port = 3334
-
-`
- : `# No upstreams needed in solo mining mode.
-upstreams = []
+ const renderUpstream = (p: NonNullable): string => `[[upstreams]]
+authority_pubkey = "${p.authority_public_key}"
+pool_address = "${p.address}"
+pool_port = ${p.port}
+jds_address = "${p.address}"
+jds_port = 3334
`;
+ let upstreamsConfig: string;
+ if (isSovereignSolo || !pool) {
+ upstreamsConfig = `# No upstreams needed in solo mining mode.\nupstreams = []\n\n`;
+ } else {
+ upstreamsConfig = `# Upstream pool connection (primary)\n${renderUpstream(pool)}\n`;
+ fallbackPools.forEach((fp, i) => {
+ upstreamsConfig += `# Upstream pool connection (fallback ${i + 1})\n${renderUpstream(fp)}\n`;
+ });
+ }
+
return `# JD Client Configuration
# Generated by sv2-ui
diff --git a/server/src/index.ts b/server/src/index.ts
index 1cbf956..6395432 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -51,7 +51,17 @@ app.use(express.static(UI_DIR));
async function loadState(): Promise<{ configured: boolean; miningMode: 'solo' | 'pool' | null; mode: 'jd' | 'no-jd' | null; data: SetupData | null }> {
try {
const content = await fs.readFile(STATE_FILE, 'utf-8');
- return JSON.parse(content);
+ const parsed = JSON.parse(content);
+ if (parsed.data) {
+ // Migrate legacy single-fallback shape to fallbackPools array.
+ if (!Array.isArray(parsed.data.fallbackPools)) {
+ parsed.data.fallbackPools = parsed.data.fallbackPool
+ ? [parsed.data.fallbackPool]
+ : [];
+ }
+ delete parsed.data.fallbackPool;
+ }
+ return parsed;
} catch {
return { configured: false, miningMode: null, mode: null, data: null };
}
@@ -204,6 +214,9 @@ app.put('/api/config', async (req, res) => {
mode: updates.mode ?? currentData.mode,
miningMode: updates.miningMode ?? currentData.miningMode,
pool: updates.pool ?? currentData.pool,
+ fallbackPools: Array.isArray(updates.fallbackPools)
+ ? updates.fallbackPools
+ : currentData.fallbackPools,
bitcoin: updates.bitcoin ?? currentData.bitcoin,
jdc: updates.jdc ?? currentData.jdc,
translator: updates.translator ?? currentData.translator,
diff --git a/server/src/types.ts b/server/src/types.ts
index ebcee76..2983283 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -40,6 +40,7 @@ export interface SetupData {
miningMode: MiningMode;
mode: SetupMode;
pool: PoolConfig | null;
+ fallbackPools: PoolConfig[];
bitcoin: BitcoinConfig | null;
jdc: JdcConfig | null;
translator: TranslatorConfig;
diff --git a/src/components/settings/ConfigurationTab.tsx b/src/components/settings/ConfigurationTab.tsx
index ca7977d..0864894 100644
--- a/src/components/settings/ConfigurationTab.tsx
+++ b/src/components/settings/ConfigurationTab.tsx
@@ -25,6 +25,9 @@ import {
Pencil,
Check,
X,
+ ArrowUp,
+ ArrowDown,
+ Plus,
} from 'lucide-react';
function clearPersistedDashboardState() {
@@ -50,7 +53,16 @@ function clearPersistedDashboardState() {
});
}
-type EditingField = null | 'pool' | 'mode' | 'identity' | 'signature' | 'advanced';
+type EditingField = null | 'pool' | 'mode' | 'identity' | 'signature' | 'advanced' | 'fallbacks';
+
+type DraftPool = { name: string; address: string; port: number; authority_public_key: string };
+
+const EMPTY_FALLBACK: DraftPool = {
+ name: 'Custom Pool',
+ address: '',
+ port: 34254,
+ authority_public_key: '',
+};
const DEFAULT_SHARES_PER_MINUTE = 6;
const DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE = 4;
@@ -65,6 +77,10 @@ function isPositiveInteger(value: string): boolean {
return isPositiveNumber(value) && Number.isInteger(parsed);
}
+function isFallbackValid(p: DraftPool): boolean {
+ return p.address.length > 0 && isValidPoolAuthorityPubkey(p.authority_public_key);
+}
+
/**
* Configuration tab for Settings page.
* Shows current setup and allows inline editing of pool and template mode.
@@ -93,7 +109,7 @@ export function ConfigurationTab() {
} = useControlApi();
const [editing, setEditing] = useState(null);
- const [editPool, setEditPool] = useState<{ name: string; address: string; port: number; authority_public_key: string } | null>(null);
+ const [editPool, setEditPool] = useState(null);
const [isCustomPool, setIsCustomPool] = useState(false);
const [editMode, setEditMode] = useState<'jd' | 'no-jd' | null>(null);
const [editIdentity, setEditIdentity] = useState('');
@@ -102,6 +118,7 @@ export function ConfigurationTab() {
shares_per_minute: string;
downstream_extranonce2_size: string;
} | null>(null);
+ const [editFallbacks, setEditFallbacks] = useState(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const clearDashboardClientState = () => {
@@ -203,6 +220,11 @@ export function ConfigurationTab() {
setEditing('advanced');
};
+ const startEditFallbacks = () => {
+ setEditFallbacks((config?.fallbackPools ?? []).map((p) => ({ ...p })));
+ setEditing('fallbacks');
+ };
+
const cancelEdit = () => {
setEditing(null);
setEditPool(null);
@@ -211,6 +233,7 @@ export function ConfigurationTab() {
setEditIdentity('');
setEditSignature('');
setEditAdvanced(null);
+ setEditFallbacks(null);
};
const isPoolValid =
@@ -223,6 +246,7 @@ export function ConfigurationTab() {
!!editAdvanced &&
isPositiveNumber(editAdvanced.shares_per_minute) &&
isPositiveInteger(editAdvanced.downstream_extranonce2_size);
+ const areFallbacksValid = editFallbacks?.every(isFallbackValid) ?? true;
const saveEdit = () => {
if (!config) return;
@@ -260,6 +284,9 @@ export function ConfigurationTab() {
shares_per_minute: Number(editAdvanced.shares_per_minute),
downstream_extranonce2_size: Number(editAdvanced.downstream_extranonce2_size),
};
+ } else if (editing === 'fallbacks' && editFallbacks) {
+ if (!areFallbacksValid) return;
+ updated.fallbackPools = editFallbacks;
}
setup(updated, {
@@ -615,6 +642,47 @@ export function ConfigurationTab() {
/>
)}
+ {/* Fallback Pools — pool mode only (sovereign solo has no upstreams) */}
+ {!isSovereignSolo && config.pool && (
+ None configured
+ ) : (
+
+ {config.fallbackPools.map((fp, i) => (
+
+
+ Fallback {i + 1}
+
+
+ {fp.address}:{fp.port}
+
+
+ ))}
+
+ )
+ }
+ editContent={
+ editFallbacks && (
+ p.badge !== 'coming-soon')}
+ />
+ )
+ }
+ />
+ )}
+
{/* Username / Identity */}
{(config.translator?.user_identity || config.jdc?.user_identity) && (() => {
const identity = config.translator?.user_identity || config.jdc?.user_identity || '';
@@ -962,3 +1030,174 @@ function PoolOption({
);
}
+
+function matchPoolByEndpoint(pools: KnownPool[], p: DraftPool): KnownPool | undefined {
+ return pools.find((kp) => kp.address === p.address && kp.port === p.port);
+}
+
+function FallbackListEditor({
+ value,
+ onChange,
+ pools,
+}: {
+ value: DraftPool[];
+ onChange: (next: DraftPool[]) => void;
+ pools: KnownPool[];
+}) {
+ const update = (i: number, patch: Partial) => {
+ onChange(value.map((p, idx) => (idx === i ? { ...p, ...patch } : p)));
+ };
+
+ const replaceAt = (i: number, next: DraftPool) => {
+ onChange(value.map((p, idx) => (idx === i ? next : p)));
+ };
+
+ const remove = (i: number) => onChange(value.filter((_, idx) => idx !== i));
+
+ const move = (i: number, dir: -1 | 1) => {
+ const t = i + dir;
+ if (t < 0 || t >= value.length) return;
+ const next = value.slice();
+ [next[i], next[t]] = [next[t], next[i]];
+ onChange(next);
+ };
+
+ const add = () => onChange([...value, { ...EMPTY_FALLBACK }]);
+
+ return (
+
+ {value.map((fp, i) => {
+ const matched = matchPoolByEndpoint(pools, fp);
+ const isCustom = !matched;
+ return (
+
+
+
+ Fallback {i + 1}
+
+
+
move(i, -1)}
+ disabled={i === 0}
+ aria-label={`Move fallback ${i + 1} up`}
+ className="w-6 h-6 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-accent disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+ >
+
+
+
move(i, 1)}
+ disabled={i === value.length - 1}
+ aria-label={`Move fallback ${i + 1} down`}
+ className="w-6 h-6 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-accent disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+ >
+
+
+
remove(i)}
+ aria-label={`Remove fallback ${i + 1}`}
+ className="w-6 h-6 rounded-md flex items-center justify-center text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
+ >
+
+
+
+
+
+
+ {pools.map((kp) => (
+
+ replaceAt(i, {
+ name: kp.name,
+ address: kp.address,
+ port: kp.port,
+ authority_public_key: kp.authority_public_key,
+ })
+ }
+ />
+ ))}
+ {
+ if (!isCustom) replaceAt(i, { ...EMPTY_FALLBACK });
+ }}
+ className={`w-full p-3 rounded-lg border transition-all text-left ${
+ isCustom
+ ? 'border-primary bg-primary/[0.04]'
+ : 'border-border bg-card hover:border-primary/45'
+ }`}
+ >
+
+
+
Custom Pool
+
Enter your own pool connection details
+
+ {isCustom && (
+
+
+
+ )}
+
+
+
+
+ {isCustom && (
+
+
+ Pool Address
+ update(i, { address: e.target.value })}
+ placeholder="pool.example.com"
+ className="w-full h-9 px-3 rounded-lg border border-input bg-background text-sm focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/15 outline-none transition-all"
+ />
+
+
+ Port
+ update(i, { port: parseInt(e.target.value) || 34254 })}
+ className="w-full h-9 px-3 rounded-lg border border-input bg-background text-sm focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/15 outline-none transition-all"
+ />
+
+
+
Authority Public Key
+
+ update(i, { authority_public_key: stripWrappingQuotes(e.target.value) })
+ }
+ placeholder="Enter pool's authority public key"
+ className="w-full h-9 px-3 rounded-lg border border-input bg-background font-mono text-sm focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/15 outline-none transition-all"
+ />
+ {getPoolAuthorityPubkeyError(fp.authority_public_key) && (
+
+ {getPoolAuthorityPubkeyError(fp.authority_public_key)}
+
+ )}
+
+
+ )}
+
+ );
+ })}
+
+
+
+ Add fallback pool
+
+
+ );
+}
diff --git a/src/components/setup/PoolPicker.tsx b/src/components/setup/PoolPicker.tsx
new file mode 100644
index 0000000..aca92c0
--- /dev/null
+++ b/src/components/setup/PoolPicker.tsx
@@ -0,0 +1,182 @@
+import { Check } from 'lucide-react';
+import { PoolIcon } from '@/components/ui/pool-icon';
+import type { KnownPool } from '@/lib/pools';
+import type { PoolConfig } from './types';
+import {
+ getPoolAuthorityPubkeyError,
+ stripWrappingQuotes,
+} from '@/lib/utils';
+
+interface PoolPickerProps {
+ pools: KnownPool[];
+ value: PoolConfig;
+ onChange: (value: PoolConfig) => void;
+ formIdPrefix: string;
+}
+
+function matchKnownPool(pools: KnownPool[], value: PoolConfig): KnownPool | undefined {
+ return pools.find((p) => p.address === value.address && p.port === value.port);
+}
+
+export function PoolPicker({ pools, value, onChange, formIdPrefix }: PoolPickerProps) {
+ const matched = matchKnownPool(pools, value);
+ const isCustom = !matched;
+
+ const selectKnown = (pool: KnownPool) => {
+ if (pool.badge === 'coming-soon') return;
+ onChange({
+ name: pool.name,
+ address: pool.address,
+ port: pool.port,
+ authority_public_key: pool.authority_public_key,
+ });
+ };
+
+ const selectCustom = () => {
+ if (isCustom) return;
+ onChange({ name: 'Custom Pool', address: '', port: 34254, authority_public_key: '' });
+ };
+
+ const updateCustomField = (field: keyof PoolConfig, val: string | number) => {
+ const normalized =
+ field === 'authority_public_key' && typeof val === 'string'
+ ? stripWrappingQuotes(val)
+ : val;
+ onChange({ ...value, [field]: normalized });
+ };
+
+ return (
+
+
+ {pools.map((pool) => {
+ const isSelected = matched?.id === pool.id;
+ const isDisabled = pool.badge === 'coming-soon';
+ return (
+
selectKnown(pool)}
+ disabled={isDisabled}
+ aria-pressed={isSelected}
+ className={`group w-full p-5 rounded-xl border transition-all text-left relative focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 ${
+ isDisabled
+ ? 'border-border opacity-50 cursor-not-allowed bg-card'
+ : isSelected
+ ? 'border-primary bg-primary/[0.04]'
+ : 'border-border bg-card hover:border-primary/45 hover:bg-primary/[0.02]'
+ }`}
+ >
+ {isSelected && (
+
+
+
+ )}
+ {pool.badge && !isSelected && (
+
+
+ {pool.badge === 'testing' ? 'Testing' : 'Coming Soon'}
+
+
+ )}
+
+
+
+
{pool.name}
+
{pool.description}
+ {pool.address && (
+
{pool.address}:{pool.port}
+ )}
+
+
+
+ );
+ })}
+
+
+ {isCustom && (
+
+
+
+ )}
+
+
Custom Pool
+
Configure your own pool connection
+
+
+
+
+ {isCustom && (
+
+ )}
+
+ );
+}
diff --git a/src/components/setup/SetupWizard.tsx b/src/components/setup/SetupWizard.tsx
index 8914708..0df83c2 100644
--- a/src/components/setup/SetupWizard.tsx
+++ b/src/components/setup/SetupWizard.tsx
@@ -137,9 +137,9 @@ export function SetupWizard() {
if (idx > 0) {
const prevStep = steps[idx - 1];
if (prevStep === 'mining-mode') {
- updateData({ miningMode: null, mode: null, pool: null, bitcoin: null, jdc: null, translator: null });
+ updateData({ miningMode: null, mode: null, pool: null, fallbackPools: [], bitcoin: null, jdc: null, translator: null });
} else if (prevStep === 'template-mode') {
- updateData({ pool: null, bitcoin: null, jdc: null, translator: null });
+ updateData({ pool: null, fallbackPools: [], bitcoin: null, jdc: null, translator: null });
}
setCurrentStep(prevStep);
}
diff --git a/src/components/setup/steps/MiningModeSelection.tsx b/src/components/setup/steps/MiningModeSelection.tsx
index 26a4c22..77fce63 100644
--- a/src/components/setup/steps/MiningModeSelection.tsx
+++ b/src/components/setup/steps/MiningModeSelection.tsx
@@ -18,7 +18,7 @@ export function MiningModeSelection({ updateData, onNext }: StepProps) {
const handleSelect = (miningMode: MiningMode) => {
if (phase !== 'idle') return;
setSelectedMode(miningMode);
- updateData({ miningMode, mode: null, pool: null, bitcoin: null, jdc: null, translator: null });
+ updateData({ miningMode, mode: null, pool: null, fallbackPools: [], bitcoin: null, jdc: null, translator: null });
setPhase('arming');
};
diff --git a/src/components/setup/steps/PoolConfigStep.tsx b/src/components/setup/steps/PoolConfigStep.tsx
index 9384215..e72845a 100644
--- a/src/components/setup/steps/PoolConfigStep.tsx
+++ b/src/components/setup/steps/PoolConfigStep.tsx
@@ -1,69 +1,64 @@
-import { useState } from 'react';
+import { useEffect } from 'react';
import { StepProps, PoolConfig } from '../types';
-import { Check } from 'lucide-react';
-import { PoolIcon } from '@/components/ui/pool-icon';
-import { POOL_MINING_NO_JD, POOL_MINING_JD, SOLO_POOLS, type KnownPool } from '@/lib/pools';
-import {
- getPoolAuthorityPubkeyError,
- isValidPoolAuthorityPubkey,
- stripWrappingQuotes,
-} from '@/lib/utils';
+import { Plus, ArrowUp, ArrowDown, X } from 'lucide-react';
+import { POOL_MINING_NO_JD, POOL_MINING_JD, SOLO_POOLS } from '@/lib/pools';
+import { isValidPoolAuthorityPubkey } from '@/lib/utils';
+import { PoolPicker } from '../PoolPicker';
+
+const EMPTY_CUSTOM: PoolConfig = {
+ name: 'Custom Pool',
+ address: '',
+ port: 34254,
+ authority_public_key: '',
+};
+
+function isPoolValid(pool: PoolConfig): boolean {
+ return pool.address.length > 0 && isValidPoolAuthorityPubkey(pool.authority_public_key);
+}
export function PoolConfigStep({ data, updateData, onNext }: StepProps) {
const isSoloMode = data.miningMode === 'solo';
const isJdMode = data.mode === 'jd';
-
const pools = isSoloMode ? SOLO_POOLS : (isJdMode ? POOL_MINING_JD : POOL_MINING_NO_JD);
- const defaultPool = pools.find(p => p.badge !== 'coming-soon') ?? null;
-
- const [isCustom, setIsCustom] = useState(false);
- const [selectedPoolId, setSelectedPoolId] = useState(() => {
- if (data.pool?.address) return null; // already has a value from back-navigation
- if (defaultPool) {
- // pre-populate data so Continue is enabled immediately
- setTimeout(() => updateData({ pool: { name: defaultPool.name, address: defaultPool.address, port: defaultPool.port, authority_public_key: defaultPool.authority_public_key } }), 0);
- return defaultPool.id;
- }
- return null;
- });
- const [customPool, setCustomPool] = useState({
- name: 'Custom Pool',
- address: '',
- port: 34254,
- authority_public_key: '',
- });
-
- const handleSelectPool = (pool: KnownPool) => {
- if (pool.badge === 'coming-soon') return;
- setSelectedPoolId(pool.id);
- updateData({ pool: { name: pool.name, address: pool.address, port: pool.port, authority_public_key: pool.authority_public_key } });
- setIsCustom(false);
+ const first = pools.find((p) => p.badge !== 'coming-soon');
+ const defaultPrimary: PoolConfig = first
+ ? { name: first.name, address: first.address, port: first.port, authority_public_key: first.authority_public_key }
+ : EMPTY_CUSTOM;
+
+ const primary: PoolConfig = data.pool ?? defaultPrimary;
+ useEffect(() => {
+ if (!data.pool) updateData({ pool: defaultPrimary });
+ // Pre-populate primary on first mount so Continue is enabled immediately.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const fallbacks = data.fallbackPools;
+
+ const setPrimary = (pool: PoolConfig) => updateData({ pool });
+
+ const setFallback = (index: number, pool: PoolConfig) => {
+ const next = fallbacks.map((f, i) => (i === index ? pool : f));
+ updateData({ fallbackPools: next });
+ };
+
+ const addFallback = () => {
+ updateData({ fallbackPools: [...fallbacks, { ...EMPTY_CUSTOM }] });
};
- const handleCustomChange = (field: keyof PoolConfig, value: string | number) => {
- // Normalize the stored pubkey so the TOML writer receives a clean value.
- // isValidPoolAuthorityPubkey also strips internally for its own robustness,
- // but the stored value has to be unquoted independently.
- const normalized =
- field === 'authority_public_key' && typeof value === 'string'
- ? stripWrappingQuotes(value)
- : value;
- const updated = { ...customPool, [field]: normalized };
- setCustomPool(updated);
- updateData({ pool: updated });
+ const removeFallback = (index: number) => {
+ updateData({ fallbackPools: fallbacks.filter((_, i) => i !== index) });
};
- const handleEnableCustom = () => {
- setIsCustom(true);
- setSelectedPoolId(null);
- updateData({ pool: customPool });
+ const moveFallback = (index: number, direction: -1 | 1) => {
+ const target = index + direction;
+ if (target < 0 || target >= fallbacks.length) return;
+ const next = fallbacks.slice();
+ [next[index], next[target]] = [next[target], next[index]];
+ updateData({ fallbackPools: next });
};
- const isValid =
- data.pool &&
- data.pool.address &&
- isValidPoolAuthorityPubkey(data.pool.authority_public_key);
+ const isValid = isPoolValid(primary) && fallbacks.every(isPoolValid);
return (
@@ -72,138 +67,80 @@ export function PoolConfigStep({ data, updateData, onNext }: StepProps) {
{isSoloMode ? 'Select Solo Pool' : 'Select Pool'}
- {isSoloMode ? 'Choose a solo mining pool to connect to' : isJdMode ? 'Choose a pool that supports Job Declaration' : 'Choose your mining pool'}
+ {isSoloMode
+ ? 'Choose a solo mining pool to connect to'
+ : isJdMode
+ ? 'Choose a pool that supports Job Declaration'
+ : 'Choose your mining pool'}
-
- {pools.map((pool) => {
- const isSelected = selectedPoolId === pool.id;
- const isDisabled = pool.badge === 'coming-soon';
- return (
-
handleSelectPool(pool)}
- disabled={isDisabled}
- aria-pressed={isSelected}
- className={`group w-full p-5 rounded-xl border transition-all text-left relative focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 ${
- isDisabled
- ? 'border-border opacity-50 cursor-not-allowed bg-card'
- : isSelected
- ? 'border-primary bg-primary/[0.04]'
- : 'border-border bg-card hover:border-primary/45 hover:bg-primary/[0.02]'
- }`}
- >
- {isSelected && (
-
-
-
- )}
- {pool.badge && !isSelected && (
-
-
- {pool.badge === 'testing' ? 'Testing' : 'Coming Soon'}
-
-
- )}
-
-
-
-
{pool.name}
-
{pool.description}
- {pool.address && (
-
{pool.address}:{pool.port}
- )}
+
+
+ {!isSoloMode && (
+
+
+
Fallback Pools
+
+ Tried in priority order if the primary pool becomes unreachable.
+
+
+
+ {fallbacks.map((fp, index) => (
+
+
+
Fallback {index + 1}
+
+
moveFallback(index, -1)}
+ disabled={index === 0}
+ aria-label={`Move fallback ${index + 1} up`}
+ className="w-7 h-7 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-accent disabled:opacity-30 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
+ >
+
+
+
moveFallback(index, 1)}
+ disabled={index === fallbacks.length - 1}
+ aria-label={`Move fallback ${index + 1} down`}
+ className="w-7 h-7 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-accent disabled:opacity-30 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
+ >
+
+
+
removeFallback(index)}
+ aria-label={`Remove fallback ${index + 1}`}
+ className="w-7 h-7 rounded-md flex items-center justify-center text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40"
+ >
+
+
-
- );
- })}
+
setFallback(index, pool)}
+ formIdPrefix={`fallback-${index}`}
+ />
+
+ ))}
-
- {isCustom && (
-
-
-
- )}
-
-
Custom Pool
-
Configure your own pool connection
-
+
+ Add fallback pool
-
-
- {isCustom && (
-
)}
diff --git a/src/components/setup/steps/ReviewStart.tsx b/src/components/setup/steps/ReviewStart.tsx
index a2d57e3..161659f 100644
--- a/src/components/setup/steps/ReviewStart.tsx
+++ b/src/components/setup/steps/ReviewStart.tsx
@@ -212,6 +212,19 @@ export function ReviewStart({ data, onComplete }: ReviewStartProps) {
will not track workers individually.
)}
+ {data.fallbackPools.map((fp, i) => (
+
+
+ Fallback {i + 1}
+
+
+ {fp.address}:{fp.port}
+
+
+ {fp.authority_public_key}
+
+
+ ))}
)}
diff --git a/src/components/setup/steps/TemplateModeSelection.tsx b/src/components/setup/steps/TemplateModeSelection.tsx
index ffe4052..3cfa049 100644
--- a/src/components/setup/steps/TemplateModeSelection.tsx
+++ b/src/components/setup/steps/TemplateModeSelection.tsx
@@ -16,6 +16,7 @@ export function TemplateModeSelection({ data, updateData, onNext }: StepProps) {
updateData({
mode,
pool: mode === 'jd' ? null : data.pool,
+ fallbackPools: mode === 'jd' ? [] : data.fallbackPools,
bitcoin: mode === 'jd' ? data.bitcoin : null,
jdc: mode === 'jd' ? data.jdc : null,
});
diff --git a/src/components/setup/types.ts b/src/components/setup/types.ts
index 5cce3cf..8012e47 100644
--- a/src/components/setup/types.ts
+++ b/src/components/setup/types.ts
@@ -40,6 +40,7 @@ export interface SetupData {
miningMode: MiningMode | null;
mode: SetupMode | null;
pool: PoolConfig | null;
+ fallbackPools: PoolConfig[];
bitcoin: BitcoinConfig | null;
jdc: JdcConfig | null;
translator: TranslatorConfig | null;
@@ -49,6 +50,7 @@ export const initialSetupData: SetupData = {
miningMode: null,
mode: null,
pool: null,
+ fallbackPools: [],
bitcoin: null,
jdc: null,
translator: null,
From f8ef94961c66719d24a4446dae1c8e6914c49117 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=A2=D0=BE=D0=BB=D0=BB=D0=B8?=
Date: Fri, 1 May 2026 11:13:18 +0300
Subject: [PATCH 2/3] fix duplicate detection and solo-mode visibility for
fallback pools
---
src/components/settings/ConfigurationTab.tsx | 85 +++++++++++--------
src/components/setup/PoolPicker.tsx | 6 +-
.../setup/steps/MiningModeSelection.tsx | 8 +-
src/components/setup/steps/PoolConfigStep.tsx | 73 +++++++++++-----
.../setup/steps/TemplateModeSelection.tsx | 5 ++
src/lib/pools.ts | 25 ++++++
6 files changed, 141 insertions(+), 61 deletions(-)
diff --git a/src/components/settings/ConfigurationTab.tsx b/src/components/settings/ConfigurationTab.tsx
index 0864894..84f6f33 100644
--- a/src/components/settings/ConfigurationTab.tsx
+++ b/src/components/settings/ConfigurationTab.tsx
@@ -7,15 +7,14 @@ import { Badge } from '@/components/ui/badge';
import { PoolIcon } from '@/components/ui/pool-icon';
import { useSetupStatus } from '@/hooks/useSetupStatus';
import { useControlApi, getCurrentConfig } from '@/hooks/useControlApi';
-import { getPoolsForMode, type KnownPool } from '@/lib/pools';
+import { getPoolsForMode, EMPTY_CUSTOM_POOL, isPoolValid, isSamePool, type KnownPool } from '@/lib/pools';
import {
getIdentifierError,
getPoolAuthorityPubkeyError,
isTomlSafeIdentifier,
- isValidPoolAuthorityPubkey,
stripWrappingQuotes,
} from '@/lib/utils';
-import type { SetupData } from '@/components/setup/types';
+import type { PoolConfig, SetupData } from '@/components/setup/types';
import {
Loader2,
AlertCircle,
@@ -55,14 +54,7 @@ function clearPersistedDashboardState() {
type EditingField = null | 'pool' | 'mode' | 'identity' | 'signature' | 'advanced' | 'fallbacks';
-type DraftPool = { name: string; address: string; port: number; authority_public_key: string };
-
-const EMPTY_FALLBACK: DraftPool = {
- name: 'Custom Pool',
- address: '',
- port: 34254,
- authority_public_key: '',
-};
+type DraftPool = PoolConfig;
const DEFAULT_SHARES_PER_MINUTE = 6;
const DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE = 4;
@@ -77,10 +69,6 @@ function isPositiveInteger(value: string): boolean {
return isPositiveNumber(value) && Number.isInteger(parsed);
}
-function isFallbackValid(p: DraftPool): boolean {
- return p.address.length > 0 && isValidPoolAuthorityPubkey(p.authority_public_key);
-}
-
/**
* Configuration tab for Settings page.
* Shows current setup and allows inline editing of pool and template mode.
@@ -236,17 +224,25 @@ export function ConfigurationTab() {
setEditFallbacks(null);
};
- const isPoolValid =
- !!editPool?.address &&
- !!editPool?.authority_public_key &&
- isValidPoolAuthorityPubkey(editPool.authority_public_key);
+ const isEditPoolValid = !!editPool && isPoolValid(editPool);
const isIdentityValid = isTomlSafeIdentifier(editIdentity);
const isSignatureValid = editSignature === '' || isTomlSafeIdentifier(editSignature);
const isAdvancedValid =
!!editAdvanced &&
isPositiveNumber(editAdvanced.shares_per_minute) &&
isPositiveInteger(editAdvanced.downstream_extranonce2_size);
- const areFallbacksValid = editFallbacks?.every(isFallbackValid) ?? true;
+ const fallbackDuplicateIndex = (() => {
+ if (!editFallbacks || !config?.pool) return -1;
+ const all = [config.pool, ...editFallbacks];
+ for (let i = 1; i < all.length; i++) {
+ for (let j = 0; j < i; j++) {
+ if (isSamePool(all[i], all[j])) return i - 1;
+ }
+ }
+ return -1;
+ })();
+ const areFallbacksValid =
+ (editFallbacks?.every(isPoolValid) ?? true) && fallbackDuplicateIndex === -1;
const saveEdit = () => {
if (!config) return;
@@ -254,7 +250,7 @@ export function ConfigurationTab() {
const updated: SetupData = { ...config };
if (editing === 'pool' && editPool) {
- if (!isPoolValid) return;
+ if (!isEditPoolValid) return;
updated.pool = { ...editPool };
} else if (editing === 'mode') {
if (editMode === 'jd' && !config.bitcoin) {
@@ -544,7 +540,7 @@ export function ConfigurationTab() {
onSave={saveEdit}
onCancel={cancelEdit}
isSaving={isSaving}
- saveDisabled={!isPoolValid}
+ saveDisabled={!isEditPoolValid}
disabled={editing !== null && editing !== 'pool'}
display={
<>
@@ -677,6 +673,8 @@ export function ConfigurationTab() {
value={editFallbacks}
onChange={setEditFallbacks}
pools={pools.filter((p) => p.badge !== 'coming-soon')}
+ primary={config.pool}
+ duplicateIndex={fallbackDuplicateIndex}
/>
)
}
@@ -1031,18 +1029,26 @@ function PoolOption({
);
}
-function matchPoolByEndpoint(pools: KnownPool[], p: DraftPool): KnownPool | undefined {
- return pools.find((kp) => kp.address === p.address && kp.port === p.port);
+function knownToConfig(kp: KnownPool): PoolConfig {
+ return { name: kp.name, address: kp.address, port: kp.port, authority_public_key: kp.authority_public_key };
+}
+
+function matchKnownByPool(pools: KnownPool[], p: DraftPool): KnownPool | undefined {
+ return pools.find((kp) => isSamePool(knownToConfig(kp), p));
}
function FallbackListEditor({
value,
onChange,
pools,
+ primary,
+ duplicateIndex,
}: {
value: DraftPool[];
onChange: (next: DraftPool[]) => void;
pools: KnownPool[];
+ primary: PoolConfig | null;
+ duplicateIndex: number;
}) {
const update = (i: number, patch: Partial) => {
onChange(value.map((p, idx) => (idx === i ? { ...p, ...patch } : p)));
@@ -1062,12 +1068,20 @@ function FallbackListEditor({
onChange(next);
};
- const add = () => onChange([...value, { ...EMPTY_FALLBACK }]);
+ const add = () => onChange([...value, { ...EMPTY_CUSTOM_POOL }]);
return (
{value.map((fp, i) => {
- const matched = matchPoolByEndpoint(pools, fp);
+ // Filter known pools shown for this slot: hide any pool already
+ // claimed by primary or another fallback. Slot's own pick stays.
+ const slotPools = pools.filter((kp) => {
+ const kpAsConfig = knownToConfig(kp);
+ if (isSamePool(fp, kpAsConfig)) return true;
+ if (primary && isSamePool(primary, kpAsConfig)) return false;
+ return !value.some((other, j) => j !== i && isSamePool(other, kpAsConfig));
+ });
+ const matched = matchKnownByPool(slotPools, fp);
const isCustom = !matched;
return (
@@ -1106,25 +1120,18 @@ function FallbackListEditor({
- {pools.map((kp) => (
+ {slotPools.map((kp) => (
- replaceAt(i, {
- name: kp.name,
- address: kp.address,
- port: kp.port,
- authority_public_key: kp.authority_public_key,
- })
- }
+ onSelect={() => replaceAt(i, knownToConfig(kp))}
/>
))}
{
- if (!isCustom) replaceAt(i, { ...EMPTY_FALLBACK });
+ if (!isCustom) replaceAt(i, { ...EMPTY_CUSTOM_POOL });
}}
className={`w-full p-3 rounded-lg border transition-all text-left ${
isCustom
@@ -1186,6 +1193,12 @@ function FallbackListEditor({
)}
+
+ {duplicateIndex === i && (
+
+ This pool is already used. Remove or change it — fallbacks must be distinct from the primary and each other.
+
+ )}
);
})}
diff --git a/src/components/setup/PoolPicker.tsx b/src/components/setup/PoolPicker.tsx
index aca92c0..4f72fc2 100644
--- a/src/components/setup/PoolPicker.tsx
+++ b/src/components/setup/PoolPicker.tsx
@@ -1,6 +1,6 @@
import { Check } from 'lucide-react';
import { PoolIcon } from '@/components/ui/pool-icon';
-import type { KnownPool } from '@/lib/pools';
+import { EMPTY_CUSTOM_POOL, isSamePool, type KnownPool } from '@/lib/pools';
import type { PoolConfig } from './types';
import {
getPoolAuthorityPubkeyError,
@@ -15,7 +15,7 @@ interface PoolPickerProps {
}
function matchKnownPool(pools: KnownPool[], value: PoolConfig): KnownPool | undefined {
- return pools.find((p) => p.address === value.address && p.port === value.port);
+ return pools.find((p) => isSamePool({ name: p.name, address: p.address, port: p.port, authority_public_key: p.authority_public_key }, value));
}
export function PoolPicker({ pools, value, onChange, formIdPrefix }: PoolPickerProps) {
@@ -34,7 +34,7 @@ export function PoolPicker({ pools, value, onChange, formIdPrefix }: PoolPickerP
const selectCustom = () => {
if (isCustom) return;
- onChange({ name: 'Custom Pool', address: '', port: 34254, authority_public_key: '' });
+ onChange({ ...EMPTY_CUSTOM_POOL });
};
const updateCustomField = (field: keyof PoolConfig, val: string | number) => {
diff --git a/src/components/setup/steps/MiningModeSelection.tsx b/src/components/setup/steps/MiningModeSelection.tsx
index 77fce63..238c0d0 100644
--- a/src/components/setup/steps/MiningModeSelection.tsx
+++ b/src/components/setup/steps/MiningModeSelection.tsx
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import type { MiningMode, StepProps } from '../types';
import { Miner3D, type MinerPhase } from './Miner3D';
-export function MiningModeSelection({ updateData, onNext }: StepProps) {
+export function MiningModeSelection({ data, updateData, onNext }: StepProps) {
const [phase, setPhase] = useState('idle');
const [selectedMode, setSelectedMode] = useState(null);
const nextRef = useRef(onNext);
@@ -18,7 +18,11 @@ export function MiningModeSelection({ updateData, onNext }: StepProps) {
const handleSelect = (miningMode: MiningMode) => {
if (phase !== 'idle') return;
setSelectedMode(miningMode);
- updateData({ miningMode, mode: null, pool: null, fallbackPools: [], bitcoin: null, jdc: null, translator: null });
+ // Only reset downstream state if the user actually changed mode. Picking
+ // the same mode (Reconfigure flow) preserves existing pool/fallback work.
+ if (miningMode !== data.miningMode) {
+ updateData({ miningMode, mode: null, pool: null, fallbackPools: [], bitcoin: null, jdc: null, translator: null });
+ }
setPhase('arming');
};
diff --git a/src/components/setup/steps/PoolConfigStep.tsx b/src/components/setup/steps/PoolConfigStep.tsx
index e72845a..0de1119 100644
--- a/src/components/setup/steps/PoolConfigStep.tsx
+++ b/src/components/setup/steps/PoolConfigStep.tsx
@@ -1,30 +1,34 @@
import { useEffect } from 'react';
import { StepProps, PoolConfig } from '../types';
import { Plus, ArrowUp, ArrowDown, X } from 'lucide-react';
-import { POOL_MINING_NO_JD, POOL_MINING_JD, SOLO_POOLS } from '@/lib/pools';
-import { isValidPoolAuthorityPubkey } from '@/lib/utils';
+import {
+ POOL_MINING_NO_JD,
+ POOL_MINING_JD,
+ SOLO_POOLS,
+ EMPTY_CUSTOM_POOL,
+ isPoolValid,
+ isSamePool,
+ type KnownPool,
+} from '@/lib/pools';
import { PoolPicker } from '../PoolPicker';
-const EMPTY_CUSTOM: PoolConfig = {
- name: 'Custom Pool',
- address: '',
- port: 34254,
- authority_public_key: '',
-};
-
-function isPoolValid(pool: PoolConfig): boolean {
- return pool.address.length > 0 && isValidPoolAuthorityPubkey(pool.authority_public_key);
+function knownPoolToConfig(p: KnownPool): PoolConfig {
+ return {
+ name: p.name,
+ address: p.address,
+ port: p.port,
+ authority_public_key: p.authority_public_key,
+ };
}
export function PoolConfigStep({ data, updateData, onNext }: StepProps) {
const isSoloMode = data.miningMode === 'solo';
const isJdMode = data.mode === 'jd';
+ const isSovereignSolo = isSoloMode && isJdMode;
const pools = isSoloMode ? SOLO_POOLS : (isJdMode ? POOL_MINING_JD : POOL_MINING_NO_JD);
const first = pools.find((p) => p.badge !== 'coming-soon');
- const defaultPrimary: PoolConfig = first
- ? { name: first.name, address: first.address, port: first.port, authority_public_key: first.authority_public_key }
- : EMPTY_CUSTOM;
+ const defaultPrimary: PoolConfig = first ? knownPoolToConfig(first) : EMPTY_CUSTOM_POOL;
const primary: PoolConfig = data.pool ?? defaultPrimary;
useEffect(() => {
@@ -38,12 +42,11 @@ export function PoolConfigStep({ data, updateData, onNext }: StepProps) {
const setPrimary = (pool: PoolConfig) => updateData({ pool });
const setFallback = (index: number, pool: PoolConfig) => {
- const next = fallbacks.map((f, i) => (i === index ? pool : f));
- updateData({ fallbackPools: next });
+ updateData({ fallbackPools: fallbacks.map((f, i) => (i === index ? pool : f)) });
};
const addFallback = () => {
- updateData({ fallbackPools: [...fallbacks, { ...EMPTY_CUSTOM }] });
+ updateData({ fallbackPools: [...fallbacks, { ...EMPTY_CUSTOM_POOL }] });
};
const removeFallback = (index: number) => {
@@ -58,7 +61,32 @@ export function PoolConfigStep({ data, updateData, onNext }: StepProps) {
updateData({ fallbackPools: next });
};
- const isValid = isPoolValid(primary) && fallbacks.every(isPoolValid);
+ // Filter known pools shown in fallback slot N: hide any pool already claimed
+ // by primary or another fallback. The slot's own current pick stays visible
+ // so its branding renders. Custom Pool always remains available.
+ const knownPoolsForFallback = (slotIndex: number): KnownPool[] =>
+ pools.filter((kp) => {
+ const kpAsConfig = knownPoolToConfig(kp);
+ if (isSamePool(fallbacks[slotIndex], kpAsConfig)) return true;
+ if (isSamePool(primary, kpAsConfig)) return false;
+ return !fallbacks.some((other, j) => j !== slotIndex && isSamePool(other, kpAsConfig));
+ });
+
+ // Reject configurations where the same SV2 endpoint appears more than once.
+ // sv2-apps treats each [[upstreams]] entry as a fresh attempt, so duplicates
+ // burn retries against the same dead pool with no failover benefit.
+ const duplicateIndex = (() => {
+ const all = [primary, ...fallbacks];
+ for (let i = 1; i < all.length; i++) {
+ for (let j = 0; j < i; j++) {
+ if (isSamePool(all[i], all[j])) return i - 1; // index in fallbacks
+ }
+ }
+ return -1;
+ })();
+ const hasDuplicate = duplicateIndex !== -1;
+
+ const isValid = isPoolValid(primary) && fallbacks.every(isPoolValid) && !hasDuplicate;
return (
@@ -82,7 +110,7 @@ export function PoolConfigStep({ data, updateData, onNext }: StepProps) {
formIdPrefix="primary-pool"
/>
- {!isSoloMode && (
+ {!isSovereignSolo && (
Fallback Pools
@@ -125,11 +153,16 @@ export function PoolConfigStep({ data, updateData, onNext }: StepProps) {
setFallback(index, pool)}
formIdPrefix={`fallback-${index}`}
/>
+ {duplicateIndex === index && (
+
+ This pool is already used. Remove or change it — fallbacks must be distinct from the primary and each other.
+
+ )}
))}
diff --git a/src/components/setup/steps/TemplateModeSelection.tsx b/src/components/setup/steps/TemplateModeSelection.tsx
index 3cfa049..540d2fb 100644
--- a/src/components/setup/steps/TemplateModeSelection.tsx
+++ b/src/components/setup/steps/TemplateModeSelection.tsx
@@ -13,6 +13,11 @@ export function TemplateModeSelection({ data, updateData, onNext }: StepProps) {
const secondaryFooter = isSoloMode ? 'Simpler setup with a solo pool' : 'Simpler setup';
const handleSelect = (mode: 'jd' | 'no-jd') => {
+ if (mode === data.mode) {
+ // Same mode — preserve everything (Reconfigure walk-through).
+ onNext();
+ return;
+ }
updateData({
mode,
pool: mode === 'jd' ? null : data.pool,
diff --git a/src/lib/pools.ts b/src/lib/pools.ts
index 5117dc9..d5a650c 100644
--- a/src/lib/pools.ts
+++ b/src/lib/pools.ts
@@ -2,6 +2,31 @@
* Shared pool preset definitions used by both the Setup Wizard and Settings.
*/
+import type { PoolConfig } from '@/components/setup/types';
+import { isValidPoolAuthorityPubkey } from '@/lib/utils';
+
+export const EMPTY_CUSTOM_POOL: PoolConfig = {
+ name: 'Custom Pool',
+ address: '',
+ port: 34254,
+ authority_public_key: '',
+};
+
+// Two pools are the same iff their full SV2 endpoint triplet matches.
+// Address+port alone isn't enough — a typo'd or malicious pubkey on the
+// same host:port is a different security context, not a duplicate.
+export function isSamePool(a: PoolConfig, b: PoolConfig): boolean {
+ return (
+ a.address.toLowerCase() === b.address.toLowerCase() &&
+ a.port === b.port &&
+ a.authority_public_key === b.authority_public_key
+ );
+}
+
+export function isPoolValid(p: PoolConfig): boolean {
+ return p.address.length > 0 && isValidPoolAuthorityPubkey(p.authority_public_key);
+}
+
export interface KnownPool {
id: string;
name: string;
From 5c666c55c25c5c2f5452017203d5ea4b8a04b80f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=A2=D0=BE=D0=BB=D0=BB=D0=B8?=
Date: Mon, 4 May 2026 22:23:10 +0300
Subject: [PATCH 3/3] show correct fallback connection in ui
---
server/src/index.ts | 18 ++++++--
server/src/logs/current-upstream.test.ts | 54 ++++++++++++++++++++++++
server/src/logs/current-upstream.ts | 54 ++++++++++++++++++++++++
3 files changed, 123 insertions(+), 3 deletions(-)
create mode 100644 server/src/logs/current-upstream.test.ts
create mode 100644 server/src/logs/current-upstream.ts
diff --git a/server/src/index.ts b/server/src/index.ts
index 6395432..e8a4cf4 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -24,6 +24,7 @@ import {
readContainerLogs
} from './docker.js';
import { getLogDiagnostics, getLogStreams, readCollatedLogLines } from './logs/diagnostics.js';
+import { getCurrentUpstreamPoolName } from './logs/current-upstream.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
@@ -104,14 +105,25 @@ app.get('/api/status', async (_req, res) => {
(containers.jdc?.status === 'healthy' || containers.jdc?.status === 'starting')
: (containers.translator?.status === 'healthy' || containers.translator?.status === 'starting');
+ const isSovereignSolo = state.data?.miningMode === 'solo' && state.data?.mode === 'jd';
+ let poolName: string | null;
+ if (isSovereignSolo) {
+ poolName = 'Sovereign Solo Mining';
+ } else if (running) {
+ const container = state.mode === 'jd' ? 'jdc' : 'translator';
+ poolName = (await getCurrentUpstreamPoolName(container, state.data))
+ ?? state.data?.pool?.name
+ ?? null;
+ } else {
+ poolName = state.data?.pool?.name ?? null;
+ }
+
const response: StatusResponse = {
configured: state.configured,
running,
miningMode: state.miningMode,
mode: state.mode,
- poolName: state.data?.miningMode === 'solo' && state.data?.mode === 'jd'
- ? 'Sovereign Solo Mining'
- : (state.data?.pool?.name ?? null),
+ poolName,
containers,
};
diff --git a/server/src/logs/current-upstream.test.ts b/server/src/logs/current-upstream.test.ts
new file mode 100644
index 0000000..0dd5c5b
--- /dev/null
+++ b/server/src/logs/current-upstream.test.ts
@@ -0,0 +1,54 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { findCurrentUpstreamFromLines } from './current-upstream.js';
+
+test('returns the only Trying upstream when there is one', () => {
+ const lines = [
+ 'INFO translator_sv2: Trying upstream 1 of 1: stratum.braiins.com:3333',
+ 'INFO translator_sv2::sv2::upstream: Connected to upstream at 172.65.65.63:3333',
+ ];
+ assert.deepEqual(findCurrentUpstreamFromLines(lines), { host: 'stratum.braiins.com', port: 3333 });
+});
+
+test('after failover, returns the second upstream not the first that moved on', () => {
+ const lines = [
+ 'INFO translator_sv2: Trying upstream 1 of 2: nonexistent-pool.invalid:3333',
+ 'WARN translator_sv2: Max retries reached for nonexistent-pool.invalid:3333, moving to next upstream',
+ 'INFO translator_sv2: Trying upstream 2 of 2: blitzpool.yourdevice.ch:3333',
+ 'INFO translator_sv2::sv2::upstream: Connected to upstream at 1.2.3.4:3333',
+ ];
+ assert.deepEqual(findCurrentUpstreamFromLines(lines), {
+ host: 'blitzpool.yourdevice.ch',
+ port: 3333,
+ });
+});
+
+test('returns null when all upstreams failed', () => {
+ const lines = [
+ 'INFO translator_sv2: Trying upstream 1 of 2: a.example.com:3333',
+ 'WARN translator_sv2: Max retries reached for a.example.com:3333, moving to next upstream',
+ 'INFO translator_sv2: Trying upstream 2 of 2: b.example.com:3333',
+ 'WARN translator_sv2: Max retries reached for b.example.com:3333, moving to next upstream',
+ 'ERROR translator_sv2: All upstreams failed after 3 retries each',
+ ];
+ assert.equal(findCurrentUpstreamFromLines(lines), null);
+});
+
+test('multi-cycle reconnect returns the most recent upstream', () => {
+ const lines = [
+ 'INFO translator_sv2: Trying upstream 1 of 2: pool-a.example.com:3333',
+ 'INFO translator_sv2::sv2::upstream: Connected to upstream at 1.1.1.1:3333',
+ 'WARN translator_sv2: Max retries reached for pool-a.example.com:3333, moving to next upstream',
+ 'INFO translator_sv2: Trying upstream 2 of 2: pool-b.example.com:3333',
+ 'INFO translator_sv2::sv2::upstream: Connected to upstream at 2.2.2.2:3333',
+ 'WARN translator_sv2: Max retries reached for pool-b.example.com:3333, moving to next upstream',
+ 'INFO translator_sv2: Trying upstream 1 of 2: pool-a.example.com:3333',
+ 'INFO translator_sv2::sv2::upstream: Connected to upstream at 1.1.1.1:3333',
+ ];
+ assert.deepEqual(findCurrentUpstreamFromLines(lines), { host: 'pool-a.example.com', port: 3333 });
+});
+
+test('returns null on empty log', () => {
+ assert.equal(findCurrentUpstreamFromLines([]), null);
+});
diff --git a/server/src/logs/current-upstream.ts b/server/src/logs/current-upstream.ts
new file mode 100644
index 0000000..902397b
--- /dev/null
+++ b/server/src/logs/current-upstream.ts
@@ -0,0 +1,54 @@
+import { readContainerLogs } from '../docker.js';
+import type { LogContainerRole } from './types.js';
+import type { PoolConfig, SetupData } from '../types.js';
+
+const TRYING_RE = /Trying upstream \d+ of \d+:\s+([^\s:]+):(\d+)/;
+const MAX_RETRIES_RE = /Max retries reached for ([^\s:]+):(\d+),\s*moving to next upstream/;
+
+export function findCurrentUpstreamFromLines(lines: string[]): { host: string; port: number } | null {
+ for (let i = lines.length - 1; i >= 0; i--) {
+ const line = lines[i];
+ if (/All upstreams failed/.test(line)) return null;
+ const trying = line.match(TRYING_RE);
+ if (!trying) continue;
+ const host = trying[1];
+ const port = Number(trying[2]);
+ let movedOn = false;
+ for (let j = i + 1; j < lines.length; j++) {
+ const m = lines[j].match(MAX_RETRIES_RE);
+ if (m && m[1] === host && Number(m[2]) === port) {
+ movedOn = true;
+ break;
+ }
+ }
+ if (movedOn) continue;
+ return { host, port };
+ }
+ return null;
+}
+
+function matchPool(host: string, port: number, data: SetupData): PoolConfig | null {
+ const candidates: PoolConfig[] = [];
+ if (data.pool) candidates.push(data.pool);
+ candidates.push(...data.fallbackPools);
+ return candidates.find(
+ (p) => p.address.toLowerCase() === host.toLowerCase() && p.port === port,
+ ) ?? null;
+}
+
+export async function getCurrentUpstreamPoolName(
+ container: LogContainerRole,
+ data: SetupData | null,
+): Promise {
+ if (!data) return null;
+ let lines: string[];
+ try {
+ const logLines = await readContainerLogs(container, { tail: 100 });
+ lines = logLines.map((l) => l.message);
+ } catch {
+ return null;
+ }
+ const found = findCurrentUpstreamFromLines(lines);
+ if (!found) return null;
+ return matchPool(found.host, found.port, data)?.name ?? `${found.host}:${found.port}`;
+}