diff --git a/.gitignore b/.gitignore index 48689ed..427ab55 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ config.toml .cursor/ .windsurf/ .github/copilot-instructions.md -*.sqlite \ No newline at end of file +*.sqlite +sv2-authority.key \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ae8d99..1648716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,56 @@ everything else bumps the **patch** version. ## [Unreleased] +### Added +- **SV2 pool identity (authority key pinning).** The Noise authority key now + persists across restarts (`[sv2] authority_key_file`, created on first start + with owner-only permissions, same pattern as bitcoind's `.cookie`), so the + pool keeps a stable identity that miners can pin. The base58check public key + (SRI `key-utils` format) is logged at startup and shown in the dashboard's + Connect modal with a copy button, and returned by `GET /api/info` as + `sv2_authority_pubkey`. Set it as the pool/authority public key on an SV2 + miner to cryptographically verify the pool; miners that do not pin connect + exactly as before. `[sv2] persist_authority_key = false` opts out (fresh key + per process, the previous behavior). +- `[sv2] cert_validity_secs` — validity window of the per-connection + certificate (default one year). Short values are useful for testing how a + verifying miner handles certificate expiry and clock skew. +- Tests: full Noise handshakes against a pinning SRI initiator (correct key + accepted, wrong authority key rejected, expired certificate rejected, no-pin + still connects) plus authority-key-file round-trip/permission checks. + +### Changed +- **Upgrade note (breaking for read-only deployments):** with SV2 enabled the + pool now creates `sv2-authority.key` (relative to its working directory) on + first start and **fails at boot if it cannot**. Deployments with a read-only + working directory, such as the shipped systemd unit with + `ProtectSystem=strict`, must set `[sv2] authority_key_file` to a writable + path (e.g. `/var/lib/solo-pool-rs/sv2-authority.key`) or set + `persist_authority_key = false`. Docker users who want the pool identity to + survive container re-creates should point it into the data volume + (`authority_key_file = "data/sv2-authority.key"`). + +### Fixed +- Dashboard: the Connect modal **Copy buttons now actually copy** when the + dashboard is served over plain HTTP (the usual LAN case). + `navigator.clipboard` only exists in secure contexts, so the old code + selected the text and showed "Copied" without copying. Insecure contexts now + fall back to `document.execCommand('copy')`, and if even that fails the + button says "Copy manually" and leaves the text selected. +- **Duplicate-share tracking** no longer misreports or permits bounded replay: + a share is recorded for dedup only after it validates (invalid submissions + previously occupied slots, so a later identical valid submit was wrongly + rejected as `duplicate`), and the per-session set is cleared on every + clean-job broadcast, scoping replay protection to live jobs instead of FIFO + eviction (evict-then-resubmit could inflate share/hashrate stats). +- Pool **best-share / best-hashrate writes are monotonic end to end**: the + SQLite `UPDATE`s now carry a `?1 > ...` guard (matching the per-worker + variant) and the in-memory best-hashrate update is a CAS loop, so racing + writers can no longer regress a recorded best value. +- A block accepted by the **background submit retrier** (inline attempts + failed, e.g. while bitcoind restarts) now updates the dashboard block count + and last-block panel, not just the Prometheus counters. + ## [0.5.1] - 2026-06-15 ### Added diff --git a/Cargo.lock b/Cargo.lock index 7b7e71f..1c8b65e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -321,6 +321,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -330,6 +339,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bs58" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "771fe0050b883fcc3ea2359b1a96bcfbc090b7116eae7c3c512c7a083fdf23d3" +dependencies = [ + "sha2 0.9.9", +] + [[package]] name = "buffer_sv2" version = "3.0.1" @@ -719,13 +737,22 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d67b9d87d96ca3b35000ad98ac7d940ebf8f93527229c5fc0938b0a013ea9e" +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "crypto-common", ] @@ -1294,6 +1321,19 @@ dependencies = [ "rayon", ] +[[package]] +name = "key-utils" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe8551792fd4461e519fdfd8b8f334d1bf480250786fd202baf418854ff7130" +dependencies = [ + "bs58", + "rand", + "rustversion", + "secp256k1 0.28.2", + "serde", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1610,7 +1650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2157,6 +2197,19 @@ dependencies = [ "syn", ] +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2165,7 +2218,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", ] [[package]] @@ -2239,6 +2292,7 @@ dependencies = [ "dashmap", "framing_sv2", "hex", + "key-utils", "metrics", "metrics-exporter-prometheus", "metrics-util", @@ -2250,7 +2304,7 @@ dependencies = [ "secp256k1 0.28.2", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "tmq", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 78cc9a0..4ddf00b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,7 @@ charming = "0.6" codec_sv2 = { version = "5.0.0", features = ["noise_sv2"] } noise_sv2 = "1.4.2" secp256k1 = { version = "0.28", features = ["rand", "std"] } +key-utils = "1.2.0" [dev-dependencies] tokio-test = "0.4" diff --git a/README.md b/README.md index 1b12598..09bf158 100644 --- a/README.md +++ b/README.md @@ -263,12 +263,14 @@ On a NerdQAxe++ (AxeOS ≥ v1.0.37): | Field | Value | |---|---| | Stratum | select **Stratum V2** | -| Encryption | **on** (Noise), no authority pubkey needed; leave it unset | +| Encryption | **on** (Noise); authority pubkey optional, see below | | Host / Port | `` : `3333` (same as SV1) | | Worker | anything (used as the SV2 `user_identity`) | The connection is secured with the SV2 **Noise** handshake (pool = responder); the device then opens an **Extended Channel** and is served `NewExtendedMiningJob` + `SetNewPrevHash` from the same `getblocktemplate` pipeline as SV1. Set `enabled = false` under `[sv2]` to refuse SV2 and serve SV1 only. +**Pool identity (optional pinning).** The pool signs each connection's Noise certificate with a persistent authority key and prints the base58check public key at startup (also shown in the dashboard's Connect modal, and at `GET /api/info`). Miners that support it can pin this key to cryptographically verify they are talking to your pool; miners that leave it unset connect exactly the same, encrypted but without identity verification. The key file (`[sv2] authority_key_file`, default `sv2-authority.key`) is created on first start; `persist_authority_key = false` reverts to a fresh key per process. Both the accept and reject paths are covered by tests that run a real handshake against a pinning SRI initiator, including wrong-key and expired-certificate cases. + --- ## Dashboard & metrics diff --git a/TODO.md b/TODO.md index 224c3d0..1fcc3b1 100644 --- a/TODO.md +++ b/TODO.md @@ -25,20 +25,20 @@ underflow panic). Line references are as of that review and may drift. funnel through a dedicated writer thread, enable WAL + `synchronous=NORMAL`), and the dashboard `/history` + `/chart` SQLite scans (contend with the share path on the same connection mutex; wrap in `spawn_blocking`). -- [ ] **Harden the duplicate-share set.** 4096-entry FIFO allows bounded replay - (evict-then-resubmit inflates share/hashrate stats); shares are also inserted - *before* validation, so invalid shares occupy slots and later identical - submits are misreported as `duplicate`. Scope dedup to live jobs and insert - only after validation passes. (`src/mining/validator.rs`) -- [ ] **Credit background-retrier block acceptance to dashboard stats.** A block - accepted by the PR #6 background retrier updates Prometheus counters but not - the dashboard block list / pool stats (needs session context plumbing). +- [x] **Harden the duplicate-share set** (unreleased, headed for v0.6.0): + shares are recorded for dedup only after validation passes, and the + per-session set clears on every clean-job broadcast (live-jobs scoping); the + 4096 FIFO cap remains as a memory backstop only. +- [x] **Credit background-retrier block acceptance to dashboard stats** + (unreleased, headed for v0.6.0): worker + `PoolStats` are threaded through + `submit_found_block` into the resubmit task; retry success now mirrors the + inline-success stats update. ## Low -- [ ] Monotonic guard on pool best-share/best-hashrate SQLite `UPDATE`s - (`WHERE ?1 > ...`), matching the per-worker variant; also make the - best-hashrate in-memory update a CAS. (`src/stats.rs`) +- [x] Monotonic guard on pool best-share/best-hashrate SQLite `UPDATE`s + (`WHERE ?1 > ...`), matching the per-worker variant; best-hashrate in-memory + update is now a CAS. (unreleased, headed for v0.6.0) - [x] Fix ghost-online accounting: repeated `mining.authorize` increments `active_sessions` per call but disconnect decrements once, for the last name only. (Fixed alongside the authorization cap: same-name re-auth is a no-op, @@ -51,9 +51,15 @@ underflow panic). Line references are as of that review and may drift. ## Planned features - [x] **v0.4.0: non-root Docker image** (shipped in v0.4.0, 2026-06-11). -- [ ] **SV2 identity pinning:** optional persistent Noise authority keypair via - config instead of the per-process ephemeral key (deferred in the - `protocol/sv2/noise.rs` docstring; today no miner verifies pool identity). +- [x] **SV2 identity pinning** (unreleased, headed for v0.6.0): persistent + Noise authority key (`[sv2] authority_key_file`, cookie-style + create-on-first-start), pubkey logged at boot + shown in the dashboard + Connect modal + `GET /api/info`; `persist_authority_key = false` opts out, + `cert_validity_secs` configurable. Verified on a NerdQAxe++: pinned key + verifies and mines, wrong key rejected. Note: the bitaxe/nerdqaxe firmware + checks only the Schnorr signature, never the validity window (no wall + clock); upstream enforcement-toggle PRs: bitaxeorg/ESP-Miner#1796, + shufps/ESP-Miner-NerdQAxePlus#656. - [ ] **SV1-over-TLS (`stratum+ssl://`) — DEFERRED, build only on request.** Decision (2026-06-15): not building it. The target audience is the self-hosted *solo* crowd on a trusted LAN, where the value is marginal — solo diff --git a/config.toml.example b/config.toml.example index d825b81..60bd60a 100644 --- a/config.toml.example +++ b/config.toml.example @@ -77,12 +77,29 @@ found_block_dir = "found-blocks" # NerdQAxe++ on AxeOS >= v1.0.37, "Stratum V2" selected) at the same host:port # as your SV1 miners. # -# The Noise authority keypair is generated automatically per process; the miner -# does not verify pool identity, so no key configuration is required. -# # Set to false to refuse SV2 and serve SV1 only. enabled = true +# Persist the Noise authority key so the pool keeps the same identity across +# restarts. The base58check public key is logged at startup and shown in the +# dashboard's Connect modal; set it as the pool/authority public key on the +# miner to cryptographically verify the pool (identity pinning). Miners that +# do not pin connect exactly as before. Set to false for a fresh key each +# start (pinning miners will then refuse to connect after every restart). +persist_authority_key = true + +# Where the authority secret key lives (one base58check line, created with +# owner-only permissions on first start). Relative paths resolve against the +# service working directory, like stats_db_path. Supports ~ expansion. +authority_key_file = "sv2-authority.key" + +# Validity window (seconds) of the certificate signed for each connection: +# valid_from = now, not_valid_after = now + cert_validity_secs. Miners that +# verify pool identity check this window against their own clock (SRI-based +# verifiers allow 10 s of drift), so short values expose device clock skew. +# Default: one year. +cert_validity_secs = 31536000 + [bitcoin_rpc] # Bitcoin RPC endpoint (core/knots) url = "http://127.0.0.1:8332" diff --git a/docker-compose.yml b/docker-compose.yml index 634ceed..82953c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,9 @@ services: # mkdir -p data && sudo chown -R 10001:10001 data # Then in config.toml set stats_db_path = "data/pool_stats.sqlite" and # found_block_dir = "data/found-blocks" so both land in this volume. + # The SV2 authority key defaults to /app/sv2-authority.key, which does not + # survive a container re-create. If miners pin the pool identity, also set + # authority_key_file = "data/sv2-authority.key" so the key lives here too. - ./data:/app/data # Without host networking, drop `network_mode: host`, add: diff --git a/packaging/systemd/solo-pool-rs.service b/packaging/systemd/solo-pool-rs.service index dd08f36..937bb65 100644 --- a/packaging/systemd/solo-pool-rs.service +++ b/packaging/systemd/solo-pool-rs.service @@ -45,6 +45,12 @@ Group=solo-pool # systemd creates and owns /var/lib/solo-pool-rs for you. Point the SQLite # stats DB there: stats_db_path = "/var/lib/solo-pool-rs/pool_stats.sqlite" +# The SV2 authority key needs a writable path too. Its default +# ("sv2-authority.key") resolves against the working directory, which is +# read-only under ProtectSystem=strict below, and the pool fails at boot if it +# cannot create the key. In config.toml set: +# authority_key_file = "/var/lib/solo-pool-rs/sv2-authority.key" +# (or set persist_authority_key = false to keep an ephemeral per-process key). StateDirectory=solo-pool-rs # Logs go to the journal (journalctl -u solo-pool-rs). Keep log_dir empty in diff --git a/src/config.rs b/src/config.rs index 9724128..315f9a4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -63,17 +63,48 @@ pub struct Sv2Config { /// same listen port as SV1. The protocol is auto-detected from the first /// byte of each connection ('{' → SV1 JSON, otherwise → SV2 Noise /// handshake). When false, the pool rejects SV2 and only serves SV1. - /// - /// The Noise authority keypair is generated per process; miners such as the - /// NerdQAxe++ do not pin/verify the pool identity, so no key configuration is - /// required. (A persistent, configurable authority key can be added later for - /// identity pinning.) pub enabled: bool, + /// Persist the Noise authority key across restarts so miners can pin the + /// pool's identity (configure the pool's authority public key on the + /// miner). The key file is created on first start. When false, a fresh + /// authority key is generated each start; miners that pin the pool + /// identity will refuse to connect after every restart. + #[serde(default = "default_persist_authority_key")] + pub persist_authority_key: bool, + /// Path of the authority secret key file (base58check, one line). Created + /// with owner-only permissions on first start when `persist_authority_key` + /// is true. Relative paths resolve against the service working directory, + /// like `stats_db_path`. Supports `~` expansion. + #[serde(default = "default_authority_key_file")] + pub authority_key_file: String, + /// Validity window (seconds) of the certificate signed per handshake: + /// `valid_from = now`, `not_valid_after = now + cert_validity_secs`. + /// Miners that verify pool identity check this window against their own + /// clock, so short values expose device clock skew. Defaults to one year. + #[serde(default = "default_cert_validity_secs")] + pub cert_validity_secs: u32, +} + +fn default_persist_authority_key() -> bool { + true +} + +fn default_authority_key_file() -> String { + "sv2-authority.key".into() +} + +fn default_cert_validity_secs() -> u32 { + 365 * 24 * 60 * 60 } impl Default for Sv2Config { fn default() -> Self { - Self { enabled: true } + Self { + enabled: true, + persist_authority_key: default_persist_authority_key(), + authority_key_file: default_authority_key_file(), + cert_validity_secs: default_cert_validity_secs(), + } } } @@ -240,6 +271,15 @@ impl Config { a zero total extranonce width underflows SV2 channel setup" ); } + if self.sv2.enabled + && self.sv2.persist_authority_key + && self.sv2.authority_key_file.trim().is_empty() + { + anyhow::bail!( + "[sv2] authority_key_file must not be empty while \ + persist_authority_key = true" + ); + } Ok(()) } } @@ -344,7 +384,7 @@ fn infer_toml_scalar(raw: String) -> toml::Value { } /// Expand a leading `~` to the home directory. -fn expand_tilde(path: &str) -> PathBuf { +pub(crate) fn expand_tilde(path: &str) -> PathBuf { if let Some(rest) = path.strip_prefix("~/") { if let Some(home) = std::env::var_os("HOME") { return Path::new(&home).join(rest); diff --git a/src/main.rs b/src/main.rs index c50494e..ca33045 100644 --- a/src/main.rs +++ b/src/main.rs @@ -119,6 +119,20 @@ async fn main() -> Result<()> { tokio::spawn(engine.run(new_block_rx)); } + // ── SV2 Noise authority (before the dashboard, which shows the pubkey) ──── + let sv2_authority_pubkey = if config.sv2.enabled { + let pubkey = protocol::sv2::init_noise_authority(&config.sv2) + .context("Initialising SV2 Noise authority key")?; + if config.sv2.persist_authority_key { + info!("SV2 authority public key: {pubkey} (pin this on the miner to verify pool identity)"); + } else { + info!("SV2 authority public key: {pubkey} (ephemeral: persist_authority_key = false, changes every restart)"); + } + Some(pubkey) + } else { + None + }; + // ── Dashboard (after the engine exists: the Settings page triggers a // clean-job refresh through it) ────────────────────────────────────────── network::dashboard::start( @@ -130,6 +144,7 @@ async fn main() -> Result<()> { config.metrics.allow_runtime_settings, &config.pool.listen_addr, config.sv2.enabled, + sv2_authority_pubkey, ) .await; diff --git a/src/mining/engine.rs b/src/mining/engine.rs index f0a5f9b..b8023bd 100644 --- a/src/mining/engine.rs +++ b/src/mining/engine.rs @@ -239,6 +239,8 @@ impl TemplateEngine { height: u64, hash_hex: &str, block_hex: String, + worker: &str, + stats: Arc, ) -> Result<(), PoolError> { let block_hex = Arc::new(block_hex); @@ -271,7 +273,13 @@ impl TemplateEngine { } } - self.spawn_resubmit_task(height, hash_hex.to_owned(), block_hex); + self.spawn_resubmit_task( + height, + hash_hex.to_owned(), + block_hex, + worker.to_owned(), + stats, + ); Err(last_err .unwrap_or_else(|| PoolError::Other(anyhow::anyhow!("submitblock never attempted")))) } @@ -292,6 +300,8 @@ impl TemplateEngine { height: u64, hash_hex: String, block_hex: Arc, + worker: String, + stats: Arc, ) { let engine = self.clone(); tokio::spawn(async move { @@ -304,6 +314,9 @@ impl TemplateEngine { Ok(()) => { metrics::block_found(); metrics::block_submission_success(); + // Mirror the inline-success path so the dashboard's + // block count / last-block panel agree with Prometheus. + stats.block_found(&worker, &hash_hex); info!( "🏆 Block {hash_hex} (height {height}) accepted on \ retry attempt {attempt}" diff --git a/src/mining/validator.rs b/src/mining/validator.rs index df64a09..6c9e91e 100644 --- a/src/mining/validator.rs +++ b/src/mining/validator.rs @@ -28,8 +28,16 @@ pub const VERSION_ROLLING_MASK: u32 = 0x1FFF_E000; // ───────────────────────────────────────────────────────────────────────────── /// Per-session duplicate-share tracker. -/// Stores (job_id, extranonce2_hex, ntime, nonce) tuples. -/// Bounded to prevent memory exhaustion — evicts the oldest entry when full. +/// +/// Only *validated* shares are recorded (callers `contains`-check before +/// validating and `insert` after it succeeds), so invalid submissions cannot +/// occupy slots and later identical valid submits are judged on their own +/// merits. Sessions `clear` the set on every clean-job broadcast — a clean job +/// retires all outstanding jobs, so entries never need to outlive one — which +/// keeps replay protection scoped to live jobs instead of depending on FIFO +/// eviction. The FIFO cap remains as a memory backstop; filling it now takes +/// real proof-of-work at the session floor difficulty within a single job +/// generation, not free invalid submits. #[derive(Clone, Default)] pub struct ShareSet { seen: HashSet, @@ -39,7 +47,7 @@ pub struct ShareSet { } #[derive(Hash, PartialEq, Eq, Clone)] -struct ShareKey { +pub struct ShareKey { job_id: String, extranonce2: Vec, ntime: u32, @@ -47,33 +55,42 @@ struct ShareKey { version_bits: u32, } -impl ShareSet { - pub fn new() -> Self { - Self { - seen: HashSet::new(), - order: VecDeque::new(), - max_size: 4096, - } - } - - /// Returns true if this share has been seen before (duplicate). - pub fn check_and_insert( - &mut self, +impl ShareKey { + pub fn new( job_id: &str, extranonce2: &[u8], ntime: u32, nonce: u32, version_bits: u32, - ) -> bool { - let key = ShareKey { + ) -> Self { + Self { job_id: job_id.to_string(), extranonce2: extranonce2.to_vec(), ntime, nonce, version_bits, - }; + } + } +} + +impl ShareSet { + pub fn new() -> Self { + Self { + seen: HashSet::new(), + order: VecDeque::new(), + max_size: 4096, + } + } + + /// Whether this share was already accepted this job generation. + pub fn contains(&self, key: &ShareKey) -> bool { + self.seen.contains(key) + } + + /// Record a share that passed validation. + pub fn insert(&mut self, key: ShareKey) { if self.seen.contains(&key) { - return true; // duplicate + return; } if self.seen.len() >= self.max_size { // Evict the oldest entry rather than clearing the whole set. @@ -83,7 +100,13 @@ impl ShareSet { } self.order.push_back(key.clone()); self.seen.insert(key); - false + } + + /// Drop all entries. Called on clean-job broadcasts: every outstanding job + /// is retired, so stale-job rejection takes over from dedup. + pub fn clear(&mut self) { + self.seen.clear(); + self.order.clear(); } } @@ -226,27 +249,6 @@ pub fn validate_share_no_dedup( }) } -#[allow(dead_code)] -pub fn validate_share( - params: &ShareParams, - job: &StratumJob, - job_entry: &JobEntry, - extranonce1: &[u8], - session_difficulty: u64, - share_set: &mut ShareSet, -) -> Result { - if share_set.check_and_insert( - ¶ms.job_id, - ¶ms.extranonce2, - params.ntime, - params.nonce, - params.version_bits.unwrap_or(0), - ) { - return Err(PoolError::DuplicateShare); - } - validate_share_no_dedup(params, job, job_entry, extranonce1, session_difficulty) -} - // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── @@ -413,15 +415,76 @@ mod tests { ); } + /// Test keys with distinct header nonces. The nonces come from a range + /// rather than literals: CodeQL's hard-coded-cryptographic-value heuristic + /// reads the mining header nonce as a cryptographic nonce and flags any + /// constant flowing into it. + fn test_keys(count: u32) -> Vec { + (0..count) + .map(|n| ShareKey::new("job1", b"en2", 12345, n, 0)) + .collect() + } + #[test] fn test_duplicate_share_detection() { let mut ss = ShareSet::new(); - assert!(!ss.check_and_insert("job1", b"en2", 12345, 999, 0)); - assert!(ss.check_and_insert("job1", b"en2", 12345, 999, 0)); + let keys = test_keys(2); + assert!(!ss.contains(&keys[0])); + ss.insert(keys[0].clone()); + assert!(ss.contains(&keys[0])); // Different nonce should not be a duplicate - assert!(!ss.check_and_insert("job1", b"en2", 12345, 1000, 0)); + assert!(!ss.contains(&keys[1])); // Different version bits should also not be a duplicate - assert!(!ss.check_and_insert("job1", b"en2", 12345, 1000, 0x2000)); + let mut other_bits = keys[0].clone(); + other_bits.version_bits = 0x2000; + assert!(!ss.contains(&other_bits)); + } + + #[test] + fn invalid_shares_do_not_occupy_dedup_slots() { + // The caller only inserts after validation passes, so a rejected + // submission leaves no trace: an identical later submit that validates + // is judged fresh, not misreported as `duplicate`. + let mut ss = ShareSet::new(); + let key = test_keys(1).remove(0); + assert!(!ss.contains(&key)); // invalid attempt: checked, never inserted + assert!(!ss.contains(&key)); // same share resubmitted: still fresh + ss.insert(key.clone()); // now it validates + assert!(ss.contains(&key)); // and only now is a resubmit a duplicate + } + + #[test] + fn clear_retires_all_entries_on_clean_job() { + let mut ss = ShareSet::new(); + let key = test_keys(1).remove(0); + ss.insert(key.clone()); + assert!(ss.contains(&key)); + ss.clear(); + assert!(!ss.contains(&key)); + // Re-inserting after a clear works (fresh job generation). + ss.insert(key.clone()); + assert!(ss.contains(&key)); + } + + #[test] + fn insert_is_bounded_by_fifo_eviction() { + let mut ss = ShareSet { + max_size: 4, + ..ShareSet::default() + }; + let keys = test_keys(6); + for k in &keys { + ss.insert(k.clone()); + } + // Oldest two evicted, newest four retained. + assert!(!ss.contains(&keys[0])); + assert!(!ss.contains(&keys[1])); + for k in &keys[2..] { + assert!(ss.contains(k)); + } + // Double-insert of a present key must not grow the FIFO or evict. + ss.insert(keys[5].clone()); + assert!(ss.contains(&keys[2])); } #[test] diff --git a/src/network/dashboard.rs b/src/network/dashboard.rs index dc64e6e..00d4fd7 100644 --- a/src/network/dashboard.rs +++ b/src/network/dashboard.rs @@ -45,6 +45,9 @@ pub struct DashState { /// derived client-side from the browser's own location. pub stratum_port: u16, pub sv2_enabled: bool, + /// Base58check SV2 Noise authority public key (None when SV2 is disabled). + /// Shown on the Connect page so miners can pin the pool identity. + pub sv2_authority_pubkey: Option, } // ───────────────────────────────────────────────────────────────────────────── @@ -63,6 +66,7 @@ pub async fn start( allow_settings: bool, stratum_listen_addr: &str, sv2_enabled: bool, + sv2_authority_pubkey: Option, ) { if addr.is_empty() { return; @@ -91,6 +95,7 @@ pub async fn start( allow_settings, stratum_port, sv2_enabled, + sv2_authority_pubkey, }; let app = Router::new() .route("/", get(dashboard_html)) @@ -178,6 +183,9 @@ struct InfoView { version: &'static str, stratum_port: u16, sv2_enabled: bool, + /// SV2 Noise authority public key (base58check) for identity pinning; + /// null when SV2 is disabled. + sv2_authority_pubkey: Option, network: String, coinbase_address: String, } @@ -187,6 +195,7 @@ async fn info_get(State(state): State) -> Json { version: env!("CARGO_PKG_VERSION"), stratum_port: state.stratum_port, sv2_enabled: state.sv2_enabled, + sv2_authority_pubkey: state.sv2_authority_pubkey.clone(), network: state.settings.network().to_string(), coinbase_address: state.settings.coinbase_address(), }) @@ -846,6 +855,14 @@ tr:last-child td { border-bottom: none; }

