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
193 changes: 147 additions & 46 deletions server/src/config-generator.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
50 changes: 33 additions & 17 deletions server/src/config-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -148,7 +160,7 @@ job_keepalive_interval_secs = 60
address = "${upstreamAddress}"
port = ${upstreamPort}
authority_pubkey = "${authorityPubkey}"
`;
${fallbackBlocks}`;
}

/**
Expand All @@ -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;

Expand All @@ -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<typeof pool>): 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

Expand Down
33 changes: 29 additions & 4 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -51,7 +52,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 };
}
Expand Down Expand Up @@ -94,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,
};

Expand Down Expand Up @@ -204,6 +226,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,
Expand Down
Loading
Loading