From 8966b313b827d2e46ab2915b405d1f2571da4915 Mon Sep 17 00:00:00 2001 From: Wired4ncer <102553581+Wired4ncer@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:09:43 -0600 Subject: [PATCH] vardiff: stop steering on Poisson noise, and damp the under-sampled step At the shipped defaults -- vardiff_target_spm = 12 over a 30 s window -- an ON-TARGET connection produces six shares. Poisson noise on six samples is +/-41% (1/sqrt(6)), so ratio = observed/target leaves the [0.5, 2.0] deadband routinely even when the difficulty is already correct, and the controller oscillates instead of converging. The existing 4x step cap bounds each swing; it does not stop it. Observed in production, six consecutive retargets of one worker inside four minutes, on a port fed by a proxied fleet: 500000 -> 1141329 (27.4 spm observed, 12.0 target) 1141329 -> 569753 ( 6.0 spm) 569753 -> 500000 ( 3.6 spm) 500000 -> 1064387 (25.5 spm) 1064387 -> 513667 ( 5.8 spm) 513667 -> 500000 ( 4.5 spm) A 14% difficulty rise cannot cut a share rate 7.6x, so observed_spm was never tracking difficulty -- it tracked which connection the proxy fed that window. vardiff_min_samples (20) makes a window that elapsed but holds too few shares stay OPEN and keep accumulating rather than retarget on noise. vardiff_max_window_mult (8) bounds that extension, so a connection whose difficulty is genuinely far too high still ratchets down. Setting vardiff_min_samples to 0 restores the previous behaviour exactly. vardiff_idle_step (2.0) replaces the flat 4x for any window that never met the sample floor. That is what keeps a quiet connection off vardiff_min: one rig behind a proxy arrives as many connections, each going quiet between bursts, and at 4x a pair of near-empty windows cuts difficulty 16x -- which the miner's own firmware then reports back as "difficulty too low". This is separate from VD_FLOOR_MIN_SAMPLES, which already gates the miner-local-floor detector. That constant guards a different reading of the same window; the rate loop had no sample gate at all. Tests: three. test_vardiff_waits_for_min_samples asserts the NEGATIVE -- shares 1..4 must not retarget -- which is the shape that passes for the wrong reason, so the fixture also asserts those four were ACCEPTED (they reached the retarget path) and share 5 proves a retarget was reachable all along: same connection, same window, same ratio, only the sample count changed. The min_samples = 0 test is the negative control for it. The idle-step test pins the value, so the step cannot move without coming through it. Mutation-verified: removing the sample floor fails 2 checks, never applying the idle step fails 1, and ignoring the extension bound fails 1. --- proxy.conf.example | 21 +++++ src/config.c | 6 ++ src/config.h | 28 +++++++ src/main.c | 3 + src/stratum.c | 42 +++++++++- src/stratum.h | 7 ++ tests/test_stratum.c | 179 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 282 insertions(+), 4 deletions(-) diff --git a/proxy.conf.example b/proxy.conf.example index f056346..85aa14b 100644 --- a/proxy.conf.example +++ b/proxy.conf.example @@ -41,6 +41,27 @@ vardiff_min = 1 vardiff_max = 1e12 vardiff_window_sec = 30 # how often to retarget +# How many accepted shares a window must hold before its rate is trusted +# enough to act on. At the defaults above an ON-TARGET connection produces +# six shares a window, and Poisson noise on six samples is +/-41%, so the +# measured rate leaves the retarget deadband on noise alone and the +# difficulty oscillates around the right answer instead of settling on it. +# +# Under the floor the window stays OPEN and keeps accumulating, for up to +# vardiff_max_window_mult windows -- after which it acts on what it has, so a +# connection whose difficulty really is far too high still ratchets down. +# +# 0 restores the previous behaviour exactly. +vardiff_min_samples = 20 +vardiff_max_window_mult = 8 + +# Largest difficulty step a window that never met the sample floor may make. +# A window that met it keeps the historical 4x. This is what stops a quiet +# connection being driven to vardiff_min: one rig behind a proxy arrives as +# many connections, each going quiet between bursts, and at 4x two near-empty +# windows cut its difficulty 16x. +vardiff_idle_step = 2 + # Extra stratum ports, each with its own difficulty policy. Repeatable — # unlike every other key here, a second `listener` line adds a port rather # than replacing the first. listen_port above is always served too, on the diff --git a/src/config.c b/src/config.c index 15c7ee6..a3f56f8 100644 --- a/src/config.c +++ b/src/config.c @@ -58,6 +58,9 @@ void proxy_config_defaults(proxy_config_t *cfg) { cfg->vardiff_max = 1e12; cfg->max_suggested_diff = 5e7; cfg->vardiff_window_sec = 30; + cfg->vardiff_min_samples = 20; + cfg->vardiff_max_window_mult = 8; + cfg->vardiff_idle_step = 2.0; cfg->idle_timeout_sec = 600; /* 10 min silent recv → reap */ cfg->idle_timeout_authorized_sec = 7200; /* 2 h once a miner is working */ @@ -270,6 +273,9 @@ int proxy_config_load(const char *path, proxy_config_t *cfg, else if (strcmp(k, "vardiff_max") == 0) cfg->vardiff_max = atof(v); else if (strcmp(k, "max_suggested_diff") == 0) cfg->max_suggested_diff = atof(v); else if (strcmp(k, "vardiff_window_sec") == 0) cfg->vardiff_window_sec = atoi(v); + else if (strcmp(k, "vardiff_min_samples") == 0) cfg->vardiff_min_samples = atoi(v); + else if (strcmp(k, "vardiff_max_window_mult") == 0) cfg->vardiff_max_window_mult = atoi(v); + else if (strcmp(k, "vardiff_idle_step") == 0) cfg->vardiff_idle_step = atof(v); else if (strcmp(k, "idle_timeout_sec") == 0) cfg->idle_timeout_sec = atoi(v); else if (strcmp(k, "idle_timeout_authorized_sec") == 0) cfg->idle_timeout_authorized_sec = atoi(v); else if (strcmp(k, "db_path") == 0) copy_str(cfg->db_path, sizeof cfg->db_path, v); diff --git a/src/config.h b/src/config.h index 567c534..ffa4456 100644 --- a/src/config.h +++ b/src/config.h @@ -50,6 +50,34 @@ typedef struct { double max_suggested_diff; /* default 5e7; <= 0 disables requests */ int vardiff_window_sec; /* retarget interval, default 30 */ + /* Minimum accepted shares a window must hold before its rate is trusted + * enough to retarget on. Default 20. + * + * At the default target_spm = 12 over a 30 s window an ON-TARGET + * connection produces SIX shares, and Poisson noise on six samples is + * +/-41% (1/sqrt(6)). The ratio therefore lands outside the [0.5, 2.0] + * deadband routinely even when the difficulty is already correct, so the + * controller oscillates instead of converging. Below this floor the + * window is EXTENDED rather than acted on -- up to + * vardiff_max_window_mult times the nominal window, after which we act on + * what we have so a genuinely over-difficult connection still ratchets + * down. 0 restores the previous behaviour exactly. */ + int vardiff_min_samples; /* default 20 */ + + /* How far a window may be extended, as a multiple of vardiff_window_sec, + * while waiting for vardiff_min_samples. Default 8. */ + int vardiff_max_window_mult; /* default 8 */ + + /* Max step for a window that did NOT meet vardiff_min_samples. A window + * that met it keeps the historical 4x. Default 2. + * + * This is what stops a quiet connection being driven to the floor: a + * proxied fleet spreads one rig over many connections, each going quiet + * between bursts, and at 4x a pair of near-empty windows cuts difficulty + * 16x -- which the miner's own firmware then reports back as "difficulty + * too low". */ + double vardiff_idle_step; /* default 2.0 */ + /* Idle-connection reaper. A connection that hasn't sent any bytes in * idle_timeout_sec is closed. Guards against half-open TCPs from * crashed miners and clients that connect but never authenticate. diff --git a/src/main.c b/src/main.c index d14ec91..41a8481 100644 --- a/src/main.c +++ b/src/main.c @@ -1151,6 +1151,9 @@ int main(int argc, char **argv) { stcfg.vardiff_min = cfg.vardiff_min; stcfg.vardiff_max = cfg.vardiff_max; stcfg.vardiff_window_sec = cfg.vardiff_window_sec; + stcfg.vardiff_min_samples = cfg.vardiff_min_samples; + stcfg.vardiff_max_window_mult = cfg.vardiff_max_window_mult; + stcfg.vardiff_idle_step = cfg.vardiff_idle_step; stcfg.idle_timeout_sec = cfg.idle_timeout_sec; stcfg.idle_timeout_authorized_sec = cfg.idle_timeout_authorized_sec; stcfg.max_submits_per_sec = cfg.max_submits_per_sec; diff --git a/src/stratum.c b/src/stratum.c index f186b1f..11cab10 100644 --- a/src/stratum.c +++ b/src/stratum.c @@ -1115,6 +1115,26 @@ static void vardiff_maybe_retarget(stratum_server_t *s, stratum_conn_t *c, uint64_t window_ms = (uint64_t)s->cfg.vardiff_window_sec * 1000ULL; if (elapsed_ms < window_ms) return; + /* A window's share RATE only means something if the window holds enough + * shares to measure one. At the default target_spm = 12 over a 30 s + * window an on-target connection produces six, and Poisson noise on six + * samples is +/-41% (1/sqrt(6)) -- so `ratio` leaves the [0.5, 2.0] + * deadband on noise alone and the controller oscillates around the right + * answer instead of settling on it. + * + * So: keep accumulating rather than steering on noise. The extension is + * bounded, because a connection whose difficulty is genuinely far too + * high submits almost nothing and must still be able to ratchet down. + * + * Leaving the window OPEN -- returning before the reset at the bottom -- + * is the whole mechanism. */ + if (s->cfg.vardiff_min_samples > 0 && + c->vd_window_shares < (uint32_t)s->cfg.vardiff_min_samples) { + int mult = s->cfg.vardiff_max_window_mult > 0 + ? s->cfg.vardiff_max_window_mult : 8; + if (elapsed_ms < window_ms * (uint64_t)mult) return; + } + /* Observed shares per minute over this window. */ double observed_spm = ((double)c->vd_window_shares * 60000.0) / (double)elapsed_ms; @@ -1125,10 +1145,24 @@ static void vardiff_maybe_retarget(stratum_server_t *s, stratum_conn_t *c, double new_diff = old_diff; if (ratio > 2.0 || ratio < 0.5) { new_diff = old_diff * ratio; - /* Cap each adjustment to a 4x step to avoid wild swings on small - * windows. */ - if (new_diff > old_diff * 4.0) new_diff = old_diff * 4.0; - if (new_diff < old_diff / 4.0) new_diff = old_diff / 4.0; + /* Cap each adjustment. A window that met the sample floor is trusted + * with the historical 4x step; one that only ended because it hit the + * extension limit is not, and gets the gentler idle step. + * + * That distinction is what keeps a quiet connection off the floor. + * One rig behind a proxy arrives as many connections, each going + * quiet between bursts; at 4x a pair of near-empty windows cuts + * difficulty 16x and pins the worker to vardiff_min, which is exactly + * the state the miner's own firmware reports as "difficulty too + * low". */ + double max_step = 4.0; + if (s->cfg.vardiff_min_samples > 0 && + c->vd_window_shares < (uint32_t)s->cfg.vardiff_min_samples) { + max_step = s->cfg.vardiff_idle_step > 1.0 + ? s->cfg.vardiff_idle_step : 2.0; + } + if (new_diff > old_diff * max_step) new_diff = old_diff * max_step; + if (new_diff < old_diff / max_step) new_diff = old_diff / max_step; } /* Every share this window cleared a difficulty far above the one we diff --git a/src/stratum.h b/src/stratum.h index c387def..0c23d4d 100644 --- a/src/stratum.h +++ b/src/stratum.h @@ -206,6 +206,13 @@ typedef struct { double vardiff_min; double vardiff_max; int vardiff_window_sec; + /* See config.h: a window holding fewer than vardiff_min_samples shares is + * extended rather than acted on (up to vardiff_max_window_mult windows), + * and when it does end under-sampled its step is capped at + * vardiff_idle_step instead of the usual 4x. 0 samples disables both. */ + int vardiff_min_samples; + int vardiff_max_window_mult; + double vardiff_idle_step; /* Drop a connection whose recv() has been silent for this long. Guards * against half-open TCPs from crashed miners and misconfigured clients diff --git a/tests/test_stratum.c b/tests/test_stratum.c index 4b698f6..4396db1 100644 --- a/tests/test_stratum.c +++ b/tests/test_stratum.c @@ -986,6 +986,182 @@ static void test_vardiff_clamped_to_network_diff(void) { stratum_server_free(s); } +/* vardiff_min_samples: a window that elapsed but holds too few shares must + * NOT retarget -- it must stay open and keep accumulating. + * + * This is the oscillation fix. At the default target_spm = 12 over a 30 s + * window an on-target connection produces six shares, and Poisson noise on + * six samples (+/-41%) pushes `ratio` outside the [0.5, 2.0] deadband on its + * own, so the controller chases noise instead of converging. + * + * The assertion that matters is the NEGATIVE one on shares 1..4, which is the + * shape that passes for the wrong reason. Two guards against that: the + * fixture asserts those four shares were ACCEPTED, so they really reached the + * retarget path; and share 5 then proves a retarget was reachable all along + * -- same connection, same elapsed window, same ratio, only the sample count + * changed. */ +static void test_vardiff_waits_for_min_samples(void) { + obs_t obs = {0}; + stratum_cfg_t cfg = { .bind_port = 0, .max_conns = 1, + .initial_diff = 1e-12, + .vardiff_enabled = 1, + .vardiff_target_spm = 0.001, + .vardiff_min = 1e-12, + .vardiff_max = 1e15, + .vardiff_window_sec = 1, + .vardiff_min_samples = 5, + .vardiff_max_window_mult = 8, + .vardiff_idle_step = 2.0, + .ctx = &obs, .on_share = on_share, + .on_reject = on_reject, .on_block = on_block }; + snprintf(cfg.bind_addr, sizeof(cfg.bind_addr), "127.0.0.1"); + stratum_server_t *s = NULL; + stratum_server_start(&cfg, &s); + + /* All-zero network target: nothing is ever a block, and no network clamp + * can mask the retarget we are looking for. */ + uint8_t net[32] = {0}; + stratum_server_set_job(s, make_test_job("J1", net), 1); + + stratum_conn_t *c = stratum_conn_new_for_test(s); + char *out = NULL; size_t olen = 0; + stratum_handle_message(s, c, "{\"id\":1,\"method\":\"mining.subscribe\",\"params\":[]}", + &out, &olen); free(out); out = NULL; olen = 0; + stratum_handle_message(s, c, + "{\"id\":2,\"method\":\"mining.authorize\"," + "\"params\":[\"" TEST_ADDR "\",\"x\"]}", + &out, &olen); free(out); out = NULL; olen = 0; + + /* Let the nominal window elapse. Every submit below is past it. */ + sleep_ms(1100); + + for (int i = 1; i <= 4; ++i) { + char msg[256]; + snprintf(msg, sizeof msg, + "{\"id\":%d,\"method\":\"mining.submit\"," + "\"params\":[\"w\",\"J1\",\"deadbeefcafebabe\",\"60000000\"," + "\"0000000%d\"]}", 10 + i, i); + stratum_handle_message(s, c, msg, &out, &olen); + CHECK(out != NULL); + /* Under the sample floor: accepted, but no difficulty change. */ + CHECK(strstr(out, "mining.set_difficulty") == NULL); + free(out); out = NULL; olen = 0; + } + /* Precondition for the negative assertions above: those four shares + * really were ACCEPTED, so they really did land in the vardiff window. + * Without this the test would pass just as well if every submit had been + * rejected before ever reaching the retarget path. */ + CHECK(obs.shares == 4); + CHECK(obs.rejects == 0); + + /* Fifth share meets the floor -- now the retarget fires. */ + stratum_handle_message(s, c, + "{\"id\":15,\"method\":\"mining.submit\"," + "\"params\":[\"w\",\"J1\",\"deadbeefcafebabe\",\"60000000\",\"00000005\"]}", + &out, &olen); + CHECK(out != NULL); + CHECK(strstr(out, "mining.set_difficulty") != NULL); + CHECK(obs.shares == 5); + free(out); + stratum_conn_free_for_test(c); + stratum_server_free(s); +} + +/* The negative control: vardiff_min_samples = 0 restores the previous + * behaviour exactly. Without this, "the sample floor held the retarget back" + * and "this build never retargets here" are indistinguishable from the test + * above. Same fixture, same single share, opposite expectation. */ +static void test_vardiff_min_samples_zero_retargets_on_one_share(void) { + obs_t obs = {0}; + stratum_cfg_t cfg = { .bind_port = 0, .max_conns = 1, + .initial_diff = 1e-12, + .vardiff_enabled = 1, + .vardiff_target_spm = 0.001, + .vardiff_min = 1e-12, + .vardiff_max = 1e15, + .vardiff_window_sec = 1, + .vardiff_min_samples = 0, + .ctx = &obs, .on_share = on_share, + .on_reject = on_reject, .on_block = on_block }; + snprintf(cfg.bind_addr, sizeof(cfg.bind_addr), "127.0.0.1"); + stratum_server_t *s = NULL; + stratum_server_start(&cfg, &s); + uint8_t net[32] = {0}; + stratum_server_set_job(s, make_test_job("J1", net), 1); + + stratum_conn_t *c = stratum_conn_new_for_test(s); + char *out = NULL; size_t olen = 0; + stratum_handle_message(s, c, "{\"id\":1,\"method\":\"mining.subscribe\",\"params\":[]}", + &out, &olen); free(out); out = NULL; olen = 0; + stratum_handle_message(s, c, + "{\"id\":2,\"method\":\"mining.authorize\"," + "\"params\":[\"" TEST_ADDR "\",\"x\"]}", + &out, &olen); free(out); out = NULL; olen = 0; + sleep_ms(1100); + stratum_handle_message(s, c, + "{\"id\":3,\"method\":\"mining.submit\"," + "\"params\":[\"w\",\"J1\",\"deadbeefcafebabe\",\"60000000\",\"00000001\"]}", + &out, &olen); + CHECK(out != NULL); + CHECK(strstr(out, "mining.set_difficulty") != NULL); + CHECK(obs.shares == 1); + free(out); + stratum_conn_free_for_test(c); + stratum_server_free(s); +} + +/* An under-sampled window that ends because it hit the extension limit steps + * by vardiff_idle_step, not the usual 4x. + * + * The rate here is far below target, so the unclamped proposal is a deep cut + * and the cap is what decides the answer: at the idle step the difficulty + * halves, where the historical 4x would quarter it. Asserted as the exact + * value, so the step cannot be changed without coming through this test. */ +static void test_an_under_sampled_window_uses_the_idle_step(void) { + obs_t obs = {0}; + stratum_cfg_t cfg = { .bind_port = 0, .max_conns = 1, + .initial_diff = 1e-12, + .vardiff_enabled = 1, + .vardiff_target_spm = 1e6, + .vardiff_min = 1e-18, + .vardiff_max = 1e15, + .vardiff_window_sec = 1, + .vardiff_min_samples = 5, + .vardiff_max_window_mult = 2, + .vardiff_idle_step = 2.0, + .ctx = &obs, .on_share = on_share, + .on_reject = on_reject, .on_block = on_block }; + snprintf(cfg.bind_addr, sizeof(cfg.bind_addr), "127.0.0.1"); + stratum_server_t *s = NULL; + stratum_server_start(&cfg, &s); + uint8_t net[32] = {0}; + stratum_server_set_job(s, make_test_job("J1", net), 1); + + stratum_conn_t *c = stratum_conn_new_for_test(s); + char *out = NULL; size_t olen = 0; + stratum_handle_message(s, c, "{\"id\":1,\"method\":\"mining.subscribe\",\"params\":[]}", + &out, &olen); free(out); out = NULL; olen = 0; + stratum_handle_message(s, c, + "{\"id\":2,\"method\":\"mining.authorize\"," + "\"params\":[\"" TEST_ADDR "\",\"x\"]}", + &out, &olen); free(out); out = NULL; olen = 0; + + /* Past the extension limit (2 x 1 s), still one share short of the floor: + * the window ends under-sampled and the step is capped at the idle + * value. */ + sleep_ms(2200); + stratum_handle_message(s, c, + "{\"id\":3,\"method\":\"mining.submit\"," + "\"params\":[\"w\",\"J1\",\"deadbeefcafebabe\",\"60000000\",\"00000001\"]}", + &out, &olen); + CHECK(out != NULL); + double got = set_diff_value(out); + CHECK(got > 4.9e-13 && got < 5.1e-13); /* halved, not quartered */ + free(out); + stratum_conn_free_for_test(c); + stratum_server_free(s); +} + /* After a retarget raises the difficulty, shares for a job the miner already * holds must stay acceptable at the difficulty that job went out under -- the * miner only applies set_difficulty on a later job. */ @@ -2818,6 +2994,9 @@ int main(void) { test_vardiff_tracks_miner_local_floor(); test_vardiff_still_lowers_for_a_matched_miner(); test_vardiff_clamped_to_network_diff(); + test_vardiff_waits_for_min_samples(); + test_vardiff_min_samples_zero_retargets_on_one_share(); + test_an_under_sampled_window_uses_the_idle_step(); test_submit_ceiling_refuses_past_the_limit(); test_submit_ceiling_window_rolls(); test_submit_ceiling_zero_disables();