Skip to content
Merged
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
13 changes: 13 additions & 0 deletions server/src/config-generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,19 @@ test('normalization backfills advanced defaults for old saved configs', () => {
assert.equal('verify_payout' in normalized.translator, false);
});

test('normalization replaces an out-of-range downstream extranonce2 size', () => {
const normalized = normalizeSetupData({
...BASE_DATA_30,
translator: {
...BASE_DATA_30.translator!,
downstream_extranonce2_size: 1_000_000_000_000,
},
});

assert.equal(normalized.translator?.downstream_extranonce2_size, 4);
assert.match(generateTranslatorConfig(normalized), /downstream_extranonce2_size = 4/);
});

test('normalization backfills payout verification only for solo pool mining', () => {
const normalized = normalizeSetupData({
...BASE_DATA_30,
Expand Down
23 changes: 15 additions & 8 deletions server/src/config-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isFullDonationIdentity,
DEFAULT_SHARES_PER_MINUTE,
DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE,
MAX_DOWNSTREAM_EXTRANONCE2_SIZE,
DEFAULT_MIN_HASHRATE,
bitcoinCoreVersionToIpcMajor,
formatSupportedVersions,
Expand All @@ -26,9 +27,17 @@ function positiveNumber(value: number | undefined, fallback: number): number {
: fallback;
}

function positiveInteger(value: number | undefined, fallback: number): number {
const normalized = positiveNumber(value, fallback);
return Math.max(1, Math.trunc(normalized));
function validDownstreamExtranonce2Size(value: number | undefined): value is number {
return typeof value === 'number'
&& Number.isInteger(value)
&& value > 0
&& value <= MAX_DOWNSTREAM_EXTRANONCE2_SIZE;
}

function downstreamExtranonce2Size(value: number | undefined): number {
return validDownstreamExtranonce2Size(value)
? value
: DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE;
}

function legacyIdentity(data: SetupData): string {
Expand Down Expand Up @@ -154,9 +163,8 @@ export function normalizeSetupData(data: SetupData): SetupData {
...(isSoloPool ? { verify_payout: data.translator.verify_payout ?? true } : {}),
min_hashrate: positiveNumber(data.translator.min_hashrate, DEFAULT_MIN_HASHRATE),
shares_per_minute: positiveNumber(data.translator.shares_per_minute, DEFAULT_SHARES_PER_MINUTE),
downstream_extranonce2_size: positiveInteger(
downstream_extranonce2_size: downstreamExtranonce2Size(
data.translator.downstream_extranonce2_size,
DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE,
),
},
};
Expand Down Expand Up @@ -191,9 +199,8 @@ verify_payout = ${verifyPayout}
const minHashrate = `${positiveNumber(translator.min_hashrate, DEFAULT_MIN_HASHRATE)}.0`;
// Shares per minute target
const sharesPerMinute = positiveNumber(translator.shares_per_minute, DEFAULT_SHARES_PER_MINUTE).toFixed(1);
const downstreamExtranonce2Size = positiveInteger(
const normalizedDownstreamExtranonce2Size = downstreamExtranonce2Size(
translator.downstream_extranonce2_size,
DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE,
);

const upstreams = isJdMode
Expand Down Expand Up @@ -226,7 +233,7 @@ max_supported_version = 2
min_supported_version = 2

# Extranonce2 size for downstream connections
downstream_extranonce2_size = ${downstreamExtranonce2Size}
downstream_extranonce2_size = ${normalizedDownstreamExtranonce2Size}

${verifyPayoutConfig}
# Aggregate channels: if true, all miners share one upstream channel
Expand Down
1 change: 1 addition & 0 deletions shared/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const JDC_MONITORING_PORT = 9091;

export const DEFAULT_SHARES_PER_MINUTE = 6;
export const DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE = 4;
export const MAX_DOWNSTREAM_EXTRANONCE2_SIZE = 65_535;
export const DEFAULT_MIN_HASHRATE = 100_000_000_000_000;
export const DEFAULT_POOL_PORT = 34254;

Expand Down
79 changes: 79 additions & 0 deletions src/components/AdvancedMiningConfigForm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import {
createAdvancedMiningConfigValues,
isAdvancedMiningConfigValid,
parseAdvancedMiningConfigValues,
} from './mining/AdvancedMiningConfigForm';

test('creates advanced mining form values from shared defaults', () => {
assert.deepEqual(createAdvancedMiningConfigValues(), {
sharesPerMinute: '6',
downstreamExtranonce2Size: '4',
verifyPayout: true,
});
});

test('creates advanced mining form values from translator configuration', () => {
assert.deepEqual(createAdvancedMiningConfigValues({
enable_vardiff: true,
aggregate_channels: false,
verify_payout: false,
min_hashrate: 100_000_000_000_000,
shares_per_minute: 12.5,
downstream_extranonce2_size: 8,
}), {
sharesPerMinute: '12.5',
downstreamExtranonce2Size: '8',
verifyPayout: false,
});
});

test('validates positive share rates and unsigned 16-bit extranonce2 sizes', () => {
assert.equal(isAdvancedMiningConfigValid({
sharesPerMinute: '6.5',
downstreamExtranonce2Size: '65535',
verifyPayout: true,
}), true);
assert.equal(isAdvancedMiningConfigValid({
sharesPerMinute: '0',
downstreamExtranonce2Size: '8',
verifyPayout: true,
}), false);
assert.equal(isAdvancedMiningConfigValid({
sharesPerMinute: '6.5',
downstreamExtranonce2Size: '4.5',
verifyPayout: true,
}), false);
assert.equal(isAdvancedMiningConfigValid({
sharesPerMinute: '6.5',
downstreamExtranonce2Size: '65536',
verifyPayout: true,
}), false);
assert.equal(isAdvancedMiningConfigValid({
sharesPerMinute: '6.5',
downstreamExtranonce2Size: '1000000000000',
verifyPayout: true,
}), false);
});

test('parses form values for translator configuration', () => {
assert.deepEqual(parseAdvancedMiningConfigValues({
sharesPerMinute: '12.5',
downstreamExtranonce2Size: '8',
verifyPayout: false,
}), {
sharesPerMinute: 12.5,
downstreamExtranonce2Size: 8,
verifyPayout: false,
});
});

test('falls back to the default for an out-of-range extranonce2 size', () => {
assert.equal(parseAdvancedMiningConfigValues({
sharesPerMinute: '6',
downstreamExtranonce2Size: '1000000000000',
verifyPayout: true,
}).downstreamExtranonce2Size, 4);
});
166 changes: 166 additions & 0 deletions src/components/mining/AdvancedMiningConfigForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import type { TranslatorConfig } from '@sv2-ui/shared';
import {
DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE,
DEFAULT_SHARES_PER_MINUTE,
MAX_DOWNSTREAM_EXTRANONCE2_SIZE,
} from '@sv2-ui/shared';

import { Switch } from '@/components/ui/switch';

export interface AdvancedMiningConfigValues {
sharesPerMinute: string;
downstreamExtranonce2Size: string;
verifyPayout: boolean;
}

interface ParsedAdvancedMiningConfigValues {
sharesPerMinute: number;
downstreamExtranonce2Size: number;
verifyPayout: boolean;
}

interface AdvancedMiningConfigFormProps {
idPrefix: string;
value: AdvancedMiningConfigValues;
onChange: (value: AdvancedMiningConfigValues) => void;
showCoinbaseVerification?: boolean;
}

function isPositiveNumber(value: string): boolean {
const parsed = Number(value);
return value.trim() !== '' && Number.isFinite(parsed) && parsed > 0;
}

function isPositiveInteger(value: string): boolean {
return isPositiveNumber(value) && Number.isInteger(Number(value));
}

function isValidDownstreamExtranonce2Size(value: string): boolean {
return isPositiveInteger(value) && Number(value) <= MAX_DOWNSTREAM_EXTRANONCE2_SIZE;
}

export function createAdvancedMiningConfigValues(
translator?: TranslatorConfig | null,
): AdvancedMiningConfigValues {
return {
sharesPerMinute: String(translator?.shares_per_minute ?? DEFAULT_SHARES_PER_MINUTE),
downstreamExtranonce2Size: String(
translator?.downstream_extranonce2_size ?? DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE,
),
verifyPayout: translator?.verify_payout ?? true,
};
}

export function isAdvancedMiningConfigValid(value: AdvancedMiningConfigValues): boolean {
return isPositiveNumber(value.sharesPerMinute)
&& isValidDownstreamExtranonce2Size(value.downstreamExtranonce2Size);
}

export function parseAdvancedMiningConfigValues(
value: AdvancedMiningConfigValues,
): ParsedAdvancedMiningConfigValues {
return {
sharesPerMinute: Number(value.sharesPerMinute) || DEFAULT_SHARES_PER_MINUTE,
downstreamExtranonce2Size: isValidDownstreamExtranonce2Size(value.downstreamExtranonce2Size)
? Number(value.downstreamExtranonce2Size)
: DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE,
verifyPayout: value.verifyPayout,
};
}

/** Shared compact form for translator settings shown during setup and later configuration. */
export function AdvancedMiningConfigForm({
idPrefix,
value,
onChange,
showCoinbaseVerification = false,
}: AdvancedMiningConfigFormProps) {
const sharesPerMinuteValid = isPositiveNumber(value.sharesPerMinute);
const downstreamExtranonce2SizeValid = isValidDownstreamExtranonce2Size(
value.downstreamExtranonce2Size,
);
const verifyPayoutLabelId = `${idPrefix}-verify-payout-label`;
const verifyPayoutDescriptionId = `${idPrefix}-verify-payout-description`;
const sharesPerMinuteId = `${idPrefix}-shares-per-minute`;
const sharesPerMinuteDescriptionId = `${idPrefix}-shares-per-minute-description`;
const downstreamExtranonce2SizeId = `${idPrefix}-downstream-extranonce2-size`;
const downstreamExtranonce2SizeDescriptionId = `${idPrefix}-downstream-extranonce2-size-description`;

return (
<div className="max-w-3xl space-y-3">
{showCoinbaseVerification && (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/50 bg-muted/20 px-3 py-2.5">
<div className="min-w-0 space-y-0.5">
<p id={verifyPayoutLabelId} className="text-xs font-medium">Coinbase Verification</p>
<p id={verifyPayoutDescriptionId} className="text-xs text-muted-foreground">
Verify that your payout address is included in the pool&apos;s coinbase transaction.
</p>
</div>
<Switch
id={`${idPrefix}-verify-payout-switch`}
checked={value.verifyPayout}
onCheckedChange={(checked) => onChange({ ...value, verifyPayout: checked })}
aria-labelledby={verifyPayoutLabelId}
aria-describedby={verifyPayoutDescriptionId}
className="shrink-0"
/>
</div>
)}

<div className="grid gap-3 sm:grid-cols-2">
<div className="min-w-0">
<label htmlFor={sharesPerMinuteId} className="mb-1 block text-xs font-medium">
Shares Per Minute
</label>
<input
id={sharesPerMinuteId}
type="number"
min="0.1"
step="0.1"
value={value.sharesPerMinute}
onChange={(event) => onChange({ ...value, sharesPerMinute: event.target.value })}
aria-describedby={sharesPerMinuteDescriptionId}
aria-invalid={!sharesPerMinuteValid}
className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-all focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/15"
/>
{!sharesPerMinuteValid ? (
<p id={sharesPerMinuteDescriptionId} className="mt-1 text-xs text-destructive">
Enter a value greater than 0.
</p>
) : (
<p id={sharesPerMinuteDescriptionId} className="mt-1 text-xs text-muted-foreground">
Target share rate for variable difficulty.
</p>
)}
</div>

<div className="min-w-0">
<label htmlFor={downstreamExtranonce2SizeId} className="mb-1 block text-xs font-medium">
Extranonce2 Size
</label>
<input
id={downstreamExtranonce2SizeId}
type="number"
min="1"
max={MAX_DOWNSTREAM_EXTRANONCE2_SIZE}
step="1"
value={value.downstreamExtranonce2Size}
onChange={(event) => onChange({ ...value, downstreamExtranonce2Size: event.target.value })}
aria-describedby={downstreamExtranonce2SizeDescriptionId}
aria-invalid={!downstreamExtranonce2SizeValid}
className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-all focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/15"
/>
{!downstreamExtranonce2SizeValid ? (
<p id={downstreamExtranonce2SizeDescriptionId} className="mt-1 text-xs text-destructive">
Enter a whole number from 1 to 65,535.
</p>
) : (
<p id={downstreamExtranonce2SizeDescriptionId} className="mt-1 text-xs text-muted-foreground">
Bytes assigned to downstream SV1 miners.
</p>
)}
</div>
</div>
</div>
);
}
Loading
Loading