diff --git a/README.md b/README.md
index 1ecba48..17ac3f4 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ goPool is a **from-scratch Golang solo Bitcoin mining pool**. It connects direct
- **Operator controls**: optional admin panel for live settings updates, persist-to-disk controls, log tooling, and guarded reboot action.
- **Storage and backups**: SQLite state store with atomic snapshots and optional Backblaze B2 upload workflow.
- **Auth/integrations**: optional Clerk auth flows, saved-worker pages, Discord notification toggles, and one-time worker linking codes.
-- **Performance options**: fast-path Stratum decode/encode toggles, socket buffer tuning, optional SIMD JSON/hash paths, and built-in profiling hooks.
+- **Performance options**: Stratum socket buffer tuning, optional SIMD JSON/hash paths, and built-in profiling hooks.
## Direct Go libraries and licenses
diff --git a/config_build.go b/config_build.go
index be2ed61..0e060b6 100644
--- a/config_build.go
+++ b/config_build.go
@@ -166,8 +166,6 @@ func buildTuningFileConfig(cfg Config) tuningFileConfig {
SavedWorkerHistoryFlushIntervalSec: new(int(cfg.SavedWorkerHistoryFlushInterval / time.Second)),
},
Stratum: tuningStratumConfig{
- FastDecodeEnabled: new(cfg.StratumFastDecodeEnabled),
- FastEncodeEnabled: new(cfg.StratumFastEncodeEnabled),
TCPReadBufferBytes: new(cfg.StratumTCPReadBufferBytes),
TCPWriteBufferBytes: new(cfg.StratumTCPWriteBufferBytes),
},
@@ -203,8 +201,6 @@ func (cfg Config) Effective() EffectiveConfig {
GitHubURL: cfg.GitHubURL,
ServerLocation: cfg.ServerLocation,
StratumTLSListen: cfg.StratumTLSListen,
- StratumFastDecodeEnabled: cfg.StratumFastDecodeEnabled,
- StratumFastEncodeEnabled: cfg.StratumFastEncodeEnabled,
SafeMode: cfg.SafeMode,
CKPoolEmulate: cfg.CKPoolEmulate,
StratumTCPReadBufferBytes: cfg.StratumTCPReadBufferBytes,
diff --git a/config_examples.go b/config_examples.go
index 434551a..dca645d 100644
--- a/config_examples.go
+++ b/config_examples.go
@@ -96,7 +96,7 @@ func baseConfigDocComments() []byte {
# - [stratum].stratum_password_enabled: Require miners to send a password on authorize (requires restart).
# - [stratum].stratum_password: Password string checked against mining.authorize params (requires restart).
# - [stratum].stratum_password_public: Show the stratum password on the public connect panel (requires restart).
-# - [stratum].safe_mode: Force conservative compatibility/safety behavior (disables fast-path Stratum tuning and unsafe debug/public-RPC toggles).
+# - [stratum].safe_mode: Force conservative compatibility/safety behavior (disables unsafe debug/public-RPC toggles).
# - Runtime override: --safe-mode=true/false
#
# Logging
@@ -144,7 +144,7 @@ func tuningConfigDocComments() []byte {
# - template_extra_nonce2_size: Template extranonce2 byte length used in generated jobs (requires restart).
# - job_entropy: Entropy bytes added to per-job coinbase tags (requires restart).
# - coinbase_scriptsig_max_bytes: Maximum allowed coinbase scriptSig size in bytes (requires restart).
-# - difficulty_step_granularity: Quantize difficulty to 2^(k/N) steps (N=1 power-of-two, N=2 half, N=3 third, N=4 quarter). Higher values are finer; requires restart.
+# - difficulty_step_granularity: Quantize difficulty to 2^(k/N) steps (N=1 power-of-two, N=4 quarter, N=10 tenth-step default). Higher values are finer; requires restart.
#
# Hashrate ([hashrate])
# - hashrate_ema_tau_seconds: EMA time constant for per-connection hashrate smoothing (seconds; requires restart).
@@ -156,8 +156,6 @@ func tuningConfigDocComments() []byte {
# - enabled/max_ping_ms/min_peers: Optional cleanup of high-latency peers.
#
# Stratum tuning ([stratum])
-# - fast_decode_enabled: Enable fast-path decoding/sniffing for common Stratum methods (restart to apply).
-# - fast_encode_enabled: Enable fast-path response encoding for common Stratum responses (restart to apply).
# - tcp_read_buffer_bytes / tcp_write_buffer_bytes: Socket buffer sizes in bytes (0 = OS default; restart to apply).
#
#
diff --git a/config_file_types.go b/config_file_types.go
index 7278c5c..4472382 100644
--- a/config_file_types.go
+++ b/config_file_types.go
@@ -252,10 +252,8 @@ type tuningHashrateConfig struct {
}
type tuningStratumConfig struct {
- FastDecodeEnabled *bool `toml:"fast_decode_enabled"`
- FastEncodeEnabled *bool `toml:"fast_encode_enabled"`
- TCPReadBufferBytes *int `toml:"tcp_read_buffer_bytes"`
- TCPWriteBufferBytes *int `toml:"tcp_write_buffer_bytes"`
+ TCPReadBufferBytes *int `toml:"tcp_read_buffer_bytes"`
+ TCPWriteBufferBytes *int `toml:"tcp_write_buffer_bytes"`
}
type tuningFileConfig struct {
diff --git a/config_load.go b/config_load.go
index c2fae0f..12cb82b 100644
--- a/config_load.go
+++ b/config_load.go
@@ -632,12 +632,6 @@ func applyPolicyConfig(cfg *Config, fc policyFileConfig) {
}
func applyTuningConfig(cfg *Config, fc tuningFileConfig) {
- if fc.Stratum.FastDecodeEnabled != nil {
- cfg.StratumFastDecodeEnabled = *fc.Stratum.FastDecodeEnabled
- }
- if fc.Stratum.FastEncodeEnabled != nil {
- cfg.StratumFastEncodeEnabled = *fc.Stratum.FastEncodeEnabled
- }
if fc.Stratum.TCPReadBufferBytes != nil {
cfg.StratumTCPReadBufferBytes = *fc.Stratum.TCPReadBufferBytes
}
diff --git a/config_test.go b/config_test.go
index 3d72336..a51fc04 100644
--- a/config_test.go
+++ b/config_test.go
@@ -654,6 +654,37 @@ func TestAutoConfigureAcceptRateLimits_SteadyStateAbove1000Capped(t *testing.T)
}
}
+func TestLoadTuningFile_IgnoresRemovedStratumFastPathKeys(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "tuning.toml")
+ data := []byte(`
+[stratum]
+ fast_decode_enabled = true
+ fast_encode_enabled = true
+ tcp_read_buffer_bytes = 131072
+ tcp_write_buffer_bytes = 262144
+`)
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ t.Fatalf("write tuning file: %v", err)
+ }
+
+ loaded, ok, err := loadTuningFile(path)
+ if err != nil {
+ t.Fatalf("loadTuningFile returned error for legacy fast-path keys: %v", err)
+ }
+ if !ok || loaded == nil {
+ t.Fatalf("expected tuning file to load")
+ }
+
+ cfg := defaultConfig()
+ applyTuningConfig(&cfg, *loaded)
+ if cfg.StratumTCPReadBufferBytes != 131072 {
+ t.Fatalf("StratumTCPReadBufferBytes = %d, want 131072", cfg.StratumTCPReadBufferBytes)
+ }
+ if cfg.StratumTCPWriteBufferBytes != 262144 {
+ t.Fatalf("StratumTCPWriteBufferBytes = %d, want 262144", cfg.StratumTCPWriteBufferBytes)
+ }
+}
+
func TestRewriteConfigFile_BackupAndAtomic(t *testing.T) {
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, "config.toml")
diff --git a/config_types.go b/config_types.go
index fd7c98a..383ed9f 100644
--- a/config_types.go
+++ b/config_types.go
@@ -55,12 +55,6 @@ type Config struct {
StratumPasswordEnabled bool
StratumPassword string
StratumPasswordPublic bool // show password in public connect panel when enabled
- // Stratum fast decode: enables lightweight request sniffing for common methods
- // (e.g. mining.submit/mining.subscribe/mining.ping) to reduce allocations.
- StratumFastDecodeEnabled bool
- // Stratum fast encode: enables canned/manual JSON encoding for common Stratum
- // responses to reduce allocations (encode path only).
- StratumFastEncodeEnabled bool
// Safe mode: force conservative compatibility/safety-oriented runtime behavior.
SafeMode bool
// CKPool compatibility mode: advertise a minimal CKPool-style subscribe
@@ -154,7 +148,7 @@ type Config struct {
LockSuggestedDifficulty bool // keep suggested difficulty instead of vardiff
EnforceSuggestedDifficultyLimits bool // ban/disconnect when suggest_* outside min/max
- DifficultyStepGranularity int // 1=pow2, 2=half, 3=third, 4=quarter steps
+ DifficultyStepGranularity int // quantize to 2^(k/N) steps; default N=10
HashrateEMATauSeconds float64 // EMA time constant for hashrate
HashrateCumulativeEnabled bool // blend per-connection EMA with cumulative hashrate (display)
HashrateRecentCumulativeEnabled bool // allow short-horizon cumulative (vardiff window) to influence display
@@ -208,8 +202,6 @@ type EffectiveConfig struct {
GitHubURL string `json:"github_url,omitempty"`
ServerLocation string `json:"server_location,omitempty"`
StratumTLSListen string `json:"stratum_tls_listen,omitempty"`
- StratumFastDecodeEnabled bool `json:"stratum_fast_decode_enabled"`
- StratumFastEncodeEnabled bool `json:"stratum_fast_encode_enabled"`
SafeMode bool `json:"safe_mode,omitempty"`
CKPoolEmulate bool `json:"ckpool_emulate"`
StratumTCPReadBufferBytes int `json:"stratum_tcp_read_buffer_bytes,omitempty"`
diff --git a/const.go b/const.go
index 339f0cb..c558ef1 100644
--- a/const.go
+++ b/const.go
@@ -105,7 +105,7 @@ const (
defaultVarDiffStep = 2
defaultVarDiffDampingFactor = 0.7
defaultVarDiffRetargetDelay = 30 * time.Second
- defaultDifficultyStepGranularity = 4
+ defaultDifficultyStepGranularity = 10
vardiffAdaptiveMinWindow = 30 * time.Second
vardiffAdaptiveMaxWindow = 4 * time.Minute
vardiffAdaptiveHighShareCount = 24.0
diff --git a/data/config/examples/config.toml.example b/data/config/examples/config.toml.example
index 00935c2..116e145 100644
--- a/data/config/examples/config.toml.example
+++ b/data/config/examples/config.toml.example
@@ -9,7 +9,7 @@
# - [stratum].stratum_password_enabled: Require miners to send a password on authorize (requires restart).
# - [stratum].stratum_password: Password string checked against mining.authorize params (requires restart).
# - [stratum].stratum_password_public: Show the stratum password on the public connect panel (requires restart).
-# - [stratum].safe_mode: Force conservative compatibility/safety behavior (disables fast-path Stratum tuning and unsafe debug/public-RPC toggles).
+# - [stratum].safe_mode: Force conservative compatibility/safety behavior (disables unsafe debug/public-RPC toggles).
# - Runtime override: --safe-mode=true/false
#
# Logging
diff --git a/data/config/examples/tuning.toml.example b/data/config/examples/tuning.toml.example
index 052f0e5..a74b05b 100644
--- a/data/config/examples/tuning.toml.example
+++ b/data/config/examples/tuning.toml.example
@@ -26,7 +26,7 @@
# - template_extra_nonce2_size: Template extranonce2 byte length used in generated jobs (requires restart).
# - job_entropy: Entropy bytes added to per-job coinbase tags (requires restart).
# - coinbase_scriptsig_max_bytes: Maximum allowed coinbase scriptSig size in bytes (requires restart).
-# - difficulty_step_granularity: Quantize difficulty to 2^(k/N) steps (N=1 power-of-two, N=2 half, N=3 third, N=4 quarter). Higher values are finer; requires restart.
+# - difficulty_step_granularity: Quantize difficulty to 2^(k/N) steps (N=1 power-of-two, N=4 quarter, N=10 tenth-step default). Higher values are finer; requires restart.
#
# Hashrate ([hashrate])
# - hashrate_ema_tau_seconds: EMA time constant for per-connection hashrate smoothing (seconds; requires restart).
@@ -38,8 +38,6 @@
# - enabled/max_ping_ms/min_peers: Optional cleanup of high-latency peers.
#
# Stratum tuning ([stratum])
-# - fast_decode_enabled: Enable fast-path decoding/sniffing for common Stratum methods (restart to apply).
-# - fast_encode_enabled: Enable fast-path response encoding for common Stratum responses (restart to apply).
# - tcp_read_buffer_bytes / tcp_write_buffer_bytes: Socket buffer sizes in bytes (0 = OS default; restart to apply).
#
#
@@ -61,7 +59,7 @@
[mining]
coinbase_scriptsig_max_bytes = 100
- difficulty_step_granularity = 4
+ difficulty_step_granularity = 10
disable_pool_job_entropy = false
extranonce2_size = 4
job_entropy = 4
@@ -87,7 +85,5 @@
stratum_messages_per_minute = 0
[stratum]
- fast_decode_enabled = false
- fast_encode_enabled = false
tcp_read_buffer_bytes = 0
tcp_write_buffer_bytes = 0
diff --git a/data/templates/admin.tmpl b/data/templates/admin.tmpl
index f3ac723..b29e5e8 100644
--- a/data/templates/admin.tmpl
+++ b/data/templates/admin.tmpl
@@ -205,7 +205,7 @@
-
difficulty_step_granularity
Difficulty quantization granularity for mining.set_difficulty (1=power-of-two, 2=half, 3=third, 4=quarter). Applies on live apply.
+
difficulty_step_granularity
Difficulty quantization granularity for mining.set_difficulty (1=power-of-two, 4=quarter, 10=tenth-step default). Applies on live apply.
@@ -459,7 +459,7 @@
Reload UI assets
- Reloads HTML templates and cached static files from disk without restarting the pool.
+ Re-parses embedded HTML templates and refreshes the embedded static cache without restarting the pool.
{{if .AdminReloadError}}
{{.AdminReloadError}}
diff --git a/data/templates/hashrate_graph_script.tmpl b/data/templates/hashrate_graph_script.tmpl
index d0243f0..1e901d4 100644
--- a/data/templates/hashrate_graph_script.tmpl
+++ b/data/templates/hashrate_graph_script.tmpl
@@ -38,9 +38,21 @@
return `${val.toFixed(2)} ${units[idx]}`;
}
+ function formatSmallDifficulty(value) {
+ let prec = Math.ceil(-Math.log10(value)) + 2;
+ prec = Math.min(8, Math.max(3, prec));
+ const scale = 10 ** prec;
+ const trunc = Math.floor(value * scale) / scale;
+ let s = trunc.toFixed(prec);
+ s = s.replace(/\.?0+$/, '');
+ if (s === '0') return value.toPrecision(3).replace(/\.?0+(e|$)/, '$1');
+ return s;
+ }
+
function formatDifficultyGraph(diff) {
const value = Number(diff || 0);
if (!Number.isFinite(value) || value <= 0) return '0';
+ if (value < 1) return formatSmallDifficulty(value);
if (value < 1000) return value.toFixed(0);
const units = ['K', 'M', 'G', 'T', 'P', 'E'];
let scaled = value;
@@ -435,7 +447,9 @@
function primeHashrateGraphHistory(history) {
let hRows = [];
let bRows = [];
+ let compactHistory = false;
if (history && typeof history === 'object' && !Array.isArray(history) && Array.isArray(history.hq)) {
+ compactHistory = true;
hRows = decodeCompactWorkerSeries(history, 'hq', 'h0', 'h1').rows;
bRows = decodeCompactWorkerSeries(history, 'bq', 'b0', 'b1').rows;
} else if (Array.isArray(history)) {
@@ -449,7 +463,7 @@
if ((!Array.isArray(hRows) || hRows.length === 0) && (!Array.isArray(bRows) || bRows.length === 0)) {
return false;
}
- if (!historyPrimed) {
+ if (!historyPrimed || compactHistory) {
historyHashrateRows = Array.isArray(hRows) ? hRows : [];
}
historyBestRows = Array.isArray(bRows) ? bRows : [];
diff --git a/data/templates/layout.tmpl b/data/templates/layout.tmpl
index 5eb687c..e817fd7 100644
--- a/data/templates/layout.tmpl
+++ b/data/templates/layout.tmpl
@@ -134,13 +134,42 @@ document.addEventListener('click', function(e) {
if (!copyable) return;
const text = copyable.dataset.copy || copyable.textContent;
- navigator.clipboard.writeText(text).then(function() {
+ function fallbackCopy(value) {
+ const ta = document.createElement('textarea');
+ ta.value = value;
+ ta.setAttribute('readonly', '');
+ ta.style.position = 'fixed';
+ ta.style.opacity = '0';
+ document.body.appendChild(ta);
+ ta.select();
+ try {
+ return document.execCommand('copy');
+ } finally {
+ document.body.removeChild(ta);
+ }
+ }
+
+ function flashCopied() {
const original = copyable.style.background;
copyable.style.background = 'var(--accent-soft)';
setTimeout(function() {
copyable.style.background = original;
}, 200);
- }).catch(function(err) {
+ }
+
+ const canUseClipboard = navigator.clipboard && typeof navigator.clipboard.writeText === 'function';
+ const copyPromise = canUseClipboard
+ ? navigator.clipboard.writeText(text)
+ : new Promise(function(resolve, reject) {
+ try {
+ fallbackCopy(text);
+ resolve();
+ } catch (err) {
+ reject(err);
+ }
+ });
+
+ copyPromise.then(flashCopied).catch(function(err) {
console.error('Failed to copy:', err);
});
});
diff --git a/data/templates/node.tmpl b/data/templates/node.tmpl
index c37e194..1783d9a 100644
--- a/data/templates/node.tmpl
+++ b/data/templates/node.tmpl
@@ -218,13 +218,19 @@
item.className = 'peer-entry';
const pingLabel = peer.ping_ms > 0 ? `${peer.ping_ms.toFixed(1)} ms` : '--';
const connectedLabel = formatDurationLabel(peer.connected_at);
- item.innerHTML = `
-
${peer.display}
-
- Ping ${pingLabel}
- Time ${connectedLabel}
-
- `;
+ const label = document.createElement('div');
+ label.className = 'peer-label';
+ label.textContent = peer.display || '--';
+ const meta = document.createElement('div');
+ meta.className = 'peer-meta';
+ const ping = document.createElement('span');
+ ping.textContent = `Ping ${pingLabel}`;
+ const connected = document.createElement('span');
+ connected.textContent = `Time ${connectedLabel}`;
+ meta.appendChild(ping);
+ meta.appendChild(connected);
+ item.appendChild(label);
+ item.appendChild(meta);
grid.appendChild(item);
});
peerListEl.appendChild(grid);
diff --git a/data/templates/overview.tmpl b/data/templates/overview.tmpl
index f80a388..eed2f29 100644
--- a/data/templates/overview.tmpl
+++ b/data/templates/overview.tmpl
@@ -357,6 +357,15 @@
}
}
+ function escapeHTML(value) {
+ return String(value ?? '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+ }
+
function formatHashrate(h) {
if (!h) return '---';
const units = ['H/s', 'KH/s', 'MH/s', 'GH/s', 'TH/s', 'PH/s', 'EH/s'];
@@ -394,26 +403,32 @@
return (rate / 1000000000).toFixed(1) + 'G';
}
+ function formatSmallDifficulty(value) {
+ let prec = Math.ceil(-Math.log10(value)) + 2;
+ prec = Math.min(8, Math.max(3, prec));
+ const scale = 10 ** prec;
+ const trunc = Math.floor(value * scale) / scale;
+ let s = trunc.toFixed(prec);
+ s = s.replace(/\.?0+$/, '');
+ if (s === '0') return value.toPrecision(3).replace(/\.?0+(e|$)/, '$1');
+ return s;
+ }
+
function formatDiff(d) {
- if (!d) return '—';
- if (d < 1) {
- let prec = Math.ceil(-Math.log10(d)) + 2;
- prec = Math.min(8, Math.max(3, prec));
- const scale = 10 ** prec;
- const trunc = Math.floor(d * scale) / scale;
- let s = trunc.toFixed(prec);
- s = s.replace(/\.?0+$/, '');
- if (s === '0') return d.toExponential(3);
- return s;
+ const value = Number(d || 0);
+ if (!Number.isFinite(value) || value <= 0) return '—';
+ if (value < 1) return formatSmallDifficulty(value);
+ if (value < 1000000) return value.toFixed(0);
+ if (value >= 1000000000000000) {
+ return (value / 1000000000000000).toFixed(1) + 'P';
}
- if (d < 1000000) return d.toFixed(0);
- if (d >= 1000000000000) {
- return (d / 1000000000000).toFixed(1) + 'P';
+ if (value >= 1000000000000) {
+ return (value / 1000000000000).toFixed(1) + 'T';
}
- if (d >= 1000000000) {
- return (d / 1000000000).toFixed(1) + 'G';
+ if (value >= 1000000000) {
+ return (value / 1000000000).toFixed(1) + 'G';
}
- return (d / 1000000).toFixed(1) + 'M';
+ return (value / 1000000).toFixed(1) + 'M';
}
function formatBestShareDiff(d) {
@@ -508,12 +523,13 @@
pruneWorkerHashrateFallback(nowMillis);
pruneWorkerShareRateFallback(nowMillis);
const rows = workers.map(worker => {
- const name = worker.display_name || worker.name || '—';
- const suffix = workerSuffix(name);
+ const rawName = worker.display_name || worker.name || '—';
+ const name = escapeHTML(rawName);
+ const suffix = escapeHTML(workerSuffix(rawName));
const shareRate = effectiveWorkerShareRate(worker, nowMillis).toFixed(1);
const hashrate = effectiveWorkerHashrate(worker, nowMillis);
const hashrateClass = hashrate > 0 ? hashrateClassForAccuracy(worker.hashrate_accuracy) : '';
- const hashrateText = hashrate > 0 ? formatWorkerHashrate(hashrate, worker.hashrate_accuracy) : '—';
+ const hashrateText = escapeHTML(hashrate > 0 ? formatWorkerHashrate(hashrate, worker.hashrate_accuracy) : '—');
return `
${name} ${suffix}
@@ -534,14 +550,16 @@
return;
}
const rows = bestShares.map(share => {
- const worker = share.display_worker || share.worker || '—';
- const suffix = workerSuffix(worker);
+ const rawWorker = share.display_worker || share.worker || '—';
+ const worker = escapeHTML(rawWorker);
+ const suffix = escapeHTML(workerSuffix(rawWorker));
+ const hash = escapeHTML(share.display_hash || share.hash || '—');
return `
${worker} ${suffix}
${formatBestShareDiff(share.difficulty)}
${formatTimeAgo(share.timestamp)} ${formatTimeAgoShort(share.timestamp)}
- ${share.display_hash || share.hash || '—'}
+ ${hash}
`;
});
@@ -569,12 +587,14 @@
resultLabel = 'Stale';
resultStyle = 'color:#fca5a5;';
}
- const worker = block.display_worker || block.worker || '—';
- const suffix = workerSuffix(worker);
+ const rawWorker = block.display_worker || block.worker || '—';
+ const worker = escapeHTML(rawWorker);
+ const suffix = escapeHTML(workerSuffix(rawWorker));
+ const hash = escapeHTML(block.display_hash || block.hash || '—');
return `
${block.height !== undefined ? block.height : '—'}
- ${block.display_hash || block.hash || '—'}
+ ${hash}
${worker} ${suffix}
${resultLabel}
${confirmations}
@@ -602,14 +622,14 @@
list.style.display = '';
const entries = minerTypes.map(mt => {
const versionText = mt.versions && mt.versions.length ?
- mt.versions.map((v, idx) => `${idx ? ', ' : ''}${v.version || '(unknown)'} (${v.workers})`).join('') :
+ mt.versions.map((v, idx) => `${idx ? ', ' : ''}${escapeHTML(v.version || '(unknown)')} (${Number(v.workers || 0)})`).join('') :
'(no version info)';
- const total = mt.total_workers || 0;
+ const total = Number(mt.total_workers || 0);
return `
- ${mt.name}
+ ${escapeHTML(mt.name || '(unknown)')}
${total} worker${total === 1 ? '' : 's'}
@@ -636,8 +656,9 @@
warning.style.display = 'none';
}
const rows = bannedWorkers.map(worker => {
- const name = worker.display_name || worker.name || '—';
- const banStatus = `
Banned Until ${formatTimeUntil(worker.banned_until)}${worker.ban_reason ? `
Reason ${worker.ban_reason}` : ''}`;
+ const name = escapeHTML(worker.display_name || worker.name || '—');
+ const reason = worker.ban_reason ? `
Reason ${escapeHTML(worker.ban_reason)}` : '';
+ const banStatus = `
Banned Until ${formatTimeUntil(worker.banned_until)}${reason}`;
return `
${name}
@@ -709,7 +730,7 @@
return '/' + tag;
}
- function updateBTCPrice(newData) {
+ function updateBTCPrice(newData, fallbackUpdatedAt) {
if (!newData) return;
const priceEl = document.getElementById('status-btc-price');
const updatedEl = document.getElementById('status-btc-price-updated');
@@ -720,35 +741,33 @@
if (priceEl) {
const formatted = price > 0 ? formatFiatNoDecimals(Math.round(price), currency) : null;
priceEl.textContent = formatted ? ('BTC ' + formatted + ' ' + currency) : '--';
- }
- if (updatedEl) {
- const ts = newData.btc_price_updated_at;
- if (ts) {
- try {
- const d = new Date(ts);
- const millis = d.getTime();
- const ago = formatTimeAgoMillis(millis);
- updatedEl.textContent = ago ? `Updated ${ago}` : 'Updated --';
- } catch (_) {
- updatedEl.textContent = 'Updated --';
- }
+ }
+ if (updatedEl) {
+ const ts = newData.btc_price_updated_at || (price > 0 ? fallbackUpdatedAt : '');
+ if (ts) {
+ try {
+ const ago = formatTimeAgo(ts);
+ updatedEl.textContent = ago && ago !== '—' ? `Updated ${ago}` : 'Updated --';
+ } catch (_) {
+ updatedEl.textContent = 'Updated --';
+ }
} else {
updatedEl.textContent = 'Updated --';
}
}
}
- function updateDOM(newData) {
+ function updateDOM(newData, updatedAt) {
if (!newData) return;
const poolTagEl = document.getElementById('status-pool-tag');
if (poolTagEl) {
const tag = normalizePoolTag(newData.pool_tag);
poolTagEl.textContent = tag || '--';
- }
- updateGridCards(newData);
- updateBTCPrice(newData);
- renderWorkersTable(newData.workers);
- renderBestSharesTable(newData.best_shares);
+ }
+ updateGridCards(newData);
+ updateBTCPrice(newData, updatedAt);
+ renderWorkersTable(newData.workers);
+ renderBestSharesTable(newData.best_shares);
renderMinerTypesList(newData.miner_types);
renderBannedWorkersTable(newData.banned_workers);
updateRenderTime(newData.render_duration);
@@ -770,14 +789,14 @@
if (!response.ok) throw new Error('Network response was not ok');
const updatedAt = response.headers.get('X-JSON-Updated-At');
const dataRefreshedEl = document.getElementById('overview-data-refreshed');
- if (dataRefreshedEl && updatedAt) {
- dataRefreshedEl.textContent = formatUTCTimestamp(updatedAt);
- }
- return response.json();
- })
- .then(data => {
- updateDOM(data);
- })
+ if (dataRefreshedEl && updatedAt) {
+ dataRefreshedEl.textContent = formatUTCTimestamp(updatedAt);
+ }
+ return response.json().then(data => ({ data, updatedAt }));
+ })
+ .then(({ data, updatedAt }) => {
+ updateDOM(data, updatedAt);
+ })
.catch(error => {
console.error('Error fetching status update:', error);
});
@@ -870,21 +889,34 @@
return `${val.toFixed(2)} ${units[idx]}`;
}
+ function formatSmallDifficulty(value) {
+ let prec = Math.ceil(-Math.log10(value)) + 2;
+ prec = Math.min(8, Math.max(3, prec));
+ const scale = 10 ** prec;
+ const trunc = Math.floor(value * scale) / scale;
+ let s = trunc.toFixed(prec);
+ s = s.replace(/\.?0+$/, '');
+ if (s === '0') return value.toPrecision(3).replace(/\.?0+(e|$)/, '$1');
+ return s;
+ }
+
function formatDifficulty(diff) {
- if (!diff || diff <= 0) {
+ const value = Number(diff || 0);
+ if (!Number.isFinite(value) || value <= 0) {
return '--';
}
- if (diff < 1000000) return diff.toFixed(0);
- if (diff >= 1000000000000000) {
- return (diff / 1000000000000000).toFixed(1) + 'P';
+ if (value < 1) return formatSmallDifficulty(value);
+ if (value < 1000000) return value.toFixed(0);
+ if (value >= 1000000000000000) {
+ return (value / 1000000000000000).toFixed(1) + 'P';
}
- if (diff >= 1000000000000) {
- return (diff / 1000000000000).toFixed(1) + 'T';
+ if (value >= 1000000000000) {
+ return (value / 1000000000000).toFixed(1) + 'T';
}
- if (diff >= 1000000000) {
- return (diff / 1000000000).toFixed(1) + 'G';
+ if (value >= 1000000000) {
+ return (value / 1000000000).toFixed(1) + 'G';
}
- return (diff / 1000000).toFixed(1) + 'M';
+ return (value / 1000000).toFixed(1) + 'M';
}
function formatDuration(seconds) {
@@ -1078,7 +1110,7 @@
}
if (data && typeof data.pool_hashrate === 'number') {
updateHashrateDisplay(data.pool_hashrate);
- if (window.updateHashrateGraph && data.pool_hashrate > 0 && !primedFromHistory) {
+ if (window.updateHashrateGraph && data.pool_hashrate > 0) {
window.updateHashrateGraph(data.pool_hashrate);
}
}
@@ -1116,12 +1148,12 @@
blockHeightContextEl.innerHTML = `${next.toLocaleString()}(~${durationText}) `;
}
}
- } else if (blockHeightEl) {
- blockHeightEl.textContent = '--';
- if (blockHeightContextEl) {
- blockHeightContextEl.textContent = 'Current chain tip';
+ } else if (blockHeightEl) {
+ blockHeightEl.textContent = '--';
+ if (blockHeightContextEl) {
+ blockHeightContextEl.textContent = '--';
+ }
}
- }
let networkHashrate = null;
if (blockDifficultyEl && typeof data.block_difficulty === 'number') {
blockDifficultyEl.textContent = formatDifficulty(data.block_difficulty);
diff --git a/data/templates/saved_workers.tmpl b/data/templates/saved_workers.tmpl
index 9799859..f5938b2 100644
--- a/data/templates/saved_workers.tmpl
+++ b/data/templates/saved_workers.tmpl
@@ -700,6 +700,7 @@
Slots: {{.SavedWorkersCount}} / {{.SavedWorkersMax}}
Online: {{.SavedWorkersOnline}}
+ Best: {{if gt .SavedWorkersBestDifficulty 0.0}}{{formatDiff .SavedWorkersBestDifficulty}}{{else}}—{{end}}
@@ -1103,6 +1104,7 @@
let clerkLoadPromise = null;
let clerkRefreshing = null;
+ let lastOverviewData = null;
function loginURL() {
return (clerkLoginURL || '/sign-in?redirect=/saved-workers');
@@ -1499,22 +1501,35 @@
if (value < 1000) return value.toFixed(1);
if (value < 1000000) return (value / 1000).toFixed(1) + 'K';
if (value < 1000000000) return (value / 1000000).toFixed(1) + 'M';
- return (value / 1000000000).toFixed(1) + 'B';
+ return (value / 1000000000).toFixed(1) + 'G';
}
+ function formatSmallDifficulty(value) {
+ let prec = Math.ceil(-Math.log10(value)) + 2;
+ prec = Math.min(8, Math.max(3, prec));
+ const scale = 10 ** prec;
+ const trunc = Math.floor(value * scale) / scale;
+ let s = trunc.toFixed(prec);
+ s = s.replace(/\.?0+$/, '');
+ if (s === '0') return value.toPrecision(3).replace(/\.?0+(e|$)/, '$1');
+ return s;
+ }
+
function formatDifficulty(diff) {
const value = Number(diff || 0);
- if (!value || value <= 0) return '—';
- if (value < 1000000) return value.toFixed(0);
- if (value >= 1000000000000000) return (value / 1000000000000000).toFixed(1) + 'P';
- if (value >= 1000000000000) return (value / 1000000000000).toFixed(1) + 'T';
- if (value >= 1000000000) return (value / 1000000000).toFixed(1) + 'G';
- return (value / 1000000).toFixed(1) + 'M';
+ if (!Number.isFinite(value) || value <= 0) return '—';
+ if (value < 1) return formatSmallDifficulty(value);
+ if (value < 1000000) return value.toFixed(0);
+ if (value >= 1000000000000000) return (value / 1000000000000000).toFixed(1) + 'P';
+ if (value >= 1000000000000) return (value / 1000000000000).toFixed(1) + 'T';
+ if (value >= 1000000000) return (value / 1000000000).toFixed(1) + 'G';
+ return (value / 1000000).toFixed(1) + 'M';
}
function formatDifficultyGraph(diff) {
const value = Number(diff || 0);
if (!Number.isFinite(value) || value <= 0) return '0';
+ if (value < 1) return formatSmallDifficulty(value);
if (value < 1000) return value.toFixed(0);
const units = ['K', 'M', 'G', 'T', 'P', 'E'];
let scaled = value;
@@ -3359,12 +3374,20 @@
const online = Array.isArray(data.online_workers) ? data.online_workers : [];
// Update total hashrate time series (sum of online worker hashrate).
let totalHashrate = 0;
+ let totalShareRate = 0;
for (const w of online) {
totalHashrate += Number(w.hashrate || 0);
+ totalShareRate += Number(w.shares_per_minute || 0);
}
const safeHashrate = isFinite(totalHashrate) ? totalHashrate : 0;
+ const safeShareRate = isFinite(totalShareRate) ? totalShareRate : 0;
+ window.savedWorkersOnlineCount = online.length;
window.savedWorkersTotalHashrate = safeHashrate;
+ window.savedWorkersTotalSharesPerMinute = safeShareRate;
updateSavedWorkersTopChartLiveHashrate(safeHashrate);
+ if (lastOverviewData) {
+ updateGridCards(lastOverviewData);
+ }
renderOnlineWorkers(online);
const offlineList = document.getElementById('offlineWorkersList');
@@ -3560,9 +3583,18 @@
} catch (_) {}
}
- // Used by the saved workers table (hashrate cells render from data-hashrate attributes).
- // This script block runs separately from the main saved-workers dashboard script above,
- // so it must define its own helper to avoid ReferenceError and breaking status boxes.
+ function escapeHTML(value) {
+ return String(value ?? '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+ }
+
+ // Used by the saved workers table (hashrate cells render from data-hashrate attributes).
+ // This script block runs separately from the main saved-workers dashboard script above,
+ // so it must define its own helper to avoid ReferenceError and breaking status boxes.
function renderStoredHashrates() {
const cells = document.querySelectorAll('[data-hashrate]');
cells.forEach((el) => {
@@ -3666,8 +3698,12 @@
break;
case 'Shares per minute':
{
- const rate = newData.shares_per_minute ?? 0;
- const workers = newData.active_miners ?? 0;
+ const rate = typeof window.savedWorkersTotalSharesPerMinute === 'number'
+ ? window.savedWorkersTotalSharesPerMinute
+ : (newData.shares_per_minute ?? 0);
+ const workers = typeof window.savedWorkersOnlineCount === 'number'
+ ? window.savedWorkersOnlineCount
+ : (newData.active_miners ?? 0);
const avgPerWorker = workers > 0 ? rate / workers : 0;
if (workers > 0) {
valueEl.innerHTML = `${formatShareRate(rate)} (${formatShareRate(avgPerWorker)}/worker) `;
@@ -3698,7 +3734,7 @@
});
}
- function updateBTCPrice(newData) {
+ function updateBTCPrice(newData, fallbackUpdatedAt) {
if (!newData) return;
const priceEl = document.getElementById('status-btc-price');
const updatedEl = document.getElementById('status-btc-price-updated');
@@ -3708,12 +3744,12 @@
if (priceEl) {
const formatted = price > 0 ? formatFiatNoDecimals(Math.round(price), fiatCurrency) : null;
priceEl.textContent = formatted ? ('BTC ' + formatted + ' ' + fiatCurrency) : '--';
- }
- if (updatedEl) {
- const ts = newData.btc_price_updated_at;
- if (ts) {
- try {
- const d = new Date(ts);
+ }
+ if (updatedEl) {
+ const ts = newData.btc_price_updated_at || (price > 0 ? fallbackUpdatedAt : '');
+ if (ts) {
+ try {
+ const d = new Date(ts);
updatedEl.textContent = 'Updated ' + d.toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
} catch (_) {
updatedEl.textContent = 'Updated --';
@@ -3724,14 +3760,15 @@
}
}
- function updateOverviewDOM(newData) {
+ function updateOverviewDOM(newData, updatedAt) {
if (!newData) return;
+ lastOverviewData = newData;
if (poolTagEl) {
const tag = normalizePoolTag(newData.pool_tag);
poolTagEl.textContent = tag || '--';
}
updateGridCards(newData);
- updateBTCPrice(newData);
+ updateBTCPrice(newData, updatedAt);
}
function formatOddsText(chance) {
@@ -3762,21 +3799,34 @@
return `1 in ${rounded.toLocaleString()}`;
}
+ function formatSmallDifficulty(value) {
+ let prec = Math.ceil(-Math.log10(value)) + 2;
+ prec = Math.min(8, Math.max(3, prec));
+ const scale = 10 ** prec;
+ const trunc = Math.floor(value * scale) / scale;
+ let s = trunc.toFixed(prec);
+ s = s.replace(/\.?0+$/, '');
+ if (s === '0') return value.toPrecision(3).replace(/\.?0+(e|$)/, '$1');
+ return s;
+ }
+
function formatDifficulty(diff) {
- if (!diff || diff <= 0) {
+ const value = Number(diff || 0);
+ if (!Number.isFinite(value) || value <= 0) {
return '--';
}
- if (diff < 1000000) return diff.toFixed(0);
- if (diff >= 1000000000000000) {
- return (diff / 1000000000000000).toFixed(1) + 'P';
+ if (value < 1) return formatSmallDifficulty(value);
+ if (value < 1000000) return value.toFixed(0);
+ if (value >= 1000000000000000) {
+ return (value / 1000000000000000).toFixed(1) + 'P';
}
- if (diff >= 1000000000000) {
- return (diff / 1000000000000).toFixed(1) + 'T';
+ if (value >= 1000000000000) {
+ return (value / 1000000000000).toFixed(1) + 'T';
}
- if (diff >= 1000000000) {
- return (diff / 1000000000).toFixed(1) + 'G';
+ if (value >= 1000000000) {
+ return (value / 1000000000).toFixed(1) + 'G';
}
- return (diff / 1000000).toFixed(1) + 'M';
+ return (value / 1000000).toFixed(1) + 'M';
}
function formatDuration(seconds) {
@@ -3949,16 +3999,17 @@
}
}
- function fetchOverview() {
- fetch('/api/overview', { credentials: 'same-origin' })
- .then(response => {
- if (!response.ok) throw new Error('Network response was not ok');
- return response.json();
- })
- .then(data => {
- writeJSONCache(OVERVIEW_CACHE_KEY, data);
- updateOverviewDOM(data);
- })
+ function fetchOverview() {
+ fetch('/api/overview', { credentials: 'same-origin' })
+ .then(response => {
+ if (!response.ok) throw new Error('Network response was not ok');
+ const updatedAt = response.headers.get('X-JSON-Updated-At');
+ return response.json().then(data => ({ data, updatedAt }));
+ })
+ .then(({ data, updatedAt }) => {
+ writeJSONCache(OVERVIEW_CACHE_KEY, { data, updatedAt });
+ updateOverviewDOM(data, updatedAt);
+ })
.catch(err => {
console.error('Error fetching overview stats:', err);
});
@@ -4075,11 +4126,15 @@
});
}
- fiatCurrency = normalizeFiatCurrency(FIAT_CURRENCY);
- const cachedOverview = readRecentJSONCache(OVERVIEW_CACHE_KEY, REFRESH_INTERVAL);
- if (cachedOverview) {
- updateOverviewDOM(cachedOverview);
- }
+ fiatCurrency = normalizeFiatCurrency(FIAT_CURRENCY);
+ const cachedOverview = readRecentJSONCache(OVERVIEW_CACHE_KEY, REFRESH_INTERVAL);
+ if (cachedOverview) {
+ if (cachedOverview.data) {
+ updateOverviewDOM(cachedOverview.data, cachedOverview.updatedAt);
+ } else {
+ updateOverviewDOM(cachedOverview);
+ }
+ }
const cachedHashrate = readRecentJSONCache(HASHRATE_CACHE_KEY, REFRESH_INTERVAL);
if (cachedHashrate && cachedHashrate.data) {
const data = cachedHashrate.data;
@@ -4129,19 +4184,23 @@
const str = String(worker);
const dotIdx = str.indexOf('.');
const suffix = dotIdx === -1 ? str : str.slice(dotIdx);
+ const workerText = escapeHTML(worker);
+ const suffixText = escapeHTML(suffix);
+ const hashText = escapeHTML(block.display_hash || block.hash || '—');
const ts = block.timestamp ? formatTimeAgoMillis(new Date(block.timestamp).getTime()) || '—' : '—';
const diff = block.share_diff;
let diffLabel = '—';
if (diff && diff > 0) {
if (diff < 1000000) diffLabel = diff.toFixed(0);
- else if (diff >= 1e12) diffLabel = (diff / 1e12).toFixed(1) + 'P';
+ else if (diff >= 1e15) diffLabel = (diff / 1e15).toFixed(1) + 'P';
+ else if (diff >= 1e12) diffLabel = (diff / 1e12).toFixed(1) + 'T';
else if (diff >= 1e9) diffLabel = (diff / 1e9).toFixed(1) + 'G';
else diffLabel = (diff / 1e6).toFixed(1) + 'M';
}
return `
${block.height !== undefined ? block.height : '—'}
- ${block.display_hash || block.hash || '—'}
- ${worker} ${suffix}
+ ${hashText}
+ ${workerText} ${suffixText}
${resultLabel}
${confirmations}
${block.pool_fee_sats || 0}
diff --git a/data/templates/status_boxes.tmpl b/data/templates/status_boxes.tmpl
index 658bfea..f3f993e 100644
--- a/data/templates/status_boxes.tmpl
+++ b/data/templates/status_boxes.tmpl
@@ -1,7 +1,7 @@
{{define "status_boxes"}}
-
Pool
-
+
Pool
+
Open connections
--
@@ -14,71 +14,69 @@
Pool hashrate
--
-
-
Annual block odds
-
--
-
-
-
Time left for block
-
--
-
--
-
-
-
TRANSACTIONS REWARDS
-
--
-
- {{/*
-
Next diff change
-
--
-
*/}}
-
-
-
Pool fee
- {{if gt .PoolFeePercent 0.0}}
-
- {{printf "%.2f" .PoolFeePercent}}%
-
- {{if gt .OperatorDonationPercent 0.0}}
-
Split coinbase, pays directly to miner
-
{{printf "%.1f" .OperatorDonationPercent}}% of pool fee donated{{if .OperatorDonationName}} to {{if .OperatorDonationURL}}{{.OperatorDonationName}} {{else}}{{.OperatorDonationName}}{{end}}{{end}}
+
+
Annual block odds
+
--
+
+
+
Time left for block
+
--
+
--
+
+
+
Transaction fees
+
--
+
Updated --
+
+
+
+
Pool fee
+ {{if gt .PoolFeePercent 0.0}}
+
+ {{printf "%.2f" .PoolFeePercent}}%
+ {{if gt .OperatorDonationPercent 0.0}}
+
Split coinbase, pays directly to miner
+
{{printf "%.1f" .OperatorDonationPercent}}% of pool fee donated{{if .OperatorDonationName}} to {{if .OperatorDonationURL}}{{.OperatorDonationName}} {{else}}{{.OperatorDonationName}}{{end}}{{end}}
+
+ {{else}}
+
Split coinbase, pays directly to miner
+
+ {{end}}
{{else}}
-
Split coinbase, pays directly to miner
-
+
None
+
No fee charged
{{end}}
- {{else}}
-
None
-
No fee charged
- {{end}}
+
-
-
NETWORK
-
-
-
Network HASHRATE
-
--
-
-
-
Network difficulty
-
--
-
- {{/*
-
Next diff change
-
--
-
*/}}
-
-
-
BTC price
-
--
+
NETWORK
+
+
+
Network hashrate
+
--
+
+
+
Network difficulty
+
--
+
+
+
Next diff change
+
--
+
+
+
+
BTC price
+
--
+
Updated --
+
-
{{end}}
diff --git a/data/templates/worker_status.tmpl b/data/templates/worker_status.tmpl
index 3acba15..ac11587 100644
--- a/data/templates/worker_status.tmpl
+++ b/data/templates/worker_status.tmpl
@@ -53,7 +53,7 @@
Hashrate
-
{{if .Worker.RollingHashrate}}{{formatHashrate .Worker.RollingHashrate}}{{else}}---{{end}}
+
{{formatWorkerHashrate .Worker.RollingHashrate .Worker.HashrateAccuracy}}
Wallet checked
@@ -164,9 +164,9 @@
{{end}}
Difficulty:
{{if gt .Worker.LastShareDifficulty 0.0}}
- {{printf "%.8f" .Worker.LastShareDifficulty}}
+ {{formatDiffDetail .Worker.LastShareDifficulty}}
{{else if gt .Worker.Difficulty 0.0}}
- {{printf "%.8f" .Worker.Difficulty}} (current target)
+ {{formatDiffDetail .Worker.Difficulty}} (current target)
{{else}}
unknown
{{end}}
diff --git a/data/templates/worker_wallet_search.tmpl b/data/templates/worker_wallet_search.tmpl
index a0535d0..8c973b7 100644
--- a/data/templates/worker_wallet_search.tmpl
+++ b/data/templates/worker_wallet_search.tmpl
@@ -66,7 +66,7 @@
{{.DisplayName}}
-
{{if gt .RollingHashrate 0.0}}{{formatHashrate .RollingHashrate}}{{else}}—{{end}}
+
{{formatWorkerHashrate .RollingHashrate .HashrateAccuracy}}
{{formatShareRate .ShareRate}}
{{formatDiff .Difficulty}}
{{formatTime .ConnectedAt}}
diff --git a/defaults.go b/defaults.go
index 307c17d..fce2ad4 100644
--- a/defaults.go
+++ b/defaults.go
@@ -20,8 +20,6 @@ func defaultConfig() Config {
StratumPasswordPublic: false,
SafeMode: false,
CKPoolEmulate: true,
- StratumFastDecodeEnabled: false,
- StratumFastEncodeEnabled: false,
StratumTCPReadBufferBytes: 0,
StratumTCPWriteBufferBytes: 0,
ClerkIssuerURL: defaultClerkIssuerURL,
diff --git a/difficulty_test.go b/difficulty_test.go
index 55be5ba..b1c2651 100644
--- a/difficulty_test.go
+++ b/difficulty_test.go
@@ -85,7 +85,8 @@ func TestQuantizeDifficultyGranularity(t *testing.T) {
}{
{name: "pow2_only", diff: 2.3, granularity: 1, want: 2.0},
{name: "half_steps", diff: 2.3, granularity: 2, want: 2.0},
- {name: "quarter_steps_default", diff: 2.3, granularity: 4, want: 2.378414230005442},
+ {name: "quarter_steps", diff: 2.3, granularity: 4, want: 2.378414230005442},
+ {name: "tenth_steps_default", diff: 2.3, granularity: defaultDifficultyStepGranularity, want: 2.29739670999407},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
diff --git a/documentation/TESTING.md b/documentation/TESTING.md
index d800741..03ea2c0 100644
--- a/documentation/TESTING.md
+++ b/documentation/TESTING.md
@@ -72,81 +72,7 @@ go test -race ./...
### Performance / Timing
- **`submit_timing_test.go`** - Measures latency from `handleBlockShare` entry to `submitblock` invocation
- Benchmark suites live alongside the code as `*_bench_test.go` files; run them with `go test -run '^$' -bench . -benchmem ./...`.
-- **`miner_decode_bench_test.go`** - Stratum decode microbenchmarks comparing full JSON unmarshal vs fast/manual sniffing for `ping`, `subscribe`, `authorize`, and `submit`.
-- **`stratum_fastpath_bench_test.go`** - Stratum encode microbenchmarks comparing normal vs fast-path response encoding (`true`, `pong`, subscribe response in CKPool and expanded modes).
-
-### Stratum Fast-Path Benchmarks
-
-Use these commands to compare normal vs fast decode/encode paths without running unit tests:
-
-```bash
-# Decode comparison (full JSON unmarshal vs fast/manual sniff path)
-go test -run '^$' -bench 'BenchmarkStratumDecode(FastJSON|Manual)' -benchmem .
-
-# Encode comparison (normal vs fast response encoding)
-go test -run '^$' -bench 'BenchmarkStratumEncode' -benchmem .
-
-# Run both together
-go test -run '^$' -bench 'BenchmarkStratum(Decode(FastJSON|Manual)|Encode)' -benchmem .
-```
-
-For more stable comparisons across changes/machines, run multiple samples and (optionally) compare with `benchstat`:
-
-```bash
-# Baseline / candidate example
-go test -run '^$' -bench 'BenchmarkStratum(Decode(FastJSON|Manual)|Encode)' -benchmem -count=5 . > before.txt
-go test -run '^$' -bench 'BenchmarkStratum(Decode(FastJSON|Manual)|Encode)' -benchmem -count=5 . > after.txt
-
-# Optional (if benchstat is installed)
-benchstat before.txt after.txt
-```
-
-### Stratum Fast-Path Benchmark Snapshot (example)
-
-Example local run command:
-
-```bash
-go test -run '^$' -bench 'BenchmarkStratum(Decode(FastJSON|Manual)|Encode)' -benchmem -benchtime=100ms .
-```
-
-Environment for the sample numbers below:
-
-- `goos`: `linux`
-- `goarch`: `amd64`
-- `cpu`: `AMD Ryzen 9 7950X 16-Core Processor`
-- `pkg`: `goPool`
-
-Key results (microbenchmarks):
-
-- **Decode (`mining.submit`)**
- - Full decode (`fastJSONUnmarshal`): `366.6 ns/op`, `461 B/op`, `11 allocs/op`
- - Fast/manual sniff path: `107.3 ns/op`, `0 B/op`, `0 allocs/op`
- - Roughly **3.4x faster** with the fast path in this benchmark
-- **Decode (`mining.ping`)**
- - Full decode: `129.8 ns/op`, `106 B/op`, `3 allocs/op`
- - Fast/manual sniff path: `39.22 ns/op`, `0 B/op`, `0 allocs/op`
- - Roughly **3.3x faster**
-- **Encode (`true` response)**
- - Normal encode: `157.6 ns/op`, `204 B/op`, `4 allocs/op`
- - Fast encode: `48.34 ns/op`, `0 B/op`, `0 allocs/op`
- - Roughly **3.3x faster**
-- **Encode (`pong` response)**
- - Normal encode: `168.9 ns/op`, `205 B/op`, `4 allocs/op`
- - Fast encode: `45.13 ns/op`, `0 B/op`, `0 allocs/op`
- - Roughly **3.7x faster**
-- **Encode (`mining.subscribe`, CKPool mode)**
- - Normal encode: `346.7 ns/op`, `501 B/op`, `11 allocs/op`
- - Fast encode: `62.73 ns/op`, `0 B/op`, `0 allocs/op`
- - Roughly **5.5x faster**
-- **Encode (`mining.subscribe`, expanded mode)**
- - Normal encode: `630.7 ns/op`, `1063 B/op`, `17 allocs/op`
- - Fast encode: `105.9 ns/op`, `0 B/op`, `0 allocs/op`
- - Roughly **6.0x faster**
-
-Notes:
-
-- These are **microbenchmarks** of parsing/encoding paths, not full end-to-end pool throughput benchmarks.
-- Re-run on your target hardware and compare with `benchstat` before using the numbers for capacity planning.
+- **`miner_decode_bench_test.go`** - Stratum JSON decode microbenchmarks for `ping`, `subscribe`, `authorize`, and `submit`.
### Hex Fast-Path Benchmarks
diff --git a/documentation/benchmark_results.txt b/documentation/benchmark_results.txt
index d1ca902..722f17f 100644
--- a/documentation/benchmark_results.txt
+++ b/documentation/benchmark_results.txt
@@ -26,18 +26,11 @@ BenchmarkDecodeHexToFixedBytesBytes_4_Std-32 357129858
BenchmarkFormatBigInt_064x_Sprintf-32 7797034 156.9 ns/op 152 B/op 5 allocs/op
BenchmarkFormatBigInt_32_FillBytesHexEncodeToString-32 19330873 65.23 ns/op 128 B/op 2 allocs/op
BenchmarkStratumDecodeFastJSON-32 9513711 128.2 ns/op 107 B/op 3 allocs/op
-BenchmarkStratumDecodeManual-32 30908343 38.54 ns/op 0 B/op 0 allocs/op
BenchmarkStratumDecodeFastJSON_MiningSubmit-32 3461053 349.9 ns/op 463 B/op 11 allocs/op
-BenchmarkStratumDecodeManual_MiningSubmit-32 10892079 109.3 ns/op 0 B/op 0 allocs/op
BenchmarkStratumDecodeFastJSON_MiningSubscribe-32 5917798 180.8 ns/op 172 B/op 5 allocs/op
-BenchmarkStratumDecodeManual_MiningSubscribe-32 10200378 113.4 ns/op 32 B/op 2 allocs/op
BenchmarkStratumDecodeFastJSON_MiningAuthorize-32 5915431 203.5 ns/op 188 B/op 6 allocs/op
-BenchmarkStratumDecodeManual_MiningAuthorize-32 8970410 132.1 ns/op 56 B/op 3 allocs/op
BenchmarkFastJSONMarshalSimple-32 9629696 119.1 ns/op 227 B/op 5 allocs/op
-BenchmarkFmtSprintfSimple-32 17993379 67.87 ns/op 56 B/op 2 allocs/op
-BenchmarkManualAppendSimple-32 53785938 23.82 ns/op 64 B/op 1 allocs/op
BenchmarkFastJSONMarshalSubscribe-32 5501211 217.2 ns/op 424 B/op 5 allocs/op
-BenchmarkManualAppendSubscribe-32 19006324 67.24 ns/op 296 B/op 1 allocs/op
BenchmarkProcessSubmissionTaskAcceptedShare-32 1432725 909.3 ns/op 909.3 ns/share 1099772 shares/s 4399088 workers@15spm 3079361 workers@15spm_70pct 1190 B/op 19 allocs/op
BenchmarkHandleSubmitAndProcessAcceptedShare-32 2129619 550.5 ns/op 550.5 ns/share 1816373 shares/s 7265493 workers@15spm 5085845 workers@15spm_70pct 1526 B/op 33 allocs/op
BenchmarkHandleSubmitAndProcessAcceptedShare_DupCheckEnabled-32 2246118 536.3 ns/op 536.3 ns/share 1864528 shares/s 7458113 workers@15spm 5220679 workers@15spm_70pct 1629 B/op 33 allocs/op
@@ -71,14 +64,6 @@ BenchmarkEstimateMemory500kWorkers-32 1000000000
status_bench_test.go:301: Estimated 500k workers memory: heapAlloc=2.34 GiB heapInuse=2.36 GiB objects=1504104 (alloc≈4.90 KiB/worker inuse≈4.96 KiB/worker)
status_bench_test.go:301: Estimated 500k workers memory: heapAlloc=2.34 GiB heapInuse=2.36 GiB objects=1504104 (alloc≈4.90 KiB/worker inuse≈4.96 KiB/worker)
status_bench_test.go:301: Estimated 500k workers memory: heapAlloc=2.34 GiB heapInuse=2.36 GiB objects=1504104 (alloc≈4.90 KiB/worker inuse≈4.96 KiB/worker)
-BenchmarkStratumEncodeTrueResponse_Normal-32 6902528 171.3 ns/op 192 B/op 4 allocs/op
-BenchmarkStratumEncodeTrueResponse_Fast-32 26142127 44.44 ns/op 0 B/op 0 allocs/op
-BenchmarkStratumEncodePongResponse_Normal-32 8736789 179.2 ns/op 192 B/op 4 allocs/op
-BenchmarkStratumEncodePongResponse_Fast-32 25689387 45.18 ns/op 0 B/op 0 allocs/op
-BenchmarkStratumEncodeSubscribeResponse_CKPool_Normal-32 3431833 369.9 ns/op 472 B/op 11 allocs/op
-BenchmarkStratumEncodeSubscribeResponse_CKPool_Fast-32 18773166 62.67 ns/op 0 B/op 0 allocs/op
-BenchmarkStratumEncodeSubscribeResponse_Expanded_Normal-32 1781980 599.6 ns/op 992 B/op 17 allocs/op
-BenchmarkStratumEncodeSubscribeResponse_Expanded_Fast-32 11248816 104.9 ns/op 0 B/op 0 allocs/op
PASS
ok goPool 111.738s
diff --git a/documentation/operations.md b/documentation/operations.md
index 6738893..9ecd5fb 100644
--- a/documentation/operations.md
+++ b/documentation/operations.md
@@ -68,10 +68,8 @@ Both values appear on the status page and JSON endpoints so you can verify the e
| `-debug-log
` | Override debug log file path. |
| `-net-debug-log ` | Override net-debug log file path. |
| `-max-conns ` | Override max concurrent miner connections (`-1` keeps configured value). |
-| `-safe-mode ` | Force conservative compatibility/safety settings (can disable fast-path tuning and automatic bans). |
+| `-safe-mode ` | Force conservative compatibility/safety settings (can disable automatic bans). |
| `-ckpool-emulate ` | Override CKPool-style Stratum subscribe response shape. |
-| `-stratum-fast-decode ` | Override fast-path Stratum decode/sniffing behavior. |
-| `-stratum-fast-encode ` | Override fast-path Stratum response encoding behavior. |
| `-stratum-tcp-read-buffer ` | Override Stratum TCP read buffer bytes (`0` uses OS default). |
| `-stratum-tcp-write-buffer ` | Override Stratum TCP write buffer bytes (`0` uses OS default). |
| `-secrets ` | Point to an alternate `secrets.toml`; the file is not rewritten. |
@@ -99,8 +97,8 @@ The required `data/config/config.toml` is the primary interface for pool behavio
- `[branding]`: Styling and branding options shown in the status UI (tagline, pool donation link, location string).
- `[stratum]`: `stratum_tls_listen` for TLS-enabled Stratum (leave blank to disable secure Stratum), plus `stratum_password_enabled`/`stratum_password` to require a shared password on `mining.authorize`, and `stratum_password_public` to show the password on the public connect panel.
- `policy.toml [stratum]`: `ckpool_emulate` controls CKPool-style subscribe response compatibility.
-- `tuning.toml [stratum]`: `fast_decode_enabled`, `fast_encode_enabled`, `tcp_read_buffer_bytes`, and `tcp_write_buffer_bytes` control Stratum fast-path and socket buffer tuning.
-- Optional runtime overrides (temporary): `-ckpool-emulate`, `-stratum-fast-decode`, `-stratum-fast-encode`, `-stratum-tcp-read-buffer`, and `-stratum-tcp-write-buffer`.
+- `tuning.toml [stratum]`: `tcp_read_buffer_bytes` and `tcp_write_buffer_bytes` control Stratum socket buffer tuning.
+- Optional runtime overrides (temporary): `-ckpool-emulate`, `-stratum-tcp-read-buffer`, and `-stratum-tcp-write-buffer`.
- `[node]`: `rpc_url`, `rpc_cookie_path`, and ZMQ addresses (`zmq_hashblock_addr`/`zmq_rawblock_addr`).
- `[mining]`: Pool fee, donation settings, and `pooltag_prefix`.
- `[logging]`: `debug` enables verbose runtime logging, and `net_debug` enables raw network tracing (`net-debug.log`) when debug logging is active.
@@ -117,7 +115,7 @@ Optional split override files can layer advanced settings without touching the m
- `[timeouts]`: `connection_timeout_seconds`.
- `[mining]` in `policy.toml`: share-validation policy toggles (`share_*` settings) plus `submit_process_inline`.
- `[difficulty]`: `default_difficulty` fallback when no suggestion arrives, `max_difficulty`/`min_difficulty` clamps (0 disables a clamp), whether to lock miner-suggested difficulty, and whether to enforce min/max on suggested difficulty (ban/disconnect when outside limits). The first `mining.suggest_*` is honored once per connection, triggers a clean notify, and subsequent suggests are ignored.
-- `[mining]`: `extranonce2_size`, `template_extra_nonce2_size`, `job_entropy`, `coinbase_scriptsig_max_bytes`, `disable_pool_job_entropy` to remove the `-` suffix, and `difficulty_step_granularity` to control difficulty quantization precision (`1` power-of-two, `2` half-step, `3` third-step, `4` quarter-step default).
+- `[mining]`: `extranonce2_size`, `template_extra_nonce2_size`, `job_entropy`, `coinbase_scriptsig_max_bytes`, `disable_pool_job_entropy` to remove the `-` suffix, and `difficulty_step_granularity` to control difficulty quantization precision (`1` power-of-two, `4` quarter-step, `10` tenth-step default).
- `[hashrate]`: `hashrate_ema_tau_seconds`, `share_ntime_max_forward_seconds`.
- `[peer_cleaning]`: Enable/disable peer cleanup and tune thresholds.
- `[bans]`: Ban thresholds/durations, `banned_miner_types` (disconnect miners by client ID on subscribe), and `clean_expired_on_startup` (defaults to `true`). Prefer `data/config/miner_blacklist.json` for client ID blacklist management; it overrides `banned_miner_types` when present. Set `clean_expired_on_startup = false` if you want to keep expired bans for inspection.
@@ -292,7 +290,7 @@ Each override value logs when set, so goPool operators can audit what changed vi
## Runtime operations
-- **SIGUSR1** reloads the HTML templates under `data/templates/`. Errors (parse failures, missing files) are logged but the previous template set remains active so the site keeps serving—check `pool.log` if pages look odd after a reload.
+- **SIGUSR1** re-parses the embedded HTML templates and refreshes the embedded static cache. Errors are logged but the previous template set remains active so the site keeps serving—check `pool.log` if pages look odd after a reload.
- **SIGUSR2** reloads `config.toml`, `secrets.toml`, `services.toml`, `policy.toml`, `tuning.toml`, and `version_bits.toml`, reapplies overrides, and updates the status server with the new config.
- **Shutdown** occurs on `SIGINT`/`SIGTERM`. goPool stops the status servers, Stratum listener, and pending replayers gracefully.
- **TLS cert reloading** uses `certReloader` to monitor `data/tls_cert.pem`/`tls_key.pem` hourly. Certificate renewals (e.g., via certbot) are picked up without restarts.
diff --git a/documentation/stratum-v1.md b/documentation/stratum-v1.md
index 108f048..304c442 100644
--- a/documentation/stratum-v1.md
+++ b/documentation/stratum-v1.md
@@ -9,7 +9,7 @@ Code pointers:
- Encode helpers + subscribe response shape: `miner_io.go`
- Difficulty / version mask / extranonce notifications: `miner_rejects.go`
- Submit parsing / policy: `miner_submit_parse.go`
-- Fast-path method sniffing (decode): `stratum_sniff.go`
+- Method sniffing for rate limits and parse-error IDs: `stratum_sniff.go`
## Supported (client → pool)
diff --git a/embedded_assets.go b/embedded_assets.go
new file mode 100644
index 0000000..470c1cc
--- /dev/null
+++ b/embedded_assets.go
@@ -0,0 +1,57 @@
+package main
+
+import (
+ "embed"
+ "fmt"
+ "io/fs"
+)
+
+//go:embed data/templates data/www
+var embeddedAssets embed.FS
+
+const (
+ embeddedTemplatesRoot = "data/templates"
+ embeddedWWWRoot = "data/www"
+)
+
+type uiAssetLoader struct {
+ templates fs.FS
+ static fs.FS
+}
+
+func newUIAssetLoader() (*uiAssetLoader, error) {
+ return newEmbeddedUIAssetLoader()
+}
+
+func newEmbeddedUIAssetLoader() (*uiAssetLoader, error) {
+ templates, err := embeddedAssetFS(embeddedTemplatesRoot)
+ if err != nil {
+ return nil, fmt.Errorf("open embedded templates: %w", err)
+ }
+ static, err := embeddedAssetFS(embeddedWWWRoot)
+ if err != nil {
+ return nil, fmt.Errorf("open embedded static assets: %w", err)
+ }
+ return &uiAssetLoader{
+ templates: templates,
+ static: static,
+ }, nil
+}
+
+func embeddedAssetFS(root string) (fs.FS, error) {
+ return fs.Sub(embeddedAssets, root)
+}
+
+func (l *uiAssetLoader) readTemplate(name string) ([]byte, error) {
+ if l == nil || l.templates == nil {
+ return nil, fmt.Errorf("template asset filesystem not configured")
+ }
+ return fs.ReadFile(l.templates, name)
+}
+
+func (l *uiAssetLoader) staticFiles() (fs.FS, error) {
+ if l == nil || l.static == nil {
+ return nil, fmt.Errorf("static asset filesystem not configured")
+ }
+ return l.static, nil
+}
diff --git a/http_fallback.go b/http_fallback.go
index 198bb94..59b8e47 100644
--- a/http_fallback.go
+++ b/http_fallback.go
@@ -3,12 +3,10 @@ package main
import (
"bytes"
"fmt"
- "io"
"io/fs"
"mime"
"net/http"
- "os"
- "path/filepath"
+ "path"
"strings"
"sync"
"time"
@@ -19,18 +17,15 @@ const (
staticCacheMaxFileBytes = 2 << 20 // 2MB per file
)
-// fileServerWithFallback tries to serve static files from www directory first,
+// fileServerWithFallback tries to serve embedded static files first,
// and falls back to the status server if the file doesn't exist.
type fileServerWithFallback struct {
- fileServer http.Handler
- fallback http.Handler
- wwwRoot *os.Root
- wwwDir string
-
- cacheMu sync.RWMutex
- cache map[string]cachedStaticFile
- cacheBytes int64
- cacheFrozen bool
+ staticFS fs.FS
+ fallback http.Handler
+
+ cacheMu sync.RWMutex
+ cache map[string]cachedStaticFile
+ cacheBytes int64
}
type cachedStaticFile struct {
@@ -40,6 +35,25 @@ type cachedStaticFile struct {
contentType string
}
+func newEmbeddedStaticFileServer(fallback http.Handler) (*fileServerWithFallback, error) {
+ assets, err := newUIAssetLoader()
+ if err != nil {
+ return nil, err
+ }
+ staticFS, err := assets.staticFiles()
+ if err != nil {
+ return nil, err
+ }
+ return newStaticFileServer(staticFS, fallback), nil
+}
+
+func newStaticFileServer(staticFS fs.FS, fallback http.Handler) *fileServerWithFallback {
+ return &fileServerWithFallback{
+ staticFS: staticFS,
+ fallback: fallback,
+ }
+}
+
func (h *fileServerWithFallback) ServeCached(w http.ResponseWriter, r *http.Request, cleanPath string) bool {
if h == nil || cleanPath == "" {
return false
@@ -53,82 +67,67 @@ func (h *fileServerWithFallback) ServeCached(w http.ResponseWriter, r *http.Requ
if entry.contentType != "" {
w.Header().Set("Content-Type", entry.contentType)
}
- http.ServeContent(w, r, filepath.Base(cleanPath), entry.modTime, bytes.NewReader(entry.payload))
+ http.ServeContent(w, r, path.Base(cleanPath), entry.modTime, bytes.NewReader(entry.payload))
return true
}
func (h *fileServerWithFallback) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- // Check if file exists in www directory using os.Root for secure path resolution.
- // os.Root provides OS-level guarantees against path traversal by ensuring all
- // file operations stay within the root directory, similar to chroot.
- urlPath := strings.TrimPrefix(r.URL.Path, "/")
- cleanPath := filepath.Clean(urlPath)
-
if r.Method != http.MethodGet && r.Method != http.MethodHead {
- h.fileServer.ServeHTTP(w, r)
+ h.serveFallback(w, r)
return
}
- if h.ServeCached(w, r, cleanPath) {
+ if h.ServePath(w, r, r.URL.Path) {
return
}
+ h.serveFallback(w, r)
+}
- // Use os.Root to safely check if file exists within wwwDir.
- // This automatically prevents any path traversal attempts.
- info, err := h.wwwRoot.Stat(cleanPath)
- if err == nil && !info.IsDir() {
- if h.cacheFrozen {
- h.fileServer.ServeHTTP(w, r)
- return
- }
-
- if info.Size() <= 0 || info.Size() > staticCacheMaxFileBytes || info.Size() > staticCacheMaxBytes {
- h.fileServer.ServeHTTP(w, r)
- return
- }
-
- h.cacheMu.Lock()
- if h.cacheBytes+info.Size() > staticCacheMaxBytes {
- h.cache = make(map[string]cachedStaticFile)
- h.cacheBytes = 0
- }
- h.cacheMu.Unlock()
+func (h *fileServerWithFallback) ServePath(w http.ResponseWriter, r *http.Request, requestPath string) bool {
+ if h == nil || h.staticFS == nil {
+ return false
+ }
+ cleanPath, ok := cleanStaticAssetPath(requestPath)
+ if !ok {
+ return false
+ }
+ if h.ServeCached(w, r, cleanPath) {
+ return true
+ }
- file, err := h.wwwRoot.Open(cleanPath)
- if err != nil {
- h.fileServer.ServeHTTP(w, r)
- return
- }
- defer file.Close()
+ info, err := fs.Stat(h.staticFS, cleanPath)
+ if err != nil || info.IsDir() {
+ return false
+ }
- payload, err := io.ReadAll(file)
- if err != nil {
- h.fileServer.ServeHTTP(w, r)
- return
- }
- if int64(len(payload)) != info.Size() {
- h.fileServer.ServeHTTP(w, r)
- return
- }
+ payload, err := fs.ReadFile(h.staticFS, cleanPath)
+ if err != nil {
+ return false
+ }
+ if int64(len(payload)) != info.Size() {
+ return false
+ }
- contentType := detectContentType(cleanPath, payload)
+ contentType := detectContentType(cleanPath, payload)
+ if canCacheStaticFile(info) {
+ h.reserveStaticCacheSpace(info.Size())
h.storeCached(cleanPath, info, payload, contentType)
- w.Header().Set("Content-Type", contentType)
- http.ServeContent(w, r, filepath.Base(cleanPath), info.ModTime(), bytes.NewReader(payload))
- return
}
- h.fallback.ServeHTTP(w, r)
+ w.Header().Set("Content-Type", contentType)
+ http.ServeContent(w, r, path.Base(cleanPath), info.ModTime(), bytes.NewReader(payload))
+ return true
}
-func (h *fileServerWithFallback) storeCached(cleanPath string, info os.FileInfo, payload []byte, contentType string) {
+func (h *fileServerWithFallback) storeCached(cleanPath string, info fs.FileInfo, payload []byte, contentType string) {
h.cacheMu.Lock()
defer h.cacheMu.Unlock()
if h.cache == nil {
h.cache = make(map[string]cachedStaticFile)
}
- if _, ok := h.cache[cleanPath]; !ok {
- h.cacheBytes += info.Size()
+ if prev, ok := h.cache[cleanPath]; ok {
+ h.cacheBytes -= prev.size
}
+ h.cacheBytes += info.Size()
h.cache[cleanPath] = cachedStaticFile{
payload: payload,
size: info.Size(),
@@ -137,27 +136,38 @@ func (h *fileServerWithFallback) storeCached(cleanPath string, info os.FileInfo,
}
}
+func (h *fileServerWithFallback) reserveStaticCacheSpace(size int64) {
+ h.cacheMu.Lock()
+ defer h.cacheMu.Unlock()
+ if h.cache == nil {
+ h.cache = make(map[string]cachedStaticFile)
+ }
+ if h.cacheBytes+size > staticCacheMaxBytes {
+ h.cache = make(map[string]cachedStaticFile)
+ h.cacheBytes = 0
+ }
+}
+
func (h *fileServerWithFallback) PreloadCache() error {
if h == nil {
return nil
}
- if h.wwwDir == "" {
- return fmt.Errorf("www directory not configured")
+ if h.staticFS == nil {
+ return fmt.Errorf("static asset filesystem not configured")
}
h.cacheMu.Lock()
if h.cache == nil {
h.cache = make(map[string]cachedStaticFile)
}
- h.cacheFrozen = false
h.cacheMu.Unlock()
- err := filepath.WalkDir(h.wwwDir, func(path string, d fs.DirEntry, walkErr error) error {
+ err := fs.WalkDir(h.staticFS, ".", func(assetPath string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
if d.Name() == ".well-known" {
- return filepath.SkipDir
+ return fs.SkipDir
}
return nil
}
@@ -165,15 +175,14 @@ func (h *fileServerWithFallback) PreloadCache() error {
if err != nil {
return nil
}
- if info.Size() <= 0 || info.Size() > staticCacheMaxFileBytes || info.Size() > staticCacheMaxBytes {
+ if !canCacheStaticFile(info) {
return nil
}
- rel, err := filepath.Rel(h.wwwDir, path)
- if err != nil {
+ cleanPath, ok := cleanStaticAssetPath(assetPath)
+ if !ok {
return nil
}
- cleanPath := filepath.Clean(rel)
- payload, err := os.ReadFile(path)
+ payload, err := fs.ReadFile(h.staticFS, cleanPath)
if err != nil {
return nil
}
@@ -181,12 +190,7 @@ func (h *fileServerWithFallback) PreloadCache() error {
return nil
}
- h.cacheMu.Lock()
- if h.cacheBytes+info.Size() > staticCacheMaxBytes {
- h.cache = make(map[string]cachedStaticFile)
- h.cacheBytes = 0
- }
- h.cacheMu.Unlock()
+ h.reserveStaticCacheSpace(info.Size())
contentType := detectContentType(cleanPath, payload)
h.storeCached(cleanPath, info, payload, contentType)
@@ -195,9 +199,6 @@ func (h *fileServerWithFallback) PreloadCache() error {
if err != nil {
return err
}
- h.cacheMu.Lock()
- h.cacheFrozen = true
- h.cacheMu.Unlock()
return nil
}
@@ -208,13 +209,24 @@ func (h *fileServerWithFallback) ReloadCache() error {
h.cacheMu.Lock()
h.cache = make(map[string]cachedStaticFile)
h.cacheBytes = 0
- h.cacheFrozen = false
h.cacheMu.Unlock()
return h.PreloadCache()
}
+func (h *fileServerWithFallback) serveFallback(w http.ResponseWriter, r *http.Request) {
+ if h != nil && h.fallback != nil {
+ h.fallback.ServeHTTP(w, r)
+ return
+ }
+ http.NotFound(w, r)
+}
+
+func canCacheStaticFile(info fs.FileInfo) bool {
+ return info != nil && info.Size() > 0 && info.Size() <= staticCacheMaxFileBytes && info.Size() <= staticCacheMaxBytes
+}
+
func detectContentType(cleanPath string, payload []byte) string {
- ext := strings.ToLower(filepath.Ext(cleanPath))
+ ext := strings.ToLower(path.Ext(cleanPath))
contentType := mime.TypeByExtension(ext)
if contentType == "" {
contentType = http.DetectContentType(payload)
@@ -224,3 +236,17 @@ func detectContentType(cleanPath string, payload []byte) string {
}
return contentType
}
+
+func cleanStaticAssetPath(requestPath string) (string, bool) {
+ requestPath = strings.TrimPrefix(requestPath, "/")
+ for _, part := range strings.Split(requestPath, "/") {
+ if part == ".." {
+ return "", false
+ }
+ }
+ cleanPath := strings.TrimPrefix(path.Clean("/"+requestPath), "/")
+ if cleanPath == "" || cleanPath == "." || !fs.ValidPath(cleanPath) {
+ return "", false
+ }
+ return cleanPath, true
+}
diff --git a/main.go b/main.go
index 024f6aa..c2f034d 100644
--- a/main.go
+++ b/main.go
@@ -62,24 +62,6 @@ func main() {
ckpoolEmulateFlag = &b
return nil
})
- var fastDecodeFlag *bool
- flag.Func("stratum-fast-decode", "override fast-path Stratum decode/sniffing (true/false)", func(v string) error {
- b, err := strconv.ParseBool(strings.TrimSpace(v))
- if err != nil {
- return err
- }
- fastDecodeFlag = &b
- return nil
- })
- var fastEncodeFlag *bool
- flag.Func("stratum-fast-encode", "override fast-path Stratum response encoding (true/false)", func(v string) error {
- b, err := strconv.ParseBool(strings.TrimSpace(v))
- if err != nil {
- return err
- }
- fastEncodeFlag = &b
- return nil
- })
var stratumTCPReadBufFlag *int
flag.Func("stratum-tcp-read-buffer", "override Stratum TCP read buffer bytes (0 = OS default)", func(v string) error {
n, err := strconv.Atoi(strings.TrimSpace(v))
@@ -137,8 +119,6 @@ func main() {
stratumTLSListen: *stratumTLSFlag,
safeMode: safeModeFlag,
ckpoolEmulate: ckpoolEmulateFlag,
- stratumFastDecode: fastDecodeFlag,
- stratumFastEncode: fastEncodeFlag,
stratumTCPReadBuf: stratumTCPReadBufFlag,
stratumTCPWriteBuf: stratumTCPWriteBufFlag,
rpcURL: *rpcURLFlag,
@@ -483,7 +463,7 @@ func main() {
logger.Warn("discord notifier start failed", "error", err)
}
- // Start SIGUSR1/SIGUSR2 handler for live template/config reloading
+ // Start SIGUSR1/SIGUSR2 handler for embedded UI refreshes and config reloading.
go func() {
for {
select {
@@ -492,7 +472,7 @@ func main() {
case sig := <-reloadChan:
switch sig {
case syscall.SIGUSR1:
- logger.Info("SIGUSR1 received, reloading templates and static cache")
+ logger.Info("SIGUSR1 received, refreshing embedded templates and static cache")
if err := statusServer.ReloadTemplates(); err != nil {
logger.Error("template reload failed", "error", err)
}
@@ -532,12 +512,6 @@ func main() {
}
}()
- // Prepare www directory for static files (certbot .well-known, logo.png, style.css, etc.)
- wwwDir := filepath.Join(cfg.DataDir, "www")
- if err := os.MkdirAll(wwwDir, 0o755); err != nil {
- logger.Warn("create www directory", "error", err)
- }
-
disableJSONEndpoints := *disableJSONFlag
if disableJSONEndpoints {
logger.Warn("JSON status endpoints disabled", "flag", "disable-json-endpoint")
@@ -613,24 +587,16 @@ func main() {
mux.HandleFunc("/stats/", func(w http.ResponseWriter, r *http.Request) {
statusServer.handleWorkerLookupByWallet(w, r, "/stats")
})
- // Catch-all: try static files first, fall back to status server
- // Use os.OpenRoot for secure, chroot-like file serving that prevents path traversal.
- wwwRoot, err := os.OpenRoot(wwwDir)
+ // Catch-all: try embedded static files first, fall back to status server.
+ staticFiles, err := newEmbeddedStaticFileServer(statusServer)
if err != nil {
- logger.Warn("open www root", "error", err, "path", wwwDir)
- // Fall back to status server only if we can't open the www directory
+ logger.Warn("open embedded static assets", "error", err)
mux.Handle("/", statusServer)
} else {
- staticFiles := &fileServerWithFallback{
- fileServer: http.FileServer(http.Dir(wwwDir)),
- fallback: statusServer,
- wwwRoot: wwwRoot,
- wwwDir: wwwDir,
- }
if err := staticFiles.PreloadCache(); err != nil {
logger.Warn("preload static cache failed", "error", err)
} else {
- logger.Info("static cache preloaded", "path", wwwDir)
+ logger.Info("embedded static cache preloaded")
}
statusServer.SetStaticFileServer(staticFiles)
mux.Handle("/", staticFiles)
diff --git a/miner_auth.go b/miner_auth.go
index ad0187c..589eaa6 100644
--- a/miner_auth.go
+++ b/miner_auth.go
@@ -62,145 +62,6 @@ func (mc *MinerConn) handleSubscribe(req *StratumRequest) {
mc.handleSubscribeID(req.ID, clientID, haveClientID, sessionID, haveSessionID)
}
-func (mc *MinerConn) handleSubscribeRawID(idRaw []byte, clientID string, haveClientID bool, sessionID string, haveSessionID bool) {
- idVal, _, ok := parseJSONValue(idRaw, 0)
- if !ok {
- return
- }
-
- // Ignore duplicate subscribe requests - should only subscribe once
- if mc.subscribed {
- logger.Debug("subscribe rejected: already subscribed", "component", "miner", "kind", "protocol", "remote", mc.id)
- mc.writeResponse(StratumResponse{
- ID: idVal,
- Result: nil,
- Error: newStratumError(stratumErrCodeInvalidRequest, "already subscribed"),
- })
- return
- }
-
- if haveClientID {
- // Validate client ID length to prevent abuse
- if len(clientID) > maxMinerClientIDLen {
- logger.Warn("subscribe rejected: client identifier too long", "component", "miner", "kind", "protocol", "remote", mc.id, "len", len(clientID))
- mc.writeResponse(StratumResponse{
- ID: idVal,
- Result: nil,
- Error: newStratumError(stratumErrCodeInvalidRequest, "client identifier too long"),
- })
- mc.Close("client identifier too long")
- return
- }
- if clientID != "" {
- // Best-effort split into name/version for nicer aggregation.
- name, ver := parseMinerID(clientID)
- mc.stateMu.Lock()
- mc.minerType = clientID
- if name != "" {
- mc.minerClientName = name
- }
- if ver != "" {
- mc.minerClientVersion = ver
- }
- mc.stateMu.Unlock()
- if mc.minerTypeBanned(clientID, name) {
- logger.Warn("subscribe rejected: banned miner type",
- "component", "miner", "kind", "ban",
- "remote", mc.id,
- "miner_type", clientID,
- "miner_name", name,
- )
- mc.writeResponse(StratumResponse{
- ID: idVal,
- Result: nil,
- Error: newStratumError(stratumErrCodeInvalidRequest, "banned miner type"),
- })
- mc.Close("banned miner type")
- return
- }
- }
- }
-
- if haveSessionID {
- mc.stateMu.Lock()
- if mc.sessionID == "" {
- mc.sessionID = strings.TrimSpace(sessionID)
- }
- mc.stateMu.Unlock()
- }
-
- // Ensure a stable per-connection session ID is available for the subscribe
- // response. Some miners send it back as params[1] on reconnect.
- mc.assignConnectionSeq()
- if haveSessionID {
- mc.stateMu.Lock()
- if mc.sessionID == "" {
- mc.sessionID = strings.TrimSpace(sessionID)
- }
- mc.stateMu.Unlock()
- } else {
- mc.stateMu.Lock()
- if mc.sessionID == "" {
- mc.sessionID = mc.connectionIDString()
- }
- mc.stateMu.Unlock()
- }
-
- mc.subscribed = true
-
- ex1 := mc.extranonce1Hex
- en2Size := mc.cfg.Extranonce2Size
- if en2Size <= 0 {
- en2Size = 4
- }
-
- mc.writeSubscribeResponseRawID(idRaw, ex1, en2Size, mc.currentSessionID())
-
- // Support authorize-before-subscribe: if the miner already authorized,
- // start the listener and schedule initial work now that subscribe is done.
- if mc.authorized {
- if !mc.listenerOn {
- if mc.jobCh != nil {
- for {
- select {
- case <-mc.jobCh:
- default:
- goto drained
- }
- }
- }
- drained:
- mc.listenerOn = true
- if mc.jobCh != nil {
- go mc.listenJobs()
- }
- }
- if mc.jobMgr != nil {
- mc.scheduleInitialWork()
- }
- }
-
- initialJob := mc.jobMgr.CurrentJob()
- if initialJob != nil {
- mc.updateVersionMask(initialJob.VersionMask)
- }
- if mc.extranonceSubscribed {
- mc.sendSetExtranonce(ex1, en2Size)
- }
- if initialJob == nil {
- status := mc.jobMgr.FeedStatus()
- fields := []any{"remote", mc.id, "reason", "no job available"}
- if status.LastError != nil {
- fields = append(fields, "job_error", status.LastError.Error())
- }
- if !status.LastSuccess.IsZero() {
- fields = append(fields, "last_job_at", status.LastSuccess)
- }
- fields = append([]any{"component", "miner", "kind", "job_state"}, fields...)
- logger.Info("miner subscribed but no job ready", fields...)
- }
-}
-
func (mc *MinerConn) handleSubscribeID(id any, clientID string, haveClientID bool, sessionID string, haveSessionID bool) {
// Ignore duplicate subscribe requests - should only subscribe once
if mc.subscribed {
@@ -1054,6 +915,24 @@ func (mc *MinerConn) maybeSendCleanJobAfterSuggest() {
}
}
+func stratumNotifyJobID(base string, seq uint64) string {
+ base = strings.TrimSpace(base)
+ if seq > 0 {
+ seq--
+ }
+ suffix := "-" + encodeBase58Uint64(seq)
+ if base == "" {
+ return strings.TrimPrefix(suffix, "-")
+ }
+ if len(base)+len(suffix) <= maxJobIDLen {
+ return base + suffix
+ }
+ if len(suffix) >= maxJobIDLen {
+ return suffix[len(suffix)-maxJobIDLen:]
+ }
+ return base[:maxJobIDLen-len(suffix)] + suffix
+}
+
// difficultyFromTargetHex converts a target hex string to difficulty.
// difficulty = diff1Target / target
func difficultyFromTargetHex(targetHex string) (float64, bool) {
@@ -1267,8 +1146,9 @@ func (mc *MinerConn) sendNotifyFor(job *Job, forceClean bool) {
if mc.jobScriptTime == nil {
mc.jobScriptTime = make(map[string]int64, mc.maxRecentJobs)
}
+ stratumJobID := stratumNotifyJobID(job.JobID, seq)
uniqueScriptTime := job.ScriptTime + int64(seq)
- mc.jobScriptTime[job.JobID] = uniqueScriptTime
+ mc.jobScriptTime[stratumJobID] = uniqueScriptTime
mc.jobMu.Unlock()
worker := mc.currentWorker()
@@ -1354,7 +1234,7 @@ func (mc *MinerConn) sendNotifyFor(job *Job, forceClean bool) {
if mc.jobNotifyCoinbase == nil {
mc.jobNotifyCoinbase = make(map[string]notifiedCoinbaseParts, mc.maxRecentJobs)
}
- mc.jobNotifyCoinbase[job.JobID] = notifiedCoinbaseParts{coinb1: coinb1, coinb2: coinb2}
+ mc.jobNotifyCoinbase[stratumJobID] = notifiedCoinbaseParts{coinb1: coinb1, coinb2: coinb2}
mc.jobMu.Unlock()
prevhashLE := hexToLEHex(job.PrevHash)
@@ -1363,8 +1243,8 @@ func (mc *MinerConn) sendNotifyFor(job *Job, forceClean bool) {
// clean_jobs should only be true when the template actually changed (prevhash/height)
// unless we're forcing a clean notify to pair with a difficulty change.
cleanJobs := forceClean || (job.Clean && mc.cleanFlagFor(job))
- mc.trackJob(job, cleanJobs)
- mc.setJobDifficulty(job.JobID, mc.currentDifficulty())
+ mc.trackJob(job, stratumJobID, cleanJobs)
+ mc.setJobDifficulty(stratumJobID, mc.currentDifficulty())
// Stratum notify shape per docs/protocols/stratum-v1.mediawiki:
// [job_id, prevhash, coinb1, coinb2, merkle_branch[], version, nbits, ntime, clean_jobs].
@@ -1375,7 +1255,7 @@ func (mc *MinerConn) sendNotifyFor(job *Job, forceClean bool) {
ntimeBE := uint32ToBEHex(uint32(job.Template.CurTime))
params := []any{
- job.JobID,
+ stratumJobID,
prevhashLE,
coinb1,
coinb2,
@@ -1390,7 +1270,8 @@ func (mc *MinerConn) sendNotifyFor(job *Job, forceClean bool) {
merkleRoot := computeMerkleRootBE(coinb1, coinb2, job.MerkleBranches)
headerHashLE := headerHashFromNotify(prevhashLE, merkleRoot, uint32(job.Template.Version), job.Template.Bits, job.Template.CurTime)
logger.Debug("notify payload",
- "job", job.JobID,
+ "job", stratumJobID,
+ "template_job", job.JobID,
"prevhash", prevhashLE,
"coinb1", coinb1,
"coinb2", coinb2,
diff --git a/miner_auth_variants_test.go b/miner_auth_variants_test.go
index 73c8d34..f7e48e8 100644
--- a/miner_auth_variants_test.go
+++ b/miner_auth_variants_test.go
@@ -197,7 +197,7 @@ func TestSubscribeResponseAdvertisesSetExtranonce(t *testing.T) {
mc := &MinerConn{
id: "subscribe-advertise-extranonce",
conn: conn,
- cfg: Config{StratumFastEncodeEnabled: false, CKPoolEmulate: false},
+ cfg: Config{CKPoolEmulate: false},
}
mc.writeSubscribeResponse(1, "00", 4, "sid")
diff --git a/miner_conn.go b/miner_conn.go
index d0804ed..aa2e45b 100644
--- a/miner_conn.go
+++ b/miner_conn.go
@@ -357,60 +357,6 @@ func (mc *MinerConn) handle() {
return
}
- if sniffedOK && mc.cfg.StratumFastDecodeEnabled {
- switch sniffedMethod {
- case stratumMethodMiningPing:
- mc.writePongResponseRawID(sniffedIDRaw)
- continue
- case stratumMethodMiningAuthorize:
- // Fast-path: mining.authorize typically uses string params.
- // Avoid full JSON unmarshal on the connection goroutine.
- if params, ok := sniffStratumStringParams(line, 2); ok && len(params) > 0 {
- worker := params[0]
- pass := ""
- if len(params) > 1 {
- pass = params[1]
- }
- idVal, _, ok := parseJSONValue(sniffedIDRaw, 0)
- if ok {
- mc.handleAuthorizeID(idVal, worker, pass)
- }
- continue
- }
- case stratumMethodMiningSubscribe:
- // Fast-path: mining.subscribe only needs the request ID and (optionally)
- // a string client identifier in params[0] and optional session in params[1].
- params, ok := sniffStratumStringParams(line, 2)
- if ok {
- clientID := ""
- haveClientID := false
- sessionID := ""
- haveSessionID := false
- if len(params) > 0 {
- clientID = params[0]
- haveClientID = true
- }
- if len(params) > 1 {
- sessionID = strings.TrimSpace(params[1])
- haveSessionID = sessionID != ""
- }
- mc.handleSubscribeRawID(sniffedIDRaw, clientID, haveClientID, sessionID, haveSessionID)
- continue
- }
- case stratumMethodMiningSubmit:
- // Fast-path: most mining.submit payloads are small and string-only.
- // Avoid full JSON unmarshal on the connection goroutine to reduce
- // allocations and tail latency under load.
- worker, jobID, en2, ntime, nonce, version, haveVersion, ok := sniffStratumSubmitParamsBytes(line)
- if ok {
- idVal, _, ok := parseJSONValue(sniffedIDRaw, 0)
- if ok {
- mc.handleSubmitFastBytes(idVal, worker, jobID, en2, ntime, nonce, version, haveVersion)
- }
- continue
- }
- }
- }
var req StratumRequest
if err := fastJSONUnmarshal(line, &req); err != nil {
if sniffedOK && len(sniffedIDRaw) > 0 {
diff --git a/miner_decode_bench_test.go b/miner_decode_bench_test.go
index 4183e8f..146d4bd 100644
--- a/miner_decode_bench_test.go
+++ b/miner_decode_bench_test.go
@@ -18,17 +18,6 @@ func BenchmarkStratumDecodeFastJSON(b *testing.B) {
}
}
-func BenchmarkStratumDecodeManual(b *testing.B) {
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- if method, id, ok := sniffStratumMethodIDTagRawID(sampleStratumRequest); !ok || method == stratumMethodUnknown {
- b.Fatalf("manual decode failed")
- } else {
- _ = id
- }
- }
-}
-
func BenchmarkStratumDecodeFastJSON_MiningSubmit(b *testing.B) {
var req StratumRequest
b.ReportAllocs()
@@ -40,34 +29,6 @@ func BenchmarkStratumDecodeFastJSON_MiningSubmit(b *testing.B) {
}
}
-func BenchmarkStratumDecodeManual_MiningSubmit(b *testing.B) {
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- method, _, ok := sniffStratumMethodIDTagRawID(sampleSubmitRequest)
- if !ok || method != stratumMethodMiningSubmit {
- b.Fatalf("manual decode method failed")
- }
- worker, jobID, en2, ntime, nonce, ver, haveVer, ok := sniffStratumSubmitParamsBytes(sampleSubmitRequest)
- if !ok || haveVer || len(worker) == 0 || len(jobID) == 0 {
- b.Fatalf("manual decode params failed")
- }
- if _, err := parseUint32BEHexBytes(ntime); err != nil {
- b.Fatalf("parse ntime: %v", err)
- }
- if _, err := parseUint32BEHexBytes(nonce); err != nil {
- b.Fatalf("parse nonce: %v", err)
- }
- if haveVer {
- if _, err := parseUint32BEHexBytes(ver); err != nil {
- b.Fatalf("parse version: %v", err)
- }
- }
- if _, _, _, err := decodeExtranonce2HexBytes(en2, true, 4); err != nil {
- b.Fatalf("decode extranonce2: %v", err)
- }
- }
-}
-
func BenchmarkStratumDecodeFastJSON_MiningSubscribe(b *testing.B) {
var req StratumRequest
b.ReportAllocs()
@@ -79,20 +40,6 @@ func BenchmarkStratumDecodeFastJSON_MiningSubscribe(b *testing.B) {
}
}
-func BenchmarkStratumDecodeManual_MiningSubscribe(b *testing.B) {
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- method, _, ok := sniffStratumMethodIDTagRawID(sampleSubscribeRequest)
- if !ok || method != stratumMethodMiningSubscribe {
- b.Fatalf("manual decode method failed")
- }
- params, ok := sniffStratumStringParams(sampleSubscribeRequest, 1)
- if !ok || len(params) != 1 {
- b.Fatalf("manual decode params failed")
- }
- }
-}
-
func BenchmarkStratumDecodeFastJSON_MiningAuthorize(b *testing.B) {
var req StratumRequest
b.ReportAllocs()
@@ -103,17 +50,3 @@ func BenchmarkStratumDecodeFastJSON_MiningAuthorize(b *testing.B) {
}
}
}
-
-func BenchmarkStratumDecodeManual_MiningAuthorize(b *testing.B) {
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- method, _, ok := sniffStratumMethodIDTagRawID(sampleAuthorizeRequest)
- if !ok || method != stratumMethodMiningAuthorize {
- b.Fatalf("manual decode method failed")
- }
- params, ok := sniffStratumStringParams(sampleAuthorizeRequest, 2)
- if !ok || len(params) != 2 {
- b.Fatalf("manual decode params failed")
- }
- }
-}
diff --git a/miner_get_transactions_test.go b/miner_get_transactions_test.go
index 5421594..534d3c0 100644
--- a/miner_get_transactions_test.go
+++ b/miner_get_transactions_test.go
@@ -55,3 +55,28 @@ func TestHandleGetTransactions_EmptyParamsUsesLastJob(t *testing.T) {
t.Fatalf("expected txids from last job, got: %q", out)
}
}
+
+func TestHandleGetTransactions_ReturnsTxidsForStratumNotifyJobID(t *testing.T) {
+ mc, notifyConn := minerConnForNotifyTest(t)
+ job := benchmarkSubmitJobForTest(t)
+ job.Transactions = []GBTTransaction{
+ {Txid: "dd"},
+ {Txid: "ee"},
+ }
+
+ mc.sendNotifyFor(job, true)
+ ids := notifyJobIDsFromOutput(t, notifyConn.String())
+ if len(ids) != 1 {
+ t.Fatalf("expected one notify id, got %#v", ids)
+ }
+
+ respConn := &writeRecorderConn{}
+ mc.conn = respConn
+ req := &StratumRequest{ID: 1, Method: "mining.get_transactions", Params: []any{ids[0]}}
+ mc.handleGetTransactions(req)
+
+ out := respConn.String()
+ if !strings.Contains(out, "\"result\":[\"dd\",\"ee\"]") {
+ t.Fatalf("expected txids for emitted stratum job id, got: %q", out)
+ }
+}
diff --git a/miner_io.go b/miner_io.go
index eff6144..13c95ce 100644
--- a/miner_io.go
+++ b/miner_io.go
@@ -1,9 +1,7 @@
package main
import (
- "encoding/json"
"io"
- "strconv"
"strings"
"time"
)
@@ -86,18 +84,7 @@ func (mc *MinerConn) sendClientShowMessage(message string) {
}
}
-var (
- cannedPongSuffix = []byte(`,"result":"pong","error":null}`)
- cannedEmptySliceSuffix = []byte(`,"result":[],"error":null}`)
- cannedTrueSuffix = []byte(`,"result":true,"error":null}`)
- cannedSubscribeSuffix = []byte(`],"error":null}`)
-)
-
func (mc *MinerConn) writePongResponse(id any) {
- if mc.cfg.StratumFastEncodeEnabled {
- mc.sendCannedResponse("pong", id, cannedPongSuffix)
- return
- }
mc.writeResponse(StratumResponse{
ID: id,
Result: "pong",
@@ -105,23 +92,7 @@ func (mc *MinerConn) writePongResponse(id any) {
})
}
-func (mc *MinerConn) writePongResponseRawID(idRaw []byte) {
- if mc.cfg.StratumFastEncodeEnabled {
- mc.sendCannedResponseRawID("pong", idRaw, cannedPongSuffix)
- return
- }
- idVal, _, ok := parseJSONValue(idRaw, 0)
- if !ok {
- return
- }
- mc.writePongResponse(idVal)
-}
-
func (mc *MinerConn) writeEmptySliceResponse(id any) {
- if mc.cfg.StratumFastEncodeEnabled {
- mc.sendCannedResponse("empty slice", id, cannedEmptySliceSuffix)
- return
- }
mc.writeResponse(StratumResponse{
ID: id,
Result: []any{},
@@ -129,23 +100,7 @@ func (mc *MinerConn) writeEmptySliceResponse(id any) {
})
}
-func (mc *MinerConn) writeEmptySliceResponseRawID(idRaw []byte) {
- if mc.cfg.StratumFastEncodeEnabled {
- mc.sendCannedResponseRawID("empty slice", idRaw, cannedEmptySliceSuffix)
- return
- }
- idVal, _, ok := parseJSONValue(idRaw, 0)
- if !ok {
- return
- }
- mc.writeEmptySliceResponse(idVal)
-}
-
func (mc *MinerConn) writeTrueResponse(id any) {
- if mc.cfg.StratumFastEncodeEnabled {
- mc.sendCannedResponse("true", id, cannedTrueSuffix)
- return
- }
mc.writeResponse(StratumResponse{
ID: id,
Result: true,
@@ -153,26 +108,7 @@ func (mc *MinerConn) writeTrueResponse(id any) {
})
}
-func (mc *MinerConn) writeTrueResponseRawID(idRaw []byte) {
- if mc.cfg.StratumFastEncodeEnabled {
- mc.sendCannedResponseRawID("true", idRaw, cannedTrueSuffix)
- return
- }
- idVal, _, ok := parseJSONValue(idRaw, 0)
- if !ok {
- return
- }
- mc.writeTrueResponse(idVal)
-}
-
func (mc *MinerConn) writeSubscribeResponse(id any, extranonce1Hex string, extranonce2Size int, subID string) {
- if mc.cfg.StratumFastEncodeEnabled {
- if err := mc.writeCannedSubscribeResponse(id, extranonce1Hex, extranonce2Size, subID); err != nil {
- logger.Error("write canned response", "remote", mc.id, "label", "subscribe", "error", err)
- }
- return
- }
-
if strings.TrimSpace(subID) == "" {
subID = "1"
}
@@ -188,249 +124,6 @@ func (mc *MinerConn) writeSubscribeResponse(id any, extranonce1Hex string, extra
})
}
-func (mc *MinerConn) writeSubscribeResponseRawID(idRaw []byte, extranonce1Hex string, extranonce2Size int, subID string) {
- if mc.cfg.StratumFastEncodeEnabled {
- if err := mc.writeCannedSubscribeResponseRawID(idRaw, extranonce1Hex, extranonce2Size, subID); err != nil {
- logger.Error("write canned response", "remote", mc.id, "label", "subscribe", "error", err)
- }
- return
- }
- idVal, _, ok := parseJSONValue(idRaw, 0)
- if !ok {
- return
- }
- mc.writeSubscribeResponse(idVal, extranonce1Hex, extranonce2Size, subID)
-}
-
-func (mc *MinerConn) sendCannedResponse(label string, id any, suffix []byte) {
- if err := mc.writeCannedResponse(id, suffix); err != nil {
- logger.Error("write canned response", "remote", mc.id, "label", label, "error", err)
- }
-}
-
-func (mc *MinerConn) sendCannedResponseRawID(label string, idRaw []byte, suffix []byte) {
- if err := mc.writeCannedResponseRawID(idRaw, suffix); err != nil {
- logger.Error("write canned response", "remote", mc.id, "label", label, "error", err)
- }
-}
-
-func (mc *MinerConn) writeCannedResponse(id any, suffix []byte) error {
- mc.writeMu.Lock()
- defer mc.writeMu.Unlock()
-
- buf := mc.writeScratch[:0]
- buf = append(buf, `{"id":`...)
- var err error
- buf, err = appendJSONValue(buf, id)
- if err != nil {
- return err
- }
- buf = append(buf, suffix...)
- buf = append(buf, '\n')
-
- // Persist the (possibly grown) scratch for reuse.
- mc.writeScratch = buf[:0]
- return mc.writeBytesLocked(buf)
-}
-
-func (mc *MinerConn) writeCannedResponseRawID(idRaw []byte, suffix []byte) error {
- mc.writeMu.Lock()
- defer mc.writeMu.Unlock()
-
- buf := mc.writeScratch[:0]
- buf = append(buf, `{"id":`...)
- buf = append(buf, idRaw...)
- buf = append(buf, suffix...)
- buf = append(buf, '\n')
-
- mc.writeScratch = buf[:0]
- return mc.writeBytesLocked(buf)
-}
-
-func (mc *MinerConn) writeCannedSubscribeResponse(id any, extranonce1Hex string, extranonce2Size int, subID string) error {
- mc.writeMu.Lock()
- defer mc.writeMu.Unlock()
-
- buf := mc.writeScratch[:0]
- buf, err := appendSubscribeResponseBytes(buf, id, extranonce1Hex, extranonce2Size, subID, mc.cfg.CKPoolEmulate)
- if err != nil {
- return err
- }
- buf = append(buf, '\n')
-
- mc.writeScratch = buf[:0]
- return mc.writeBytesLocked(buf)
-}
-
-func (mc *MinerConn) writeCannedSubscribeResponseRawID(idRaw []byte, extranonce1Hex string, extranonce2Size int, subID string) error {
- mc.writeMu.Lock()
- defer mc.writeMu.Unlock()
-
- // subID is a logical identifier used by miners to correlate subscriptions.
- // Don't use strings.TrimSpace here; it is unnecessary overhead in a hot path.
- if subID == "" {
- subID = "1"
- }
- subIDDigits := isASCIIAll(subID, isASCIIDigit)
- ex1Hex := isASCIIAll(extranonce1Hex, isASCIIHexDigit)
- ckpoolEmulate := mc.cfg.CKPoolEmulate
-
- buf := mc.writeScratch[:0]
- buf = append(buf, `{"id":`...)
- buf = append(buf, idRaw...)
- buf = append(buf, `,"result":[[[`...)
- if ckpoolEmulate {
- buf = append(buf, `"mining.notify",`...)
- if subIDDigits {
- buf = append(buf, '"')
- buf = append(buf, subID...)
- buf = append(buf, '"')
- } else {
- buf = strconv.AppendQuote(buf, subID)
- }
- } else {
- buf = append(buf, `"mining.set_difficulty",`...)
- if subIDDigits {
- buf = append(buf, '"')
- buf = append(buf, subID...)
- buf = append(buf, '"')
- } else {
- buf = strconv.AppendQuote(buf, subID)
- }
- buf = append(buf, `],["mining.notify",`...)
- if subIDDigits {
- buf = append(buf, '"')
- buf = append(buf, subID...)
- buf = append(buf, '"')
- } else {
- buf = strconv.AppendQuote(buf, subID)
- }
- buf = append(buf, `],["mining.set_extranonce",`...)
- if subIDDigits {
- buf = append(buf, '"')
- buf = append(buf, subID...)
- buf = append(buf, '"')
- } else {
- buf = strconv.AppendQuote(buf, subID)
- }
- buf = append(buf, `],["mining.set_version_mask",`...)
- if subIDDigits {
- buf = append(buf, '"')
- buf = append(buf, subID...)
- buf = append(buf, '"')
- } else {
- buf = strconv.AppendQuote(buf, subID)
- }
- }
- buf = append(buf, `]],`...)
- if ex1Hex {
- buf = append(buf, '"')
- buf = append(buf, extranonce1Hex...)
- buf = append(buf, '"')
- } else {
- buf = strconv.AppendQuote(buf, extranonce1Hex)
- }
- buf = append(buf, ',')
- buf = strconv.AppendInt(buf, int64(extranonce2Size), 10)
- buf = append(buf, cannedSubscribeSuffix...)
- buf = append(buf, '\n')
-
- mc.writeScratch = buf[:0]
- return mc.writeBytesLocked(buf)
-}
-
-func buildSubscribeResponseBytes(id any, extranonce1Hex string, extranonce2Size int) ([]byte, error) {
- return buildSubscribeResponseBytesWithMode(id, extranonce1Hex, extranonce2Size, true)
-}
-
-func buildSubscribeResponseBytesWithMode(id any, extranonce1Hex string, extranonce2Size int, ckpoolEmulate bool) ([]byte, error) {
- // The subscribe response JSON is fairly large (multiple method strings).
- // Use a capacity that avoids buffer growth in typical cases.
- buf := make([]byte, 0, 256+len(extranonce1Hex))
- buf, err := appendSubscribeResponseBytes(buf, id, extranonce1Hex, extranonce2Size, "1", ckpoolEmulate)
- if err != nil {
- return nil, err
- }
- buf = append(buf, '\n')
- return buf, nil
-}
-
-func appendSubscribeResponseBytes(buf []byte, id any, extranonce1Hex string, extranonce2Size int, subID string, ckpoolEmulate bool) ([]byte, error) {
- // subscribe result is configurable:
- // - CKPool emulate: [[["mining.notify",""]],"",]
- // - Extended: [[["mining.set_difficulty",""],["mining.notify",""],["mining.set_extranonce",""],["mining.set_version_mask",""]],"",]
- // Avoid TrimSpace: subID is generated by us, and if it ever arrives blank we
- // just use the canonical "1".
- if subID == "" {
- subID = "1"
- }
- subIDDigits := isASCIIAll(subID, isASCIIDigit)
- ex1Hex := isASCIIAll(extranonce1Hex, isASCIIHexDigit)
-
- buf = append(buf, `{"id":`...)
- var err error
- buf, err = appendJSONValue(buf, id)
- if err != nil {
- return nil, err
- }
- buf = append(buf, `,"result":[[[`...)
- if ckpoolEmulate {
- buf = append(buf, `"mining.notify",`...)
- if subIDDigits {
- buf = append(buf, '"')
- buf = append(buf, subID...)
- buf = append(buf, '"')
- } else {
- buf = strconv.AppendQuote(buf, subID)
- }
- } else {
- buf = append(buf, `"mining.set_difficulty",`...)
- if subIDDigits {
- buf = append(buf, '"')
- buf = append(buf, subID...)
- buf = append(buf, '"')
- } else {
- buf = strconv.AppendQuote(buf, subID)
- }
- buf = append(buf, `],["mining.notify",`...)
- if subIDDigits {
- buf = append(buf, '"')
- buf = append(buf, subID...)
- buf = append(buf, '"')
- } else {
- buf = strconv.AppendQuote(buf, subID)
- }
- buf = append(buf, `],["mining.set_extranonce",`...)
- if subIDDigits {
- buf = append(buf, '"')
- buf = append(buf, subID...)
- buf = append(buf, '"')
- } else {
- buf = strconv.AppendQuote(buf, subID)
- }
- buf = append(buf, `],["mining.set_version_mask",`...)
- if subIDDigits {
- buf = append(buf, '"')
- buf = append(buf, subID...)
- buf = append(buf, '"')
- } else {
- buf = strconv.AppendQuote(buf, subID)
- }
- }
- buf = append(buf, `]],`...)
- if ex1Hex {
- buf = append(buf, '"')
- buf = append(buf, extranonce1Hex...)
- buf = append(buf, '"')
- } else {
- buf = strconv.AppendQuote(buf, extranonce1Hex)
- }
- buf = append(buf, ',')
- buf = strconv.AppendInt(buf, int64(extranonce2Size), 10)
- buf = append(buf, cannedSubscribeSuffix...)
- return buf, nil
-}
-
func subscribeMethodTuples(subID string, ckpoolEmulate bool) [][]any {
if ckpoolEmulate {
return [][]any{
@@ -444,66 +137,3 @@ func subscribeMethodTuples(subID string, ckpoolEmulate bool) [][]any {
{"mining.set_version_mask", subID},
}
}
-
-func isASCIIAll(s string, fn func(byte) bool) bool {
- for i := 0; i < len(s); i++ {
- if !fn(s[i]) {
- return false
- }
- }
- return true
-}
-
-func isASCIIDigit(b byte) bool { return b >= '0' && b <= '9' }
-
-func isASCIIHexDigit(b byte) bool {
- return (b >= '0' && b <= '9') ||
- (b >= 'a' && b <= 'f') ||
- (b >= 'A' && b <= 'F')
-}
-
-func appendJSONValue(buf []byte, value any) ([]byte, error) {
- switch v := value.(type) {
- case nil:
- return append(buf, "null"...), nil
- case string:
- return strconv.AppendQuote(buf, v), nil
- case bool:
- if v {
- return append(buf, "true"...), nil
- }
- return append(buf, "false"...), nil
- case json.Number:
- return append(buf, v...), nil
- case float64:
- return strconv.AppendFloat(buf, v, 'g', -1, 64), nil
- case float32:
- return strconv.AppendFloat(buf, float64(v), 'g', -1, 32), nil
- case int:
- return strconv.AppendInt(buf, int64(v), 10), nil
- case int8:
- return strconv.AppendInt(buf, int64(v), 10), nil
- case int16:
- return strconv.AppendInt(buf, int64(v), 10), nil
- case int32:
- return strconv.AppendInt(buf, int64(v), 10), nil
- case int64:
- return strconv.AppendInt(buf, v, 10), nil
- case uint:
- return strconv.AppendUint(buf, uint64(v), 10), nil
- case uint8:
- return strconv.AppendUint(buf, uint64(v), 10), nil
- case uint16:
- return strconv.AppendUint(buf, uint64(v), 10), nil
- case uint32:
- return strconv.AppendUint(buf, uint64(v), 10), nil
- case uint64:
- return strconv.AppendUint(buf, v, 10), nil
- default:
- b, err := fastJSONMarshal(value)
- if err != nil {
- return buf, err
- }
- return append(buf, b...), nil
- }
-}
diff --git a/miner_io_subscribe_test.go b/miner_io_subscribe_test.go
index 7e4d9a8..941307e 100644
--- a/miner_io_subscribe_test.go
+++ b/miner_io_subscribe_test.go
@@ -7,10 +7,10 @@ import (
)
func TestBuildSubscribeResponseBytes(t *testing.T) {
- b, err := buildSubscribeResponseBytes(int64(7), "0011aabb", 4)
- if err != nil {
- t.Fatalf("buildSubscribeResponseBytes: %v", err)
- }
+ conn := &recordConn{}
+ mc := &MinerConn{conn: conn, cfg: Config{CKPoolEmulate: true}}
+ mc.writeSubscribeResponse(int64(7), "0011aabb", 4, "1")
+ b := []byte(conn.String())
if !bytes.HasSuffix(b, []byte{'\n'}) {
t.Fatalf("expected newline-terminated response")
}
@@ -60,10 +60,10 @@ func TestBuildSubscribeResponseBytes(t *testing.T) {
}
func TestBuildSubscribeResponseBytes_ExpandedWhenCKPoolEmulateDisabled(t *testing.T) {
- b, err := buildSubscribeResponseBytesWithMode(int64(7), "0011aabb", 4, false)
- if err != nil {
- t.Fatalf("buildSubscribeResponseBytesWithMode: %v", err)
- }
+ conn := &recordConn{}
+ mc := &MinerConn{conn: conn, cfg: Config{CKPoolEmulate: false}}
+ mc.writeSubscribeResponse(int64(7), "0011aabb", 4, "1")
+ b := []byte(conn.String())
var resp StratumResponse
if err := json.Unmarshal(bytes.TrimSpace(b), &resp); err != nil {
diff --git a/miner_notify_jobid_test.go b/miner_notify_jobid_test.go
new file mode 100644
index 0000000..784392b
--- /dev/null
+++ b/miner_notify_jobid_test.go
@@ -0,0 +1,88 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+func notifyJobIDsFromOutput(t *testing.T, out string) []string {
+ t.Helper()
+ msgs := notifyMessagesFromOutput(t, out)
+ ids := make([]string, 0, len(msgs))
+ for _, msg := range msgs {
+ if len(msg.Params) == 0 {
+ t.Fatalf("notify without params: %#v", msg)
+ }
+ id, ok := msg.Params[0].(string)
+ if !ok || id == "" {
+ t.Fatalf("notify job id is not a non-empty string: %#v", msg.Params[0])
+ }
+ ids = append(ids, id)
+ }
+ return ids
+}
+
+func notifyMessagesFromOutput(t *testing.T, out string) []StratumMessage {
+ t.Helper()
+ var msgs []StratumMessage
+ for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ var msg StratumMessage
+ if err := fastJSONUnmarshal([]byte(line), &msg); err != nil {
+ t.Fatalf("decode stratum message: %v; line=%q", err, line)
+ }
+ if msg.Method != "mining.notify" {
+ continue
+ }
+ msgs = append(msgs, msg)
+ }
+ return msgs
+}
+
+func minerConnForNotifyTest(t *testing.T) (*MinerConn, *recordConn) {
+ t.Helper()
+ mc := benchmarkMinerConnForSubmit(NewPoolMetrics())
+ conn := &recordConn{}
+ mc.conn = conn
+ mc.lockDifficulty = true
+ mc.maxRecentJobs = 10
+ mc.activeJobs = make(map[string]*Job, mc.maxRecentJobs)
+ mc.jobOrder = make([]string, 0, mc.maxRecentJobs)
+ mc.jobDifficulty = make(map[string]float64, mc.maxRecentJobs)
+ mc.jobScriptTime = make(map[string]int64, mc.maxRecentJobs)
+ mc.jobNotifyCoinbase = make(map[string]notifiedCoinbaseParts, mc.maxRecentJobs)
+ return mc, conn
+}
+
+func TestSendNotifyForUsesUniqueStratumJobIDsForRepeatedNotify(t *testing.T) {
+ mc, conn := minerConnForNotifyTest(t)
+
+ job := benchmarkSubmitJobForTest(t)
+ job.ScriptTime = job.Template.CurTime
+
+ mc.sendNotifyFor(job, true)
+ mc.sendNotifyFor(job, true)
+
+ ids := notifyJobIDsFromOutput(t, conn.String())
+ if len(ids) != 2 {
+ t.Fatalf("expected two notify job ids, got %d: %#v", len(ids), ids)
+ }
+ if ids[0] == ids[1] {
+ t.Fatalf("expected repeated notifies to use distinct job ids, got %q", ids[0])
+ }
+ if ids[0] == job.JobID || ids[1] == job.JobID {
+ t.Fatalf("expected emitted Stratum job ids to be per-notify ids, base=%q ids=%#v", job.JobID, ids)
+ }
+
+ firstJob, _, _, _, _, firstScriptTime, firstOK := mc.jobForIDWithLast(ids[0])
+ secondJob, _, _, _, _, secondScriptTime, secondOK := mc.jobForIDWithLast(ids[1])
+ if !firstOK || !secondOK || firstJob != job || secondJob != job {
+ t.Fatalf("notify ids did not resolve to the underlying job")
+ }
+ if firstScriptTime == 0 || secondScriptTime == 0 || firstScriptTime == secondScriptTime {
+ t.Fatalf("expected immutable per-notify script times, got first=%d second=%d", firstScriptTime, secondScriptTime)
+ }
+}
diff --git a/miner_rejects.go b/miner_rejects.go
index 2087bbb..f98232e 100644
--- a/miner_rejects.go
+++ b/miner_rejects.go
@@ -614,19 +614,22 @@ func (mc *MinerConn) updateHashrateLocked(targetDiff float64, shareTime time.Tim
}
}
-func (mc *MinerConn) trackJob(job *Job, clean bool) {
+func (mc *MinerConn) trackJob(job *Job, stratumJobID string, clean bool) {
+ if stratumJobID == "" {
+ stratumJobID = job.JobID
+ }
mc.jobMu.Lock()
defer mc.jobMu.Unlock()
// No longer clear old jobs on clean - preserve them for miners with latency
// The eviction logic below will handle cleanup when we exceed maxRecentJobs
- if _, ok := mc.activeJobs[job.JobID]; !ok {
- mc.jobOrder = append(mc.jobOrder, job.JobID)
+ if _, ok := mc.activeJobs[stratumJobID]; !ok {
+ mc.jobOrder = append(mc.jobOrder, stratumJobID)
}
- // Note: Don't clear shareCache on job re-send - coinbase is stable for a
- // given job (payouts configured at boot). Clearing would allow duplicate
- // shares after difficulty changes, wasting miner work.
- mc.activeJobs[job.JobID] = job
+ // Note: don't clear shareCache on clean sends. Each notify has its own
+ // Stratum job id, so repeated clean sends can coexist for late shares.
+ mc.activeJobs[stratumJobID] = job
mc.lastJob = job
+ mc.lastJobID = stratumJobID
mc.lastJobPrevHash = job.Template.Previous
mc.lastJobHeight = job.Template.Height
mc.lastClean = clean
@@ -639,7 +642,7 @@ func (mc *MinerConn) trackJob(job *Job, clean bool) {
if slack <= 0 {
slack = defaultShareNTimeMaxForwardSeconds
}
- mc.jobNTimeBounds[job.JobID] = jobNTimeBounds{
+ mc.jobNTimeBounds[stratumJobID] = jobNTimeBounds{
min: minNTime,
max: minNTime + int64(slack),
}
@@ -718,6 +721,14 @@ func (mc *MinerConn) jobForIDWithLast(jobID string) (job *Job, lastJob *Job, las
if mc.jobScriptTime != nil {
scriptTime = mc.jobScriptTime[jobID]
}
+ if !ok && mc.lastJobID != "" {
+ if mc.cfg.ShareCheckNTimeWindow && mc.jobNTimeBounds != nil {
+ ntimeBounds = mc.jobNTimeBounds[mc.lastJobID]
+ }
+ if mc.jobScriptTime != nil {
+ scriptTime = mc.jobScriptTime[mc.lastJobID]
+ }
+ }
return job, mc.lastJob, mc.lastJobPrevHash, mc.lastJobHeight, ntimeBounds, scriptTime, ok
}
diff --git a/miner_response_bench_test.go b/miner_response_bench_test.go
index 5917ce3..4731ac5 100644
--- a/miner_response_bench_test.go
+++ b/miner_response_bench_test.go
@@ -1,12 +1,6 @@
package main
-import (
- "fmt"
- "strconv"
- "testing"
-)
-
-const cannedSuffix = ",\"result\":true,\"error\":null}\n"
+import "testing"
func BenchmarkFastJSONMarshalSimple(b *testing.B) {
resp := StratumResponse{
@@ -26,24 +20,6 @@ func BenchmarkFastJSONMarshalSimple(b *testing.B) {
}
}
-func BenchmarkFmtSprintfSimple(b *testing.B) {
- b.ReportAllocs()
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- id := i
- _ = fmt.Appendf(nil, "{\"id\":%d%s", id, cannedSuffix)
- }
-}
-
-func BenchmarkManualAppendSimple(b *testing.B) {
- b.ReportAllocs()
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- id := int64(i)
- _ = buildManualResponse(id)
- }
-}
-
func BenchmarkFastJSONMarshalSubscribe(b *testing.B) {
ex1 := "aabbccdd"
en2Size := 4
@@ -71,23 +47,3 @@ func BenchmarkFastJSONMarshalSubscribe(b *testing.B) {
_ = append(bts, '\n')
}
}
-
-func BenchmarkManualAppendSubscribe(b *testing.B) {
- ex1 := "aabbccdd"
- en2Size := 4
- b.ReportAllocs()
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- if _, err := buildSubscribeResponseBytes(int64(i), ex1, en2Size); err != nil {
- b.Fatal(err)
- }
- }
-}
-
-func buildManualResponse(id int64) []byte {
- buf := make([]byte, 0, 64)
- buf = append(buf, `{"id":`...)
- buf = strconv.AppendInt(buf, id, 10)
- buf = append(buf, cannedSuffix...)
- return buf
-}
diff --git a/miner_rpc.go b/miner_rpc.go
index 89b28dd..be9497a 100644
--- a/miner_rpc.go
+++ b/miner_rpc.go
@@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
+ "fmt"
"time"
)
@@ -57,6 +58,18 @@ func (mc *MinerConn) submitBlockWithFastRetry(job *Job, workerName, hashHex, blo
cancel()
if err == nil {
+ if resultErr := submitBlockResultError(submitRes); resultErr != nil {
+ if blockAccepted() {
+ logger.Warn("submitblock returned rejection but block is in chain; treating as success",
+ "attempts", attempt,
+ "worker", mc.minerName(workerName),
+ "hash", hashHex,
+ "result", resultErr.Error(),
+ )
+ return nil
+ }
+ return resultErr
+ }
if attempt > 1 {
logger.Info("submitblock succeeded after retries",
"attempts", attempt,
@@ -118,3 +131,18 @@ func (mc *MinerConn) submitBlockWithFastRetry(job *Job, workerName, hashHex, blo
time.Sleep(retryInterval)
}
}
+
+func submitBlockResultError(submitRes *any) error {
+ if submitRes == nil || *submitRes == nil {
+ return nil
+ }
+ switch v := (*submitRes).(type) {
+ case string:
+ if v == "" {
+ return nil
+ }
+ return fmt.Errorf("submitblock rejected: %s", v)
+ default:
+ return fmt.Errorf("submitblock returned unexpected result %T: %v", *submitRes, *submitRes)
+ }
+}
diff --git a/miner_stats.go b/miner_stats.go
index e169723..4a1a4c7 100644
--- a/miner_stats.go
+++ b/miner_stats.go
@@ -206,7 +206,6 @@ func (mc *MinerConn) dynamicWindowStartLagPercentLocked(now time.Time) int {
// display/detail). They may differ when vardiff changed between notify and
// submit; we always want hashrate to use the assigned target.
func (mc *MinerConn) recordShare(worker string, accepted bool, creditedDiff float64, shareDiff float64, reason string, shareHash string, detail *ShareDetail, now time.Time) {
- // Send update to async stats worker instead of blocking on mutex
update := statsUpdate{
worker: worker,
accepted: accepted,
@@ -218,11 +217,7 @@ func (mc *MinerConn) recordShare(worker string, accepted bool, creditedDiff floa
timestamp: now,
}
- select {
- case mc.statsUpdates <- update:
- // Successfully queued for async processing
- default:
- // Channel full, process synchronously as fallback
+ if !mc.queueStatsUpdate(update) {
mc.recordShareSync(update)
}
@@ -231,6 +226,23 @@ func (mc *MinerConn) recordShare(worker string, accepted bool, creditedDiff floa
}
}
+func (mc *MinerConn) queueStatsUpdate(update statsUpdate) (queued bool) {
+ if mc.statsUpdates == nil {
+ return false
+ }
+ defer func() {
+ if recover() != nil {
+ queued = false
+ }
+ }()
+ select {
+ case mc.statsUpdates <- update:
+ return true
+ default:
+ return false
+ }
+}
+
// recordShareSync is the fallback synchronous stats update (only when channel is full)
func (mc *MinerConn) recordShareSync(update statsUpdate) {
mc.statsMu.Lock()
diff --git a/miner_stats_snapshot_test.go b/miner_stats_snapshot_test.go
index 58afd52..c865737 100644
--- a/miner_stats_snapshot_test.go
+++ b/miner_stats_snapshot_test.go
@@ -18,6 +18,21 @@ func TestSnapshotShareInfo_WorkStartShowsLiveElapsedWhileAwaitingFirstShare(t *t
}
}
+func TestRecordShareFallsBackWhenStatsChannelClosed(t *testing.T) {
+ now := time.Unix(1_700_000_000, 0)
+ mc := &MinerConn{
+ statsUpdates: make(chan statsUpdate),
+ }
+ close(mc.statsUpdates)
+
+ mc.recordShare("worker", true, 1, 2, "", "hash", nil, now)
+
+ stats := mc.snapshotStats()
+ if stats.Accepted != 1 || stats.WindowAccepted != 1 || stats.WindowSubmissions != 1 || stats.TotalDifficulty != 1 {
+ t.Fatalf("recordShare did not fall back synchronously after closed stats channel: %+v", stats)
+ }
+}
+
func TestEnsureWindowLocked_DoesNotResetByVardiffCadence(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
mc := &MinerConn{
diff --git a/miner_submit_async_test.go b/miner_submit_async_test.go
new file mode 100644
index 0000000..e770c5f
--- /dev/null
+++ b/miner_submit_async_test.go
@@ -0,0 +1,58 @@
+package main
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestQueuedSubmitUsesCapturedAssignedDifficultyAfterStateChanges(t *testing.T) {
+ mc := benchmarkMinerConnForSubmit(NewPoolMetrics())
+ conn := &recordConn{}
+ mc.conn = conn
+ mc.lockDifficulty = true
+ mc.cfg.ShareCheckDuplicate = false
+
+ job := benchmarkSubmitJobForTest(t)
+ stratumJobID := "notify-job-1"
+ mc.activeJobs = map[string]*Job{stratumJobID: job}
+ mc.lastJob = job
+ mc.lastJobID = stratumJobID
+ mc.jobDifficulty[stratumJobID] = 1
+ mc.jobScriptTime = map[string]int64{stratumJobID: job.ScriptTime}
+
+ ntimeHex := fmt.Sprintf("%08x", uint32(job.Template.CurTime))
+ req := &StratumRequest{
+ ID: 1,
+ Method: "mining.submit",
+ Params: []any{mc.currentWorker(), stratumJobID, "00000000", ntimeHex, "00000000"},
+ }
+ task, ok := mc.prepareSubmissionTask(req, time.Unix(job.Template.CurTime, 0))
+ if !ok {
+ t.Fatalf("prepareSubmissionTask failed")
+ }
+ if task.assignedDifficulty != 1 {
+ t.Fatalf("assigned difficulty got %g want 1", task.assignedDifficulty)
+ }
+
+ // Simulate async queue delay: a later notify/difficulty change evicts or
+ // changes live connection state before this already-parsed task is processed.
+ delete(mc.jobDifficulty, stratumJobID)
+ atomicStoreFloat64(&mc.difficulty, 1024)
+ mc.shareTarget.Store(targetFromDifficulty(1024))
+
+ mc.processShare(task, shareContext{
+ hashHex: strings.Repeat("0", 64),
+ shareDiff: 1,
+ isBlock: false,
+ })
+
+ out := conn.String()
+ if !strings.Contains(out, `"result":true`) {
+ t.Fatalf("expected share accepted using captured difficulty, got: %q", out)
+ }
+ if strings.Contains(out, "low difficulty share") {
+ t.Fatalf("share was evaluated against changed live difficulty: %q", out)
+ }
+}
diff --git a/miner_submit_block.go b/miner_submit_block.go
index a24e854..4e66157 100644
--- a/miner_submit_block.go
+++ b/miner_submit_block.go
@@ -13,13 +13,15 @@ import (
// builds the full block (reusing any dual-payout header/coinbase when
// available), submits it via RPC, logs the reward split and found-block
// record, and sends the final Stratum response.
-func (mc *MinerConn) handleBlockShare(reqID any, job *Job, workerName string, en2 []byte, ntime string, nonce string, useVersion uint32, hashHex string, shareDiff float64, now time.Time) {
+func (mc *MinerConn) handleBlockShare(reqID any, job *Job, stratumJobID string, workerName string, en2 []byte, ntime string, nonce string, useVersion uint32, scriptTime int64, hashHex string, shareDiff float64, now time.Time) {
var (
blockHex string
submitRes any
err error
)
- scriptTime := mc.scriptTimeForJob(job.JobID, job.ScriptTime)
+ if scriptTime == 0 {
+ scriptTime = mc.scriptTimeForJob(stratumJobID, job.ScriptTime)
+ }
// Only construct the full block (including all non-coinbase transactions)
// when the share actually satisfies the network target.
diff --git a/miner_submit_block_safety_test.go b/miner_submit_block_safety_test.go
index b0b0672..6f287b4 100644
--- a/miner_submit_block_safety_test.go
+++ b/miner_submit_block_safety_test.go
@@ -1,10 +1,13 @@
package main
import (
+ "bytes"
"context"
"encoding/hex"
+ "errors"
"fmt"
"math/big"
+ "strings"
"sync/atomic"
"testing"
"time"
@@ -12,6 +15,7 @@ import (
type countingSubmitRPC struct {
submitCalls atomic.Int64
+ blockHex string
}
func (c *countingSubmitRPC) call(method string, params any, out any) error {
@@ -21,10 +25,35 @@ func (c *countingSubmitRPC) call(method string, params any, out any) error {
func (c *countingSubmitRPC) callCtx(_ context.Context, method string, params any, out any) error {
if method == "submitblock" {
c.submitCalls.Add(1)
+ if p, ok := params.([]any); ok && len(p) > 0 {
+ if blockHex, ok := p[0].(string); ok {
+ c.blockHex = blockHex
+ }
+ }
}
return nil
}
+type rejectingSubmitRPC struct {
+ submitCalls atomic.Int64
+ result any
+}
+
+func (r *rejectingSubmitRPC) callCtx(_ context.Context, method string, _ any, out any) error {
+ switch method {
+ case "submitblock":
+ r.submitCalls.Add(1)
+ if dst, ok := out.(*any); ok {
+ *dst = r.result
+ }
+ return nil
+ case "getblockheader":
+ return errors.New("block not found")
+ default:
+ return nil
+ }
+}
+
func flushFoundBlockLog(t *testing.T) {
t.Helper()
done := make(chan struct{})
@@ -40,6 +69,26 @@ func flushFoundBlockLog(t *testing.T) {
}
}
+func TestSubmitBlockResultStringIsRejection(t *testing.T) {
+ rpc := &rejectingSubmitRPC{result: "bad-cb-amount"}
+ mc := &MinerConn{
+ id: "submitblock-result-test",
+ rpc: rpc,
+ }
+
+ var submitRes any
+ err := mc.submitBlockWithFastRetry(&Job{Template: GetBlockTemplateResult{Height: 1}}, "worker", strings.Repeat("0", 64), "deadbeef", &submitRes)
+ if err == nil {
+ t.Fatalf("expected submitblock BIP22 result string to be treated as rejection")
+ }
+ if !strings.Contains(err.Error(), "bad-cb-amount") {
+ t.Fatalf("expected rejection reason in error, got %v", err)
+ }
+ if got := rpc.submitCalls.Load(); got != 1 {
+ t.Fatalf("expected no retry for validation rejection, got %d calls", got)
+ }
+}
+
func TestWinningBlockNotRejectedAsDuplicate(t *testing.T) {
metrics := NewPoolMetrics()
mc := benchmarkMinerConnForSubmit(metrics)
@@ -79,7 +128,6 @@ func TestWinningBlockNotRejectedAsDuplicate(t *testing.T) {
if dup := mc.isDuplicateShare(jobID, (&task).extranonce2Decoded(), task.ntimeVal, task.nonceVal, task.useVersion); dup {
t.Fatalf("unexpected duplicate when seeding cache")
}
-
mc.conn = nopConn{}
mc.processSubmissionTask(task)
flushFoundBlockLog(t)
@@ -212,6 +260,9 @@ func TestWinningBlockUsesNotifiedScriptTime(t *testing.T) {
scriptTime: notifiedScriptTime,
receivedAt: time.Unix(1700000000, 0),
}
+ // Simulate a clean re-notify for the same underlying template after the
+ // share was parsed. Block submission must still use task.scriptTime.
+ mc.jobScriptTime[jobID] = job.ScriptTime
mc.conn = nopConn{}
mc.processSubmissionTask(task)
@@ -221,6 +272,20 @@ func TestWinningBlockUsesNotifiedScriptTime(t *testing.T) {
if got := rpc.submitCalls.Load(); got != 1 {
t.Fatalf("expected submitblock to be called once, got %d", got)
}
+ expectedBlockHex, _, _, _, err := buildBlockWithScriptTime(job, mc.extranonce1, ex2, ntimeHex, chosenNonce, int32(useVersion), payoutScript, notifiedScriptTime)
+ if err != nil {
+ t.Fatalf("build expected notified block: %v", err)
+ }
+ fallbackBlockHex, _, _, _, err := buildBlockWithScriptTime(job, mc.extranonce1, ex2, ntimeHex, chosenNonce, int32(useVersion), payoutScript, job.ScriptTime)
+ if err != nil {
+ t.Fatalf("build fallback block: %v", err)
+ }
+ if rpc.blockHex != expectedBlockHex {
+ t.Fatalf("submitted block did not use notified scriptTime")
+ }
+ if rpc.blockHex == fallbackBlockHex {
+ t.Fatalf("submitted block used fallback scriptTime")
+ }
}
func TestBlockBypassesPolicyRejects(t *testing.T) {
@@ -268,6 +333,103 @@ func TestBlockBypassesPolicyRejects(t *testing.T) {
}
}
+func TestSubmitBlockMatchesNotifyPayload(t *testing.T) {
+ mc, notifyConn := minerConnForNotifyTest(t)
+ mc.cfg.DataDir = t.TempDir()
+ mc.cfg.SubmitProcessInline = true
+ rpc := &countingSubmitRPC{}
+ mc.rpc = rpc
+
+ job := benchmarkSubmitJobForTest(t)
+ job.Target = new(big.Int).Set(maxUint256)
+ const rawTxHex = "0100000001" +
+ "0000000000000000000000000000000000000000000000000000000000000000" +
+ "ffffffff00ffffffff0101000000000000000000000000"
+ rawTx, err := hex.DecodeString(rawTxHex)
+ if err != nil {
+ t.Fatalf("decode raw tx: %v", err)
+ }
+ txid := reverseBytes(doubleSHA256(rawTx))
+ job.MerkleBranches = buildMerkleBranches([][]byte{txid})
+ job.Transactions = []GBTTransaction{{Data: rawTxHex, Txid: hex.EncodeToString(txid)}}
+
+ mc.sendNotifyFor(job, true)
+ notifies := notifyMessagesFromOutput(t, notifyConn.String())
+ if len(notifies) != 1 {
+ t.Fatalf("expected one notify, got %d", len(notifies))
+ }
+ params := notifies[0].Params
+ if len(params) < 9 {
+ t.Fatalf("notify params too short: %#v", params)
+ }
+ stratumJobID := params[0].(string)
+ prevhashLE := params[1].(string)
+ coinb1 := params[2].(string)
+ coinb2 := params[3].(string)
+ branches := params[4].([]any)
+ versionHex := params[5].(string)
+ bitsHex := params[6].(string)
+ ntimeHex := params[7].(string)
+ if len(branches) != len(job.MerkleBranches) || branches[0] != job.MerkleBranches[0] {
+ t.Fatalf("notify merkle branches got %#v want %#v", branches, job.MerkleBranches)
+ }
+ if prevhashLE != hexToLEHex(job.PrevHash) {
+ t.Fatalf("notify prevhash got %q want %q", prevhashLE, hexToLEHex(job.PrevHash))
+ }
+ if bitsHex != job.Template.Bits {
+ t.Fatalf("notify bits got %q want %q", bitsHex, job.Template.Bits)
+ }
+ version, err := parseUint32BEHex(versionHex)
+ if err != nil {
+ t.Fatalf("parse notify version: %v", err)
+ }
+
+ en2Hex := "00000000"
+ coinbaseHex := coinb1 + hex.EncodeToString(mc.extranonce1) + en2Hex + coinb2
+ coinbaseBytes, err := hex.DecodeString(coinbaseHex)
+ if err != nil {
+ t.Fatalf("decode notify coinbase: %v", err)
+ }
+ coinbaseTxID := doubleSHA256(coinbaseBytes)
+ merkleRoot := computeMerkleRootFromBranches(coinbaseTxID, job.MerkleBranches)
+ nonceHex := "00000000"
+ expectedHeader, err := job.buildBlockHeader(merkleRoot, ntimeHex, nonceHex, int32(version))
+ if err != nil {
+ t.Fatalf("build expected header: %v", err)
+ }
+
+ mc.handleSubmit(&StratumRequest{
+ ID: 1,
+ Method: "mining.submit",
+ Params: []any{mc.currentWorker(), stratumJobID, en2Hex, ntimeHex, nonceHex},
+ })
+ flushFoundBlockLog(t)
+
+ if got := rpc.submitCalls.Load(); got != 1 {
+ t.Fatalf("expected submitblock to be called once, got %d", got)
+ }
+ blockBytes, err := hex.DecodeString(rpc.blockHex)
+ if err != nil {
+ t.Fatalf("decode submitted block: %v", err)
+ }
+ if len(blockBytes) <= 81 {
+ t.Fatalf("submitted block too short: %d bytes", len(blockBytes))
+ }
+ if !bytes.Equal(blockBytes[:80], expectedHeader) {
+ t.Fatalf("submitted block header does not match notify payload")
+ }
+ if blockBytes[80] != 2 {
+ t.Fatalf("expected two transactions in submitted block, got varint byte %#x", blockBytes[80])
+ }
+ var expectedPayload bytes.Buffer
+ expectedPayload.WriteByte(2)
+ expectedPayload.Write(coinbaseBytes)
+ expectedPayload.Write(rawTx)
+ if !bytes.Equal(blockBytes[80:], expectedPayload.Bytes()) {
+ t.Fatalf("submitted block payload does not match notify coinbase plus job transactions")
+ }
+}
+
func benchmarkSubmitJobForTest(t *testing.T) *Job {
t.Helper()
// Reuse the benchmark job shape but without testing.B dependency.
diff --git a/miner_submit_handlers.go b/miner_submit_handlers.go
index 377337c..380840c 100644
--- a/miner_submit_handlers.go
+++ b/miner_submit_handlers.go
@@ -33,20 +33,6 @@ func (mc *MinerConn) handleSubmitStringParams(id any, params []string) {
submissionWorkers.submit(task)
}
-func (mc *MinerConn) handleSubmitFastBytes(id any, worker, jobID, extranonce2, ntime, nonce, version []byte, haveVersion bool) {
- now := time.Now()
- task, ok := mc.prepareSubmissionTaskFastBytes(id, worker, jobID, extranonce2, ntime, nonce, version, haveVersion, now)
- if !ok {
- return
- }
- if mc.cfg.SubmitProcessInline {
- mc.processSubmissionTask(task)
- return
- }
- ensureSubmissionWorkerPool()
- submissionWorkers.submit(task)
-}
-
func (mc *MinerConn) prepareSubmissionTaskStringParams(id any, params []string, now time.Time) (submissionTask, bool) {
parsed, ok := mc.parseSubmitParamsStrings(id, params, now)
if !ok {
diff --git a/miner_submit_modes_test.go b/miner_submit_modes_test.go
index 1dd50ad..f90cc83 100644
--- a/miner_submit_modes_test.go
+++ b/miner_submit_modes_test.go
@@ -63,56 +63,25 @@ type prepareOutcome struct {
out string
}
-func runPrepareSubmissionBothPaths(
+func runPrepareSubmission(
t *testing.T,
configure func(mc *MinerConn, job *Job),
mutateReq func(req *StratumRequest),
-) (prepareOutcome, prepareOutcome) {
+) prepareOutcome {
t.Helper()
- runStandard := func() prepareOutcome {
- mc, job := newSubmitReadyMinerConnForModesTest(t)
- if configure != nil {
- configure(mc, job)
- }
- conn := &recordConn{}
- mc.conn = conn
- req := testSubmitRequestForJob(job, mc.currentWorker())
- if mutateReq != nil {
- mutateReq(req)
- }
- task, ok := mc.prepareSubmissionTask(req, time.Unix(1700000000, 0))
- return prepareOutcome{task: task, ok: ok, out: conn.String()}
+ mc, job := newSubmitReadyMinerConnForModesTest(t)
+ if configure != nil {
+ configure(mc, job)
}
-
- runFast := func() prepareOutcome {
- mc, job := newSubmitReadyMinerConnForModesTest(t)
- if configure != nil {
- configure(mc, job)
- }
- conn := &recordConn{}
- mc.conn = conn
- req := testSubmitRequestForJob(job, mc.currentWorker())
- if mutateReq != nil {
- mutateReq(req)
- }
-
- worker := []byte(req.Params[0].(string))
- jobID := []byte(req.Params[1].(string))
- en2 := []byte(req.Params[2].(string))
- ntime := []byte(req.Params[3].(string))
- nonce := []byte(req.Params[4].(string))
- var version []byte
- haveVersion := len(req.Params) == 6
- if haveVersion {
- version = []byte(req.Params[5].(string))
- }
-
- task, ok := mc.prepareSubmissionTaskFastBytes(req.ID, worker, jobID, en2, ntime, nonce, version, haveVersion, time.Unix(1700000000, 0))
- return prepareOutcome{task: task, ok: ok, out: conn.String()}
+ conn := &recordConn{}
+ mc.conn = conn
+ req := testSubmitRequestForJob(job, mc.currentWorker())
+ if mutateReq != nil {
+ mutateReq(req)
}
-
- return runStandard(), runFast()
+ task, ok := mc.prepareSubmissionTask(req, time.Unix(1700000000, 0))
+ return prepareOutcome{task: task, ok: ok, out: conn.String()}
}
func TestPrepareSubmissionTask_WorkerMismatch_AuthorizationToggle(t *testing.T) {
@@ -607,7 +576,7 @@ func TestPrepareSubmissionTask_VersionRollingPolicyBoundaries(t *testing.T) {
})
}
-func TestPrepareSubmissionTaskFastBytes_Parity_FieldValidationAndBoundaries(t *testing.T) {
+func TestPrepareSubmissionTask_FieldValidationAndBoundaries(t *testing.T) {
type parityCase struct {
name string
configure func(mc *MinerConn, job *Job)
@@ -868,48 +837,36 @@ func TestPrepareSubmissionTaskFastBytes_Parity_FieldValidationAndBoundaries(t *t
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
- std, fast := runPrepareSubmissionBothPaths(t, tc.configure, tc.mutateReq)
+ got := runPrepareSubmission(t, tc.configure, tc.mutateReq)
- if std.ok != fast.ok {
- t.Fatalf("path parity mismatch ok: standard=%v fast=%v", std.ok, fast.ok)
- }
- if std.ok != tc.wantOK {
- t.Fatalf("ok=%v want %v", std.ok, tc.wantOK)
+ if got.ok != tc.wantOK {
+ t.Fatalf("ok=%v want %v", got.ok, tc.wantOK)
}
if !tc.wantOK {
- if !strings.Contains(std.out, tc.wantErrContains) {
- t.Fatalf("standard path expected error %q, got: %q", tc.wantErrContains, std.out)
- }
- if !strings.Contains(fast.out, tc.wantErrContains) {
- t.Fatalf("fast path expected error %q, got: %q", tc.wantErrContains, fast.out)
+ if !strings.Contains(got.out, tc.wantErrContains) {
+ t.Fatalf("expected error %q, got: %q", tc.wantErrContains, got.out)
}
return
}
- if std.task.policyReject.reason != fast.task.policyReject.reason {
- t.Fatalf("policy parity mismatch: standard=%v fast=%v", std.task.policyReject.reason, fast.task.policyReject.reason)
- }
- if std.task.policyReject.reason != tc.wantPolicyReason {
- t.Fatalf("policy=%v want %v", std.task.policyReject.reason, tc.wantPolicyReason)
- }
- if std.task.ntimeVal != fast.task.ntimeVal || std.task.ntimeVal != tc.wantNTime {
- t.Fatalf("ntime parity/value mismatch: standard=%d fast=%d want=%d", std.task.ntimeVal, fast.task.ntimeVal, tc.wantNTime)
+ if got.task.policyReject.reason != tc.wantPolicyReason {
+ t.Fatalf("policy=%v want %v", got.task.policyReject.reason, tc.wantPolicyReason)
}
- if std.task.nonceVal != fast.task.nonceVal || std.task.nonceVal != tc.wantNonce {
- t.Fatalf("nonce parity/value mismatch: standard=%d fast=%d want=%d", std.task.nonceVal, fast.task.nonceVal, tc.wantNonce)
+ if got.task.ntimeVal != tc.wantNTime {
+ t.Fatalf("ntime=%d want=%d", got.task.ntimeVal, tc.wantNTime)
}
- if std.task.useVersion != fast.task.useVersion {
- t.Fatalf("useVersion parity mismatch: standard=%d fast=%d", std.task.useVersion, fast.task.useVersion)
+ if got.task.nonceVal != tc.wantNonce {
+ t.Fatalf("nonce=%d want=%d", got.task.nonceVal, tc.wantNonce)
}
- if std.task.useVersion != tc.wantUseVersion {
- t.Fatalf("useVersion=%d want=%d", std.task.useVersion, tc.wantUseVersion)
+ if got.task.useVersion != tc.wantUseVersion {
+ t.Fatalf("useVersion=%d want=%d", got.task.useVersion, tc.wantUseVersion)
}
})
}
}
-func TestPrepareSubmissionTaskFastBytes_Parity_StaleAndFallbackFreshnessModes(t *testing.T) {
+func TestPrepareSubmissionTask_StaleAndFallbackFreshnessModes(t *testing.T) {
type parityCase struct {
name string
configure func(mc *MinerConn, job *Job)
@@ -973,33 +930,24 @@ func TestPrepareSubmissionTaskFastBytes_Parity_StaleAndFallbackFreshnessModes(t
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
- std, fast := runPrepareSubmissionBothPaths(t, tc.configure, tc.mutateReq)
+ got := runPrepareSubmission(t, tc.configure, tc.mutateReq)
- if std.ok != fast.ok {
- t.Fatalf("path parity mismatch ok: standard=%v fast=%v", std.ok, fast.ok)
- }
- if std.ok != tc.wantOK {
- t.Fatalf("ok=%v want %v", std.ok, tc.wantOK)
+ if got.ok != tc.wantOK {
+ t.Fatalf("ok=%v want %v", got.ok, tc.wantOK)
}
if !tc.wantOK {
- if !strings.Contains(std.out, tc.wantErrContains) {
- t.Fatalf("standard path expected error %q, got: %q", tc.wantErrContains, std.out)
- }
- if !strings.Contains(fast.out, tc.wantErrContains) {
- t.Fatalf("fast path expected error %q, got: %q", tc.wantErrContains, fast.out)
+ if !strings.Contains(got.out, tc.wantErrContains) {
+ t.Fatalf("expected error %q, got: %q", tc.wantErrContains, got.out)
}
return
}
- if std.task.policyReject.reason != fast.task.policyReject.reason {
- t.Fatalf("policy parity mismatch: standard=%v fast=%v", std.task.policyReject.reason, fast.task.policyReject.reason)
- }
- if std.task.policyReject.reason != tc.wantPolicyReason {
- t.Fatalf("policy=%v want %v", std.task.policyReject.reason, tc.wantPolicyReason)
+ if got.task.policyReject.reason != tc.wantPolicyReason {
+ t.Fatalf("policy=%v want %v", got.task.policyReject.reason, tc.wantPolicyReason)
}
- if std.task.job == nil || fast.task.job == nil {
- t.Fatalf("expected both paths to return a populated task job")
+ if got.task.job == nil {
+ t.Fatalf("expected a populated task job")
}
})
}
diff --git a/miner_submit_parse.go b/miner_submit_parse.go
index feaecc5..5788fff 100644
--- a/miner_submit_parse.go
+++ b/miner_submit_parse.go
@@ -43,29 +43,6 @@ func decodeExtranonce2Hex(extranonce2 string, validateFields bool, expectedSize
return small, uint16(size), large, nil
}
-func decodeExtranonce2HexBytes(extranonce2 []byte, validateFields bool, expectedSize int) ([32]byte, uint16, []byte, error) {
- var small [32]byte
- if validateFields && expectedSize > 0 && len(extranonce2) != expectedSize*2 {
- return small, 0, nil, fmt.Errorf("expected extranonce2 len %d, got %d", expectedSize*2, len(extranonce2))
- }
- if len(extranonce2)%2 != 0 {
- return small, 0, nil, fmt.Errorf("odd-length extranonce2 hex")
- }
- size := len(extranonce2) / 2
- if size <= len(small) {
- dst := small[:size]
- if err := decodeHexToFixedBytesBytes(dst, extranonce2); err != nil {
- return small, 0, nil, err
- }
- return small, uint16(size), nil, nil
- }
- large := make([]byte, size)
- if err := decodeHexToFixedBytesBytes(large, extranonce2); err != nil {
- return small, 0, nil, err
- }
- return small, uint16(size), large, nil
-}
-
// resolveSubmittedVersion interprets submit version values as deltas
// (rolled_version ^ base_version) whenever possible.
//
@@ -281,77 +258,6 @@ func (mc *MinerConn) parseSubmitParamsStrings(id any, params []string, now time.
return out, true
}
-func (mc *MinerConn) prepareSubmissionTaskFastBytes(reqID any, workerB, jobIDB, extranonce2B, ntimeB, nonceB, versionB []byte, haveVersion bool, now time.Time) (submissionTask, bool) {
- // Allocate only for worker/job_id; keep hex fields in bytes and pre-parse
- // ntime/nonce/version to uint32.
- validateFields := mc.cfg.ShareCheckParamFormat
-
- worker := string(workerB)
- if validateFields {
- worker = trimSpaceFast(worker)
- }
- if validateFields && len(worker) == 0 {
- mc.recordShare("", false, 0, 0, "empty worker", "", nil, now)
- mc.writeResponse(StratumResponse{ID: reqID, Result: false, Error: newStratumError(stratumErrCodeInvalidRequest, "worker name required")})
- return submissionTask{}, false
- }
- if validateFields && len(worker) > maxWorkerNameLen {
- logger.Debug("submit rejected: worker name too long", "remote", mc.id, "len", len(worker))
- mc.recordShare("", false, 0, 0, "worker name too long", "", nil, now)
- mc.writeResponse(StratumResponse{ID: reqID, Result: false, Error: newStratumError(stratumErrCodeInvalidRequest, "worker name too long")})
- return submissionTask{}, false
- }
-
- jobID := string(jobIDB)
- if validateFields {
- jobID = trimSpaceFast(jobID)
- }
- if len(jobID) == 0 {
- mc.recordShare(worker, false, 0, 0, "empty job id", "", nil, now)
- mc.writeResponse(StratumResponse{ID: reqID, Result: false, Error: newStratumError(stratumErrCodeInvalidRequest, "job id required")})
- return submissionTask{}, false
- }
- if validateFields && len(jobID) > maxJobIDLen {
- logger.Debug("submit rejected: job id too long", "remote", mc.id, "len", len(jobID))
- mc.recordShare(worker, false, 0, 0, "job id too long", "", nil, now)
- mc.writeResponse(StratumResponse{ID: reqID, Result: false, Error: newStratumError(stratumErrCodeInvalidRequest, "job id too long")})
- return submissionTask{}, false
- }
-
- submittedVersion := uint32(0)
- if haveVersion {
- if validateFields && len(versionB) == 0 {
- mc.recordShare(worker, false, 0, 0, "empty version", "", nil, now)
- mc.writeResponse(StratumResponse{ID: reqID, Result: false, Error: newStratumError(stratumErrCodeInvalidRequest, "version required")})
- return submissionTask{}, false
- }
- if validateFields && len(versionB) > maxVersionHexLen {
- logger.Debug("submit rejected: version too long", "remote", mc.id, "len", len(versionB))
- mc.recordShare(worker, false, 0, 0, "version too long", "", nil, now)
- mc.writeResponse(StratumResponse{ID: reqID, Result: false, Error: newStratumError(stratumErrCodeInvalidRequest, "version too long")})
- return submissionTask{}, false
- }
- verVal, err := parseUint32BEHexBytes(versionB)
- if err != nil {
- if validateFields {
- mc.recordShare(worker, false, 0, 0, "invalid version", "", nil, now)
- mc.writeResponse(StratumResponse{ID: reqID, Result: false, Error: newStratumError(stratumErrCodeInvalidRequest, "invalid version")})
- return submissionTask{}, false
- }
- verVal = 0
- }
- submittedVersion = verVal
- }
-
- params := submitParams{
- worker: worker,
- jobID: jobID,
- submittedVersion: submittedVersion,
- }
-
- return mc.prepareSubmissionTaskFromParsedBytes(reqID, params, extranonce2B, ntimeB, nonceB, now)
-}
-
// prepareSubmissionTask validates a mining.submit request and, if valid, returns
// a fully-populated submissionTask. On any validation failure it writes the
// appropriate Stratum response and returns ok=false.
@@ -367,197 +273,6 @@ func (mc *MinerConn) prepareSubmissionTask(req *StratumRequest, now time.Time) (
return mc.prepareSubmissionTaskFromParsed(req.ID, params, now)
}
-func (mc *MinerConn) prepareSubmissionTaskFromParsedBytes(reqID any, params submitParams, extranonce2B, ntimeB, nonceB []byte, now time.Time) (submissionTask, bool) {
- worker := params.worker
- jobID := params.jobID
- submittedVersion := params.submittedVersion
- validateFields := mc.cfg.ShareCheckParamFormat
-
- if mc.cfg.ShareRequireAuthorizedConnection && !mc.authorized {
- logger.Debug("submit rejected: unauthorized", "remote", mc.id)
- mc.recordShare(worker, false, 0, 0, "unauthorized", "", nil, now)
- if mc.metrics != nil {
- mc.metrics.RecordSubmitError("unauthorized")
- }
- mc.writeResponse(StratumResponse{ID: reqID, Result: false, Error: newStratumError(stratumErrCodeUnauthorized, "unauthorized")})
- return submissionTask{}, false
- }
-
- authorizedWorker := mc.currentWorker()
- submitWorker := worker
- if mc.cfg.ShareRequireAuthorizedConnection && mc.cfg.ShareRequireWorkerMatch && authorizedWorker != "" && submitWorker != authorizedWorker {
- logger.Warn("submit rejected: worker mismatch", "remote", mc.id, "authorized", authorizedWorker, "submitted", submitWorker)
- mc.recordShare(authorizedWorker, false, 0, 0, "unauthorized worker", "", nil, now)
- if mc.metrics != nil {
- mc.metrics.RecordSubmitError("worker_mismatch")
- }
- mc.writeResponse(StratumResponse{ID: reqID, Result: false, Error: newStratumError(stratumErrCodeUnauthorized, "unauthorized")})
- return submissionTask{}, false
- }
-
- workerName := authorizedWorker
- if workerName == "" {
- workerName = worker
- }
- if mc.isBanned(now) {
- until, reason, _ := mc.banDetails()
- logger.Warn("submit rejected: banned", "miner", mc.minerName(workerName), "ban_until", until, "reason", reason)
- if mc.metrics != nil {
- mc.metrics.RecordSubmitError("banned")
- }
- mc.writeResponse(StratumResponse{ID: reqID, Result: false, Error: mc.bannedStratumError()})
- return submissionTask{}, false
- }
-
- job, curLast, curPrevHash, curHeight, ntimeBounds, notifiedScriptTime, ok := mc.jobForIDWithLast(jobID)
- usedFallbackJob := false
- if !ok || job == nil {
- if shareJobFreshnessChecksJobID(mc.cfg.ShareJobFreshnessMode) {
- logger.Debug("submit rejected: stale job", "remote", mc.id, "job", jobID)
- mc.rejectShareWithBan(&StratumRequest{ID: reqID, Method: "mining.submit"}, workerName, rejectStaleJob, stratumErrCodeJobNotFound, "job not found", now)
- return submissionTask{}, false
- }
- if curLast == nil {
- logger.Debug("submit rejected: no fallback job available", "remote", mc.id, "job", jobID)
- mc.rejectShareWithBan(&StratumRequest{ID: reqID, Method: "mining.submit"}, workerName, rejectStaleJob, stratumErrCodeJobNotFound, "job not found", now)
- return submissionTask{}, false
- }
- job = curLast
- usedFallbackJob = true
- if notifiedScriptTime == 0 {
- notifiedScriptTime = mc.scriptTimeForJob(job.JobID, job.ScriptTime)
- }
- }
-
- policyReject := submitPolicyReject{reason: rejectUnknown}
- if usedFallbackJob {
- // Even when job-id freshness checks are disabled, classify non-block
- // shares for unknown/expired job IDs as stale rather than lowdiff.
- policyReject = submitPolicyReject{reason: rejectStaleJob, errCode: stratumErrCodeJobNotFound, errMsg: "job not found"}
- }
- if shareJobFreshnessChecksPrevhash(mc.cfg.ShareJobFreshnessMode) && curLast != nil && (curPrevHash != job.Template.Previous || curHeight != job.Template.Height) {
- logger.Warn("submit: stale job mismatch (policy)", "remote", mc.id, "job", jobID, "expected_prev", job.Template.Previous, "expected_height", job.Template.Height, "current_prev", curPrevHash, "current_height", curHeight)
- policyReject = submitPolicyReject{reason: rejectStaleJob, errCode: stratumErrCodeJobNotFound, errMsg: "job not found"}
- }
-
- en2Small, en2Len, en2Large, err := decodeExtranonce2HexBytes(extranonce2B, validateFields, job.Extranonce2Size)
- if err != nil {
- logger.Debug("submit bad extranonce2", "remote", mc.id, "error", err)
- mc.rejectShareWithBan(&StratumRequest{ID: reqID, Method: "mining.submit"}, workerName, rejectInvalidExtranonce2, stratumErrCodeInvalidRequest, "invalid extranonce2", now)
- return submissionTask{}, false
- }
-
- if validateFields && len(ntimeB) != 8 {
- logger.Debug("submit invalid ntime length", "remote", mc.id, "len", len(ntimeB))
- mc.rejectShareWithBan(&StratumRequest{ID: reqID, Method: "mining.submit"}, workerName, rejectInvalidNTime, stratumErrCodeInvalidRequest, "invalid ntime", now)
- return submissionTask{}, false
- }
- ntimeVal, err := parseUint32BEHexBytes(ntimeB)
- if err != nil {
- logger.Debug("submit bad ntime", "remote", mc.id, "error", err)
- mc.rejectShareWithBan(&StratumRequest{ID: reqID, Method: "mining.submit"}, workerName, rejectInvalidNTime, stratumErrCodeInvalidRequest, "invalid ntime", now)
- return submissionTask{}, false
- }
- minNTime := ntimeBounds.min
- maxNTime := ntimeBounds.max
- if mc.cfg.ShareCheckNTimeWindow && (int64(ntimeVal) < minNTime || int64(ntimeVal) > maxNTime) {
- logger.Warn("submit ntime outside window (policy)", "remote", mc.id, "ntime", ntimeVal, "min", minNTime, "max", maxNTime)
- if policyReject.reason == rejectUnknown {
- policyReject = submitPolicyReject{reason: rejectInvalidNTime, errCode: stratumErrCodeInvalidRequest, errMsg: "invalid ntime"}
- }
- }
-
- if validateFields && len(nonceB) != 8 {
- logger.Debug("submit invalid nonce length", "remote", mc.id, "len", len(nonceB))
- mc.rejectShareWithBan(&StratumRequest{ID: reqID, Method: "mining.submit"}, workerName, rejectInvalidNonce, stratumErrCodeInvalidRequest, "invalid nonce", now)
- return submissionTask{}, false
- }
- nonceVal, err := parseUint32BEHexBytes(nonceB)
- if err != nil {
- logger.Debug("submit bad nonce", "remote", mc.id, "error", err)
- mc.rejectShareWithBan(&StratumRequest{ID: reqID, Method: "mining.submit"}, workerName, rejectInvalidNonce, stratumErrCodeInvalidRequest, "invalid nonce", now)
- return submissionTask{}, false
- }
-
- baseVersion := uint32(job.Template.Version)
- useVersion, versionDiff := resolveSubmittedVersion(baseVersion, submittedVersion, mc.versionMask, mc.cfg.ShareAllowVersionMaskMismatch)
-
- extranonce2 := ""
- ntime := ""
- nonce := ""
- versionHex := ""
- if debugLogging || verboseRuntimeLogging {
- extranonce2 = string(extranonce2B)
- ntime = string(ntimeB)
- nonce = string(nonceB)
- versionHex = uint32ToHex8Lower(useVersion)
- }
-
- if mc.cfg.ShareCheckVersionRolling && versionDiff != 0 {
- maskedDiff := versionDiff & mc.versionMask
-
- if !mc.versionRoll {
- logger.Warn("submit version rolling disabled (policy)", "remote", mc.id, "diff", uint32ToHex8Lower(versionDiff))
- if policyReject.reason == rejectUnknown {
- policyReject = submitPolicyReject{reason: rejectInvalidVersion, errCode: stratumErrCodeInvalidRequest, errMsg: "version rolling not enabled"}
- }
- }
-
- if versionDiff&^mc.versionMask != 0 {
- if !mc.cfg.ShareAllowVersionMaskMismatch {
- logger.Warn("submit version outside mask (policy)", "remote", mc.id, "version", uint32ToHex8Lower(useVersion), "mask", uint32ToHex8Lower(mc.versionMask))
- if policyReject.reason == rejectUnknown {
- policyReject = submitPolicyReject{reason: rejectInvalidVersionMask, errCode: stratumErrCodeInvalidRequest, errMsg: "invalid version mask"}
- }
- } else {
- logger.Debug("submit version outside mask allowed (compat)",
- "remote", mc.id,
- "version", uint32ToHex8Lower(useVersion),
- "mask", uint32ToHex8Lower(mc.versionMask))
- }
- }
-
- if mc.minVerBits > 0 {
- usedBits := bits.OnesCount32(maskedDiff)
- if usedBits < mc.minVerBits {
- if !mc.cfg.ShareAllowDegradedVersionBits {
- logger.Warn("submit insufficient version rolling bits (policy)", "remote", mc.id, "version", uint32ToHex8Lower(useVersion), "required_bits", mc.minVerBits)
- if policyReject.reason == rejectUnknown {
- policyReject = submitPolicyReject{reason: rejectInsufficientVersionBits, errCode: stratumErrCodeInvalidRequest, errMsg: "insufficient version bits"}
- }
- } else {
- logger.Warn("submit: miner operating in degraded version rolling mode (allowed by BIP310)",
- "remote", mc.id, "version", uint32ToHex8Lower(useVersion),
- "used_bits", usedBits,
- "negotiated_minimum", mc.minVerBits)
- }
- }
- }
- }
-
- task := submissionTask{
- mc: mc,
- reqID: reqID,
- job: job,
- jobID: jobID,
- workerName: workerName,
- extranonce2: extranonce2,
- extranonce2Len: en2Len,
- extranonce2Bytes: en2Small,
- extranonce2Large: en2Large,
- ntime: ntime,
- ntimeVal: ntimeVal,
- nonce: nonce,
- nonceVal: nonceVal,
- versionHex: versionHex,
- useVersion: useVersion,
- scriptTime: notifiedScriptTime,
- policyReject: policyReject,
- receivedAt: now,
- }
- return task, true
-}
-
func (mc *MinerConn) prepareSubmissionTaskFromParsed(reqID any, params submitParams, now time.Time) (submissionTask, bool) {
worker := params.worker
jobID := params.jobID
@@ -735,24 +450,25 @@ func (mc *MinerConn) prepareSubmissionTaskFromParsed(reqID any, params submitPar
}
task := submissionTask{
- mc: mc,
- reqID: reqID,
- job: job,
- jobID: jobID,
- workerName: workerName,
- extranonce2: extranonce2,
- extranonce2Len: en2Len,
- extranonce2Bytes: en2Small,
- extranonce2Large: en2Large,
- ntime: ntime,
- ntimeVal: ntimeVal,
- nonce: nonce,
- nonceVal: nonceVal,
- versionHex: versionHex,
- useVersion: useVersion,
- scriptTime: notifiedScriptTime,
- policyReject: policyReject,
- receivedAt: now,
+ mc: mc,
+ reqID: reqID,
+ job: job,
+ jobID: jobID,
+ workerName: workerName,
+ extranonce2: extranonce2,
+ extranonce2Len: en2Len,
+ extranonce2Bytes: en2Small,
+ extranonce2Large: en2Large,
+ ntime: ntime,
+ ntimeVal: ntimeVal,
+ nonce: nonce,
+ nonceVal: nonceVal,
+ versionHex: versionHex,
+ useVersion: useVersion,
+ scriptTime: notifiedScriptTime,
+ assignedDifficulty: mc.assignedDifficulty(jobID),
+ policyReject: policyReject,
+ receivedAt: now,
}
return task, true
}
diff --git a/miner_submit_process.go b/miner_submit_process.go
index f084e29..4ec81cc 100644
--- a/miner_submit_process.go
+++ b/miner_submit_process.go
@@ -55,7 +55,10 @@ func (mc *MinerConn) processShare(task submissionTask, ctx shareContext) {
nonce := task.nonce
versionHex := task.versionHex
- assignedDiff := mc.assignedDifficulty(jobID)
+ assignedDiff := task.assignedDifficulty
+ if assignedDiff <= 0 {
+ assignedDiff = mc.assignedDifficulty(jobID)
+ }
currentDiff := mc.currentDifficulty()
creditedDiff := assignedDiff
if creditedDiff <= 0 {
@@ -155,25 +158,26 @@ func (mc *MinerConn) processShare(task submissionTask, ctx shareContext) {
detail = mc.buildShareDetailFromCoinbase(job, ctx.cbTx)
}
- if ctx.isBlock {
- mc.noteValidSubmit(now)
- mc.handleBlockShare(reqID, job, workerName, (&task).extranonce2Decoded(), uint32ToHex8Lower(task.ntimeVal), uint32ToHex8Lower(task.nonceVal), task.useVersion, ctx.hashHex, ctx.shareDiff, now)
- mc.trackBestShare(workerName, shareHash, ctx.shareDiff, now)
- mc.maybeUpdateSavedWorkerMinuteBestDiff(ctx.shareDiff, now)
- mc.maybeUpdateSavedWorkerBestDiff(ctx.shareDiff)
- return
- }
-
+ if ctx.isBlock {
mc.noteValidSubmit(now)
- mc.recordShare(workerName, true, creditedDiff, ctx.shareDiff, "", shareHash, detail, now)
+ mc.handleBlockShare(reqID, job, task.jobID, workerName, (&task).extranonce2Decoded(), uint32ToHex8Lower(task.ntimeVal), uint32ToHex8Lower(task.nonceVal), task.useVersion, task.scriptTime, ctx.hashHex, ctx.shareDiff, now)
mc.trackBestShare(workerName, shareHash, ctx.shareDiff, now)
mc.maybeUpdateSavedWorkerMinuteBestDiff(ctx.shareDiff, now)
mc.maybeUpdateSavedWorkerBestDiff(ctx.shareDiff)
+ return
+ }
+
+ mc.noteValidSubmit(now)
+ mc.recordShare(workerName, true, creditedDiff, ctx.shareDiff, "", shareHash, detail, now)
// Respond first; any vardiff adjustment and follow-up notify can happen after
// the submit is acknowledged to minimize perceived submit latency.
mc.writeTrueResponse(reqID)
+ mc.trackBestShare(workerName, shareHash, ctx.shareDiff, now)
+ mc.maybeUpdateSavedWorkerMinuteBestDiff(ctx.shareDiff, now)
+ mc.maybeUpdateSavedWorkerBestDiff(ctx.shareDiff)
+
if mc.maybeAdjustDifficulty(now) {
mc.sendNotifyFor(job, true)
}
diff --git a/miner_types.go b/miner_types.go
index b22141d..6812aa2 100644
--- a/miner_types.go
+++ b/miner_types.go
@@ -149,6 +149,7 @@ type MinerConn struct {
shareCache map[string]*duplicateShareSet
evictedShareCache map[string]*evictedCacheEntry
lastJob *Job
+ lastJobID string
lastJobPrevHash string
lastJobHeight int64
lastClean bool
diff --git a/path_traversal_test.go b/path_traversal_test.go
index 6d9b7ac..a1945ed 100644
--- a/path_traversal_test.go
+++ b/path_traversal_test.go
@@ -1,33 +1,22 @@
package main
import (
+ "io/fs"
"net/http"
"net/http/httptest"
- "os"
- "path/filepath"
+ "strings"
"testing"
+ "testing/fstest"
)
// TestFileServerPathTraversal verifies that the fileServerWithFallback
// correctly prevents path traversal attacks.
func TestFileServerPathTraversal(t *testing.T) {
- // Create a temporary www directory with a test file
- tmpDir := t.TempDir()
- wwwDir := filepath.Join(tmpDir, "www")
- if err := os.MkdirAll(wwwDir, 0755); err != nil {
- t.Fatal(err)
- }
-
- // Create a test file inside www directory
- testFile := filepath.Join(wwwDir, "test.txt")
- if err := os.WriteFile(testFile, []byte("safe content"), 0644); err != nil {
- t.Fatal(err)
- }
-
- // Create a sensitive file outside www directory
- sensitiveFile := filepath.Join(tmpDir, "secrets.txt")
- if err := os.WriteFile(sensitiveFile, []byte("sensitive data"), 0644); err != nil {
- t.Fatal(err)
+ staticFS := fstest.MapFS{
+ "test.txt": &fstest.MapFile{
+ Data: []byte("safe content"),
+ Mode: fs.FileMode(0o644),
+ },
}
// Create a fallback handler that returns 404
@@ -36,24 +25,12 @@ func TestFileServerPathTraversal(t *testing.T) {
w.Write([]byte("Not found"))
})
- // Create the file server with fallback using os.Root for security
- wwwRoot, err := os.OpenRoot(wwwDir)
- if err != nil {
- t.Fatalf("Failed to open www root: %v", err)
- }
- defer wwwRoot.Close()
-
- handler := &fileServerWithFallback{
- fileServer: http.FileServer(http.Dir(wwwDir)),
- fallback: fallback,
- wwwRoot: wwwRoot,
- }
+ handler := newStaticFileServer(staticFS, fallback)
tests := []struct {
name string
path string
expectStatus int
- expectContent string
shouldContain string
}{
{
@@ -100,7 +77,7 @@ func TestFileServerPathTraversal(t *testing.T) {
}
body := rec.Body.String()
- if tt.shouldContain != "" && body != tt.shouldContain {
+ if tt.shouldContain != "" && !strings.Contains(body, tt.shouldContain) {
t.Errorf("Expected body to contain %q, got %q", tt.shouldContain, body)
}
@@ -111,3 +88,51 @@ func TestFileServerPathTraversal(t *testing.T) {
})
}
}
+
+func TestEmbeddedStaticFileServerServesAssets(t *testing.T) {
+ handler, err := newEmbeddedStaticFileServer(http.NotFoundHandler())
+ if err != nil {
+ t.Fatalf("newEmbeddedStaticFileServer: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/style.css", nil)
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%q", rec.Code, rec.Body.String())
+ }
+ if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "text/css") {
+ t.Fatalf("Content-Type=%q, want text/css", got)
+ }
+ if !strings.Contains(rec.Body.String(), "--bg-gradient-start") {
+ t.Fatalf("expected embedded stylesheet body, got %q", rec.Body.String()[:min(len(rec.Body.String()), 120)])
+ }
+}
+
+func TestHandleStaticFileRejectsUnsupportedMethods(t *testing.T) {
+ staticFS := fstest.MapFS{
+ "privacy.html": &fstest.MapFile{
+ Data: []byte("privacy"),
+ Mode: fs.FileMode(0o644),
+ },
+ }
+ s := &StatusServer{
+ staticFiles: newStaticFileServer(staticFS, http.NotFoundHandler()),
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/privacy", strings.NewReader("x=1"))
+ rec := httptest.NewRecorder()
+
+ s.handleStaticFile("privacy.html").ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusMethodNotAllowed {
+ t.Fatalf("status=%d, want %d", rec.Code, http.StatusMethodNotAllowed)
+ }
+ if got := rec.Header().Get("Allow"); got != "GET, HEAD" {
+ t.Fatalf("Allow=%q, want GET, HEAD", got)
+ }
+ if strings.Contains(rec.Body.String(), "privacy") {
+ t.Fatalf("unsupported method served static body: %q", rec.Body.String())
+ }
+}
diff --git a/runtime_overrides.go b/runtime_overrides.go
index 913f94d..bfe7ab7 100644
--- a/runtime_overrides.go
+++ b/runtime_overrides.go
@@ -14,8 +14,6 @@ type runtimeOverrides struct {
stratumTLSListen string
safeMode *bool
ckpoolEmulate *bool
- stratumFastDecode *bool
- stratumFastEncode *bool
stratumTCPReadBuf *int
stratumTCPWriteBuf *int
rpcURL string
@@ -134,12 +132,6 @@ func applyRuntimeOverrides(cfg *Config, overrides runtimeOverrides) error {
if overrides.safeMode != nil {
cfg.SafeMode = *overrides.safeMode
}
- if overrides.stratumFastDecode != nil {
- cfg.StratumFastDecodeEnabled = *overrides.stratumFastDecode
- }
- if overrides.stratumFastEncode != nil {
- cfg.StratumFastEncodeEnabled = *overrides.stratumFastEncode
- }
if overrides.stratumTCPReadBuf != nil {
cfg.StratumTCPReadBufferBytes = *overrides.stratumTCPReadBuf
}
@@ -172,8 +164,6 @@ func applySafeModeProfile(cfg *Config) {
// Conservative compatibility/safety defaults for troubleshooting and broad miner support.
cfg.CKPoolEmulate = true
- cfg.StratumFastDecodeEnabled = false
- cfg.StratumFastEncodeEnabled = false
cfg.StratumTCPReadBufferBytes = 0
cfg.StratumTCPWriteBufferBytes = 0
diff --git a/share_detail.go b/share_detail.go
index 1b524ea..1f21648 100644
--- a/share_detail.go
+++ b/share_detail.go
@@ -43,6 +43,9 @@ func (mc *MinerConn) buildCurrentJobCoinbaseDetail(job *Job) *ShareDetail {
en2 := make([]byte, extranonce2Size)
mc.jobMu.Lock()
parts, ok := mc.jobNotifyCoinbase[job.JobID]
+ if !ok && mc.lastJob == job && mc.lastJobID != "" {
+ parts, ok = mc.jobNotifyCoinbase[mc.lastJobID]
+ }
mc.jobMu.Unlock()
if !ok || parts.coinb1 == "" || parts.coinb2 == "" {
return nil
diff --git a/status_display_helpers.go b/status_display_helpers.go
index 5c9b8ff..799b2ec 100644
--- a/status_display_helpers.go
+++ b/status_display_helpers.go
@@ -2,11 +2,7 @@ package main
import "strings"
-// shortDisplayID returns a sanitized, shortened version of s suitable for
-// display in HTML templates. It keeps only [A-Za-z0-9._-] characters and, if
-// the cleaned string is longer than prefix+suffix+3, returns
-// prefix + "..." + suffix.
-func shortDisplayID(s string, prefix, suffix int) string {
+func cleanDisplayID(s string) string {
if s == "" {
return ""
}
@@ -23,6 +19,15 @@ func shortDisplayID(s string, prefix, suffix int) string {
// drop spaces, newlines, and any other unexpected chars
}
}
+ return string(cleaned)
+}
+
+// shortDisplayID returns a sanitized, shortened version of s suitable for
+// display in HTML templates. It keeps only [A-Za-z0-9._-] characters and, if
+// the cleaned string is longer than prefix+suffix+3, returns
+// prefix + "..." + suffix.
+func shortDisplayID(s string, prefix, suffix int) string {
+ cleaned := []byte(cleanDisplayID(s))
if len(cleaned) == 0 {
return ""
}
@@ -59,5 +64,12 @@ func shortWorkerName(s string, prefix, suffix int) string {
return shortDisplayID(s, prefix, suffix)
}
shortHead := shortDisplayID(head, prefix, suffix)
- return shortHead + "." + tail
+ cleanTail := cleanDisplayID(tail)
+ if cleanTail == "" {
+ return shortHead
+ }
+ if shortHead == "" {
+ return "." + cleanTail
+ }
+ return shortHead + "." + cleanTail
}
diff --git a/status_display_test.go b/status_display_test.go
index 4258bbb..224a6bc 100644
--- a/status_display_test.go
+++ b/status_display_test.go
@@ -30,6 +30,13 @@ func TestShortWorkerName_PreservesSuffixAfterDot(t *testing.T) {
}
}
+func TestShortWorkerName_SanitizesSuffixAfterDot(t *testing.T) {
+ got := shortWorkerName("1234567890.", 3, 3)
+ if got != "123...890.scriptalert1script" {
+ t.Fatalf("got %q, want %q", got, "123...890.scriptalert1script")
+ }
+}
+
func TestShortWorkerName_NoDotUsesShortDisplayID(t *testing.T) {
got := shortWorkerName("0123456789", 3, 3)
if got != "012...789" {
diff --git a/status_server_http.go b/status_server_http.go
index cc7ddb0..226c1e8 100644
--- a/status_server_http.go
+++ b/status_server_http.go
@@ -9,15 +9,23 @@ import (
)
func (s *StatusServer) SetJobManager(jm *JobManager) {
+ if s == nil {
+ return
+ }
s.jobMgr = jm
- // Set up callback to invalidate status cache when new blocks arrive
- jm.onNewBlock = s.invalidateStatusCache
+ if jm != nil {
+ // Set up callback to invalidate status cache when new blocks arrive.
+ jm.onNewBlock = s.invalidateStatusCache
+ }
}
func (s *StatusServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/favicon.png":
- http.ServeFile(w, r, "logo.png")
+ if s != nil && s.staticFiles != nil && s.staticFiles.ServePath(w, r, "favicon.png") {
+ return
+ }
+ http.NotFound(w, r)
case r.URL.Path == "/" || r.URL.Path == "":
if err := s.serveOverviewOrNodeDown(w); err != nil {
diff --git a/status_server_http_node_down_test.go b/status_server_http_node_down_test.go
index 07c72af..81b5c22 100644
--- a/status_server_http_node_down_test.go
+++ b/status_server_http_node_down_test.go
@@ -10,7 +10,7 @@ import (
)
func TestStatusServerOverview_RendersNodeDownWhenStale(t *testing.T) {
- tmpl, err := loadTemplates("data")
+ tmpl, err := loadTemplates()
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
diff --git a/status_server_http_test.go b/status_server_http_test.go
new file mode 100644
index 0000000..2b5fceb
--- /dev/null
+++ b/status_server_http_test.go
@@ -0,0 +1,23 @@
+package main
+
+import "testing"
+
+func TestStatusServerSetJobManagerHandlesNil(t *testing.T) {
+ var nilServer *StatusServer
+ nilServer.SetJobManager(nil)
+
+ s := &StatusServer{}
+ jm := &JobManager{}
+ s.SetJobManager(jm)
+ if s.jobMgr != jm {
+ t.Fatalf("job manager was not attached")
+ }
+ if jm.onNewBlock == nil {
+ t.Fatalf("expected new-block callback to be installed")
+ }
+
+ s.SetJobManager(nil)
+ if s.jobMgr != nil {
+ t.Fatalf("job manager was not cleared")
+ }
+}
diff --git a/status_snapshot.go b/status_snapshot.go
index 317f2c5..b1ac534 100644
--- a/status_snapshot.go
+++ b/status_snapshot.go
@@ -9,8 +9,6 @@ import (
"io"
"math"
"net"
- "os"
- "path/filepath"
"sort"
"strconv"
"strings"
@@ -636,7 +634,7 @@ func (s *StatusServer) findAllWorkerViewsByHash(hash string, now time.Time) []Wo
}
func formatHashrateValue(h float64) string {
- units := []string{"H/s", "KH/s", "MH/s", "GH/s", "TH/s", "PH/s"}
+ units := []string{"H/s", "KH/s", "MH/s", "GH/s", "TH/s", "PH/s", "EH/s"}
unit := units[0]
val := h
for i := 0; i < len(units)-1 && val >= 1000; i++ {
@@ -667,6 +665,61 @@ func formatLatencyMS(ms float64) string {
return fmt.Sprintf("%.1fm", sec/60)
}
+func formatDiffValue(d float64) string {
+ if d <= 0 || math.IsNaN(d) || math.IsInf(d, 0) {
+ return "0"
+ }
+ if d < 1 {
+ // Display small difficulties as decimals (e.g. 0.5) instead of rounding to 0.
+ //
+ // We intentionally truncate instead of round so values slightly below 1 don't
+ // display as "1" due to formatting.
+ prec := max(int(math.Ceil(-math.Log10(d)))+2, 3)
+ if prec > 8 {
+ prec = 8
+ }
+ scale := math.Pow10(prec)
+ trunc := math.Trunc(d*scale) / scale
+ s := strconv.FormatFloat(trunc, 'f', prec, 64)
+ s = strings.TrimRight(s, "0")
+ s = strings.TrimRight(s, ".")
+ if s == "" || s == "0" {
+ // Extremely small values may truncate to 0 at our precision cap.
+ return strconv.FormatFloat(d, 'g', 3, 64)
+ }
+ return s
+ }
+ if d < 1_000_000 {
+ return fmt.Sprintf("%.0f", math.Round(d))
+ }
+ switch {
+ case d >= 1_000_000_000_000_000:
+ return fmt.Sprintf("%.1fP", d/1_000_000_000_000_000.0)
+ case d >= 1_000_000_000_000:
+ return fmt.Sprintf("%.1fT", d/1_000_000_000_000.0)
+ case d >= 1_000_000_000:
+ return fmt.Sprintf("%.1fG", d/1_000_000_000.0)
+ default:
+ return fmt.Sprintf("%.1fM", d/1_000_000.0)
+ }
+}
+
+func formatDiffDetailValue(d float64) string {
+ if d <= 0 || math.IsNaN(d) || math.IsInf(d, 0) {
+ return "0"
+ }
+ if d < 1 {
+ return formatDiffValue(d)
+ }
+ s := strconv.FormatFloat(d, 'f', 8, 64)
+ s = strings.TrimRight(s, "0")
+ s = strings.TrimRight(s, ".")
+ if s == "" {
+ return "0"
+ }
+ return s
+}
+
// buildTemplateFuncs returns the template.FuncMap used for all HTML templates.
func buildTemplateFuncs() template.FuncMap {
return template.FuncMap{
@@ -690,7 +743,7 @@ func buildTemplateFuncs() template.FuncMap {
}
base := formatHashrateValue(h)
marker := strings.TrimSpace(accuracy)
- if marker == "" {
+ if marker == "" || marker == "≈+" || marker == "✓" {
return base
}
return marker + " " + base
@@ -705,42 +758,8 @@ func buildTemplateFuncs() template.FuncMap {
}
return formatLatencyMS(lastMS)
},
- "formatDiff": func(d float64) string {
- if d <= 0 {
- return "0"
- }
- if d < 1 {
- // Display small difficulties as decimals (e.g. 0.5) instead of rounding to 0.
- //
- // We intentionally truncate instead of round so values slightly below 1 don't
- // display as "1" due to formatting.
- prec := max(int(math.Ceil(-math.Log10(d)))+2, 3)
- if prec > 8 {
- prec = 8
- }
- scale := math.Pow10(prec)
- trunc := math.Trunc(d*scale) / scale
- s := strconv.FormatFloat(trunc, 'f', prec, 64)
- s = strings.TrimRight(s, "0")
- s = strings.TrimRight(s, ".")
- if s == "" || s == "0" {
- // Extremely small values may truncate to 0 at our precision cap.
- return strconv.FormatFloat(d, 'g', 3, 64)
- }
- return s
- }
- if d < 1_000_000 {
- return fmt.Sprintf("%.0f", math.Round(d))
- }
- switch {
- case d >= 1_000_000_000_000:
- return fmt.Sprintf("%.1fP", d/1_000_000_000_000.0)
- case d >= 1_000_000_000:
- return fmt.Sprintf("%.1fG", d/1_000_000_000.0)
- default:
- return fmt.Sprintf("%.1fM", d/1_000_000.0)
- }
- },
+ "formatDiff": formatDiffValue,
+ "formatDiffDetail": formatDiffDetailValue,
"formatTime": func(t time.Time) string {
if t.IsZero() {
return "—"
@@ -802,217 +821,72 @@ func buildTemplateFuncs() template.FuncMap {
}
}
-// loadTemplates loads and parses all HTML templates from the specified data directory.
+// loadTemplates loads and parses all embedded HTML templates.
// It returns a fully configured template or an error if any template fails to load or parse.
-func loadTemplates(dataDir string) (*template.Template, error) {
- funcs := buildTemplateFuncs()
-
- // Build template paths
- layoutPath := filepath.Join(dataDir, "templates", "layout.tmpl")
- statusPath := filepath.Join(dataDir, "templates", "overview.tmpl")
- statusBoxesPath := filepath.Join(dataDir, "templates", "status_boxes.tmpl")
- hashrateGraphPath := filepath.Join(dataDir, "templates", "hashrate_graph.tmpl")
- hashrateGraphScriptPath := filepath.Join(dataDir, "templates", "hashrate_graph_script.tmpl")
- serverInfoPath := filepath.Join(dataDir, "templates", "server.tmpl")
- workerLoginPath := filepath.Join(dataDir, "templates", "worker_login.tmpl")
- signInPath := filepath.Join(dataDir, "templates", "sign_in.tmpl")
- savedWorkersPath := filepath.Join(dataDir, "templates", "saved_workers.tmpl")
- workerStatusPath := filepath.Join(dataDir, "templates", "worker_status.tmpl")
- workerWalletSearchPath := filepath.Join(dataDir, "templates", "worker_wallet_search.tmpl")
- nodeInfoPath := filepath.Join(dataDir, "templates", "node.tmpl")
- poolInfoPath := filepath.Join(dataDir, "templates", "pool.tmpl")
- aboutPath := filepath.Join(dataDir, "templates", "about.tmpl")
- helpPath := filepath.Join(dataDir, "templates", "help.tmpl")
- nodeDownPath := filepath.Join(dataDir, "templates", "node_down.tmpl")
- adminPath := filepath.Join(dataDir, "templates", "admin.tmpl")
- adminMinersPath := filepath.Join(dataDir, "templates", "admin_miners.tmpl")
- adminLoginsPath := filepath.Join(dataDir, "templates", "admin_logins.tmpl")
- adminBansPath := filepath.Join(dataDir, "templates", "admin_bans.tmpl")
- adminOperatorPath := filepath.Join(dataDir, "templates", "admin_operator.tmpl")
- adminConfigPath := filepath.Join(dataDir, "templates", "admin_config.tmpl")
- adminLogsPath := filepath.Join(dataDir, "templates", "admin_logs.tmpl")
- errorPath := filepath.Join(dataDir, "templates", "error.tmpl")
-
- // Load template files
- layoutHTML, err := os.ReadFile(layoutPath)
- if err != nil {
- return nil, fmt.Errorf("load layout template: %w", err)
- }
- statusHTML, err := os.ReadFile(statusPath)
- if err != nil {
- return nil, fmt.Errorf("load status template: %w", err)
- }
- statusBoxesHTML, err := os.ReadFile(statusBoxesPath)
- if err != nil {
- return nil, fmt.Errorf("load status boxes template: %w", err)
- }
- hashrateGraphHTML, err := os.ReadFile(hashrateGraphPath)
- if err != nil {
- return nil, fmt.Errorf("load hashrate graph template: %w", err)
- }
- hashrateGraphScriptHTML, err := os.ReadFile(hashrateGraphScriptPath)
- if err != nil {
- return nil, fmt.Errorf("load hashrate graph script template: %w", err)
- }
- serverInfoHTML, err := os.ReadFile(serverInfoPath)
- if err != nil {
- return nil, fmt.Errorf("load server info template: %w", err)
- }
- workerLoginHTML, err := os.ReadFile(workerLoginPath)
- if err != nil {
- return nil, fmt.Errorf("load worker login template: %w", err)
- }
- signInHTML, err := os.ReadFile(signInPath)
- if err != nil {
- return nil, fmt.Errorf("load sign in template: %w", err)
- }
- savedWorkersHTML, err := os.ReadFile(savedWorkersPath)
- if err != nil {
- return nil, fmt.Errorf("load saved workers template: %w", err)
- }
- workerStatusHTML, err := os.ReadFile(workerStatusPath)
- if err != nil {
- return nil, fmt.Errorf("load worker status template: %w", err)
- }
- workerWalletSearchHTML, err := os.ReadFile(workerWalletSearchPath)
- if err != nil {
- return nil, fmt.Errorf("load worker wallet search template: %w", err)
- }
- nodeInfoHTML, err := os.ReadFile(nodeInfoPath)
- if err != nil {
- return nil, fmt.Errorf("load node info template: %w", err)
- }
- poolInfoHTML, err := os.ReadFile(poolInfoPath)
- if err != nil {
- return nil, fmt.Errorf("load pool info template: %w", err)
- }
- aboutHTML, err := os.ReadFile(aboutPath)
- if err != nil {
- return nil, fmt.Errorf("load about template: %w", err)
- }
- helpHTML, err := os.ReadFile(helpPath)
+func loadTemplates() (*template.Template, error) {
+ assets, err := newUIAssetLoader()
if err != nil {
- return nil, fmt.Errorf("load help template: %w", err)
+ return nil, err
}
- nodeDownHTML, err := os.ReadFile(nodeDownPath)
- if err != nil {
- return nil, fmt.Errorf("load node down template: %w", err)
- }
- adminHTML, err := os.ReadFile(adminPath)
- if err != nil {
- return nil, fmt.Errorf("load admin template: %w", err)
- }
- adminMinersHTML, err := os.ReadFile(adminMinersPath)
- if err != nil {
- return nil, fmt.Errorf("load admin miners template: %w", err)
- }
- adminLoginsHTML, err := os.ReadFile(adminLoginsPath)
- if err != nil {
- return nil, fmt.Errorf("load admin logins template: %w", err)
- }
- adminBansHTML, err := os.ReadFile(adminBansPath)
- if err != nil {
- return nil, fmt.Errorf("load admin bans template: %w", err)
- }
- adminOperatorHTML, err := os.ReadFile(adminOperatorPath)
- if err != nil {
- return nil, fmt.Errorf("load admin operator template: %w", err)
- }
- adminConfigHTML, err := os.ReadFile(adminConfigPath)
- if err != nil {
- return nil, fmt.Errorf("load admin config template: %w", err)
- }
- adminLogsHTML, err := os.ReadFile(adminLogsPath)
- if err != nil {
- return nil, fmt.Errorf("load admin logs template: %w", err)
- }
- errorHTML, err := os.ReadFile(errorPath)
- if err != nil {
- return nil, fmt.Errorf("load error template: %w", err)
+ return loadTemplatesFromAssets(assets)
+}
+
+func loadTemplatesFromAssets(assets *uiAssetLoader) (*template.Template, error) {
+ funcs := buildTemplateFuncs()
+
+ templateFiles := []struct {
+ name string
+ path string
+ label string
+ }{
+ {"layout", "layout.tmpl", "layout template"},
+ {"overview", "overview.tmpl", "status template"},
+ {"status_boxes", "status_boxes.tmpl", "status boxes template"},
+ {"hashrate_graph", "hashrate_graph.tmpl", "hashrate graph template"},
+ {"hashrate_graph_script", "hashrate_graph_script.tmpl", "hashrate graph script template"},
+ {"server", "server.tmpl", "server info template"},
+ {"worker_login", "worker_login.tmpl", "worker login template"},
+ {"sign_in", "sign_in.tmpl", "sign in template"},
+ {"saved_workers", "saved_workers.tmpl", "saved workers template"},
+ {"worker_status", "worker_status.tmpl", "worker status template"},
+ {"worker_wallet_search", "worker_wallet_search.tmpl", "worker wallet search template"},
+ {"node", "node.tmpl", "node info template"},
+ {"pool", "pool.tmpl", "pool template"},
+ {"about", "about.tmpl", "about template"},
+ {"help", "help.tmpl", "help template"},
+ {"node_down", "node_down.tmpl", "node down template"},
+ {"admin", "admin.tmpl", "admin template"},
+ {"admin_miners", "admin_miners.tmpl", "admin miners template"},
+ {"admin_logins", "admin_logins.tmpl", "admin logins template"},
+ {"admin_bans", "admin_bans.tmpl", "admin bans template"},
+ {"admin_operator", "admin_operator.tmpl", "admin operator template"},
+ {"admin_config", "admin_config.tmpl", "admin config template"},
+ {"admin_logs", "admin_logs.tmpl", "admin logs template"},
+ {"error", "error.tmpl", "error template"},
}
- // Parse templates
tmpl := template.New("layout").Funcs(funcs)
- if _, err := tmpl.Parse(string(layoutHTML)); err != nil {
- return nil, fmt.Errorf("parse layout template: %w", err)
- }
- if _, err := tmpl.New("overview").Parse(string(statusHTML)); err != nil {
- return nil, fmt.Errorf("parse status template: %w", err)
- }
- if _, err := tmpl.New("status_boxes").Parse(string(statusBoxesHTML)); err != nil {
- return nil, fmt.Errorf("parse status boxes template: %w", err)
- }
- if _, err := tmpl.New("hashrate_graph").Parse(string(hashrateGraphHTML)); err != nil {
- return nil, fmt.Errorf("parse hashrate graph template: %w", err)
- }
- if _, err := tmpl.New("hashrate_graph_script").Parse(string(hashrateGraphScriptHTML)); err != nil {
- return nil, fmt.Errorf("parse hashrate graph script template: %w", err)
- }
- if _, err := tmpl.New("server").Parse(string(serverInfoHTML)); err != nil {
- return nil, fmt.Errorf("parse server info template: %w", err)
- }
- if _, err := tmpl.New("worker_login").Parse(string(workerLoginHTML)); err != nil {
- return nil, fmt.Errorf("parse worker login template: %w", err)
- }
- if _, err := tmpl.New("sign_in").Parse(string(signInHTML)); err != nil {
- return nil, fmt.Errorf("parse sign in template: %w", err)
- }
- if _, err := tmpl.New("saved_workers").Parse(string(savedWorkersHTML)); err != nil {
- return nil, fmt.Errorf("parse saved workers template: %w", err)
- }
- if _, err := tmpl.New("worker_status").Parse(string(workerStatusHTML)); err != nil {
- return nil, fmt.Errorf("parse worker status template: %w", err)
- }
- if _, err := tmpl.New("worker_wallet_search").Parse(string(workerWalletSearchHTML)); err != nil {
- return nil, fmt.Errorf("parse worker wallet search template: %w", err)
- }
- if _, err := tmpl.New("node").Parse(string(nodeInfoHTML)); err != nil {
- return nil, fmt.Errorf("parse node info template: %w", err)
- }
- if _, err := tmpl.New("pool").Parse(string(poolInfoHTML)); err != nil {
- return nil, fmt.Errorf("parse pool template: %w", err)
- }
- if _, err := tmpl.New("about").Parse(string(aboutHTML)); err != nil {
- return nil, fmt.Errorf("parse about template: %w", err)
- }
- if _, err := tmpl.New("help").Parse(string(helpHTML)); err != nil {
- return nil, fmt.Errorf("parse help template: %w", err)
- }
- if _, err := tmpl.New("node_down").Parse(string(nodeDownHTML)); err != nil {
- return nil, fmt.Errorf("parse node down template: %w", err)
- }
- if _, err := tmpl.New("admin").Parse(string(adminHTML)); err != nil {
- return nil, fmt.Errorf("parse admin template: %w", err)
- }
- if _, err := tmpl.New("admin_miners").Parse(string(adminMinersHTML)); err != nil {
- return nil, fmt.Errorf("parse admin miners template: %w", err)
- }
- if _, err := tmpl.New("admin_logins").Parse(string(adminLoginsHTML)); err != nil {
- return nil, fmt.Errorf("parse admin logins template: %w", err)
- }
- if _, err := tmpl.New("admin_bans").Parse(string(adminBansHTML)); err != nil {
- return nil, fmt.Errorf("parse admin bans template: %w", err)
- }
- if _, err := tmpl.New("admin_operator").Parse(string(adminOperatorHTML)); err != nil {
- return nil, fmt.Errorf("parse admin operator template: %w", err)
- }
- if _, err := tmpl.New("admin_config").Parse(string(adminConfigHTML)); err != nil {
- return nil, fmt.Errorf("parse admin config template: %w", err)
- }
- if _, err := tmpl.New("admin_logs").Parse(string(adminLogsHTML)); err != nil {
- return nil, fmt.Errorf("parse admin logs template: %w", err)
- }
- if _, err := tmpl.New("error").Parse(string(errorHTML)); err != nil {
- return nil, fmt.Errorf("parse error template: %w", err)
+ for _, item := range templateFiles {
+ payload, err := assets.readTemplate(item.path)
+ if err != nil {
+ return nil, fmt.Errorf("load %s: %w", item.label, err)
+ }
+ if item.name == "layout" {
+ if _, err := tmpl.Parse(string(payload)); err != nil {
+ return nil, fmt.Errorf("parse %s: %w", item.label, err)
+ }
+ continue
+ }
+ if _, err := tmpl.New(item.name).Parse(string(payload)); err != nil {
+ return nil, fmt.Errorf("parse %s: %w", item.label, err)
+ }
}
return tmpl, nil
}
func NewStatusServer(ctx context.Context, jobMgr *JobManager, metrics *PoolMetrics, registry *MinerRegistry, workerRegistry *workerConnectionRegistry, accounting *AccountStore, rpc *RPCClient, cfg Config, start time.Time, clerk *ClerkVerifier, workerLists *workerListStore, configPath, adminConfigPath string, shutdown func()) *StatusServer {
- // Load HTML templates from data_dir/templates so operators can customize the
- // UI without recompiling. These are treated as required assets.
- tmpl, err := loadTemplates(cfg.DataDir)
+ tmpl, err := loadTemplates()
if err != nil {
fatal("load templates", err)
}
@@ -1068,15 +942,13 @@ func (s *StatusServer) executeTemplate(w io.Writer, name string, data any) error
return tmpl.ExecuteTemplate(w, name, data)
}
-// ReloadTemplates reloads all HTML templates from disk. This allows operators
-// to update templates without restarting the pool server. It's designed to be
-// called in response to SIGUSR1 or other reload triggers.
+// ReloadTemplates reloads the embedded HTML templates and clears cached pages.
func (s *StatusServer) ReloadTemplates() error {
if s == nil {
return fmt.Errorf("status server is nil")
}
- tmpl, err := loadTemplates(s.Config().DataDir)
+ tmpl, err := loadTemplates()
if err != nil {
return err
}
@@ -1086,7 +958,7 @@ func (s *StatusServer) ReloadTemplates() error {
s.tmpl = tmpl
s.tmplMu.Unlock()
s.clearPageCache()
- logger.Info("templates reloaded successfully")
+ logger.Info("embedded templates reloaded successfully")
return nil
}
diff --git a/status_sysinfo.go b/status_sysinfo.go
index 9885089..10f3326 100644
--- a/status_sysinfo.go
+++ b/status_sysinfo.go
@@ -7,7 +7,6 @@ import (
"io"
"net/http"
"os"
- "path/filepath"
"sort"
"strconv"
"strings"
@@ -503,37 +502,21 @@ func (s *StatusServer) renderErrorPage(w http.ResponseWriter, r *http.Request, s
}
// handleStaticFile returns an http.HandlerFunc that serves a static HTML file
-// from the www directory. This is used for legal pages like privacy.html and terms.html.
+// from embedded static assets. This is used for legal pages like privacy.html and terms.html.
func (s *StatusServer) handleStaticFile(filename string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.Header().Set("Allow", "GET, HEAD")
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
setShortHTMLCacheHeaders(w, false)
if s != nil && s.staticFiles != nil {
- cleanPath := filepath.Clean(filename)
- if s.staticFiles.ServeCached(w, r, cleanPath) {
+ if s.staticFiles.ServePath(w, r, filename) {
return
}
}
- cfg := s.Config()
- wwwDir := filepath.Join(cfg.DataDir, "www")
- filePath := filepath.Join(wwwDir, filename)
-
- // Security check: ensure the resolved path is within wwwDir
- cleanPath := filepath.Clean(filePath)
- wwwDirClean := filepath.Clean(wwwDir)
- if !strings.HasPrefix(cleanPath, wwwDirClean) {
- http.Error(w, "Invalid file path", http.StatusBadRequest)
- return
- }
-
- // Check if file exists
- info, err := os.Stat(cleanPath)
- if err != nil || info.IsDir() {
- http.NotFound(w, r)
- return
- }
-
- // Serve the file
- http.ServeFile(w, r, cleanPath)
+ http.NotFound(w, r)
}
}
diff --git a/status_template_funcs_test.go b/status_template_funcs_test.go
index 75e5c21..4cbf496 100644
--- a/status_template_funcs_test.go
+++ b/status_template_funcs_test.go
@@ -11,10 +11,17 @@ func TestFormatDiff_SmallValues(t *testing.T) {
}{
{0, "0"},
{0.5, "0.5"},
+ {0.25, "0.25"},
+ {0.125, "0.125"},
+ {0.0625, "0.0625"},
{0.01, "0.01"},
{0.0001234, "0.000123"},
{0.0000009, "0.0000009"},
{1, "1"},
+ {1_000_000, "1.0M"},
+ {1_000_000_000, "1.0G"},
+ {1_000_000_000_000, "1.0T"},
+ {1_000_000_000_000_000, "1.0P"},
}
for _, tc := range tests {
@@ -23,3 +30,25 @@ func TestFormatDiff_SmallValues(t *testing.T) {
}
}
}
+
+func TestFormatDiffDetail_TrimsFixedDecimals(t *testing.T) {
+ f := buildTemplateFuncs()["formatDiffDetail"].(func(float64) string)
+
+ tests := []struct {
+ in float64
+ want string
+ }{
+ {0, "0"},
+ {0.5, "0.5"},
+ {0.25, "0.25"},
+ {1, "1"},
+ {1.25, "1.25"},
+ {256.00000001, "256.00000001"},
+ }
+
+ for _, tc := range tests {
+ if got := f(tc.in); got != tc.want {
+ t.Fatalf("formatDiffDetail(%v) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
diff --git a/status_templates_load_test.go b/status_templates_load_test.go
index 9ce78ef..344238e 100644
--- a/status_templates_load_test.go
+++ b/status_templates_load_test.go
@@ -1,11 +1,48 @@
package main
-import "testing"
+import (
+ "strings"
+ "testing"
+)
func TestLoadTemplates_Parse(t *testing.T) {
t.Parallel()
- if _, err := loadTemplates("data"); err != nil {
- t.Fatalf("loadTemplates(data) error: %v", err)
+ if _, err := loadTemplates(); err != nil {
+ t.Fatalf("loadTemplates error: %v", err)
+ }
+}
+
+func TestOverviewLiveStatusScriptHasLocalDifficultyFormatter(t *testing.T) {
+ t.Parallel()
+
+ assets, err := newUIAssetLoader()
+ if err != nil {
+ t.Fatalf("newUIAssetLoader error: %v", err)
+ }
+ payload, err := assets.readTemplate("overview.tmpl")
+ if err != nil {
+ t.Fatalf("read overview template: %v", err)
+ }
+ html := string(payload)
+ marker := "const blockDifficultyEl = document.getElementById('status-block-difficulty');"
+ idx := strings.Index(html, marker)
+ if idx < 0 {
+ t.Fatalf("overview live status script marker not found")
+ }
+ statusScript := html[idx:]
+ end := strings.Index(statusScript, "function formatDuration(seconds)")
+ if end < 0 {
+ t.Fatalf("overview live status difficulty formatter boundary not found")
+ }
+ formatterBlock := statusScript[:end]
+ if strings.Contains(formatterBlock, "formatDiff(") {
+ t.Fatalf("live status formatter must not depend on formatDiff from another script closure")
+ }
+ if !strings.Contains(formatterBlock, "function formatSmallDifficulty(value)") {
+ t.Fatalf("live status formatter is missing small difficulty formatting")
+ }
+ if !strings.Contains(formatterBlock, "1000000000000000") || !strings.Contains(formatterBlock, "1000000000000") {
+ t.Fatalf("live status formatter is missing large difficulty units")
}
}
diff --git a/status_workers_worker_helpers_test.go b/status_workers_worker_helpers_test.go
index c12dc8d..3e8add8 100644
--- a/status_workers_worker_helpers_test.go
+++ b/status_workers_worker_helpers_test.go
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"encoding/hex"
+ "strings"
"testing"
)
@@ -77,3 +78,29 @@ func TestBuildCurrentJobCoinbaseDetail_UsesExactSinglePayoutPath(t *testing.T) {
t.Fatalf("coinbase mismatch between display path and miner build path")
}
}
+
+func TestBuildCurrentJobCoinbaseDetail_UsesLatestStratumNotifyID(t *testing.T) {
+ mc, _ := minerConnForNotifyTest(t)
+ job := benchmarkSubmitJobForTest(t)
+
+ mc.sendNotifyFor(job, true)
+
+ mc.jobMu.Lock()
+ if _, ok := mc.jobNotifyCoinbase[job.JobID]; ok {
+ t.Fatalf("expected notify coinbase to be keyed by emitted stratum job id, not template job id")
+ }
+ parts, ok := mc.jobNotifyCoinbase[mc.lastJobID]
+ mc.jobMu.Unlock()
+ if !ok {
+ t.Fatalf("missing notify coinbase for last stratum job id")
+ }
+
+ detail := mc.buildCurrentJobCoinbaseDetail(job)
+ if detail == nil || detail.Coinbase == "" {
+ t.Fatalf("buildCurrentJobCoinbaseDetail failed for latest stratum job id")
+ }
+ expected := parts.coinb1 + hex.EncodeToString(mc.extranonce1) + strings.Repeat("00", job.Extranonce2Size) + parts.coinb2
+ if detail.Coinbase != expected {
+ t.Fatalf("coinbase detail mismatch\n got: %s\nwant: %s", detail.Coinbase, expected)
+ }
+}
diff --git a/stratum_fastpath_bench_test.go b/stratum_fastpath_bench_test.go
deleted file mode 100644
index f87eb90..0000000
--- a/stratum_fastpath_bench_test.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package main
-
-import (
- "net"
- "testing"
- "time"
-)
-
-type benchDiscardConn struct{}
-
-func (benchDiscardConn) Read([]byte) (int, error) { return 0, nil }
-func (benchDiscardConn) Write(b []byte) (int, error) { return len(b), nil }
-func (benchDiscardConn) Close() error { return nil }
-func (benchDiscardConn) LocalAddr() net.Addr { return &net.IPAddr{} }
-func (benchDiscardConn) RemoteAddr() net.Addr { return &net.IPAddr{} }
-func (benchDiscardConn) SetDeadline(time.Time) error { return nil }
-func (benchDiscardConn) SetReadDeadline(time.Time) error { return nil }
-func (benchDiscardConn) SetWriteDeadline(time.Time) error { return nil }
-
-func benchmarkEncodeMinerConn(fastEncode bool, ckpool bool) *MinerConn {
- return &MinerConn{
- id: "bench-encode",
- conn: benchDiscardConn{},
- cfg: Config{
- StratumFastEncodeEnabled: fastEncode,
- CKPoolEmulate: ckpool,
- },
- }
-}
-
-func BenchmarkStratumEncodeTrueResponse_Normal(b *testing.B) {
- mc := benchmarkEncodeMinerConn(false, true)
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- mc.writeTrueResponse(1)
- }
-}
-
-func BenchmarkStratumEncodeTrueResponse_Fast(b *testing.B) {
- mc := benchmarkEncodeMinerConn(true, true)
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- mc.writeTrueResponse(1)
- }
-}
-
-func BenchmarkStratumEncodePongResponse_Normal(b *testing.B) {
- mc := benchmarkEncodeMinerConn(false, true)
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- mc.writePongResponse(7)
- }
-}
-
-func BenchmarkStratumEncodePongResponse_Fast(b *testing.B) {
- mc := benchmarkEncodeMinerConn(true, true)
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- mc.writePongResponse(7)
- }
-}
-
-func BenchmarkStratumEncodeSubscribeResponse_CKPool_Normal(b *testing.B) {
- mc := benchmarkEncodeMinerConn(false, true)
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- mc.writeSubscribeResponse(2, "01020304", 4, "sid")
- }
-}
-
-func BenchmarkStratumEncodeSubscribeResponse_CKPool_Fast(b *testing.B) {
- mc := benchmarkEncodeMinerConn(true, true)
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- mc.writeSubscribeResponse(2, "01020304", 4, "sid")
- }
-}
-
-func BenchmarkStratumEncodeSubscribeResponse_Expanded_Normal(b *testing.B) {
- mc := benchmarkEncodeMinerConn(false, false)
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- mc.writeSubscribeResponse(2, "01020304", 4, "sid")
- }
-}
-
-func BenchmarkStratumEncodeSubscribeResponse_Expanded_Fast(b *testing.B) {
- mc := benchmarkEncodeMinerConn(true, false)
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- mc.writeSubscribeResponse(2, "01020304", 4, "sid")
- }
-}
diff --git a/stratum_fastpath_compat_test.go b/stratum_fastpath_compat_test.go
deleted file mode 100644
index 729e1a5..0000000
--- a/stratum_fastpath_compat_test.go
+++ /dev/null
@@ -1,193 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "reflect"
- "testing"
-)
-
-func decodeJSONLine(t *testing.T, s string) any {
- t.Helper()
- var v any
- if err := json.Unmarshal([]byte(s), &v); err != nil {
- t.Fatalf("json unmarshal failed: %v; input=%q", err, s)
- }
- return v
-}
-
-func captureFastVsNormal(t *testing.T, fn func(mc *MinerConn)) (fast any, normal any) {
- t.Helper()
- fastConn := &writeRecorderConn{}
- fastMC := &MinerConn{conn: fastConn, cfg: Config{StratumFastEncodeEnabled: true}}
- fn(fastMC)
-
- normalConn := &writeRecorderConn{}
- normalMC := &MinerConn{conn: normalConn, cfg: Config{StratumFastEncodeEnabled: false}}
- fn(normalMC)
-
- return decodeJSONLine(t, fastConn.String()), decodeJSONLine(t, normalConn.String())
-}
-
-func TestFastEncodeSimpleResponsesMatchNormal(t *testing.T) {
- cases := []struct {
- name string
- fn func(mc *MinerConn)
- }{
- {name: "true_int_id", fn: func(mc *MinerConn) { mc.writeTrueResponse(1) }},
- {name: "true_string_id", fn: func(mc *MinerConn) { mc.writeTrueResponse("abc") }},
- {name: "true_null_id", fn: func(mc *MinerConn) { mc.writeTrueResponse(nil) }},
- {name: "pong", fn: func(mc *MinerConn) { mc.writePongResponse(7) }},
- {name: "empty_slice", fn: func(mc *MinerConn) { mc.writeEmptySliceResponse(9) }},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- fast, normal := captureFastVsNormal(t, tc.fn)
- if !reflect.DeepEqual(fast, normal) {
- t.Fatalf("fast response != normal response\nfast=%#v\nnormal=%#v", fast, normal)
- }
- })
- }
-}
-
-func TestFastEncodeSubscribeResponseMatchesNormal(t *testing.T) {
- cases := []struct {
- name string
- ckpool bool
- subID string
- extranonce1 string
- extranonce2n int
- id any
- }{
- {name: "default", ckpool: false, subID: "sid", extranonce1: "abcdef01", extranonce2n: 4, id: 1},
- {name: "ckpool", ckpool: true, subID: "1", extranonce1: "00", extranonce2n: 8, id: "sub-1"},
- {name: "empty_subid_defaults", ckpool: false, subID: "", extranonce1: "00", extranonce2n: 4, id: nil},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- fastConn := &writeRecorderConn{}
- fastMC := &MinerConn{conn: fastConn, cfg: Config{StratumFastEncodeEnabled: true, CKPoolEmulate: tc.ckpool}}
- fastMC.writeSubscribeResponse(tc.id, tc.extranonce1, tc.extranonce2n, tc.subID)
-
- normalConn := &writeRecorderConn{}
- normalMC := &MinerConn{conn: normalConn, cfg: Config{StratumFastEncodeEnabled: false, CKPoolEmulate: tc.ckpool}}
- normalMC.writeSubscribeResponse(tc.id, tc.extranonce1, tc.extranonce2n, tc.subID)
-
- fast := decodeJSONLine(t, fastConn.String())
- normal := decodeJSONLine(t, normalConn.String())
- if !reflect.DeepEqual(fast, normal) {
- t.Fatalf("fast subscribe response != normal\nfast=%#v\nnormal=%#v", fast, normal)
- }
- })
- }
-}
-
-func TestFastEncodeRawIDResponsesMatchNormal(t *testing.T) {
- rawIDs := [][]byte{
- []byte(`1`),
- []byte(`"abc"`),
- []byte(`null`),
- }
- for _, rawID := range rawIDs {
- t.Run(string(rawID), func(t *testing.T) {
- fastConn := &writeRecorderConn{}
- fastMC := &MinerConn{conn: fastConn, cfg: Config{StratumFastEncodeEnabled: true}}
- fastMC.writeTrueResponseRawID(rawID)
-
- normalConn := &writeRecorderConn{}
- normalMC := &MinerConn{conn: normalConn, cfg: Config{StratumFastEncodeEnabled: false}}
- normalMC.writeTrueResponseRawID(rawID)
-
- fast := decodeJSONLine(t, fastConn.String())
- normal := decodeJSONLine(t, normalConn.String())
- if !reflect.DeepEqual(fast, normal) {
- t.Fatalf("fast raw-id response != normal\nfast=%#v\nnormal=%#v", fast, normal)
- }
- })
- }
-}
-
-func TestSniffStringParamsSupportsEscapes(t *testing.T) {
- line := []byte(`{"id":1,"method":"mining.authorize","params":["worker\u0031","p\"w"]}`)
- params, ok := sniffStratumStringParams(line, 2)
- if !ok {
- t.Fatalf("expected fast string param sniff to succeed")
- }
- if len(params) != 2 || params[0] != "worker1" || params[1] != `p"w` {
- t.Fatalf("unexpected params: %#v", params)
- }
-}
-
-func TestSniffSubmitParamsFallsBackOnEscapes(t *testing.T) {
- line := []byte(`{"id":1,"method":"mining.submit","params":["worker\u0031","1","00000001","5f5e1000","00000001"]}`)
- _, _, _, _, _, _, _, ok := sniffStratumSubmitParamsBytes(line)
- if ok {
- t.Fatalf("expected fast submit sniff to fall back when escapes are present")
- }
-}
-
-func TestSniffMethodKeepsIDForUnknownMethod(t *testing.T) {
- line := []byte(`{"id":"abc-1","method":"mining.unknown_ext","params":[]}`)
- method, idRaw, ok := sniffStratumMethodIDTagRawID(line)
- if !ok {
- t.Fatalf("expected sniff to return id even for unknown method")
- }
- if method != stratumMethodUnknown {
- t.Fatalf("expected unknown method tag, got %v", method)
- }
- if string(idRaw) != `"abc-1"` {
- t.Fatalf("unexpected raw id: %q", idRaw)
- }
-}
-
-func TestSniffMethodIDUsesTopLevelIDWhenFieldsReordered(t *testing.T) {
- line := []byte(`{"params":["id"],"method":"mining.ping","id":1}`)
- method, idRaw, ok := sniffStratumMethodIDTagRawID(line)
- if !ok {
- t.Fatalf("expected sniff ok")
- }
- if method != stratumMethodMiningPing {
- t.Fatalf("expected mining.ping, got %v", method)
- }
- if string(idRaw) != "1" {
- t.Fatalf("expected top-level id raw=1, got %q", idRaw)
- }
-}
-
-func TestSniffStringParamsUsesTopLevelParamsKey(t *testing.T) {
- line := []byte(`{"id":1,"meta":{"params":["bad"]},"method":"mining.authorize","params":["worker","pass"]}`)
- params, ok := sniffStratumStringParams(line, 2)
- if !ok {
- t.Fatalf("expected sniff ok")
- }
- if len(params) != 2 || params[0] != "worker" || params[1] != "pass" {
- t.Fatalf("unexpected params: %#v", params)
- }
-}
-
-func TestSniffSubmitParamsUsesTopLevelParamsKey(t *testing.T) {
- line := []byte(`{"id":1,"meta":{"params":["bad"]},"method":"mining.submit","params":["worker","1","00000001","5f5e1000","00000001"]}`)
- worker, jobID, en2, ntime, nonce, ver, haveVer, ok := sniffStratumSubmitParamsBytes(line)
- if !ok {
- t.Fatalf("expected submit sniff ok")
- }
- if haveVer || ver != nil {
- t.Fatalf("unexpected version field: have=%v ver=%q", haveVer, ver)
- }
- if string(worker) != "worker" || string(jobID) != "1" || string(en2) != "00000001" || string(ntime) != "5f5e1000" || string(nonce) != "00000001" {
- t.Fatalf("unexpected submit fields: worker=%q job=%q en2=%q ntime=%q nonce=%q", worker, jobID, en2, ntime, nonce)
- }
-}
-
-func TestSniffSubmitParamsWithVersionField(t *testing.T) {
- line := []byte(`{"id":1,"method":"mining.submit","params":["worker","1","00000001","5f5e1000","00000001","20000000"]}`)
- worker, jobID, en2, ntime, nonce, ver, haveVer, ok := sniffStratumSubmitParamsBytes(line)
- if !ok {
- t.Fatalf("expected submit sniff ok with version field")
- }
- if !haveVer || ver == nil {
- t.Fatalf("expected version field to be present")
- }
- if string(worker) != "worker" || string(jobID) != "1" || string(en2) != "00000001" || string(ntime) != "5f5e1000" || string(nonce) != "00000001" || string(ver) != "20000000" {
- t.Fatalf("unexpected submit fields/version: worker=%q job=%q en2=%q ntime=%q nonce=%q ver=%q", worker, jobID, en2, ntime, nonce, ver)
- }
-}
diff --git a/stratum_fastpath_e2e_parity_test.go b/stratum_fastpath_e2e_parity_test.go
deleted file mode 100644
index 1c4d91d..0000000
--- a/stratum_fastpath_e2e_parity_test.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package main
-
-import (
- "bufio"
- "context"
- "encoding/json"
- "io"
- "net"
- "testing"
- "time"
-)
-
-type fastpathParityRunConfig struct {
- fastDecode bool
- fastEncode bool
- setup func(mc *MinerConn)
-}
-
-func runMinerConnSingleRequestJSON(t *testing.T, cfg fastpathParityRunConfig, reqLine string) any {
- t.Helper()
-
- server, client := net.Pipe()
- defer client.Close()
-
- mc := &MinerConn{
- id: "fastpath-parity",
- ctx: context.Background(),
- conn: server,
- reader: bufio.NewReader(server),
- jobMgr: &JobManager{},
- cfg: Config{ConnectionTimeout: time.Hour, StratumFastDecodeEnabled: cfg.fastDecode, StratumFastEncodeEnabled: cfg.fastEncode},
- lastActivity: time.Now(),
- }
- if cfg.setup != nil {
- cfg.setup(mc)
- }
-
- done := make(chan struct{})
- go func() {
- mc.handle()
- close(done)
- }()
-
- if _, err := io.WriteString(client, reqLine+"\n"); err != nil {
- t.Fatalf("write request: %v", err)
- }
-
- br := bufio.NewReader(client)
- line, err := br.ReadString('\n')
- if err != nil {
- t.Fatalf("read response: %v", err)
- }
-
- var out any
- if err := json.Unmarshal([]byte(line), &out); err != nil {
- t.Fatalf("unmarshal response: %v; line=%q", err, line)
- }
-
- _ = client.Close()
- select {
- case <-done:
- case <-time.After(1 * time.Second):
- t.Fatalf("miner conn did not exit")
- }
- return out
-}
-
-func assertFastpathParityMatrix(t *testing.T, reqLine string, setup func(mc *MinerConn)) {
- t.Helper()
-
- base := runMinerConnSingleRequestJSON(t, fastpathParityRunConfig{
- fastDecode: false,
- fastEncode: false,
- setup: setup,
- }, reqLine)
-
- variants := []fastpathParityRunConfig{
- {fastDecode: true, fastEncode: false, setup: setup},
- {fastDecode: false, fastEncode: true, setup: setup},
- {fastDecode: true, fastEncode: true, setup: setup},
- }
- for _, v := range variants {
- got := runMinerConnSingleRequestJSON(t, v, reqLine)
- if !jsonDeepEqual(base, got) {
- t.Fatalf("fastpath parity mismatch decode=%v encode=%v\nbase=%#v\ngot=%#v", v.fastDecode, v.fastEncode, base, got)
- }
- }
-}
-
-func jsonDeepEqual(a, b any) bool {
- ab, _ := json.Marshal(a)
- bb, _ := json.Marshal(b)
- return string(ab) == string(bb)
-}
-
-func TestStratumFastpathEndToEndParity_Ping(t *testing.T) {
- assertFastpathParityMatrix(t, `{"id":7,"method":"mining.ping","params":[]}`, nil)
-}
-
-func TestStratumFastpathEndToEndParity_Authorize(t *testing.T) {
- req := `{"id":1,"method":"mining.authorize","params":["1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa.worker",""]}`
- assertFastpathParityMatrix(t, req, nil)
-}
-
-func TestStratumFastpathEndToEndParity_AuthorizeEscapedFallback(t *testing.T) {
- req := `{"id":"abc","method":"mining.authorize","params":["1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa.worker\u0031","p\"w"]}`
- assertFastpathParityMatrix(t, req, nil)
-}
-
-func TestStratumFastpathEndToEndParity_Subscribe(t *testing.T) {
- req := `{"id":"sub-1","method":"mining.subscribe","params":["cgminer/4.11.1","resume-fixed-session"]}`
- assertFastpathParityMatrix(t, req, func(mc *MinerConn) {
- mc.extranonce1Hex = "01020304"
- mc.extranonce1 = []byte{1, 2, 3, 4}
- mc.cfg.Extranonce2Size = 4
- })
-}
-
-func TestStratumFastpathEndToEndParity_UnknownMethod(t *testing.T) {
- req := `{"id":"abc-1","method":"mining.unknown_ext","params":[]}`
- assertFastpathParityMatrix(t, req, nil)
-}
-
-func TestStratumFastpathEndToEndParity_InvalidJSONParseError(t *testing.T) {
- // Keep top-level id/method sniffable, but break the params array so the
- // authorize fast-path cannot extract both string params and must fall back.
- req := `{"id":1,"method":"mining.authorize","params":["worker","x`
- assertFastpathParityMatrix(t, req, nil)
-}
-
-func TestStratumFastpathEndToEndParity_SubmitInvalidParams(t *testing.T) {
- req := `{"id":1,"method":"mining.submit","params":["worker"]}`
- assertFastpathParityMatrix(t, req, nil)
-}
-
-func TestStratumFastpathEndToEndParity_SubmitEscapedFallbackInvalidParams(t *testing.T) {
- req := `{"id":1,"method":"mining.submit","params":["worker\u0031"]}`
- assertFastpathParityMatrix(t, req, nil)
-}
diff --git a/stratum_fastpath_fuzz_test.go b/stratum_fastpath_fuzz_test.go
deleted file mode 100644
index 4437e32..0000000
--- a/stratum_fastpath_fuzz_test.go
+++ /dev/null
@@ -1,229 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "reflect"
- "strings"
- "testing"
-)
-
-func FuzzStratumFastEncodeSimpleResponsesParity(f *testing.F) {
- f.Add(int64(1), "abc", true)
- f.Add(int64(0), "", false)
-
- f.Fuzz(func(t *testing.T, idNum int64, idStr string, useStringID bool) {
- var id any = idNum
- if useStringID {
- id = idStr
- }
-
- cases := []func(mc *MinerConn){
- func(mc *MinerConn) { mc.writeTrueResponse(id) },
- func(mc *MinerConn) { mc.writePongResponse(id) },
- func(mc *MinerConn) { mc.writeEmptySliceResponse(id) },
- }
-
- for _, fn := range cases {
- fast, normal := captureFastVsNormal(t, fn)
- if !reflect.DeepEqual(fast, normal) {
- t.Fatalf("fast encode mismatch\nfast=%#v\nnormal=%#v", fast, normal)
- }
- }
- })
-}
-
-func FuzzStratumFastEncodeSubscribeParity(f *testing.F) {
- f.Add(int64(1), "sid", "01020304", 4, false, false)
- f.Add(int64(0), "", "00", 8, true, true)
-
- f.Fuzz(func(t *testing.T, idNum int64, subID, extranonce1 string, extranonce2n int, ckpool, nullID bool) {
- if extranonce2n < 0 {
- extranonce2n = -extranonce2n
- }
- if extranonce2n > 64 {
- extranonce2n = 64
- }
- if strings.TrimSpace(extranonce1) == "" {
- extranonce1 = "00"
- }
- if len(extranonce1)%2 != 0 {
- extranonce1 += "0"
- }
-
- var id any = idNum
- if nullID {
- id = nil
- }
-
- fastConn := &writeRecorderConn{}
- fastMC := &MinerConn{conn: fastConn, cfg: Config{StratumFastEncodeEnabled: true, CKPoolEmulate: ckpool}}
- fastMC.writeSubscribeResponse(id, extranonce1, extranonce2n, subID)
-
- normalConn := &writeRecorderConn{}
- normalMC := &MinerConn{conn: normalConn, cfg: Config{StratumFastEncodeEnabled: false, CKPoolEmulate: ckpool}}
- normalMC.writeSubscribeResponse(id, extranonce1, extranonce2n, subID)
-
- fast := decodeJSONLine(t, fastConn.String())
- normal := decodeJSONLine(t, normalConn.String())
- if !reflect.DeepEqual(fast, normal) {
- t.Fatalf("fast subscribe encode mismatch\nfast=%#v\nnormal=%#v", fast, normal)
- }
- })
-}
-
-func FuzzStratumSniffMethodAndIDParity(f *testing.F) {
- f.Add(int64(1), "mining.authorize")
- f.Add(int64(7), "mining.submit")
- f.Add(int64(0), "mining.unknown_ext")
-
- f.Fuzz(func(t *testing.T, id int64, method string) {
- req := map[string]any{
- "id": id,
- "meta": map[string]any{"id": "nested"},
- "method": method,
- "params": []any{},
- }
- line, err := json.Marshal(req)
- if err != nil {
- t.Fatalf("marshal request: %v", err)
- }
-
- tag, idRaw, ok := sniffStratumMethodIDTagRawID(line)
- if !ok {
- t.Fatalf("expected sniff ok for valid request: %q", string(line))
- }
-
- idVal, _, ok := parseJSONValue(idRaw, 0)
- if !ok {
- t.Fatalf("parse raw id failed: %q", string(idRaw))
- }
- switch got := idVal.(type) {
- case int:
- if int64(got) != id {
- t.Fatalf("sniffed id mismatch: got=%#v want=%d", idVal, id)
- }
- case int64:
- if got != id {
- t.Fatalf("sniffed id mismatch: got=%#v want=%d", idVal, id)
- }
- case float64:
- if int64(got) != id {
- t.Fatalf("sniffed id mismatch: got=%#v want=%d", idVal, id)
- }
- default:
- t.Fatalf("unexpected sniffed id type %T (%#v)", idVal, idVal)
- }
-
- switch method {
- case "mining.ping":
- if tag != stratumMethodMiningPing {
- t.Fatalf("expected mining.ping tag, got %v", tag)
- }
- case "mining.authorize", "mining.auth":
- if tag != stratumMethodMiningAuthorize {
- t.Fatalf("expected authorize tag, got %v", tag)
- }
- case "mining.subscribe":
- if tag != stratumMethodMiningSubscribe {
- t.Fatalf("expected subscribe tag, got %v", tag)
- }
- case "mining.submit":
- if tag != stratumMethodMiningSubmit {
- t.Fatalf("expected submit tag, got %v", tag)
- }
- default:
- if tag != stratumMethodUnknown {
- t.Fatalf("expected unknown tag, got %v", tag)
- }
- }
- })
-}
-
-func FuzzStratumSniffStringParamsParity(f *testing.F) {
- f.Add("worker", "pass")
- f.Add("worker1", `p"w`)
-
- f.Fuzz(func(t *testing.T, a, b string) {
- req := map[string]any{
- "id": 1,
- "method": "mining.authorize",
- "meta": map[string]any{"params": []any{"bad"}},
- "params": []any{a, b},
- }
- line, err := json.Marshal(req)
- if err != nil {
- t.Fatalf("marshal request: %v", err)
- }
-
- got, ok := sniffStratumStringParams(line, 2)
- if !ok {
- t.Fatalf("expected sniff ok for authorize params: %q", string(line))
- }
- if len(got) != 2 || got[0] != a || got[1] != b {
- t.Fatalf("sniffed params mismatch: got=%#v want=[%q %q]", got, a, b)
- }
- })
-}
-
-func FuzzStratumSniffSubmitParamsParityOrFallback(f *testing.F) {
- f.Add("worker", "1", "00000001", "5f5e1000", "00000001", "", false)
- f.Add("worker1", "job", "00000001", "5f5e1000", "00000001", "20000000", true)
-
- f.Fuzz(func(t *testing.T, worker, jobID, en2, ntime, nonce, version string, includeVersion bool) {
- params := []any{worker, jobID, en2, ntime, nonce}
- if includeVersion {
- params = append(params, version)
- }
-
- req := map[string]any{
- "id": 1,
- "method": "mining.submit",
- "meta": map[string]any{"params": []any{"bad"}},
- "params": params,
- }
- line, err := json.Marshal(req)
- if err != nil {
- t.Fatalf("marshal request: %v", err)
- }
-
- gotWorker, gotJobID, gotEN2, gotNTime, gotNonce, gotVer, haveVer, ok := sniffStratumSubmitParamsBytes(line)
-
- // Submit fast-sniff intentionally falls back on any escaped JSON string.
- needsEscape := anyJSONStringNeedsEscape(worker, jobID, en2, ntime, nonce)
- if includeVersion {
- needsEscape = needsEscape || anyJSONStringNeedsEscape(version)
- }
-
- if needsEscape {
- if ok {
- t.Fatalf("expected submit sniff fallback on escaped strings: %q", string(line))
- }
- return
- }
-
- if !ok {
- t.Fatalf("expected submit sniff ok: %q", string(line))
- }
- if string(gotWorker) != worker || string(gotJobID) != jobID || string(gotEN2) != en2 || string(gotNTime) != ntime || string(gotNonce) != nonce {
- t.Fatalf("submit sniff mismatch worker/job/en2/ntime/nonce")
- }
- if includeVersion {
- if !haveVer || string(gotVer) != version {
- t.Fatalf("submit sniff version mismatch have=%v got=%q want=%q", haveVer, string(gotVer), version)
- }
- } else if haveVer || gotVer != nil {
- t.Fatalf("unexpected version output have=%v ver=%q", haveVer, string(gotVer))
- }
- })
-}
-
-func anyJSONStringNeedsEscape(vals ...string) bool {
- for _, s := range vals {
- b, _ := json.Marshal(s)
- if strings.ContainsRune(string(b), '\\') {
- // JSON string always has surrounding quotes; a backslash means escape.
- return true
- }
- }
- return false
-}
diff --git a/stratum_sniff.go b/stratum_sniff.go
index 6123cd6..a61bbc3 100644
--- a/stratum_sniff.go
+++ b/stratum_sniff.go
@@ -181,67 +181,6 @@ func sniffStratumStringParams(data []byte, limit int) ([]string, bool) {
return nil, false
}
-func sniffStratumSubmitParamsBytes(data []byte) (worker, jobID, extranonce2, ntime, nonce, version []byte, haveVersion bool, ok bool) {
- // mining.submit params are typically 5 or 6 JSON strings:
- // [worker_name, job_id, extranonce2, ntime, nonce, (optional) version]
- start, ok := findTopLevelObjectKeyValueStart(data, stratumKeyParamsBytes)
- if !ok {
- return nil, nil, nil, nil, nil, nil, false, false
- }
- if start >= len(data) || data[start] != '[' {
- return nil, nil, nil, nil, nil, nil, false, false
- }
-
- i := start + 1
- var fields [6][]byte
- n := 0
- for i < len(data) {
- i = skipSpaces(data, i)
- if i >= len(data) {
- return nil, nil, nil, nil, nil, nil, false, false
- }
- switch data[i] {
- case ']':
- if n == 5 {
- return fields[0], fields[1], fields[2], fields[3], fields[4], nil, false, true
- }
- if n == 6 {
- return fields[0], fields[1], fields[2], fields[3], fields[4], fields[5], true, true
- }
- return nil, nil, nil, nil, nil, nil, false, false
- case ',':
- i++
- continue
- case '"':
- if n >= len(fields) {
- return nil, nil, nil, nil, nil, nil, false, false
- }
- j := i + 1
- for j < len(data) {
- if data[j] == '\\' {
- // Escapes require unquoting; fall back to the full decoder path.
- return nil, nil, nil, nil, nil, nil, false, false
- }
- if data[j] == '"' {
- break
- }
- j++
- }
- if j >= len(data) {
- return nil, nil, nil, nil, nil, nil, false, false
- }
- fields[n] = data[i+1 : j]
- n++
- i = j + 1
- default:
- return nil, nil, nil, nil, nil, nil, false, false
- }
- }
-
- // Parse didn't terminate cleanly.
- return nil, nil, nil, nil, nil, nil, false, false
-}
-
func skipSpaces(data []byte, idx int) int {
for idx < len(data) {
switch data[idx] {
diff --git a/submission_worker_pool.go b/submission_worker_pool.go
index 4c7dfe4..b83ef09 100644
--- a/submission_worker_pool.go
+++ b/submission_worker_pool.go
@@ -31,24 +31,25 @@ func ensureSubmissionWorkerPool() {
}
type submissionTask struct {
- mc *MinerConn
- reqID any
- job *Job
- jobID string
- workerName string
- extranonce2 string
- extranonce2Len uint16
- extranonce2Bytes [32]byte
- extranonce2Large []byte
- ntime string
- ntimeVal uint32
- nonce string
- nonceVal uint32
- versionHex string
- useVersion uint32
- scriptTime int64
- policyReject submitPolicyReject
- receivedAt time.Time
+ mc *MinerConn
+ reqID any
+ job *Job
+ jobID string
+ workerName string
+ extranonce2 string
+ extranonce2Len uint16
+ extranonce2Bytes [32]byte
+ extranonce2Large []byte
+ ntime string
+ ntimeVal uint32
+ nonce string
+ nonceVal uint32
+ versionHex string
+ useVersion uint32
+ scriptTime int64
+ assignedDifficulty float64
+ policyReject submitPolicyReject
+ receivedAt time.Time
}
func (t *submissionTask) extranonce2Decoded() []byte {
diff --git a/submit_timing_test.go b/submit_timing_test.go
index 81b100f..89f5433 100644
--- a/submit_timing_test.go
+++ b/submit_timing_test.go
@@ -98,7 +98,7 @@ func TestHandleBlockShareSubmitLatency(t *testing.T) {
req := &StratumRequest{ID: 1}
trpc.start = time.Now()
- mc.handleBlockShare(req.ID, job, workerName, en2, ntimeHex, nonceHex, useVersion, "dummyhash", 1.0, now)
+ mc.handleBlockShare(req.ID, job, job.JobID, workerName, en2, ntimeHex, nonceHex, useVersion, job.ScriptTime, "dummyhash", 1.0, now)
if trpc.method != "submitblock" {
t.Fatalf("expected submitblock RPC, got %q", trpc.method)