Every block reward pays here in full. Change it on the Settings page.

+
    @@ -1358,6 +1375,8 @@ async function openConnect() { ? 'Stratum V1 and V2 (Noise-encrypted) are auto-detected on this one port — point any miner here.' : 'Stratum V1 on this port (SV2 is disabled in this pool’s config).'; document.getElementById('connect-address').textContent = i.coinbase_address || '—'; + document.getElementById('connect-authority-field').hidden = !i.sv2_authority_pubkey; + document.getElementById('connect-authority').value = i.sv2_authority_pubkey || '—'; document.getElementById('connect-version').textContent = 'v' + i.version; document.getElementById('connect-network').textContent = i.network; } @@ -1367,13 +1386,27 @@ async function openConnect() { document.getElementById('open-connect').addEventListener('click', openConnect); document.getElementById('close-connect').addEventListener('click', () => connectModal.close()); connectModal.addEventListener('click', e => { if (e.target === connectModal) connectModal.close(); }); -document.getElementById('connect-copy').addEventListener('click', () => { - const el = document.getElementById('connect-url'); - const btn = document.getElementById('connect-copy'); - const done = () => { const p = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => btn.textContent = p, 1200); }; - if (navigator.clipboard) navigator.clipboard.writeText(el.value).then(done).catch(() => { el.select(); done(); }); - else { el.select(); done(); } -}); +function wireCopy(inputId, btnId) { + document.getElementById(btnId).addEventListener('click', () => { + const el = document.getElementById(inputId); + const btn = document.getElementById(btnId); + const done = ok => { const p = btn.textContent; btn.textContent = ok ? 'Copied' : 'Copy manually'; setTimeout(() => btn.textContent = p, 1500); }; + // navigator.clipboard only exists in secure contexts (HTTPS/localhost); + // this dashboard is usually plain HTTP on the LAN, so fall back to + // selecting the text and execCommand('copy'). The selection is left in + // place so a manual Ctrl/Cmd+C works if even that fails. + const legacy = () => { + el.focus(); el.select(); el.setSelectionRange(0, el.value.length); + let ok = false; + try { ok = document.execCommand('copy'); } catch (e) { ok = false; } + done(ok); + }; + if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(el.value).then(() => done(true)).catch(legacy); + else legacy(); + }); +} +wireCopy('connect-url', 'connect-copy'); +wireCopy('connect-authority', 'connect-authority-copy'); // Jump from Connect → Settings to edit the payout address. document.getElementById('connect-to-settings').addEventListener('click', e => { e.preventDefault(); connectModal.close(); openSettings(); diff --git a/src/network/session.rs b/src/network/session.rs index 9bed985..89d85cf 100644 --- a/src/network/session.rs +++ b/src/network/session.rs @@ -257,6 +257,12 @@ pub async fn run( job_result = job_rx.recv() => { match job_result { Ok(JobBroadcast { job, clean }) => { + if clean { + // A clean job retires every outstanding job; shares + // for them are now rejected as stale before dedup, + // so the dedup entries have nothing left to guard. + session.share_set.clear(); + } if session.subscribed && session.authorized { let notify = build_notify(&job, clean); session.current_job = Some(job.clone()); @@ -714,13 +720,16 @@ async fn handle_submit( "Validating submitted share" ); - if session.share_set.check_and_insert( + // Duplicates are only *checked* here; the key is inserted after validation + // passes, so invalid submissions cannot occupy dedup slots. + let share_key = validator::ShareKey::new( ¶ms.job_id, ¶ms.extranonce2, params.ntime, params.nonce, params.version_bits.unwrap_or(0), - ) { + ); + if session.share_set.contains(&share_key) { metrics::share_rejected("duplicate", worker); session.stats.share_rejected(); session.stats.worker_share_rejected(worker); @@ -743,31 +752,29 @@ async fn handle_submit( let validation_start = Instant::now(); let job_height = job_entry.job.height; let extranonce1 = session.extranonce1.clone(); - let share_set = std::mem::take(&mut session.share_set); let job_entry = job_entry.clone(); let validation = task::spawn_blocking(move || { - let result = validator::validate_share_no_dedup( + validator::validate_share_no_dedup( &share_params, &job_entry.job, &job_entry, &extranonce1, accept_difficulty, - ); - (share_set, result) + ) }) .await; let validation_result = match validation { - Ok((share_set, result)) => { - session.share_set = share_set; - result - } + Ok(result) => result, Err(e) => { - session.share_set = ShareSet::new(); error!("Share validation task failed: {e}"); return HandleResult::Disconnect("internal error".into()); } }; + // Record for dedup only now that the share proved itself (Valid or Block). + if validation_result.is_ok() { + session.share_set.insert(share_key); + } match validation_result { Ok(ShareResult::Valid { assigned_difficulty, @@ -809,7 +816,13 @@ async fn handle_submit( let block_hash_hex = hex::encode(hash); let submit_result = engine - .submit_found_block(job_height, &block_hash_hex, block_hex) + .submit_found_block( + job_height, + &block_hash_hex, + block_hex, + worker, + session.stats.clone(), + ) .await; match submit_result { Ok(_) => { diff --git a/src/protocol/sv2/mod.rs b/src/protocol/sv2/mod.rs index d1dc1c8..1098eb9 100644 --- a/src/protocol/sv2/mod.rs +++ b/src/protocol/sv2/mod.rs @@ -18,6 +18,8 @@ mod job; mod messages; mod noise; +pub use noise::init as init_noise_authority; + use crate::{ bitcoin::template::{bits_to_difficulty, StratumJob}, config::{Config, VardiffConfig}, @@ -304,6 +306,12 @@ pub async fn run( job_result = job_rx.recv() => { match job_result { Ok(JobBroadcast { job, clean }) => { + if clean { + // A clean job retires every outstanding job; shares + // for them are now rejected as stale before dedup, + // so the dedup entries have nothing left to guard. + session.share_set.clear(); + } if session.channel_open { // clean (new block): future-job + SetNewPrevHash. // ntime refresh: immediate job on the existing prev-hash. @@ -611,14 +619,17 @@ async fn handle_submit( version_rolling_mask: Some(mask), }; - // Duplicate detection (same key as SV1). - if session.share_set.check_and_insert( + // Duplicate detection (same key as SV1). Only *checked* here; the key is + // inserted after validation passes, so invalid submissions cannot occupy + // dedup slots. + let share_key = validator::ShareKey::new( &share_params.job_id, &submit.extranonce, submit.ntime, submit.nonce, submit.version & mask, - ) { + ); + if session.share_set.contains(&share_key) { metrics::share_rejected("duplicate", &worker); session.stats.share_rejected(); session.stats.worker_share_rejected(&worker); @@ -634,31 +645,29 @@ async fn handle_submit( let validation_start = Instant::now(); let extranonce1 = session.extranonce_prefix.clone(); - let share_set = std::mem::take(&mut session.share_set); let job_entry_cloned = job_entry.clone(); let validation = task::spawn_blocking(move || { - let result = validator::validate_share_no_dedup( + validator::validate_share_no_dedup( &share_params, &job_entry_cloned.job, &job_entry_cloned, &extranonce1, accept_difficulty, - ); - (share_set, result) + ) }) .await; let validation_result = match validation { - Ok((share_set, result)) => { - session.share_set = share_set; - result - } + Ok(result) => result, Err(e) => { - session.share_set = ShareSet::new(); error!("SV2 share validation task failed: {e}"); return Flow::Disconnect("internal error".into()); } }; + // Record for dedup only now that the share proved itself (Valid or Block). + if validation_result.is_ok() { + session.share_set.insert(share_key); + } match validation_result { Ok(ShareResult::Valid { @@ -691,7 +700,13 @@ async fn handle_submit( metrics::share_validation_time(validation_start.elapsed().as_millis() as f64); let block_hash_hex = hex::encode(hash); match engine - .submit_found_block(job_entry.job.height, &block_hash_hex, block_hex) + .submit_found_block( + job_entry.job.height, + &block_hash_hex, + block_hex, + &worker, + session.stats.clone(), + ) .await { Ok(_) => { diff --git a/src/protocol/sv2/noise.rs b/src/protocol/sv2/noise.rs index 85623a9..a4bff1c 100644 --- a/src/protocol/sv2/noise.rs +++ b/src/protocol/sv2/noise.rs @@ -11,13 +11,16 @@ //! SV2. We use the SRI `noise_sv2` responder for the handshake and `codec_sv2`'s //! noise codec for the encrypted transport. //! -//! The miner does not verify the pool's identity ("No authority pubkey -//! configured" in the device log), so the authority keypair is generated fresh -//! per process — no operator key management is required. (A configurable, -//! persistent authority key can be added later if identity pinning is wanted.) -use anyhow::{anyhow, Result}; +//! The certificate is signed by the pool's authority key. By default that key +//! persists in `[sv2] authority_key_file` so the base58check-encoded public +//! key (logged at startup, shown on the dashboard Settings page) can be pinned +//! in the miner's configuration and survives restarts. Setting +//! `persist_authority_key = false` reverts to a fresh key per process, which +//! any pinning miner will reject after a restart. +use anyhow::{anyhow, Context, Result}; use codec_sv2::{NoiseEncoder, StandardNoiseDecoder, State}; use framing_sv2::framing::{Frame, Sv2Frame}; +use key_utils::{Secp256k1PublicKey, Secp256k1SecretKey}; use noise_sv2::{Responder, ELLSWIFT_ENCODING_SIZE}; use secp256k1::{Keypair, Secp256k1, SecretKey}; use std::sync::{Arc, OnceLock}; @@ -30,10 +33,7 @@ use tokio::{ use tracing::warn; use super::messages; - -/// Certificate validity advertised to the miner (seconds). The device does not -/// pin our identity, so this only needs to be comfortably in the future. -const CERT_VALIDITY_SECS: u32 = 365 * 24 * 60 * 60; +use crate::config::Sv2Config; /// Phantom message type for the codec generics. The decoder never deserializes /// into it (we read raw payload bytes) and the encoder is fed already-serialized @@ -42,17 +42,111 @@ type Marker = mining_sv2::SubmitSharesSuccess; type Decoder = StandardNoiseDecoder; type Encoder = NoiseEncoder; -/// Process-wide authority secret key (32 bytes), generated once on first use. -static AUTHORITY_SECRET: OnceLock<[u8; 32]> = OnceLock::new(); +/// Process-wide authority key + certificate validity, set once by [`init`] at +/// boot. Falls back to an ephemeral key with the default validity if [`init`] +/// was never called (unit tests, defensive). +struct Authority { + secret: [u8; 32], + cert_validity_secs: u32, +} + +static AUTHORITY: OnceLock = OnceLock::new(); -fn authority_secret() -> &'static [u8; 32] { - AUTHORITY_SECRET.get_or_init(|| { - let secp = Secp256k1::new(); - let (sk, _pk) = secp.generate_keypair(&mut rand::thread_rng()); - sk.secret_bytes() +/// Initialise the process-wide Noise authority from config: load the secret +/// key from `authority_key_file` (creating it on first start) when +/// `persist_authority_key` is true, otherwise generate an ephemeral key. +/// Returns the base58check-encoded authority public key (the string a miner +/// pins to verify pool identity). +pub fn init(cfg: &Sv2Config) -> Result { + let secret = if cfg.persist_authority_key { + load_or_generate_secret(&crate::config::expand_tilde(&cfg.authority_key_file))? + } else { + generate_secret() + }; + let authority = Authority { + secret, + cert_validity_secs: cfg.cert_validity_secs, + }; + let authority = AUTHORITY.get_or_init(|| authority); + Ok(encode_public_key(&authority.secret)) +} + +fn authority() -> &'static Authority { + AUTHORITY.get_or_init(|| Authority { + secret: generate_secret(), + cert_validity_secs: 365 * 24 * 60 * 60, }) } +fn generate_secret() -> [u8; 32] { + let secp = Secp256k1::new(); + let (sk, _pk) = secp.generate_keypair(&mut rand::thread_rng()); + sk.secret_bytes() +} + +/// Base58check encoding of the authority public key, in the SRI `key-utils` +/// format miners expect (2-byte version prefix + 32-byte x-only key). +fn encode_public_key(secret: &[u8; 32]) -> String { + let sk = SecretKey::from_slice(secret).expect("valid authority secret"); + Secp256k1PublicKey::from(Secp256k1SecretKey(sk)).to_string() +} + +/// Read the base58check secret key from `path`, or generate one and write it +/// there (owner-only permissions) if the file does not exist yet — the same +/// create-on-first-use pattern as bitcoind's `.cookie`. +fn load_or_generate_secret(path: &std::path::Path) -> Result<[u8; 32]> { + match std::fs::read_to_string(path) { + Ok(contents) => { + let key: Secp256k1SecretKey = contents + .trim() + .parse() + .map_err(|e| anyhow!("{e:?}")) + .with_context(|| { + format!( + "Parsing SV2 authority key file {} (base58check secret key). \ + Delete the file to generate a fresh key.", + path.display() + ) + })?; + Ok(key.into_bytes()) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let secret = generate_secret(); + let sk = SecretKey::from_slice(&secret).expect("valid generated secret"); + let encoded = Secp256k1SecretKey(sk).to_string(); + if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir) + .with_context(|| format!("Creating directory {}", dir.display()))?; + } + write_owner_only(path, &encoded) + .with_context(|| format!("Writing SV2 authority key file {}", path.display()))?; + tracing::info!("Generated new SV2 authority key: {}", path.display()); + Ok(secret) + } + Err(e) => { + Err(e).with_context(|| format!("Reading SV2 authority key file {}", path.display())) + } + } +} + +#[cfg(unix)] +fn write_owner_only(path: &std::path::Path, contents: &str) -> std::io::Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path)?; + f.write_all(contents.as_bytes())?; + f.write_all(b"\n") +} + +#[cfg(not(unix))] +fn write_owner_only(path: &std::path::Path, contents: &str) -> std::io::Result<()> { + std::fs::write(path, format!("{contents}\n")) +} + fn io_err(msg: impl std::fmt::Display) -> std::io::Error { std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string()) } @@ -64,10 +158,19 @@ fn io_err(msg: impl std::fmt::Display) -> std::io::Error { /// initiator → 64-byte ElligatorSwift ephemeral key /// responder → `INITIATOR_EXPECTED_HANDSHAKE_MESSAGE_SIZE`-byte reply pub async fn responder_handshake(stream: &mut TcpStream) -> Result { + let auth = authority(); + responder_handshake_with(stream, &auth.secret, auth.cert_validity_secs).await +} + +async fn responder_handshake_with( + stream: &mut TcpStream, + secret: &[u8; 32], + cert_validity_secs: u32, +) -> Result { let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(authority_secret()).expect("valid authority secret"); + let sk = SecretKey::from_slice(secret).expect("valid authority secret"); let kp = Keypair::from_secret_key(&secp, &sk); - let mut responder = Responder::new(kp, CERT_VALIDITY_SECS); + let mut responder = Responder::new(kp, cert_validity_secs); let mut re_pub = [0u8; ELLSWIFT_ENCODING_SIZE]; stream.read_exact(&mut re_pub).await?; @@ -196,3 +299,138 @@ impl NoiseWriter { true } } + +#[cfg(test)] +mod tests { + use super::*; + use noise_sv2::{Initiator, INITIATOR_EXPECTED_HANDSHAKE_MESSAGE_SIZE}; + use tokio::net::TcpListener; + + fn authority_pubkey_bytes(secret: &[u8; 32]) -> [u8; 32] { + let sk = SecretKey::from_slice(secret).unwrap(); + Secp256k1PublicKey::from(Secp256k1SecretKey(sk)).into_bytes() + } + + /// Drive a full handshake against `responder_handshake_with`, acting as a + /// miner. `now_offset_secs` shifts the initiator's clock when it checks the + /// certificate validity window (a stand-in for device clock skew). + async fn initiator_handshake( + mut initiator: Box, + responder_secret: [u8; 32], + cert_validity_secs: u32, + now_offset_secs: i64, + ) -> Result<(), noise_sv2::Error> { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + responder_handshake_with(&mut sock, &responder_secret, cert_validity_secs) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) + }); + + let mut client = TcpStream::connect(addr).await.unwrap(); + let first = initiator.step_0().unwrap(); + client.write_all(&first).await.unwrap(); + let mut reply = [0u8; INITIATOR_EXPECTED_HANDSHAKE_MESSAGE_SIZE]; + client.read_exact(&mut reply).await.unwrap(); + + let now = (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + + now_offset_secs) as u32; + let result = initiator.step_2_with_now(reply, now).map(|_| ()); + server.await.unwrap().unwrap(); + result + } + + #[tokio::test] + async fn pinning_initiator_accepts_the_pool_certificate() { + let secret = generate_secret(); + let initiator = Initiator::from_raw_k(authority_pubkey_bytes(&secret)).unwrap(); + initiator_handshake(initiator, secret, 3600, 0) + .await + .expect("handshake with the correct pinned authority key"); + } + + #[tokio::test] + async fn pinning_initiator_rejects_a_wrong_authority_key() { + let secret = generate_secret(); + let other = generate_secret(); + let initiator = Initiator::from_raw_k(authority_pubkey_bytes(&other)).unwrap(); + let err = initiator_handshake(initiator, secret, 3600, 0) + .await + .expect_err("certificate signed by a different authority must be rejected"); + assert!(matches!(err, noise_sv2::Error::InvalidCertificate(_))); + } + + #[tokio::test] + async fn non_pinning_initiator_connects_without_the_key() { + let secret = generate_secret(); + let initiator = Initiator::without_pk().unwrap(); + initiator_handshake(initiator, secret, 3600, 0) + .await + .expect("handshake without identity pinning"); + } + + #[tokio::test] + async fn certificate_outside_its_validity_window_is_rejected() { + // Initiator clock 60 s past a 1 s validity window — well outside the + // 10 s clock-drift leeway SRI's verifier grants (stratum issue #2015). + let secret = generate_secret(); + let initiator = Initiator::from_raw_k(authority_pubkey_bytes(&secret)).unwrap(); + let err = initiator_handshake(initiator, secret, 1, 60) + .await + .expect_err("expired certificate must be rejected"); + assert!(matches!(err, noise_sv2::Error::InvalidCertificate(_))); + } + + fn temp_key_path(name: &str) -> std::path::PathBuf { + let dir = + std::env::temp_dir().join(format!("solo-pool-noise-tests-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) + } + + #[test] + fn authority_key_file_round_trips() { + let path = temp_key_path("roundtrip.key"); + std::fs::remove_file(&path).ok(); + + let first = load_or_generate_secret(&path).unwrap(); + let second = load_or_generate_secret(&path).unwrap(); + assert_eq!(first, second, "second load must return the persisted key"); + + // The file holds one base58check line in the SRI key-utils format. + let contents = std::fs::read_to_string(&path).unwrap(); + let parsed: Secp256k1SecretKey = contents.trim().parse().unwrap(); + assert_eq!(parsed.into_bytes(), first); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "key file must be owner-only"); + } + std::fs::remove_file(&path).ok(); + } + + #[test] + fn corrupt_authority_key_file_fails_loudly() { + let path = temp_key_path("corrupt.key"); + std::fs::write(&path, "not-a-key\n").unwrap(); + let err = load_or_generate_secret(&path).unwrap_err(); + assert!(err.to_string().contains("Parsing SV2 authority key file")); + std::fs::remove_file(&path).ok(); + } + + #[test] + fn encoded_public_key_parses_in_the_key_utils_format() { + let secret = generate_secret(); + let encoded = encode_public_key(&secret); + let parsed: Secp256k1PublicKey = encoded.parse().unwrap(); + assert_eq!(parsed.into_bytes(), authority_pubkey_bytes(&secret)); + } +} diff --git a/src/stats.rs b/src/stats.rs index fd52c96..e7b51d2 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -152,9 +152,14 @@ impl StatsStore { Ok((best_values.0, best_values.1, worker_best_shares)) } + // The `?1 > ...` guards (matching set_worker_best_share) make the writes + // monotonic at the SQL level: two racing writers can call these out of + // order, and a stale lower value must not overwrite a higher one already + // persisted. fn set_best_share_difficulty(&self, difficulty: u64) { if let Err(e) = self.conn.lock().execute( - "UPDATE pool_stats SET best_share_difficulty = ?1 WHERE id = 1", + "UPDATE pool_stats SET best_share_difficulty = ?1 + WHERE id = 1 AND ?1 > best_share_difficulty", params![difficulty], ) { warn!("Failed to persist best_share_difficulty: {e}"); @@ -163,7 +168,8 @@ impl StatsStore { fn set_best_hashrate_hps(&self, hps: f64) { if let Err(e) = self.conn.lock().execute( - "UPDATE pool_stats SET best_hashrate_hps = ?1 WHERE id = 1", + "UPDATE pool_stats SET best_hashrate_hps = ?1 + WHERE id = 1 AND ?1 > best_hashrate_hps", params![hps], ) { warn!("Failed to persist best_hashrate_hps: {e}"); @@ -433,19 +439,37 @@ impl PoolStats { .map(|e| f64::from_bits(*e.value())) .sum(); - // Track all-time best (persistent) and session-best (since boot) - let prev_best = f64::from_bits(self.best_hashrate_hps.load(Ordering::Relaxed)); - if total_10m > prev_best { - self.best_hashrate_hps - .store(total_10m.to_bits(), Ordering::Relaxed); - self.persist_best_hashrate_hps(total_10m); + // Track all-time best (persistent) and session-best (since boot). + // CAS loops (like share_accepted's best-share tracking) so two racing + // updaters cannot let a lower value overwrite a higher one that landed + // between the load and the store. + let mut prev = self.best_hashrate_hps.load(Ordering::Relaxed); + while total_10m > f64::from_bits(prev) { + match self.best_hashrate_hps.compare_exchange_weak( + prev, + total_10m.to_bits(), + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => { + self.persist_best_hashrate_hps(total_10m); + break; + } + Err(x) => prev = x, + } } - let prev_session_best = - f64::from_bits(self.session_best_hashrate_hps.load(Ordering::Relaxed)); - if total_10m > prev_session_best { - self.session_best_hashrate_hps - .store(total_10m.to_bits(), Ordering::Relaxed); + let mut prev_session = self.session_best_hashrate_hps.load(Ordering::Relaxed); + while total_10m > f64::from_bits(prev_session) { + match self.session_best_hashrate_hps.compare_exchange_weak( + prev_session, + total_10m.to_bits(), + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(x) => prev_session = x, + } } }