Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions proxy.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/config.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
42 changes: 38 additions & 4 deletions src/stratum.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/stratum.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
179 changes: 179 additions & 0 deletions tests/test_stratum.c
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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();
Expand Down
Loading