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 @@
Reject/ban suggested difficulties outside configured min/max bounds. Applies on live apply.
-
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 tag
    -
    --
    -
    -
    -
    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 tag
    +
    --
    +
    +
    +
    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
    -
    --
    -
    */}} -
    -
    Block height
    -
    --
    -
    -
    -
    BTC price
    -
    --
    +
    NETWORK
    +
    +
    +
    Network hashrate
    +
    --
    +
    +
    +
    Network difficulty
    +
    --
    +
    +
    +
    Next diff change
    +
    --
    +
    +
    +
    Block height
    +
    --
    +
    +
    +
    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)